@pmelab/gtd 63.0.0 → 64.0.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 +4 -3
- package/dist/chunks/{flows-DENt0uWV.mjs → flows-B7ZRCe-B.mjs} +1 -1
- package/dist/chunks/{rolldown-runtime-8H4AJuhK.mjs → rolldown-runtime-D7D4PA-g.mjs} +0 -1
- package/dist/chunks/{steering-DF_BMadH.mjs → steering-DkA51qDa.mjs} +1 -1
- package/dist/chunks/{testkit-DwypA7cH.mjs → testkit-zGjNeQNL.mjs} +1011 -313
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +3890 -1294
- package/dist/gtd.core.mjs +119 -118
- package/dist/steering.mjs +1 -1
- package/dist/testkit/gherkin.mjs +2 -2
- package/dist/testkit.mjs +2 -2
- package/dist/workflow-kit.mjs +2 -2
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +5 -8
- package/src/cli/Cli.ts +9 -27
- package/src/cli/Output.ts +2 -1
- package/src/cli/Program.test.ts +55 -53
- package/src/cli/Program.ts +11 -30
- package/src/cli/ToolCommands.ts +3 -4
- package/src/edge/Edge.test.ts +38 -12
- package/src/edge/Edge.ts +30 -10
- package/src/emit/SteeringMode.test.ts +4 -2
- package/src/emit/SteeringMode.ts +10 -9
- package/src/gherkin/messages.test.ts +1 -1
- package/src/gherkin/module-config.feature +1 -1
- package/src/gherkin/testkit.feature +2 -2
- package/src/install/Version.ts +14 -13
- package/src/platform/Host.ts +75 -22
- package/src/platform/NodePinFile.ts +44 -0
- package/src/platform/NodeRuntime.ts +19 -0
- package/src/platform/NodeSync.ts +64 -0
- package/src/platform/PinFile.ts +0 -50
- package/src/platform/Sha.ts +121 -0
- package/src/platform/SyncIo.ts +35 -0
- package/src/platform/Workspace.test.ts +8 -3
- package/src/platform/Workspace.ts +82 -123
- package/src/platform/WorktreeState.test.ts +8 -4
- package/src/platform/WorktreeState.ts +21 -19
- package/src/platform/index.ts +18 -5
- package/src/replay/Format.test.ts +16 -13
- package/src/replay/Format.ts +4 -2
- package/src/replay/Replay.test.ts +25 -5
- package/src/replay/Replay.ts +10 -6
- package/src/replay/Trailers.test.ts +10 -10
- package/src/replay/Trailers.ts +23 -1
- package/src/replay/index.ts +1 -0
- package/src/step/planStart.test.ts +2 -2
- package/src/step/planStep.test.ts +4 -4
- package/src/testkit/TestRepo.test.ts +1 -1
- package/src/testkit/TestRepo.ts +4 -18
- package/src/twins/Host.test.ts +151 -0
- package/src/twins/Layers.test.ts +2 -2
- package/src/twins/Layers.ts +44 -18
- package/src/twins/Path.test.ts +52 -0
- package/src/twins/SyncIo.test.ts +153 -0
- package/src/twins/SyncIo.ts +159 -0
- package/src/twins/cliIo.ts +8 -1
- package/src/twins/index.ts +5 -0
- package/src/workflow/discovery.test.ts +6 -5
- package/src/workflow/discovery.ts +43 -32
- package/src/workflow/fingerprint.test.ts +23 -3
- package/src/workflow/fingerprint.ts +9 -11
- package/src/workflow/load.test.ts +16 -6
- package/src/workflow/load.ts +107 -69
- package/src/workflow/pin.test.ts +3 -2
- package/src/workflow/pin.ts +17 -11
|
@@ -11,14 +11,14 @@ import { join } from "node:path"
|
|
|
11
11
|
import { mkdtempSync, realpathSync } from "node:fs"
|
|
12
12
|
import { execSync } from "node:child_process"
|
|
13
13
|
import { tmpdir } from "node:os"
|
|
14
|
-
import { Effect, Layer } from "effect"
|
|
14
|
+
import { Effect, Layer, type Path } from "effect"
|
|
15
15
|
import { describe, expect, it } from "vitest"
|
|
16
16
|
import { loopLogPath, worktreeGitDir } from "./WorktreeState.js"
|
|
17
17
|
import { GitService, Host, Workspace, NodeServices } from "./index.js"
|
|
18
18
|
import { InMemRepo, testLayers } from "../twins/index.js"
|
|
19
19
|
|
|
20
20
|
const provide = <A>(
|
|
21
|
-
eff: Effect.Effect<A, never, Host | Workspace>,
|
|
21
|
+
eff: Effect.Effect<A, never, Host | Path.Path | Workspace>,
|
|
22
22
|
repo: InMemRepo,
|
|
23
23
|
opts: {
|
|
24
24
|
readonly root?: string
|
|
@@ -112,9 +112,13 @@ describe("worktreeGitDir [real git]", () => {
|
|
|
112
112
|
const runIn = (root: string) => {
|
|
113
113
|
const hostLayer = Host.layer({ root, home: root, env: {} })
|
|
114
114
|
const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeServices.layer)))
|
|
115
|
-
const workspaceLayer = Workspace.Live.pipe(
|
|
115
|
+
const workspaceLayer = Workspace.Live.pipe(
|
|
116
|
+
Layer.provide(Layer.mergeAll(hostLayer, gitLayer, NodeServices.layer)),
|
|
117
|
+
)
|
|
116
118
|
return Effect.runPromise(
|
|
117
|
-
worktreeGitDir.pipe(
|
|
119
|
+
worktreeGitDir.pipe(
|
|
120
|
+
Effect.provide(Layer.mergeAll(hostLayer, workspaceLayer, NodeServices.layer)),
|
|
121
|
+
),
|
|
118
122
|
)
|
|
119
123
|
}
|
|
120
124
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Effect } from "effect"
|
|
1
|
+
import { Effect, Path } from "effect"
|
|
3
2
|
import { Host } from "./Host.js"
|
|
4
3
|
import { Workspace } from "./Workspace.js"
|
|
5
4
|
|
|
@@ -11,9 +10,10 @@ const GITDIR_POINTER = /^gitdir:\s*(.+)$/m
|
|
|
11
10
|
* read failure or a `.git` file with no `gitdir:` line both fall back to
|
|
12
11
|
* `".git"`, which is also correct for the in-memory test tier.
|
|
13
12
|
*/
|
|
14
|
-
export const worktreeGitDir: Effect.Effect<string, never, Host | Workspace> =
|
|
15
|
-
function* () {
|
|
13
|
+
export const worktreeGitDir: Effect.Effect<string, never, Host | Path.Path | Workspace> =
|
|
14
|
+
Effect.gen(function* () {
|
|
16
15
|
const { root } = yield* Host
|
|
16
|
+
const path = yield* Path.Path
|
|
17
17
|
const workspace = yield* Workspace
|
|
18
18
|
const content = yield* workspace
|
|
19
19
|
.read(".git")
|
|
@@ -21,9 +21,8 @@ export const worktreeGitDir: Effect.Effect<string, never, Host | Workspace> = Ef
|
|
|
21
21
|
const pointer = content === undefined ? null : GITDIR_POINTER.exec(content)
|
|
22
22
|
if (pointer === null) return ".git"
|
|
23
23
|
const gitdir = pointer[1]!.trim()
|
|
24
|
-
return isAbsolute(gitdir) ? gitdir : join(root, gitdir)
|
|
25
|
-
}
|
|
26
|
-
)
|
|
24
|
+
return path.isAbsolute(gitdir) ? gitdir : path.join(root, gitdir)
|
|
25
|
+
})
|
|
27
26
|
|
|
28
27
|
/**
|
|
29
28
|
* The per-worktree loop log path `gtd next --json` reports as `log`.
|
|
@@ -34,17 +33,20 @@ export const worktreeGitDir: Effect.Effect<string, never, Host | Workspace> = Ef
|
|
|
34
33
|
* doesn't want that (e.g. the e2e suite) must scrub it before invoking gtd.
|
|
35
34
|
* Advisory only — gtd neither creates nor truncates the file.
|
|
36
35
|
*/
|
|
37
|
-
export const loopLogPath: Effect.Effect<string, never, Host | Workspace> = Effect.gen(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
export const loopLogPath: Effect.Effect<string, never, Host | Path.Path | Workspace> = Effect.gen(
|
|
37
|
+
function* () {
|
|
38
|
+
const { root, env } = yield* Host
|
|
39
|
+
const path = yield* Path.Path
|
|
40
|
+
const override = env.GTD_LOOP_LOG
|
|
41
|
+
if (override !== undefined && override !== "") return override
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
const gitDirEnv = env.GIT_DIR
|
|
44
|
+
if (gitDirEnv !== undefined && gitDirEnv !== "") {
|
|
45
|
+
const gitDir = path.isAbsolute(gitDirEnv) ? gitDirEnv : path.join(root, gitDirEnv)
|
|
46
|
+
return path.join(gitDir, "gtd-loop.log")
|
|
47
|
+
}
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
49
|
+
const gitDir = yield* worktreeGitDir
|
|
50
|
+
return path.join(gitDir, "gtd-loop.log")
|
|
51
|
+
},
|
|
52
|
+
)
|
package/src/platform/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* gtd's
|
|
3
|
-
* working-tree reads/writes plus the read-at-ref path)
|
|
4
|
-
* home, env, scratch
|
|
2
|
+
* gtd's ports onto its environment: `Workspace` (repo file content —
|
|
3
|
+
* working-tree reads/writes plus the read-at-ref path), `Host` (root,
|
|
4
|
+
* home, env, scratch, process identity, stdio) and `SyncIo`, the reads that
|
|
5
|
+
* must stay synchronous, which only `Node*.ts` implements against Node. `GitService` moved in alongside `Workspace` since
|
|
5
6
|
* `committed` IS `git.readFileAtRef` — one module owns every read.
|
|
6
7
|
*
|
|
7
8
|
* Seam rule, stated once: a seam is a `Context.Service` unless the consuming
|
|
@@ -9,7 +10,14 @@
|
|
|
9
10
|
* reads are that exception, and use the `*Sync` members.
|
|
10
11
|
*/
|
|
11
12
|
export { Workspace, type WorkspaceOps } from "./Workspace.js"
|
|
12
|
-
export {
|
|
13
|
+
export {
|
|
14
|
+
Host,
|
|
15
|
+
nodeStdio,
|
|
16
|
+
scratchDirOf,
|
|
17
|
+
type HostOps,
|
|
18
|
+
type NodeProcess,
|
|
19
|
+
type Stdio,
|
|
20
|
+
} from "./Host.js"
|
|
13
21
|
export {
|
|
14
22
|
GitService,
|
|
15
23
|
type GitOperations,
|
|
@@ -29,4 +37,9 @@ export {
|
|
|
29
37
|
readRetainedHistory,
|
|
30
38
|
restorability,
|
|
31
39
|
} from "./RetainedHistory.js"
|
|
32
|
-
export { livePinFile, runPinModule
|
|
40
|
+
export { livePinFile, runPinModule } from "./NodePinFile.js"
|
|
41
|
+
export type { PinFile } from "./PinFile.js"
|
|
42
|
+
export type { FileInfo, SyncIo } from "./SyncIo.js"
|
|
43
|
+
export { nodeSyncIo } from "./NodeSync.js"
|
|
44
|
+
export { pureHash } from "./Sha.js"
|
|
45
|
+
export * as NodeRuntime from "./NodeRuntime.js"
|
|
@@ -3,12 +3,13 @@ import { formatCommitMessage, formatFault, parseCommitMessage } from "./index.js
|
|
|
3
3
|
|
|
4
4
|
describe("the history format", () => {
|
|
5
5
|
it("accepts a history in the current format and in the one before it", () => {
|
|
6
|
-
expect(formatFault([{ hash: "a".repeat(40), format:
|
|
6
|
+
expect(formatFault([{ hash: "a".repeat(40), format: 4 }])).toBeUndefined()
|
|
7
7
|
expect(
|
|
8
8
|
formatFault([
|
|
9
9
|
{ hash: "a".repeat(40), format: 1 },
|
|
10
10
|
{ hash: "b".repeat(40), format: 2 },
|
|
11
11
|
{ hash: "c".repeat(40), format: 3 },
|
|
12
|
+
{ hash: "d".repeat(40), format: 4 },
|
|
12
13
|
]),
|
|
13
14
|
).toBeUndefined()
|
|
14
15
|
expect(formatFault([])).toBeUndefined()
|
|
@@ -17,11 +18,11 @@ describe("the history format", () => {
|
|
|
17
18
|
it("names the first foreign commit, the found and readable formats, and what to do", () => {
|
|
18
19
|
const fault = formatFault([
|
|
19
20
|
{ hash: "a".repeat(40), format: 1 },
|
|
20
|
-
{ hash: "b".repeat(40), format:
|
|
21
|
-
{ hash: "c".repeat(40), format:
|
|
21
|
+
{ hash: "b".repeat(40), format: 5 },
|
|
22
|
+
{ hash: "c".repeat(40), format: 6 },
|
|
22
23
|
])
|
|
23
24
|
expect(fault).toBe(
|
|
24
|
-
"gtd: commit bbbbbbb is written in history format
|
|
25
|
+
"gtd: commit bbbbbbb is written in history format 5, but this gtd reads formats 1 to 4 — run a gtd release that reads format 5 to continue this process",
|
|
25
26
|
)
|
|
26
27
|
})
|
|
27
28
|
})
|
|
@@ -36,8 +37,8 @@ describe("Gtd-Approved and the history format", () => {
|
|
|
36
37
|
expect(message).toContain(
|
|
37
38
|
'Gtd-Approved: {"step":"seam#1","name":"Test","email":"test@test.com"}',
|
|
38
39
|
)
|
|
39
|
-
expect(message).toContain("Gtd-Format:
|
|
40
|
-
expect(parseCommitMessage(message).format).toBe(
|
|
40
|
+
expect(message).toContain("Gtd-Format: 4")
|
|
41
|
+
expect(parseCommitMessage(message).format).toBe(4)
|
|
41
42
|
})
|
|
42
43
|
})
|
|
43
44
|
|
|
@@ -45,20 +46,20 @@ describe("Gtd-Pin", () => {
|
|
|
45
46
|
it("round-trips add and remove, after Gtd-Mark and before Gtd-Branch", () => {
|
|
46
47
|
const message = formatCommitMessage({
|
|
47
48
|
actor: "check",
|
|
48
|
-
from: "unpin",
|
|
49
|
+
from: "gtd:unpin",
|
|
49
50
|
to: "idle",
|
|
50
51
|
pin: "remove",
|
|
51
52
|
branch: "main",
|
|
52
53
|
})
|
|
53
54
|
expect(message).toBe(
|
|
54
|
-
"gtd(check): unpin → idle\n\nGtd-Pin: remove\nGtd-Branch: main\nGtd-Format:
|
|
55
|
+
"gtd(check): gtd:unpin → idle\n\nGtd-Pin: remove\nGtd-Branch: main\nGtd-Format: 4",
|
|
55
56
|
)
|
|
56
57
|
expect(parseCommitMessage(message).pin).toBe("remove")
|
|
57
|
-
expect(parseCommitMessage("gtd(check): pin → a\n\nGtd-Pin: add").pin).toBe("add")
|
|
58
|
+
expect(parseCommitMessage("gtd(check): gtd:pin → a\n\nGtd-Pin: add").pin).toBe("add")
|
|
58
59
|
})
|
|
59
60
|
|
|
60
61
|
it("reads an unknown value as no pin", () => {
|
|
61
|
-
expect(parseCommitMessage("gtd(check): pin → a\n\nGtd-Pin: frozen").pin).toBeUndefined()
|
|
62
|
+
expect(parseCommitMessage("gtd(check): gtd:pin → a\n\nGtd-Pin: frozen").pin).toBeUndefined()
|
|
62
63
|
})
|
|
63
64
|
})
|
|
64
65
|
|
|
@@ -67,15 +68,17 @@ describe("Gtd-Anchor", () => {
|
|
|
67
68
|
const anchor = "a".repeat(40)
|
|
68
69
|
const message = formatCommitMessage({
|
|
69
70
|
actor: "check",
|
|
70
|
-
from: "pin",
|
|
71
|
+
from: "gtd:pin",
|
|
71
72
|
to: "b",
|
|
72
73
|
pin: "add",
|
|
73
74
|
anchor,
|
|
74
75
|
})
|
|
75
76
|
expect(message).toBe(
|
|
76
|
-
`gtd(check): pin → b\n\nGtd-Pin: add\nGtd-Anchor: ${anchor}\nGtd-Format:
|
|
77
|
+
`gtd(check): gtd:pin → b\n\nGtd-Pin: add\nGtd-Anchor: ${anchor}\nGtd-Format: 4`,
|
|
77
78
|
)
|
|
78
79
|
expect(parseCommitMessage(message).anchor).toBe(anchor)
|
|
79
|
-
expect(
|
|
80
|
+
expect(
|
|
81
|
+
parseCommitMessage("gtd(check): gtd:pin → b\n\nGtd-Anchor: HEAD~2").anchor,
|
|
82
|
+
).toBeUndefined()
|
|
80
83
|
})
|
|
81
84
|
})
|
package/src/replay/Format.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Bump only when what replay reads back out of git changes incompatibly — the
|
|
2
2
|
// package version must not stand in for it, since most majors leave history
|
|
3
3
|
// untouched. Format 2 adds pin commits, which format 1 would replay as turns;
|
|
4
|
-
// format 3 adds the anchor, whose set-aside commits format 2 would replay
|
|
5
|
-
|
|
4
|
+
// format 3 adds the anchor, whose set-aside commits format 2 would replay;
|
|
5
|
+
// format 4 renames the pin beats `gtd:pin`/`gtd:unpin`, which format 3 would
|
|
6
|
+
// read as steps.
|
|
7
|
+
export const HISTORY_FORMAT = 4
|
|
6
8
|
const OLDEST_READABLE_FORMAT = 1
|
|
7
9
|
|
|
8
10
|
const short = (hash: string): string => hash.slice(0, 7)
|
|
@@ -1226,7 +1226,7 @@ describe("pin commits", () => {
|
|
|
1226
1226
|
const pinned = () =>
|
|
1227
1227
|
new History()
|
|
1228
1228
|
.land("human", "idle", 1, "build", { "NOTE.md": "n" })
|
|
1229
|
-
.raw("gtd(check): pin → build\n\nGtd-Pin: add", { [PIN]: "gtdpin 1 gtd 1\n" })
|
|
1229
|
+
.raw("gtd(check): gtd:pin → build\n\nGtd-Pin: add", { [PIN]: "gtdpin 1 gtd 1\n" })
|
|
1230
1230
|
|
|
1231
1231
|
it("are never a turn: the next step answers from the commit after the pin", async () => {
|
|
1232
1232
|
const flow = async () => {
|
|
@@ -1268,15 +1268,15 @@ describe("pin commits", () => {
|
|
|
1268
1268
|
await agent("build", "b")
|
|
1269
1269
|
}
|
|
1270
1270
|
const h = pinned()
|
|
1271
|
-
.land("agent", "build", 1, "unpin", { "a.ts": "a" })
|
|
1272
|
-
.raw("gtd(check): unpin → idle\n\nGtd-Pin: remove", { [PIN]: null })
|
|
1271
|
+
.land("agent", "build", 1, "gtd:unpin", { "a.ts": "a" })
|
|
1272
|
+
.raw("gtd(check): gtd:unpin → idle\n\nGtd-Pin: remove", { [PIN]: null })
|
|
1273
1273
|
expect((await replayOf(flow, h)).kind).toBe("ended")
|
|
1274
1274
|
})
|
|
1275
1275
|
})
|
|
1276
1276
|
|
|
1277
1277
|
describe("replay: a repin's anchor", () => {
|
|
1278
1278
|
const anchorTo = (to: string, at: number): string =>
|
|
1279
|
-
formatCommitMessage({ actor: "check", from: "pin", to, pin: "add", anchor: hashOf(at) })
|
|
1279
|
+
formatCommitMessage({ actor: "check", from: "gtd:pin", to, pin: "add", anchor: hashOf(at) })
|
|
1280
1280
|
|
|
1281
1281
|
// The old flow went idle → build → old; this one goes idle → build → check.
|
|
1282
1282
|
const anchoredHistory = (): History =>
|
|
@@ -1345,11 +1345,31 @@ describe("replay: a repin's anchor", () => {
|
|
|
1345
1345
|
const h = new History()
|
|
1346
1346
|
.land("human", "idle", 1, "build", { "a.txt": "a" })
|
|
1347
1347
|
.land("agent", "build", 1, "old")
|
|
1348
|
-
.raw(anchorTo("unpin", 1))
|
|
1348
|
+
.raw(anchorTo("gtd:unpin", 1))
|
|
1349
1349
|
const outcome = await replayOf(async () => void (await human("idle")), h)
|
|
1350
1350
|
expect(outcome.kind).toBe("ended")
|
|
1351
1351
|
})
|
|
1352
1352
|
|
|
1353
|
+
it("closes the process when a format 3 anchor names the unpin by its old name", async () => {
|
|
1354
|
+
const h = new History()
|
|
1355
|
+
.land("human", "idle", 1, "build", { "a.txt": "a" })
|
|
1356
|
+
.land("agent", "build", 1, "old")
|
|
1357
|
+
.raw(`gtd(check): pin → unpin\n\nGtd-Pin: add\nGtd-Anchor: ${hashOf(1)}\nGtd-Format: 3`)
|
|
1358
|
+
const outcome = await replayOf(async () => void (await human("idle")), h)
|
|
1359
|
+
expect(outcome.kind).toBe("ended")
|
|
1360
|
+
})
|
|
1361
|
+
|
|
1362
|
+
it("reads a format 4 anchor naming unpin as a step", async () => {
|
|
1363
|
+
const outcome = await replayOf(
|
|
1364
|
+
async () => void (await human("idle")),
|
|
1365
|
+
new History()
|
|
1366
|
+
.land("human", "idle", 1, "build", { "a.txt": "a" })
|
|
1367
|
+
.land("agent", "build", 1, "old")
|
|
1368
|
+
.raw(anchorTo("unpin", 1)),
|
|
1369
|
+
)
|
|
1370
|
+
expect(outcome).toMatchObject({ kind: "divergence", reached: undefined })
|
|
1371
|
+
})
|
|
1372
|
+
|
|
1353
1373
|
it("fails when it names no commit replay reads", async () => {
|
|
1354
1374
|
const h = anchoredHistory().raw(anchorTo("build", 2))
|
|
1355
1375
|
const outcome = await replayOf(async () => void (await human("idle")), h)
|
package/src/replay/Replay.ts
CHANGED
|
@@ -29,10 +29,14 @@ import {
|
|
|
29
29
|
type CommitMessage,
|
|
30
30
|
type JudgeVerdict,
|
|
31
31
|
type StepId,
|
|
32
|
-
|
|
32
|
+
ENGINE_PREFIX,
|
|
33
|
+
pinStateIn,
|
|
33
34
|
} from "./Trailers.js"
|
|
34
35
|
|
|
35
|
-
const
|
|
36
|
+
const namesUnpin = (commit: ParsedCommit): boolean => {
|
|
37
|
+
const unpin = pinStateIn(commit.parsed.format, "remove")
|
|
38
|
+
return unpin !== undefined && commit.parsed.parsed?.to === unpin
|
|
39
|
+
}
|
|
36
40
|
|
|
37
41
|
export interface EpisodeCommit {
|
|
38
42
|
readonly hash: string
|
|
@@ -402,7 +406,7 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
402
406
|
position = commit
|
|
403
407
|
const to = commit.parsed.parsed?.to
|
|
404
408
|
expectedNext =
|
|
405
|
-
to === undefined ||
|
|
409
|
+
to === undefined || namesUnpin(commit) ? undefined : { name: to, hash: commit.hash }
|
|
406
410
|
anchorPending = expectedNext !== undefined
|
|
407
411
|
}
|
|
408
412
|
}
|
|
@@ -672,7 +676,7 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
672
676
|
// A turn that ends a pinned process names the unpin, which is no step.
|
|
673
677
|
const expectNextOf = (commit: ParsedCommit): void => {
|
|
674
678
|
const to = commit.parsed.parsed?.to
|
|
675
|
-
if (to !== undefined &&
|
|
679
|
+
if (to !== undefined && !namesUnpin(commit)) expectedNext = { name: to, hash: commit.hash }
|
|
676
680
|
}
|
|
677
681
|
|
|
678
682
|
const noteKept = (step: ReachedStep, commit: ParsedCommit): void => {
|
|
@@ -719,8 +723,8 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
719
723
|
|
|
720
724
|
// gtd.config.ts is loaded without a type check.
|
|
721
725
|
const requestFault = (request: Exclude<StepRequest, { kind: "restart" }>): string | undefined => {
|
|
722
|
-
if (
|
|
723
|
-
return `gtd: step "${scoped(request.name)}": the
|
|
726
|
+
if (scoped(request.name).startsWith(ENGINE_PREFIX)) {
|
|
727
|
+
return `gtd: step "${scoped(request.name)}": the "${ENGINE_PREFIX}" prefix is reserved for gtd's own beats — rename the step`
|
|
724
728
|
}
|
|
725
729
|
if (request.kind === "run" && typeof request.body !== "string") {
|
|
726
730
|
return `gtd: step "${scoped(request.name)}": a run() body is a shell script string — decide in flow code, then render the script (see checkScript and friends)`
|
|
@@ -46,7 +46,7 @@ describe("the commit-message codec", () => {
|
|
|
46
46
|
reviewBase: "abc",
|
|
47
47
|
})
|
|
48
48
|
expect(message).toBe(
|
|
49
|
-
"gtd(human): fix-precheck\n\nGtd-Workflow: fix\nGtd-Review-Base: abc\nGtd-Format:
|
|
49
|
+
"gtd(human): fix-precheck\n\nGtd-Workflow: fix\nGtd-Review-Base: abc\nGtd-Format: 4",
|
|
50
50
|
)
|
|
51
51
|
expect(parseCommitMessage(message).workflow).toBe("fix")
|
|
52
52
|
expect(parseCommitMessage("gtd(human): fix-precheck").workflow).toBeUndefined()
|
|
@@ -56,7 +56,7 @@ describe("the commit-message codec", () => {
|
|
|
56
56
|
const one = formatCommitMessage({ actor: "human", to: "x", vars: { b: "2", a: "1", c: "" } })
|
|
57
57
|
const two = formatCommitMessage({ actor: "human", to: "x", vars: { c: "", a: "1", b: "2" } })
|
|
58
58
|
expect(one).toBe(two)
|
|
59
|
-
expect(one).toBe("gtd(human): x\n\nGtd-Var: a=1\nGtd-Var: b=2\nGtd-Var: c=\nGtd-Format:
|
|
59
|
+
expect(one).toBe("gtd(human): x\n\nGtd-Var: a=1\nGtd-Var: b=2\nGtd-Var: c=\nGtd-Format: 4")
|
|
60
60
|
expect(parseCommitMessage(one).vars).toEqual({ a: "1", b: "2", c: "" })
|
|
61
61
|
})
|
|
62
62
|
|
|
@@ -81,8 +81,8 @@ describe("the commit-message codec", () => {
|
|
|
81
81
|
it("reads a Gtd-Version carrying control bytes as unknown, and keeps spaces and parens", () => {
|
|
82
82
|
const withVersion = (value: string) =>
|
|
83
83
|
formatCommitMessage({ actor: "a", to: "b" }).replace(
|
|
84
|
-
"Gtd-Format:
|
|
85
|
-
`Gtd-Version: ${value}\nGtd-Format:
|
|
84
|
+
"Gtd-Format: 4",
|
|
85
|
+
`Gtd-Version: ${value}\nGtd-Format: 4`,
|
|
86
86
|
)
|
|
87
87
|
expect(parseCommitMessage(withVersion("1.0\u001b]0;x\u0007")).version).toBeUndefined()
|
|
88
88
|
expect(parseCommitMessage(withVersion("1.0\u009b")).version).toBeUndefined()
|
|
@@ -100,18 +100,18 @@ describe("the commit-message codec", () => {
|
|
|
100
100
|
|
|
101
101
|
it("writes the current Gtd-Format when branch and version are written", () => {
|
|
102
102
|
const message = formatCommitMessage({ actor: "a", to: "b", branch: "m", version: "1" })
|
|
103
|
-
expect(message.endsWith("Gtd-Format:
|
|
103
|
+
expect(message.endsWith("Gtd-Format: 4")).toBe(true)
|
|
104
104
|
})
|
|
105
105
|
|
|
106
106
|
it("writes Gtd-Format last on every message, even one with no other trailer", () => {
|
|
107
107
|
const message = formatCommitMessage({ actor: "agent", to: "building" })
|
|
108
|
-
expect(message).toBe("gtd(agent): building\n\nGtd-Format:
|
|
109
|
-
expect(parseCommitMessage(message).format).toBe(
|
|
108
|
+
expect(message).toBe("gtd(agent): building\n\nGtd-Format: 4")
|
|
109
|
+
expect(parseCommitMessage(message).format).toBe(4)
|
|
110
110
|
})
|
|
111
111
|
|
|
112
112
|
it("reads a message without Gtd-Format as format 1, and an explicit one as written", () => {
|
|
113
113
|
expect(parseCommitMessage("gtd(agent): a → b\n\nGtd-Step: a#1").format).toBe(1)
|
|
114
|
-
expect(parseCommitMessage("gtd(agent): a → b\n\nGtd-Format:
|
|
114
|
+
expect(parseCommitMessage("gtd(agent): a → b\n\nGtd-Format: 4").format).toBe(4)
|
|
115
115
|
expect(parseCommitMessage("gtd(agent): a → b\n\nGtd-Format: two").format).toBe(1)
|
|
116
116
|
})
|
|
117
117
|
|
|
@@ -149,7 +149,7 @@ describe("Gtd-Kept", () => {
|
|
|
149
149
|
it("reads undefined when absent, and leaves Gtd-Format at the current format", () => {
|
|
150
150
|
const parsed = parseCommitMessage(formatCommitMessage({ actor: "agent", to: "b" }))
|
|
151
151
|
expect(parsed.kept).toBeUndefined()
|
|
152
|
-
expect(parsed.format).toBe(
|
|
152
|
+
expect(parsed.format).toBe(4)
|
|
153
153
|
})
|
|
154
154
|
})
|
|
155
155
|
|
|
@@ -289,7 +289,7 @@ describe("Gtd-Mark", () => {
|
|
|
289
289
|
const at = lines.findIndex((l) => l.startsWith("Gtd-Mark:"))
|
|
290
290
|
expect(lines[at - 1]).toMatch(/^Gtd-Judge:/)
|
|
291
291
|
expect(lines[at + 1]).toMatch(/^Gtd-Approved:/)
|
|
292
|
-
expect(lines).toContain("Gtd-Format:
|
|
292
|
+
expect(lines).toContain("Gtd-Format: 4")
|
|
293
293
|
})
|
|
294
294
|
|
|
295
295
|
it("writes nothing without marks", () => {
|
package/src/replay/Trailers.ts
CHANGED
|
@@ -70,8 +70,30 @@ export interface Approval {
|
|
|
70
70
|
|
|
71
71
|
export type PinChange = PinMark
|
|
72
72
|
|
|
73
|
+
/** Step names a workflow may not take: gtd's own beats live under it. */
|
|
74
|
+
export const ENGINE_PREFIX = "gtd:"
|
|
75
|
+
|
|
73
76
|
/** What a pin and an unpin beat rest at, and their commits name — neither is a step of the workflow. */
|
|
74
|
-
export const PIN_STATES: Readonly<Record<PinChange, string>> = {
|
|
77
|
+
export const PIN_STATES: Readonly<Record<PinChange, string>> = {
|
|
78
|
+
add: `${ENGINE_PREFIX}pin`,
|
|
79
|
+
remove: `${ENGINE_PREFIX}unpin`,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The names formats 2 and 3 gave the pin beats, before `gtd:` reserved them. */
|
|
83
|
+
export const LEGACY_PIN_STATES: Readonly<Record<PinChange, string>> = {
|
|
84
|
+
add: "pin",
|
|
85
|
+
remove: "unpin",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const SCOPED_PIN_FORMAT = 4
|
|
89
|
+
|
|
90
|
+
/** The name a commit in `format` gives a pin beat; format 1 has none. */
|
|
91
|
+
export const pinStateIn = (format: number, change: PinChange): string | undefined =>
|
|
92
|
+
format >= SCOPED_PIN_FORMAT
|
|
93
|
+
? PIN_STATES[change]
|
|
94
|
+
: format >= 2
|
|
95
|
+
? LEGACY_PIN_STATES[change]
|
|
96
|
+
: undefined
|
|
75
97
|
|
|
76
98
|
export interface CommitMessage {
|
|
77
99
|
readonly subject: string
|
package/src/replay/index.ts
CHANGED
|
@@ -176,7 +176,7 @@ describe("planStart", () => {
|
|
|
176
176
|
kind: "gitWrite",
|
|
177
177
|
write: {
|
|
178
178
|
kind: "commitAll",
|
|
179
|
-
message: "gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=\nGtd-Format:
|
|
179
|
+
message: "gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=\nGtd-Format: 4",
|
|
180
180
|
},
|
|
181
181
|
},
|
|
182
182
|
{ kind: "outcome", outcome: { kind: "commit", subject: "gtd(human): work" } },
|
|
@@ -195,7 +195,7 @@ describe("planStart", () => {
|
|
|
195
195
|
if (write?.kind !== "gitWrite" || write.write.kind !== "commitAll")
|
|
196
196
|
throw new Error("expected a gitWrite step")
|
|
197
197
|
expect(write.write.message).toBe(
|
|
198
|
-
"gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=main\nGtd-Format:
|
|
198
|
+
"gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=main\nGtd-Format: 4",
|
|
199
199
|
)
|
|
200
200
|
const outcome = plan.steps.find((s) => s.kind === "outcome")
|
|
201
201
|
expect(outcome).toEqual({
|
|
@@ -39,7 +39,7 @@ describe("planStep — commit", () => {
|
|
|
39
39
|
expect(outcome.steps).toEqual([
|
|
40
40
|
{
|
|
41
41
|
kind: "gitWrite",
|
|
42
|
-
write: { kind: "commitAll", message: "gtd(agent): await-answers\n\nGtd-Format:
|
|
42
|
+
write: { kind: "commitAll", message: "gtd(agent): await-answers\n\nGtd-Format: 4" },
|
|
43
43
|
},
|
|
44
44
|
{ kind: "outcome", outcome: { kind: "commit", subject: "gtd(agent): await-answers" } },
|
|
45
45
|
])
|
|
@@ -59,7 +59,7 @@ describe("planStep — commit", () => {
|
|
|
59
59
|
kind: "gitWrite",
|
|
60
60
|
write: {
|
|
61
61
|
kind: "commitAll",
|
|
62
|
-
message: "gtd(human): building → done\n\nGtd-Step: building#1\nGtd-Format:
|
|
62
|
+
message: "gtd(human): building → done\n\nGtd-Step: building#1\nGtd-Format: 4",
|
|
63
63
|
},
|
|
64
64
|
})
|
|
65
65
|
expect(outcome.steps.at(-1)).toEqual({
|
|
@@ -99,7 +99,7 @@ describe("planStep — commit", () => {
|
|
|
99
99
|
expect(trailers[1]).toBe("Gtd-Cost: 1.5 m")
|
|
100
100
|
expect(trailers[2]).toBe('Gtd-Judge: {"id":"q1","answer":true,"p":0.9}')
|
|
101
101
|
expect(trailers[3]).toBe('Gtd-Judge: {"id":"q2","answer":"x","p":0.4}')
|
|
102
|
-
expect(trailers[4]).toBe("Gtd-Format:
|
|
102
|
+
expect(trailers[4]).toBe("Gtd-Format: 4")
|
|
103
103
|
expect(trailers).toHaveLength(5)
|
|
104
104
|
})
|
|
105
105
|
})
|
|
@@ -182,7 +182,7 @@ describe("planStep — keepTurn", () => {
|
|
|
182
182
|
write: {
|
|
183
183
|
kind: "keepTurn",
|
|
184
184
|
ref: kept,
|
|
185
|
-
message: "gtd(agent): building → checking\n\nGtd-Step: building#1\nGtd-Format:
|
|
185
|
+
message: "gtd(agent): building → checking\n\nGtd-Step: building#1\nGtd-Format: 4",
|
|
186
186
|
},
|
|
187
187
|
})
|
|
188
188
|
})
|
|
@@ -61,7 +61,7 @@ describe("testRepo", () => {
|
|
|
61
61
|
expect(repo.subjects()).toEqual([
|
|
62
62
|
"chore: initial commit",
|
|
63
63
|
"gtd(agent): plan → build",
|
|
64
|
-
"gtd(check): pin → build",
|
|
64
|
+
"gtd(check): gtd:pin → build",
|
|
65
65
|
"gtd(agent): build → check",
|
|
66
66
|
"gtd(check): check → sign-off",
|
|
67
67
|
])
|
package/src/testkit/TestRepo.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
|
-
import { PassThrough } from "node:stream"
|
|
3
2
|
import { runCli } from "../cli/index.js"
|
|
4
3
|
import { EXIT_OK, type BeatDocument } from "../wire/index.js"
|
|
5
4
|
import {
|
|
@@ -450,8 +449,8 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
450
449
|
|
|
451
450
|
// The modules are registered only while gtd runs, so nothing outlives the
|
|
452
451
|
// call that loads them and no test can reach another's module.
|
|
453
|
-
const
|
|
454
|
-
const { io, result } = makeCapturingCliIo(repo, seenEnv())
|
|
452
|
+
const gtdWithStdin = async (stdin: string, ...args: string[]): Promise<CapturedCliResult> => {
|
|
453
|
+
const { io, result } = makeCapturingCliIo(repo, seenEnv(), {}, stdin)
|
|
455
454
|
if (typeof workflow !== "string") registry().set(id, workflow)
|
|
456
455
|
for (const [specifier, module] of modules) registry().set(moduleKey(specifier), module)
|
|
457
456
|
try {
|
|
@@ -462,19 +461,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
462
461
|
}
|
|
463
462
|
return result()
|
|
464
463
|
}
|
|
465
|
-
|
|
466
|
-
// `gtd judge answer` reads its verdict off stdin, so it is swapped for the call.
|
|
467
|
-
const withStdin = async <T>(stdin: string, fn: () => Promise<T>): Promise<T> => {
|
|
468
|
-
const saved = Object.getOwnPropertyDescriptor(process, "stdin")
|
|
469
|
-
const fake = new PassThrough()
|
|
470
|
-
Object.defineProperty(process, "stdin", { value: fake, configurable: true })
|
|
471
|
-
fake.end(stdin)
|
|
472
|
-
try {
|
|
473
|
-
return await fn()
|
|
474
|
-
} finally {
|
|
475
|
-
if (saved) Object.defineProperty(process, "stdin", saved)
|
|
476
|
-
}
|
|
477
|
-
}
|
|
464
|
+
const gtd = (...args: string[]): Promise<CapturedCliResult> => gtdWithStdin("", ...args)
|
|
478
465
|
|
|
479
466
|
const apply = (what: string, script: string): void => {
|
|
480
467
|
if (script.length === 0) return
|
|
@@ -488,8 +475,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
488
475
|
stdin?: string,
|
|
489
476
|
refused: (error: Error) => Error = (error) => error,
|
|
490
477
|
): Promise<CapturedCliResult> => {
|
|
491
|
-
const call = (...extra: string[]) =>
|
|
492
|
-
stdin === undefined ? gtd(...args, ...extra) : withStdin(stdin, () => gtd(...args, ...extra))
|
|
478
|
+
const call = (...extra: string[]) => gtdWithStdin(stdin ?? "", ...args, ...extra)
|
|
493
479
|
const reported = await call()
|
|
494
480
|
if (reported.exitCode !== EXIT_OK) throw refused(failed(`gtd ${args.join(" ")}`, reported))
|
|
495
481
|
const script = await call("--json=script")
|