@pmelab/gtd 27.2.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 (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +14 -1
  3. package/dist/chunks/{reserved-B2QaImnN.mjs → reserved-BPdVZkgv.mjs} +29 -4
  4. package/dist/chunks/{tail-BnhIxCYv.mjs → tail-iLAMoigl.mjs} +1 -1
  5. package/dist/chunks/{wire-BY1S-obW.mjs → wire-C12dahrH.mjs} +6 -4
  6. package/dist/flows.mjs +24 -1
  7. package/dist/gtd.bundle.mjs +174 -23
  8. package/dist/testkit.mjs +327 -27
  9. package/dist/wire.mjs +1 -1
  10. package/dist/workflows/cleanup.mjs +2 -2
  11. package/dist/workflows/feature.mjs +119 -6
  12. package/dist/workflows/fix.mjs +2 -2
  13. package/dist/workflows/review.mjs +2 -2
  14. package/dist/workflows/rollout.mjs +1 -1
  15. package/dist/workflows.mjs +146 -8
  16. package/package.json +1 -1
  17. package/src/cli/Cli.ts +5 -5
  18. package/src/cli/History.ts +5 -0
  19. package/src/cli/Program.ts +1 -0
  20. package/src/edge/Edge.test.ts +160 -0
  21. package/src/edge/Edge.ts +90 -8
  22. package/src/emit/GitScript.test.ts +39 -3
  23. package/src/emit/GitScript.ts +25 -1
  24. package/src/emit/index.ts +2 -0
  25. package/src/flows/helpers.ts +28 -0
  26. package/src/flows/runtime.ts +7 -0
  27. package/src/install/Install.ts +2 -1
  28. package/src/platform/Git.test.ts +25 -1
  29. package/src/platform/Git.ts +30 -0
  30. package/src/platform/index.ts +1 -0
  31. package/src/replay/Format.test.ts +16 -1
  32. package/src/replay/Replay.test.ts +91 -0
  33. package/src/replay/Replay.ts +22 -2
  34. package/src/replay/Trailers.test.ts +110 -1
  35. package/src/replay/Trailers.ts +37 -1
  36. package/src/replay/index.ts +2 -0
  37. package/src/replay/seamGate.test.ts +62 -0
  38. package/src/step/LandStep.ts +1 -0
  39. package/src/step/planStep.test.ts +32 -0
  40. package/src/step/planStep.ts +3 -1
  41. package/src/testkit/EmittedScriptRecognizer.test.ts +27 -0
  42. package/src/testkit/EmittedScriptRecognizer.ts +33 -0
  43. package/src/testkit/EmittedScripts.test.ts +9 -0
  44. package/src/testkit/FakeGitOperations.ts +5 -0
  45. package/src/testkit/GitTiers.ts +158 -0
  46. package/src/testkit/InMemRepo.ts +38 -3
  47. package/src/wire/BeatDocument.test.ts +25 -0
  48. package/src/wire/BeatDocument.ts +4 -2
  49. package/src/wire/BeatPlain.test.ts +1 -0
  50. package/src/wire/BeatStatus.test.ts +2 -0
  51. package/src/wire/BeatStatus.ts +13 -2
  52. package/src/wire/WireSchema.test.ts +10 -0
  53. package/src/wire/types.ts +8 -0
  54. package/src/workflow/Workflow.ts +1 -0
  55. package/src/workflows/feature/index.ts +1 -0
  56. package/src/workflows/feature/refactor.test.ts +466 -0
  57. package/src/workflows/feature/refactor.ts +178 -0
  58. package/src/workflows/index.ts +1 -0
  59. package/src/workflows/shared/access.ts +2 -1
  60. package/src/workflows/shared/health.test.ts +67 -3
  61. package/src/workflows/shared/health.ts +5 -3
  62. package/src/workflows/shared/options.test.ts +8 -0
  63. package/src/workflows/shared/options.ts +1 -0
  64. package/src/workflows/shared/skills.test.ts +6 -0
  65. package/src/workflows/shared/skills.ts +5 -1
  66. package/src/workflows/shared/text.ts +35 -0
  67. package/src/workflows/shared/vars.ts +1 -0
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
@@ -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
+ })