@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.
Files changed (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +4 -3
  3. package/dist/chunks/{flows-DENt0uWV.mjs → flows-B7ZRCe-B.mjs} +1 -1
  4. package/dist/chunks/{rolldown-runtime-8H4AJuhK.mjs → rolldown-runtime-D7D4PA-g.mjs} +0 -1
  5. package/dist/chunks/{steering-DF_BMadH.mjs → steering-DkA51qDa.mjs} +1 -1
  6. package/dist/chunks/{testkit-DwypA7cH.mjs → testkit-zGjNeQNL.mjs} +1011 -313
  7. package/dist/flows.mjs +1 -1
  8. package/dist/gtd.bundle.mjs +3890 -1294
  9. package/dist/gtd.core.mjs +119 -118
  10. package/dist/steering.mjs +1 -1
  11. package/dist/testkit/gherkin.mjs +2 -2
  12. package/dist/testkit.mjs +2 -2
  13. package/dist/workflow-kit.mjs +2 -2
  14. package/package.json +1 -1
  15. package/src/cli/Cli.test.ts +5 -8
  16. package/src/cli/Cli.ts +9 -27
  17. package/src/cli/Output.ts +2 -1
  18. package/src/cli/Program.test.ts +55 -53
  19. package/src/cli/Program.ts +11 -30
  20. package/src/cli/ToolCommands.ts +3 -4
  21. package/src/edge/Edge.test.ts +38 -12
  22. package/src/edge/Edge.ts +30 -10
  23. package/src/emit/SteeringMode.test.ts +4 -2
  24. package/src/emit/SteeringMode.ts +10 -9
  25. package/src/gherkin/messages.test.ts +1 -1
  26. package/src/gherkin/module-config.feature +1 -1
  27. package/src/gherkin/testkit.feature +2 -2
  28. package/src/install/Version.ts +14 -13
  29. package/src/platform/Host.ts +75 -22
  30. package/src/platform/NodePinFile.ts +44 -0
  31. package/src/platform/NodeRuntime.ts +19 -0
  32. package/src/platform/NodeSync.ts +64 -0
  33. package/src/platform/PinFile.ts +0 -50
  34. package/src/platform/Sha.ts +121 -0
  35. package/src/platform/SyncIo.ts +35 -0
  36. package/src/platform/Workspace.test.ts +8 -3
  37. package/src/platform/Workspace.ts +82 -123
  38. package/src/platform/WorktreeState.test.ts +8 -4
  39. package/src/platform/WorktreeState.ts +21 -19
  40. package/src/platform/index.ts +18 -5
  41. package/src/replay/Format.test.ts +16 -13
  42. package/src/replay/Format.ts +4 -2
  43. package/src/replay/Replay.test.ts +25 -5
  44. package/src/replay/Replay.ts +10 -6
  45. package/src/replay/Trailers.test.ts +10 -10
  46. package/src/replay/Trailers.ts +23 -1
  47. package/src/replay/index.ts +1 -0
  48. package/src/step/planStart.test.ts +2 -2
  49. package/src/step/planStep.test.ts +4 -4
  50. package/src/testkit/TestRepo.test.ts +1 -1
  51. package/src/testkit/TestRepo.ts +4 -18
  52. package/src/twins/Host.test.ts +151 -0
  53. package/src/twins/Layers.test.ts +2 -2
  54. package/src/twins/Layers.ts +44 -18
  55. package/src/twins/Path.test.ts +52 -0
  56. package/src/twins/SyncIo.test.ts +153 -0
  57. package/src/twins/SyncIo.ts +159 -0
  58. package/src/twins/cliIo.ts +8 -1
  59. package/src/twins/index.ts +5 -0
  60. package/src/workflow/discovery.test.ts +6 -5
  61. package/src/workflow/discovery.ts +43 -32
  62. package/src/workflow/fingerprint.test.ts +23 -3
  63. package/src/workflow/fingerprint.ts +9 -11
  64. package/src/workflow/load.test.ts +16 -6
  65. package/src/workflow/load.ts +107 -69
  66. package/src/workflow/pin.test.ts +3 -2
  67. 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(Layer.provide(Layer.merge(hostLayer, gitLayer)))
115
+ const workspaceLayer = Workspace.Live.pipe(
116
+ Layer.provide(Layer.mergeAll(hostLayer, gitLayer, NodeServices.layer)),
117
+ )
116
118
  return Effect.runPromise(
117
- worktreeGitDir.pipe(Effect.provide(Layer.mergeAll(hostLayer, workspaceLayer))),
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 { isAbsolute, join } from "node:path"
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> = Effect.gen(
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(function* () {
38
- const { root, env } = yield* Host
39
- const override = env.GTD_LOOP_LOG
40
- if (override !== undefined && override !== "") return override
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
- const gitDirEnv = env.GIT_DIR
43
- if (gitDirEnv !== undefined && gitDirEnv !== "") {
44
- const gitDir = isAbsolute(gitDirEnv) ? gitDirEnv : join(root, gitDirEnv)
45
- return join(gitDir, "gtd-loop.log")
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
- const gitDir = yield* worktreeGitDir
49
- return join(gitDir, "gtd-loop.log")
50
- })
49
+ const gitDir = yield* worktreeGitDir
50
+ return path.join(gitDir, "gtd-loop.log")
51
+ },
52
+ )
@@ -1,7 +1,8 @@
1
1
  /**
2
- * gtd's two ports onto its environment: `Workspace` (repo file content —
3
- * working-tree reads/writes plus the read-at-ref path) and `Host` (root,
4
- * home, env, scratch). `GitService` moved in alongside `Workspace` since
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 { Host, type HostOps } from "./Host.js"
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, type PinFile } from "./PinFile.js"
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: 3 }])).toBeUndefined()
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: 4 },
21
- { hash: "c".repeat(40), format: 5 },
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 4, but this gtd reads formats 1 to 3 — run a gtd release that reads format 4 to continue this process",
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: 3")
40
- expect(parseCommitMessage(message).format).toBe(3)
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: 3",
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: 3`,
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(parseCommitMessage("gtd(check): pin → b\n\nGtd-Anchor: HEAD~2").anchor).toBeUndefined()
80
+ expect(
81
+ parseCommitMessage("gtd(check): gtd:pin → b\n\nGtd-Anchor: HEAD~2").anchor,
82
+ ).toBeUndefined()
80
83
  })
81
84
  })
@@ -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
- export const HISTORY_FORMAT = 3
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)
@@ -29,10 +29,14 @@ import {
29
29
  type CommitMessage,
30
30
  type JudgeVerdict,
31
31
  type StepId,
32
- PIN_STATES,
32
+ ENGINE_PREFIX,
33
+ pinStateIn,
33
34
  } from "./Trailers.js"
34
35
 
35
- const UNPIN_STATE = PIN_STATES.remove
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 || to === UNPIN_STATE ? undefined : { name: to, hash: commit.hash }
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 && to !== UNPIN_STATE) expectedNext = { name: to, hash: commit.hash }
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 (Object.values(PIN_STATES).includes(scoped(request.name))) {
723
- return `gtd: step "${scoped(request.name)}": the name is reserved for the pin and unpin beats — rename the step`
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: 3",
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: 3")
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: 3",
85
- `Gtd-Version: ${value}\nGtd-Format: 3`,
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: 3")).toBe(true)
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: 3")
109
- expect(parseCommitMessage(message).format).toBe(3)
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: 3").format).toBe(3)
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(3)
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: 3")
292
+ expect(lines).toContain("Gtd-Format: 4")
293
293
  })
294
294
 
295
295
  it("writes nothing without marks", () => {
@@ -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>> = { add: "pin", remove: "unpin" }
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
@@ -17,6 +17,7 @@ export {
17
17
  type CommitSpec,
18
18
  type JudgeVerdict,
19
19
  type PinChange,
20
+ LEGACY_PIN_STATES,
20
21
  PIN_STATES,
21
22
  } from "./Trailers.js"
22
23
  export { accessShapeFault } from "./Access.js"
@@ -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: 3",
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: 3",
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: 3" },
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: 3",
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: 3")
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: 3",
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
  ])
@@ -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 gtd = async (...args: string[]): Promise<CapturedCliResult> => {
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")