@pmelab/gtd 27.3.0 → 27.5.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 (79) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +22 -1
  3. package/dist/chunks/{Schema-CyImoHTj.mjs → Schema-DO5ttJaI.mjs} +1 -1
  4. package/dist/chunks/{reserved-BPdVZkgv.mjs → reserved-DM5W_LAt.mjs} +64 -3
  5. package/dist/chunks/{tail-iLAMoigl.mjs → tail-6gvLXXcg.mjs} +1 -1
  6. package/dist/chunks/{wire-BY1S-obW.mjs → wire-C12dahrH.mjs} +6 -4
  7. package/dist/flows.mjs +27 -2
  8. package/dist/gtd.bundle.mjs +218 -32
  9. package/dist/testkit.mjs +358 -37
  10. package/dist/wire.mjs +1 -1
  11. package/dist/workflows/cleanup.mjs +2 -2
  12. package/dist/workflows/feature.mjs +140 -64
  13. package/dist/workflows/fix.mjs +2 -2
  14. package/dist/workflows/review.mjs +2 -2
  15. package/dist/workflows/rollout.mjs +1 -1
  16. package/dist/workflows.mjs +346 -107
  17. package/package.json +1 -1
  18. package/src/cli/Cli.test.ts +49 -1
  19. package/src/cli/Cli.ts +25 -8
  20. package/src/cli/History.ts +5 -0
  21. package/src/cli/Program.ts +1 -0
  22. package/src/edge/Edge.test.ts +205 -1
  23. package/src/edge/Edge.ts +105 -12
  24. package/src/emit/GitScript.test.ts +39 -3
  25. package/src/emit/GitScript.ts +25 -1
  26. package/src/emit/index.ts +2 -0
  27. package/src/flags/index.test.ts +14 -1
  28. package/src/flags/index.ts +14 -3
  29. package/src/flows/helpers.ts +34 -2
  30. package/src/flows/runtime.ts +7 -0
  31. package/src/install/Install.ts +2 -1
  32. package/src/platform/Git.test.ts +25 -1
  33. package/src/platform/Git.ts +30 -0
  34. package/src/platform/index.ts +1 -0
  35. package/src/replay/Format.test.ts +16 -1
  36. package/src/replay/Replay.test.ts +91 -0
  37. package/src/replay/Replay.ts +22 -2
  38. package/src/replay/Trailers.test.ts +110 -1
  39. package/src/replay/Trailers.ts +37 -1
  40. package/src/replay/index.ts +2 -0
  41. package/src/replay/judgeDiffShape.test.ts +43 -0
  42. package/src/replay/seamGate.test.ts +62 -0
  43. package/src/step/LandStep.ts +1 -0
  44. package/src/step/planStart.ts +3 -2
  45. package/src/step/planStep.test.ts +32 -0
  46. package/src/step/planStep.ts +3 -1
  47. package/src/testkit/EmittedScriptRecognizer.test.ts +27 -0
  48. package/src/testkit/EmittedScriptRecognizer.ts +12 -0
  49. package/src/testkit/FakeGitOperations.ts +5 -0
  50. package/src/testkit/GitTiers.ts +158 -0
  51. package/src/testkit/InMemRepo.ts +38 -3
  52. package/src/testkit/TestRepo.ts +3 -0
  53. package/src/testkit/steps.ts +18 -0
  54. package/src/wire/BeatDocument.test.ts +25 -0
  55. package/src/wire/BeatDocument.ts +4 -2
  56. package/src/wire/BeatPlain.test.ts +1 -0
  57. package/src/wire/BeatStatus.test.ts +2 -0
  58. package/src/wire/BeatStatus.ts +13 -2
  59. package/src/wire/WireSchema.test.ts +10 -0
  60. package/src/wire/types.ts +8 -0
  61. package/src/workflow/Workflow.ts +1 -0
  62. package/src/workflow/index.ts +8 -1
  63. package/src/workflow/vars.test.ts +23 -1
  64. package/src/workflow/vars.ts +24 -0
  65. package/src/workflows/feature/feature.ts +9 -2
  66. package/src/workflows/feature/index.ts +1 -0
  67. package/src/workflows/feature/seam-pin.feature +52 -0
  68. package/src/workflows/feature/seam.feature +387 -0
  69. package/src/workflows/feature/seam.test.ts +88 -0
  70. package/src/workflows/feature/seam.ts +110 -0
  71. package/src/workflows/index.ts +1 -0
  72. package/src/workflows/shared/options.test.ts +12 -1
  73. package/src/workflows/shared/options.ts +1 -0
  74. package/src/workflows/shared/skills.test.ts +20 -2
  75. package/src/workflows/shared/skills.ts +6 -0
  76. package/src/workflows/shared/text.test.ts +59 -0
  77. package/src/workflows/shared/text.ts +86 -0
  78. package/src/workflows/shared/vars.ts +4 -0
  79. package/src/workflows/shared/workflows.steps.ts +39 -1
@@ -22,7 +22,7 @@ import { GTD_VERSION } from "../install/index.js"
22
22
  import { keptRef, processHistoryRef } from "../platform/index.js"
23
23
  import { GitService, Workspace, type GitOperations, type WorkspaceOps } from "../platform/index.js"
24
24
  import { formatCommitMessage, type CommitSpec } from "../replay/index.js"
25
- import { contentHash } from "../workflow/index.js"
25
+ import { contentHash, StartFlags } from "../workflow/index.js"
26
26
  import {
27
27
  fakeGitOperations,
28
28
  InMemRepo,
@@ -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,
@@ -780,6 +781,50 @@ export const defaults = { deployTarget: "npm test", reviewer: "nobody" }
780
781
  })
781
782
  })
782
783
 
784
+ describe("StartFlags", () => {
785
+ const FLAGGED = `import { agent, human } from "@pmelab/gtd/flows"
786
+
787
+ export default async () => {
788
+ await human("idle")
789
+ }
790
+
791
+ export const side = async () => {
792
+ await agent("working", "w")
793
+ }
794
+
795
+ export const defaults = { flag_naive_pass: "0", other: "x" }
796
+ `
797
+ const withFlags = <A>(
798
+ eff: Effect.Effect<A, Error, RestRequirements>,
799
+ repo: InMemRepo,
800
+ flags: Record<string, string>,
801
+ ): Promise<A> =>
802
+ Effect.runPromise(
803
+ eff.pipe(Effect.provideService(StartFlags, flags), Effect.provide(testLayers(repo, {}))),
804
+ )
805
+
806
+ it("a starting process takes the declared flag from StartFlags, never an undeclared one", async () => {
807
+ const rest = await withFlags(currentRest, repoWith(FLAGGED), {
808
+ flag_naive_pass: "1",
809
+ flag_other: "1",
810
+ })
811
+ expect(rest.vars).toEqual({ flag_naive_pass: "1", other: "x" })
812
+ })
813
+
814
+ it("a running process missing the pin resolves the declared default, never the live flag", async () => {
815
+ const repo = repoWith(FLAGGED.replace('flag_naive_pass: "0", ', ""))
816
+ await enter(repo, "side")
817
+ // a process started before the flag existed: its opening commit lacks the pin
818
+ repo.writeFile("gtd.config.ts", FLAGGED)
819
+ const rest = await withFlags(currentRest, repo, { flag_naive_pass: "1" })
820
+ expect(rest.vars.flag_naive_pass).toBe("0")
821
+ })
822
+
823
+ it("with no StartFlags provided, resolves the declared default", async () => {
824
+ expect((await provide(currentRest, repoWith(FLAGGED))).vars.flag_naive_pass).toBe("0")
825
+ })
826
+ })
827
+
783
828
  describe("environment settings", () => {
784
829
  const ENVV = `import { agent, human } from "@pmelab/gtd/flows"
785
830
 
@@ -1306,6 +1351,28 @@ export default async () => {
1306
1351
  expect(trail.steps.filter((s) => s.verdicts.length > 0)).toHaveLength(1)
1307
1352
  })
1308
1353
 
1354
+ it("sets approval on the approval step only", async () => {
1355
+ const repo = repoWith(`import { human } from "@pmelab/gtd/flows"
1356
+
1357
+ export default async () => {
1358
+ await human("idle")
1359
+ await human("gate", { message: "seam report", approve: true })
1360
+ await human("after")
1361
+ }
1362
+ `)
1363
+ await land(repo, { "src/a.ts": "a\n" })
1364
+ const approval = await land(repo)
1365
+ const tip = await land(repo, { "src/b.ts": "b\n" })
1366
+ const trail = await provide(historyTrail(tip), repo)
1367
+ expect(trail.steps.find((s) => s.hash === approval)?.approval).toEqual({
1368
+ step: "gate#1",
1369
+ name: "Test",
1370
+ email: "test@test.com",
1371
+ commit: approval,
1372
+ })
1373
+ expect(trail.steps.filter((s) => s.approval !== undefined)).toHaveLength(1)
1374
+ })
1375
+
1309
1376
  it("records the version and warns about none while they match", async () => {
1310
1377
  const { repo, tip } = await finished()
1311
1378
  const trail = await provide(historyTrail(tip), repo)
@@ -1369,3 +1436,140 @@ export default async () => {
1369
1436
  expect(String(Exit.isFailure(exit) && exit.cause)).toContain("gtd.config.ts")
1370
1437
  })
1371
1438
  })
1439
+
1440
+ describe("landing at an approve rest", () => {
1441
+ const GATED = `import { human } from "@pmelab/gtd/flows"
1442
+
1443
+ export default async () => {
1444
+ await human("idle")
1445
+ await human("gate", { message: "seam report", approve: true })
1446
+ await human("after")
1447
+ }
1448
+ `
1449
+
1450
+ const atGate = async (): Promise<InMemRepo> => {
1451
+ const repo = repoWith(GATED)
1452
+ await land(repo, { "src/a.ts": "a\n" })
1453
+ return repo
1454
+ }
1455
+
1456
+ const snapshotAt = async (repo: InMemRepo, git?: GitOperations) => {
1457
+ const rest = await provide(currentRest, repo)
1458
+ const effect = snapshotFromRest(rest)
1459
+ return git === undefined ? await provide(effect, repo) : await provideWithGit(effect, git, repo)
1460
+ }
1461
+
1462
+ it("rests at a message step carrying approve", async () => {
1463
+ const rest = await provide(currentRest, await atGate())
1464
+ expect(rest.state).toBe("gate")
1465
+ expect(rest.stepDef).toMatchObject({ kind: "message", content: "seam report", approve: true })
1466
+ })
1467
+
1468
+ it("refuses a dirty tree and commits nothing", async () => {
1469
+ const repo = await atGate()
1470
+ const head = headOf(repo)
1471
+ repo.writeFile("src/edited.ts", "edit\n")
1472
+ const { landing } = await snapshotAt(repo)
1473
+ expect(landing).toEqual({
1474
+ kind: "refusal",
1475
+ message:
1476
+ "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`",
1477
+ })
1478
+ expect(headOf(repo)).toBe(head)
1479
+ })
1480
+
1481
+ const stageThenRevert = (repo: InMemRepo): void => {
1482
+ repo.writeFile("src/a.ts", "edited\n")
1483
+ repo.stageAll()
1484
+ repo.writeFile("src/a.ts", "a\n")
1485
+ }
1486
+
1487
+ it("refuses an edit staged and then reverted in the worktree", async () => {
1488
+ const repo = await atGate()
1489
+ stageThenRevert(repo)
1490
+ expect((await snapshotAt(repo)).landing).toMatchObject({
1491
+ kind: "refusal",
1492
+ message: expect.stringContaining("approves exactly the judged diff"),
1493
+ })
1494
+ })
1495
+
1496
+ it("counts no index-only edit at a rest that approves nothing", async () => {
1497
+ const repo = await atGate()
1498
+ await land(repo)
1499
+ stageThenRevert(repo)
1500
+ const rest = await provide(currentRest, repo)
1501
+ expect(rest.state).toBe("after")
1502
+ expect(rest.changes).toEqual([])
1503
+ expect((await snapshotAt(repo)).landing).toEqual({ kind: "noop", settled: false })
1504
+ })
1505
+
1506
+ it("refuses a clean tree when git has no author identity", async () => {
1507
+ const repo = await atGate()
1508
+ const git = { ...fakeGitOperations(repo), authorIdent: () => Effect.succeed(Option.none()) }
1509
+ const { landing } = await snapshotAt(repo, git)
1510
+ expect(landing).toEqual({
1511
+ kind: "refusal",
1512
+ message:
1513
+ "gtd land: approval-gate: gate#1 records who approved it, but git has no author identity — set user.name and user.email",
1514
+ })
1515
+ })
1516
+
1517
+ it("refuses an identity a trailer cannot carry", async () => {
1518
+ const repo = await atGate()
1519
+ const git = {
1520
+ ...fakeGitOperations(repo),
1521
+ authorIdent: () => Effect.succeed(Option.some({ name: "Ada\u001b[31m", email: "a@x.io" })),
1522
+ }
1523
+ expect((await snapshotAt(repo, git)).landing).toEqual({
1524
+ kind: "refusal",
1525
+ message:
1526
+ "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)",
1527
+ })
1528
+ })
1529
+
1530
+ it("approve implies acceptClean even when acceptClean is false", async () => {
1531
+ const repo = repoWith(GATED.replace("approve: true", "approve: true, acceptClean: false"))
1532
+ await land(repo, { "src/a.ts": "a\n" })
1533
+ expect((await snapshotAt(repo)).landing.kind).toBe("commit")
1534
+ })
1535
+
1536
+ it("lands a clean tree as a commit carrying the approver", async () => {
1537
+ const repo = await atGate()
1538
+ const { landing } = await snapshotAt(repo)
1539
+ if (landing.kind !== "commit") throw new Error(`expected commit, got ${landing.kind}`)
1540
+ expect(landing.to).toBe("after")
1541
+ expect(landing.spec.approved).toEqual({
1542
+ step: { name: "gate", occurrence: 1 },
1543
+ name: "Test",
1544
+ email: "test@test.com",
1545
+ })
1546
+ })
1547
+
1548
+ it("reads the author identity only at an approve rest", async () => {
1549
+ const repo = repoWith(GATED)
1550
+ repo.writeFile("src/a.ts", "a\n")
1551
+ const git = {
1552
+ ...fakeGitOperations(repo),
1553
+ authorIdent: () => Effect.die(new Error("authorIdent read at a plain rest")),
1554
+ }
1555
+ expect((await snapshotAt(repo, git)).landing.kind).toBe("commit")
1556
+ })
1557
+
1558
+ it("collects each approval of the current process, oldest first", async () => {
1559
+ const repo = await atGate()
1560
+ expect((await provide(currentRun, repo)).approvals).toEqual([])
1561
+ const approval = await land(repo)
1562
+ expect((await provide(currentRun, repo)).approvals).toEqual([
1563
+ { step: "gate#1", name: "Test", email: "test@test.com", commit: approval },
1564
+ ])
1565
+ })
1566
+
1567
+ it("resumes past the approval commit without asking again", async () => {
1568
+ const repo = await atGate()
1569
+ await land(repo)
1570
+ expect(repo.lastCommitMessage()).toContain(
1571
+ 'Gtd-Approved: {"step":"gate#1","name":"Test","email":"test@test.com"}',
1572
+ )
1573
+ expect((await provide(currentRest, repo)).state).toBe("after")
1574
+ })
1575
+ })
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,
@@ -15,6 +16,8 @@ import {
15
16
  contentHash,
16
17
  multilineSetting,
17
18
  resolveVars,
19
+ resolveStartVars,
20
+ StartFlags,
18
21
  resolveScopeAccess,
19
22
  resolveScopeSkills,
20
23
  type ConfigFingerprint,
@@ -29,7 +32,9 @@ import {
29
32
  } from "../workflow/index.js"
30
33
  import {
31
34
  formatFault,
35
+ formatStepId,
32
36
  formatSubject,
37
+ isRecordableApproval,
33
38
  parseCommitMessage,
34
39
  replay,
35
40
  treeFromRecord,
@@ -41,7 +46,14 @@ import {
41
46
  } from "../replay/index.js"
42
47
  import type { ScopeAccess } from "../flows/index.js"
43
48
  import { steeringFormatFor } from "../steering/index.js"
44
- import { judgeJson, UNATTRIBUTED_MODEL, type ModelCost, type StepAccess } from "../wire/index.js"
49
+ import {
50
+ judgeJson,
51
+ UNATTRIBUTED_MODEL,
52
+ type ApprovalEntry,
53
+ type ModelCost,
54
+ type StepAccess,
55
+ } from "../wire/index.js"
56
+
45
57
  import { GTD_VERSION } from "../install/index.js"
46
58
  import { keptRef, processHistoryRef } from "../platform/index.js"
47
59
  import { isAbsolute, relative, resolve, sep } from "node:path"
@@ -141,6 +153,8 @@ export interface ProcessRun {
141
153
  readonly trace: readonly TraceEntry[]
142
154
  readonly costEntries: readonly CostEntry[]
143
155
  readonly judgeVerdicts: readonly JudgeVerdictEntry[]
156
+ /** Oldest first. */
157
+ readonly approvals: readonly ApprovalEntry[]
144
158
  /** The process settings recorded in the process's first commit (empty for a process started before pinning existed). */
145
159
  readonly pinnedVars: Record<string, string>
146
160
  /** Absent for a process started before recording existed. */
@@ -228,6 +242,21 @@ const pinnedConfigOf = (history: History): ProcessRun["pinnedConfig"] => {
228
242
  return undefined
229
243
  }
230
244
 
245
+ const approvalEntryOf = (
246
+ hash: string,
247
+ message: ReturnType<typeof parseCommitMessage>,
248
+ ): readonly ApprovalEntry[] =>
249
+ message.approved === undefined
250
+ ? []
251
+ : [
252
+ {
253
+ step: formatStepId(message.approved.step),
254
+ name: message.approved.name,
255
+ email: message.approved.email,
256
+ commit: hash,
257
+ },
258
+ ]
259
+
231
260
  const buildRun = (
232
261
  history: History,
233
262
  location: EpisodeLocation,
@@ -247,6 +276,7 @@ const buildRun = (
247
276
  trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i]!)),
248
277
  costEntries: parsed.flatMap(costEntriesOf),
249
278
  judgeVerdicts: parsed.flatMap((m) => m.judge),
279
+ approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i]!)),
250
280
  pinnedVars: { ...first?.vars },
251
281
  pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
252
282
  headTurn: headTurnOf(head),
@@ -534,9 +564,11 @@ const settingsFor = (
534
564
  pinnedVars: Readonly<Record<string, string>>,
535
565
  hostEnv: Readonly<Record<string, string | undefined>>,
536
566
  startOverrides: Readonly<Record<string, string>> = {},
567
+ /** The live flags a STARTING process pins; a running one passes `{}` and replays its pins. */
568
+ startFlags: Readonly<Record<string, string>>,
537
569
  ): { readonly vars: Record<string, string>; readonly env: Record<string, string> } => ({
538
570
  vars: {
539
- ...resolveVars(workflow.vars, startOverrides, hostEnv),
571
+ ...resolveStartVars(workflow.vars, startFlags, startOverrides, hostEnv),
540
572
  ...pinnedVars,
541
573
  },
542
574
  env: resolveVars(workflow.env, {}, hostEnv),
@@ -729,7 +761,8 @@ const messageDef = (
729
761
  ...common,
730
762
  kind: "message",
731
763
  content: request.options.message ?? request.options.label ?? step.name,
732
- ...optional("acceptClean", request.options.acceptClean),
764
+ ...optional("acceptClean", request.options.approve === true || request.options.acceptClean),
765
+ ...optional("approve", request.options.approve),
733
766
  })
734
767
 
735
768
  const stepDefOf = (step: ReachedStep): StepDef => {
@@ -883,7 +916,14 @@ const setupFor = (
883
916
  Effect.gen(function* () {
884
917
  const resolved = yield* workflowOf(config, run)
885
918
  const def = resolved.def
886
- const { vars, env } = settingsFor(resolved, run.pinnedVars, host.env, startOverrides)
919
+ const startFlags = run.trace.length === 0 ? yield* StartFlags : {}
920
+ const { vars, env } = settingsFor(
921
+ resolved,
922
+ run.pinnedVars,
923
+ host.env,
924
+ startOverrides,
925
+ startFlags,
926
+ )
887
927
  const skills = yield* resolveScopeSkills(def, vars)
888
928
  const access = yield* resolveScopeAccess(def, skills, vars)
889
929
  const budget = yield* Effect.try({
@@ -933,6 +973,17 @@ const setupFor = (
933
973
  return { resolved, def, vars, env, setup }
934
974
  })
935
975
 
976
+ // Only an approval gate counts an index-only edit (one staged, then reverted
977
+ // in the worktree): its approval must cover exactly the judged diff. Every
978
+ // other rest keeps the worktree's view, so a net-zero turn stays an attempt.
979
+ const pendingChanges = (git: GitOperations, withIndex: boolean) =>
980
+ Effect.gen(function* () {
981
+ const worktree = yield* git.changedPaths()
982
+ if (!withIndex) return worktree
983
+ const seen = new Set(worktree.map((e) => e.path))
984
+ return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))]
985
+ })
986
+
936
987
  /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
937
988
  export const restAt = (
938
989
  ref: string | undefined,
@@ -974,7 +1025,7 @@ export const restAt = (
974
1025
  }
975
1026
  const changes =
976
1027
  ref === undefined
977
- ? (yield* git.changedPaths()).map((e) => ({
1028
+ ? (yield* pendingChanges(git, stepDef.approve === true)).map((e) => ({
978
1029
  status: normalizeStatus(e.status),
979
1030
  path: e.path,
980
1031
  }))
@@ -1027,7 +1078,7 @@ export const startRefusal = (
1027
1078
  > =>
1028
1079
  Effect.gen(function* () {
1029
1080
  const host = yield* Host
1030
- const { vars, env } = settingsFor(resolved, {}, host.env, startVars)
1081
+ const { vars, env } = settingsFor(resolved, {}, host.env, startVars, yield* StartFlags)
1031
1082
  const skills = yield* resolveScopeSkills(resolved.def, vars)
1032
1083
  const access = yield* resolveScopeAccess(resolved.def, skills, vars)
1033
1084
  const workspace = rest.setup.workspace
@@ -1107,6 +1158,32 @@ export const restIsIdle = (rest: Rest): boolean =>
1107
1158
 
1108
1159
  const isHumanReviewGate = (def: StepDef): boolean => def.actor === "human" && def.mode === "review"
1109
1160
 
1161
+ // An approval covers exactly the judged diff, so any edit refuses it; the
1162
+ // approver is read only at such a rest, and must be a name a trailer can carry.
1163
+ const approvalRefusal = (rest: Rest, approver: Option.Option<AuthorIdent>): Landing | undefined => {
1164
+ if (rest.stepDef.approve !== true) return undefined
1165
+ const id = rest.step.id
1166
+ if (rest.changes.length > 0) {
1167
+ return {
1168
+ kind: "refusal",
1169
+ 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\``,
1170
+ }
1171
+ }
1172
+ if (Option.isNone(approver)) {
1173
+ return {
1174
+ kind: "refusal",
1175
+ message: `gtd land: approval-gate: ${formatStepId(id)} records who approved it, but git has no author identity — set user.name and user.email`,
1176
+ }
1177
+ }
1178
+ if (!isRecordableApproval({ step: id, ...approver.value })) {
1179
+ return {
1180
+ kind: "refusal",
1181
+ 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)`,
1182
+ }
1183
+ }
1184
+ return undefined
1185
+ }
1186
+
1110
1187
  /** The landing a clean tree decides before any replay, if it decides one. */
1111
1188
  const cleanLanding = (rest: Rest): Landing | undefined => {
1112
1189
  if (rest.changes.length > 0) return undefined
@@ -1182,7 +1259,15 @@ const keptTurn = (rest: Rest) =>
1182
1259
  ? { keepTurn: keptRef(rest.run.startHash, rest.state) }
1183
1260
  : {}
1184
1261
 
1185
- const landingTo = (rest: Rest, to: StateName, branch: Option.Option<string>): Landing => {
1262
+ const approvedTrailer = (rest: Rest, approver: Option.Option<AuthorIdent>) =>
1263
+ Option.isSome(approver) ? { approved: { step: rest.step.id, ...approver.value } } : {}
1264
+
1265
+ const landingTo = (
1266
+ rest: Rest,
1267
+ to: StateName,
1268
+ branch: Option.Option<string>,
1269
+ approver: Option.Option<AuthorIdent>,
1270
+ ): Landing => {
1186
1271
  if (isSettledNoop(rest, to)) return { kind: "noop", settled: true }
1187
1272
  // The landing that leaves the flow's first step starts the process: it
1188
1273
  // pins the process settings every later invocation reads back.
@@ -1202,6 +1287,7 @@ const landingTo = (rest: Rest, to: StateName, branch: Option.Option<string>): La
1202
1287
  from: rest.state,
1203
1288
  to,
1204
1289
  step: rest.step.id,
1290
+ ...approvedTrailer(rest, approver),
1205
1291
  ...(starts ? { vars: rest.vars, config: rest.config } : {}),
1206
1292
  ...(finishes ? closingTrailers(branch) : {}),
1207
1293
  },
@@ -1219,8 +1305,11 @@ const decideLanding = (
1219
1305
  rest: Rest,
1220
1306
  verdicts: readonly JudgeVerdict[] | undefined,
1221
1307
  branch: Option.Option<string>,
1308
+ approver: Option.Option<AuthorIdent>,
1222
1309
  ): Effect.Effect<Landing, Error> =>
1223
1310
  Effect.gen(function* () {
1311
+ const unapproved = approvalRefusal(rest, approver)
1312
+ if (unapproved !== undefined) return unapproved
1224
1313
  const early = cleanLanding(rest)
1225
1314
  if (early !== undefined) return early
1226
1315
  if (rest.stepDef.kind === "prompt" && rest.stepDef.access?.write != null) {
@@ -1239,12 +1328,12 @@ const decideLanding = (
1239
1328
  )
1240
1329
  const to = yield* landingTarget(outcome, rest)
1241
1330
  if (typeof to !== "string") return to
1242
- return landingTo(rest, to, branch)
1331
+ return landingTo(rest, to, branch, approver)
1243
1332
  })
1244
1333
 
1245
1334
  /** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
1246
1335
  export const previewLanding = (rest: Rest): Effect.Effect<StateName | undefined> =>
1247
- decideLanding(rest, undefined, Option.none()).pipe(
1336
+ decideLanding(rest, undefined, Option.none(), Option.none()).pipe(
1248
1337
  Effect.map((landing) => (landing.kind === "commit" ? landing.to : undefined)),
1249
1338
  Effect.catchAll(() => Effect.succeed(undefined)),
1250
1339
  )
@@ -1255,8 +1344,10 @@ export const snapshotFromRest = (
1255
1344
  verdicts?: readonly JudgeVerdict[],
1256
1345
  ): Effect.Effect<RepoSnapshot, Error, Workspace | GitService> =>
1257
1346
  Effect.gen(function* () {
1258
- const branch = yield* (yield* GitService).currentBranch()
1259
- const landing = yield* decideLanding(rest, verdicts, branch)
1347
+ const git = yield* GitService
1348
+ const branch = yield* git.currentBranch()
1349
+ const approver = rest.stepDef.approve === true ? yield* git.authorIdent() : Option.none()
1350
+ const landing = yield* decideLanding(rest, verdicts, branch, approver)
1260
1351
  return {
1261
1352
  stepDef: rest.stepDef,
1262
1353
  state: rest.state,
@@ -1289,7 +1380,7 @@ export const summaryFor = (
1289
1380
  .map((entry) => ({ hash: entry.hash, state: entry.state })),
1290
1381
  processCost: run.costEntries.reduce((sum, entry) => sum + entry.cost, 0),
1291
1382
  processCostByModel: costByModel(run.costEntries),
1292
- ...settingsFor(resolved, run.pinnedVars, host.env),
1383
+ ...settingsFor(resolved, run.pinnedVars, host.env, {}, {}),
1293
1384
  })
1294
1385
  })
1295
1386
 
@@ -1300,6 +1391,7 @@ export interface HistoryStep {
1300
1391
  readonly from: StateName | undefined
1301
1392
  readonly to: StateName
1302
1393
  readonly verdicts: readonly JudgeVerdict[]
1394
+ readonly approval?: ApprovalEntry
1303
1395
  }
1304
1396
 
1305
1397
  export interface HistoryTrail {
@@ -1371,6 +1463,7 @@ const historySteps = (run: ProcessRun): readonly HistoryStep[] => {
1371
1463
  from: message.parsed?.from,
1372
1464
  to: entry.state,
1373
1465
  verdicts: message.judge,
1466
+ ...optional("approval", approvalEntryOf(entry.hash, message)[0]),
1374
1467
  }
1375
1468
  })
1376
1469
  }
@@ -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,
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { FLAGS, resolveFlags, type FlagDeclaration, type Flags } from "./index.js"
2
+ import { FLAGS, flagSettings, resolveFlags, type FlagDeclaration, type Flags } from "./index.js"
3
3
 
4
4
  const registry = [
5
5
  { name: "alpha", owner: "pmelab", expiry: "2020-01-01", kind: "release" },
@@ -92,4 +92,17 @@ describe("FLAGS", () => {
92
92
  })
93
93
  expect(new Set(names).size).toBe(names.length)
94
94
  })
95
+
96
+ it("leaves naive_pass off by default", () => {
97
+ expect(resolveFlags({}).naive_pass).toBe(false)
98
+ })
99
+ })
100
+
101
+ describe("flagSettings", () => {
102
+ it('maps every flag to "1" or "0" under flag_<name>', () => {
103
+ expect(flagSettings({ alpha: true, new_review: false })).toEqual({
104
+ flag_alpha: "1",
105
+ flag_new_review: "0",
106
+ })
107
+ })
95
108
  })