@pmelab/gtd 60.0.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 (81) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +21 -2
  3. package/dist/chunks/testkit-C9sq0x07.mjs +30758 -0
  4. package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
  5. package/dist/gtd.bundle.mjs +12238 -20706
  6. package/dist/gtd.core.mjs +593 -0
  7. package/dist/testkit/gherkin.mjs +2 -2
  8. package/dist/testkit.mjs +1 -1
  9. package/dist/wire.mjs +2 -2
  10. package/package.json +7 -7
  11. package/src/cli/Cli.test.ts +2 -2
  12. package/src/cli/Cli.ts +68 -51
  13. package/src/cli/History.test.ts +10 -1
  14. package/src/cli/Metrics.test.ts +10 -1
  15. package/src/cli/Output.test.ts +1 -1
  16. package/src/cli/Output.ts +3 -2
  17. package/src/cli/Program.test.ts +44 -25
  18. package/src/cli/Program.ts +39 -11
  19. package/src/cli/Strays.ts +3 -2
  20. package/src/cli/ToolCommands.ts +2 -2
  21. package/src/edge/Edge.test.ts +86 -31
  22. package/src/edge/Edge.ts +164 -31
  23. package/src/emit/GitScript.ts +21 -3
  24. package/src/emit/index.ts +3 -0
  25. package/src/flows/runtime.ts +1 -1
  26. package/src/gherkin/messages.test.ts +1 -0
  27. package/src/gherkin/module-config.feature +1 -0
  28. package/src/gherkin/more-steps.feature +1 -0
  29. package/src/gherkin/testkit.feature +2 -1
  30. package/src/install/Install.ts +9 -2
  31. package/src/install/Version.ts +9 -3
  32. package/src/install/index.ts +1 -1
  33. package/src/platform/CommandRunner.ts +13 -11
  34. package/src/platform/Commentary.ts +2 -2
  35. package/src/platform/Git.test.ts +4 -5
  36. package/src/platform/Git.ts +42 -38
  37. package/src/platform/Host.ts +1 -1
  38. package/src/platform/NodeServices.ts +11 -0
  39. package/src/platform/Workspace.test.ts +4 -5
  40. package/src/platform/Workspace.ts +3 -3
  41. package/src/platform/WorktreeState.test.ts +2 -3
  42. package/src/platform/WorktreeState.ts +1 -1
  43. package/src/platform/index.ts +2 -1
  44. package/src/replay/Format.test.ts +35 -8
  45. package/src/replay/Format.ts +7 -4
  46. package/src/replay/Replay.test.ts +54 -0
  47. package/src/replay/Replay.ts +61 -17
  48. package/src/replay/Trailers.test.ts +12 -12
  49. package/src/replay/Trailers.ts +35 -20
  50. package/src/replay/Tree.ts +11 -0
  51. package/src/replay/index.ts +3 -0
  52. package/src/step/LandStep.ts +1 -0
  53. package/src/step/RepoSnapshot.ts +2 -0
  54. package/src/step/planStart.test.ts +2 -2
  55. package/src/step/planStart.ts +3 -3
  56. package/src/step/planStep.test.ts +4 -4
  57. package/src/step/planStep.ts +10 -8
  58. package/src/testkit/TestRepo.test.ts +4 -1
  59. package/src/testkit/TestRepo.ts +3 -1
  60. package/src/twins/EmittedScriptRecognizer.ts +32 -1
  61. package/src/twins/EmittedScripts.test.ts +1 -1
  62. package/src/twins/GitTiers.ts +29 -7
  63. package/src/twins/InMemRepo.ts +14 -0
  64. package/src/twins/Layers.test.ts +1 -1
  65. package/src/twins/Layers.ts +10 -3
  66. package/src/twins/PinBeats.ts +33 -0
  67. package/src/twins/cliIo.ts +9 -4
  68. package/src/twins/index.ts +1 -0
  69. package/src/wire/BeatDocument.ts +26 -5
  70. package/src/wire/Demand.ts +16 -6
  71. package/src/wire/constants.ts +3 -0
  72. package/src/wire/types.ts +4 -0
  73. package/src/workflow/discovery.test.ts +24 -7
  74. package/src/workflow/discovery.ts +5 -6
  75. package/src/workflow/index.ts +1 -0
  76. package/src/workflow/load.test.ts +60 -3
  77. package/src/workflow/load.ts +316 -166
  78. package/src/workflow/pin.test.ts +159 -0
  79. package/src/workflow/pin.ts +72 -0
  80. package/src/workflow/vars.ts +3 -3
  81. package/dist/chunks/testkit-BvecVjQ0.mjs +0 -32355
@@ -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
  })
@@ -469,12 +490,14 @@ describe("history reads stay bounded by the episode, not the repository", () =>
469
490
  const raceyGit: GitOperations = {
470
491
  ...base,
471
492
  subjectHistory: (pageSize, skip, head) =>
472
- Effect.tap(base.subjectHistory(pageSize, skip, head), () => {
473
- if (!moved) {
474
- moved = true
475
- repo.hardResetTo(boundary)
476
- }
477
- }),
493
+ Effect.tap(base.subjectHistory(pageSize, skip, head), () =>
494
+ Effect.sync(() => {
495
+ if (!moved) {
496
+ moved = true
497
+ repo.hardResetTo(boundary)
498
+ }
499
+ }),
500
+ ),
478
501
  }
479
502
 
480
503
  const run = await provideWithGit(currentRun, raceyGit, repo)
@@ -521,7 +544,8 @@ describe("summaryRun", () => {
521
544
  expect(summary.trace).toEqual([
522
545
  { state: "building", hash: building, actor: "human" },
523
546
  { state: "checking", hash: checking, actor: "agent" },
524
- { state: "idle", hash: closing, actor: "agent" },
547
+ { state: "unpin", hash: headOf(repo, `${closing}~1`), actor: "agent" },
548
+ { state: "idle", hash: closing, actor: "check" },
525
549
  ])
526
550
  })
527
551
 
@@ -633,7 +657,9 @@ export default workflow(async () => {
633
657
  `--model=${model}`,
634
658
  )
635
659
  if (exitCode !== 0) throw new Error(`gtd land exited ${exitCode}: ${stderr}${stdout}`)
636
- 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
637
663
  }
638
664
  type Rendered = {
639
665
  entryCommit: string
@@ -709,6 +735,7 @@ describe("restAt", () => {
709
735
  )
710
736
  expect(exitCode).toBe(0)
711
737
  await applied(repo, (JSON.parse(stdout) as { readonly script: string }).script)
738
+ await takePinBeat(repo, "add")
712
739
  }
713
740
  const { cost } = await provide(currentRest, repo)
714
741
  expect(cost.total).toBe(11)
@@ -804,6 +831,7 @@ export const review = workflow(async () => {
804
831
  it("is the base the resting step names", async () => {
805
832
  const repo = repoWith(LOOP)
806
833
  const first = await land(repo, { "a.txt": "a\n" })
834
+ expect(repo.lastCommitSubject()).toBe("gtd(check): pin → checkpoint")
807
835
  expect(reviewBaseFor(await provide(currentRest, repo))).toBe(first)
808
836
  await land(repo, { "b.txt": "b\n" })
809
837
  expect(reviewBaseFor(await provide(currentRest, repo))).toBe(first)
@@ -1041,8 +1069,8 @@ export default workflow(async () => {
1041
1069
 
1042
1070
  it("an ordinary start's first commit pins every process setting, sorted by name", async () => {
1043
1071
  const repo = repoWith(LINEAR)
1044
- await land(repo, { "a.txt": "a\n" })
1045
- expect(repo.lastCommitMessage()).toContain(
1072
+ const start = await land(repo, { "a.txt": "a\n" })
1073
+ expect(repo.lastCommitMessage(start)).toContain(
1046
1074
  "\n\nGtd-Step: idle#1\nGtd-Var: base=\nGtd-Var: reviewer=nobody",
1047
1075
  )
1048
1076
  await land(repo)
@@ -1263,13 +1291,34 @@ describe("finishing landings keep history", () => {
1263
1291
  return landing
1264
1292
  }
1265
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
+
1266
1314
  it("flow return: keeps refs/gtd/history/<startHash>, records version and branch", async () => {
1267
1315
  const repo = repoWith(LINEAR)
1268
1316
  const building = await land(repo, { "a.txt": "a\n" })
1269
1317
  await land(repo, { "b.txt": "b\n" })
1270
- repo.writeFile("c.txt", "c\n")
1318
+ await toUnpin(repo, { "c.txt": "c\n" })
1271
1319
  const landing = await landingOf(repo)
1272
1320
  expect(landing.to).toBe("idle")
1321
+ expect(landing.spec.pin).toBe("remove")
1273
1322
  expect(landing.keepHistory).toBe(processHistoryRef(building))
1274
1323
  expect(landing.spec.version).toBe(GTD_VERSION)
1275
1324
  expect(landing.spec.branch).toBe(repo.branchName())
@@ -1285,7 +1334,7 @@ export default async () => {
1285
1334
  }
1286
1335
  `)
1287
1336
  const building = await land(repo, { "a.txt": "a\n" })
1288
- repo.writeFile("b.txt", "b\n")
1337
+ await toUnpin(repo, { "b.txt": "b\n" })
1289
1338
  const landing = await landingOf(repo)
1290
1339
  expect(landing.to).toBe("idle")
1291
1340
  expect(landing.keepHistory).toBe(processHistoryRef(building))
@@ -1312,7 +1361,7 @@ export default async () => {
1312
1361
  const repo = repoWith(LINEAR)
1313
1362
  const building = await land(repo, { "a.txt": "a\n" })
1314
1363
  await land(repo, { "b.txt": "b\n" })
1315
- repo.writeFile("c.txt", "c\n")
1364
+ await toUnpin(repo, { "c.txt": "c\n" })
1316
1365
  const detached: GitOperations = {
1317
1366
  ...fakeGitOperations(repo),
1318
1367
  currentBranch: () => Effect.succeed(Option.none()),
@@ -1336,7 +1385,7 @@ export const side = workflow(async () => {
1336
1385
  })
1337
1386
  `)
1338
1387
  const opening = await enter(repo, "side")
1339
- repo.writeFile("a.txt", "a\n")
1388
+ await toUnpin(repo, { "a.txt": "a\n" })
1340
1389
  const landing = await landingOf(repo)
1341
1390
  expect(landing.to).toBe("idle")
1342
1391
  expect(landing.keepHistory).toBe(processHistoryRef(opening))
@@ -1466,14 +1515,20 @@ describe("historyTrail", () => {
1466
1515
  const trail = await provide(historyTrail(tip), repo)
1467
1516
  expect(trail.tip).toBe(tip)
1468
1517
  expect(trail.ended).toBe("return")
1469
- 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
+ ])
1470
1524
  expect(trail.steps[2]).toMatchObject({
1471
- subject: "gtd(agent): checking → idle",
1525
+ subject: "gtd(agent): checking → unpin",
1472
1526
  actor: "agent",
1473
1527
  from: "checking",
1474
- to: "idle",
1528
+ to: "unpin",
1475
1529
  verdicts: [],
1476
1530
  })
1531
+ expect(trail.steps[3]).toMatchObject({ subject: "gtd(check): unpin → idle", actor: "check" })
1477
1532
  expect(trail.steps[0]).toMatchObject({ actor: "human", to: "building" })
1478
1533
  })
1479
1534
 
@@ -1626,7 +1681,7 @@ export default async () => {
1626
1681
  )
1627
1682
  }).pipe(Effect.provide(testLayers(repo, {}))),
1628
1683
  )
1629
- expect(trail.steps).toHaveLength(3)
1684
+ expect(trail.steps).toHaveLength(4)
1630
1685
  expect(calls).toBe(1)
1631
1686
  })
1632
1687
 
package/src/edge/Edge.ts CHANGED
@@ -37,18 +37,23 @@ import {
37
37
  isRecordableApproval,
38
38
  parseCommitMessage,
39
39
  replay,
40
+ type CommitMessage,
40
41
  scopeRunStart,
41
42
  treeFromRecord,
42
43
  type EpisodeCommit,
43
44
  type JudgeVerdict,
45
+ type PinChange,
46
+ PIN_STATES,
44
47
  type ReachedStep,
45
48
  type ReplayOutcome,
46
49
  type TreeView,
47
50
  } from "../replay/index.js"
48
51
  import type { Mark, ScopeAccess, SummaryContext } from "../flows/index.js"
49
52
  import { steeringFormatFor } from "../steering/index.js"
53
+ import { pinScript, unpinScript } from "../emit/index.js"
50
54
  import {
51
55
  judgeJson,
56
+ PIN_PATH,
52
57
  UNATTRIBUTED_MODEL,
53
58
  type ApprovalEntry,
54
59
  type ModelCost,
@@ -87,6 +92,16 @@ interface EpisodeLocation {
87
92
  readonly processStart: number
88
93
  }
89
94
 
95
+ const isOpening = ({ parsed, step }: CommitMessage): boolean =>
96
+ parsed !== undefined &&
97
+ step === undefined &&
98
+ parsed.from === undefined &&
99
+ parsed.actor === "human"
100
+
101
+ // A pin commit's subject may name the first step; it bounds nothing.
102
+ const boundsEpisode = (def: WorkflowDefinition, { parsed, pin }: CommitMessage): boolean =>
103
+ pin !== "add" && (parsed === undefined || !ACTORS.has(parsed.actor) || parsed.to === def.initial)
104
+
90
105
  /**
91
106
  * Where the episode HEAD belongs to begins, read off subjects alone. Walking
92
107
  * back from HEAD: a bare `gtd(human): <step>` commit with no `Gtd-Step` is what
@@ -99,13 +114,7 @@ interface EpisodeLocation {
99
114
  const locateEpisode = (def: WorkflowDefinition, history: History): EpisodeLocation => {
100
115
  for (let i = history.length - 1; i >= 0; i--) {
101
116
  const message = parseCommitMessage(history[i]!.message)
102
- const subject = message.parsed
103
- const opening =
104
- subject !== undefined &&
105
- message.step === undefined &&
106
- subject.from === undefined &&
107
- subject.actor === "human"
108
- if (opening) {
117
+ if (isOpening(message)) {
109
118
  return {
110
119
  workflow: message.workflow,
111
120
  legacyOpening: message.workflow === undefined,
@@ -113,7 +122,7 @@ const locateEpisode = (def: WorkflowDefinition, history: History): EpisodeLocati
113
122
  processStart: i,
114
123
  }
115
124
  }
116
- if (subject === undefined || !ACTORS.has(subject.actor) || subject.to === def.initial) {
125
+ if (boundsEpisode(def, message)) {
117
126
  return { workflow: undefined, legacyOpening: false, baseIndex: i, processStart: i + 1 }
118
127
  }
119
128
  }
@@ -156,6 +165,8 @@ export interface ProcessRun {
156
165
  readonly trace: readonly TraceEntry[]
157
166
  readonly costEntries: readonly CostEntry[]
158
167
  readonly judgeVerdicts: readonly JudgeVerdictEntry[]
168
+ /** Whether a pin commit has landed in this process. */
169
+ readonly pinCommitted: boolean
159
170
  /** Every process commit's `Gtd-Mark`s, oldest first. */
160
171
  readonly marks: readonly Mark[]
161
172
  /** Oldest first. */
@@ -186,7 +197,8 @@ const headTurnOf = (head: History[number] | undefined): ProcessRun["headTurn"] =
186
197
  if (head === undefined) return undefined
187
198
  const message = parseCommitMessage(head.message)
188
199
  const subject = message.parsed
189
- if (subject === undefined || !ACTORS.has(subject.actor)) return undefined
200
+ if (subject === undefined || !ACTORS.has(subject.actor) || message.pin !== undefined)
201
+ return undefined
190
202
  return {
191
203
  state: subject.to,
192
204
  actor: subject.actor,
@@ -267,8 +279,11 @@ const buildRun = (
267
279
  location: EpisodeLocation,
268
280
  closingHash: string | undefined,
269
281
  ): ProcessRun => {
270
- const processCommits = history.slice(location.processStart)
271
- const parsed = processCommits.map((c) => parseCommitMessage(c.message))
282
+ const allProcessCommits = history.slice(location.processStart)
283
+ const allParsed = allProcessCommits.map((c) => parseCommitMessage(c.message))
284
+ // A pin commit is no turn; the unpin commit stays, as the one that closes the process.
285
+ const processCommits = allProcessCommits.filter((_, i) => allParsed[i]!.pin !== "add")
286
+ const parsed = allParsed.filter((m) => m.pin !== "add")
272
287
  const first = parsed[0]
273
288
  const startParentHash = hashAt(history, location.processStart - 1) ?? EMPTY_TREE
274
289
  const head = history[history.length - 1]
@@ -281,6 +296,7 @@ const buildRun = (
281
296
  trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i]!)),
282
297
  costEntries: parsed.flatMap((m, i) => costEntriesOf(processCommits[i]!.hash, m)),
283
298
  judgeVerdicts: parsed.flatMap((m) => m.judge),
299
+ pinCommitted: allParsed.some((m) => m.pin === "add"),
284
300
  marks: parsed.flatMap((m) => m.marks),
285
301
  approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i]!)),
286
302
  pinnedVars: { ...first?.vars },
@@ -320,6 +336,8 @@ const subjectIsBoundary = (def: WorkflowDefinition, subject: string): boolean =>
320
336
  const parsed = parseCommitMessage(subject).parsed
321
337
  if (parsed === undefined || !ACTORS.has(parsed.actor)) return true
322
338
  if (parsed.actor === "human" && parsed.from === undefined) return false
339
+ // A pin commit's subject can name the first step; it bounds nothing.
340
+ if (parsed.actor === "check" && parsed.from === PIN_STATES.add) return false
323
341
  return parsed.to === def.initial
324
342
  }
325
343
 
@@ -849,6 +867,10 @@ interface ResolvedRest {
849
867
  * before any mutation.
850
868
  */
851
869
  interface Rest extends ResolvedRest {
870
+ /** Set at a pin or unpin beat; `step` is then the step the flow reached around it. */
871
+ readonly pin: PinChange | undefined
872
+ /** Whether HEAD's tree carries the pin. */
873
+ readonly pinned: boolean
852
874
  readonly step: ReachedStep
853
875
  readonly trace: readonly ReachedStep[]
854
876
  readonly run: ProcessRun
@@ -985,13 +1007,15 @@ export const restAt = (
985
1007
  // staying true; passing a pending tree here would make it serve a stale
986
1008
  // steering file on every cache hit.
987
1009
  const outcome = yield* Effect.promise(() => replayFor(setup))
988
- const error = replayError(outcome)
989
- if (error !== undefined || outcome.kind !== "rest") {
990
- return yield* Effect.fail(error ?? new Error("gtd: replay found no rest"))
1010
+ const pinned = headHasPin(workspace, run)
1011
+ const pin = pinBeatAt(outcome, run, def, pinned)
1012
+ const resting = restingOn(outcome, pin)
1013
+ if (resting === undefined) {
1014
+ return yield* Effect.fail(replayError(outcome) ?? new Error("gtd: replay found no rest"))
991
1015
  }
992
- const step = outcome.rest
993
- yield* (yield* Narrator).narrate(`rest resolved: ${step.name} (awaits ${step.actor})`)
994
- const stepDef = stepDefOf(step)
1016
+ const { reached, step } = resting
1017
+ const { stepDef, state, memory } = restFacts(pin, step, reached.trace, run)
1018
+ yield* (yield* Narrator).narrate(`rest resolved: ${state} (awaits ${stepDef.actor})`)
995
1019
  if (stepDef.mode !== undefined && def.modes[stepDef.mode] === undefined) {
996
1020
  return yield* Effect.fail(
997
1021
  new Error(
@@ -1006,7 +1030,6 @@ export const restAt = (
1006
1030
  path: e.path,
1007
1031
  }))
1008
1032
  : []
1009
- const memory = memoryOf(outcome.trace, run)
1010
1033
  const configNow = config.fingerprint
1011
1034
  // Only the current rest warns, so a command must read it once — a second
1012
1035
  // read warns twice. An earlier ref (restore's tip) would warn about a
@@ -1015,11 +1038,13 @@ export const restAt = (
1015
1038
  return {
1016
1039
  def,
1017
1040
  workflowName: resolved.name,
1018
- state: step.name,
1041
+ state,
1019
1042
  stepDef,
1020
- actor: step.actor,
1043
+ actor: stepDef.actor,
1044
+ pin,
1045
+ pinned,
1021
1046
  step,
1022
- trace: outcome.trace,
1047
+ trace: reached.trace,
1023
1048
  run,
1024
1049
  vars,
1025
1050
  env,
@@ -1035,6 +1060,66 @@ export const restAt = (
1035
1060
 
1036
1061
  export const currentRest: Effect.Effect<Rest, Error, RestRequirements> = restAt(undefined)
1037
1062
 
1063
+ /** The step the process rests at and the trace that reached it — at an unpin, the flow's last step. */
1064
+ const restingOn = (outcome: ReplayOutcome, pin: PinChange | undefined) => {
1065
+ if (outcome.kind === "rest") return { reached: outcome, step: outcome.rest }
1066
+ if (outcome.kind !== "ended" || pin !== "remove") return undefined
1067
+ const step = outcome.trace.at(-1)
1068
+ return step === undefined ? undefined : { reached: outcome, step }
1069
+ }
1070
+
1071
+ /** A pin or unpin beat has its own script, no step's, and no agent memory. */
1072
+ const restFacts = (
1073
+ pin: PinChange | undefined,
1074
+ step: ReachedStep,
1075
+ trace: readonly ReachedStep[],
1076
+ run: ProcessRun,
1077
+ ) =>
1078
+ pin === undefined
1079
+ ? { stepDef: stepDefOf(step), state: step.name, memory: memoryOf(trace, run) }
1080
+ : {
1081
+ stepDef: pinStepDef(pin),
1082
+ state: PIN_STATES[pin],
1083
+ memory: { key: undefined, resumed: false },
1084
+ }
1085
+
1086
+ const headHasPin = (workspace: WorkspaceOps, run: ProcessRun): boolean => {
1087
+ const head = run.episode.commits.at(-1)?.hash ?? run.episode.base
1088
+ return head !== undefined && workspace.treeSync(head).has(PIN_PATH)
1089
+ }
1090
+
1091
+ const underwayAt = (run: ProcessRun, step: ReachedStep, def: WorkflowDefinition): boolean =>
1092
+ run.workflow !== undefined || step.name !== def.initial || step.id.occurrence !== 1
1093
+
1094
+ /**
1095
+ * A started process pins first — again, should a turn have deleted the pin;
1096
+ * a pinned flow that ended unpins before the closing commit.
1097
+ */
1098
+ const pinBeatAt = (
1099
+ outcome: ReplayOutcome,
1100
+ run: ProcessRun,
1101
+ def: WorkflowDefinition,
1102
+ pinned: boolean,
1103
+ ): PinChange | undefined => {
1104
+ if (flowEnded(outcome, run, def)) return pinned || run.pinCommitted ? "remove" : undefined
1105
+ if (outcome.kind !== "rest" || pinned) return undefined
1106
+ return underwayAt(run, outcome.rest, def) ? "add" : undefined
1107
+ }
1108
+
1109
+ // A default-workflow process back at the first step has ended, as a returned flow has.
1110
+ const flowEnded = (outcome: ReplayOutcome, run: ProcessRun, def: WorkflowDefinition): boolean =>
1111
+ outcome.kind === "ended" ||
1112
+ (outcome.kind === "rest" &&
1113
+ run.workflow === undefined &&
1114
+ outcome.rest.name === def.initial &&
1115
+ underwayAt(run, outcome.rest, def))
1116
+
1117
+ const pinStepDef = (pin: PinChange): StepDef => ({
1118
+ actor: "check",
1119
+ kind: "script",
1120
+ content: pin === "add" ? pinScript(PIN_PATH) : unpinScript(PIN_PATH),
1121
+ })
1122
+
1038
1123
  type EndedOutcome = Extract<ReplayOutcome, { kind: "ended" }>
1039
1124
  type UnendedOutcome = Exclude<ReplayOutcome, { kind: "ended" }>
1040
1125
 
@@ -1094,6 +1179,7 @@ export interface RenderedRest extends RestHints {
1094
1179
  readonly content: string
1095
1180
  readonly memory?: string
1096
1181
  readonly memoryResumed: boolean
1182
+ readonly pin?: PinChange
1097
1183
  }
1098
1184
 
1099
1185
  export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
@@ -1105,6 +1191,7 @@ export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
1105
1191
  ...rest.hints,
1106
1192
  ...(rest.memory !== undefined ? { memory: rest.memory } : {}),
1107
1193
  memoryResumed: rest.memoryResumed,
1194
+ ...(rest.pin !== undefined ? { pin: rest.pin } : {}),
1108
1195
  })
1109
1196
 
1110
1197
  /**
@@ -1122,9 +1209,7 @@ export const stalledAt = (rest: Rest): boolean =>
1122
1209
 
1123
1210
  /** No process is underway: the default workflow's first step, first visit — a dirty tree there is a turn not yet landed, not a process. */
1124
1211
  export const noProcessUnderway = (rest: Rest): boolean =>
1125
- rest.run.workflow === undefined &&
1126
- rest.state === rest.def.initial &&
1127
- rest.step.id.occurrence === 1
1212
+ rest.pin === undefined && !underwayAt(rest.run, rest.step, rest.def)
1128
1213
 
1129
1214
  /** `idle` means exactly one thing: no process underway, clean tree. */
1130
1215
  export const restIsIdle = (rest: Rest): boolean =>
@@ -1254,15 +1339,15 @@ const landingTo = (
1254
1339
  if (refused !== undefined) return refused
1255
1340
  // Landing on the first step while a process is underway ends it (flow
1256
1341
  // return and `restart()` alike); the script then points the history ref at
1257
- // that very commit.
1258
- const finishes = underway && to === rest.def.initial
1342
+ // that very commit. A pinned process ends one beat later, at the unpin.
1343
+ const { finishes, landsAt } = endingOf(rest, to, underway)
1259
1344
  return {
1260
1345
  kind: "commit",
1261
- to,
1346
+ to: landsAt,
1262
1347
  spec: {
1263
1348
  actor: rest.actor,
1264
1349
  from: rest.state,
1265
- to,
1350
+ to: landsAt,
1266
1351
  step: rest.step.id,
1267
1352
  ...(marks.length > 0 && { marks }),
1268
1353
  ...approvedTrailer(rest, approver),
@@ -1274,6 +1359,53 @@ const landingTo = (
1274
1359
  }
1275
1360
  }
1276
1361
 
1362
+ const endingOf = (rest: Rest, to: StateName, underway: boolean) => {
1363
+ const ends = underway && to === rest.def.initial
1364
+ return { finishes: ends && !rest.pinned, landsAt: ends && rest.pinned ? PIN_STATES.remove : to }
1365
+ }
1366
+
1367
+ /** A pin or unpin commit carries the pin alone; the unpin commit closes the process. */
1368
+ const pinLanding = (rest: Rest, pin: PinChange, branch: Option.Option<string>): Landing => {
1369
+ const change = rest.changes.find((c) => c.path === PIN_PATH)?.status
1370
+ return pin === "add" ? pinCommit(rest, change) : unpinCommit(rest, change, branch)
1371
+ }
1372
+
1373
+ const pinCommit = (rest: Rest, change: string | undefined): Landing => {
1374
+ if (change === undefined || change === "D") return pinRefusal("add")
1375
+ const to = rest.step.name
1376
+ return {
1377
+ kind: "commit",
1378
+ to,
1379
+ spec: { actor: rest.actor, from: rest.state, to, pin: "add" },
1380
+ only: PIN_PATH,
1381
+ }
1382
+ }
1383
+
1384
+ const unpinCommit = (
1385
+ rest: Rest,
1386
+ change: string | undefined,
1387
+ branch: Option.Option<string>,
1388
+ ): Landing => {
1389
+ if (change !== "D" && rest.pinned) return pinRefusal("remove")
1390
+ const to = rest.def.initial
1391
+ return {
1392
+ kind: "commit",
1393
+ to,
1394
+ spec: { actor: rest.actor, from: rest.state, to, pin: "remove", ...closingTrailers(branch) },
1395
+ keepHistory: processHistoryRef(rest.run.startHash),
1396
+ // A turn already deleted the pin: the closing commit has no path of its own.
1397
+ ...(rest.pinned ? { only: PIN_PATH } : {}),
1398
+ }
1399
+ }
1400
+
1401
+ const pinRefusal = (pin: PinChange): Landing => ({
1402
+ kind: "refusal",
1403
+ message:
1404
+ pin === "add"
1405
+ ? `gtd land: ${PIN_PATH} is missing — run the pin beat's script (\`gtd next --json=content\`); if its \`gtd bundle\` fails, fix what it reports, or run \`gtd abandon\``
1406
+ : `gtd land: ${PIN_PATH} is still there — run the unpin beat's script (\`gtd next --json=content\`), then land`,
1407
+ })
1408
+
1277
1409
  const startTrailers = (rest: Rest, starts: boolean) =>
1278
1410
  starts ? { vars: rest.vars, config: rest.config } : {}
1279
1411
 
@@ -1292,6 +1424,7 @@ const decideLanding = (
1292
1424
  approver: Option.Option<AuthorIdent>,
1293
1425
  ): Effect.Effect<Landing, Error> =>
1294
1426
  Effect.gen(function* () {
1427
+ if (rest.pin !== undefined) return pinLanding(rest, rest.pin, branch)
1295
1428
  const unapproved = approvalRefusal(rest, approver)
1296
1429
  if (unapproved !== undefined) return unapproved
1297
1430
  const early = cleanLanding(rest)
@@ -1319,7 +1452,7 @@ const decideLanding = (
1319
1452
  export const previewLanding = (rest: Rest): Effect.Effect<StateName | undefined> =>
1320
1453
  decideLanding(rest, undefined, Option.none(), Option.none()).pipe(
1321
1454
  Effect.map((landing) => (landing.kind === "commit" ? landing.to : undefined)),
1322
- Effect.catchAll(() => Effect.succeed(undefined)),
1455
+ Effect.catch(() => Effect.succeed(undefined)),
1323
1456
  )
1324
1457
 
1325
1458
  /** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
@@ -1432,8 +1565,8 @@ const configRefusal = (
1432
1565
  )
1433
1566
 
1434
1567
  const differingConfigPaths = (
1435
- git: GitService["Type"],
1436
- workspace: Workspace["Type"],
1568
+ git: GitService["Service"],
1569
+ workspace: Workspace["Service"],
1437
1570
  root: string,
1438
1571
  config: { readonly fingerprint: ConfigFingerprint },
1439
1572
  run: ProcessRun,