@pmelab/gtd 60.1.0 → 61.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 (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +7 -0
  3. package/dist/chunks/{testkit-BhTey1Do.mjs → testkit-C9sq0x07.mjs} +1695 -1454
  4. package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
  5. package/dist/gtd.bundle.mjs +1666 -1449
  6. package/dist/gtd.core.mjs +86 -86
  7. package/dist/testkit/gherkin.mjs +1 -1
  8. package/dist/testkit.mjs +1 -1
  9. package/dist/wire.mjs +2 -2
  10. package/package.json +1 -1
  11. package/src/cli/History.test.ts +10 -1
  12. package/src/cli/Metrics.test.ts +10 -1
  13. package/src/cli/Program.test.ts +30 -19
  14. package/src/cli/Program.ts +4 -1
  15. package/src/cli/Strays.ts +3 -2
  16. package/src/edge/Edge.test.ts +78 -25
  17. package/src/edge/Edge.ts +161 -28
  18. package/src/emit/GitScript.ts +21 -3
  19. package/src/emit/index.ts +3 -0
  20. package/src/gherkin/messages.test.ts +1 -0
  21. package/src/gherkin/module-config.feature +1 -0
  22. package/src/gherkin/more-steps.feature +1 -0
  23. package/src/gherkin/testkit.feature +2 -1
  24. package/src/install/Install.ts +9 -2
  25. package/src/replay/Format.test.ts +35 -8
  26. package/src/replay/Format.ts +7 -4
  27. package/src/replay/Replay.test.ts +54 -0
  28. package/src/replay/Replay.ts +61 -17
  29. package/src/replay/Trailers.test.ts +12 -12
  30. package/src/replay/Trailers.ts +35 -20
  31. package/src/replay/Tree.ts +11 -0
  32. package/src/replay/index.ts +3 -0
  33. package/src/step/LandStep.ts +1 -0
  34. package/src/step/RepoSnapshot.ts +2 -0
  35. package/src/step/planStart.test.ts +2 -2
  36. package/src/step/planStep.test.ts +4 -4
  37. package/src/step/planStep.ts +10 -8
  38. package/src/testkit/TestRepo.test.ts +4 -1
  39. package/src/testkit/TestRepo.ts +3 -1
  40. package/src/twins/EmittedScriptRecognizer.ts +32 -1
  41. package/src/twins/GitTiers.ts +23 -0
  42. package/src/twins/InMemRepo.ts +14 -0
  43. package/src/twins/PinBeats.ts +33 -0
  44. package/src/twins/index.ts +1 -0
  45. package/src/wire/BeatDocument.ts +26 -5
  46. package/src/wire/Demand.ts +16 -6
  47. package/src/wire/constants.ts +3 -0
  48. package/src/wire/types.ts +4 -0
@@ -1,4 +1,4 @@
1
- import { n as runsItsScript, r as testRepo } from "../chunks/testkit-BhTey1Do.mjs";
1
+ import { n as runsItsScript, r as testRepo } from "../chunks/testkit-C9sq0x07.mjs";
2
2
  import { resolve } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { After, AfterAll, Given, QuickPickleWorld, Then, When, setWorldConstructor } from "quickpickle";
package/dist/testkit.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  import { H as installContext } from "./chunks/flows-DENt0uWV.mjs";
2
- import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-BhTey1Do.mjs";
2
+ import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-C9sq0x07.mjs";
3
3
  export { inTurn, installContext, runsItsScript, testRepo, verdict, verdictWhen, whenPrompted };
package/dist/wire.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { A as WIRE_SCHEMA, C as EXIT_RUNTIME_ERROR, D as EXIT_USAGE_ERROR, E as EXIT_STRAYS_FOUND, O as ACCESS_REFUSAL, S as EXIT_OK, T as EXIT_SIGTERM, _ as BEAT_KINDS, a as renderScriptJson, b as stallDiagnosis, c as landProseText, d as renderLandPlain, f as DEMAND_BRIEFING, g as statusOf, h as renderBeatJson, i as judgeJson, j as renderFormat, k as UNATTRIBUTED_MODEL, l as noopText, m as beatDocument, n as resolveSession, o as scriptFields, p as renderBeatPlain, r as selectPath, s as landFields, t as globMatches, u as renderLandJson, v as beatKindOf, w as EXIT_SIGINT, x as EXIT_CODES, y as demandOf } from "./chunks/wire-DZ8R1RU9.mjs";
2
- export { ACCESS_REFUSAL, BEAT_KINDS, DEMAND_BRIEFING, EXIT_CODES, EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_SIGINT, EXIT_SIGTERM, EXIT_STRAYS_FOUND, EXIT_USAGE_ERROR, UNATTRIBUTED_MODEL, WIRE_SCHEMA, beatDocument, beatKindOf, demandOf, globMatches, judgeJson, landFields, landProseText, noopText, renderBeatJson, renderBeatPlain, renderFormat, renderLandJson, renderLandPlain, renderScriptJson, resolveSession, scriptFields, selectPath, stallDiagnosis, statusOf };
1
+ import { A as UNATTRIBUTED_MODEL, C as EXIT_RUNTIME_ERROR, D as EXIT_USAGE_ERROR, E as EXIT_STRAYS_FOUND, M as renderFormat, O as ACCESS_REFUSAL, S as EXIT_OK, T as EXIT_SIGTERM, _ as BEAT_KINDS, a as renderScriptJson, b as stallDiagnosis, c as landProseText, d as renderLandPlain, f as DEMAND_BRIEFING, g as statusOf, h as renderBeatJson, i as judgeJson, j as WIRE_SCHEMA, k as PIN_PATH, l as noopText, m as beatDocument, n as resolveSession, o as scriptFields, p as renderBeatPlain, r as selectPath, s as landFields, t as globMatches, u as renderLandJson, v as beatKindOf, w as EXIT_SIGINT, x as EXIT_CODES, y as demandOf } from "./chunks/wire-CPNSm_9N.mjs";
2
+ export { ACCESS_REFUSAL, BEAT_KINDS, DEMAND_BRIEFING, EXIT_CODES, EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_SIGINT, EXIT_SIGTERM, EXIT_STRAYS_FOUND, EXIT_USAGE_ERROR, PIN_PATH, UNATTRIBUTED_MODEL, WIRE_SCHEMA, beatDocument, beatKindOf, demandOf, globMatches, judgeJson, landFields, landProseText, noopText, renderBeatJson, renderBeatPlain, renderFormat, renderLandJson, renderLandPlain, renderScriptJson, resolveSession, scriptFields, selectPath, stallDiagnosis, statusOf };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "60.1.0",
3
+ "version": "61.0.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -1,6 +1,12 @@
1
1
  import { Effect, Exit } from "effect"
2
2
  import { describe, expect, it } from "vitest"
3
- import { applyEmittedScript, InMemRepo, makeCapturingCliIo, testLayers } from "../twins/index.js"
3
+ import {
4
+ applyEmittedScript,
5
+ InMemRepo,
6
+ makeCapturingCliIo,
7
+ takePinBeat,
8
+ testLayers,
9
+ } from "../twins/index.js"
4
10
  import { runCli } from "./index.js"
5
11
  import { historyEntries, resolveHistoryRef, type HistoryEntry } from "./History.js"
6
12
 
@@ -12,7 +18,9 @@ export default async () => {
12
18
  }
13
19
  `
14
20
 
21
+ /** Lands `files` as one turn, taking a pin beat before it and an unpin beat after it as a driver does. */
15
22
  const land = async (repo: InMemRepo, files: Record<string, string>): Promise<string> => {
23
+ await takePinBeat(repo, "add")
16
24
  for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
17
25
  const { io, result } = makeCapturingCliIo(repo)
18
26
  await Effect.runPromise(runCli(["node", "gtd.js", "land", "--json"], io))
@@ -24,6 +32,7 @@ const land = async (repo: InMemRepo, files: Record<string, string>): Promise<str
24
32
  (JSON.parse(stdout) as { script: string }).script,
25
33
  )
26
34
  if (!outcome.ok) throw new Error(outcome.error)
35
+ await takePinBeat(repo, "remove")
27
36
  return repo.resolveRef("HEAD")!
28
37
  }
29
38
 
@@ -1,6 +1,12 @@
1
1
  import { Effect } from "effect"
2
2
  import { describe, expect, it } from "vitest"
3
- import { applyEmittedScript, InMemRepo, makeCapturingCliIo, testLayers } from "../twins/index.js"
3
+ import {
4
+ applyEmittedScript,
5
+ InMemRepo,
6
+ makeCapturingCliIo,
7
+ takePinBeat,
8
+ testLayers,
9
+ } from "../twins/index.js"
4
10
  import { runCli } from "./index.js"
5
11
  import { processMetrics, readProcessMetrics, totalMetrics, type MetricCommit } from "./Metrics.js"
6
12
 
@@ -213,7 +219,9 @@ export default async () => {
213
219
  }
214
220
  `
215
221
 
222
+ /** Lands `files` as one turn, taking a pin beat before it and an unpin beat after it as a driver does. */
216
223
  const land = async (repo: InMemRepo, files: Record<string, string>): Promise<string> => {
224
+ await takePinBeat(repo, "add")
217
225
  for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
218
226
  const { io, result } = makeCapturingCliIo(repo)
219
227
  await Effect.runPromise(runCli(["node", "gtd.js", "land", "--json"], io))
@@ -225,6 +233,7 @@ const land = async (repo: InMemRepo, files: Record<string, string>): Promise<str
225
233
  (JSON.parse(stdout) as { script: string }).script,
226
234
  )
227
235
  if (!outcome.ok) throw new Error(outcome.error)
236
+ await takePinBeat(repo, "remove")
228
237
  return repo.resolveRef("HEAD")!
229
238
  }
230
239
 
@@ -18,7 +18,13 @@ vi.mock("node:os", async (importOriginal) => ({
18
18
  import { runCli, type Command } from "./index.js"
19
19
  import { EXIT_USAGE_ERROR, stallDiagnosis, noopText, WIRE_SCHEMA } from "../wire/index.js"
20
20
  import { needsOf, runCommand, SelectorUsageError, type CommandRequirements } from "./Program.js"
21
- import { InMemRepo, makeCapturingCliIo, testLayers, applyEmittedScript } from "../twins/index.js"
21
+ import {
22
+ InMemRepo,
23
+ makeCapturingCliIo,
24
+ testLayers,
25
+ applyEmittedScript,
26
+ takePinBeat,
27
+ } from "../twins/index.js"
22
28
  import {
23
29
  commitAll,
24
30
  DID_NOT_RUN_COMMENT,
@@ -39,13 +45,14 @@ const run = async (
39
45
  return result()
40
46
  }
41
47
 
42
- /** Lands via `gtd land --json` and applies the emitted script, as a driver would. */
48
+ /** Lands via `gtd land --json` and applies the emitted script, then takes the pin or unpin beat it leads to, as a driver would. */
43
49
  const landAndApply = async (
44
50
  repo: InMemRepo,
45
51
  ): Promise<{ readonly exitCode: number; readonly script: string }> => {
46
52
  const { stdout, exitCode } = await run(repo, "land", "--json")
47
53
  const script = exitCode === 0 ? (JSON.parse(stdout) as { readonly script: string }).script : ""
48
54
  if (exitCode === 0) expect(applyEmittedScript(repo, new Map(), script).ok).toBe(true)
55
+ if (exitCode === 0) await takePinBeat(repo)
49
56
  return { exitCode, script }
50
57
  }
51
58
 
@@ -104,7 +111,7 @@ export const hotfix = workflow(
104
111
  expect(exitCode).toBe(0)
105
112
  expect(stdout).toContain(
106
113
  commitAll(
107
- `gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-Config: ${contentHash(WORKFLOW)} gtd.config.ts\nGtd-Format: 1`,
114
+ `gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-Config: ${contentHash(WORKFLOW)} gtd.config.ts\nGtd-Format: 2`,
108
115
  ),
109
116
  )
110
117
  expect(repo.commitHistory()).toHaveLength(before)
@@ -190,6 +197,9 @@ export const hotfix = workflow(
190
197
  const { stdout } = await run(repo, "start", "hotfix")
191
198
  expect(applyEmittedScript(repo, new Map(), stdout).ok).toBe(true)
192
199
  expect(repo.lastCommitSubject()).toBe("gtd(human): working")
200
+ const pin = JSON.parse((await run(repo, "next", "--json")).stdout) as Record<string, unknown>
201
+ expect(pin).toMatchObject({ kind: "script", state: "pin", actor: "check", pin: "add" })
202
+ expect(await takePinBeat(repo, "add")).toBe(true)
193
203
  const next = JSON.parse((await run(repo, "next", "--json")).stdout) as Record<string, unknown>
194
204
  expect(next.kind).toBe("prompt")
195
205
  expect(next.state).toBe("working")
@@ -496,6 +506,7 @@ export const resume = workflow(work)
496
506
  const repo = seed(WORKFLOW)
497
507
  const { stdout: entry } = await run(repo, "start", "resume")
498
508
  expect(applyEmittedScript(repo, new Map(), entry).ok).toBe(true)
509
+ await takePinBeat(repo, "add")
499
510
  const { stdout, exitCode } = await run(repo, "next", "--json")
500
511
  expect(exitCode).toBe(0)
501
512
  expect(JSON.parse(stdout).kind).toBe("prompt")
@@ -775,20 +786,20 @@ describe("gtd next — refuses when history diverges from what replay reaches",
775
786
  expect(repo.commitHistory()).toHaveLength(before)
776
787
 
777
788
  expect(applyEmittedScript(repo, new Map(), stdout).ok).toBe(true)
778
- expect(repo.commitHistory()).toHaveLength(before - 2)
789
+ expect(repo.commitHistory()).toHaveLength(before - 3)
779
790
  })
780
791
  })
781
792
 
782
793
  describe("a history written in a format this gtd does not read", () => {
783
794
  // The exact wording is pinned in Format.test.ts; this is the part that proves the format gate fired.
784
- const FOREIGN = "is written in history format 2, but this gtd reads format 1"
795
+ const FOREIGN = "is written in history format 3, but this gtd reads formats 1 to 2"
785
796
 
786
797
  /** A step commit that would also diverge, so only the format gate can explain the refusal. */
787
798
  const foreignStep = async (): Promise<InMemRepo> => {
788
799
  const repo = seed(IDLE_THEN_WORKING)
789
800
  await landTurn(repo, { "NOTE.md": "a note\n" })
790
801
  repo.writeFile("WORK.md", "done\n")
791
- repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format: 2")
802
+ repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format: 3")
792
803
  return repo
793
804
  }
794
805
 
@@ -813,7 +824,7 @@ describe("a history written in a format this gtd does not read", () => {
813
824
  it("refuses even when a foreign subject hides the commit from the episode", async () => {
814
825
  const repo = seed(IDLE_THEN_WORKING)
815
826
  repo.writeFile("NOTE.md", "a note\n")
816
- repo.seedCommit("gtd2[human] idle -> working\n\nGtd-Format: 2")
827
+ repo.seedCommit("gtd2[human] idle -> working\n\nGtd-Format: 3")
817
828
  for (const command of ["next", "abandon"]) {
818
829
  const { exitCode, stderr } = await run(repo, command)
819
830
  expect(exitCode).toBe(1)
@@ -824,7 +835,7 @@ describe("a history written in a format this gtd does not read", () => {
824
835
  it("a commit gtd never wrote is not read for a format, even quoting the trailer", async () => {
825
836
  const repo = seed(IDLE_THEN_WORKING)
826
837
  repo.writeFile("NOTE.md", "a note\n")
827
- repo.seedCommit("feat: x\n\nGtd-Format: 2")
838
+ repo.seedCommit("feat: x\n\nGtd-Format: 3")
828
839
  expect((await run(repo, "next")).exitCode).toBe(0)
829
840
  })
830
841
 
@@ -832,7 +843,7 @@ describe("a history written in a format this gtd does not read", () => {
832
843
  const repo = seed(IDLE_THEN_WORKING)
833
844
  const base = repo.resolveRef("HEAD")!
834
845
  repo.writeFile("NOTE.md", "a note\n")
835
- repo.seedCommit("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format: 2")
846
+ repo.seedCommit("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format: 3")
836
847
  repo.updateRef(HISTORY_REF, repo.resolveRef("HEAD")!)
837
848
  repo.hardResetTo(base)
838
849
  const { exitCode, stderr } = await run(repo, "restore")
@@ -1390,10 +1401,10 @@ export default async () => {
1390
1401
  expect(await nextOf(repo)).toEqual({ target: "accepted" })
1391
1402
  })
1392
1403
 
1393
- it("a turn that finishes the flow targets the initial step", async () => {
1404
+ it("a turn that finishes a pinned flow targets the unpin", async () => {
1394
1405
  const repo = await atWorking()
1395
1406
  repo.writeFile("REVIEW.md", "an updated review\n")
1396
- expect(await nextOf(repo)).toMatchObject({ target: "idle" })
1407
+ expect(await nextOf(repo)).toMatchObject({ target: "unpin" })
1397
1408
  })
1398
1409
 
1399
1410
  it("plain gtd next shows the same preview as a `Next:` line", async () => {
@@ -1476,7 +1487,7 @@ export default async () => {
1476
1487
  expect((JSON.parse(stdout) as { readonly script: string }).script).toContain("git commit")
1477
1488
  })
1478
1489
 
1479
- it("a clean run that finishes the flow lands an ordinary commit re-entering the initial step", async () => {
1490
+ it("a clean run that finishes a pinned flow lands an ordinary commit at the unpin", async () => {
1480
1491
  const repo = await atChecking(REENTRY_WORKFLOW)
1481
1492
  const { stdout, exitCode } = await run(repo, "land", "--json")
1482
1493
  expect(exitCode).toBe(0)
@@ -1485,7 +1496,7 @@ export default async () => {
1485
1496
  expect(script).not.toContain("git reset --mixed")
1486
1497
  expect(script).not.toContain("nothing to retain")
1487
1498
  expect(applyEmittedScript(repo, new Map(), script).ok).toBe(true)
1488
- expect(repo.lastCommitSubject()).toBe("gtd(check): checking → idle")
1499
+ expect(repo.lastCommitSubject()).toBe("gtd(check): checking → unpin")
1489
1500
  })
1490
1501
 
1491
1502
  it("the same rest with a pending change lands an ordinary commit too", async () => {
@@ -1523,7 +1534,7 @@ export default async () => {
1523
1534
  expect(parsed.script).toContain("nothing to do")
1524
1535
  })
1525
1536
 
1526
- it("gtd land --json a decision that re-enters the initial step reports settled:false, idle:true, state:idle", async () => {
1537
+ it("gtd land --json a decision that ends a pinned flow reports settled:false, idle:false, state:unpin", async () => {
1527
1538
  const repo = await atChecking(REENTRY_WORKFLOW)
1528
1539
  const { stdout, exitCode } = await run(repo, "land", "--json")
1529
1540
  expect(exitCode).toBe(0)
@@ -1533,8 +1544,8 @@ export default async () => {
1533
1544
  readonly state: string
1534
1545
  }
1535
1546
  expect(parsed.settled).toBe(false)
1536
- expect(parsed.idle).toBe(true)
1537
- expect(parsed.state).toBe("idle")
1547
+ expect(parsed.idle).toBe(false)
1548
+ expect(parsed.state).toBe("unpin")
1538
1549
  })
1539
1550
 
1540
1551
  it("gtd land --json an ordinary commit reports settled:false", async () => {
@@ -1683,7 +1694,7 @@ export default async () => {
1683
1694
  const { exitCode, script } = await landAndApply(repo)
1684
1695
  expect(exitCode).toBe(0)
1685
1696
  expect(script).not.toContain("fmt-notes")
1686
- expect(repo.lastCommitSubject()).toBe("gtd(agent): drafting → idle")
1697
+ expect(repo.lastCommitSubject("HEAD~1")).toBe("gtd(agent): drafting → unpin")
1687
1698
  })
1688
1699
 
1689
1700
  it("`gtd next --json`'s `validate` field carries the mode's format command", async () => {
@@ -1867,7 +1878,7 @@ export default workflow(
1867
1878
  it("writes the rendered summary, with a trailing newline, counting the human turns after the entry commit", async () => {
1868
1879
  const repo = await reviewed()
1869
1880
  const history = repo.commitHistory()
1870
- const entryHash = history[history.length - 3]!.hash
1881
+ const entryHash = history[history.length - 4]!.hash
1871
1882
  const tipHash = history[history.length - 1]!.hash
1872
1883
 
1873
1884
  const { exitCode, stdout } = await run(repo, "summary")
@@ -1961,7 +1972,7 @@ export default async () => {
1961
1972
  it("refuses once the process has closed and HEAD rests at the initial step again", async () => {
1962
1973
  const repo = await atDeciding()
1963
1974
  await landTurn(repo)
1964
- expect(repo.lastCommitSubject()).toBe("gtd(human): deciding → idle")
1975
+ expect(repo.lastCommitSubject()).toBe("gtd(check): unpin → idle")
1965
1976
 
1966
1977
  const { exitCode, stdout, stderr } = await run(repo, "base")
1967
1978
  expect(exitCode).toBe(1)
@@ -47,6 +47,7 @@ import {
47
47
  type StatusChange,
48
48
  selectPath,
49
49
  EXIT_OK,
50
+ PIN_PATH,
50
51
  } from "../wire/index.js"
51
52
  import {
52
53
  currentRest,
@@ -80,6 +81,7 @@ import {
80
81
  deleteRef,
81
82
  hardResetTo,
82
83
  mixedResetTo,
84
+ unpinScript,
83
85
  ScriptSurface,
84
86
  updateRef,
85
87
  type RunnableScript,
@@ -799,6 +801,7 @@ const runAbandonCommand = (
799
801
  const steps: EmitStep[] = [
800
802
  { kind: "gitWrite", command: updateRef(HISTORY_REF, tip) },
801
803
  { kind: "gitWrite", command: mixedResetTo(run.startParentHash) },
804
+ { kind: "gitWrite", command: unpinScript(PIN_PATH) },
802
805
  { kind: "outcome", command: abandonedOutcome(restState, run.startParentHash, initial) },
803
806
  ]
804
807
  const required = emitScripts(steps).required
@@ -1060,7 +1063,7 @@ const runCodeThreadsCheckCommand = (): Effect.Effect<void, Error, CommandRequire
1060
1063
  const workspace = yield* Workspace
1061
1064
  const changed = yield* git.changedPaths(rest.run.diffBase)
1062
1065
  const lines = changed
1063
- .filter((c) => c.status !== "D")
1066
+ .filter((c) => c.status !== "D" && c.path !== PIN_PATH)
1064
1067
  .flatMap(({ path }) => {
1065
1068
  const { threads, findings } = parseCodeThreads(path, workspace.atPath(path) ?? "")
1066
1069
  return [
package/src/cli/Strays.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Effect } from "effect"
2
2
  import { GitService, GtdError, Narrator, type GitOperations } from "../platform/index.js"
3
3
  import { strayPaths } from "../step/index.js"
4
- import { EXIT_OK, EXIT_STRAYS_FOUND } from "../wire/index.js"
4
+ import { EXIT_OK, EXIT_STRAYS_FOUND, PIN_PATH } from "../wire/index.js"
5
5
  import {
6
6
  SelectorUsageError,
7
7
  writeSelection,
@@ -31,7 +31,8 @@ export const runStraysCommand = (
31
31
  const narrator = yield* Narrator
32
32
  const base = yield* resolveSide(git, command.base)
33
33
  const tip = yield* resolveSide(git, command.tip)
34
- const touched = yield* git.diffPaths(base, tip)
34
+ // The pin is the engine's, never a turn's, so it is never a stray either.
35
+ const touched = (yield* git.diffPaths(base, tip)).filter((path) => path !== PIN_PATH)
35
36
  const strays = strayPaths(touched, command.globs)
36
37
  const total = touched.length
37
38
 
@@ -30,6 +30,7 @@ import {
30
30
  applyEmittedScript,
31
31
  makeCapturingCliIo,
32
32
  makeInMemoryWorkspaceOps,
33
+ takePinBeat,
33
34
  testLayers,
34
35
  } from "../twins/index.js"
35
36
 
@@ -74,7 +75,7 @@ const write = (repo: InMemRepo, files: Files): void => {
74
75
  for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
75
76
  }
76
77
 
77
- const headOf = (repo: InMemRepo): string => repo.resolveRef("HEAD")!
78
+ const headOf = (repo: InMemRepo, ref = "HEAD"): string => repo.resolveRef(ref)!
78
79
 
79
80
  const repoWith = (config: string, files: Files = {}): InMemRepo => {
80
81
  const repo = new InMemRepo()
@@ -95,19 +96,27 @@ const applied = (repo: InMemRepo, script: string): string => {
95
96
  return headOf(repo)
96
97
  }
97
98
 
98
- /** Lands the pending turn the way a driver does — `gtd land --json`, then its script — and returns the new HEAD. */
99
+ /**
100
+ * Lands the pending turn the way a driver does — `gtd land --json`, then its
101
+ * script, then the pin or unpin beat it leads to — and returns the turn's
102
+ * commit, or the closing commit when the turn led to the unpin.
103
+ */
99
104
  const land = async (repo: InMemRepo, files: Files = {}): Promise<string> => {
100
105
  write(repo, files)
101
106
  const { stdout, stderr, exitCode } = await cli(repo, "land", "--json")
102
107
  if (exitCode !== 0) throw new Error(`gtd land exited ${exitCode}: ${stderr}${stdout}`)
103
- return applied(repo, (JSON.parse(stdout) as { readonly script: string }).script)
108
+ const turn = applied(repo, (JSON.parse(stdout) as { readonly script: string }).script)
109
+ await takePinBeat(repo, "add")
110
+ return (await takePinBeat(repo, "remove")) ? headOf(repo) : turn
104
111
  }
105
112
 
106
113
  const enter = async (repo: InMemRepo, workflow: string, ...vars: string[]): Promise<string> => {
107
114
  const flags = vars.flatMap((v) => ["--var", v])
108
115
  const { stdout, stderr, exitCode } = await cli(repo, "start", workflow, ...flags)
109
116
  if (exitCode !== 0) throw new Error(`gtd start exited ${exitCode}: ${stderr}`)
110
- return applied(repo, stdout)
117
+ const opening = applied(repo, stdout)
118
+ await takePinBeat(repo, "add")
119
+ return opening
111
120
  }
112
121
 
113
122
  /** Commits an exact message — for trailers no landing writes on its own. */
@@ -174,7 +183,11 @@ describe("currentRun", () => {
174
183
  { state: "checking", hash: checking, actor: "agent" },
175
184
  ])
176
185
  expect(run.episode.base).toBe(boundary)
177
- expect(run.episode.commits.map((c) => c.hash)).toEqual([building, checking])
186
+ expect(run.episode.commits.map((c) => c.message.split("\n")[0])).toEqual([
187
+ "gtd(human): idle → building",
188
+ "gtd(check): pin → building",
189
+ "gtd(agent): building → checking",
190
+ ])
178
191
  })
179
192
 
180
193
  it("a non-gtd commit on top of a process bounds a fresh episode", async () => {
@@ -192,7 +205,7 @@ describe("currentRun", () => {
192
205
  await land(repo, { "a.txt": "a\n" })
193
206
  await land(repo, { "b.txt": "b\n" })
194
207
  const closing = await land(repo, { "c.txt": "c\n" })
195
- expect(repo.lastCommitSubject()).toBe("gtd(agent): checking → idle")
208
+ expect(repo.lastCommitSubject()).toBe("gtd(check): unpin → idle")
196
209
  const next = await land(repo, { "d.txt": "d\n" })
197
210
  const run = await provide(currentRun, repo)
198
211
  expect(run.startParentHash).toBe(closing)
@@ -203,8 +216,8 @@ describe("currentRun", () => {
203
216
  const repo = repoWith(LINEAR)
204
217
  const before = headOf(repo)
205
218
  const opening = await enter(repo, "side")
206
- expect(repo.lastCommitMessage()).toBe(
207
- `gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Config: ${contentHash(LINEAR)} gtd.config.ts\nGtd-Format: 1`,
219
+ expect(repo.lastCommitMessage(opening)).toBe(
220
+ `gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Config: ${contentHash(LINEAR)} gtd.config.ts\nGtd-Format: 2`,
208
221
  )
209
222
  const fixed = await land(repo, { "fix.txt": "x\n" })
210
223
  const run = await provide(currentRun, repo)
@@ -216,7 +229,7 @@ describe("currentRun", () => {
216
229
  { state: "tidying", hash: fixed, actor: "agent" },
217
230
  ])
218
231
  expect(run.episode.base).toBe(opening)
219
- expect(run.episode.commits.map((c) => c.hash)).toEqual([fixed])
232
+ expect(run.episode.commits.map((c) => c.hash)).toEqual([headOf(repo, `${fixed}~1`), fixed])
220
233
  })
221
234
 
222
235
  it("a workflow starting at the default's initial step still opens its own episode, not the empty tree", async () => {
@@ -236,8 +249,8 @@ export const again = workflow(async () => {
236
249
  const repo = repoWith(STARTS_AT_INITIAL)
237
250
  const before = headOf(repo)
238
251
  const opening = await enter(repo, "again")
239
- expect(repo.lastCommitMessage()).toBe(
240
- `gtd(human): idle\n\nGtd-Workflow: again\nGtd-Config: ${contentHash(STARTS_AT_INITIAL)} gtd.config.ts\nGtd-Format: 1`,
252
+ expect(repo.lastCommitMessage(opening)).toBe(
253
+ `gtd(human): idle\n\nGtd-Workflow: again\nGtd-Config: ${contentHash(STARTS_AT_INITIAL)} gtd.config.ts\nGtd-Format: 2`,
241
254
  )
242
255
  const fixed = await land(repo, { "fix.txt": "x\n" })
243
256
  const run = await provide(currentRun, repo)
@@ -374,15 +387,23 @@ export const again = workflow(async () => {
374
387
  it("describes a step commit that changed something", async () => {
375
388
  const repo = repoWith(LINEAR)
376
389
  await land(repo, { "a.txt": "a\n" })
390
+ await land(repo, { "b.txt": "b\n" })
377
391
  const run = await provide(currentRun, repo)
378
- expect(run.headTurn).toEqual({ state: "building", actor: "human", empty: false, step: true })
392
+ expect(run.headTurn).toEqual({ state: "checking", actor: "agent", empty: false, step: true })
393
+ })
394
+
395
+ it("is undefined at a pin commit, which is no turn", async () => {
396
+ const repo = repoWith(LINEAR)
397
+ await land(repo, { "a.txt": "a\n" })
398
+ expect(repo.lastCommitSubject()).toBe("gtd(check): pin → building")
399
+ expect((await provide(currentRun, repo)).headTurn).toBeUndefined()
379
400
  })
380
401
 
381
402
  it("describes an attempt: a step-less, empty commit", async () => {
382
403
  const repo = repoWith(LINEAR)
383
404
  await land(repo, { "a.txt": "a\n" })
384
405
  await land(repo)
385
- expect(repo.lastCommitMessage()).toBe("gtd(agent): building\n\nGtd-Format: 1")
406
+ expect(repo.lastCommitMessage()).toBe("gtd(agent): building\n\nGtd-Format: 2")
386
407
  const run = await provide(currentRun, repo)
387
408
  expect(run.headTurn).toEqual({ state: "building", actor: "agent", empty: true, step: false })
388
409
  })
@@ -523,7 +544,8 @@ describe("summaryRun", () => {
523
544
  expect(summary.trace).toEqual([
524
545
  { state: "building", hash: building, actor: "human" },
525
546
  { state: "checking", hash: checking, actor: "agent" },
526
- { state: "idle", hash: closing, actor: "agent" },
547
+ { state: "unpin", hash: headOf(repo, `${closing}~1`), actor: "agent" },
548
+ { state: "idle", hash: closing, actor: "check" },
527
549
  ])
528
550
  })
529
551
 
@@ -635,7 +657,9 @@ export default workflow(async () => {
635
657
  `--model=${model}`,
636
658
  )
637
659
  if (exitCode !== 0) throw new Error(`gtd land exited ${exitCode}: ${stderr}${stdout}`)
638
- return applied(repo, (JSON.parse(stdout) as { readonly script: string }).script)
660
+ const turn = applied(repo, (JSON.parse(stdout) as { readonly script: string }).script)
661
+ await takePinBeat(repo, "add")
662
+ return (await takePinBeat(repo, "remove")) ? headOf(repo) : turn
639
663
  }
640
664
  type Rendered = {
641
665
  entryCommit: string
@@ -711,6 +735,7 @@ describe("restAt", () => {
711
735
  )
712
736
  expect(exitCode).toBe(0)
713
737
  await applied(repo, (JSON.parse(stdout) as { readonly script: string }).script)
738
+ await takePinBeat(repo, "add")
714
739
  }
715
740
  const { cost } = await provide(currentRest, repo)
716
741
  expect(cost.total).toBe(11)
@@ -806,6 +831,7 @@ export const review = workflow(async () => {
806
831
  it("is the base the resting step names", async () => {
807
832
  const repo = repoWith(LOOP)
808
833
  const first = await land(repo, { "a.txt": "a\n" })
834
+ expect(repo.lastCommitSubject()).toBe("gtd(check): pin → checkpoint")
809
835
  expect(reviewBaseFor(await provide(currentRest, repo))).toBe(first)
810
836
  await land(repo, { "b.txt": "b\n" })
811
837
  expect(reviewBaseFor(await provide(currentRest, repo))).toBe(first)
@@ -1043,8 +1069,8 @@ export default workflow(async () => {
1043
1069
 
1044
1070
  it("an ordinary start's first commit pins every process setting, sorted by name", async () => {
1045
1071
  const repo = repoWith(LINEAR)
1046
- await land(repo, { "a.txt": "a\n" })
1047
- expect(repo.lastCommitMessage()).toContain(
1072
+ const start = await land(repo, { "a.txt": "a\n" })
1073
+ expect(repo.lastCommitMessage(start)).toContain(
1048
1074
  "\n\nGtd-Step: idle#1\nGtd-Var: base=\nGtd-Var: reviewer=nobody",
1049
1075
  )
1050
1076
  await land(repo)
@@ -1265,13 +1291,34 @@ describe("finishing landings keep history", () => {
1265
1291
  return landing
1266
1292
  }
1267
1293
 
1294
+ /** Lands the turn that ends the flow, and runs the unpin beat's script without landing it. */
1295
+ const toUnpin = async (repo: InMemRepo, files: Files) => {
1296
+ write(repo, files)
1297
+ const turn = await cli(repo, "land", "--json")
1298
+ applied(repo, (JSON.parse(turn.stdout) as { readonly script: string }).script)
1299
+ const beat = await cli(repo, "next", "--json")
1300
+ applied(repo, (JSON.parse(beat.stdout) as { readonly content: string }).content)
1301
+ }
1302
+
1303
+ it("a pinned process's last turn lands at the unpin, keeping no history yet", async () => {
1304
+ const repo = repoWith(LINEAR)
1305
+ await land(repo, { "a.txt": "a\n" })
1306
+ await land(repo, { "b.txt": "b\n" })
1307
+ repo.writeFile("c.txt", "c\n")
1308
+ const landing = await landingOf(repo)
1309
+ expect(landing.to).toBe("unpin")
1310
+ expect(landing.keepHistory).toBeUndefined()
1311
+ expect(landing.spec.version).toBeUndefined()
1312
+ })
1313
+
1268
1314
  it("flow return: keeps refs/gtd/history/<startHash>, records version and branch", async () => {
1269
1315
  const repo = repoWith(LINEAR)
1270
1316
  const building = await land(repo, { "a.txt": "a\n" })
1271
1317
  await land(repo, { "b.txt": "b\n" })
1272
- repo.writeFile("c.txt", "c\n")
1318
+ await toUnpin(repo, { "c.txt": "c\n" })
1273
1319
  const landing = await landingOf(repo)
1274
1320
  expect(landing.to).toBe("idle")
1321
+ expect(landing.spec.pin).toBe("remove")
1275
1322
  expect(landing.keepHistory).toBe(processHistoryRef(building))
1276
1323
  expect(landing.spec.version).toBe(GTD_VERSION)
1277
1324
  expect(landing.spec.branch).toBe(repo.branchName())
@@ -1287,7 +1334,7 @@ export default async () => {
1287
1334
  }
1288
1335
  `)
1289
1336
  const building = await land(repo, { "a.txt": "a\n" })
1290
- repo.writeFile("b.txt", "b\n")
1337
+ await toUnpin(repo, { "b.txt": "b\n" })
1291
1338
  const landing = await landingOf(repo)
1292
1339
  expect(landing.to).toBe("idle")
1293
1340
  expect(landing.keepHistory).toBe(processHistoryRef(building))
@@ -1314,7 +1361,7 @@ export default async () => {
1314
1361
  const repo = repoWith(LINEAR)
1315
1362
  const building = await land(repo, { "a.txt": "a\n" })
1316
1363
  await land(repo, { "b.txt": "b\n" })
1317
- repo.writeFile("c.txt", "c\n")
1364
+ await toUnpin(repo, { "c.txt": "c\n" })
1318
1365
  const detached: GitOperations = {
1319
1366
  ...fakeGitOperations(repo),
1320
1367
  currentBranch: () => Effect.succeed(Option.none()),
@@ -1338,7 +1385,7 @@ export const side = workflow(async () => {
1338
1385
  })
1339
1386
  `)
1340
1387
  const opening = await enter(repo, "side")
1341
- repo.writeFile("a.txt", "a\n")
1388
+ await toUnpin(repo, { "a.txt": "a\n" })
1342
1389
  const landing = await landingOf(repo)
1343
1390
  expect(landing.to).toBe("idle")
1344
1391
  expect(landing.keepHistory).toBe(processHistoryRef(opening))
@@ -1468,14 +1515,20 @@ describe("historyTrail", () => {
1468
1515
  const trail = await provide(historyTrail(tip), repo)
1469
1516
  expect(trail.tip).toBe(tip)
1470
1517
  expect(trail.ended).toBe("return")
1471
- expect(trail.steps.map((s) => s.hash)).toEqual([building, checking, tip])
1518
+ expect(trail.steps.map((s) => s.hash)).toEqual([
1519
+ building,
1520
+ checking,
1521
+ headOf(repo, `${tip}~1`),
1522
+ tip,
1523
+ ])
1472
1524
  expect(trail.steps[2]).toMatchObject({
1473
- subject: "gtd(agent): checking → idle",
1525
+ subject: "gtd(agent): checking → unpin",
1474
1526
  actor: "agent",
1475
1527
  from: "checking",
1476
- to: "idle",
1528
+ to: "unpin",
1477
1529
  verdicts: [],
1478
1530
  })
1531
+ expect(trail.steps[3]).toMatchObject({ subject: "gtd(check): unpin → idle", actor: "check" })
1479
1532
  expect(trail.steps[0]).toMatchObject({ actor: "human", to: "building" })
1480
1533
  })
1481
1534
 
@@ -1628,7 +1681,7 @@ export default async () => {
1628
1681
  )
1629
1682
  }).pipe(Effect.provide(testLayers(repo, {}))),
1630
1683
  )
1631
- expect(trail.steps).toHaveLength(3)
1684
+ expect(trail.steps).toHaveLength(4)
1632
1685
  expect(calls).toBe(1)
1633
1686
  })
1634
1687