@pmelab/gtd 60.1.0 → 62.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +16 -0
  3. package/dist/chunks/{testkit-BhTey1Do.mjs → testkit-XQeYCNMn.mjs} +1670 -1470
  4. package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
  5. package/dist/gtd.bundle.mjs +991 -627
  6. package/dist/gtd.core.mjs +117 -117
  7. package/dist/testkit/gherkin.mjs +1 -1
  8. package/dist/testkit.mjs +1 -1
  9. package/dist/wire.mjs +2 -2
  10. package/package.json +1 -1
  11. package/src/cli/Cli.ts +6 -1
  12. package/src/cli/History.test.ts +10 -1
  13. package/src/cli/Launch.test.ts +52 -0
  14. package/src/cli/Launch.ts +143 -0
  15. package/src/cli/Metrics.test.ts +10 -1
  16. package/src/cli/Program.test.ts +30 -20
  17. package/src/cli/Program.ts +4 -2
  18. package/src/cli/Strays.ts +3 -2
  19. package/src/cli/index.ts +1 -0
  20. package/src/edge/Edge.test.ts +79 -26
  21. package/src/edge/Edge.ts +178 -99
  22. package/src/emit/GitScript.ts +21 -3
  23. package/src/emit/index.ts +3 -0
  24. package/src/gherkin/messages.test.ts +1 -0
  25. package/src/gherkin/module-config.feature +1 -0
  26. package/src/gherkin/more-steps.feature +1 -0
  27. package/src/gherkin/testkit.feature +2 -1
  28. package/src/install/Install.ts +9 -2
  29. package/src/platform/PinFile.ts +61 -0
  30. package/src/platform/index.ts +1 -0
  31. package/src/replay/Format.test.ts +35 -8
  32. package/src/replay/Format.ts +7 -4
  33. package/src/replay/Replay.test.ts +54 -0
  34. package/src/replay/Replay.ts +61 -17
  35. package/src/replay/Trailers.test.ts +11 -48
  36. package/src/replay/Trailers.ts +36 -42
  37. package/src/replay/Tree.ts +11 -0
  38. package/src/replay/index.ts +3 -0
  39. package/src/step/LandStep.ts +1 -0
  40. package/src/step/RepoSnapshot.ts +2 -0
  41. package/src/step/planStart.test.ts +2 -3
  42. package/src/step/planStart.ts +1 -5
  43. package/src/step/planStep.test.ts +4 -4
  44. package/src/step/planStep.ts +10 -8
  45. package/src/testkit/TestRepo.test.ts +4 -1
  46. package/src/testkit/TestRepo.ts +3 -1
  47. package/src/twins/EmittedScriptRecognizer.ts +32 -1
  48. package/src/twins/GitTiers.ts +23 -0
  49. package/src/twins/InMemRepo.ts +14 -0
  50. package/src/twins/PinBeats.ts +33 -0
  51. package/src/twins/PinFile.test.ts +107 -0
  52. package/src/twins/PinFile.ts +12 -0
  53. package/src/twins/index.ts +2 -0
  54. package/src/wire/BeatDocument.ts +26 -5
  55. package/src/wire/Demand.ts +16 -6
  56. package/src/wire/constants.ts +3 -0
  57. package/src/wire/types.ts +4 -0
  58. package/src/workflow/fingerprint.ts +2 -2
  59. package/src/workflow/index.ts +2 -2
  60. package/src/workflow/load.ts +14 -1
  61. package/src/workflow/pin.ts +15 -7
@@ -0,0 +1,61 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import { existsSync, readFileSync } from "node:fs"
3
+ import { registerHooks } from "node:module"
4
+ import { join } from "node:path"
5
+ import { pathToFileURL } from "node:url"
6
+
7
+ /** A process's pin as the launcher found it: the file's bytes and where its engine runs from. */
8
+ export interface FoundPin {
9
+ readonly path: string
10
+ readonly bytes: Uint8Array
11
+ }
12
+
13
+ /**
14
+ * Where the launcher looks for a pin: the worktree's copy, else HEAD's — so
15
+ * the landing after an unpin beat deleted the file still runs on the pin.
16
+ */
17
+ export type PinFile = () => FoundPin | undefined
18
+
19
+ // `cwd`, as the engine's own root is: gtd runs only at the worktree root, and
20
+ // a relocated `$GIT_DIR` leaves no `.git` to walk up to. A file that cannot be
21
+ // read counts as no pin, so the launcher refuses it rather than crashing.
22
+ export const livePinFile =
23
+ (cwd: string, path: string): PinFile =>
24
+ () => {
25
+ const file = join(cwd, path)
26
+ if (existsSync(file)) {
27
+ try {
28
+ return { path: file, bytes: readFileSync(file) }
29
+ } catch {
30
+ return { path: file, bytes: new Uint8Array() }
31
+ }
32
+ }
33
+ try {
34
+ const bytes = execFileSync("git", ["show", `HEAD:${path}`], {
35
+ cwd,
36
+ stdio: ["ignore", "pipe", "ignore"],
37
+ })
38
+ return { path: file, bytes }
39
+ } catch {
40
+ return undefined
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Runs a pin's module as this process's main program. Node refuses an unknown
46
+ * extension, so a load hook serves the unpacked source under the pin's own
47
+ * URL — `import.meta.url` stays a file in the repository.
48
+ */
49
+ export const runPinModule = async (path: string, source: string): Promise<void> => {
50
+ const url = pathToFileURL(path).href
51
+ // A pin read from HEAD has no file behind its URL, which Node's own resolve refuses.
52
+ registerHooks({
53
+ resolve: (specifier, context, nextResolve) =>
54
+ specifier === url ? { url, shortCircuit: true } : nextResolve(specifier, context),
55
+ load: (requested, context, nextLoad) =>
56
+ requested === url
57
+ ? { format: "module", source, shortCircuit: true }
58
+ : nextLoad(requested, context),
59
+ })
60
+ await import(url)
61
+ }
@@ -29,3 +29,4 @@ export {
29
29
  readRetainedHistory,
30
30
  restorability,
31
31
  } from "./RetainedHistory.js"
32
+ export { livePinFile, runPinModule, type PinFile } from "./PinFile.js"
@@ -2,25 +2,31 @@ import { describe, expect, it } from "vitest"
2
2
  import { formatCommitMessage, formatFault, parseCommitMessage } from "./index.js"
3
3
 
4
4
  describe("the history format", () => {
5
- it("accepts a history entirely in the current format", () => {
6
- expect(formatFault([{ hash: "a".repeat(40), format: 1 }])).toBeUndefined()
5
+ it("accepts a history in the current format and in the one before it", () => {
6
+ expect(formatFault([{ hash: "a".repeat(40), format: 2 }])).toBeUndefined()
7
+ expect(
8
+ formatFault([
9
+ { hash: "a".repeat(40), format: 1 },
10
+ { hash: "b".repeat(40), format: 2 },
11
+ ]),
12
+ ).toBeUndefined()
7
13
  expect(formatFault([])).toBeUndefined()
8
14
  })
9
15
 
10
16
  it("names the first foreign commit, the found and readable formats, and what to do", () => {
11
17
  const fault = formatFault([
12
18
  { hash: "a".repeat(40), format: 1 },
13
- { hash: "b".repeat(40), format: 2 },
14
- { hash: "c".repeat(40), format: 3 },
19
+ { hash: "b".repeat(40), format: 3 },
20
+ { hash: "c".repeat(40), format: 4 },
15
21
  ])
16
22
  expect(fault).toBe(
17
- "gtd: commit bbbbbbb is written in history format 2, but this gtd reads format 1 — run a gtd release that reads format 2 to continue this process",
23
+ "gtd: commit bbbbbbb is written in history format 3, but this gtd reads formats 1 to 2 — run a gtd release that reads format 3 to continue this process",
18
24
  )
19
25
  })
20
26
  })
21
27
 
22
28
  describe("Gtd-Approved and the history format", () => {
23
- it("leaves Gtd-Format at 1 on a message carrying it", () => {
29
+ it("writes the current format on a message carrying it", () => {
24
30
  const message = formatCommitMessage({
25
31
  actor: "human",
26
32
  to: "b",
@@ -29,7 +35,28 @@ describe("Gtd-Approved and the history format", () => {
29
35
  expect(message).toContain(
30
36
  'Gtd-Approved: {"step":"seam#1","name":"Test","email":"test@test.com"}',
31
37
  )
32
- expect(message).toContain("Gtd-Format: 1")
33
- expect(parseCommitMessage(message).format).toBe(1)
38
+ expect(message).toContain("Gtd-Format: 2")
39
+ expect(parseCommitMessage(message).format).toBe(2)
40
+ })
41
+ })
42
+
43
+ describe("Gtd-Pin", () => {
44
+ it("round-trips add and remove, after Gtd-Mark and before Gtd-Branch", () => {
45
+ const message = formatCommitMessage({
46
+ actor: "check",
47
+ from: "unpin",
48
+ to: "idle",
49
+ pin: "remove",
50
+ branch: "main",
51
+ })
52
+ expect(message).toBe(
53
+ "gtd(check): unpin → idle\n\nGtd-Pin: remove\nGtd-Branch: main\nGtd-Format: 2",
54
+ )
55
+ expect(parseCommitMessage(message).pin).toBe("remove")
56
+ expect(parseCommitMessage("gtd(check): pin → a\n\nGtd-Pin: add").pin).toBe("add")
57
+ })
58
+
59
+ it("reads an unknown value as no pin", () => {
60
+ expect(parseCommitMessage("gtd(check): pin → a\n\nGtd-Pin: frozen").pin).toBeUndefined()
34
61
  })
35
62
  })
@@ -1,14 +1,17 @@
1
1
  // Bump only when what replay reads back out of git changes incompatibly — the
2
2
  // package version must not stand in for it, since most majors leave history
3
- // untouched. A bump that keeps reading the previous format widens the check below.
4
- export const HISTORY_FORMAT = 1
3
+ // untouched. Format 2 adds pin commits, which format 1 would replay as turns.
4
+ export const HISTORY_FORMAT = 2
5
+ const OLDEST_READABLE_FORMAT = 1
5
6
 
6
7
  const short = (hash: string): string => hash.slice(0, 7)
7
8
 
8
9
  export const formatFault = (
9
10
  commits: readonly { readonly hash: string; readonly format: number }[],
10
11
  ): string | undefined => {
11
- const foreign = commits.find((c) => c.format !== HISTORY_FORMAT)
12
+ const foreign = commits.find(
13
+ (c) => c.format < OLDEST_READABLE_FORMAT || c.format > HISTORY_FORMAT,
14
+ )
12
15
  if (foreign === undefined) return undefined
13
- return `gtd: commit ${short(foreign.hash)} is written in history format ${foreign.format}, but this gtd reads format ${HISTORY_FORMAT} — run a gtd release that reads format ${foreign.format} to continue this process`
16
+ return `gtd: commit ${short(foreign.hash)} is written in history format ${foreign.format}, but this gtd reads formats ${OLDEST_READABLE_FORMAT} to ${HISTORY_FORMAT} — run a gtd release that reads format ${foreign.format} to continue this process`
14
17
  }
@@ -6,6 +6,7 @@ import {
6
6
  changes,
7
7
  changesSince,
8
8
  codeThreads,
9
+ head,
9
10
  human,
10
11
  judge,
11
12
  kept,
@@ -1217,3 +1218,56 @@ describe("human({ approve })", () => {
1217
1218
  expect(outcome.kind).toBe("rest")
1218
1219
  })
1219
1220
  })
1221
+
1222
+ describe("pin commits", () => {
1223
+ const PIN = ".gtd/process.gtdpin"
1224
+ const pinned = () =>
1225
+ new History()
1226
+ .land("human", "idle", 1, "build", { "NOTE.md": "n" })
1227
+ .raw("gtd(check): pin → build\n\nGtd-Pin: add", { [PIN]: "gtdpin 1 gtd 1\n" })
1228
+
1229
+ it("are never a turn: the next step answers from the commit after the pin", async () => {
1230
+ const flow = async () => {
1231
+ await human("idle")
1232
+ await agent("build", "b")
1233
+ await agent("check", "c")
1234
+ }
1235
+ const h = pinned().land("agent", "build", 1, "check", { "a.ts": "a" })
1236
+ expect(restName(await replayOf(flow, h))).toBe("check#1")
1237
+ })
1238
+
1239
+ it("hide the pin from every tree flow code reads, so a turn's changes leave it out", async () => {
1240
+ let seen: { readonly changed: readonly string[]; readonly pin: string | undefined } | undefined
1241
+ const flow = async () => {
1242
+ await human("idle")
1243
+ await agent("build", "b")
1244
+ seen = { changed: changes().map((c) => c.path), pin: read(PIN) }
1245
+ await agent("check", "c")
1246
+ }
1247
+ const h = pinned().land("agent", "build", 1, "check", { "a.ts": "a" })
1248
+ await replayOf(flow, h)
1249
+ expect(seen).toEqual({ changed: ["a.ts"], pin: undefined })
1250
+ })
1251
+
1252
+ it("are never what head() names: a flow reverting the start reverts the turn", async () => {
1253
+ let named: string | undefined
1254
+ const flow = async () => {
1255
+ await human("idle")
1256
+ named = head()
1257
+ await agent("build", "b")
1258
+ }
1259
+ await replayOf(flow, pinned())
1260
+ expect(named).toBe(hashOf(1))
1261
+ })
1262
+
1263
+ it("trailing an ended flow are no divergence", async () => {
1264
+ const flow = async () => {
1265
+ await human("idle")
1266
+ await agent("build", "b")
1267
+ }
1268
+ 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
+ expect((await replayOf(flow, h)).kind).toBe("ended")
1272
+ })
1273
+ })
@@ -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,47 +56,10 @@ 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
 
63
- it("writes Gtd-Config trailers sorted by path, after Gtd-Var, and reads paths with spaces back", () => {
64
- const message = formatCommitMessage({
65
- actor: "human",
66
- to: "x",
67
- vars: { a: "1" },
68
- config: { "lib/my flow.ts": "0123456789abcdef", "gtd.config.ts": "deadbeefdeadbeef" },
69
- })
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",
72
- )
73
- expect(parseCommitMessage(message).config).toEqual({
74
- "gtd.config.ts": "deadbeefdeadbeef",
75
- "lib/my flow.ts": "0123456789abcdef",
76
- })
77
- })
78
-
79
- it("round-trips a Gtd-Config path that git or the trailer syntax would alter", () => {
80
- const config = {
81
- " lead.ts": "0123456789abcdef",
82
- "trail.ts ": "0123456789abcdef",
83
- '"q.ts': "0123456789abcdef",
84
- }
85
- const message = formatCommitMessage({ actor: "human", to: "x", config })
86
- expect(message).toContain('Gtd-Config: 0123456789abcdef " lead.ts"')
87
- expect(parseCommitMessage(message).config).toEqual(config)
88
- })
89
-
90
- it("ignores a Gtd-Config trailer without a 16-digit hex hash, without a path, or with a control character in its path", () => {
91
- const read = (value: string) =>
92
- parseCommitMessage(`gtd(human): x\n\nGtd-Config: ${value}`).config
93
- expect(read("zzzzzzzzzzzzzzzz gtd.config.ts")).toEqual({})
94
- expect(read("deadbeef gtd.config.ts")).toEqual({})
95
- expect(read("deadbeefdeadbeef")).toEqual({})
96
- expect(read("deadbeefdeadbeef a\x1b]0;x\x07.ts")).toEqual({})
97
- expect(read('deadbeefdeadbeef "a\\u001b.ts"')).toEqual({})
98
- })
99
-
100
63
  it("round-trips Gtd-Branch and Gtd-Version, after Gtd-Judge and before Gtd-Format", () => {
101
64
  const message = formatCommitMessage({
102
65
  actor: "agent",
@@ -118,8 +81,8 @@ describe("the commit-message codec", () => {
118
81
  it("reads a Gtd-Version carrying control bytes as unknown, and keeps spaces and parens", () => {
119
82
  const withVersion = (value: string) =>
120
83
  formatCommitMessage({ actor: "a", to: "b" }).replace(
121
- "Gtd-Format: 1",
122
- `Gtd-Version: ${value}\nGtd-Format: 1`,
84
+ "Gtd-Format: 2",
85
+ `Gtd-Version: ${value}\nGtd-Format: 2`,
123
86
  )
124
87
  expect(parseCommitMessage(withVersion("1.0\u001b]0;x\u0007")).version).toBeUndefined()
125
88
  expect(parseCommitMessage(withVersion("1.0\u009b")).version).toBeUndefined()
@@ -135,15 +98,15 @@ describe("the commit-message codec", () => {
135
98
  expect(parsed.version).toBeUndefined()
136
99
  })
137
100
 
138
- it("keeps Gtd-Format at 1 when branch and version are written", () => {
101
+ it("writes the current Gtd-Format when branch and version are written", () => {
139
102
  const message = formatCommitMessage({ actor: "a", to: "b", branch: "m", version: "1" })
140
- expect(message.endsWith("Gtd-Format: 1")).toBe(true)
103
+ expect(message.endsWith("Gtd-Format: 2")).toBe(true)
141
104
  })
142
105
 
143
106
  it("writes Gtd-Format last on every message, even one with no other trailer", () => {
144
107
  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)
108
+ expect(message).toBe("gtd(agent): building\n\nGtd-Format: 2")
109
+ expect(parseCommitMessage(message).format).toBe(2)
147
110
  })
148
111
 
149
112
  it("reads a message without Gtd-Format as format 1, and an explicit one as written", () => {
@@ -183,10 +146,10 @@ describe("Gtd-Kept", () => {
183
146
  expect(parseCommitMessage(`gtd(agent): a → b\n\nGtd-Kept: ${hash}0`).kept).toBeUndefined()
184
147
  })
185
148
 
186
- it("reads undefined when absent, and leaves Gtd-Format at 1", () => {
149
+ it("reads undefined when absent, and leaves Gtd-Format at the current format", () => {
187
150
  const parsed = parseCommitMessage(formatCommitMessage({ actor: "agent", to: "b" }))
188
151
  expect(parsed.kept).toBeUndefined()
189
- expect(parsed.format).toBe(1)
152
+ expect(parsed.format).toBe(2)
190
153
  })
191
154
  })
192
155
 
@@ -326,7 +289,7 @@ describe("Gtd-Mark", () => {
326
289
  const at = lines.findIndex((l) => l.startsWith("Gtd-Mark:"))
327
290
  expect(lines[at - 1]).toMatch(/^Gtd-Judge:/)
328
291
  expect(lines[at + 1]).toMatch(/^Gtd-Approved:/)
329
- expect(lines).toContain("Gtd-Format: 1")
292
+ expect(lines).toContain("Gtd-Format: 2")
330
293
  })
331
294
 
332
295
  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
@@ -77,8 +83,6 @@ export interface CommitMessage {
77
83
  /** `Gtd-Mark`, in order; a malformed or control-character line is skipped. */
78
84
  readonly marks: readonly Mark[]
79
85
  readonly vars: Readonly<Record<string, string>>
80
- /** Written only on a process's first commit. */
81
- readonly config: Readonly<Record<string, string>>
82
86
  readonly reviewBase: string | undefined
83
87
  readonly cost: readonly CostEntry[]
84
88
  /** Written only on a process's finishing landing. */
@@ -88,6 +92,8 @@ export interface CommitMessage {
88
92
  readonly kept: string | undefined
89
93
  /** `Gtd-Approved`; a malformed or control-character value is skipped. */
90
94
  readonly approved: Approval | undefined
95
+ /** `Gtd-Pin`: a commit that adds or removes the pin — never a turn. */
96
+ readonly pin: PinChange | undefined
91
97
  readonly format: number
92
98
  }
93
99
 
@@ -112,20 +118,10 @@ const parseJson = (raw: string): unknown => {
112
118
  const TRAILER_RE = /^(Gtd-[A-Za-z-]+):[ \t]*(.*?)[ \t]*$/gm
113
119
  const COST_RE = /^([0-9]+(?:\.[0-9]+)?)(?:[ \t]+(.+))?$/
114
120
  const VAR_RE = /^([^=\s]+)=(.*)$/
115
- const CONFIG_RE = /^([0-9a-f]{16}) (.+)$/
116
- // A path git's message cleanup or the trailer syntax would alter travels as a
117
- // JSON string. A control character is refused on read: history written by
118
- // someone else must never put escape sequences on a terminal.
121
+ // A control character is refused on read: history written by someone else
122
+ // must never put escape sequences on a terminal.
119
123
  // eslint-disable-next-line no-control-regex -- the control range IS the point
120
124
  const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f\u2028\u2029]/
121
- const encodePath = (path: string): string =>
122
- path.trim() !== path || path.startsWith('"') || CONTROL_CHARACTER.test(path)
123
- ? JSON.stringify(path)
124
- : path
125
- const decodePath = (raw: string): string | undefined => {
126
- const path = raw.startsWith('"') ? parseJson(raw) : raw
127
- return typeof path === "string" && path !== "" && !CONTROL_CHARACTER.test(path) ? path : undefined
128
- }
129
125
  const decodeApproval = (raw: string): Approval | undefined => {
130
126
  const parsed = parseJson(raw)
131
127
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined
@@ -156,10 +152,10 @@ interface Trailers {
156
152
  version: string | undefined
157
153
  kept: string | undefined
158
154
  approved: Approval | undefined
155
+ pin: PinChange | undefined
159
156
  readonly judge: JudgeVerdict[]
160
157
  readonly marks: Mark[]
161
158
  readonly vars: Record<string, string>
162
- readonly config: Record<string, string>
163
159
  readonly cost: CostEntry[]
164
160
  format: number | undefined
165
161
  }
@@ -184,11 +180,6 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
184
180
  const kv = VAR_RE.exec(value)
185
181
  if (kv !== null) into.vars[kv[1]!] = kv[2]!
186
182
  },
187
- "Gtd-Config": (value, into) => {
188
- const entry = CONFIG_RE.exec(value)
189
- const path = entry === null ? undefined : decodePath(entry[2]!)
190
- if (path !== undefined) into.config[path] = entry![1]!
191
- },
192
183
  "Gtd-Review-Base": (value, into) => {
193
184
  into.reviewBase ??= value.split(/\s/)[0]
194
185
  },
@@ -206,6 +197,9 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
206
197
  "Gtd-Approved": (value, into) => {
207
198
  into.approved ??= decodeApproval(value)
208
199
  },
200
+ "Gtd-Pin": (value, into) => {
201
+ if (value === "add" || value === "remove") into.pin ??= value
202
+ },
209
203
  "Gtd-Format": (value, into) => {
210
204
  if (FORMAT_RE.test(value)) into.format ??= Number(value)
211
205
  },
@@ -226,10 +220,10 @@ export const parseCommitMessage = (message: string): CommitMessage => {
226
220
  version: undefined,
227
221
  kept: undefined,
228
222
  approved: undefined,
223
+ pin: undefined,
229
224
  judge: [],
230
225
  marks: [],
231
226
  vars: {},
232
- config: {},
233
227
  cost: [],
234
228
  format: undefined,
235
229
  }
@@ -249,13 +243,13 @@ export interface CommitSpec {
249
243
  readonly workflow?: string
250
244
  readonly reviewBase?: string
251
245
  readonly vars?: Readonly<Record<string, string>>
252
- readonly config?: Readonly<Record<string, string>>
253
246
  readonly cost?: { readonly cost: number; readonly model?: string }
254
247
  readonly judge?: readonly JudgeVerdict[]
255
248
  /** Written after `Gtd-Judge`, before `Gtd-Approved`. */
256
249
  readonly marks?: readonly Mark[]
257
250
  /** Written after `Gtd-Mark`, before `Gtd-Branch`. */
258
251
  readonly approved?: Approval
252
+ readonly pin?: PinChange
259
253
  readonly branch?: string
260
254
  readonly version?: string
261
255
  }
@@ -264,27 +258,27 @@ const sortedEntries = (record: Readonly<Record<string, string>> = {}): [string,
264
258
  Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
265
259
 
266
260
  /** Trailer order is fixed so the same spec always yields the same bytes. */
261
+ const trailer = (name: string, value: string | undefined): readonly string[] =>
262
+ value === undefined ? [] : [`Gtd-${name}: ${value}`]
263
+
264
+ const costValue = (cost: CommitSpec["cost"]): string | undefined =>
265
+ cost === undefined ? undefined : `${cost.cost}${cost.model !== undefined ? ` ${cost.model}` : ""}`
266
+
267
267
  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}`)
268
+ const lines = [
269
+ ...trailer("Step", spec.step && formatStepId(spec.step)),
270
+ ...trailer("Workflow", spec.workflow),
271
+ ...trailer("Review-Base", spec.reviewBase),
272
+ ...sortedEntries(spec.vars).map(([name, value]) => `Gtd-Var: ${name}=${value}`),
273
+ ...trailer("Cost", costValue(spec.cost)),
274
+ ...(spec.judge ?? []).map((verdict) => `Gtd-Judge: ${JSON.stringify(verdict)}`),
275
+ ...(spec.marks ?? []).map((m) => `Gtd-Mark: ${m.name}=${m.value}`),
276
+ ...trailer("Approved", spec.approved && encodeApproval(spec.approved)),
277
+ ...trailer("Pin", spec.pin),
278
+ ...trailer("Branch", spec.branch),
279
+ ...trailer("Version", spec.version),
280
+ `Gtd-Format: ${HISTORY_FORMAT}`,
281
+ ]
288
282
  const subject = formatSubject(spec.actor, spec.to, spec.from)
289
283
  return `${subject}\n\n${lines.join("\n")}`
290
284
  }