@pmelab/gtd 24.2.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +26 -2
- package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
- package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-DJwU5C4z.mjs} +1 -1
- package/dist/gtd.bundle.mjs +337 -55
- package/dist/testkit.mjs +477 -65
- package/dist/workflows/feature.mjs +1 -1
- package/dist/workflows/fix.mjs +1 -1
- package/dist/workflows/review.mjs +1 -1
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +57 -0
- package/src/cli/Cli.ts +89 -9
- package/src/cli/History.test.ts +225 -0
- package/src/cli/History.ts +143 -0
- package/src/cli/Output.ts +68 -0
- package/src/cli/Program.test.ts +7 -1
- package/src/cli/Program.ts +23 -64
- package/src/edge/Edge.test.ts +256 -1
- package/src/edge/Edge.ts +217 -39
- package/src/edge/index.ts +1 -0
- package/src/emit/GitScript.test.ts +27 -0
- package/src/emit/GitScript.ts +18 -2
- package/src/emit/index.ts +1 -0
- package/src/platform/Git.ts +48 -10
- package/src/platform/RetainedHistory.test.ts +14 -0
- package/src/platform/RetainedHistory.ts +5 -0
- package/src/platform/index.ts +7 -1
- package/src/replay/Trailers.test.ts +32 -0
- package/src/replay/Trailers.ts +17 -0
- package/src/step/LandStep.ts +3 -1
- package/src/step/RepoSnapshot.ts +7 -1
- package/src/step/planStart.test.ts +6 -3
- package/src/step/planStep.test.ts +29 -1
- package/src/step/planStep.ts +8 -0
- package/src/testkit/EmittedScriptRecognizer.ts +1 -1
- package/src/testkit/FakeGitOperations.ts +6 -2
- package/src/testkit/GitTiers.ts +133 -2
- 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
|
package/src/cli/Program.test.ts
CHANGED
|
@@ -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
|
|
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
|
})
|
package/src/cli/Program.ts
CHANGED
|
@@ -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
|
|
package/src/edge/Edge.test.ts
CHANGED
|
@@ -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
|
+
})
|