@pmelab/gtd 24.1.0 → 24.3.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 (46) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +37 -10
  3. package/claude/hooks/access.ts +25 -0
  4. package/claude/hooks/assist.ts +135 -0
  5. package/claude/hooks/drive.ts +15 -7
  6. package/claude/hooks/judge.ts +27 -0
  7. package/claude/hooks/register.tsx +294 -135
  8. package/claude/hooks/tools.ts +121 -0
  9. package/claude/hooks/wording.ts +2 -35
  10. package/claude/types/index.d.ts +9 -0
  11. package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
  12. package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-DJwU5C4z.mjs} +1 -1
  13. package/dist/gtd.bundle.mjs +337 -55
  14. package/dist/testkit.mjs +477 -65
  15. package/dist/workflows/feature.mjs +1 -1
  16. package/dist/workflows/fix.mjs +1 -1
  17. package/dist/workflows/review.mjs +1 -1
  18. package/package.json +1 -1
  19. package/src/cli/Cli.test.ts +57 -0
  20. package/src/cli/Cli.ts +89 -9
  21. package/src/cli/History.test.ts +225 -0
  22. package/src/cli/History.ts +143 -0
  23. package/src/cli/Output.ts +68 -0
  24. package/src/cli/Program.test.ts +7 -1
  25. package/src/cli/Program.ts +23 -64
  26. package/src/edge/Edge.test.ts +256 -1
  27. package/src/edge/Edge.ts +217 -39
  28. package/src/edge/index.ts +1 -0
  29. package/src/emit/GitScript.test.ts +27 -0
  30. package/src/emit/GitScript.ts +18 -2
  31. package/src/emit/index.ts +1 -0
  32. package/src/platform/Git.ts +48 -10
  33. package/src/platform/RetainedHistory.test.ts +14 -0
  34. package/src/platform/RetainedHistory.ts +5 -0
  35. package/src/platform/index.ts +7 -1
  36. package/src/replay/Trailers.test.ts +32 -0
  37. package/src/replay/Trailers.ts +17 -0
  38. package/src/step/LandStep.ts +3 -1
  39. package/src/step/RepoSnapshot.ts +7 -1
  40. package/src/step/planStart.test.ts +6 -3
  41. package/src/step/planStep.test.ts +29 -1
  42. package/src/step/planStep.ts +8 -0
  43. package/src/testkit/EmittedScriptRecognizer.ts +1 -1
  44. package/src/testkit/FakeGitOperations.ts +6 -2
  45. package/src/testkit/GitTiers.ts +133 -2
  46. package/src/testkit/InMemRepo.ts +26 -6
@@ -0,0 +1,68 @@
1
+ import { Effect } from "effect"
2
+ import { CommandRunner, GitService, Host, Narrator, Workspace } from "../platform/index.js"
3
+ import { selectPath } from "../wire/index.js"
4
+ import { ConfigDiscovery, ConfigService } from "../workflow/index.js"
5
+ import type { ToolCommands } from "./ToolCommands.js"
6
+
7
+ /**
8
+ * A command-level failure that should exit like a CLI usage error (2), not a
9
+ * generic runtime failure (1): an unknown `--json=<path>` selector, or a
10
+ * `gtd judge answer` verdict that fails to decode — malformed JSON on stdin,
11
+ * or one naming a question id the pending judgment never asked. Both are
12
+ * caller-input errors per `docs/cli.md`'s exit-code table, not a refusal
13
+ * about the resolved rest's own state.
14
+ */
15
+ export class SelectorUsageError extends Error {}
16
+
17
+ /** The `--json=<path>` select branch, shared by the emitting commands so none drift on the unknown-selector message. `absent` writes nothing and lets the caller's success path continue. */
18
+ export const writeSelection = (
19
+ out: ArtifactOut,
20
+ fields: unknown,
21
+ path: string,
22
+ ): Effect.Effect<void, Error> =>
23
+ Effect.gen(function* () {
24
+ const selection = selectPath(fields, path)
25
+ if (selection.kind === "value") {
26
+ out.write(`${selection.text}\n`)
27
+ } else if (selection.kind === "unknown") {
28
+ return yield* Effect.fail(
29
+ new SelectorUsageError(
30
+ `gtd: unknown --json selector "${selection.path}" — see \`gtd --help\``,
31
+ ),
32
+ )
33
+ }
34
+ })
35
+
36
+ /**
37
+ * `--json`'s three shapes: absent, bare (the whole document), or `--json=<path>`
38
+ * (one reduced value). See `src/wire/Select.ts` for how `select` is later resolved.
39
+ */
40
+ export type JsonMode =
41
+ | { readonly kind: "off" }
42
+ | { readonly kind: "document" }
43
+ | { readonly kind: "select"; readonly path: string }
44
+
45
+ /**
46
+ * The all-or-nothing stdout buffer every `run*Command` handler writes
47
+ * through instead of `io.stdout` directly. `flush()` is called exactly once,
48
+ * by `runCli`, after the command's Effect succeeds; on any failure `flush()`
49
+ * is never reached, so the buffer is simply discarded and nothing reaches
50
+ * `io.stdout`. `ui` is the one handler that calls `flush()` itself,
51
+ * ahead of blocking on `Effect.never` — since it blocks forever, `runCli`'s
52
+ * own flush-on-success would otherwise never fire and its output would never
53
+ * reach stdout.
54
+ */
55
+ export interface ArtifactOut {
56
+ readonly write: (chunk: string) => void
57
+ readonly flush: () => void
58
+ }
59
+
60
+ export type CommandRequirements =
61
+ | GitService
62
+ | ConfigService
63
+ | ConfigDiscovery
64
+ | Workspace
65
+ | Host
66
+ | Narrator
67
+ | CommandRunner
68
+ | ToolCommands
@@ -1697,6 +1697,9 @@ describe("runCommand — refuses in a repository with no commits", () => {
1697
1697
  judge: { kind: "judge" },
1698
1698
  judgeAnswer: { kind: "judgeAnswer" },
1699
1699
  judgeRun: { kind: "judgeRun", provider: "fixed" },
1700
+ historyList: { kind: "historyList" },
1701
+ historyShow: { kind: "historyShow", ref: "x" },
1702
+ historyFetch: { kind: "historyFetch", remote: "origin" },
1700
1703
  }
1701
1704
 
1702
1705
  const stateKinds = (Object.keys(commandFor) as Command["kind"][]).filter(
@@ -1706,7 +1709,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
1706
1709
  const NO_COMMITS_MESSAGE =
1707
1710
  "gtd requires a repository with at least one commit — make an initial commit, then run gtd again"
1708
1711
 
1709
- it("derives exactly the thirteen non-standalone kinds — a canary for the table-driven cases below", () => {
1712
+ it("derives exactly the sixteen non-standalone kinds — a canary for the table-driven cases below", () => {
1710
1713
  expect(stateKinds.sort()).toEqual(
1711
1714
  [
1712
1715
  "abandon",
@@ -1722,6 +1725,9 @@ describe("runCommand — refuses in a repository with no commits", () => {
1722
1725
  "ui",
1723
1726
  "judge",
1724
1727
  "judgeAnswer",
1728
+ "historyList",
1729
+ "historyShow",
1730
+ "historyFetch",
1725
1731
  ].sort(),
1726
1732
  )
1727
1733
  })
@@ -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,
@@ -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]
@@ -1289,6 +1242,12 @@ const dispatchVoidCommand = (
1289
1242
  return runJudgeAnswerCommand(json, out)
1290
1243
  case "judgeRun":
1291
1244
  return runJudgeRunCommand(command, out)
1245
+ case "historyList":
1246
+ return runHistoryListCommand(json, out)
1247
+ case "historyShow":
1248
+ return runHistoryShowCommand(command.ref, json, out)
1249
+ case "historyFetch":
1250
+ return runHistoryFetchCommand(command.remote, out)
1292
1251
  }
1293
1252
  }
1294
1253
 
@@ -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,6 +18,8 @@ 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"
21
25
  import {
@@ -1014,3 +1018,254 @@ describe("the pending working tree is read at most once per restAt, and never st
1014
1018
  expect(calls()).toBe(2)
1015
1019
  })
1016
1020
  })
1021
+
1022
+ describe("closedRunAt", () => {
1023
+ it("is the run summaryRun reads when HEAD is the tip", async () => {
1024
+ const repo = repoWith(LINEAR)
1025
+ await land(repo, { "a.txt": "a\n" })
1026
+ await land(repo, { "b.txt": "b\n" })
1027
+ const tip = await land(repo, { "c.txt": "c\n" })
1028
+ expect(await provide(closedRunAt(tip), repo)).toEqual(await provide(summaryRun, repo))
1029
+ })
1030
+
1031
+ it("resolves the closed run with HEAD elsewhere", async () => {
1032
+ const repo = repoWith(LINEAR)
1033
+ const building = await land(repo, { "a.txt": "a\n" })
1034
+ await land(repo, { "b.txt": "b\n" })
1035
+ const tip = await land(repo, { "c.txt": "c\n" })
1036
+ const closed = await provide(closedRunAt(tip), repo)
1037
+ repo.writeFile("d.txt", "d\n")
1038
+ repo.commitAllWithPrefix("chore: unrelated")
1039
+ const later = await provide(closedRunAt(tip), repo)
1040
+ expect(later).toEqual(closed)
1041
+ expect(later.closingHash).toBe(tip)
1042
+ expect(later.startHash).toBe(building)
1043
+ })
1044
+ })
1045
+
1046
+ describe("finishing landings keep history", () => {
1047
+ const landingOf = async (repo: InMemRepo, git?: GitOperations) => {
1048
+ const rest = await provide(currentRest, repo)
1049
+ const eff = snapshotFromRest(rest)
1050
+ const snapshot = await (git === undefined ? provide(eff, repo) : provideWithGit(eff, git, repo))
1051
+ const landing = snapshot.landing
1052
+ if (landing.kind !== "commit") throw new Error(`expected commit, got ${landing.kind}`)
1053
+ return landing
1054
+ }
1055
+
1056
+ it("flow return: keeps refs/gtd/history/<startHash>, records version and branch", async () => {
1057
+ const repo = repoWith(LINEAR)
1058
+ const building = await land(repo, { "a.txt": "a\n" })
1059
+ await land(repo, { "b.txt": "b\n" })
1060
+ repo.writeFile("c.txt", "c\n")
1061
+ const landing = await landingOf(repo)
1062
+ expect(landing.to).toBe("idle")
1063
+ expect(landing.keepHistory).toBe(processHistoryRef(building))
1064
+ expect(landing.spec.version).toBe(GTD_VERSION)
1065
+ expect(landing.spec.branch).toBe(repo.branchName())
1066
+ })
1067
+
1068
+ it("restart() is a finish too", async () => {
1069
+ const repo = repoWith(`import { agent, human, restart } from "@pmelab/gtd/flows"
1070
+
1071
+ export default async () => {
1072
+ await human("idle")
1073
+ await agent("building", "build-prompt")
1074
+ await restart()
1075
+ }
1076
+ `)
1077
+ const building = await land(repo, { "a.txt": "a\n" })
1078
+ repo.writeFile("b.txt", "b\n")
1079
+ const landing = await landingOf(repo)
1080
+ expect(landing.to).toBe("idle")
1081
+ expect(landing.keepHistory).toBe(processHistoryRef(building))
1082
+ })
1083
+
1084
+ it("starting landings and mid-process landings carry no keepHistory and no new trailers", async () => {
1085
+ const repo = repoWith(LINEAR)
1086
+ repo.writeFile("a.txt", "a\n")
1087
+ const starting = await landingOf(repo)
1088
+ expect(starting.to).toBe("building")
1089
+ expect(starting.keepHistory).toBeUndefined()
1090
+ expect(starting.spec.version).toBeUndefined()
1091
+ expect(starting.spec.branch).toBeUndefined()
1092
+ await land(repo, { "a.txt": "a\n" })
1093
+ repo.writeFile("b.txt", "b\n")
1094
+ const mid = await landingOf(repo)
1095
+ expect(mid.to).toBe("checking")
1096
+ expect(mid.keepHistory).toBeUndefined()
1097
+ expect(mid.spec.version).toBeUndefined()
1098
+ expect(mid.spec.branch).toBeUndefined()
1099
+ })
1100
+
1101
+ it("detached HEAD keeps history but records no branch", async () => {
1102
+ const repo = repoWith(LINEAR)
1103
+ const building = await land(repo, { "a.txt": "a\n" })
1104
+ await land(repo, { "b.txt": "b\n" })
1105
+ repo.writeFile("c.txt", "c\n")
1106
+ const detached: GitOperations = {
1107
+ ...fakeGitOperations(repo),
1108
+ currentBranch: () => Effect.succeed(Option.none()),
1109
+ }
1110
+ const landing = await landingOf(repo, detached)
1111
+ expect(landing.keepHistory).toBe(processHistoryRef(building))
1112
+ expect(landing.spec.version).toBe(GTD_VERSION)
1113
+ expect(landing.spec.branch).toBeUndefined()
1114
+ })
1115
+
1116
+ it("a --workflow process's entry is its opening commit", async () => {
1117
+ const repo = repoWith(`import { agent, human } from "@pmelab/gtd/flows"
1118
+
1119
+ export default async () => {
1120
+ await human("idle")
1121
+ await agent("building", "build-prompt")
1122
+ }
1123
+
1124
+ export const side = async () => {
1125
+ await agent("fixing", "fix-prompt")
1126
+ }
1127
+ `)
1128
+ const opening = await enter(repo, "side")
1129
+ repo.writeFile("a.txt", "a\n")
1130
+ const landing = await landingOf(repo)
1131
+ expect(landing.to).toBe("idle")
1132
+ expect(landing.keepHistory).toBe(processHistoryRef(opening))
1133
+ })
1134
+
1135
+ it("the landing script overwrites an existing ref with its own commit", async () => {
1136
+ const repo = repoWith(LINEAR)
1137
+ const building = await land(repo, { "a.txt": "a\n" })
1138
+ await land(repo, { "b.txt": "b\n" })
1139
+ const ref = processHistoryRef(building)
1140
+ repo.updateRef(ref, building)
1141
+ const tip = await land(repo, { "c.txt": "c\n" })
1142
+ expect(repo.resolveRef(ref)).toBe(tip)
1143
+ })
1144
+ })
1145
+
1146
+ describe("historyTrail", () => {
1147
+ const finished = async (config = LINEAR) => {
1148
+ const repo = repoWith(config)
1149
+ const building = await land(repo, { "a.txt": "a\n" })
1150
+ const checking = await land(repo, { "b.txt": "b\n" })
1151
+ const tip = await land(repo, { "c.txt": "c\n" })
1152
+ return { repo, building, checking, tip }
1153
+ }
1154
+
1155
+ it("replays every commit of the closed run, with HEAD elsewhere", async () => {
1156
+ const { repo, building, checking, tip } = await finished()
1157
+ repo.writeFile("d.txt", "d\n")
1158
+ repo.commitAllWithPrefix("chore: unrelated")
1159
+ const trail = await provide(historyTrail(tip), repo)
1160
+ expect(trail.tip).toBe(tip)
1161
+ expect(trail.ended).toBe("return")
1162
+ expect(trail.steps.map((s) => s.hash)).toEqual([building, checking, tip])
1163
+ expect(trail.steps[2]).toMatchObject({
1164
+ subject: "gtd(agent): checking → idle",
1165
+ actor: "agent",
1166
+ from: "checking",
1167
+ to: "idle",
1168
+ verdicts: [],
1169
+ })
1170
+ expect(trail.steps[0]).toMatchObject({ actor: "human", to: "building" })
1171
+ })
1172
+
1173
+ it("a restart() ends the trail as restart", async () => {
1174
+ const repo = repoWith(`import { agent, human, restart } from "@pmelab/gtd/flows"
1175
+
1176
+ export default async () => {
1177
+ await human("idle")
1178
+ await agent("building", "build-prompt")
1179
+ await restart()
1180
+ }
1181
+ `)
1182
+ await land(repo, { "a.txt": "a\n" })
1183
+ const tip = await land(repo, { "b.txt": "b\n" })
1184
+ expect((await provide(historyTrail(tip), repo)).ended).toBe("restart")
1185
+ })
1186
+
1187
+ it("carries each judge verdict on the commit that recorded it", async () => {
1188
+ const repo = repoWith(LINEAR)
1189
+ await land(repo, { "a.txt": "a\n" })
1190
+ const verdictCommit = commit(
1191
+ repo,
1192
+ {
1193
+ actor: "agent",
1194
+ from: "building",
1195
+ to: "checking",
1196
+ step: { name: "building", occurrence: 1 },
1197
+ judge: [{ id: "q", answer: "yes", p: 0.9 }],
1198
+ },
1199
+ { "b.txt": "b\n" },
1200
+ )
1201
+ const tip = await land(repo, { "c.txt": "c\n" })
1202
+ const trail = await provide(historyTrail(tip), repo)
1203
+ expect(trail.steps.find((s) => s.hash === verdictCommit)?.verdicts).toEqual([
1204
+ { id: "q", answer: "yes", p: 0.9 },
1205
+ ])
1206
+ expect(trail.steps.filter((s) => s.verdicts.length > 0)).toHaveLength(1)
1207
+ })
1208
+
1209
+ it("records the version and warns about none while they match", async () => {
1210
+ const { repo, tip } = await finished()
1211
+ const trail = await provide(historyTrail(tip), repo)
1212
+ expect(trail.version).toEqual({ recorded: GTD_VERSION, running: GTD_VERSION })
1213
+ expect(trail.versionWarning).toBeUndefined()
1214
+ })
1215
+
1216
+ const withTipMessage = (repo: InMemRepo, edit: (message: string) => string): GitOperations => {
1217
+ const real = fakeGitOperations(repo)
1218
+ return {
1219
+ ...real,
1220
+ lastCommitMessage: (ref) => Effect.map(real.lastCommitMessage(ref), edit),
1221
+ }
1222
+ }
1223
+
1224
+ it("warns, never refuses, when the recorded version differs", async () => {
1225
+ const { repo, tip } = await finished()
1226
+ const git = withTipMessage(repo, (m) =>
1227
+ m.replace(`Gtd-Version: ${GTD_VERSION}`, "Gtd-Version: 0.0.1"),
1228
+ )
1229
+ const trail = await Effect.runPromise(
1230
+ historyTrail(tip).pipe(
1231
+ Effect.provideService(GitService, git),
1232
+ Effect.provide(testLayers(repo, {})),
1233
+ ),
1234
+ )
1235
+ expect(trail.version.recorded).toBe("0.0.1")
1236
+ expect(trail.versionWarning).toContain("0.0.1")
1237
+ expect(trail.versionWarning).toContain(GTD_VERSION)
1238
+ })
1239
+
1240
+ it("a missing recorded version counts as different", async () => {
1241
+ const { repo, tip } = await finished()
1242
+ const git = withTipMessage(repo, (m) => m.replace(/Gtd-Version: .*\n?/, ""))
1243
+ const trail = await Effect.runPromise(
1244
+ historyTrail(tip).pipe(
1245
+ Effect.provideService(GitService, git),
1246
+ Effect.provide(testLayers(repo, {})),
1247
+ ),
1248
+ )
1249
+ expect(trail.version.recorded).toBeUndefined()
1250
+ expect(trail.versionWarning).toContain(GTD_VERSION)
1251
+ })
1252
+
1253
+ it("refuses naming the config file that differs at the tip", async () => {
1254
+ const { repo, tip } = await finished()
1255
+ repo.writeFile("gtd.config.ts", `${LINEAR}\n// edited\n`)
1256
+ repo.commitAllWithPrefix("chore: edit config")
1257
+ const exit = await provideExit(historyTrail(tip), repo)
1258
+ expect(Exit.isFailure(exit)).toBe(true)
1259
+ expect(String(Exit.isFailure(exit) && exit.cause)).toContain("gtd.config.ts")
1260
+ })
1261
+
1262
+ it("refuses naming a config file missing from the tip", async () => {
1263
+ const { repo, tip } = await finished()
1264
+ repo.writeFile("gtd.config.ts", `import "./extra.ts"\n${LINEAR}`)
1265
+ repo.writeFile("extra.ts", "export {}\n")
1266
+ repo.commitAllWithPrefix("chore: split config")
1267
+ const exit = await provideExit(historyTrail(tip), repo)
1268
+ expect(Exit.isFailure(exit)).toBe(true)
1269
+ expect(String(Exit.isFailure(exit) && exit.cause)).toContain("gtd.config.ts")
1270
+ })
1271
+ })