@pmelab/gtd 60.1.0 → 61.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +7 -0
  3. package/dist/chunks/{testkit-BhTey1Do.mjs → testkit-C9sq0x07.mjs} +1695 -1454
  4. package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
  5. package/dist/gtd.bundle.mjs +1666 -1449
  6. package/dist/gtd.core.mjs +86 -86
  7. package/dist/testkit/gherkin.mjs +1 -1
  8. package/dist/testkit.mjs +1 -1
  9. package/dist/wire.mjs +2 -2
  10. package/package.json +1 -1
  11. package/src/cli/History.test.ts +10 -1
  12. package/src/cli/Metrics.test.ts +10 -1
  13. package/src/cli/Program.test.ts +30 -19
  14. package/src/cli/Program.ts +4 -1
  15. package/src/cli/Strays.ts +3 -2
  16. package/src/edge/Edge.test.ts +78 -25
  17. package/src/edge/Edge.ts +161 -28
  18. package/src/emit/GitScript.ts +21 -3
  19. package/src/emit/index.ts +3 -0
  20. package/src/gherkin/messages.test.ts +1 -0
  21. package/src/gherkin/module-config.feature +1 -0
  22. package/src/gherkin/more-steps.feature +1 -0
  23. package/src/gherkin/testkit.feature +2 -1
  24. package/src/install/Install.ts +9 -2
  25. package/src/replay/Format.test.ts +35 -8
  26. package/src/replay/Format.ts +7 -4
  27. package/src/replay/Replay.test.ts +54 -0
  28. package/src/replay/Replay.ts +61 -17
  29. package/src/replay/Trailers.test.ts +12 -12
  30. package/src/replay/Trailers.ts +35 -20
  31. package/src/replay/Tree.ts +11 -0
  32. package/src/replay/index.ts +3 -0
  33. package/src/step/LandStep.ts +1 -0
  34. package/src/step/RepoSnapshot.ts +2 -0
  35. package/src/step/planStart.test.ts +2 -2
  36. package/src/step/planStep.test.ts +4 -4
  37. package/src/step/planStep.ts +10 -8
  38. package/src/testkit/TestRepo.test.ts +4 -1
  39. package/src/testkit/TestRepo.ts +3 -1
  40. package/src/twins/EmittedScriptRecognizer.ts +32 -1
  41. package/src/twins/GitTiers.ts +23 -0
  42. package/src/twins/InMemRepo.ts +14 -0
  43. package/src/twins/PinBeats.ts +33 -0
  44. package/src/twins/index.ts +1 -0
  45. package/src/wire/BeatDocument.ts +26 -5
  46. package/src/wire/Demand.ts +16 -6
  47. package/src/wire/constants.ts +3 -0
  48. package/src/wire/types.ts +4 -0
@@ -20,17 +20,20 @@ import {
20
20
  threads,
21
21
  } from "../steering/index.js"
22
22
  import { accessShapeFault, foldAccess, sameAccess } from "./Access.js"
23
- import { globMatches } from "../wire/index.js"
23
+ import { globMatches, PIN_PATH } from "../wire/index.js"
24
24
  import { refNameFault } from "./RefName.js"
25
- import { diffTrees, isEmptyDiff, type TreeView } from "./Tree.js"
25
+ import { diffTrees, isEmptyDiff, withoutPath, type TreeView } from "./Tree.js"
26
26
  import {
27
27
  formatStepId,
28
28
  parseCommitMessage,
29
29
  type CommitMessage,
30
30
  type JudgeVerdict,
31
31
  type StepId,
32
+ PIN_STATES,
32
33
  } from "./Trailers.js"
33
34
 
35
+ const UNPIN_STATE = PIN_STATES.remove
36
+
34
37
  export interface EpisodeCommit {
35
38
  readonly hash: string
36
39
  readonly message: string
@@ -297,7 +300,29 @@ const modeWithoutFile = (step: ReachedStep): string | undefined => {
297
300
  return `gtd: step "${step.name}": mode requires file in ${step.kind}() options — name the steering file the mode checks`
298
301
  }
299
302
 
300
- export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
303
+ // The pin is the engine's, not the process's: flow code never sees it, so a
304
+ // pin commit is an empty diff replay can step over.
305
+ const withoutPin = (input: ReplayInput): ReplayInput => {
306
+ const hide = (tree: TreeView): TreeView => withoutPath(tree, PIN_PATH)
307
+ return {
308
+ ...input,
309
+ episode: {
310
+ base: { ...input.episode.base, tree: hide(input.episode.base.tree) },
311
+ commits: input.episode.commits.map((c) => ({
312
+ ...c,
313
+ tree: hide(c.tree),
314
+ ...(c.kept !== undefined ? { kept: { ...c.kept, tree: hide(c.kept.tree) } } : {}),
315
+ })),
316
+ },
317
+ ...(input.startTree !== undefined ? { startTree: hide(input.startTree) } : {}),
318
+ ...(input.pending !== undefined
319
+ ? { pending: { ...input.pending, tree: hide(input.pending.tree) } }
320
+ : {}),
321
+ }
322
+ }
323
+
324
+ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
325
+ const input = withoutPin(raw)
301
326
  const commits: readonly ParsedCommit[] = input.episode.commits.map((c) => ({
302
327
  ...c,
303
328
  parsed: parseCommitMessage(c.message),
@@ -317,6 +342,11 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
317
342
  let cursor = 0
318
343
  let pendingUsed = false
319
344
  let position: Position = input.episode.base
345
+ // Never stands on a pin commit: `head()` names the turn, so a flow
346
+ // reverting it reverts the turn, not the pin.
347
+ const skipPins = (): void => {
348
+ while (cursor < commits.length && commits[cursor]!.parsed.pin !== undefined) cursor++
349
+ }
320
350
  let previousPosition: Position = input.episode.base
321
351
  let expectedNext: { readonly name: string; readonly hash: string } | undefined
322
352
  const occurrences = new Map<string, number>()
@@ -560,20 +590,25 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
560
590
  if (unapproved !== undefined) return finish(unapproved)
561
591
  cursor++
562
592
  advance(commit)
563
- if (commit.parsed.kept !== undefined) {
564
- if (commit.kept === undefined) keptTips.delete(step.name)
565
- else
566
- keptTips.set(step.name, {
567
- hash: commit.kept.hash,
568
- before: commit.tree,
569
- after: commit.kept.tree,
570
- })
571
- }
593
+ skipPins()
594
+ noteKept(step, commit)
572
595
  const to = commit.parsed.parsed?.to
573
- if (to !== undefined) expectedNext = { name: to, hash: commit.hash }
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 }
574
598
  return replyFor(step.request, commit.parsed.judge)
575
599
  }
576
600
 
601
+ const noteKept = (step: ReachedStep, commit: ParsedCommit): void => {
602
+ if (commit.parsed.kept === undefined) return
603
+ if (commit.kept === undefined) keptTips.delete(step.name)
604
+ else
605
+ keptTips.set(step.name, {
606
+ hash: commit.kept.hash,
607
+ before: commit.tree,
608
+ after: commit.kept.tree,
609
+ })
610
+ }
611
+
577
612
  const landPending = (step: ReachedStep, pending: NonNullable<typeof input.pending>): unknown => {
578
613
  pendingUsed = true
579
614
  landed = step
@@ -587,7 +622,10 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
587
622
  }
588
623
 
589
624
  const answer = (step: ReachedStep): unknown => {
590
- while (cursor < commits.length && isAttemptAt(commits[cursor]!, step.name)) cursor++
625
+ while (cursor < commits.length && isAttemptAt(commits[cursor]!, step.name)) {
626
+ cursor++
627
+ skipPins()
628
+ }
591
629
  const commit = commits[cursor]
592
630
  if (commit !== undefined) return consumeCommit(step, commit)
593
631
  if (input.pending === undefined || pendingUsed) {
@@ -604,6 +642,9 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
604
642
 
605
643
  // gtd.config.ts is loaded without a type check.
606
644
  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`
647
+ }
607
648
  if (request.kind === "run" && typeof request.body !== "string") {
608
649
  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)`
609
650
  }
@@ -636,13 +677,15 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
636
677
  return blocker === undefined ? answer(step) : finish(blocker)
637
678
  }
638
679
 
639
- const endOfFlow = (via: "return" | "restart"): ReplayOutcome =>
640
- cursor < commits.length
680
+ const endOfFlow = (via: "return" | "restart"): ReplayOutcome => {
681
+ skipPins()
682
+ return cursor < commits.length
641
683
  ? {
642
684
  kind: "divergence",
643
685
  message: `gtd: the flow ended, but commit ${short(commits[cursor]!.hash)} continues the episode — ${DIVERGED}`,
644
686
  }
645
687
  : { kind: "ended", via, trace, landed, marks }
688
+ }
646
689
 
647
690
  const changesSince = (hash: string): readonly Change[] => {
648
691
  const tree = treeAt(hash)
@@ -714,13 +757,14 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
714
757
  // At the rest, trailing attempts sit above the last step commit: the head a
715
758
  // prompt names is the commit the process actually stands on.
716
759
  head: () => {
717
- const rest = commits.slice(cursor)
760
+ const rest = commits.slice(cursor).filter((c) => c.parsed.pin === undefined)
718
761
  return rest.length > 0 && rest.every((c) => c.parsed.step === undefined)
719
762
  ? rest[rest.length - 1]!.hash
720
763
  : position.hash
721
764
  },
722
765
  }
723
766
 
767
+ skipPins()
724
768
  installContext(context)
725
769
  try {
726
770
  // A pre-upgrade flow destructuring `{ entry }` reads `undefined` (an
@@ -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: 1",
49
+ "gtd(human): fix-precheck\n\nGtd-Workflow: fix\nGtd-Review-Base: abc\nGtd-Format: 2",
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: 1")
59
+ expect(one).toBe("gtd(human): x\n\nGtd-Var: a=1\nGtd-Var: b=2\nGtd-Var: c=\nGtd-Format: 2")
60
60
  expect(parseCommitMessage(one).vars).toEqual({ a: "1", b: "2", c: "" })
61
61
  })
62
62
 
@@ -68,7 +68,7 @@ describe("the commit-message codec", () => {
68
68
  config: { "lib/my flow.ts": "0123456789abcdef", "gtd.config.ts": "deadbeefdeadbeef" },
69
69
  })
70
70
  expect(message).toBe(
71
- "gtd(human): x\n\nGtd-Var: a=1\nGtd-Config: deadbeefdeadbeef gtd.config.ts\nGtd-Config: 0123456789abcdef lib/my flow.ts\nGtd-Format: 1",
71
+ "gtd(human): x\n\nGtd-Var: a=1\nGtd-Config: deadbeefdeadbeef gtd.config.ts\nGtd-Config: 0123456789abcdef lib/my flow.ts\nGtd-Format: 2",
72
72
  )
73
73
  expect(parseCommitMessage(message).config).toEqual({
74
74
  "gtd.config.ts": "deadbeefdeadbeef",
@@ -118,8 +118,8 @@ describe("the commit-message codec", () => {
118
118
  it("reads a Gtd-Version carrying control bytes as unknown, and keeps spaces and parens", () => {
119
119
  const withVersion = (value: string) =>
120
120
  formatCommitMessage({ actor: "a", to: "b" }).replace(
121
- "Gtd-Format: 1",
122
- `Gtd-Version: ${value}\nGtd-Format: 1`,
121
+ "Gtd-Format: 2",
122
+ `Gtd-Version: ${value}\nGtd-Format: 2`,
123
123
  )
124
124
  expect(parseCommitMessage(withVersion("1.0\u001b]0;x\u0007")).version).toBeUndefined()
125
125
  expect(parseCommitMessage(withVersion("1.0\u009b")).version).toBeUndefined()
@@ -135,15 +135,15 @@ describe("the commit-message codec", () => {
135
135
  expect(parsed.version).toBeUndefined()
136
136
  })
137
137
 
138
- it("keeps Gtd-Format at 1 when branch and version are written", () => {
138
+ it("writes the current Gtd-Format when branch and version are written", () => {
139
139
  const message = formatCommitMessage({ actor: "a", to: "b", branch: "m", version: "1" })
140
- expect(message.endsWith("Gtd-Format: 1")).toBe(true)
140
+ expect(message.endsWith("Gtd-Format: 2")).toBe(true)
141
141
  })
142
142
 
143
143
  it("writes Gtd-Format last on every message, even one with no other trailer", () => {
144
144
  const message = formatCommitMessage({ actor: "agent", to: "building" })
145
- expect(message).toBe("gtd(agent): building\n\nGtd-Format: 1")
146
- expect(parseCommitMessage(message).format).toBe(1)
145
+ expect(message).toBe("gtd(agent): building\n\nGtd-Format: 2")
146
+ expect(parseCommitMessage(message).format).toBe(2)
147
147
  })
148
148
 
149
149
  it("reads a message without Gtd-Format as format 1, and an explicit one as written", () => {
@@ -183,10 +183,10 @@ describe("Gtd-Kept", () => {
183
183
  expect(parseCommitMessage(`gtd(agent): a → b\n\nGtd-Kept: ${hash}0`).kept).toBeUndefined()
184
184
  })
185
185
 
186
- it("reads undefined when absent, and leaves Gtd-Format at 1", () => {
186
+ it("reads undefined when absent, and leaves Gtd-Format at the current format", () => {
187
187
  const parsed = parseCommitMessage(formatCommitMessage({ actor: "agent", to: "b" }))
188
188
  expect(parsed.kept).toBeUndefined()
189
- expect(parsed.format).toBe(1)
189
+ expect(parsed.format).toBe(2)
190
190
  })
191
191
  })
192
192
 
@@ -326,7 +326,7 @@ describe("Gtd-Mark", () => {
326
326
  const at = lines.findIndex((l) => l.startsWith("Gtd-Mark:"))
327
327
  expect(lines[at - 1]).toMatch(/^Gtd-Judge:/)
328
328
  expect(lines[at + 1]).toMatch(/^Gtd-Approved:/)
329
- expect(lines).toContain("Gtd-Format: 1")
329
+ expect(lines).toContain("Gtd-Format: 2")
330
330
  })
331
331
 
332
332
  it("writes nothing without marks", () => {
@@ -4,6 +4,7 @@
4
4
  // command unusable.
5
5
 
6
6
  import type { Mark } from "../flows/index.js"
7
+ import type { PinMark } from "../wire/index.js"
7
8
  import { HISTORY_FORMAT } from "./Format.js"
8
9
 
9
10
  const TRANSITION_SEP = " → "
@@ -67,6 +68,11 @@ export interface Approval {
67
68
  readonly email: string
68
69
  }
69
70
 
71
+ export type PinChange = PinMark
72
+
73
+ /** 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" }
75
+
70
76
  export interface CommitMessage {
71
77
  readonly subject: string
72
78
  readonly parsed: ParsedSubject | undefined
@@ -88,6 +94,8 @@ export interface CommitMessage {
88
94
  readonly kept: string | undefined
89
95
  /** `Gtd-Approved`; a malformed or control-character value is skipped. */
90
96
  readonly approved: Approval | undefined
97
+ /** `Gtd-Pin`: a commit that adds or removes the pin — never a turn. */
98
+ readonly pin: PinChange | undefined
91
99
  readonly format: number
92
100
  }
93
101
 
@@ -156,6 +164,7 @@ interface Trailers {
156
164
  version: string | undefined
157
165
  kept: string | undefined
158
166
  approved: Approval | undefined
167
+ pin: PinChange | undefined
159
168
  readonly judge: JudgeVerdict[]
160
169
  readonly marks: Mark[]
161
170
  readonly vars: Record<string, string>
@@ -206,6 +215,9 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
206
215
  "Gtd-Approved": (value, into) => {
207
216
  into.approved ??= decodeApproval(value)
208
217
  },
218
+ "Gtd-Pin": (value, into) => {
219
+ if (value === "add" || value === "remove") into.pin ??= value
220
+ },
209
221
  "Gtd-Format": (value, into) => {
210
222
  if (FORMAT_RE.test(value)) into.format ??= Number(value)
211
223
  },
@@ -226,6 +238,7 @@ export const parseCommitMessage = (message: string): CommitMessage => {
226
238
  version: undefined,
227
239
  kept: undefined,
228
240
  approved: undefined,
241
+ pin: undefined,
229
242
  judge: [],
230
243
  marks: [],
231
244
  vars: {},
@@ -256,6 +269,7 @@ export interface CommitSpec {
256
269
  readonly marks?: readonly Mark[]
257
270
  /** Written after `Gtd-Mark`, before `Gtd-Branch`. */
258
271
  readonly approved?: Approval
272
+ readonly pin?: PinChange
259
273
  readonly branch?: string
260
274
  readonly version?: string
261
275
  }
@@ -264,27 +278,28 @@ const sortedEntries = (record: Readonly<Record<string, string>> = {}): [string,
264
278
  Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
265
279
 
266
280
  /** Trailer order is fixed so the same spec always yields the same bytes. */
281
+ const trailer = (name: string, value: string | undefined): readonly string[] =>
282
+ value === undefined ? [] : [`Gtd-${name}: ${value}`]
283
+
284
+ const costValue = (cost: CommitSpec["cost"]): string | undefined =>
285
+ cost === undefined ? undefined : `${cost.cost}${cost.model !== undefined ? ` ${cost.model}` : ""}`
286
+
267
287
  export const formatCommitMessage = (spec: CommitSpec): string => {
268
- const lines: string[] = []
269
- if (spec.step !== undefined) lines.push(`Gtd-Step: ${formatStepId(spec.step)}`)
270
- if (spec.workflow !== undefined) lines.push(`Gtd-Workflow: ${spec.workflow}`)
271
- if (spec.reviewBase !== undefined) lines.push(`Gtd-Review-Base: ${spec.reviewBase}`)
272
- for (const [name, value] of sortedEntries(spec.vars)) lines.push(`Gtd-Var: ${name}=${value}`)
273
- for (const [path, hash] of sortedEntries(spec.config))
274
- lines.push(`Gtd-Config: ${hash} ${encodePath(path)}`)
275
- if (spec.cost !== undefined) {
276
- lines.push(
277
- `Gtd-Cost: ${spec.cost.cost}${spec.cost.model !== undefined ? ` ${spec.cost.model}` : ""}`,
278
- )
279
- }
280
- for (const verdict of spec.judge ?? []) lines.push(`Gtd-Judge: ${JSON.stringify(verdict)}`)
281
- for (const m of spec.marks ?? []) lines.push(`Gtd-Mark: ${m.name}=${m.value}`)
282
- if (spec.approved !== undefined) {
283
- lines.push(`Gtd-Approved: ${encodeApproval(spec.approved)}`)
284
- }
285
- if (spec.branch !== undefined) lines.push(`Gtd-Branch: ${spec.branch}`)
286
- if (spec.version !== undefined) lines.push(`Gtd-Version: ${spec.version}`)
287
- lines.push(`Gtd-Format: ${HISTORY_FORMAT}`)
288
+ const lines = [
289
+ ...trailer("Step", spec.step && formatStepId(spec.step)),
290
+ ...trailer("Workflow", spec.workflow),
291
+ ...trailer("Review-Base", spec.reviewBase),
292
+ ...sortedEntries(spec.vars).map(([name, value]) => `Gtd-Var: ${name}=${value}`),
293
+ ...sortedEntries(spec.config).map(([path, hash]) => `Gtd-Config: ${hash} ${encodePath(path)}`),
294
+ ...trailer("Cost", costValue(spec.cost)),
295
+ ...(spec.judge ?? []).map((verdict) => `Gtd-Judge: ${JSON.stringify(verdict)}`),
296
+ ...(spec.marks ?? []).map((m) => `Gtd-Mark: ${m.name}=${m.value}`),
297
+ ...trailer("Approved", spec.approved && encodeApproval(spec.approved)),
298
+ ...trailer("Pin", spec.pin),
299
+ ...trailer("Branch", spec.branch),
300
+ ...trailer("Version", spec.version),
301
+ `Gtd-Format: ${HISTORY_FORMAT}`,
302
+ ]
288
303
  const subject = formatSubject(spec.actor, spec.to, spec.from)
289
304
  return `${subject}\n\n${lines.join("\n")}`
290
305
  }
@@ -43,3 +43,14 @@ export const diffTrees = (before: TreeView, after: TreeView): TreeDiff => {
43
43
 
44
44
  export const isEmptyDiff = (diff: TreeDiff): boolean =>
45
45
  diff.added.length === 0 && diff.modified.length === 0 && diff.deleted.length === 0
46
+
47
+ export const withoutPath = (tree: TreeView, hidden: string): TreeView => {
48
+ let paths: readonly string[] | undefined
49
+ return {
50
+ paths: () => (paths ??= tree.paths().filter((path) => path !== hidden)),
51
+ read: (path) => (path === hidden ? undefined : tree.read(path)),
52
+ ...(tree.id !== undefined
53
+ ? { id: (path: string) => (path === hidden ? undefined : tree.id!(path)) }
54
+ : {}),
55
+ }
56
+ }
@@ -12,8 +12,11 @@ export {
12
12
  formatSubject,
13
13
  isRecordableApproval,
14
14
  parseCommitMessage,
15
+ type CommitMessage,
15
16
  type CommitSpec,
16
17
  type JudgeVerdict,
18
+ type PinChange,
19
+ PIN_STATES,
17
20
  } from "./Trailers.js"
18
21
  export { accessShapeFault } from "./Access.js"
19
22
  export { formatFault } from "./Format.js"
@@ -8,6 +8,7 @@ import type { StateName } from "../workflow/index.js"
8
8
  */
9
9
  export type GitWrite =
10
10
  | { readonly kind: "commitAll"; readonly message: string }
11
+ | { readonly kind: "commitPath"; readonly path: string; readonly message: string }
11
12
  | { readonly kind: "commitClean"; readonly message: string }
12
13
  | { readonly kind: "updateRef"; readonly ref: string; readonly target: "HEAD" }
13
14
  | { readonly kind: "keepTurn"; readonly ref: string; readonly message: string }
@@ -19,6 +19,8 @@ export type Landing =
19
19
  readonly keepHistory?: string
20
20
  /** The kept ref this landing's turn goes to; the branch gets a marker commit instead. */
21
21
  readonly keepTurn?: string
22
+ /** The one path this landing commits; every other pending change stays pending. */
23
+ readonly only?: string
22
24
  }
23
25
 
24
26
  /**
@@ -177,7 +177,7 @@ describe("planStart", () => {
177
177
  kind: "gitWrite",
178
178
  write: {
179
179
  kind: "commitAll",
180
- message: "gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=\nGtd-Format: 1",
180
+ message: "gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=\nGtd-Format: 2",
181
181
  },
182
182
  },
183
183
  { kind: "outcome", outcome: { kind: "commit", subject: "gtd(human): work" } },
@@ -196,7 +196,7 @@ describe("planStart", () => {
196
196
  if (write?.kind !== "gitWrite" || write.write.kind !== "commitAll")
197
197
  throw new Error("expected a gitWrite step")
198
198
  expect(write.write.message).toBe(
199
- "gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=main\nGtd-Format: 1",
199
+ "gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=main\nGtd-Format: 2",
200
200
  )
201
201
  const outcome = plan.steps.find((s) => s.kind === "outcome")
202
202
  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: 1" },
42
+ write: { kind: "commitAll", message: "gtd(agent): await-answers\n\nGtd-Format: 2" },
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: 1",
62
+ message: "gtd(human): building → done\n\nGtd-Step: building#1\nGtd-Format: 2",
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: 1")
102
+ expect(trailers[4]).toBe("Gtd-Format: 2")
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: 1",
185
+ message: "gtd(agent): building → checking\n\nGtd-Step: building#1\nGtd-Format: 2",
186
186
  },
187
187
  })
188
188
  })
@@ -1,7 +1,7 @@
1
1
  import { formatCommitMessage, type JudgeVerdict } from "../replay/index.js"
2
2
  import type { StateName } from "../workflow/index.js"
3
- import type { LandStep } from "./LandStep.js"
4
- import type { RepoSnapshot } from "./RepoSnapshot.js"
3
+ import type { GitWrite, LandStep } from "./LandStep.js"
4
+ import type { Landing, RepoSnapshot } from "./RepoSnapshot.js"
5
5
 
6
6
  export type { JudgeVerdict }
7
7
 
@@ -35,6 +35,13 @@ const uncheckStep = (snapshot: RepoSnapshot): readonly LandStep[] =>
35
35
  ? [{ kind: "uncheck", file: snapshot.file }]
36
36
  : []
37
37
 
38
+ const gitWriteOf = (landing: Extract<Landing, { kind: "commit" }>, message: string): GitWrite => {
39
+ if (landing.only !== undefined) return { kind: "commitPath", path: landing.only, message }
40
+ if (landing.keepTurn !== undefined) return { kind: "keepTurn", ref: landing.keepTurn, message }
41
+ if (landing.spec.approved !== undefined) return { kind: "commitClean", message }
42
+ return { kind: "commitAll", message }
43
+ }
44
+
38
45
  export const planStep = (
39
46
  snapshot: RepoSnapshot,
40
47
  opts: {
@@ -84,12 +91,7 @@ export const planStep = (
84
91
  ...uncheckStep(snapshot),
85
92
  {
86
93
  kind: "gitWrite",
87
- write:
88
- landing.keepTurn !== undefined
89
- ? { kind: "keepTurn", ref: landing.keepTurn, message }
90
- : landing.spec.approved !== undefined
91
- ? { kind: "commitClean", message }
92
- : { kind: "commitAll", message },
94
+ write: gitWriteOf(landing, message),
93
95
  },
94
96
  ...(landing.keepHistory !== undefined
95
97
  ? [
@@ -61,10 +61,12 @@ 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
65
  "gtd(agent): build → check",
65
66
  "gtd(check): check → sign-off",
66
67
  ])
67
68
  expect(repo.read("src/add.ts")).toContain("a + b")
69
+ expect(repo.read(".gtd/process.gtdpin")).toBeDefined()
68
70
  })
69
71
 
70
72
  it("runs a workflow given as gtd.config.ts source", async () => {
@@ -550,7 +552,8 @@ describe("testRepo", () => {
550
552
  trailers: { Refs: ["#1", "#2"] },
551
553
  })
552
554
  await repo.drive({ plan: (_, r) => r.write("PLAN.md", "p\n") })
553
- expect(repo.commits().at(-1)!.trailers["Gtd-Step"]).toEqual(["plan#1"])
555
+ expect(repo.commits().at(-2)!.trailers["Gtd-Step"]).toEqual(["plan#1"])
556
+ expect(repo.commits().at(-1)!.trailers["Gtd-Pin"]).toEqual(["add"])
554
557
  })
555
558
 
556
559
  const greeter = {
@@ -353,13 +353,15 @@ const expectedVerdict = (doc: BeatDocument): string => {
353
353
 
354
354
  const quoted = (names: readonly string[]): string => names.map((name) => `"${name}"`).join(", ")
355
355
 
356
- /** Who acts at `doc`: its scripted actor, or a check's own script under `checks: "run"`. A stall has none. */
356
+ /** Who acts at `doc`: its scripted actor, or its own script at a pin beat or a check under `checks: "run"`. A stall has none. */
357
357
  const actorAt = (
358
358
  doc: BeatDocument,
359
359
  actors: Actors,
360
360
  checks: DriveOptions["checks"],
361
361
  ): { readonly scripted: boolean; readonly actor: ScriptedActor | undefined } => {
362
362
  if (doc.kind === "stalled") return { scripted: false, actor: undefined }
363
+ // A drive is a driver: pinning is its own business, not a step to script.
364
+ if (doc.pin !== undefined) return { scripted: false, actor: runsItsScript }
363
365
  if (Object.hasOwn(actors, doc.state)) return { scripted: true, actor: actors[doc.state] }
364
366
  const runs = checks === "run" && doc.actor === "check"
365
367
  return { scripted: false, actor: runs ? runsItsScript : undefined }
@@ -7,6 +7,7 @@ import {
7
7
  commitAll,
8
8
  commitAsIs,
9
9
  commitClean,
10
+ commitPath,
10
11
  deleteRef,
11
12
  discardPending,
12
13
  hardResetTo,
@@ -24,6 +25,8 @@ import {
24
25
  contradictionMessage,
25
26
  modeContradictionSkipNotice,
26
27
  OUTCOME_MARKER,
28
+ pinScript,
29
+ unpinScript,
27
30
  } from "../emit/index.js"
28
31
  import {
29
32
  BUILT_IN_MODE_NAMES,
@@ -32,7 +35,7 @@ import {
32
35
  type SteeringFormat,
33
36
  } from "../steering/internal.js"
34
37
  import { refNameFault } from "../replay/index.js"
35
- import { renderFormat } from "../wire/index.js"
38
+ import { PIN_PATH, renderFormat } from "../wire/index.js"
36
39
  import { applyFlowScript, rewritten } from "./FlowScripts.js"
37
40
  import type { InMemRepo } from "./InMemRepo.js"
38
41
  import type { CommandTable } from "./Layers.js"
@@ -174,6 +177,17 @@ const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | un
174
177
 
175
178
  if (block.startsWith("git add -A || exit 1\ngtd_sign_cfg=")) return recognizeKeepTurn(repo, block)
176
179
 
180
+ if (block.startsWith("git add -A -- ")) {
181
+ const [path, message] = extractQuotedTokens(block)
182
+ if (path === undefined || message === undefined || commitPath(path, message) !== block) {
183
+ return undefined
184
+ }
185
+ const failure = repo.commitPath(path, message)
186
+ return failure === undefined
187
+ ? { kind: "applied" }
188
+ : { kind: "failed", error: failure, status: 1 }
189
+ }
190
+
177
191
  if (block.startsWith("git add -A || exit 1\n")) {
178
192
  const [message] = extractQuotedTokens(conditionLine)
179
193
  if (message !== undefined && commitAll(message) === block) {
@@ -255,6 +269,22 @@ const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | un
255
269
  return undefined
256
270
  }
257
271
 
272
+ /** What the in-memory `gtd bundle` prints: a pin header, and no engine behind it. */
273
+ const IN_MEMORY_PIN = "gtdpin 1 gtd in-memory\n"
274
+
275
+ /** A pin or unpin beat's script. */
276
+ const recognizePinScripts = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
277
+ if (block === pinScript(PIN_PATH)) {
278
+ repo.writeFile(PIN_PATH, IN_MEMORY_PIN)
279
+ return { kind: "applied" }
280
+ }
281
+ if (block === unpinScript(PIN_PATH)) {
282
+ if (repo.readFile(PIN_PATH) !== undefined) repo.deleteFile(PIN_PATH)
283
+ return { kind: "applied" }
284
+ }
285
+ return undefined
286
+ }
287
+
258
288
  /** The REAL `fileExistsGuard` block, re-derived from the extracted path and string-compared. The one recognizer that can report `{ kind: "stopped" }`: a real `exit 0` there ends the script successfully. */
259
289
  const recognizeFileExistsGuard = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
260
290
  const [file] = extractQuotedTokens(block)
@@ -694,6 +724,7 @@ const recognizersFor = (
694
724
  (block) => recognizeBinaryGuard(commands, block),
695
725
  (block) => (git ? recognizeGitBuilders(repo, block) : undefined),
696
726
  (block) => recognizeWriteLines(repo, block),
727
+ (block) => recognizePinScripts(repo, block),
697
728
  (block) => recognizeFailurePromptWrapper(repo, commands, block),
698
729
  (block) => recognizePresentationSubshell(repo, commands, block, git),
699
730
  (block) => recognizeModeContradictionSkipNotice(block),
@@ -21,6 +21,8 @@ import { Effect, Exit, Option } from "effect"
21
21
  import { Narrator, GitService, Host, type GitOperations, NodeServices } from "../platform/index.js"
22
22
  import {
23
23
  commitAll,
24
+ commitPath,
25
+ unpinScript,
24
26
  commitAsIs,
25
27
  commitClean,
26
28
  softResetTo,
@@ -1356,6 +1358,27 @@ export const runGitScriptContract = (makeTier: () => GitTier): void => {
1356
1358
  })
1357
1359
  })
1358
1360
 
1361
+ describe("commitPath", () => {
1362
+ it("commits the one path; every other pending change stays pending", () => {
1363
+ t.seed.writeFile(".gtd/process.gtdpin", "pin")
1364
+ t.seed.writeFile("other.ts", "export const y = 1")
1365
+ run(t, commitPath(".gtd/process.gtdpin", "gtd(check): pin → building"))
1366
+ expect(t.observe.headSubject()).toBe("gtd(check): pin → building")
1367
+ expect(t.observe.headTouchedPaths()).toEqual([".gtd/process.gtdpin"])
1368
+ expect(t.observe.statusPorcelain()).toContain("other.ts")
1369
+ expect(t.observe.statusPorcelain()).not.toContain("process.gtdpin")
1370
+ })
1371
+
1372
+ it("commits the path's deletion", () => {
1373
+ t.seed.commit("feat: pinned", { ".gtd/process.gtdpin": "pin" })
1374
+ run(t, unpinScript(".gtd/process.gtdpin"))
1375
+ run(t, commitPath(".gtd/process.gtdpin", "gtd(check): unpin → idle"))
1376
+ expect(t.observe.headTouchedPaths()).toEqual([".gtd/process.gtdpin"])
1377
+ expect(t.observe.existsPath(".gtd/process.gtdpin")).toBe(false)
1378
+ expect(t.observe.statusPorcelain().trim()).toBe("")
1379
+ })
1380
+ })
1381
+
1359
1382
  describe("commitClean", () => {
1360
1383
  it("on a clean tree commits an empty commit with the message", () => {
1361
1384
  const headBefore = t.observe.resolveRef("HEAD")
@@ -474,6 +474,20 @@ export class InMemRepo {
474
474
  this.resetHard()
475
475
  }
476
476
 
477
+ /** Twin of `GitScript.commitPath`: commits `path` as the worktree has it, every other staged change left staged. */
478
+ commitPath(path: string, message: string): string | undefined {
479
+ if (!this.worktree.has(path) && !this.headTree().has(path) && !this.index.has(path)) {
480
+ return `error: pathspec '${path}' did not match any file(s) known to git`
481
+ }
482
+ const staged = new Map(this.index)
483
+ this.index = new Map(this.headTree())
484
+ this.stage(path)
485
+ const failure = this.commitAsIs(message)
486
+ this.index = staged
487
+ this.stage(path)
488
+ return failure
489
+ }
490
+
477
491
  commitAllWithPrefix(prefix: string): string | undefined {
478
492
  this.index = new Map(this.worktree)
479
493
  return this.commitAsIs(prefix)