@pmelab/gtd 24.2.0 → 24.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 (81) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +41 -6
  3. package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
  4. package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-B9pG93Fc.mjs} +53 -13
  5. package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
  6. package/dist/flows.mjs +9 -1
  7. package/dist/gtd.bundle.mjs +1114 -865
  8. package/dist/steering.mjs +2 -2
  9. package/dist/testkit.mjs +1135 -218
  10. package/dist/workflow-kit.mjs +9 -7
  11. package/dist/workflows/feature.mjs +152 -35
  12. package/dist/workflows/fix.mjs +2 -2
  13. package/dist/workflows/review.mjs +1 -1
  14. package/dist/workflows.mjs +223 -54
  15. package/package.json +1 -1
  16. package/src/cli/Cli.test.ts +57 -0
  17. package/src/cli/Cli.ts +89 -9
  18. package/src/cli/History.test.ts +225 -0
  19. package/src/cli/History.ts +143 -0
  20. package/src/cli/Output.ts +68 -0
  21. package/src/cli/Program.test.ts +10 -9
  22. package/src/cli/Program.ts +24 -72
  23. package/src/edge/Edge.test.ts +261 -3
  24. package/src/edge/Edge.ts +218 -41
  25. package/src/edge/index.ts +1 -0
  26. package/src/emit/GitScript.test.ts +27 -0
  27. package/src/emit/GitScript.ts +18 -2
  28. package/src/emit/index.ts +2 -0
  29. package/src/flows/scripts.ts +6 -0
  30. package/src/platform/Git.test.ts +6 -9
  31. package/src/platform/Git.ts +48 -10
  32. package/src/platform/RetainedHistory.test.ts +14 -0
  33. package/src/platform/RetainedHistory.ts +5 -0
  34. package/src/platform/index.ts +7 -1
  35. package/src/replay/Trailers.test.ts +32 -0
  36. package/src/replay/Trailers.ts +17 -0
  37. package/src/steering/FORMATS.md +1 -1
  38. package/src/steering/SteeringFormat.test.ts +9 -1
  39. package/src/steering/SteeringFormat.ts +7 -0
  40. package/src/steering/contract.test.ts +3 -0
  41. package/src/steering/index.ts +1 -0
  42. package/src/steering/review.test.ts +30 -0
  43. package/src/steering/review.ts +8 -2
  44. package/src/step/LandStep.ts +3 -1
  45. package/src/step/RepoSnapshot.ts +7 -1
  46. package/src/step/planStart.test.ts +6 -3
  47. package/src/step/planStep.test.ts +29 -1
  48. package/src/step/planStep.ts +8 -0
  49. package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
  50. package/src/testkit/EmittedScriptRecognizer.ts +149 -52
  51. package/src/testkit/EmittedScripts.test.ts +166 -0
  52. package/src/testkit/FakeGitOperations.ts +6 -2
  53. package/src/testkit/FlowScripts.test.ts +242 -0
  54. package/src/testkit/FlowScripts.ts +326 -0
  55. package/src/testkit/GitTiers.ts +269 -47
  56. package/src/testkit/InMemRepo.ts +61 -8
  57. package/src/testkit/Layers.ts +10 -2
  58. package/src/testkit/TestRepo.ts +4 -1
  59. package/src/testkit/index.ts +1 -1
  60. package/src/workflow/discovery.test.ts +147 -0
  61. package/src/workflow/discovery.ts +5 -0
  62. package/src/workflow/fingerprint.test.ts +20 -4
  63. package/src/workflow/fingerprint.ts +8 -9
  64. package/src/workflow/index.ts +1 -6
  65. package/src/workflow/load.test.ts +5 -19
  66. package/src/workflow/load.ts +94 -10
  67. package/src/workflow-kit/review.test.ts +84 -0
  68. package/src/workflow-kit/review.ts +9 -6
  69. package/src/workflows/feature/review.test.ts +238 -12
  70. package/src/workflows/feature/review.ts +172 -31
  71. package/src/workflows/feature/steps.test.ts +12 -0
  72. package/src/workflows/feature/steps.ts +8 -1
  73. package/src/workflows/shared/access.test.ts +4 -2
  74. package/src/workflows/shared/access.ts +1 -0
  75. package/src/workflows/shared/options.test.ts +8 -0
  76. package/src/workflows/shared/options.ts +1 -0
  77. package/src/workflows/shared/steps.ts +2 -0
  78. package/src/workflows/shared/tail.ts +3 -11
  79. package/src/workflows/shared/text.test.ts +106 -0
  80. package/src/workflows/shared/text.ts +57 -1
  81. package/src/workflows/shared/vars.ts +1 -0
@@ -1,6 +1,5 @@
1
1
  import { Effect, Option, Schema } from "effect"
2
2
  import {
3
- CommandRunner,
4
3
  GtdUsageError,
5
4
  Narrator,
6
5
  GitService,
@@ -67,7 +66,7 @@ import {
67
66
  openThreads,
68
67
  parseCodeThreads,
69
68
  unansweredQuestions,
70
- type SteeringFinding,
69
+ formatFinding,
71
70
  type SteeringFormat,
72
71
  } from "../steering/index.js"
73
72
  import {
@@ -89,6 +88,20 @@ import {
89
88
  } from "../emit/index.js"
90
89
  import { CONFIG_SCAFFOLD, renderBriefing } from "../install/index.js"
91
90
  import { ToolCommands, type SelfGtd } from "./ToolCommands.js"
91
+ import {
92
+ SelectorUsageError,
93
+ writeSelection,
94
+ type ArtifactOut,
95
+ type CommandRequirements,
96
+ type JsonMode,
97
+ } from "./Output.js"
98
+ export {
99
+ SelectorUsageError,
100
+ type ArtifactOut,
101
+ type CommandRequirements,
102
+ type JsonMode,
103
+ } from "./Output.js"
104
+ import { runHistoryFetchCommand, runHistoryListCommand, runHistoryShowCommand } from "./History.js"
92
105
 
93
106
  /**
94
107
  * `Edge.ts`'s `Rest` is module-private (`.gtd/packages/05-step-core.md`
@@ -98,35 +111,6 @@ import { ToolCommands, type SelfGtd } from "./ToolCommands.js"
98
111
  */
99
112
  type Rest = Effect.Effect.Success<typeof currentRest>
100
113
 
101
- /**
102
- * A command-level failure that should exit like a CLI usage error (2), not a
103
- * generic runtime failure (1): an unknown `--json=<path>` selector, or a
104
- * `gtd judge answer` verdict that fails to decode — malformed JSON on stdin,
105
- * or one naming a question id the pending judgment never asked. Both are
106
- * caller-input errors per `docs/cli.md`'s exit-code table, not a refusal
107
- * about the resolved rest's own state.
108
- */
109
- export class SelectorUsageError extends Error {}
110
-
111
- /** The `--json=<path>` select branch, shared by all three emitting commands so none drift on the unknown-selector message. `absent` writes nothing and lets the caller's success path continue. */
112
- const writeSelection = (
113
- out: ArtifactOut,
114
- fields: unknown,
115
- path: string,
116
- ): Effect.Effect<void, Error> =>
117
- Effect.gen(function* () {
118
- const selection = selectPath(fields, path)
119
- if (selection.kind === "value") {
120
- out.write(`${selection.text}\n`)
121
- } else if (selection.kind === "unknown") {
122
- return yield* Effect.fail(
123
- new SelectorUsageError(
124
- `gtd: unknown --json selector "${selection.path}" — see \`gtd --help\``,
125
- ),
126
- )
127
- }
128
- })
129
-
130
114
  export type Command =
131
115
  | { readonly kind: "lsp" }
132
116
  | {
@@ -174,6 +158,9 @@ export type Command =
174
158
  | { readonly kind: "base" }
175
159
  | { readonly kind: "judge" }
176
160
  | { readonly kind: "judgeAnswer" }
161
+ | { readonly kind: "historyList" }
162
+ | { readonly kind: "historyShow"; readonly ref: string }
163
+ | { readonly kind: "historyFetch"; readonly remote: string }
177
164
  | {
178
165
  readonly kind: "judgeRun"
179
166
  readonly provider?: "fixed" | "jev" | "llm"
@@ -181,15 +168,6 @@ export type Command =
181
168
  readonly model?: string
182
169
  }
183
170
 
184
- /**
185
- * `--json`'s three shapes: absent, bare (the whole document), or `--json=<path>`
186
- * (one reduced value). See `src/wire/Select.ts` for how `select` is later resolved.
187
- */
188
- export type JsonMode =
189
- | { readonly kind: "off" }
190
- | { readonly kind: "document" }
191
- | { readonly kind: "select"; readonly path: string }
192
-
193
171
  /**
194
172
  * What a command kind needs before it may run. `pure`/`removed` never reach
195
173
  * `io.layers()` at all (they resolve to `output`/`usage` plans). `state`
@@ -199,31 +177,6 @@ export type JsonMode =
199
177
  */
200
178
  export type Needs = "pure" | "removed" | "none" | "fs" | "state"
201
179
 
202
- /**
203
- * The all-or-nothing stdout buffer every `run*Command` handler writes
204
- * through instead of `io.stdout` directly. `flush()` is called exactly once,
205
- * by `runCli`, after the command's Effect succeeds; on any failure `flush()`
206
- * is never reached, so the buffer is simply discarded and nothing reaches
207
- * `io.stdout`. `ui` is the one handler that calls `flush()` itself,
208
- * ahead of blocking on `Effect.never` — since it blocks forever, `runCli`'s
209
- * own flush-on-success would otherwise never fire and its output would never
210
- * reach stdout.
211
- */
212
- export interface ArtifactOut {
213
- readonly write: (chunk: string) => void
214
- readonly flush: () => void
215
- }
216
-
217
- export type CommandRequirements =
218
- | GitService
219
- | ConfigService
220
- | ConfigDiscovery
221
- | Workspace
222
- | Host
223
- | Narrator
224
- | CommandRunner
225
- | ToolCommands
226
-
227
180
  const selfGtd = (): SelfGtd => {
228
181
  const script = process.argv[1]
229
182
  return script === undefined ? ["gtd"] : [process.execPath, script]
@@ -898,13 +851,6 @@ const runValidateCommand = (out: ArtifactOut): Effect.Effect<void, Error, Comman
898
851
  )
899
852
  })
900
853
 
901
- /** `file:line:col: message` — the shape editors and grep already jump on. Stored 0-based, printed 1-based. A finding with a `line` but no `range`, or none at all, degrades to the shorter forms. */
902
- export const formatFinding = (file: string, finding: SteeringFinding): string => {
903
- if (finding.line === undefined) return finding.message
904
- const col = finding.range !== undefined ? `:${finding.range.start.character + 1}` : ""
905
- return `${file}:${finding.line + 1}${col}: ${finding.message}`
906
- }
907
-
908
854
  /**
909
855
  * Runs `<mode>`'s parser over `<file>`, exiting non-zero on any finding.
910
856
  * Resolves no workflow state and reads no config — this is what a seeded
@@ -1289,6 +1235,12 @@ const dispatchVoidCommand = (
1289
1235
  return runJudgeAnswerCommand(json, out)
1290
1236
  case "judgeRun":
1291
1237
  return runJudgeRunCommand(command, out)
1238
+ case "historyList":
1239
+ return runHistoryListCommand(json, out)
1240
+ case "historyShow":
1241
+ return runHistoryShowCommand(command.ref, json, out)
1242
+ case "historyFetch":
1243
+ return runHistoryFetchCommand(command.remote, out)
1292
1244
  }
1293
1245
  }
1294
1246
 
@@ -1,9 +1,11 @@
1
- import { Effect, Exit } from "effect"
1
+ import { Effect, Exit, Option } from "effect"
2
2
  import { describe, expect, it } from "vitest"
3
3
  import { runCli } from "../cli/index.js"
4
4
  import {
5
5
  currentRest,
6
+ closedRunAt,
6
7
  currentRun,
8
+ historyTrail,
7
9
  noProcessUnderway,
8
10
  renderRest,
9
11
  restAt,
@@ -16,8 +18,11 @@ import {
16
18
  UNATTRIBUTED_MODEL,
17
19
  type RestRequirements,
18
20
  } from "./Edge.js"
21
+ import { GTD_VERSION } from "../install/index.js"
22
+ import { processHistoryRef } from "../platform/index.js"
19
23
  import { GitService, Workspace, type GitOperations, type WorkspaceOps } from "../platform/index.js"
20
24
  import { formatCommitMessage, type CommitSpec } from "../replay/index.js"
25
+ import { contentHash } from "../workflow/index.js"
21
26
  import {
22
27
  fakeGitOperations,
23
28
  InMemRepo,
@@ -197,7 +202,7 @@ describe("currentRun", () => {
197
202
  const before = headOf(repo)
198
203
  const opening = await enter(repo, "side")
199
204
  expect(repo.lastCommitMessage()).toBe(
200
- "gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Format: 1",
205
+ `gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Config: ${contentHash(LINEAR)} gtd.config.ts\nGtd-Format: 1`,
201
206
  )
202
207
  const fixed = await land(repo, { "fix.txt": "x\n" })
203
208
  const run = await provide(currentRun, repo)
@@ -229,7 +234,9 @@ export const again = async () => {
229
234
  const repo = repoWith(STARTS_AT_INITIAL)
230
235
  const before = headOf(repo)
231
236
  const opening = await enter(repo, "again")
232
- expect(repo.lastCommitMessage()).toBe("gtd(human): idle\n\nGtd-Workflow: again\nGtd-Format: 1")
237
+ expect(repo.lastCommitMessage()).toBe(
238
+ `gtd(human): idle\n\nGtd-Workflow: again\nGtd-Config: ${contentHash(STARTS_AT_INITIAL)} gtd.config.ts\nGtd-Format: 1`,
239
+ )
233
240
  const fixed = await land(repo, { "fix.txt": "x\n" })
234
241
  const run = await provide(currentRun, repo)
235
242
  expect(run.workflow).toBe("again")
@@ -1014,3 +1021,254 @@ describe("the pending working tree is read at most once per restAt, and never st
1014
1021
  expect(calls()).toBe(2)
1015
1022
  })
1016
1023
  })
1024
+
1025
+ describe("closedRunAt", () => {
1026
+ it("is the run summaryRun reads when HEAD is the tip", async () => {
1027
+ const repo = repoWith(LINEAR)
1028
+ await land(repo, { "a.txt": "a\n" })
1029
+ await land(repo, { "b.txt": "b\n" })
1030
+ const tip = await land(repo, { "c.txt": "c\n" })
1031
+ expect(await provide(closedRunAt(tip), repo)).toEqual(await provide(summaryRun, repo))
1032
+ })
1033
+
1034
+ it("resolves the closed run with HEAD elsewhere", async () => {
1035
+ const repo = repoWith(LINEAR)
1036
+ const building = await land(repo, { "a.txt": "a\n" })
1037
+ await land(repo, { "b.txt": "b\n" })
1038
+ const tip = await land(repo, { "c.txt": "c\n" })
1039
+ const closed = await provide(closedRunAt(tip), repo)
1040
+ repo.writeFile("d.txt", "d\n")
1041
+ repo.commitAllWithPrefix("chore: unrelated")
1042
+ const later = await provide(closedRunAt(tip), repo)
1043
+ expect(later).toEqual(closed)
1044
+ expect(later.closingHash).toBe(tip)
1045
+ expect(later.startHash).toBe(building)
1046
+ })
1047
+ })
1048
+
1049
+ describe("finishing landings keep history", () => {
1050
+ const landingOf = async (repo: InMemRepo, git?: GitOperations) => {
1051
+ const rest = await provide(currentRest, repo)
1052
+ const eff = snapshotFromRest(rest)
1053
+ const snapshot = await (git === undefined ? provide(eff, repo) : provideWithGit(eff, git, repo))
1054
+ const landing = snapshot.landing
1055
+ if (landing.kind !== "commit") throw new Error(`expected commit, got ${landing.kind}`)
1056
+ return landing
1057
+ }
1058
+
1059
+ it("flow return: keeps refs/gtd/history/<startHash>, records version and branch", async () => {
1060
+ const repo = repoWith(LINEAR)
1061
+ const building = await land(repo, { "a.txt": "a\n" })
1062
+ await land(repo, { "b.txt": "b\n" })
1063
+ repo.writeFile("c.txt", "c\n")
1064
+ const landing = await landingOf(repo)
1065
+ expect(landing.to).toBe("idle")
1066
+ expect(landing.keepHistory).toBe(processHistoryRef(building))
1067
+ expect(landing.spec.version).toBe(GTD_VERSION)
1068
+ expect(landing.spec.branch).toBe(repo.branchName())
1069
+ })
1070
+
1071
+ it("restart() is a finish too", async () => {
1072
+ const repo = repoWith(`import { agent, human, restart } from "@pmelab/gtd/flows"
1073
+
1074
+ export default async () => {
1075
+ await human("idle")
1076
+ await agent("building", "build-prompt")
1077
+ await restart()
1078
+ }
1079
+ `)
1080
+ const building = await land(repo, { "a.txt": "a\n" })
1081
+ repo.writeFile("b.txt", "b\n")
1082
+ const landing = await landingOf(repo)
1083
+ expect(landing.to).toBe("idle")
1084
+ expect(landing.keepHistory).toBe(processHistoryRef(building))
1085
+ })
1086
+
1087
+ it("starting landings and mid-process landings carry no keepHistory and no new trailers", async () => {
1088
+ const repo = repoWith(LINEAR)
1089
+ repo.writeFile("a.txt", "a\n")
1090
+ const starting = await landingOf(repo)
1091
+ expect(starting.to).toBe("building")
1092
+ expect(starting.keepHistory).toBeUndefined()
1093
+ expect(starting.spec.version).toBeUndefined()
1094
+ expect(starting.spec.branch).toBeUndefined()
1095
+ await land(repo, { "a.txt": "a\n" })
1096
+ repo.writeFile("b.txt", "b\n")
1097
+ const mid = await landingOf(repo)
1098
+ expect(mid.to).toBe("checking")
1099
+ expect(mid.keepHistory).toBeUndefined()
1100
+ expect(mid.spec.version).toBeUndefined()
1101
+ expect(mid.spec.branch).toBeUndefined()
1102
+ })
1103
+
1104
+ it("detached HEAD keeps history but records no branch", async () => {
1105
+ const repo = repoWith(LINEAR)
1106
+ const building = await land(repo, { "a.txt": "a\n" })
1107
+ await land(repo, { "b.txt": "b\n" })
1108
+ repo.writeFile("c.txt", "c\n")
1109
+ const detached: GitOperations = {
1110
+ ...fakeGitOperations(repo),
1111
+ currentBranch: () => Effect.succeed(Option.none()),
1112
+ }
1113
+ const landing = await landingOf(repo, detached)
1114
+ expect(landing.keepHistory).toBe(processHistoryRef(building))
1115
+ expect(landing.spec.version).toBe(GTD_VERSION)
1116
+ expect(landing.spec.branch).toBeUndefined()
1117
+ })
1118
+
1119
+ it("a --workflow process's entry is its opening commit", async () => {
1120
+ const repo = repoWith(`import { agent, human } from "@pmelab/gtd/flows"
1121
+
1122
+ export default async () => {
1123
+ await human("idle")
1124
+ await agent("building", "build-prompt")
1125
+ }
1126
+
1127
+ export const side = async () => {
1128
+ await agent("fixing", "fix-prompt")
1129
+ }
1130
+ `)
1131
+ const opening = await enter(repo, "side")
1132
+ repo.writeFile("a.txt", "a\n")
1133
+ const landing = await landingOf(repo)
1134
+ expect(landing.to).toBe("idle")
1135
+ expect(landing.keepHistory).toBe(processHistoryRef(opening))
1136
+ })
1137
+
1138
+ it("the landing script overwrites an existing ref with its own commit", async () => {
1139
+ const repo = repoWith(LINEAR)
1140
+ const building = await land(repo, { "a.txt": "a\n" })
1141
+ await land(repo, { "b.txt": "b\n" })
1142
+ const ref = processHistoryRef(building)
1143
+ repo.updateRef(ref, building)
1144
+ const tip = await land(repo, { "c.txt": "c\n" })
1145
+ expect(repo.resolveRef(ref)).toBe(tip)
1146
+ })
1147
+ })
1148
+
1149
+ describe("historyTrail", () => {
1150
+ const finished = async (config = LINEAR) => {
1151
+ const repo = repoWith(config)
1152
+ const building = await land(repo, { "a.txt": "a\n" })
1153
+ const checking = await land(repo, { "b.txt": "b\n" })
1154
+ const tip = await land(repo, { "c.txt": "c\n" })
1155
+ return { repo, building, checking, tip }
1156
+ }
1157
+
1158
+ it("replays every commit of the closed run, with HEAD elsewhere", async () => {
1159
+ const { repo, building, checking, tip } = await finished()
1160
+ repo.writeFile("d.txt", "d\n")
1161
+ repo.commitAllWithPrefix("chore: unrelated")
1162
+ const trail = await provide(historyTrail(tip), repo)
1163
+ expect(trail.tip).toBe(tip)
1164
+ expect(trail.ended).toBe("return")
1165
+ expect(trail.steps.map((s) => s.hash)).toEqual([building, checking, tip])
1166
+ expect(trail.steps[2]).toMatchObject({
1167
+ subject: "gtd(agent): checking → idle",
1168
+ actor: "agent",
1169
+ from: "checking",
1170
+ to: "idle",
1171
+ verdicts: [],
1172
+ })
1173
+ expect(trail.steps[0]).toMatchObject({ actor: "human", to: "building" })
1174
+ })
1175
+
1176
+ it("a restart() ends the trail as restart", async () => {
1177
+ const repo = repoWith(`import { agent, human, restart } from "@pmelab/gtd/flows"
1178
+
1179
+ export default async () => {
1180
+ await human("idle")
1181
+ await agent("building", "build-prompt")
1182
+ await restart()
1183
+ }
1184
+ `)
1185
+ await land(repo, { "a.txt": "a\n" })
1186
+ const tip = await land(repo, { "b.txt": "b\n" })
1187
+ expect((await provide(historyTrail(tip), repo)).ended).toBe("restart")
1188
+ })
1189
+
1190
+ it("carries each judge verdict on the commit that recorded it", async () => {
1191
+ const repo = repoWith(LINEAR)
1192
+ await land(repo, { "a.txt": "a\n" })
1193
+ const verdictCommit = commit(
1194
+ repo,
1195
+ {
1196
+ actor: "agent",
1197
+ from: "building",
1198
+ to: "checking",
1199
+ step: { name: "building", occurrence: 1 },
1200
+ judge: [{ id: "q", answer: "yes", p: 0.9 }],
1201
+ },
1202
+ { "b.txt": "b\n" },
1203
+ )
1204
+ const tip = await land(repo, { "c.txt": "c\n" })
1205
+ const trail = await provide(historyTrail(tip), repo)
1206
+ expect(trail.steps.find((s) => s.hash === verdictCommit)?.verdicts).toEqual([
1207
+ { id: "q", answer: "yes", p: 0.9 },
1208
+ ])
1209
+ expect(trail.steps.filter((s) => s.verdicts.length > 0)).toHaveLength(1)
1210
+ })
1211
+
1212
+ it("records the version and warns about none while they match", async () => {
1213
+ const { repo, tip } = await finished()
1214
+ const trail = await provide(historyTrail(tip), repo)
1215
+ expect(trail.version).toEqual({ recorded: GTD_VERSION, running: GTD_VERSION })
1216
+ expect(trail.versionWarning).toBeUndefined()
1217
+ })
1218
+
1219
+ const withTipMessage = (repo: InMemRepo, edit: (message: string) => string): GitOperations => {
1220
+ const real = fakeGitOperations(repo)
1221
+ return {
1222
+ ...real,
1223
+ lastCommitMessage: (ref) => Effect.map(real.lastCommitMessage(ref), edit),
1224
+ }
1225
+ }
1226
+
1227
+ it("warns, never refuses, when the recorded version differs", async () => {
1228
+ const { repo, tip } = await finished()
1229
+ const git = withTipMessage(repo, (m) =>
1230
+ m.replace(`Gtd-Version: ${GTD_VERSION}`, "Gtd-Version: 0.0.1"),
1231
+ )
1232
+ const trail = await Effect.runPromise(
1233
+ historyTrail(tip).pipe(
1234
+ Effect.provideService(GitService, git),
1235
+ Effect.provide(testLayers(repo, {})),
1236
+ ),
1237
+ )
1238
+ expect(trail.version.recorded).toBe("0.0.1")
1239
+ expect(trail.versionWarning).toContain("0.0.1")
1240
+ expect(trail.versionWarning).toContain(GTD_VERSION)
1241
+ })
1242
+
1243
+ it("a missing recorded version counts as different", async () => {
1244
+ const { repo, tip } = await finished()
1245
+ const git = withTipMessage(repo, (m) => m.replace(/Gtd-Version: .*\n?/, ""))
1246
+ const trail = await Effect.runPromise(
1247
+ historyTrail(tip).pipe(
1248
+ Effect.provideService(GitService, git),
1249
+ Effect.provide(testLayers(repo, {})),
1250
+ ),
1251
+ )
1252
+ expect(trail.version.recorded).toBeUndefined()
1253
+ expect(trail.versionWarning).toContain(GTD_VERSION)
1254
+ })
1255
+
1256
+ it("refuses naming the config file that differs at the tip", async () => {
1257
+ const { repo, tip } = await finished()
1258
+ repo.writeFile("gtd.config.ts", `${LINEAR}\n// edited\n`)
1259
+ repo.commitAllWithPrefix("chore: edit config")
1260
+ const exit = await provideExit(historyTrail(tip), repo)
1261
+ expect(Exit.isFailure(exit)).toBe(true)
1262
+ expect(String(Exit.isFailure(exit) && exit.cause)).toContain("gtd.config.ts")
1263
+ })
1264
+
1265
+ it("refuses naming a config file missing from the tip", async () => {
1266
+ const { repo, tip } = await finished()
1267
+ repo.writeFile("gtd.config.ts", `import "./extra.ts"\n${LINEAR}`)
1268
+ repo.writeFile("extra.ts", "export {}\n")
1269
+ repo.commitAllWithPrefix("chore: split config")
1270
+ const exit = await provideExit(historyTrail(tip), repo)
1271
+ expect(Exit.isFailure(exit)).toBe(true)
1272
+ expect(String(Exit.isFailure(exit) && exit.cause)).toContain("gtd.config.ts")
1273
+ })
1274
+ })