@pmelab/gtd 62.1.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 (68) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +8 -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-CPzWnl3P.mjs → testkit-zGjNeQNL.mjs} +1144 -345
  7. package/dist/flows.mjs +1 -1
  8. package/dist/gtd.bundle.mjs +4023 -1326
  9. package/dist/gtd.core.mjs +120 -119
  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 +10 -28
  17. package/src/cli/Output.ts +2 -1
  18. package/src/cli/Program.test.ts +55 -53
  19. package/src/cli/Program.ts +36 -45
  20. package/src/cli/ToolCommands.ts +3 -4
  21. package/src/edge/Edge.test.ts +38 -12
  22. package/src/edge/Edge.ts +98 -13
  23. package/src/edge/index.ts +1 -0
  24. package/src/emit/SteeringMode.test.ts +4 -2
  25. package/src/emit/SteeringMode.ts +10 -9
  26. package/src/gherkin/messages.test.ts +1 -1
  27. package/src/gherkin/module-config.feature +1 -1
  28. package/src/gherkin/testkit.feature +2 -2
  29. package/src/install/Version.ts +14 -13
  30. package/src/platform/Host.ts +75 -22
  31. package/src/platform/NodePinFile.ts +44 -0
  32. package/src/platform/NodeRuntime.ts +19 -0
  33. package/src/platform/NodeSync.ts +64 -0
  34. package/src/platform/PinFile.ts +0 -50
  35. package/src/platform/Sha.ts +121 -0
  36. package/src/platform/SyncIo.ts +35 -0
  37. package/src/platform/Workspace.test.ts +8 -3
  38. package/src/platform/Workspace.ts +82 -123
  39. package/src/platform/WorktreeState.test.ts +8 -4
  40. package/src/platform/WorktreeState.ts +21 -19
  41. package/src/platform/index.ts +18 -5
  42. package/src/replay/Format.test.ts +32 -10
  43. package/src/replay/Format.ts +5 -2
  44. package/src/replay/Replay.test.ts +110 -3
  45. package/src/replay/Replay.ts +120 -28
  46. package/src/replay/Trailers.test.ts +10 -10
  47. package/src/replay/Trailers.ts +32 -1
  48. package/src/replay/index.ts +2 -0
  49. package/src/step/planStart.test.ts +2 -2
  50. package/src/step/planStep.test.ts +4 -4
  51. package/src/testkit/TestRepo.test.ts +1 -1
  52. package/src/testkit/TestRepo.ts +4 -18
  53. package/src/twins/Host.test.ts +151 -0
  54. package/src/twins/Layers.test.ts +2 -2
  55. package/src/twins/Layers.ts +44 -18
  56. package/src/twins/Path.test.ts +52 -0
  57. package/src/twins/SyncIo.test.ts +153 -0
  58. package/src/twins/SyncIo.ts +159 -0
  59. package/src/twins/cliIo.ts +8 -1
  60. package/src/twins/index.ts +5 -0
  61. package/src/workflow/discovery.test.ts +6 -5
  62. package/src/workflow/discovery.ts +43 -32
  63. package/src/workflow/fingerprint.test.ts +23 -3
  64. package/src/workflow/fingerprint.ts +9 -11
  65. package/src/workflow/load.test.ts +16 -6
  66. package/src/workflow/load.ts +107 -69
  67. package/src/workflow/pin.test.ts +3 -2
  68. package/src/workflow/pin.ts +17 -11
@@ -1154,6 +1154,8 @@ describe("human({ approve })", () => {
1154
1154
  expect(outcome).toEqual({
1155
1155
  kind: "divergence",
1156
1156
  message: `gtd: commit ${hashOf(1).slice(0, 7)} completes approval gate "gate#1" but records no Gtd-Approved for it — the workflow changed under this process; run \`gtd abandon\` to start over`,
1157
+ at: hashOf(0),
1158
+ reached: "gate",
1157
1159
  })
1158
1160
  })
1159
1161
 
@@ -1224,7 +1226,7 @@ describe("pin commits", () => {
1224
1226
  const pinned = () =>
1225
1227
  new History()
1226
1228
  .land("human", "idle", 1, "build", { "NOTE.md": "n" })
1227
- .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" })
1228
1230
 
1229
1231
  it("are never a turn: the next step answers from the commit after the pin", async () => {
1230
1232
  const flow = async () => {
@@ -1266,8 +1268,113 @@ describe("pin commits", () => {
1266
1268
  await agent("build", "b")
1267
1269
  }
1268
1270
  const h = pinned()
1269
- .land("agent", "build", 1, "unpin", { "a.ts": "a" })
1270
- .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 })
1271
1273
  expect((await replayOf(flow, h)).kind).toBe("ended")
1272
1274
  })
1273
1275
  })
1276
+
1277
+ describe("replay: a repin's anchor", () => {
1278
+ const anchorTo = (to: string, at: number): string =>
1279
+ formatCommitMessage({ actor: "check", from: "gtd:pin", to, pin: "add", anchor: hashOf(at) })
1280
+
1281
+ // The old flow went idle → build → old; this one goes idle → build → check.
1282
+ const anchoredHistory = (): History =>
1283
+ new History()
1284
+ .land("human", "idle", 1, "build", { "a.txt": "a" })
1285
+ .land("agent", "build", 1, "old", { "b.txt": "b" })
1286
+ .raw(anchorTo("build", 1))
1287
+
1288
+ it("records where replay diverged and the step it reached there", async () => {
1289
+ const h = new History().land("human", "idle", 1, "fix", { "TODO.md": "go" })
1290
+ const outcome = await replayOf(fixLoop, h)
1291
+ expect(outcome).toMatchObject({ kind: "divergence", at: hashOf(1), reached: "check" })
1292
+ })
1293
+
1294
+ it("rests at the anchored step, redone on the anchor's tree", async () => {
1295
+ let afterBuild: readonly string[] = []
1296
+ const flow: Flow = async () => {
1297
+ await human("idle")
1298
+ await agent("build", "p")
1299
+ afterBuild = changes().map((c) => c.path)
1300
+ await agent("check", "p")
1301
+ }
1302
+ expect(restName(await replayOf(flow, anchoredHistory()))).toBe("build#1")
1303
+ const outcome = await replayOf(flow, anchoredHistory(), { pending: { "c.txt": "c" } })
1304
+ expect(restName(outcome)).toBe("check#1")
1305
+ expect(afterBuild).toEqual(["c.txt"])
1306
+ })
1307
+
1308
+ it("fails changesSince() on a commit it set aside", async () => {
1309
+ const flow: Flow = async () => {
1310
+ await human("idle")
1311
+ changesSince(hashOf(2))
1312
+ await agent("build", "p")
1313
+ }
1314
+ const outcome = await replayOf(flow, anchoredHistory())
1315
+ expect(outcome.kind === "failed" && outcome.message).toContain("a repin set 0000000 aside")
1316
+ })
1317
+
1318
+ it("diverges again when the flow reaches another step than the anchor names", async () => {
1319
+ const flow: Flow = async () => {
1320
+ await human("idle")
1321
+ await agent("make", "p")
1322
+ }
1323
+ expect(await replayOf(flow, anchoredHistory())).toMatchObject({
1324
+ kind: "divergence",
1325
+ at: hashOf(3),
1326
+ reached: "make",
1327
+ })
1328
+ })
1329
+
1330
+ it("lets the flow code leading to the step read HEAD's tree", async () => {
1331
+ const flow: Flow = async () => {
1332
+ await human("idle")
1333
+ await agent("build", `seen: ${read("b.txt") ?? "nothing"}`)
1334
+ }
1335
+ const outcome = await replayOf(flow, anchoredHistory())
1336
+ expect(outcome.kind === "rest" && outcome.rest.request).toMatchObject({ prompt: "seen: b" })
1337
+ })
1338
+
1339
+ it("diverges when the flow ends where it names a step", async () => {
1340
+ const outcome = await replayOf(async () => void (await human("idle")), anchoredHistory())
1341
+ expect(outcome).toMatchObject({ kind: "divergence", at: hashOf(3), reached: undefined })
1342
+ })
1343
+
1344
+ it("closes the process when it names the unpin and the flow ends", async () => {
1345
+ const h = new History()
1346
+ .land("human", "idle", 1, "build", { "a.txt": "a" })
1347
+ .land("agent", "build", 1, "old")
1348
+ .raw(anchorTo("gtd:unpin", 1))
1349
+ const outcome = await replayOf(async () => void (await human("idle")), h)
1350
+ expect(outcome.kind).toBe("ended")
1351
+ })
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
+
1373
+ it("fails when it names no commit replay reads", async () => {
1374
+ const h = anchoredHistory().raw(anchorTo("build", 2))
1375
+ const outcome = await replayOf(async () => void (await human("idle")), h)
1376
+ expect(outcome.kind === "failed" && outcome.message).toContain(
1377
+ "which is no commit replay reads",
1378
+ )
1379
+ })
1380
+ })
@@ -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
@@ -127,7 +131,14 @@ export type ReplayOutcome =
127
131
  readonly landed: ReachedStep | undefined
128
132
  readonly marks: readonly Mark[]
129
133
  }
130
- | { readonly kind: "divergence"; readonly message: string }
134
+ | {
135
+ readonly kind: "divergence"
136
+ readonly message: string
137
+ /** The commit replay last stood on — where a repin anchors. */
138
+ readonly at: string
139
+ /** The step replay reached there; `undefined` when the flow ended instead. */
140
+ readonly reached: string | undefined
141
+ }
131
142
  | { readonly kind: "refused"; readonly message: string }
132
143
  | { readonly kind: "failed"; readonly message: string }
133
144
 
@@ -261,6 +272,38 @@ interface ParsedCommit extends EpisodeCommit {
261
272
  readonly parsed: CommitMessage
262
273
  }
263
274
 
275
+ const anchorOf = (parsed: CommitMessage): string | undefined =>
276
+ parsed.pin === "add" ? parsed.anchor : undefined
277
+
278
+ const isAnchor = (commit: ParsedCommit): boolean => anchorOf(commit.parsed) !== undefined
279
+
280
+ /**
281
+ * The commits every anchor sets aside — those between the commit it names and
282
+ * itself — or a fault for an anchor naming no commit still read.
283
+ */
284
+ export const setAsideBy = (
285
+ base: string,
286
+ all: readonly { readonly hash: string; readonly parsed: CommitMessage }[],
287
+ ): { readonly setAside: ReadonlySet<string> } | { readonly fault: string } => {
288
+ const setAside = new Set<string>()
289
+ if (all.every((c) => anchorOf(c.parsed) === undefined)) return { setAside }
290
+ let kept: string[] = []
291
+ for (const commit of all) {
292
+ const anchor = anchorOf(commit.parsed)
293
+ if (anchor !== undefined) {
294
+ const at = anchor === base ? -1 : kept.indexOf(anchor)
295
+ if (anchor !== base && at === -1) {
296
+ return {
297
+ fault: `gtd: commit ${short(commit.hash)} anchors the process at ${short(anchor)}, which is no commit replay reads in this episode`,
298
+ }
299
+ }
300
+ for (const hash of kept.splice(at + 1)) setAside.add(hash)
301
+ }
302
+ kept.push(commit.hash)
303
+ }
304
+ return { setAside }
305
+ }
306
+
264
307
  /**
265
308
  * Replay one episode: run its entry flow, answering each step from the
266
309
  * episode's commits in order, until a step has no commit left — the rest.
@@ -323,10 +366,17 @@ const withoutPin = (input: ReplayInput): ReplayInput => {
323
366
 
324
367
  export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
325
368
  const input = withoutPin(raw)
326
- const commits: readonly ParsedCommit[] = input.episode.commits.map((c) => ({
369
+ const all: readonly ParsedCommit[] = input.episode.commits.map((c) => ({
327
370
  ...c,
328
371
  parsed: parseCommitMessage(c.message),
329
372
  }))
373
+ const resolved = setAsideBy(input.episode.base.hash, all)
374
+ if ("fault" in resolved) return { kind: "failed", message: resolved.fault }
375
+ const setAside = resolved.setAside
376
+ const commits = all.filter((c) => !setAside.has(c.hash))
377
+ const pinned = (raw.episode.commits.at(-1)?.tree ?? raw.episode.base.tree)
378
+ .paths()
379
+ .includes(PIN_PATH)
330
380
 
331
381
  // Every replay re-derives base, commits and head() from whatever history
332
382
  // exists right now, so a hash a flow reads this same run (head()/start())
@@ -343,12 +393,27 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
343
393
  let pendingUsed = false
344
394
  let position: Position = input.episode.base
345
395
  // Never stands on a pin commit: `head()` names the turn, so a flow
346
- // reverting it reverts the turn, not the pin.
396
+ // reverting it reverts the turn, not the pin. An anchor is the exception:
397
+ // replay stands on it for HEAD's tree, so the flow code leading to the step
398
+ // it names, and that step's actor, see the set-aside work in place.
399
+ // `changes()` keeps the previous position: the set-aside range counts as
400
+ // part of the last step replay read.
347
401
  const skipPins = (): void => {
348
- while (cursor < commits.length && commits[cursor]!.parsed.pin !== undefined) cursor++
402
+ while (cursor < commits.length && commits[cursor]!.parsed.pin !== undefined) {
403
+ const commit = commits[cursor]!
404
+ cursor++
405
+ if (!isAnchor(commit)) continue
406
+ position = commit
407
+ const to = commit.parsed.parsed?.to
408
+ expectedNext =
409
+ to === undefined || namesUnpin(commit) ? undefined : { name: to, hash: commit.hash }
410
+ anchorPending = expectedNext !== undefined
411
+ }
349
412
  }
350
413
  let previousPosition: Position = input.episode.base
351
414
  let expectedNext: { readonly name: string; readonly hash: string } | undefined
415
+ // Whether `expectedNext` is an anchor's, which a flow that ends contradicts.
416
+ let anchorPending = false
352
417
  const occurrences = new Map<string, number>()
353
418
  const personas = new Map<string, Identity>()
354
419
  const scopes: ScopeOptions[] = []
@@ -547,18 +612,28 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
547
612
  : `gtd: "${step.name}" runs with a different model, system prompt, skills or file access than an earlier agent step in its conversation in memory scope "${step.memoryScope || "root"}" — a conversation has one identity`
548
613
  }
549
614
 
550
- const DIVERGED = "the workflow changed under this process; run `gtd abandon` to start over"
615
+ const DIVERGED = pinned
616
+ ? "the workflow changed under this process; run `gtd repin` to continue under it from there, or `gtd abandon` to start over"
617
+ : "the workflow changed under this process; run `gtd abandon` to start over"
618
+
619
+ const diverged = (message: string, reached: ReachedStep | undefined): ReplayOutcome => ({
620
+ kind: "divergence",
621
+ message: `${message} — ${DIVERGED}`,
622
+ at: position.hash,
623
+ reached: reached?.name,
624
+ })
551
625
 
552
626
  // Checks a reached step against what the previous commit's subject promised
553
627
  // and against the persona of its scope — the reasons replay cannot go on.
554
628
  const blockerAt = (step: ReachedStep): ReplayOutcome | undefined => {
555
629
  const expected = expectedNext
556
630
  expectedNext = undefined
631
+ anchorPending = false
557
632
  if (expected !== undefined && expected.name !== step.name) {
558
- return {
559
- kind: "divergence",
560
- message: `gtd: commit ${short(expected.hash)} names "${expected.name}" as the next step, but replay reached "${step.name}" — ${DIVERGED}`,
561
- }
633
+ return diverged(
634
+ `gtd: commit ${short(expected.hash)} names "${expected.name}" as the next step, but replay reached "${step.name}"`,
635
+ step,
636
+ )
562
637
  }
563
638
  const error =
564
639
  unknownOptions(step) ?? approveConflict(step) ?? modeWithoutFile(step) ?? checkIdentity(step)
@@ -569,10 +644,10 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
569
644
  if (step.request.kind !== "human" || step.request.options.approve !== true) return undefined
570
645
  const approved = commit.parsed.approved
571
646
  if (approved !== undefined && formatStepId(approved.step) === expected) return undefined
572
- return {
573
- kind: "divergence" as const,
574
- message: `gtd: commit ${short(commit.hash)} completes approval gate "${expected}" but records no Gtd-Approved for it — ${DIVERGED}`,
575
- }
647
+ return diverged(
648
+ `gtd: commit ${short(commit.hash)} completes approval gate "${expected}" but records no Gtd-Approved for it`,
649
+ step,
650
+ )
576
651
  }
577
652
 
578
653
  const consumeCommit = (step: ReachedStep, commit: ParsedCommit): unknown => {
@@ -581,23 +656,29 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
581
656
  if (recorded === undefined || formatStepId(recorded) !== expected) {
582
657
  const what =
583
658
  recorded === undefined ? "records no step" : `records step "${formatStepId(recorded)}"`
584
- return finish({
585
- kind: "divergence",
586
- message: `gtd: commit ${short(commit.hash)} ${what}, but replay expected "${expected}" — ${DIVERGED}`,
587
- })
659
+ return finish(
660
+ diverged(
661
+ `gtd: commit ${short(commit.hash)} ${what}, but replay expected "${expected}"`,
662
+ step,
663
+ ),
664
+ )
588
665
  }
589
666
  const unapproved = missingApproval(step, commit, expected)
590
667
  if (unapproved !== undefined) return finish(unapproved)
591
668
  cursor++
592
669
  advance(commit)
670
+ expectNextOf(commit)
593
671
  skipPins()
594
672
  noteKept(step, commit)
595
- const to = commit.parsed.parsed?.to
596
- // A turn that ends a pinned process names the unpin, which is no step.
597
- if (to !== undefined && to !== UNPIN_STATE) expectedNext = { name: to, hash: commit.hash }
598
673
  return replyFor(step.request, commit.parsed.judge)
599
674
  }
600
675
 
676
+ // A turn that ends a pinned process names the unpin, which is no step.
677
+ const expectNextOf = (commit: ParsedCommit): void => {
678
+ const to = commit.parsed.parsed?.to
679
+ if (to !== undefined && !namesUnpin(commit)) expectedNext = { name: to, hash: commit.hash }
680
+ }
681
+
601
682
  const noteKept = (step: ReachedStep, commit: ParsedCommit): void => {
602
683
  if (commit.parsed.kept === undefined) return
603
684
  if (commit.kept === undefined) keptTips.delete(step.name)
@@ -642,8 +723,8 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
642
723
 
643
724
  // gtd.config.ts is loaded without a type check.
644
725
  const requestFault = (request: Exclude<StepRequest, { kind: "restart" }>): string | undefined => {
645
- if (Object.values(PIN_STATES).includes(scoped(request.name))) {
646
- 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`
647
728
  }
648
729
  if (request.kind === "run" && typeof request.body !== "string") {
649
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)`
@@ -679,16 +760,27 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
679
760
 
680
761
  const endOfFlow = (via: "return" | "restart"): ReplayOutcome => {
681
762
  skipPins()
763
+ if (anchorPending && cursor === commits.length) {
764
+ return diverged(
765
+ `gtd: the flow ended, but commit ${short(expectedNext!.hash)} continues the process at "${expectedNext!.name}"`,
766
+ undefined,
767
+ )
768
+ }
682
769
  return cursor < commits.length
683
- ? {
684
- kind: "divergence",
685
- message: `gtd: the flow ended, but commit ${short(commits[cursor]!.hash)} continues the episode — ${DIVERGED}`,
686
- }
770
+ ? diverged(
771
+ `gtd: the flow ended, but commit ${short(commits[cursor]!.hash)} continues the episode`,
772
+ undefined,
773
+ )
687
774
  : { kind: "ended", via, trace, landed, marks }
688
775
  }
689
776
 
690
777
  const changesSince = (hash: string): readonly Change[] => {
691
778
  const tree = treeAt(hash)
779
+ if (setAside.has(hash)) {
780
+ throw new Error(
781
+ `gtd: changesSince(${hash}): a repin set ${short(hash)} aside — pass a hash this run read from head() or start()`,
782
+ )
783
+ }
692
784
  if (tree === undefined) {
693
785
  throw new Error(
694
786
  `gtd: changesSince(${hash}): ${hash} is not the episode base or one of its commits — pass a hash this run read from head() or start(), not one captured earlier, read from state, or from another branch`,
@@ -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: 2",
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: 2")
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: 2",
85
- `Gtd-Version: ${value}\nGtd-Format: 2`,
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: 2")).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: 2")
109
- expect(parseCommitMessage(message).format).toBe(2)
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: 2").format).toBe(2)
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(2)
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: 2")
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
@@ -94,6 +116,8 @@ export interface CommitMessage {
94
116
  readonly approved: Approval | undefined
95
117
  /** `Gtd-Pin`: a commit that adds or removes the pin — never a turn. */
96
118
  readonly pin: PinChange | undefined
119
+ /** `Gtd-Anchor`, on a repin's commit: replay sets aside every commit after this one; a non-hash value is skipped. */
120
+ readonly anchor: string | undefined
97
121
  readonly format: number
98
122
  }
99
123
 
@@ -153,6 +177,7 @@ interface Trailers {
153
177
  kept: string | undefined
154
178
  approved: Approval | undefined
155
179
  pin: PinChange | undefined
180
+ anchor: string | undefined
156
181
  readonly judge: JudgeVerdict[]
157
182
  readonly marks: Mark[]
158
183
  readonly vars: Record<string, string>
@@ -200,6 +225,9 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
200
225
  "Gtd-Pin": (value, into) => {
201
226
  if (value === "add" || value === "remove") into.pin ??= value
202
227
  },
228
+ "Gtd-Anchor": (value, into) => {
229
+ if (HASH_RE.test(value)) into.anchor ??= value
230
+ },
203
231
  "Gtd-Format": (value, into) => {
204
232
  if (FORMAT_RE.test(value)) into.format ??= Number(value)
205
233
  },
@@ -221,6 +249,7 @@ export const parseCommitMessage = (message: string): CommitMessage => {
221
249
  kept: undefined,
222
250
  approved: undefined,
223
251
  pin: undefined,
252
+ anchor: undefined,
224
253
  judge: [],
225
254
  marks: [],
226
255
  vars: {},
@@ -250,6 +279,7 @@ export interface CommitSpec {
250
279
  /** Written after `Gtd-Mark`, before `Gtd-Branch`. */
251
280
  readonly approved?: Approval
252
281
  readonly pin?: PinChange
282
+ readonly anchor?: string
253
283
  readonly branch?: string
254
284
  readonly version?: string
255
285
  }
@@ -275,6 +305,7 @@ export const formatCommitMessage = (spec: CommitSpec): string => {
275
305
  ...(spec.marks ?? []).map((m) => `Gtd-Mark: ${m.name}=${m.value}`),
276
306
  ...trailer("Approved", spec.approved && encodeApproval(spec.approved)),
277
307
  ...trailer("Pin", spec.pin),
308
+ ...trailer("Anchor", spec.anchor),
278
309
  ...trailer("Branch", spec.branch),
279
310
  ...trailer("Version", spec.version),
280
311
  `Gtd-Format: ${HISTORY_FORMAT}`,
@@ -1,6 +1,7 @@
1
1
  export {
2
2
  replay,
3
3
  scopeRunStart,
4
+ setAsideBy,
4
5
  type EpisodeCommit,
5
6
  type ReachedStep,
6
7
  type ReplayOutcome,
@@ -16,6 +17,7 @@ export {
16
17
  type CommitSpec,
17
18
  type JudgeVerdict,
18
19
  type PinChange,
20
+ LEGACY_PIN_STATES,
19
21
  PIN_STATES,
20
22
  } from "./Trailers.js"
21
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: 2",
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: 2",
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: 2" },
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: 2",
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: 2")
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: 2",
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")