@pmelab/gtd 60.1.0 → 62.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 (61) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +16 -0
  3. package/dist/chunks/{testkit-BhTey1Do.mjs → testkit-XQeYCNMn.mjs} +1670 -1470
  4. package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
  5. package/dist/gtd.bundle.mjs +991 -627
  6. package/dist/gtd.core.mjs +117 -117
  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/Cli.ts +6 -1
  12. package/src/cli/History.test.ts +10 -1
  13. package/src/cli/Launch.test.ts +52 -0
  14. package/src/cli/Launch.ts +143 -0
  15. package/src/cli/Metrics.test.ts +10 -1
  16. package/src/cli/Program.test.ts +30 -20
  17. package/src/cli/Program.ts +4 -2
  18. package/src/cli/Strays.ts +3 -2
  19. package/src/cli/index.ts +1 -0
  20. package/src/edge/Edge.test.ts +79 -26
  21. package/src/edge/Edge.ts +178 -99
  22. package/src/emit/GitScript.ts +21 -3
  23. package/src/emit/index.ts +3 -0
  24. package/src/gherkin/messages.test.ts +1 -0
  25. package/src/gherkin/module-config.feature +1 -0
  26. package/src/gherkin/more-steps.feature +1 -0
  27. package/src/gherkin/testkit.feature +2 -1
  28. package/src/install/Install.ts +9 -2
  29. package/src/platform/PinFile.ts +61 -0
  30. package/src/platform/index.ts +1 -0
  31. package/src/replay/Format.test.ts +35 -8
  32. package/src/replay/Format.ts +7 -4
  33. package/src/replay/Replay.test.ts +54 -0
  34. package/src/replay/Replay.ts +61 -17
  35. package/src/replay/Trailers.test.ts +11 -48
  36. package/src/replay/Trailers.ts +36 -42
  37. package/src/replay/Tree.ts +11 -0
  38. package/src/replay/index.ts +3 -0
  39. package/src/step/LandStep.ts +1 -0
  40. package/src/step/RepoSnapshot.ts +2 -0
  41. package/src/step/planStart.test.ts +2 -3
  42. package/src/step/planStart.ts +1 -5
  43. package/src/step/planStep.test.ts +4 -4
  44. package/src/step/planStep.ts +10 -8
  45. package/src/testkit/TestRepo.test.ts +4 -1
  46. package/src/testkit/TestRepo.ts +3 -1
  47. package/src/twins/EmittedScriptRecognizer.ts +32 -1
  48. package/src/twins/GitTiers.ts +23 -0
  49. package/src/twins/InMemRepo.ts +14 -0
  50. package/src/twins/PinBeats.ts +33 -0
  51. package/src/twins/PinFile.test.ts +107 -0
  52. package/src/twins/PinFile.ts +12 -0
  53. package/src/twins/index.ts +2 -0
  54. package/src/wire/BeatDocument.ts +26 -5
  55. package/src/wire/Demand.ts +16 -6
  56. package/src/wire/constants.ts +3 -0
  57. package/src/wire/types.ts +4 -0
  58. package/src/workflow/fingerprint.ts +2 -2
  59. package/src/workflow/index.ts +2 -2
  60. package/src/workflow/load.ts +14 -1
  61. package/src/workflow/pin.ts +15 -7
@@ -23,13 +23,14 @@ import { keptRef, processHistoryRef } from "../platform/index.js"
23
23
  import { GitService, Workspace, type GitOperations, type WorkspaceOps } from "../platform/index.js"
24
24
  import type { Mark } from "../flows/index.js"
25
25
  import { formatCommitMessage, type CommitSpec } from "../replay/index.js"
26
- import { contentHash, StartFlags } from "../workflow/index.js"
26
+ import { StartFlags } from "../workflow/index.js"
27
27
  import {
28
28
  fakeGitOperations,
29
29
  InMemRepo,
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-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-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