@pmelab/gtd 27.3.0 → 27.4.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 (45) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +14 -1
  3. package/dist/chunks/{wire-BY1S-obW.mjs → wire-C12dahrH.mjs} +6 -4
  4. package/dist/flows.mjs +24 -1
  5. package/dist/gtd.bundle.mjs +174 -23
  6. package/dist/testkit.mjs +315 -27
  7. package/dist/wire.mjs +1 -1
  8. package/package.json +1 -1
  9. package/src/cli/Cli.ts +5 -5
  10. package/src/cli/History.ts +5 -0
  11. package/src/cli/Program.ts +1 -0
  12. package/src/edge/Edge.test.ts +160 -0
  13. package/src/edge/Edge.ts +90 -8
  14. package/src/emit/GitScript.test.ts +39 -3
  15. package/src/emit/GitScript.ts +25 -1
  16. package/src/emit/index.ts +2 -0
  17. package/src/flows/helpers.ts +28 -0
  18. package/src/flows/runtime.ts +7 -0
  19. package/src/install/Install.ts +2 -1
  20. package/src/platform/Git.test.ts +25 -1
  21. package/src/platform/Git.ts +30 -0
  22. package/src/platform/index.ts +1 -0
  23. package/src/replay/Format.test.ts +16 -1
  24. package/src/replay/Replay.test.ts +91 -0
  25. package/src/replay/Replay.ts +22 -2
  26. package/src/replay/Trailers.test.ts +110 -1
  27. package/src/replay/Trailers.ts +37 -1
  28. package/src/replay/index.ts +2 -0
  29. package/src/replay/seamGate.test.ts +62 -0
  30. package/src/step/LandStep.ts +1 -0
  31. package/src/step/planStep.test.ts +32 -0
  32. package/src/step/planStep.ts +3 -1
  33. package/src/testkit/EmittedScriptRecognizer.test.ts +27 -0
  34. package/src/testkit/EmittedScriptRecognizer.ts +12 -0
  35. package/src/testkit/FakeGitOperations.ts +5 -0
  36. package/src/testkit/GitTiers.ts +158 -0
  37. package/src/testkit/InMemRepo.ts +38 -3
  38. package/src/wire/BeatDocument.test.ts +25 -0
  39. package/src/wire/BeatDocument.ts +4 -2
  40. package/src/wire/BeatPlain.test.ts +1 -0
  41. package/src/wire/BeatStatus.test.ts +2 -0
  42. package/src/wire/BeatStatus.ts +13 -2
  43. package/src/wire/WireSchema.test.ts +10 -0
  44. package/src/wire/types.ts +8 -0
  45. package/src/workflow/Workflow.ts +1 -0
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "nod
3
3
  import { tmpdir } from "node:os"
4
4
  import { join } from "node:path"
5
5
  import { NodeContext } from "@effect/platform-node"
6
- import { Cause, Effect, Exit } from "effect"
6
+ import { Cause, Effect, Exit, Option } from "effect"
7
7
  import { describe, expect, it } from "vitest"
8
8
  import { GitService, InvalidRefError, resolvedRefOrCorrupted } from "./Git.js"
9
9
  import { Host } from "./index.js"
@@ -140,3 +140,27 @@ describe("diffPaths [Live]", () => {
140
140
  }
141
141
  })
142
142
  })
143
+
144
+ describe("authorIdent [Live]", () => {
145
+ it("is none when git has no author identity", async () => {
146
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-ident-")))
147
+ try {
148
+ const git = (...args: string[]) =>
149
+ execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: "pipe" })
150
+ git("init")
151
+ git("config", "user.useConfigOnly", "true")
152
+ git("config", "user.name", "")
153
+ git("config", "user.email", "")
154
+ const ident = await Effect.runPromise(
155
+ Effect.flatMap(GitService, (g) => g.authorIdent()).pipe(
156
+ Effect.provide(GitService.Live),
157
+ Effect.provide(Host.layer({ root, home: root, env: {} })),
158
+ Effect.provide(NodeContext.layer),
159
+ ),
160
+ )
161
+ expect(Option.isNone(ident)).toBe(true)
162
+ } finally {
163
+ rmSync(root, { recursive: true, force: true })
164
+ }
165
+ })
166
+ })
@@ -94,6 +94,15 @@ export interface GitReaderOperations {
94
94
  readonly changedPaths: (
95
95
  base?: string,
96
96
  ) => Effect.Effect<ReadonlyArray<{ readonly path: string; readonly status: string }>, Error>
97
+ /**
98
+ * The index vs `HEAD`, as `{path, status}` pairs — what a commit would carry
99
+ * that `changedPaths` misses when the worktree reverted a staged edit. Empty
100
+ * before the first commit.
101
+ */
102
+ readonly stagedPaths: () => Effect.Effect<
103
+ ReadonlyArray<{ readonly path: string; readonly status: string }>,
104
+ Error
105
+ >
97
106
  /**
98
107
  * Paths whose content differs between the trees of `base` and `tip`: added,
99
108
  * modified and deleted alike; a rename reports both paths (no rename
@@ -103,8 +112,12 @@ export interface GitReaderOperations {
103
112
  * working tree. Fails when either side does not resolve.
104
113
  */
105
114
  readonly diffPaths: (base: string, tip: string) => Effect.Effect<ReadonlyArray<string>, Error>
115
+ /** `git var GIT_AUTHOR_IDENT`'s name and email; `None` when git has no identity. */
116
+ readonly authorIdent: () => Effect.Effect<Option.Option<AuthorIdent>, Error>
106
117
  }
107
118
 
119
+ export type AuthorIdent = { readonly name: string; readonly email: string }
120
+
108
121
  export interface GitWriterOperations {
109
122
  /**
110
123
  * `git add -A` then commit. `--allow-empty` is load-bearing: the machine
@@ -406,6 +419,17 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
406
419
  "--",
407
420
  ).pipe(Effect.map(splitNul)),
408
421
 
422
+ authorIdent: () =>
423
+ exec("git", "var", "GIT_AUTHOR_IDENT").pipe(
424
+ Effect.map((out) => {
425
+ const match = /^(.*?) <([^<>]*)> \d+ [+-]\d{4}$/.exec(out.trim())
426
+ return match === null || match[1] === "" || match[2] === ""
427
+ ? Option.none()
428
+ : Option.some({ name: match[1]!, email: match[2]! })
429
+ }),
430
+ Effect.catchAll(() => Effect.succeed(Option.none())),
431
+ ),
432
+
409
433
  changedPaths: (base?: string) =>
410
434
  Effect.gen(function* () {
411
435
  const ref = base ?? "HEAD"
@@ -443,6 +467,12 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
443
467
  return all
444
468
  }),
445
469
 
470
+ stagedPaths: () =>
471
+ exec("git", "diff", "--cached", "--name-status", "--no-renames", "-z", "HEAD").pipe(
472
+ Effect.catchAll(() => Effect.succeed("")),
473
+ Effect.map((out) => parseNameStatus(splitNul(out))),
474
+ ),
475
+
446
476
  resolveRef: (ref: string) =>
447
477
  exec("git", "rev-parse", "--verify", "--end-of-options", ref).pipe(
448
478
  Effect.map((s) => s.trim()),
@@ -15,6 +15,7 @@ export {
15
15
  type GitOperations,
16
16
  type GitReaderOperations,
17
17
  type GitWriterOperations,
18
+ type AuthorIdent,
18
19
  } from "./Git.js"
19
20
  export { Narrator, GtdError, GtdUsageError, renderFailure } from "./Commentary.js"
20
21
  export { CommandRunner } from "./CommandRunner.js"
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { formatFault } from "./Format.js"
2
+ import { formatCommitMessage, formatFault, parseCommitMessage } from "./index.js"
3
3
 
4
4
  describe("the history format", () => {
5
5
  it("accepts a history entirely in the current format", () => {
@@ -18,3 +18,18 @@ describe("the history format", () => {
18
18
  )
19
19
  })
20
20
  })
21
+
22
+ describe("Gtd-Approved and the history format", () => {
23
+ it("leaves Gtd-Format at 1 on a message carrying it", () => {
24
+ const message = formatCommitMessage({
25
+ actor: "human",
26
+ to: "b",
27
+ approved: { step: { name: "seam", occurrence: 1 }, name: "Test", email: "test@test.com" },
28
+ })
29
+ expect(message).toContain(
30
+ 'Gtd-Approved: {"step":"seam#1","name":"Test","email":"test@test.com"}',
31
+ )
32
+ expect(message).toContain("Gtd-Format: 1")
33
+ expect(parseCommitMessage(message).format).toBe(1)
34
+ })
35
+ })
@@ -1076,3 +1076,94 @@ describe("replay: access resolution", () => {
1076
1076
  expect(outcome.kind === "failed" && outcome.message).toContain('scope "a"')
1077
1077
  })
1078
1078
  })
1079
+
1080
+ describe("human({ approve })", () => {
1081
+ const gate: Flow = async () => {
1082
+ await human("gate", { approve: true })
1083
+ await human("after")
1084
+ }
1085
+ const sign = (step: string, occurrence: number) => ({
1086
+ step: { name: step, occurrence },
1087
+ name: "Test",
1088
+ email: "test@test.com",
1089
+ })
1090
+ const approval = (approved?: ReturnType<typeof sign>): string =>
1091
+ formatCommitMessage({
1092
+ actor: "human",
1093
+ from: "gate",
1094
+ to: "after",
1095
+ step: { name: "gate", occurrence: 1 },
1096
+ ...(approved !== undefined ? { approved } : {}),
1097
+ })
1098
+
1099
+ it("is accepted: no unknown-option failure, the gate is the rest", async () => {
1100
+ const outcome = await replayOf(gate, new History())
1101
+ expect(outcome.kind).toBe("rest")
1102
+ if (outcome.kind === "rest") expect(outcome.rest.name).toBe("gate")
1103
+ })
1104
+
1105
+ it("consumes a commit carrying a matching Gtd-Approved and continues past the gate", async () => {
1106
+ const h = new History().raw(approval(sign("gate", 1)))
1107
+ const outcome = await replayOf(gate, h)
1108
+ expect(outcome.kind).toBe("rest")
1109
+ if (outcome.kind === "rest") expect(outcome.rest.name).toBe("after")
1110
+ })
1111
+
1112
+ it("diverges on a commit without Gtd-Approved", async () => {
1113
+ const outcome = await replayOf(gate, new History().raw(approval()))
1114
+ expect(outcome).toEqual({
1115
+ kind: "divergence",
1116
+ message: `gtd: commit ${hashOf(1).slice(0, 7)} completes approval gate "gate#1" but records no Gtd-Approved for it — the workflow changed under this process; run \`gtd abandon\` to start over`,
1117
+ })
1118
+ })
1119
+
1120
+ it("diverges on a Gtd-Approved naming another step, or another occurrence", async () => {
1121
+ for (const other of [sign("elsewhere", 1), sign("gate", 2)]) {
1122
+ const outcome = await replayOf(gate, new History().raw(approval(other)))
1123
+ expect(outcome.kind).toBe("divergence")
1124
+ expect(outcome.kind === "divergence" && outcome.message).toContain(
1125
+ 'completes approval gate "gate#1" but records no Gtd-Approved for it',
1126
+ )
1127
+ }
1128
+ })
1129
+
1130
+ describe("refuses mode and file", () => {
1131
+ const tail =
1132
+ "in human() options — an approval records exactly the judged tree, and a steering file is rewritten on landing"
1133
+ const outcomeOf = async (options: Record<string, unknown>) =>
1134
+ replayOf(async () => {
1135
+ await human("gate", options as never)
1136
+ }, new History())
1137
+
1138
+ it.each([
1139
+ [{ approve: true, mode: "review" }, "mode"],
1140
+ [{ approve: true, file: ".gtd/REVIEW.md" }, "file"],
1141
+ [{ approve: true, mode: "review", file: ".gtd/REVIEW.md" }, "mode, file"],
1142
+ ])("fails on %j naming %s", async (options, keys) => {
1143
+ expect(await outcomeOf(options)).toEqual({
1144
+ kind: "failed",
1145
+ message: `gtd: step "gate": approve cannot be combined with ${keys} ${tail}`,
1146
+ })
1147
+ })
1148
+
1149
+ it("replays approve with label or base, and approve: false with mode", async () => {
1150
+ for (const options of [
1151
+ { approve: true, label: "x" },
1152
+ { approve: true, base: "start" },
1153
+ { approve: false, mode: "review" },
1154
+ { approve: true, mode: undefined },
1155
+ ]) {
1156
+ expect((await outcomeOf(options)).kind).toBe("rest")
1157
+ }
1158
+ })
1159
+ })
1160
+
1161
+ it("does not demand Gtd-Approved at a plain human step", async () => {
1162
+ const h = new History().land("human", "plain", 1, "after")
1163
+ const outcome = await replayOf(async () => {
1164
+ await human("plain")
1165
+ await human("after")
1166
+ }, h)
1167
+ expect(outcome.kind).toBe("rest")
1168
+ })
1169
+ })
@@ -236,7 +236,7 @@ const STEERING_OPTIONS = ["file", "mode", "label", "base"]
236
236
  // type check, so a misspelt key would otherwise be silently ignored.
237
237
  const KNOWN_OPTIONS: Readonly<Record<StepKind, ReadonlySet<string>>> = {
238
238
  agent: new Set([...STEERING_OPTIONS, "model", "system", "allowEmpty", "keep"]),
239
- human: new Set([...STEERING_OPTIONS, "message", "acceptClean"]),
239
+ human: new Set([...STEERING_OPTIONS, "message", "acceptClean", "approve"]),
240
240
  run: new Set(STEERING_OPTIONS),
241
241
  judge: new Set([...STEERING_OPTIONS, "message"]),
242
242
  }
@@ -249,6 +249,14 @@ const unknownOptions = (step: ReachedStep): string | undefined => {
249
249
  return `gtd: step "${step.name}": unknown key(s) ${unknown.join(", ")} in ${step.kind}() options`
250
250
  }
251
251
 
252
+ const approveConflict = (step: ReachedStep): string | undefined => {
253
+ const request = step.request
254
+ if (request.kind !== "human" || request.options.approve !== true) return undefined
255
+ const keys = (["mode", "file"] as const).filter((key) => request.options[key] !== undefined)
256
+ if (keys.length === 0) return undefined
257
+ return `gtd: step "${step.name}": approve cannot be combined with ${keys.join(", ")} in human() options — an approval records exactly the judged tree, and a steering file is rewritten on landing`
258
+ }
259
+
252
260
  export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
253
261
  const commits: readonly ParsedCommit[] = input.episode.commits.map((c) => ({
254
262
  ...c,
@@ -485,10 +493,20 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
485
493
  message: `gtd: commit ${short(expected.hash)} names "${expected.name}" as the next step, but replay reached "${step.name}" — ${DIVERGED}`,
486
494
  }
487
495
  }
488
- const error = unknownOptions(step) ?? checkIdentity(step)
496
+ const error = unknownOptions(step) ?? approveConflict(step) ?? checkIdentity(step)
489
497
  return error === undefined ? undefined : { kind: "failed", message: error }
490
498
  }
491
499
 
500
+ const missingApproval = (step: ReachedStep, commit: ParsedCommit, expected: string) => {
501
+ if (step.request.kind !== "human" || step.request.options.approve !== true) return undefined
502
+ const approved = commit.parsed.approved
503
+ if (approved !== undefined && formatStepId(approved.step) === expected) return undefined
504
+ return {
505
+ kind: "divergence" as const,
506
+ message: `gtd: commit ${short(commit.hash)} completes approval gate "${expected}" but records no Gtd-Approved for it — ${DIVERGED}`,
507
+ }
508
+ }
509
+
492
510
  const consumeCommit = (step: ReachedStep, commit: ParsedCommit): unknown => {
493
511
  const recorded = commit.parsed.step
494
512
  const expected = formatStepId(step.id)
@@ -500,6 +518,8 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
500
518
  message: `gtd: commit ${short(commit.hash)} ${what}, but replay expected "${expected}" — ${DIVERGED}`,
501
519
  })
502
520
  }
521
+ const unapproved = missingApproval(step, commit, expected)
522
+ if (unapproved !== undefined) return finish(unapproved)
503
523
  cursor++
504
524
  advance(commit)
505
525
  if (commit.parsed.kept !== undefined) {
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { formatCommitMessage, parseCommitMessage, parseSubject } from "./Trailers.js"
2
+ import {
3
+ formatCommitMessage,
4
+ isRecordableApproval,
5
+ parseCommitMessage,
6
+ parseSubject,
7
+ } from "./Trailers.js"
3
8
 
4
9
  describe("the commit-message codec", () => {
5
10
  it("round-trips a step commit's subject and trailers", () => {
@@ -173,3 +178,107 @@ describe("Gtd-Kept", () => {
173
178
  expect(parsed.format).toBe(1)
174
179
  })
175
180
  })
181
+
182
+ describe("Gtd-Approved", () => {
183
+ const approved = {
184
+ step: { name: "seam", occurrence: 2 },
185
+ name: "Ada Lovelace",
186
+ email: "ada@x.io",
187
+ }
188
+ const line = (value: string) => `gtd(human): a → b\n\nGtd-Approved: ${value}`
189
+
190
+ it("is written as one JSON line between Gtd-Judge and Gtd-Branch and round-trips", () => {
191
+ const message = formatCommitMessage({
192
+ actor: "human",
193
+ to: "next",
194
+ from: "seam",
195
+ step: { name: "seam", occurrence: 2 },
196
+ judge: [{ id: "v", answer: "yes", p: 1 }],
197
+ branch: "feat",
198
+ approved,
199
+ })
200
+ const lines = message.split("\n")
201
+ const at = lines.indexOf(
202
+ 'Gtd-Approved: {"step":"seam#2","name":"Ada Lovelace","email":"ada@x.io"}',
203
+ )
204
+ expect(at).toBeGreaterThan(-1)
205
+ expect(lines[at - 1]).toMatch(/^Gtd-Judge:/)
206
+ expect(lines[at + 1]).toBe("Gtd-Branch: feat")
207
+ expect(parseCommitMessage(message).approved).toEqual(approved)
208
+ })
209
+
210
+ it.each([
211
+ ["a plain step", "seam"],
212
+ ["a step with a space and hash", "phase#1 review"],
213
+ ["a step with angle brackets", "a <b> c"],
214
+ ["a scoped step", "pkg.seam"],
215
+ ["a quote/backslash approver name", "seam", 'Ada "A" \\ L'],
216
+ ])("round-trips %s", (_label, stepName, approver = "Ada") => {
217
+ const a = { step: { name: stepName, occurrence: 3 }, name: approver, email: "a@x.io" }
218
+ expect(
219
+ parseCommitMessage(formatCommitMessage({ actor: "human", to: "b", approved: a })).approved,
220
+ ).toEqual(a)
221
+ })
222
+
223
+ it("reads undefined for the old shape, non-objects and bad fields", () => {
224
+ for (const value of [
225
+ "seam#1 Ada <a@x.io>",
226
+ "",
227
+ "[]",
228
+ '"x"',
229
+ "null",
230
+ '{"step":"seam#1","name":"Ada"}',
231
+ '{"step":"seam#1","name":"Ada","email":1}',
232
+ '{"step":"seam","name":"Ada","email":"a@x.io"}',
233
+ '{"step":"seam#0","name":"Ada","email":"a@x.io"}',
234
+ ]) {
235
+ expect(parseCommitMessage(line(value)).approved).toBe(undefined)
236
+ }
237
+ })
238
+
239
+ it("reads undefined for a control character in any field", () => {
240
+ for (const json of [
241
+ '{"step":"se\\u001bam#1","name":"Ada","email":"a@x.io"}',
242
+ '{"step":"seam#1","name":"Ada\\u0007","email":"a@x.io"}',
243
+ '{"step":"seam#1","name":"Ada","email":"a\\u0085@x.io"}',
244
+ ]) {
245
+ expect(parseCommitMessage(line(json)).approved).toBe(undefined)
246
+ }
247
+ })
248
+
249
+ it("reads undefined when absent", () => {
250
+ expect(parseCommitMessage(formatCommitMessage({ actor: "agent", to: "b" })).approved).toBe(
251
+ undefined,
252
+ )
253
+ })
254
+ })
255
+
256
+ describe("isRecordableApproval", () => {
257
+ const of = (name: string, email: string, occurrence = 1) => ({
258
+ step: { name: "gate", occurrence },
259
+ name,
260
+ email,
261
+ })
262
+
263
+ it("accepts ordinary, quoted, backslashed and angle-bracketed identities", () => {
264
+ for (const name of ["Ada", 'A "B"', "A\\B", "A <B>"]) {
265
+ expect(isRecordableApproval(of(name, "a@x.io"))).toBe(true)
266
+ }
267
+ expect(isRecordableApproval(of("Ada", "a<b>@x.io"))).toBe(true)
268
+ })
269
+
270
+ it("refuses a C1 control character in name or email", () => {
271
+ expect(isRecordableApproval(of("Ada\u0085", "a@x.io"))).toBe(false)
272
+ expect(isRecordableApproval(of("Ada", "a\u0085@x.io"))).toBe(false)
273
+ })
274
+
275
+ it("refuses a JS line terminator that would split the trailer on read", () => {
276
+ const name = "x\u2028Gtd-Branch: forged\u2028"
277
+ expect(isRecordableApproval(of(name, "a@x.io"))).toBe(false)
278
+ expect(isRecordableApproval(of("Ada", "a\u2029@x.io"))).toBe(false)
279
+ })
280
+
281
+ it("refuses an occurrence that does not read back", () => {
282
+ expect(isRecordableApproval(of("Ada", "a@x.io", 0))).toBe(false)
283
+ })
284
+ })
@@ -60,6 +60,12 @@ export interface CostEntry {
60
60
  readonly model: string | undefined
61
61
  }
62
62
 
63
+ export interface Approval {
64
+ readonly step: StepId
65
+ readonly name: string
66
+ readonly email: string
67
+ }
68
+
63
69
  export interface CommitMessage {
64
70
  readonly subject: string
65
71
  readonly parsed: ParsedSubject | undefined
@@ -77,6 +83,8 @@ export interface CommitMessage {
77
83
  readonly version: string | undefined
78
84
  /** The off-branch tip a keep step set aside; a non-hash value is skipped. */
79
85
  readonly kept: string | undefined
86
+ /** `Gtd-Approved`; a malformed or control-character value is skipped. */
87
+ readonly approved: Approval | undefined
80
88
  readonly format: number
81
89
  }
82
90
 
@@ -106,7 +114,7 @@ const CONFIG_RE = /^([0-9a-f]{16}) (.+)$/
106
114
  // JSON string. A control character is refused on read: history written by
107
115
  // someone else must never put escape sequences on a terminal.
108
116
  // eslint-disable-next-line no-control-regex -- the control range IS the point
109
- const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f]/
117
+ const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f\u2028\u2029]/
110
118
  const encodePath = (path: string): string =>
111
119
  path.trim() !== path || path.startsWith('"') || CONTROL_CHARACTER.test(path)
112
120
  ? JSON.stringify(path)
@@ -115,6 +123,24 @@ const decodePath = (raw: string): string | undefined => {
115
123
  const path = raw.startsWith('"') ? parseJson(raw) : raw
116
124
  return typeof path === "string" && path !== "" && !CONTROL_CHARACTER.test(path) ? path : undefined
117
125
  }
126
+ const decodeApproval = (raw: string): Approval | undefined => {
127
+ const parsed = parseJson(raw)
128
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined
129
+ const { step, name, email } = parsed as Record<string, unknown>
130
+ if (typeof step !== "string" || typeof name !== "string" || typeof email !== "string")
131
+ return undefined
132
+ if ([step, name, email].some((field) => CONTROL_CHARACTER.test(field))) return undefined
133
+ const id = parseStepId(step)
134
+ return id === undefined ? undefined : { step: id, name, email }
135
+ }
136
+ const encodeApproval = ({ step, name, email }: Approval): string =>
137
+ JSON.stringify({ step: formatStepId(step), name, email })
138
+ // The land-time refusal relies on this: an approval that would not read back
139
+ // unchanged must never be written.
140
+ export const isRecordableApproval = (approval: Approval): boolean => {
141
+ const back = decodeApproval(encodeApproval(approval))
142
+ return back !== undefined && encodeApproval(back) === encodeApproval(approval)
143
+ }
118
144
  const FORMAT_RE = /^[1-9][0-9]*$/
119
145
  const HASH_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
120
146
 
@@ -125,6 +151,7 @@ interface Trailers {
125
151
  branch: string | undefined
126
152
  version: string | undefined
127
153
  kept: string | undefined
154
+ approved: Approval | undefined
128
155
  readonly judge: JudgeVerdict[]
129
156
  readonly vars: Record<string, string>
130
157
  readonly config: Record<string, string>
@@ -164,6 +191,9 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
164
191
  "Gtd-Kept": (value, into) => {
165
192
  if (HASH_RE.test(value)) into.kept ??= value
166
193
  },
194
+ "Gtd-Approved": (value, into) => {
195
+ into.approved ??= decodeApproval(value)
196
+ },
167
197
  "Gtd-Format": (value, into) => {
168
198
  if (FORMAT_RE.test(value)) into.format ??= Number(value)
169
199
  },
@@ -183,6 +213,7 @@ export const parseCommitMessage = (message: string): CommitMessage => {
183
213
  branch: undefined,
184
214
  version: undefined,
185
215
  kept: undefined,
216
+ approved: undefined,
186
217
  judge: [],
187
218
  vars: {},
188
219
  config: {},
@@ -208,6 +239,8 @@ export interface CommitSpec {
208
239
  readonly config?: Readonly<Record<string, string>>
209
240
  readonly cost?: { readonly cost: number; readonly model?: string }
210
241
  readonly judge?: readonly JudgeVerdict[]
242
+ /** Written after `Gtd-Judge`, before `Gtd-Branch`. */
243
+ readonly approved?: Approval
211
244
  readonly branch?: string
212
245
  readonly version?: string
213
246
  }
@@ -230,6 +263,9 @@ export const formatCommitMessage = (spec: CommitSpec): string => {
230
263
  )
231
264
  }
232
265
  for (const verdict of spec.judge ?? []) lines.push(`Gtd-Judge: ${JSON.stringify(verdict)}`)
266
+ if (spec.approved !== undefined) {
267
+ lines.push(`Gtd-Approved: ${encodeApproval(spec.approved)}`)
268
+ }
233
269
  if (spec.branch !== undefined) lines.push(`Gtd-Branch: ${spec.branch}`)
234
270
  if (spec.version !== undefined) lines.push(`Gtd-Version: ${spec.version}`)
235
271
  lines.push(`Gtd-Format: ${HISTORY_FORMAT}`)
@@ -2,7 +2,9 @@ export { replay, type EpisodeCommit, type ReachedStep, type ReplayOutcome } from
2
2
  export { treeFromRecord, type TreeView } from "./Tree.js"
3
3
  export {
4
4
  formatCommitMessage,
5
+ formatStepId,
5
6
  formatSubject,
7
+ isRecordableApproval,
6
8
  parseCommitMessage,
7
9
  type CommitSpec,
8
10
  type JudgeVerdict,
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { human, seamGate, type DiffShape, type DiffShapeResult, type Flow } from "../flows/index.js"
3
+ import { replay, treeFromRecord } from "./index.js"
4
+
5
+ // Lives in replay, which drives a flow to its rest (flows tests may not import replay).
6
+ const shape = (
7
+ choice: DiffShape,
8
+ strays: string[] = [],
9
+ unreadable: string[] = [],
10
+ ): DiffShapeResult => ({ choice, strays, unreadable, judgment: undefined })
11
+
12
+ const restOf = async (result: DiffShapeResult) => {
13
+ const flow: Flow = async () => {
14
+ await seamGate("seam", result)
15
+ await human("next")
16
+ }
17
+ const outcome = await replay({
18
+ flow,
19
+ episode: { base: { hash: "0".repeat(40), tree: treeFromRecord({}) }, commits: [] },
20
+ vars: {},
21
+ env: {},
22
+ start: "0".repeat(40),
23
+ budgetBytes: 1024,
24
+ })
25
+ if (outcome.kind !== "rest") throw new Error(`expected a rest, got ${outcome.kind}`)
26
+ return outcome.rest
27
+ }
28
+
29
+ describe("seamGate", () => {
30
+ for (const choice of ["land", "reject"] as const) {
31
+ it(`reaches no step on ${choice}: the flow's next step is the rest`, async () => {
32
+ const rest = await restOf(shape(choice, ["a.ts"]))
33
+ expect(rest.name).toBe("next")
34
+ })
35
+ }
36
+
37
+ it("rests at an approve human step named after the caller on refactor-first", async () => {
38
+ const rest = await restOf(shape("refactor-first", ["a.ts"]))
39
+ expect(rest.name).toBe("seam")
40
+ expect(rest.request).toMatchObject({ kind: "human", options: { approve: true } })
41
+ })
42
+
43
+ it("lists every stray in order, marks only the unreadable, names gtd land and gtd abandon", async () => {
44
+ const rest = await restOf(shape("refactor-first", ["a.ts", "b/c.ts", "z.bin"], ["b/c.ts"]))
45
+ if (rest.request.kind !== "human") throw new Error("expected a human step")
46
+ const message = rest.request.options.message ?? ""
47
+ const lines = message.split("\n")
48
+ const strays = lines.filter((line) => line.startsWith("- "))
49
+ expect(strays).toEqual(["- a.ts", "- b/c.ts (unreadable — not judged)", "- z.bin"])
50
+ expect(message).toMatch(/`gtd land`[^\n]*approve/)
51
+ expect(message).toContain("`gtd abandon`")
52
+ })
53
+
54
+ it("quotes a stray path with a control character", async () => {
55
+ const rest = await restOf(shape("refactor-first", ["a\nb", "c\u001b[2K"]))
56
+ if (rest.request.kind !== "human") throw new Error("expected a human step")
57
+ const strays = (rest.request.options.message ?? "")
58
+ .split("\n")
59
+ .filter((line) => line.startsWith("- "))
60
+ expect(strays).toEqual(['- "a\\nb"', '- "c\\u001b[2K"'])
61
+ })
62
+ })
@@ -8,6 +8,7 @@ import type { StateName } from "../workflow/index.js"
8
8
  */
9
9
  export type GitWrite =
10
10
  | { readonly kind: "commitAll"; readonly message: string }
11
+ | { readonly kind: "commitClean"; readonly message: string }
11
12
  | { readonly kind: "updateRef"; readonly ref: string; readonly target: "HEAD" }
12
13
  | { readonly kind: "keepTurn"; readonly ref: string; readonly message: string }
13
14
 
@@ -131,6 +131,38 @@ describe("planStep — keepHistory", () => {
131
131
  })
132
132
  })
133
133
 
134
+ describe("planStep — approval landing", () => {
135
+ const approved = { step: { name: "gate", occurrence: 1 }, name: "Ada", email: "ada@x.io" }
136
+
137
+ it("commits with commitClean when the landing records an approval", () => {
138
+ const base = commitTo("gate", "again")
139
+ const s = snapshot({
140
+ state: "gate",
141
+ stepDef: {},
142
+ landing: { ...base, spec: { ...base.spec, approved } },
143
+ })
144
+ const outcome = planStep(s)
145
+ if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
146
+ expect(outcome.steps[0]).toMatchObject({ write: { kind: "commitClean" } })
147
+ })
148
+
149
+ it("a finishing approval still points the history ref after the commitClean write", () => {
150
+ const base = commitTo("gate", "idle")
151
+ const s = snapshot({
152
+ state: "gate",
153
+ stepDef: {},
154
+ landing: { ...base, spec: { ...base.spec, approved }, keepHistory: "refs/gtd/history/abc" },
155
+ })
156
+ const outcome = planStep(s)
157
+ if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
158
+ expect(outcome.steps.map((x) => (x.kind === "gitWrite" ? x.write.kind : x.kind))).toEqual([
159
+ "commitClean",
160
+ "updateRef",
161
+ "outcome",
162
+ ])
163
+ })
164
+ })
165
+
134
166
  describe("planStep — keepTurn", () => {
135
167
  const kept = "refs/gtd/kept/abc/building"
136
168
 
@@ -87,7 +87,9 @@ export const planStep = (
87
87
  write:
88
88
  landing.keepTurn !== undefined
89
89
  ? { kind: "keepTurn", ref: landing.keepTurn, message }
90
- : { kind: "commitAll", message },
90
+ : landing.spec.approved !== undefined
91
+ ? { kind: "commitClean", message }
92
+ : { kind: "commitAll", message },
91
93
  },
92
94
  ...(landing.keepHistory !== undefined
93
95
  ? [
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"
2
2
  import {
3
3
  commitAll,
4
4
  commitAsIs,
5
+ commitClean,
5
6
  deleteRef,
6
7
  discardPending,
7
8
  hardResetTo,
@@ -664,3 +665,29 @@ describe("applyEmittedScript — keepTurn", () => {
664
665
  expect(repo.resolveRef(REF)).toBeNull()
665
666
  })
666
667
  })
668
+
669
+ describe("applyEmittedScript — commitClean", () => {
670
+ const MESSAGE = 'gtd(human): gate → again\n\nGtd-Approved: {"step":"gate#1"}'
671
+
672
+ it("applies the rendered block as an empty commit over HEAD", () => {
673
+ const repo = new InMemRepo()
674
+ repo.writeFile("base.txt", "0")
675
+ repo.seedCommit("chore: first")
676
+ const result = applyEmittedScript(repo, NO_COMMANDS, commitClean(MESSAGE))
677
+ expect(result).toMatchObject({ ok: true })
678
+ expect(repo.lastCommitMessage()).toBe(MESSAGE)
679
+ expect(repo.statusPorcelain()).toBe("")
680
+ })
681
+
682
+ it("fails loudly on a one-character near-miss and applies nothing", () => {
683
+ const repo = new InMemRepo()
684
+ repo.writeFile("base.txt", "0")
685
+ repo.seedCommit("chore: first")
686
+ const before = snapshot(repo)
687
+ const nearMiss = commitClean(MESSAGE).replace("update-ref -m", "update-ref -n")
688
+ const result = applyEmittedScript(repo, NO_COMMANDS, nearMiss)
689
+ expect(result.ok).toBe(false)
690
+ expect(result.error).toContain("unrecognized script block")
691
+ expect(snapshot(repo)).toEqual(before)
692
+ })
693
+ })