@pmelab/gtd 25.0.0 → 26.1.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 +7 -0
- package/dist/flows.mjs +15 -1
- package/dist/gtd.bundle.mjs +161 -20
- package/dist/testkit.mjs +311 -57
- package/package.json +2 -1
- package/src/cli/Cli.test.ts +28 -3
- package/src/cli/Cli.ts +12 -1
- package/src/cli/History.test.ts +2 -2
- package/src/cli/Program.test.ts +10 -12
- package/src/edge/Edge.test.ts +113 -16
- package/src/edge/Edge.ts +22 -6
- package/src/emit/GitScript.test.ts +65 -2
- package/src/emit/GitScript.ts +34 -1
- package/src/emit/index.ts +1 -0
- package/src/flags/index.test.ts +67 -0
- package/src/flags/index.ts +38 -0
- package/src/flows/runtime.test.ts +33 -0
- package/src/flows/runtime.ts +31 -0
- package/src/platform/RetainedHistory.test.ts +9 -0
- package/src/platform/RetainedHistory.ts +3 -0
- package/src/platform/Workspace.test.ts +1 -1
- package/src/platform/index.ts +1 -0
- package/src/replay/RefName.test.ts +40 -0
- package/src/replay/RefName.ts +19 -0
- package/src/replay/Replay.test.ts +193 -2
- package/src/replay/Replay.ts +51 -6
- package/src/replay/Trailers.test.ts +26 -0
- package/src/replay/Trailers.ts +8 -0
- package/src/replay/index.ts +1 -0
- package/src/step/LandStep.ts +1 -0
- package/src/step/RepoSnapshot.ts +2 -0
- package/src/step/planStart.test.ts +5 -5
- package/src/step/planStep.test.ts +53 -0
- package/src/step/planStep.ts +7 -1
- package/src/testkit/EmittedScriptRecognizer.test.ts +57 -29
- package/src/testkit/EmittedScriptRecognizer.ts +26 -4
- package/src/testkit/EmittedScripts.test.ts +182 -0
- package/src/testkit/FakeGitOperations.ts +8 -2
- package/src/testkit/GitTiers.ts +103 -24
- package/src/testkit/InMemRepo.ts +57 -12
- package/src/testkit/TestRepo.ts +2 -2
- package/src/workflow/Workflow.ts +1 -0
- package/src/workflow/discovery.test.ts +1 -1
- package/src/workflow/vars.test.ts +16 -0
- package/src/workflow/vars.ts +4 -1
- package/src/workflows/shared/text.fixture.ts +1 -0
package/src/cli/Cli.test.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
ToolCommands,
|
|
24
24
|
} from "./index.js"
|
|
25
25
|
import { EXIT_CODES, EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR } from "../wire/index.js"
|
|
26
|
+
import { Host } from "../platform/index.js"
|
|
26
27
|
import { type CommandRequirements } from "./Cli.js"
|
|
27
28
|
import { InMemRepo, testLayers } from "../testkit/index.js"
|
|
28
29
|
import { CONFIG_SCAFFOLD } from "../install/index.js"
|
|
@@ -1164,6 +1165,30 @@ describe("runCli — layers() is never invoked for output/usage plans", () => {
|
|
|
1164
1165
|
})
|
|
1165
1166
|
})
|
|
1166
1167
|
|
|
1168
|
+
describe("runCli — a garbled GTD_FLAG_* value", () => {
|
|
1169
|
+
it("exits 1 naming the variable", async () => {
|
|
1170
|
+
const { io, captured } = capturingIo(
|
|
1171
|
+
() =>
|
|
1172
|
+
Layer.succeed(Host, {
|
|
1173
|
+
root: "/",
|
|
1174
|
+
home: "/",
|
|
1175
|
+
scratchDir: "/",
|
|
1176
|
+
realPath: (p: string) => Effect.succeed(p),
|
|
1177
|
+
env: { GTD_FLAG_BETA: "ture" },
|
|
1178
|
+
}) as unknown as Layer.Layer<CommandRequirements>,
|
|
1179
|
+
)
|
|
1180
|
+
await Effect.runPromise(
|
|
1181
|
+
runCli(["node", "gtd.js", "next"], {
|
|
1182
|
+
...io,
|
|
1183
|
+
flags: [{ name: "beta", owner: "pmelab", expiry: "2099-01-01", kind: "ops" }],
|
|
1184
|
+
}),
|
|
1185
|
+
)
|
|
1186
|
+
expect(captured().exitCode).toBe(1)
|
|
1187
|
+
expect(captured().stdout).toBe("")
|
|
1188
|
+
expect(captured().stderr).toContain("GTD_FLAG_BETA")
|
|
1189
|
+
})
|
|
1190
|
+
})
|
|
1191
|
+
|
|
1167
1192
|
describe("runCli — exit codes", () => {
|
|
1168
1193
|
it("output plan: exit is never called (success) — --help and --version still exit 0", async () => {
|
|
1169
1194
|
const { io, captured } = capturingIo(throwingLayers)
|
|
@@ -1206,7 +1231,7 @@ describe("runCli — exit codes", () => {
|
|
|
1206
1231
|
const repo = new InMemRepo()
|
|
1207
1232
|
repo.writeFile("NOTE.md", "a note\n")
|
|
1208
1233
|
repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
|
|
1209
|
-
repo.
|
|
1234
|
+
repo.seedCommit("gtd: init")
|
|
1210
1235
|
const { io, captured } = capturingIo(() => testLayers(repo))
|
|
1211
1236
|
await Effect.runPromise(runCli(["node", "gtd.js", "next", "--json=does.not.exist"], io))
|
|
1212
1237
|
const { stdout, stderr, exitCode } = captured()
|
|
@@ -1291,7 +1316,7 @@ describe("runCli — stdout stays byte-empty on every failing surface", () => {
|
|
|
1291
1316
|
// produce zero calls, not merely an empty joined string.
|
|
1292
1317
|
const repo = new InMemRepo()
|
|
1293
1318
|
repo.writeFile("README.md", "x\n")
|
|
1294
|
-
repo.
|
|
1319
|
+
repo.seedCommit("chore: init gtd workflow")
|
|
1295
1320
|
repo.writeFile(".gtd/TODO.md", "## Open Questions\n\n###\n\nno question text.\n")
|
|
1296
1321
|
const stdoutCalls: string[] = []
|
|
1297
1322
|
const io: CliIo = {
|
|
@@ -1371,7 +1396,7 @@ describe("runCli — flush normalizes the artifact's trailing newline", () => {
|
|
|
1371
1396
|
const repo = new InMemRepo()
|
|
1372
1397
|
repo.writeFile(".gitignore", "node_modules\n")
|
|
1373
1398
|
repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
|
|
1374
|
-
repo.
|
|
1399
|
+
repo.seedCommit("chore: initial commit")
|
|
1375
1400
|
const { io, captured } = capturingIo(() => testLayers(repo))
|
|
1376
1401
|
await Effect.runPromise(runCli(["node", "gtd.js", "land"], io))
|
|
1377
1402
|
const { stdout } = captured()
|
package/src/cli/Cli.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NodeContext } from "@effect/platform-node"
|
|
2
2
|
import { Cause, Effect, Either, Layer } from "effect"
|
|
3
3
|
import {
|
|
4
|
+
GtdError,
|
|
4
5
|
GtdUsageError,
|
|
5
6
|
Narrator,
|
|
6
7
|
renderFailure,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
Workspace,
|
|
10
11
|
CommandRunner,
|
|
11
12
|
} from "../platform/index.js"
|
|
13
|
+
import { resolveFlags, type FlagDeclaration } from "../flags/index.js"
|
|
12
14
|
import { GTD_VERSION } from "../install/index.js"
|
|
13
15
|
import {
|
|
14
16
|
ConfigDiscovery,
|
|
@@ -1270,6 +1272,8 @@ export interface CliIo {
|
|
|
1270
1272
|
* writer when `false`.
|
|
1271
1273
|
*/
|
|
1272
1274
|
readonly layers: (verbose: boolean) => Layer.Layer<CommandRequirements>
|
|
1275
|
+
/** Test seam: the shipped registry is empty, so a garbled-value test cannot reach the failure path without injecting one. Drop it when the first real flag ships. */
|
|
1276
|
+
readonly flags?: readonly FlagDeclaration[]
|
|
1273
1277
|
}
|
|
1274
1278
|
|
|
1275
1279
|
// The one write-to-stderr primitive nodeCliIo uses for both `stderr` (errors)
|
|
@@ -1403,7 +1407,14 @@ export const runCli = (argv: readonly string[], io: CliIo): Effect.Effect<void,
|
|
|
1403
1407
|
}
|
|
1404
1408
|
|
|
1405
1409
|
const out = bufferedArtifactOut(io)
|
|
1406
|
-
return
|
|
1410
|
+
return Effect.flatMap(Host, ({ env }) =>
|
|
1411
|
+
Effect.try({
|
|
1412
|
+
// Validate only: the result is discarded so a garbled value fails before any command runs.
|
|
1413
|
+
try: () => resolveFlags(env, io.flags),
|
|
1414
|
+
catch: (e) => new GtdError((e as Error).message),
|
|
1415
|
+
}),
|
|
1416
|
+
).pipe(
|
|
1417
|
+
Effect.flatMap(() => runCommand(plan.command, plan.json, out)),
|
|
1407
1418
|
// `flush()` fires here, on success, BEFORE `io.exit` — the one point
|
|
1408
1419
|
// where the whole buffered artifact is known-complete and safe to hand
|
|
1409
1420
|
// to stdout (a failure discards the buffer instead — see
|
package/src/cli/History.test.ts
CHANGED
|
@@ -36,7 +36,7 @@ const finishedProcess = async (repo: InMemRepo, file: string) => {
|
|
|
36
36
|
const repoWithConfig = (): InMemRepo => {
|
|
37
37
|
const repo = new InMemRepo()
|
|
38
38
|
repo.writeFile("gtd.config.ts", CONFIG)
|
|
39
|
-
repo.
|
|
39
|
+
repo.seedCommit("chore: add workflow")
|
|
40
40
|
return repo
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -90,7 +90,7 @@ describe("historyEntries", () => {
|
|
|
90
90
|
const repo = repoWithConfig()
|
|
91
91
|
const { entry: hash } = await finishedProcess(repo, "a.txt")
|
|
92
92
|
repo.writeFile("x.txt", "x\n")
|
|
93
|
-
repo.
|
|
93
|
+
repo.seedCommit("chore: bare tip")
|
|
94
94
|
repo.updateRef(`refs/gtd/history/${hash}`, "HEAD")
|
|
95
95
|
expect((await entries(repo))[0]?.branch).toBeNull()
|
|
96
96
|
})
|
package/src/cli/Program.test.ts
CHANGED
|
@@ -60,7 +60,7 @@ const seed = (source: string, extra: Record<string, string> = {}): InMemRepo =>
|
|
|
60
60
|
const repo = new InMemRepo()
|
|
61
61
|
repo.writeFile("gtd.config.ts", source)
|
|
62
62
|
for (const [path, content] of Object.entries(extra)) repo.writeFile(path, content)
|
|
63
|
-
repo.
|
|
63
|
+
repo.seedCommit("chore: add custom workflow")
|
|
64
64
|
return repo
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -69,7 +69,7 @@ const seedBundled = (): InMemRepo => {
|
|
|
69
69
|
const repo = new InMemRepo()
|
|
70
70
|
repo.writeFile("README.md", "# test project\n")
|
|
71
71
|
repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
|
|
72
|
-
repo.
|
|
72
|
+
repo.seedCommit("chore: init")
|
|
73
73
|
return repo
|
|
74
74
|
}
|
|
75
75
|
|
|
@@ -199,7 +199,7 @@ describe("gtd --workflow <name> — the shipped workflows", () => {
|
|
|
199
199
|
const repo = seedBundled()
|
|
200
200
|
const base = repo.commitHistory().at(-1)!.hash
|
|
201
201
|
repo.writeFile("scratch.txt", "more work\n")
|
|
202
|
-
repo.
|
|
202
|
+
repo.seedCommit("chore: more work")
|
|
203
203
|
const before = repo.commitHistory().length
|
|
204
204
|
const { stdout, exitCode } = await run(
|
|
205
205
|
repo,
|
|
@@ -288,7 +288,7 @@ describe("gtd next --json — log path emission", () => {
|
|
|
288
288
|
const repo = new InMemRepo()
|
|
289
289
|
repo.writeFile("NOTE.md", "a note\n")
|
|
290
290
|
repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
|
|
291
|
-
repo.
|
|
291
|
+
repo.seedCommit("gtd: init")
|
|
292
292
|
return repo
|
|
293
293
|
}
|
|
294
294
|
|
|
@@ -703,7 +703,7 @@ describe("gtd next — refuses when history diverges from what replay reaches",
|
|
|
703
703
|
const diverged = async (): Promise<InMemRepo> => {
|
|
704
704
|
const repo = await atWorking()
|
|
705
705
|
repo.writeFile("WORK.md", "done\n")
|
|
706
|
-
repo.
|
|
706
|
+
repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1")
|
|
707
707
|
return repo
|
|
708
708
|
}
|
|
709
709
|
|
|
@@ -752,9 +752,7 @@ describe("a history written in a format this gtd does not read", () => {
|
|
|
752
752
|
const repo = seed(IDLE_THEN_WORKING)
|
|
753
753
|
await landTurn(repo, { "NOTE.md": "a note\n" })
|
|
754
754
|
repo.writeFile("WORK.md", "done\n")
|
|
755
|
-
repo.
|
|
756
|
-
"gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format: 2",
|
|
757
|
-
)
|
|
755
|
+
repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format: 2")
|
|
758
756
|
return repo
|
|
759
757
|
}
|
|
760
758
|
|
|
@@ -779,7 +777,7 @@ describe("a history written in a format this gtd does not read", () => {
|
|
|
779
777
|
it("refuses even when a foreign subject hides the commit from the episode", async () => {
|
|
780
778
|
const repo = seed(IDLE_THEN_WORKING)
|
|
781
779
|
repo.writeFile("NOTE.md", "a note\n")
|
|
782
|
-
repo.
|
|
780
|
+
repo.seedCommit("gtd2[human] idle -> working\n\nGtd-Format: 2")
|
|
783
781
|
for (const command of ["next", "abandon"]) {
|
|
784
782
|
const { exitCode, stderr } = await run(repo, command)
|
|
785
783
|
expect(exitCode).toBe(1)
|
|
@@ -790,7 +788,7 @@ describe("a history written in a format this gtd does not read", () => {
|
|
|
790
788
|
it("a commit gtd never wrote is not read for a format, even quoting the trailer", async () => {
|
|
791
789
|
const repo = seed(IDLE_THEN_WORKING)
|
|
792
790
|
repo.writeFile("NOTE.md", "a note\n")
|
|
793
|
-
repo.
|
|
791
|
+
repo.seedCommit("feat: x\n\nGtd-Format: 2")
|
|
794
792
|
expect((await run(repo, "next")).exitCode).toBe(0)
|
|
795
793
|
})
|
|
796
794
|
|
|
@@ -798,7 +796,7 @@ describe("a history written in a format this gtd does not read", () => {
|
|
|
798
796
|
const repo = seed(IDLE_THEN_WORKING)
|
|
799
797
|
const base = repo.resolveRef("HEAD")!
|
|
800
798
|
repo.writeFile("NOTE.md", "a note\n")
|
|
801
|
-
repo.
|
|
799
|
+
repo.seedCommit("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format: 2")
|
|
802
800
|
repo.updateRef(HISTORY_REF, repo.resolveRef("HEAD")!)
|
|
803
801
|
repo.hardResetTo(base)
|
|
804
802
|
const { exitCode, stderr } = await run(repo, "restore")
|
|
@@ -1949,7 +1947,7 @@ describe("gtd next/land --json=<path> — the select branch", () => {
|
|
|
1949
1947
|
const repo = new InMemRepo()
|
|
1950
1948
|
repo.writeFile("NOTE.md", "a note\n")
|
|
1951
1949
|
repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
|
|
1952
|
-
repo.
|
|
1950
|
+
repo.seedCommit("gtd: init")
|
|
1953
1951
|
return repo
|
|
1954
1952
|
}
|
|
1955
1953
|
|
package/src/edge/Edge.test.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
type RestRequirements,
|
|
20
20
|
} from "./Edge.js"
|
|
21
21
|
import { GTD_VERSION } from "../install/index.js"
|
|
22
|
-
import { processHistoryRef } from "../platform/index.js"
|
|
22
|
+
import { keptRef, processHistoryRef } from "../platform/index.js"
|
|
23
23
|
import { GitService, Workspace, type GitOperations, type WorkspaceOps } from "../platform/index.js"
|
|
24
24
|
import { formatCommitMessage, type CommitSpec } from "../replay/index.js"
|
|
25
25
|
import { contentHash } from "../workflow/index.js"
|
|
@@ -78,7 +78,7 @@ const headOf = (repo: InMemRepo): string => repo.resolveRef("HEAD")!
|
|
|
78
78
|
const repoWith = (config: string, files: Files = {}): InMemRepo => {
|
|
79
79
|
const repo = new InMemRepo()
|
|
80
80
|
write(repo, { "gtd.config.ts": config, ...files })
|
|
81
|
-
repo.
|
|
81
|
+
repo.seedCommit("chore: add workflow")
|
|
82
82
|
return repo
|
|
83
83
|
}
|
|
84
84
|
|
|
@@ -112,7 +112,7 @@ const enter = async (repo: InMemRepo, workflow: string, ...vars: string[]): Prom
|
|
|
112
112
|
/** Commits an exact message — for trailers no landing writes on its own. */
|
|
113
113
|
const commit = (repo: InMemRepo, spec: CommitSpec, files: Files = {}): string => {
|
|
114
114
|
write(repo, files)
|
|
115
|
-
repo.
|
|
115
|
+
repo.seedCommit(formatCommitMessage(spec))
|
|
116
116
|
return headOf(repo)
|
|
117
117
|
}
|
|
118
118
|
|
|
@@ -179,7 +179,7 @@ describe("currentRun", () => {
|
|
|
179
179
|
const repo = repoWith(LINEAR)
|
|
180
180
|
await land(repo, { "a.txt": "a\n" })
|
|
181
181
|
repo.writeFile("b.txt", "b\n")
|
|
182
|
-
repo.
|
|
182
|
+
repo.seedCommit("feat: unrelated")
|
|
183
183
|
const run = await provide(currentRun, repo)
|
|
184
184
|
expect(run.trace).toEqual([])
|
|
185
185
|
expect(run.startParentHash).toBe(headOf(repo))
|
|
@@ -296,7 +296,7 @@ export const again = async () => {
|
|
|
296
296
|
{ "a.txt": "a\n" },
|
|
297
297
|
)
|
|
298
298
|
repo.writeFile("b.txt", "b\n")
|
|
299
|
-
repo.
|
|
299
|
+
repo.seedCommit(
|
|
300
300
|
[
|
|
301
301
|
"gtd(agent): building → checking",
|
|
302
302
|
"",
|
|
@@ -316,7 +316,7 @@ export const again = async () => {
|
|
|
316
316
|
const repo = repoWith(LINEAR)
|
|
317
317
|
const base = headOf(repo)
|
|
318
318
|
repo.writeFile("later.txt", "later\n")
|
|
319
|
-
repo.
|
|
319
|
+
repo.seedCommit("feat: later")
|
|
320
320
|
const before = headOf(repo)
|
|
321
321
|
await enter(repo, "review", `base=${base}`)
|
|
322
322
|
const run = await provide(currentRun, repo)
|
|
@@ -398,13 +398,13 @@ describe("history reads stay bounded by the episode, not the repository", () =>
|
|
|
398
398
|
const repoWithHistory = (count: number): InMemRepo => {
|
|
399
399
|
const repo = new InMemRepo()
|
|
400
400
|
repo.writeFile("seed.txt", "0")
|
|
401
|
-
repo.
|
|
401
|
+
repo.seedCommit("chore: seed 0")
|
|
402
402
|
for (let i = 1; i < count; i++) {
|
|
403
403
|
repo.writeFile("seed.txt", String(i))
|
|
404
|
-
repo.
|
|
404
|
+
repo.seedCommit(`chore: seed ${i}`)
|
|
405
405
|
}
|
|
406
406
|
write(repo, { "gtd.config.ts": LINEAR })
|
|
407
|
-
repo.
|
|
407
|
+
repo.seedCommit("chore: add workflow")
|
|
408
408
|
return repo
|
|
409
409
|
}
|
|
410
410
|
|
|
@@ -529,7 +529,7 @@ describe("summaryRun", () => {
|
|
|
529
529
|
await land(repo, { "b.txt": "b\n" })
|
|
530
530
|
await land(repo, { "c.txt": "c\n" })
|
|
531
531
|
repo.writeFile("d.txt", "d\n")
|
|
532
|
-
repo.
|
|
532
|
+
repo.seedCommit("chore: unrelated")
|
|
533
533
|
const summary = await provide(summaryRun, repo)
|
|
534
534
|
expect(summary.closingHash).toBeUndefined()
|
|
535
535
|
expect(summary.trace).toEqual([])
|
|
@@ -638,7 +638,7 @@ export const base = (workflow, vars) => (workflow === "review" ? (vars.base ?? "
|
|
|
638
638
|
const repo = repoWith(LOOP)
|
|
639
639
|
const base = headOf(repo)
|
|
640
640
|
repo.writeFile("later.txt", "later\n")
|
|
641
|
-
repo.
|
|
641
|
+
repo.seedCommit("feat: later")
|
|
642
642
|
await enter(repo, "review", `base=${base}`)
|
|
643
643
|
expect(reviewBaseFor(await provide(currentRest, repo))).toBe(base)
|
|
644
644
|
})
|
|
@@ -850,7 +850,7 @@ describe("stalledAt", () => {
|
|
|
850
850
|
const repo = repoWith(LINEAR)
|
|
851
851
|
await enter(repo, "side")
|
|
852
852
|
await land(repo, { "fix.txt": "x\n" })
|
|
853
|
-
repo.
|
|
853
|
+
repo.seedCommit("gtd(agent): tidying")
|
|
854
854
|
const rest = await provide(currentRest, repo)
|
|
855
855
|
expect(rest.state).toBe("tidying")
|
|
856
856
|
expect(stalledAt(rest)).toBe(false)
|
|
@@ -1038,7 +1038,7 @@ describe("closedRunAt", () => {
|
|
|
1038
1038
|
const tip = await land(repo, { "c.txt": "c\n" })
|
|
1039
1039
|
const closed = await provide(closedRunAt(tip), repo)
|
|
1040
1040
|
repo.writeFile("d.txt", "d\n")
|
|
1041
|
-
repo.
|
|
1041
|
+
repo.seedCommit("chore: unrelated")
|
|
1042
1042
|
const later = await provide(closedRunAt(tip), repo)
|
|
1043
1043
|
expect(later).toEqual(closed)
|
|
1044
1044
|
expect(later.closingHash).toBe(tip)
|
|
@@ -1146,6 +1146,103 @@ export const side = async () => {
|
|
|
1146
1146
|
})
|
|
1147
1147
|
})
|
|
1148
1148
|
|
|
1149
|
+
describe("keep steps", () => {
|
|
1150
|
+
const KEEP = `import { agent, human } from "@pmelab/gtd/flows"
|
|
1151
|
+
|
|
1152
|
+
export default async () => {
|
|
1153
|
+
await human("idle")
|
|
1154
|
+
await agent("building", "build-prompt", { keep: true })
|
|
1155
|
+
await agent("checking", "check-prompt")
|
|
1156
|
+
}
|
|
1157
|
+
`
|
|
1158
|
+
|
|
1159
|
+
const snapshotOf = async (repo: InMemRepo) =>
|
|
1160
|
+
(await provide(snapshotFromRest(await provide(currentRest, repo)), repo)).landing
|
|
1161
|
+
|
|
1162
|
+
it("landing at a keep step carries keepTurn under refs/gtd/kept/<startHash>/<step>", async () => {
|
|
1163
|
+
const repo = repoWith(KEEP)
|
|
1164
|
+
const building = await land(repo, { "a.txt": "a\n" })
|
|
1165
|
+
repo.writeFile("naive.txt", "naive\n")
|
|
1166
|
+
const landing = await snapshotOf(repo)
|
|
1167
|
+
if (landing.kind !== "commit") throw new Error(`expected commit, got ${landing.kind}`)
|
|
1168
|
+
expect(landing.to).toBe("checking")
|
|
1169
|
+
expect(landing.keepTurn).toBe(keptRef(building, "building"))
|
|
1170
|
+
expect(landing.keepTurn).toBe(`refs/gtd/kept/${building}/building`)
|
|
1171
|
+
})
|
|
1172
|
+
|
|
1173
|
+
it("a landing at a step that does not keep carries no keepTurn", async () => {
|
|
1174
|
+
const repo = repoWith(KEEP)
|
|
1175
|
+
repo.writeFile("a.txt", "a\n")
|
|
1176
|
+
const landing = await snapshotOf(repo)
|
|
1177
|
+
if (landing.kind !== "commit") throw new Error(`expected commit, got ${landing.kind}`)
|
|
1178
|
+
expect(landing.keepTurn).toBeUndefined()
|
|
1179
|
+
})
|
|
1180
|
+
|
|
1181
|
+
it("a clean tree at a keep step is still an attempt, never a keep", async () => {
|
|
1182
|
+
const repo = repoWith(KEEP)
|
|
1183
|
+
await land(repo, { "a.txt": "a\n" })
|
|
1184
|
+
const landing = await snapshotOf(repo)
|
|
1185
|
+
expect(landing.kind).toBe("attempt")
|
|
1186
|
+
})
|
|
1187
|
+
|
|
1188
|
+
it("a marker at HEAD is not a stall: the process rests at the step after the keep", async () => {
|
|
1189
|
+
const repo = repoWith(KEEP)
|
|
1190
|
+
await land(repo, { "a.txt": "a\n" })
|
|
1191
|
+
await land(repo, { "naive.txt": "naive\n" })
|
|
1192
|
+
expect(repo.readFile("naive.txt")).toBeUndefined()
|
|
1193
|
+
expect(repo.lastCommitMessage()).toMatch(/\nGtd-Kept: [0-9a-f]{40}$/)
|
|
1194
|
+
const rest = await provide(currentRest, repo)
|
|
1195
|
+
expect(rest.state).toBe("checking")
|
|
1196
|
+
expect(stalledAt(rest)).toBe(false)
|
|
1197
|
+
})
|
|
1198
|
+
})
|
|
1199
|
+
|
|
1200
|
+
describe("kept episode commits", () => {
|
|
1201
|
+
const KEEP = `import { agent, human } from "@pmelab/gtd/flows"
|
|
1202
|
+
|
|
1203
|
+
export default async () => {
|
|
1204
|
+
await human("idle")
|
|
1205
|
+
await agent("building", "build-prompt", { keep: true })
|
|
1206
|
+
await agent("checking", "check-prompt")
|
|
1207
|
+
}
|
|
1208
|
+
`
|
|
1209
|
+
|
|
1210
|
+
const keptRepo = async () => {
|
|
1211
|
+
const repo = repoWith(KEEP)
|
|
1212
|
+
await land(repo, { "a.txt": "a\n" })
|
|
1213
|
+
await land(repo, { "naive.txt": "naive\n" })
|
|
1214
|
+
return repo
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
it("builds kept: { hash, tree } for a marker whose kept object exists", async () => {
|
|
1218
|
+
const repo = await keptRepo()
|
|
1219
|
+
const rest = await provide(currentRest, repo)
|
|
1220
|
+
const marker = rest.setup.episode.at(-1)!
|
|
1221
|
+
const hash = /Gtd-Kept: ([0-9a-f]{40})/.exec(repo.lastCommitMessage()!)![1]!
|
|
1222
|
+
expect(marker.kept?.hash).toBe(hash)
|
|
1223
|
+
expect(marker.kept?.tree.read("naive.txt")).toBe("naive\n")
|
|
1224
|
+
expect(marker.tree.read("naive.txt")).toBeUndefined()
|
|
1225
|
+
})
|
|
1226
|
+
|
|
1227
|
+
it("builds no kept for a marker whose kept object is missing", async () => {
|
|
1228
|
+
const repo = await keptRepo()
|
|
1229
|
+
const message = repo
|
|
1230
|
+
.lastCommitMessage()!
|
|
1231
|
+
.replace(/Gtd-Kept: [0-9a-f]{40}/, `Gtd-Kept: ${"ab".repeat(20)}`)
|
|
1232
|
+
repo.hardResetTo("HEAD~1")
|
|
1233
|
+
repo.commitAsIs(message)
|
|
1234
|
+
const rest = await provide(currentRest, repo)
|
|
1235
|
+
expect(rest.state).toBe("checking")
|
|
1236
|
+
expect(rest.setup.episode.at(-1)!.kept).toBeUndefined()
|
|
1237
|
+
})
|
|
1238
|
+
|
|
1239
|
+
it("builds no kept for a commit without the trailer", async () => {
|
|
1240
|
+
const repo = await keptRepo()
|
|
1241
|
+
const rest = await provide(currentRest, repo)
|
|
1242
|
+
expect(rest.setup.episode.slice(0, -1).every((c) => c.kept === undefined)).toBe(true)
|
|
1243
|
+
})
|
|
1244
|
+
})
|
|
1245
|
+
|
|
1149
1246
|
describe("historyTrail", () => {
|
|
1150
1247
|
const finished = async (config = LINEAR) => {
|
|
1151
1248
|
const repo = repoWith(config)
|
|
@@ -1158,7 +1255,7 @@ describe("historyTrail", () => {
|
|
|
1158
1255
|
it("replays every commit of the closed run, with HEAD elsewhere", async () => {
|
|
1159
1256
|
const { repo, building, checking, tip } = await finished()
|
|
1160
1257
|
repo.writeFile("d.txt", "d\n")
|
|
1161
|
-
repo.
|
|
1258
|
+
repo.seedCommit("chore: unrelated")
|
|
1162
1259
|
const trail = await provide(historyTrail(tip), repo)
|
|
1163
1260
|
expect(trail.tip).toBe(tip)
|
|
1164
1261
|
expect(trail.ended).toBe("return")
|
|
@@ -1256,7 +1353,7 @@ export default async () => {
|
|
|
1256
1353
|
it("refuses naming the config file that differs at the tip", async () => {
|
|
1257
1354
|
const { repo, tip } = await finished()
|
|
1258
1355
|
repo.writeFile("gtd.config.ts", `${LINEAR}\n// edited\n`)
|
|
1259
|
-
repo.
|
|
1356
|
+
repo.seedCommit("chore: edit config")
|
|
1260
1357
|
const exit = await provideExit(historyTrail(tip), repo)
|
|
1261
1358
|
expect(Exit.isFailure(exit)).toBe(true)
|
|
1262
1359
|
expect(String(Exit.isFailure(exit) && exit.cause)).toContain("gtd.config.ts")
|
|
@@ -1266,7 +1363,7 @@ export default async () => {
|
|
|
1266
1363
|
const { repo, tip } = await finished()
|
|
1267
1364
|
repo.writeFile("gtd.config.ts", `import "./extra.ts"\n${LINEAR}`)
|
|
1268
1365
|
repo.writeFile("extra.ts", "export {}\n")
|
|
1269
|
-
repo.
|
|
1366
|
+
repo.seedCommit("chore: split config")
|
|
1270
1367
|
const exit = await provideExit(historyTrail(tip), repo)
|
|
1271
1368
|
expect(Exit.isFailure(exit)).toBe(true)
|
|
1272
1369
|
expect(String(Exit.isFailure(exit) && exit.cause)).toContain("gtd.config.ts")
|
package/src/edge/Edge.ts
CHANGED
|
@@ -43,7 +43,7 @@ import type { ScopeAccess } from "../flows/index.js"
|
|
|
43
43
|
import { steeringFormatFor } from "../steering/index.js"
|
|
44
44
|
import { judgeJson, UNATTRIBUTED_MODEL, type ModelCost, type StepAccess } from "../wire/index.js"
|
|
45
45
|
import { GTD_VERSION } from "../install/index.js"
|
|
46
|
-
import { processHistoryRef } from "../platform/index.js"
|
|
46
|
+
import { keptRef, processHistoryRef } from "../platform/index.js"
|
|
47
47
|
import { isAbsolute, relative, resolve, sep } from "node:path"
|
|
48
48
|
import { accessRefusal, type Landing, type RepoSnapshot } from "../step/index.js"
|
|
49
49
|
|
|
@@ -700,6 +700,7 @@ const promptDef = (
|
|
|
700
700
|
...optional("model", request.options.model),
|
|
701
701
|
...optional("system", request.options.system),
|
|
702
702
|
...optional("allowEmpty", request.options.allowEmpty),
|
|
703
|
+
...optional("keep", request.options.keep),
|
|
703
704
|
...optional("skills", skills),
|
|
704
705
|
...optional("access", access),
|
|
705
706
|
})
|
|
@@ -902,11 +903,19 @@ const setupFor = (
|
|
|
902
903
|
run.episode.base === undefined
|
|
903
904
|
? { hash: EMPTY_TREE, tree: treeFromRecord({}) }
|
|
904
905
|
: { hash: run.episode.base, tree: commitTree(workspace, run.episode.base) }
|
|
905
|
-
const
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
906
|
+
const git = yield* GitService
|
|
907
|
+
const episode: EpisodeCommit[] = []
|
|
908
|
+
for (const c of run.episode.commits) {
|
|
909
|
+
const tip = parseCommitMessage(c.message).kept
|
|
910
|
+
// A plain `rev-parse --verify` of a missing 40-hex object succeeds; `^{commit}` does not.
|
|
911
|
+
const exists = tip !== undefined && Option.isSome(yield* git.readRefOption(`${tip}^{commit}`))
|
|
912
|
+
episode.push({
|
|
913
|
+
hash: c.hash,
|
|
914
|
+
message: c.message,
|
|
915
|
+
tree: commitTree(workspace, c.hash),
|
|
916
|
+
...(exists ? { kept: { hash: tip, tree: commitTree(workspace, tip) } } : {}),
|
|
917
|
+
})
|
|
918
|
+
}
|
|
910
919
|
let pendingMemo: ReadonlyMap<string, string | undefined> | undefined
|
|
911
920
|
const setup: ReplaySetup = {
|
|
912
921
|
def,
|
|
@@ -1167,6 +1176,12 @@ const closingTrailers = (branch: Option.Option<string>) => ({
|
|
|
1167
1176
|
const isSettledNoop = (rest: Rest, to: StateName): boolean =>
|
|
1168
1177
|
rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state
|
|
1169
1178
|
|
|
1179
|
+
// A clean tree has nothing to set aside (an allowEmpty keep step lands as an ordinary empty commit).
|
|
1180
|
+
const keptTurn = (rest: Rest) =>
|
|
1181
|
+
rest.stepDef.keep === true && rest.changes.length > 0
|
|
1182
|
+
? { keepTurn: keptRef(rest.run.startHash, rest.state) }
|
|
1183
|
+
: {}
|
|
1184
|
+
|
|
1170
1185
|
const landingTo = (rest: Rest, to: StateName, branch: Option.Option<string>): Landing => {
|
|
1171
1186
|
if (isSettledNoop(rest, to)) return { kind: "noop", settled: true }
|
|
1172
1187
|
// The landing that leaves the flow's first step starts the process: it
|
|
@@ -1191,6 +1206,7 @@ const landingTo = (rest: Rest, to: StateName, branch: Option.Option<string>): La
|
|
|
1191
1206
|
...(finishes ? closingTrailers(branch) : {}),
|
|
1192
1207
|
},
|
|
1193
1208
|
...(finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {}),
|
|
1209
|
+
...keptTurn(rest),
|
|
1194
1210
|
}
|
|
1195
1211
|
}
|
|
1196
1212
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync, spawnSync } from "node:child_process"
|
|
2
|
-
import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"
|
|
3
3
|
import { tmpdir } from "node:os"
|
|
4
4
|
import { join } from "node:path"
|
|
5
5
|
import { describe, expect, it } from "vitest"
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
discardPending,
|
|
12
12
|
fetchHistoryRefs,
|
|
13
13
|
hardResetTo,
|
|
14
|
+
keepTurn,
|
|
14
15
|
mixedResetTo,
|
|
15
16
|
pathspec,
|
|
16
17
|
ScriptSurface,
|
|
@@ -61,7 +62,7 @@ const withFakeGit = (
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
const runWithFakeGit = (script: string, binDir: string): { status: number | null } =>
|
|
64
|
-
spawnSync("
|
|
65
|
+
spawnSync("sh", ["-c", script], {
|
|
65
66
|
env: { ...process.env, PATH: `${binDir}:${process.env["PATH"]}` },
|
|
66
67
|
encoding: "utf8",
|
|
67
68
|
})
|
|
@@ -74,6 +75,7 @@ const allBuilders: ReadonlyArray<[string, string]> = [
|
|
|
74
75
|
["hardResetTo", hardResetTo("HEAD~1")],
|
|
75
76
|
["discardPending", discardPending()],
|
|
76
77
|
["updateRef", updateRef("refs/worktree/gtd/review-head", "abc123")],
|
|
78
|
+
["keepTurn", keepTurn("refs/gtd/kept/abc/build.naive", "gtd(agent): it's done\n\nGtd-Format: 1")],
|
|
77
79
|
["deleteRef", deleteRef("refs/worktree/gtd/review-head")],
|
|
78
80
|
]
|
|
79
81
|
|
|
@@ -285,3 +287,64 @@ describe("fetchHistoryRefs", () => {
|
|
|
285
287
|
expect(runBashCheckSyntax(script)).toBe(0)
|
|
286
288
|
})
|
|
287
289
|
})
|
|
290
|
+
|
|
291
|
+
describe("keepTurn", () => {
|
|
292
|
+
const block = keepTurn("refs/gtd/kept/abc/build.naive", "gtd(agent): done")
|
|
293
|
+
|
|
294
|
+
it("a branch update refused by the guard exits 1, deleting the kept ref after reset -q", () => {
|
|
295
|
+
const binDir = mkdtempSync(join(tmpdir(), "gitscript-fakebin-"))
|
|
296
|
+
const log = join(binDir, "log")
|
|
297
|
+
writeFileSync(
|
|
298
|
+
join(binDir, "git"),
|
|
299
|
+
[
|
|
300
|
+
"#!/bin/sh",
|
|
301
|
+
`echo "$*" >> '${log}'`,
|
|
302
|
+
`case "$1" in`,
|
|
303
|
+
` rev-parse) echo head0 ;;`,
|
|
304
|
+
` write-tree) echo tree0 ;;`,
|
|
305
|
+
` commit-tree) echo commit0 ;;`,
|
|
306
|
+
` config) echo false ;;`,
|
|
307
|
+
` update-ref) [ "$2" = HEAD ] && exit 1; exit 0 ;;`,
|
|
308
|
+
` *) exit 0 ;;`,
|
|
309
|
+
`esac`,
|
|
310
|
+
].join("\n"),
|
|
311
|
+
{ mode: 0o755 },
|
|
312
|
+
)
|
|
313
|
+
const result = runWithFakeGit(`set -eu\n${block}`, binDir)
|
|
314
|
+
expect(result.status).toBe(1)
|
|
315
|
+
const calls = readFileSync(log, "utf8").trim().split("\n")
|
|
316
|
+
expect(calls).toContain("update-ref HEAD commit0 head0")
|
|
317
|
+
const resetAt = calls.indexOf("reset -q")
|
|
318
|
+
expect(resetAt).toBeGreaterThan(-1)
|
|
319
|
+
expect(calls.slice(resetAt + 1)).toEqual([
|
|
320
|
+
"update-ref -d refs/gtd/kept/abc/build.naive/commit0",
|
|
321
|
+
])
|
|
322
|
+
expect(calls).not.toContain("reset --hard HEAD")
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it("a failed reset -q exits 1 and keeps the kept ref", () => {
|
|
326
|
+
const binDir = mkdtempSync(join(tmpdir(), "gitscript-fakebin-"))
|
|
327
|
+
const log = join(binDir, "log")
|
|
328
|
+
writeFileSync(
|
|
329
|
+
join(binDir, "git"),
|
|
330
|
+
[
|
|
331
|
+
"#!/bin/sh",
|
|
332
|
+
`echo "$*" >> '${log}'`,
|
|
333
|
+
`case "$1" in`,
|
|
334
|
+
` rev-parse) echo head0 ;;`,
|
|
335
|
+
` write-tree) echo tree0 ;;`,
|
|
336
|
+
` commit-tree) echo commit0 ;;`,
|
|
337
|
+
` config) echo false ;;`,
|
|
338
|
+
` update-ref) [ "$2" = HEAD ] && exit 1; exit 0 ;;`,
|
|
339
|
+
` reset) exit 1 ;;`,
|
|
340
|
+
` *) exit 0 ;;`,
|
|
341
|
+
`esac`,
|
|
342
|
+
].join("\n"),
|
|
343
|
+
{ mode: 0o755 },
|
|
344
|
+
)
|
|
345
|
+
const result = runWithFakeGit(`set -eu\n${block}`, binDir)
|
|
346
|
+
expect(result.status).toBe(1)
|
|
347
|
+
const calls = readFileSync(log, "utf8").trim().split("\n")
|
|
348
|
+
expect(calls).not.toContain("update-ref -d refs/gtd/kept/abc/build.naive/commit0")
|
|
349
|
+
})
|
|
350
|
+
})
|
package/src/emit/GitScript.ts
CHANGED
|
@@ -17,7 +17,7 @@ const commitAllowEmpty = (message: string): string => {
|
|
|
17
17
|
return [
|
|
18
18
|
`if ! out=$(git commit --allow-empty -m ${m} 2>&1); then`,
|
|
19
19
|
` case "$out" in`,
|
|
20
|
-
` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} ;;`,
|
|
20
|
+
` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} || exit 1 ;;`,
|
|
21
21
|
` *) printf '%s\\n' "$out" >&2; exit 1 ;;`,
|
|
22
22
|
` esac`,
|
|
23
23
|
`fi`,
|
|
@@ -47,6 +47,37 @@ export const hardResetTo = (ref: string): string => `git reset --hard ${shellQuo
|
|
|
47
47
|
*/
|
|
48
48
|
export const discardPending = (): string => `git add -A && git reset --hard HEAD`
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* POSIX sh (runs under dash). The marker is a `commit-tree` over HEAD's own
|
|
52
|
+
* tree, so no hook runs on it and its tree is the pre-turn tree by
|
|
53
|
+
* construction; `commit-tree` ignores `commit.gpgSign`, hence the explicit
|
|
54
|
+
* `-S`, read (and failing closed) before any object or ref is written; the
|
|
55
|
+
* index is already staged by then, so an early exit leaves the turn staged.
|
|
56
|
+
* The kept ref is deleted only after `reset -q` succeeds: it is then the
|
|
57
|
+
* turn's only handle.
|
|
58
|
+
* The branch moves before the final hard reset, so if that reset fails the
|
|
59
|
+
* marker has landed and the turn is still in the working tree (kept ref
|
|
60
|
+
* present); every other failure leaves the branch untouched.
|
|
61
|
+
*/
|
|
62
|
+
export const keepTurn = (refPrefix: string, message: string): string => {
|
|
63
|
+
const keptRef = `${shellQuote(`${refPrefix}/`)}"$gtd_kept"`
|
|
64
|
+
return [
|
|
65
|
+
`git add -A || exit 1`,
|
|
66
|
+
`gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
|
|
67
|
+
`if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
|
|
68
|
+
`gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
|
|
69
|
+
`gtd_tree=$(git write-tree) || exit 1`,
|
|
70
|
+
`gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
|
|
71
|
+
`git update-ref ${keptRef} "$gtd_kept" || exit 1`,
|
|
72
|
+
`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`,
|
|
74
|
+
` git reset -q && git update-ref -d ${keptRef}`,
|
|
75
|
+
` exit 1`,
|
|
76
|
+
`fi`,
|
|
77
|
+
`git reset --hard HEAD`,
|
|
78
|
+
].join("\n")
|
|
79
|
+
}
|
|
80
|
+
|
|
50
81
|
export const updateRef = (ref: string, hash: string): string =>
|
|
51
82
|
`git update-ref ${shellQuote(ref)} ${shellQuote(hash)}`
|
|
52
83
|
|
|
@@ -100,6 +131,8 @@ const renderGitWrite = (write: GitWrite): string => {
|
|
|
100
131
|
return commitAll(write.message)
|
|
101
132
|
case "updateRef":
|
|
102
133
|
return updateRef(write.ref, write.target)
|
|
134
|
+
case "keepTurn":
|
|
135
|
+
return keepTurn(write.ref, write.message)
|
|
103
136
|
}
|
|
104
137
|
}
|
|
105
138
|
|