@pmelab/gtd 62.1.0 → 63.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.
@@ -127,7 +127,14 @@ export type ReplayOutcome =
127
127
  readonly landed: ReachedStep | undefined
128
128
  readonly marks: readonly Mark[]
129
129
  }
130
- | { readonly kind: "divergence"; readonly message: string }
130
+ | {
131
+ readonly kind: "divergence"
132
+ readonly message: string
133
+ /** The commit replay last stood on — where a repin anchors. */
134
+ readonly at: string
135
+ /** The step replay reached there; `undefined` when the flow ended instead. */
136
+ readonly reached: string | undefined
137
+ }
131
138
  | { readonly kind: "refused"; readonly message: string }
132
139
  | { readonly kind: "failed"; readonly message: string }
133
140
 
@@ -261,6 +268,38 @@ interface ParsedCommit extends EpisodeCommit {
261
268
  readonly parsed: CommitMessage
262
269
  }
263
270
 
271
+ const anchorOf = (parsed: CommitMessage): string | undefined =>
272
+ parsed.pin === "add" ? parsed.anchor : undefined
273
+
274
+ const isAnchor = (commit: ParsedCommit): boolean => anchorOf(commit.parsed) !== undefined
275
+
276
+ /**
277
+ * The commits every anchor sets aside — those between the commit it names and
278
+ * itself — or a fault for an anchor naming no commit still read.
279
+ */
280
+ export const setAsideBy = (
281
+ base: string,
282
+ all: readonly { readonly hash: string; readonly parsed: CommitMessage }[],
283
+ ): { readonly setAside: ReadonlySet<string> } | { readonly fault: string } => {
284
+ const setAside = new Set<string>()
285
+ if (all.every((c) => anchorOf(c.parsed) === undefined)) return { setAside }
286
+ let kept: string[] = []
287
+ for (const commit of all) {
288
+ const anchor = anchorOf(commit.parsed)
289
+ if (anchor !== undefined) {
290
+ const at = anchor === base ? -1 : kept.indexOf(anchor)
291
+ if (anchor !== base && at === -1) {
292
+ return {
293
+ fault: `gtd: commit ${short(commit.hash)} anchors the process at ${short(anchor)}, which is no commit replay reads in this episode`,
294
+ }
295
+ }
296
+ for (const hash of kept.splice(at + 1)) setAside.add(hash)
297
+ }
298
+ kept.push(commit.hash)
299
+ }
300
+ return { setAside }
301
+ }
302
+
264
303
  /**
265
304
  * Replay one episode: run its entry flow, answering each step from the
266
305
  * episode's commits in order, until a step has no commit left — the rest.
@@ -323,10 +362,17 @@ const withoutPin = (input: ReplayInput): ReplayInput => {
323
362
 
324
363
  export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
325
364
  const input = withoutPin(raw)
326
- const commits: readonly ParsedCommit[] = input.episode.commits.map((c) => ({
365
+ const all: readonly ParsedCommit[] = input.episode.commits.map((c) => ({
327
366
  ...c,
328
367
  parsed: parseCommitMessage(c.message),
329
368
  }))
369
+ const resolved = setAsideBy(input.episode.base.hash, all)
370
+ if ("fault" in resolved) return { kind: "failed", message: resolved.fault }
371
+ const setAside = resolved.setAside
372
+ const commits = all.filter((c) => !setAside.has(c.hash))
373
+ const pinned = (raw.episode.commits.at(-1)?.tree ?? raw.episode.base.tree)
374
+ .paths()
375
+ .includes(PIN_PATH)
330
376
 
331
377
  // Every replay re-derives base, commits and head() from whatever history
332
378
  // exists right now, so a hash a flow reads this same run (head()/start())
@@ -343,12 +389,27 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
343
389
  let pendingUsed = false
344
390
  let position: Position = input.episode.base
345
391
  // Never stands on a pin commit: `head()` names the turn, so a flow
346
- // reverting it reverts the turn, not the pin.
392
+ // reverting it reverts the turn, not the pin. An anchor is the exception:
393
+ // replay stands on it for HEAD's tree, so the flow code leading to the step
394
+ // it names, and that step's actor, see the set-aside work in place.
395
+ // `changes()` keeps the previous position: the set-aside range counts as
396
+ // part of the last step replay read.
347
397
  const skipPins = (): void => {
348
- while (cursor < commits.length && commits[cursor]!.parsed.pin !== undefined) cursor++
398
+ while (cursor < commits.length && commits[cursor]!.parsed.pin !== undefined) {
399
+ const commit = commits[cursor]!
400
+ cursor++
401
+ if (!isAnchor(commit)) continue
402
+ position = commit
403
+ const to = commit.parsed.parsed?.to
404
+ expectedNext =
405
+ to === undefined || to === UNPIN_STATE ? undefined : { name: to, hash: commit.hash }
406
+ anchorPending = expectedNext !== undefined
407
+ }
349
408
  }
350
409
  let previousPosition: Position = input.episode.base
351
410
  let expectedNext: { readonly name: string; readonly hash: string } | undefined
411
+ // Whether `expectedNext` is an anchor's, which a flow that ends contradicts.
412
+ let anchorPending = false
352
413
  const occurrences = new Map<string, number>()
353
414
  const personas = new Map<string, Identity>()
354
415
  const scopes: ScopeOptions[] = []
@@ -547,18 +608,28 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
547
608
  : `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
609
  }
549
610
 
550
- const DIVERGED = "the workflow changed under this process; run `gtd abandon` to start over"
611
+ const DIVERGED = pinned
612
+ ? "the workflow changed under this process; run `gtd repin` to continue under it from there, or `gtd abandon` to start over"
613
+ : "the workflow changed under this process; run `gtd abandon` to start over"
614
+
615
+ const diverged = (message: string, reached: ReachedStep | undefined): ReplayOutcome => ({
616
+ kind: "divergence",
617
+ message: `${message} — ${DIVERGED}`,
618
+ at: position.hash,
619
+ reached: reached?.name,
620
+ })
551
621
 
552
622
  // Checks a reached step against what the previous commit's subject promised
553
623
  // and against the persona of its scope — the reasons replay cannot go on.
554
624
  const blockerAt = (step: ReachedStep): ReplayOutcome | undefined => {
555
625
  const expected = expectedNext
556
626
  expectedNext = undefined
627
+ anchorPending = false
557
628
  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
- }
629
+ return diverged(
630
+ `gtd: commit ${short(expected.hash)} names "${expected.name}" as the next step, but replay reached "${step.name}"`,
631
+ step,
632
+ )
562
633
  }
563
634
  const error =
564
635
  unknownOptions(step) ?? approveConflict(step) ?? modeWithoutFile(step) ?? checkIdentity(step)
@@ -569,10 +640,10 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
569
640
  if (step.request.kind !== "human" || step.request.options.approve !== true) return undefined
570
641
  const approved = commit.parsed.approved
571
642
  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
- }
643
+ return diverged(
644
+ `gtd: commit ${short(commit.hash)} completes approval gate "${expected}" but records no Gtd-Approved for it`,
645
+ step,
646
+ )
576
647
  }
577
648
 
578
649
  const consumeCommit = (step: ReachedStep, commit: ParsedCommit): unknown => {
@@ -581,21 +652,27 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
581
652
  if (recorded === undefined || formatStepId(recorded) !== expected) {
582
653
  const what =
583
654
  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
- })
655
+ return finish(
656
+ diverged(
657
+ `gtd: commit ${short(commit.hash)} ${what}, but replay expected "${expected}"`,
658
+ step,
659
+ ),
660
+ )
588
661
  }
589
662
  const unapproved = missingApproval(step, commit, expected)
590
663
  if (unapproved !== undefined) return finish(unapproved)
591
664
  cursor++
592
665
  advance(commit)
666
+ expectNextOf(commit)
593
667
  skipPins()
594
668
  noteKept(step, commit)
669
+ return replyFor(step.request, commit.parsed.judge)
670
+ }
671
+
672
+ // A turn that ends a pinned process names the unpin, which is no step.
673
+ const expectNextOf = (commit: ParsedCommit): void => {
595
674
  const to = commit.parsed.parsed?.to
596
- // A turn that ends a pinned process names the unpin, which is no step.
597
675
  if (to !== undefined && to !== UNPIN_STATE) expectedNext = { name: to, hash: commit.hash }
598
- return replyFor(step.request, commit.parsed.judge)
599
676
  }
600
677
 
601
678
  const noteKept = (step: ReachedStep, commit: ParsedCommit): void => {
@@ -679,16 +756,27 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
679
756
 
680
757
  const endOfFlow = (via: "return" | "restart"): ReplayOutcome => {
681
758
  skipPins()
759
+ if (anchorPending && cursor === commits.length) {
760
+ return diverged(
761
+ `gtd: the flow ended, but commit ${short(expectedNext!.hash)} continues the process at "${expectedNext!.name}"`,
762
+ undefined,
763
+ )
764
+ }
682
765
  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
- }
766
+ ? diverged(
767
+ `gtd: the flow ended, but commit ${short(commits[cursor]!.hash)} continues the episode`,
768
+ undefined,
769
+ )
687
770
  : { kind: "ended", via, trace, landed, marks }
688
771
  }
689
772
 
690
773
  const changesSince = (hash: string): readonly Change[] => {
691
774
  const tree = treeAt(hash)
775
+ if (setAside.has(hash)) {
776
+ throw new Error(
777
+ `gtd: changesSince(${hash}): a repin set ${short(hash)} aside — pass a hash this run read from head() or start()`,
778
+ )
779
+ }
692
780
  if (tree === undefined) {
693
781
  throw new Error(
694
782
  `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: 3",
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: 3")
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: 3",
85
+ `Gtd-Version: ${value}\nGtd-Format: 3`,
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: 3")).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: 3")
109
+ expect(parseCommitMessage(message).format).toBe(3)
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: 3").format).toBe(3)
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(3)
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: 3")
293
293
  })
294
294
 
295
295
  it("writes nothing without marks", () => {
@@ -94,6 +94,8 @@ export interface CommitMessage {
94
94
  readonly approved: Approval | undefined
95
95
  /** `Gtd-Pin`: a commit that adds or removes the pin — never a turn. */
96
96
  readonly pin: PinChange | undefined
97
+ /** `Gtd-Anchor`, on a repin's commit: replay sets aside every commit after this one; a non-hash value is skipped. */
98
+ readonly anchor: string | undefined
97
99
  readonly format: number
98
100
  }
99
101
 
@@ -153,6 +155,7 @@ interface Trailers {
153
155
  kept: string | undefined
154
156
  approved: Approval | undefined
155
157
  pin: PinChange | undefined
158
+ anchor: string | undefined
156
159
  readonly judge: JudgeVerdict[]
157
160
  readonly marks: Mark[]
158
161
  readonly vars: Record<string, string>
@@ -200,6 +203,9 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
200
203
  "Gtd-Pin": (value, into) => {
201
204
  if (value === "add" || value === "remove") into.pin ??= value
202
205
  },
206
+ "Gtd-Anchor": (value, into) => {
207
+ if (HASH_RE.test(value)) into.anchor ??= value
208
+ },
203
209
  "Gtd-Format": (value, into) => {
204
210
  if (FORMAT_RE.test(value)) into.format ??= Number(value)
205
211
  },
@@ -221,6 +227,7 @@ export const parseCommitMessage = (message: string): CommitMessage => {
221
227
  kept: undefined,
222
228
  approved: undefined,
223
229
  pin: undefined,
230
+ anchor: undefined,
224
231
  judge: [],
225
232
  marks: [],
226
233
  vars: {},
@@ -250,6 +257,7 @@ export interface CommitSpec {
250
257
  /** Written after `Gtd-Mark`, before `Gtd-Branch`. */
251
258
  readonly approved?: Approval
252
259
  readonly pin?: PinChange
260
+ readonly anchor?: string
253
261
  readonly branch?: string
254
262
  readonly version?: string
255
263
  }
@@ -275,6 +283,7 @@ export const formatCommitMessage = (spec: CommitSpec): string => {
275
283
  ...(spec.marks ?? []).map((m) => `Gtd-Mark: ${m.name}=${m.value}`),
276
284
  ...trailer("Approved", spec.approved && encodeApproval(spec.approved)),
277
285
  ...trailer("Pin", spec.pin),
286
+ ...trailer("Anchor", spec.anchor),
278
287
  ...trailer("Branch", spec.branch),
279
288
  ...trailer("Version", spec.version),
280
289
  `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,
@@ -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: 3",
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: 3",
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: 3" },
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: 3",
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: 3")
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: 3",
186
186
  },
187
187
  })
188
188
  })