@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
package/dist/wire.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { C as EXIT_SIGTERM, D as UNATTRIBUTED_MODEL, E as ACCESS_REFUSAL, O as WIRE_SCHEMA, S as EXIT_SIGINT, T as EXIT_USAGE_ERROR, _ as demandOf, a as landFields, b as EXIT_OK, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, h as BEAT_KINDS, i as judgeJson, k as renderFormat, l as renderLandPlain, m as statusOf, n as resolveSession, o as landProseText, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches, u as DEMAND_BRIEFING, v as stallDiagnosis, w as EXIT_STRAYS_FOUND, x as EXIT_RUNTIME_ERROR, y as EXIT_CODES } from "./chunks/wire-BY1S-obW.mjs";
1
+ import { C as EXIT_SIGTERM, D as UNATTRIBUTED_MODEL, E as ACCESS_REFUSAL, O as WIRE_SCHEMA, S as EXIT_SIGINT, T as EXIT_USAGE_ERROR, _ as demandOf, a as landFields, b as EXIT_OK, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, h as BEAT_KINDS, i as judgeJson, k as renderFormat, l as renderLandPlain, m as statusOf, n as resolveSession, o as landProseText, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches, u as DEMAND_BRIEFING, v as stallDiagnosis, w as EXIT_STRAYS_FOUND, x as EXIT_RUNTIME_ERROR, y as EXIT_CODES } from "./chunks/wire-C12dahrH.mjs";
2
2
  export { ACCESS_REFUSAL, BEAT_KINDS, DEMAND_BRIEFING, EXIT_CODES, EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_SIGINT, EXIT_SIGTERM, EXIT_STRAYS_FOUND, EXIT_USAGE_ERROR, UNATTRIBUTED_MODEL, WIRE_SCHEMA, beatDocument, beatKindOf, demandOf, globMatches, judgeJson, landFields, landProseText, noopText, renderBeatJson, renderBeatPlain, renderFormat, renderLandJson, renderLandPlain, resolveSession, selectPath, stallDiagnosis, statusOf };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "27.3.0",
3
+ "version": "27.4.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
package/src/cli/Cli.ts CHANGED
@@ -705,11 +705,11 @@ const COMMAND_ROWS: readonly CommandRow[] = [
705
705
  arity: { name: "ref" },
706
706
  details: [
707
707
  "Replay one kept process under today's config and print its",
708
- "steps and judge verdicts. <ref> is a full ref, an entry hash",
709
- "(or unique prefix) or a branch name. Refuses (exit 1) when a",
710
- "config file differs from its content at the ref's tip. A",
711
- "version mismatch only warns on stderr. --json prints the",
712
- "trail as one document; writes nothing",
708
+ "steps, judge verdicts and approvals. <ref> is a full ref, an",
709
+ "entry hash (or unique prefix) or a branch name. Refuses",
710
+ "(exit 1) when a config file differs from its content at the",
711
+ "ref's tip. A version mismatch only warns on stderr. --json",
712
+ "prints the trail as one document; writes nothing",
713
713
  ],
714
714
  },
715
715
  {
@@ -128,6 +128,11 @@ export const runHistoryShowCommand = (
128
128
  for (const step of trail.steps) {
129
129
  lines.push(`${seven(step.hash)} ${step.subject}`)
130
130
  for (const v of step.verdicts) lines.push(` judge ${v.id} = ${v.answer} (p=${v.p})`)
131
+ if (step.approval !== undefined) {
132
+ lines.push(
133
+ ` approved ${step.approval.step} by ${step.approval.name} <${step.approval.email}>`,
134
+ )
135
+ }
131
136
  }
132
137
  lines.push(`ended (${trail.ended})`)
133
138
  out.write(`${lines.join("\n")}\n`)
@@ -1080,6 +1080,7 @@ const gatherBeatDocument = (
1080
1080
  next: yield* computeNextMatch(rest),
1081
1081
  cost: rest.cost.total,
1082
1082
  costByModel: rest.cost.byModel,
1083
+ approvals: rest.run.approvals,
1083
1084
  })
1084
1085
  return beatDocument(demand, status)
1085
1086
  })
@@ -151,6 +151,7 @@ describe("currentRun", () => {
151
151
  trace: [],
152
152
  costEntries: [],
153
153
  judgeVerdicts: [],
154
+ approvals: [],
154
155
  pinnedVars: {},
155
156
  headTurn: undefined,
156
157
  closingHash: undefined,
@@ -1306,6 +1307,28 @@ export default async () => {
1306
1307
  expect(trail.steps.filter((s) => s.verdicts.length > 0)).toHaveLength(1)
1307
1308
  })
1308
1309
 
1310
+ it("sets approval on the approval step only", async () => {
1311
+ const repo = repoWith(`import { human } from "@pmelab/gtd/flows"
1312
+
1313
+ export default async () => {
1314
+ await human("idle")
1315
+ await human("gate", { message: "seam report", approve: true })
1316
+ await human("after")
1317
+ }
1318
+ `)
1319
+ await land(repo, { "src/a.ts": "a\n" })
1320
+ const approval = await land(repo)
1321
+ const tip = await land(repo, { "src/b.ts": "b\n" })
1322
+ const trail = await provide(historyTrail(tip), repo)
1323
+ expect(trail.steps.find((s) => s.hash === approval)?.approval).toEqual({
1324
+ step: "gate#1",
1325
+ name: "Test",
1326
+ email: "test@test.com",
1327
+ commit: approval,
1328
+ })
1329
+ expect(trail.steps.filter((s) => s.approval !== undefined)).toHaveLength(1)
1330
+ })
1331
+
1309
1332
  it("records the version and warns about none while they match", async () => {
1310
1333
  const { repo, tip } = await finished()
1311
1334
  const trail = await provide(historyTrail(tip), repo)
@@ -1369,3 +1392,140 @@ export default async () => {
1369
1392
  expect(String(Exit.isFailure(exit) && exit.cause)).toContain("gtd.config.ts")
1370
1393
  })
1371
1394
  })
1395
+
1396
+ describe("landing at an approve rest", () => {
1397
+ const GATED = `import { human } from "@pmelab/gtd/flows"
1398
+
1399
+ export default async () => {
1400
+ await human("idle")
1401
+ await human("gate", { message: "seam report", approve: true })
1402
+ await human("after")
1403
+ }
1404
+ `
1405
+
1406
+ const atGate = async (): Promise<InMemRepo> => {
1407
+ const repo = repoWith(GATED)
1408
+ await land(repo, { "src/a.ts": "a\n" })
1409
+ return repo
1410
+ }
1411
+
1412
+ const snapshotAt = async (repo: InMemRepo, git?: GitOperations) => {
1413
+ const rest = await provide(currentRest, repo)
1414
+ const effect = snapshotFromRest(rest)
1415
+ return git === undefined ? await provide(effect, repo) : await provideWithGit(effect, git, repo)
1416
+ }
1417
+
1418
+ it("rests at a message step carrying approve", async () => {
1419
+ const rest = await provide(currentRest, await atGate())
1420
+ expect(rest.state).toBe("gate")
1421
+ expect(rest.stepDef).toMatchObject({ kind: "message", content: "seam report", approve: true })
1422
+ })
1423
+
1424
+ it("refuses a dirty tree and commits nothing", async () => {
1425
+ const repo = await atGate()
1426
+ const head = headOf(repo)
1427
+ repo.writeFile("src/edited.ts", "edit\n")
1428
+ const { landing } = await snapshotAt(repo)
1429
+ expect(landing).toEqual({
1430
+ kind: "refusal",
1431
+ message:
1432
+ "gtd land: approval-gate: gate#1 approves exactly the judged diff — revert your edits and run `gtd land` on a clean tree to approve, or run `gtd abandon`",
1433
+ })
1434
+ expect(headOf(repo)).toBe(head)
1435
+ })
1436
+
1437
+ const stageThenRevert = (repo: InMemRepo): void => {
1438
+ repo.writeFile("src/a.ts", "edited\n")
1439
+ repo.stageAll()
1440
+ repo.writeFile("src/a.ts", "a\n")
1441
+ }
1442
+
1443
+ it("refuses an edit staged and then reverted in the worktree", async () => {
1444
+ const repo = await atGate()
1445
+ stageThenRevert(repo)
1446
+ expect((await snapshotAt(repo)).landing).toMatchObject({
1447
+ kind: "refusal",
1448
+ message: expect.stringContaining("approves exactly the judged diff"),
1449
+ })
1450
+ })
1451
+
1452
+ it("counts no index-only edit at a rest that approves nothing", async () => {
1453
+ const repo = await atGate()
1454
+ await land(repo)
1455
+ stageThenRevert(repo)
1456
+ const rest = await provide(currentRest, repo)
1457
+ expect(rest.state).toBe("after")
1458
+ expect(rest.changes).toEqual([])
1459
+ expect((await snapshotAt(repo)).landing).toEqual({ kind: "noop", settled: false })
1460
+ })
1461
+
1462
+ it("refuses a clean tree when git has no author identity", async () => {
1463
+ const repo = await atGate()
1464
+ const git = { ...fakeGitOperations(repo), authorIdent: () => Effect.succeed(Option.none()) }
1465
+ const { landing } = await snapshotAt(repo, git)
1466
+ expect(landing).toEqual({
1467
+ kind: "refusal",
1468
+ message:
1469
+ "gtd land: approval-gate: gate#1 records who approved it, but git has no author identity — set user.name and user.email",
1470
+ })
1471
+ })
1472
+
1473
+ it("refuses an identity a trailer cannot carry", async () => {
1474
+ const repo = await atGate()
1475
+ const git = {
1476
+ ...fakeGitOperations(repo),
1477
+ authorIdent: () => Effect.succeed(Option.some({ name: "Ada\u001b[31m", email: "a@x.io" })),
1478
+ }
1479
+ expect((await snapshotAt(repo, git)).landing).toEqual({
1480
+ kind: "refusal",
1481
+ message:
1482
+ "gtd land: approval-gate: gate#1 cannot record Ada\u001b[31m <a@x.io> as approver — it would not read back unchanged from its Gtd-Approved trailer (a control character, for example)",
1483
+ })
1484
+ })
1485
+
1486
+ it("approve implies acceptClean even when acceptClean is false", async () => {
1487
+ const repo = repoWith(GATED.replace("approve: true", "approve: true, acceptClean: false"))
1488
+ await land(repo, { "src/a.ts": "a\n" })
1489
+ expect((await snapshotAt(repo)).landing.kind).toBe("commit")
1490
+ })
1491
+
1492
+ it("lands a clean tree as a commit carrying the approver", async () => {
1493
+ const repo = await atGate()
1494
+ const { landing } = await snapshotAt(repo)
1495
+ if (landing.kind !== "commit") throw new Error(`expected commit, got ${landing.kind}`)
1496
+ expect(landing.to).toBe("after")
1497
+ expect(landing.spec.approved).toEqual({
1498
+ step: { name: "gate", occurrence: 1 },
1499
+ name: "Test",
1500
+ email: "test@test.com",
1501
+ })
1502
+ })
1503
+
1504
+ it("reads the author identity only at an approve rest", async () => {
1505
+ const repo = repoWith(GATED)
1506
+ repo.writeFile("src/a.ts", "a\n")
1507
+ const git = {
1508
+ ...fakeGitOperations(repo),
1509
+ authorIdent: () => Effect.die(new Error("authorIdent read at a plain rest")),
1510
+ }
1511
+ expect((await snapshotAt(repo, git)).landing.kind).toBe("commit")
1512
+ })
1513
+
1514
+ it("collects each approval of the current process, oldest first", async () => {
1515
+ const repo = await atGate()
1516
+ expect((await provide(currentRun, repo)).approvals).toEqual([])
1517
+ const approval = await land(repo)
1518
+ expect((await provide(currentRun, repo)).approvals).toEqual([
1519
+ { step: "gate#1", name: "Test", email: "test@test.com", commit: approval },
1520
+ ])
1521
+ })
1522
+
1523
+ it("resumes past the approval commit without asking again", async () => {
1524
+ const repo = await atGate()
1525
+ await land(repo)
1526
+ expect(repo.lastCommitMessage()).toContain(
1527
+ 'Gtd-Approved: {"step":"gate#1","name":"Test","email":"test@test.com"}',
1528
+ )
1529
+ expect((await provide(currentRest, repo)).state).toBe("after")
1530
+ })
1531
+ })
package/src/edge/Edge.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  GitService,
5
5
  Host,
6
6
  Workspace,
7
+ type AuthorIdent,
7
8
  type GitOperations,
8
9
  type HostOps,
9
10
  type WorkspaceOps,
@@ -29,7 +30,9 @@ import {
29
30
  } from "../workflow/index.js"
30
31
  import {
31
32
  formatFault,
33
+ formatStepId,
32
34
  formatSubject,
35
+ isRecordableApproval,
33
36
  parseCommitMessage,
34
37
  replay,
35
38
  treeFromRecord,
@@ -41,7 +44,14 @@ import {
41
44
  } from "../replay/index.js"
42
45
  import type { ScopeAccess } from "../flows/index.js"
43
46
  import { steeringFormatFor } from "../steering/index.js"
44
- import { judgeJson, UNATTRIBUTED_MODEL, type ModelCost, type StepAccess } from "../wire/index.js"
47
+ import {
48
+ judgeJson,
49
+ UNATTRIBUTED_MODEL,
50
+ type ApprovalEntry,
51
+ type ModelCost,
52
+ type StepAccess,
53
+ } from "../wire/index.js"
54
+
45
55
  import { GTD_VERSION } from "../install/index.js"
46
56
  import { keptRef, processHistoryRef } from "../platform/index.js"
47
57
  import { isAbsolute, relative, resolve, sep } from "node:path"
@@ -141,6 +151,8 @@ export interface ProcessRun {
141
151
  readonly trace: readonly TraceEntry[]
142
152
  readonly costEntries: readonly CostEntry[]
143
153
  readonly judgeVerdicts: readonly JudgeVerdictEntry[]
154
+ /** Oldest first. */
155
+ readonly approvals: readonly ApprovalEntry[]
144
156
  /** The process settings recorded in the process's first commit (empty for a process started before pinning existed). */
145
157
  readonly pinnedVars: Record<string, string>
146
158
  /** Absent for a process started before recording existed. */
@@ -228,6 +240,21 @@ const pinnedConfigOf = (history: History): ProcessRun["pinnedConfig"] => {
228
240
  return undefined
229
241
  }
230
242
 
243
+ const approvalEntryOf = (
244
+ hash: string,
245
+ message: ReturnType<typeof parseCommitMessage>,
246
+ ): readonly ApprovalEntry[] =>
247
+ message.approved === undefined
248
+ ? []
249
+ : [
250
+ {
251
+ step: formatStepId(message.approved.step),
252
+ name: message.approved.name,
253
+ email: message.approved.email,
254
+ commit: hash,
255
+ },
256
+ ]
257
+
231
258
  const buildRun = (
232
259
  history: History,
233
260
  location: EpisodeLocation,
@@ -247,6 +274,7 @@ const buildRun = (
247
274
  trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i]!)),
248
275
  costEntries: parsed.flatMap(costEntriesOf),
249
276
  judgeVerdicts: parsed.flatMap((m) => m.judge),
277
+ approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i]!)),
250
278
  pinnedVars: { ...first?.vars },
251
279
  pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
252
280
  headTurn: headTurnOf(head),
@@ -729,7 +757,8 @@ const messageDef = (
729
757
  ...common,
730
758
  kind: "message",
731
759
  content: request.options.message ?? request.options.label ?? step.name,
732
- ...optional("acceptClean", request.options.acceptClean),
760
+ ...optional("acceptClean", request.options.approve === true || request.options.acceptClean),
761
+ ...optional("approve", request.options.approve),
733
762
  })
734
763
 
735
764
  const stepDefOf = (step: ReachedStep): StepDef => {
@@ -933,6 +962,17 @@ const setupFor = (
933
962
  return { resolved, def, vars, env, setup }
934
963
  })
935
964
 
965
+ // Only an approval gate counts an index-only edit (one staged, then reverted
966
+ // in the worktree): its approval must cover exactly the judged diff. Every
967
+ // other rest keeps the worktree's view, so a net-zero turn stays an attempt.
968
+ const pendingChanges = (git: GitOperations, withIndex: boolean) =>
969
+ Effect.gen(function* () {
970
+ const worktree = yield* git.changedPaths()
971
+ if (!withIndex) return worktree
972
+ const seen = new Set(worktree.map((e) => e.path))
973
+ return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))]
974
+ })
975
+
936
976
  /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
937
977
  export const restAt = (
938
978
  ref: string | undefined,
@@ -974,7 +1014,7 @@ export const restAt = (
974
1014
  }
975
1015
  const changes =
976
1016
  ref === undefined
977
- ? (yield* git.changedPaths()).map((e) => ({
1017
+ ? (yield* pendingChanges(git, stepDef.approve === true)).map((e) => ({
978
1018
  status: normalizeStatus(e.status),
979
1019
  path: e.path,
980
1020
  }))
@@ -1107,6 +1147,32 @@ export const restIsIdle = (rest: Rest): boolean =>
1107
1147
 
1108
1148
  const isHumanReviewGate = (def: StepDef): boolean => def.actor === "human" && def.mode === "review"
1109
1149
 
1150
+ // An approval covers exactly the judged diff, so any edit refuses it; the
1151
+ // approver is read only at such a rest, and must be a name a trailer can carry.
1152
+ const approvalRefusal = (rest: Rest, approver: Option.Option<AuthorIdent>): Landing | undefined => {
1153
+ if (rest.stepDef.approve !== true) return undefined
1154
+ const id = rest.step.id
1155
+ if (rest.changes.length > 0) {
1156
+ return {
1157
+ kind: "refusal",
1158
+ message: `gtd land: approval-gate: ${formatStepId(id)} approves exactly the judged diff — revert your edits and run \`gtd land\` on a clean tree to approve, or run \`gtd abandon\``,
1159
+ }
1160
+ }
1161
+ if (Option.isNone(approver)) {
1162
+ return {
1163
+ kind: "refusal",
1164
+ message: `gtd land: approval-gate: ${formatStepId(id)} records who approved it, but git has no author identity — set user.name and user.email`,
1165
+ }
1166
+ }
1167
+ if (!isRecordableApproval({ step: id, ...approver.value })) {
1168
+ return {
1169
+ kind: "refusal",
1170
+ message: `gtd land: approval-gate: ${formatStepId(id)} cannot record ${approver.value.name} <${approver.value.email}> as approver — it would not read back unchanged from its Gtd-Approved trailer (a control character, for example)`,
1171
+ }
1172
+ }
1173
+ return undefined
1174
+ }
1175
+
1110
1176
  /** The landing a clean tree decides before any replay, if it decides one. */
1111
1177
  const cleanLanding = (rest: Rest): Landing | undefined => {
1112
1178
  if (rest.changes.length > 0) return undefined
@@ -1182,7 +1248,15 @@ const keptTurn = (rest: Rest) =>
1182
1248
  ? { keepTurn: keptRef(rest.run.startHash, rest.state) }
1183
1249
  : {}
1184
1250
 
1185
- const landingTo = (rest: Rest, to: StateName, branch: Option.Option<string>): Landing => {
1251
+ const approvedTrailer = (rest: Rest, approver: Option.Option<AuthorIdent>) =>
1252
+ Option.isSome(approver) ? { approved: { step: rest.step.id, ...approver.value } } : {}
1253
+
1254
+ const landingTo = (
1255
+ rest: Rest,
1256
+ to: StateName,
1257
+ branch: Option.Option<string>,
1258
+ approver: Option.Option<AuthorIdent>,
1259
+ ): Landing => {
1186
1260
  if (isSettledNoop(rest, to)) return { kind: "noop", settled: true }
1187
1261
  // The landing that leaves the flow's first step starts the process: it
1188
1262
  // pins the process settings every later invocation reads back.
@@ -1202,6 +1276,7 @@ const landingTo = (rest: Rest, to: StateName, branch: Option.Option<string>): La
1202
1276
  from: rest.state,
1203
1277
  to,
1204
1278
  step: rest.step.id,
1279
+ ...approvedTrailer(rest, approver),
1205
1280
  ...(starts ? { vars: rest.vars, config: rest.config } : {}),
1206
1281
  ...(finishes ? closingTrailers(branch) : {}),
1207
1282
  },
@@ -1219,8 +1294,11 @@ const decideLanding = (
1219
1294
  rest: Rest,
1220
1295
  verdicts: readonly JudgeVerdict[] | undefined,
1221
1296
  branch: Option.Option<string>,
1297
+ approver: Option.Option<AuthorIdent>,
1222
1298
  ): Effect.Effect<Landing, Error> =>
1223
1299
  Effect.gen(function* () {
1300
+ const unapproved = approvalRefusal(rest, approver)
1301
+ if (unapproved !== undefined) return unapproved
1224
1302
  const early = cleanLanding(rest)
1225
1303
  if (early !== undefined) return early
1226
1304
  if (rest.stepDef.kind === "prompt" && rest.stepDef.access?.write != null) {
@@ -1239,12 +1317,12 @@ const decideLanding = (
1239
1317
  )
1240
1318
  const to = yield* landingTarget(outcome, rest)
1241
1319
  if (typeof to !== "string") return to
1242
- return landingTo(rest, to, branch)
1320
+ return landingTo(rest, to, branch, approver)
1243
1321
  })
1244
1322
 
1245
1323
  /** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
1246
1324
  export const previewLanding = (rest: Rest): Effect.Effect<StateName | undefined> =>
1247
- decideLanding(rest, undefined, Option.none()).pipe(
1325
+ decideLanding(rest, undefined, Option.none(), Option.none()).pipe(
1248
1326
  Effect.map((landing) => (landing.kind === "commit" ? landing.to : undefined)),
1249
1327
  Effect.catchAll(() => Effect.succeed(undefined)),
1250
1328
  )
@@ -1255,8 +1333,10 @@ export const snapshotFromRest = (
1255
1333
  verdicts?: readonly JudgeVerdict[],
1256
1334
  ): Effect.Effect<RepoSnapshot, Error, Workspace | GitService> =>
1257
1335
  Effect.gen(function* () {
1258
- const branch = yield* (yield* GitService).currentBranch()
1259
- const landing = yield* decideLanding(rest, verdicts, branch)
1336
+ const git = yield* GitService
1337
+ const branch = yield* git.currentBranch()
1338
+ const approver = rest.stepDef.approve === true ? yield* git.authorIdent() : Option.none()
1339
+ const landing = yield* decideLanding(rest, verdicts, branch, approver)
1260
1340
  return {
1261
1341
  stepDef: rest.stepDef,
1262
1342
  state: rest.state,
@@ -1300,6 +1380,7 @@ export interface HistoryStep {
1300
1380
  readonly from: StateName | undefined
1301
1381
  readonly to: StateName
1302
1382
  readonly verdicts: readonly JudgeVerdict[]
1383
+ readonly approval?: ApprovalEntry
1303
1384
  }
1304
1385
 
1305
1386
  export interface HistoryTrail {
@@ -1371,6 +1452,7 @@ const historySteps = (run: ProcessRun): readonly HistoryStep[] => {
1371
1452
  from: message.parsed?.from,
1372
1453
  to: entry.state,
1373
1454
  verdicts: message.judge,
1455
+ ...optional("approval", approvalEntryOf(entry.hash, message)[0]),
1374
1456
  }
1375
1457
  })
1376
1458
  }
@@ -7,6 +7,7 @@ import fc from "fast-check"
7
7
  import {
8
8
  commitAll,
9
9
  commitAsIs,
10
+ commitClean,
10
11
  deleteRef,
11
12
  discardPending,
12
13
  fetchHistoryRefs,
@@ -70,6 +71,7 @@ const runWithFakeGit = (script: string, binDir: string): { status: number | null
70
71
  const allBuilders: ReadonlyArray<[string, string]> = [
71
72
  ["commitAll", commitAll("gtd(agent): working")],
72
73
  ["commitAsIs", commitAsIs("gtd(agent): working")],
74
+ ["commitClean", commitClean("gtd(human): gate → again")],
73
75
  ["softResetTo", softResetTo("HEAD~1")],
74
76
  ["mixedResetTo", mixedResetTo("HEAD~1")],
75
77
  ["hardResetTo", hardResetTo("HEAD~1")],
@@ -104,6 +106,40 @@ describe("commitAsIs", () => {
104
106
  })
105
107
  })
106
108
 
109
+ describe("commitClean", () => {
110
+ const logGit = (body: string): { binDir: string; log: string } => {
111
+ const dir = mkdtempSync(join(tmpdir(), "gtd-log-git-"))
112
+ const log = join(dir, "log")
113
+ const gitPath = join(dir, "git")
114
+ writeFileSync(gitPath, `#!/bin/sh\necho "$*" >> '${log}'\n${body}\n`)
115
+ chmodSync(gitPath, 0o755)
116
+ return { binDir: dir, log }
117
+ }
118
+ const readLog = (log: string): string[] =>
119
+ execFileSync("cat", [log], { encoding: "utf8" }).trim().split("\n")
120
+ const cases = `case "$1" in
121
+ rev-parse) echo head0 ;;
122
+ status) ;;
123
+ config) echo \${GTD_CFG:-false} ;;
124
+ commit-tree) echo commit0 ;;
125
+ esac`
126
+
127
+ it("a dirty tree exits 1 before any commit", () => {
128
+ const { binDir, counterFile } = withFakeGit(`echo head0`, `echo " M a.txt"`)
129
+ const result = runWithFakeGit(commitClean("msg"), binDir)
130
+ expect(result.status).toBe(1)
131
+ expect(execFileSync("cat", [counterFile], { encoding: "utf8" }).trim()).toBe("2")
132
+ })
133
+
134
+ it("a refused update-ref (HEAD moved) exits 1; the write is guarded by the pinned HEAD", () => {
135
+ const { binDir, log } = logGit(`${cases}\nif [ "$1" = update-ref ]; then exit 1; fi`)
136
+ const result = runWithFakeGit(commitClean("msg"), binDir)
137
+ expect(result.status).toBe(1)
138
+ const lines = readLog(log)
139
+ expect(lines[lines.length - 1]).toBe("update-ref -m commit: msg HEAD commit0 head0")
140
+ })
141
+ })
142
+
107
143
  describe("commitAllowEmpty retry discrimination (via commitAsIs)", () => {
108
144
  it("retries without hooks on the hook's own empty-git-commit rejection", () => {
109
145
  const { binDir, counterFile } = withFakeGit(
@@ -304,7 +340,7 @@ describe("keepTurn", () => {
304
340
  ` write-tree) echo tree0 ;;`,
305
341
  ` commit-tree) echo commit0 ;;`,
306
342
  ` config) echo false ;;`,
307
- ` update-ref) [ "$2" = HEAD ] && exit 1; exit 0 ;;`,
343
+ ` update-ref) [ "$4" = HEAD ] && exit 1; exit 0 ;;`,
308
344
  ` *) exit 0 ;;`,
309
345
  `esac`,
310
346
  ].join("\n"),
@@ -313,7 +349,7 @@ describe("keepTurn", () => {
313
349
  const result = runWithFakeGit(`set -eu\n${block}`, binDir)
314
350
  expect(result.status).toBe(1)
315
351
  const calls = readFileSync(log, "utf8").trim().split("\n")
316
- expect(calls).toContain("update-ref HEAD commit0 head0")
352
+ expect(calls).toContain("update-ref -m commit: gtd(agent): done HEAD commit0 head0")
317
353
  const resetAt = calls.indexOf("reset -q")
318
354
  expect(resetAt).toBeGreaterThan(-1)
319
355
  expect(calls.slice(resetAt + 1)).toEqual([
@@ -335,7 +371,7 @@ describe("keepTurn", () => {
335
371
  ` write-tree) echo tree0 ;;`,
336
372
  ` commit-tree) echo commit0 ;;`,
337
373
  ` config) echo false ;;`,
338
- ` update-ref) [ "$2" = HEAD ] && exit 1; exit 0 ;;`,
374
+ ` update-ref) [ "$4" = HEAD ] && exit 1; exit 0 ;;`,
339
375
  ` reset) exit 1 ;;`,
340
376
  ` *) exit 0 ;;`,
341
377
  `esac`,
@@ -24,10 +24,32 @@ const commitAllowEmpty = (message: string): string => {
24
24
  ].join("\n")
25
25
  }
26
26
 
27
+ export const CHANGED_SINCE_LAND =
28
+ "gtd: the working tree changed since `gtd land` — revert the edits and run `gtd land` again to approve"
29
+
27
30
  /** `git add -A || exit 1` (an `&&` left side would not abort under `set -e`) then `commitAllowEmpty`. */
28
31
  export const commitAll = (message: string): string =>
29
32
  `git add -A || exit 1\n${commitAllowEmpty(message)}`
30
33
 
34
+ /**
35
+ * An approval records exactly the judged tree: HEAD is pinned before the
36
+ * clean check and the commit is built over its tree with `commit-tree`, so no
37
+ * hook runs and none can add to it. A HEAD that moved fails the guarded
38
+ * `update-ref`. `commit-tree` ignores `commit.gpgSign`, so it is read here.
39
+ */
40
+ export const commitClean = (message: string): string =>
41
+ [
42
+ `gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
43
+ `if [ -n "$(git status --porcelain --untracked-files=all --ignore-submodules=none)" ]; then`,
44
+ ` printf '%s\\n' ${shellQuote(CHANGED_SINCE_LAND)} >&2`,
45
+ ` exit 1`,
46
+ `fi`,
47
+ `gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
48
+ `if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
49
+ `gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
50
+ `git update-ref -m ${shellQuote(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head" || exit 1`,
51
+ ].join("\n")
52
+
31
53
  /** No current caller — see `Git.ts`'s `GitWriterOperations.commitAsIs` for why it's kept anyway. */
32
54
  export const commitAsIs = (message: string): string => commitAllowEmpty(message)
33
55
 
@@ -70,7 +92,7 @@ export const keepTurn = (refPrefix: string, message: string): string => {
70
92
  `gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
71
93
  `git update-ref ${keptRef} "$gtd_kept" || exit 1`,
72
94
  `gtd_marker=$(printf '%s\\nGtd-Kept: %s' ${shellQuote(message)} "$gtd_kept")`,
73
- `if ! gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m "$gtd_marker") || ! git update-ref HEAD "$gtd_tip" "$gtd_head"; then`,
95
+ `if ! gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m "$gtd_marker") || ! git update-ref -m ${shellQuote(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head"; then`,
74
96
  ` git reset -q && git update-ref -d ${keptRef}`,
75
97
  ` exit 1`,
76
98
  `fi`,
@@ -129,6 +151,8 @@ const renderGitWrite = (write: GitWrite): string => {
129
151
  switch (write.kind) {
130
152
  case "commitAll":
131
153
  return commitAll(write.message)
154
+ case "commitClean":
155
+ return commitClean(write.message)
132
156
  case "updateRef":
133
157
  return updateRef(write.ref, write.target)
134
158
  case "keepTurn":
package/src/emit/index.ts CHANGED
@@ -15,6 +15,8 @@ export {
15
15
  shellQuote,
16
16
  commitAll,
17
17
  commitAsIs,
18
+ commitClean,
19
+ CHANGED_SINCE_LAND,
18
20
  softResetTo,
19
21
  mixedResetTo,
20
22
  hardResetTo,
@@ -2,6 +2,7 @@ import {
2
2
  changes,
3
3
  codeThreads,
4
4
  head,
5
+ human,
5
6
  judge,
6
7
  matches,
7
8
  openQuestions,
@@ -250,3 +251,30 @@ export const judgeDiffShape = async (
250
251
  : "refactor-first"
251
252
  return { choice, strays, unreadable: [], judgment }
252
253
  }
254
+
255
+ const displayPath = (path: string): string =>
256
+ // eslint-disable-next-line no-control-regex -- the control range IS the point
257
+ /[\x00-\x1f\x7f-\x9f]/.test(path) ? JSON.stringify(path) : path
258
+
259
+ const seamReport = (shape: DiffShapeResult): string => {
260
+ const unreadable = new Set(shape.unreadable)
261
+ const strays = shape.strays.map(
262
+ (path) => `- ${displayPath(path)}${unreadable.has(path) ? " (unreadable — not judged)" : ""}`,
263
+ )
264
+ return [
265
+ "Seam report: the judge found the diff's shape needs a refactor first. These paths stray outside the allowlist:",
266
+ "",
267
+ ...strays,
268
+ "",
269
+ "Landing a clean tree with `gtd land` approves this diff exactly as judged; edits are refused. To stop instead, run `gtd abandon`.",
270
+ ].join("\n")
271
+ }
272
+
273
+ /**
274
+ * Rest at an approval gate `name` showing the seam report — only when
275
+ * `shape.choice` is `refactor-first`. `land` and `reject` reach no step.
276
+ */
277
+ export const seamGate = async (name: string, shape: DiffShapeResult): Promise<void> => {
278
+ if (shape.choice !== "refactor-first") return
279
+ await human(name, { message: seamReport(shape), approve: true })
280
+ }
@@ -39,6 +39,13 @@ export interface HumanOptions extends SteeringOptions {
39
39
  * Without this a clean landing is a no-op: the gate waits for a change.
40
40
  */
41
41
  readonly acceptClean?: boolean | undefined
42
+ /**
43
+ * A sign-off gate: only a clean landing completes it, and that commit
44
+ * carries `Gtd-Approved: {"step":"<step>#<n>","name":"<name>","email":"<email>"}`.
45
+ * A dirty landing is refused. Implies `acceptClean`. Cannot be combined with
46
+ * `mode` or `file`: an approval records exactly the judged tree.
47
+ */
48
+ readonly approve?: boolean | undefined
42
49
  }
43
50
 
44
51
  export interface RunOptions extends SteeringOptions {}
@@ -246,7 +246,8 @@ skills, \`skills\` (an array of the skill names, already trimmed and split —
246
246
  (\`{read, write}\`, each an array of globs or \`null\` for unrestricted). When set: \`model\`, \`memory\`,
247
247
  \`label\`, \`file\`, \`mode\`, \`edges\`, \`judge\` (the pending judgment's rendered
248
248
  document). When a cost has been recorded (a prior \`gtd land --cost=<n>\`):
249
- \`cost\`, \`costByModel\`.
249
+ \`cost\`, \`costByModel\`. Always \`approvals\` (an array of
250
+ \`{step, name, email, commit}\`, \`[]\` when none).
250
251
 
251
252
  \`gtd land\` fields, in fixed order: \`schema\`, \`script\` (the POSIX sh to run), \`settled\`
252
253
  (true for a no-op at a \`script\` rest — stop immediately, nothing more to