@pmelab/gtd 24.0.0 → 24.1.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 (196) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +31 -5
  3. package/claude/hooks/access.ts +1 -1
  4. package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
  5. package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
  6. package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
  7. package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
  8. package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
  9. package/dist/flows.mjs +525 -0
  10. package/dist/gtd.bundle.mjs +43339 -55575
  11. package/dist/steering.mjs +2 -0
  12. package/dist/testkit.mjs +13006 -0
  13. package/dist/wire.mjs +2 -0
  14. package/dist/workflow-kit.mjs +352 -0
  15. package/dist/workflows/feature.mjs +315 -0
  16. package/dist/workflows/fix.mjs +40 -0
  17. package/dist/workflows/review.mjs +25 -0
  18. package/dist/workflows.mjs +8 -6
  19. package/package.json +69 -8
  20. package/src/cli/Cli.test.ts +1329 -0
  21. package/src/cli/Cli.ts +1348 -0
  22. package/src/cli/Program.test.ts +2262 -0
  23. package/src/cli/Program.ts +1331 -0
  24. package/src/cli/ToolCommands.ts +43 -0
  25. package/src/cli/index.ts +14 -0
  26. package/src/edge/Edge.test.ts +1016 -0
  27. package/src/edge/Edge.ts +1225 -0
  28. package/src/edge/index.ts +16 -0
  29. package/src/emit/Emit.test.ts +425 -0
  30. package/src/emit/Emit.ts +139 -0
  31. package/src/emit/GitScript.test.ts +260 -0
  32. package/src/emit/GitScript.ts +131 -0
  33. package/src/emit/ModeContradiction.test.ts +120 -0
  34. package/src/emit/ModeContradiction.ts +66 -0
  35. package/src/emit/OutcomeScript.test.ts +238 -0
  36. package/src/emit/OutcomeScript.ts +73 -0
  37. package/src/emit/SteeringMode.test.ts +274 -0
  38. package/src/emit/SteeringMode.ts +256 -0
  39. package/src/emit/index.ts +40 -0
  40. package/src/install/ConfigScaffold.ts +19 -0
  41. package/src/install/Install.test.ts +426 -0
  42. package/src/install/Install.ts +634 -0
  43. package/src/install/Version.ts +27 -0
  44. package/src/install/index.ts +3 -0
  45. package/src/platform/CommandRunner.ts +74 -0
  46. package/src/platform/Commentary.test.ts +126 -0
  47. package/src/platform/Commentary.ts +92 -0
  48. package/src/platform/Git.test.ts +50 -0
  49. package/src/platform/Git.ts +552 -0
  50. package/src/platform/Host.ts +42 -0
  51. package/src/platform/RetainedHistory.test.ts +65 -0
  52. package/src/platform/RetainedHistory.ts +34 -0
  53. package/src/platform/Workspace.test.ts +669 -0
  54. package/src/platform/Workspace.ts +631 -0
  55. package/src/platform/WorktreeState.test.ts +162 -0
  56. package/src/platform/WorktreeState.ts +50 -0
  57. package/src/platform/index.ts +22 -0
  58. package/src/replay/Access.test.ts +53 -0
  59. package/src/replay/Access.ts +46 -0
  60. package/src/replay/Format.test.ts +20 -0
  61. package/src/replay/Format.ts +14 -0
  62. package/src/replay/Replay.test.ts +887 -0
  63. package/src/replay/Replay.ts +669 -0
  64. package/src/replay/Trailers.test.ts +117 -0
  65. package/src/replay/Trailers.ts +213 -0
  66. package/src/replay/Tree.test.ts +25 -0
  67. package/src/replay/Tree.ts +45 -0
  68. package/src/replay/index.ts +11 -0
  69. package/src/steering/Blocks.fixture.ts +6 -0
  70. package/src/steering/Blocks.test.ts +167 -0
  71. package/src/steering/Blocks.ts +321 -0
  72. package/src/steering/CodeThreads.test.ts +127 -0
  73. package/src/steering/CodeThreads.ts +197 -0
  74. package/src/steering/Edits.test.ts +19 -0
  75. package/src/steering/Edits.ts +20 -0
  76. package/src/steering/Eol.test.ts +16 -0
  77. package/src/steering/Eol.ts +2 -0
  78. package/src/steering/FORMATS.md +58 -0
  79. package/src/steering/Footnotes.test.ts +977 -0
  80. package/src/steering/Footnotes.ts +661 -0
  81. package/src/steering/Inline.fixture.ts +7 -0
  82. package/src/steering/Inline.test.ts +227 -0
  83. package/src/steering/Inline.ts +195 -0
  84. package/src/steering/MarkdownTree.test.ts +247 -0
  85. package/src/steering/MarkdownTree.ts +260 -0
  86. package/src/steering/Outline.ts +19 -0
  87. package/src/steering/SteeringFormat.test.ts +91 -0
  88. package/src/steering/SteeringFormat.ts +267 -0
  89. package/src/steering/SteeringFormats.test.ts +163 -0
  90. package/src/steering/Threads.ts +163 -0
  91. package/src/steering/contract.test.ts +264 -0
  92. package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
  93. package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
  94. package/src/steering/fixtures/free-form/notes.md +12 -0
  95. package/src/steering/fixtures/free-form/notes.read.json +42 -0
  96. package/src/steering/fixtures/qa/answered.md +23 -0
  97. package/src/steering/fixtures/qa/answered.read.json +119 -0
  98. package/src/steering/fixtures/qa/requirements.md +160 -0
  99. package/src/steering/fixtures/qa/requirements.read.json +475 -0
  100. package/src/steering/fixtures/review/review.md +76 -0
  101. package/src/steering/fixtures/review/review.read.json +237 -0
  102. package/src/steering/fixtures/review/ticked.md +23 -0
  103. package/src/steering/fixtures/review/ticked.read.json +99 -0
  104. package/src/steering/fixtures/review-notes/baseline.md +14 -0
  105. package/src/steering/fixtures/review-notes/noted.md +19 -0
  106. package/src/steering/freeform.test.ts +311 -0
  107. package/src/steering/freeform.ts +148 -0
  108. package/src/steering/index.test.ts +62 -0
  109. package/src/steering/index.ts +140 -0
  110. package/src/steering/qa.test.ts +2160 -0
  111. package/src/steering/qa.ts +1104 -0
  112. package/src/steering/review.test.ts +1829 -0
  113. package/src/steering/review.ts +1180 -0
  114. package/src/step/LandStep.ts +31 -0
  115. package/src/step/RepoSnapshot.ts +29 -0
  116. package/src/step/accessRefusal.test.ts +58 -0
  117. package/src/step/accessRefusal.ts +26 -0
  118. package/src/step/index.ts +5 -0
  119. package/src/step/planStart.test.ts +202 -0
  120. package/src/step/planStart.ts +167 -0
  121. package/src/step/planStep.test.ts +104 -0
  122. package/src/step/planStep.ts +89 -0
  123. package/src/step/snapshot.fixture.ts +28 -0
  124. package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
  125. package/src/testkit/EmittedScriptRecognizer.ts +642 -0
  126. package/src/testkit/FakeGitOperations.ts +95 -0
  127. package/src/testkit/GitTiers.test.ts +11 -0
  128. package/src/testkit/GitTiers.ts +1052 -0
  129. package/src/testkit/InMemRepo.ts +467 -0
  130. package/src/testkit/Layers.test.ts +58 -0
  131. package/src/testkit/Layers.ts +241 -0
  132. package/src/testkit/StrictGitOperations.ts +31 -0
  133. package/src/testkit/TestRepo.test.ts +119 -0
  134. package/src/testkit/TestRepo.ts +274 -0
  135. package/src/testkit/cliIo.ts +36 -0
  136. package/src/testkit/index.ts +9 -0
  137. package/src/testkit/steps.ts +177 -0
  138. package/src/testkit/testkit.feature +65 -0
  139. package/src/wire/BeatDocument.test.ts +419 -0
  140. package/src/wire/BeatDocument.ts +114 -0
  141. package/src/wire/BeatPlain.test.ts +113 -0
  142. package/src/wire/BeatPlain.ts +127 -0
  143. package/src/wire/BeatStatus.test.ts +59 -0
  144. package/src/wire/BeatStatus.ts +81 -0
  145. package/src/wire/Demand.test.ts +142 -0
  146. package/src/wire/Demand.ts +108 -0
  147. package/src/wire/ExitCodes.test.ts +21 -0
  148. package/src/wire/ExitCodes.ts +16 -0
  149. package/src/wire/Glob.ts +29 -0
  150. package/src/wire/Judge.ts +22 -0
  151. package/src/wire/Land.test.ts +120 -0
  152. package/src/wire/Land.ts +75 -0
  153. package/src/wire/Select.test.ts +148 -0
  154. package/src/wire/Select.ts +78 -0
  155. package/src/wire/Sessions.test.ts +59 -0
  156. package/src/wire/Sessions.ts +39 -0
  157. package/src/wire/WireSchema.test.ts +162 -0
  158. package/src/wire/constants.ts +22 -0
  159. package/src/wire/golden/1/beat-capture.json +34 -0
  160. package/src/wire/golden/1/beat-message.json +34 -0
  161. package/src/wire/golden/1/beat-prompt.json +44 -0
  162. package/src/wire/golden/1/beat-script.json +34 -0
  163. package/src/wire/golden/1/beat-stalled.json +34 -0
  164. package/src/wire/golden/1/judge.json +14 -0
  165. package/src/wire/golden/1/land-noop.json +10 -0
  166. package/src/wire/golden/1/land.json +10 -0
  167. package/src/wire/index.ts +12 -0
  168. package/src/wire/types.ts +46 -0
  169. package/src/workflow/Diagnostic.test.ts +93 -0
  170. package/src/workflow/Diagnostic.ts +54 -0
  171. package/src/workflow/SteeringFormats.test.ts +27 -0
  172. package/src/workflow/SteeringFormats.ts +6 -0
  173. package/src/workflow/Workflow.ts +77 -0
  174. package/src/workflow/compile.test.ts +53 -0
  175. package/src/workflow/compile.ts +56 -0
  176. package/src/workflow/discovery.ts +122 -0
  177. package/src/workflow/fingerprint.test.ts +52 -0
  178. package/src/workflow/fingerprint.ts +53 -0
  179. package/src/workflow/index.ts +33 -0
  180. package/src/workflow/load.test.ts +1017 -0
  181. package/src/workflow/load.ts +836 -0
  182. package/src/workflow/skills.test.ts +109 -0
  183. package/src/workflow/skills.ts +90 -0
  184. package/src/workflow/vars.ts +33 -0
  185. package/src/workflows/feature/feature.test.ts +27 -33
  186. package/src/workflows/feature/index.ts +10 -0
  187. package/src/workflows/fix/fix.feature +80 -0
  188. package/src/workflows/fix/fix.test.ts +58 -78
  189. package/src/workflows/fix/index.ts +6 -0
  190. package/src/workflows/index.ts +2 -10
  191. package/src/workflows/review/index.ts +6 -0
  192. package/src/workflows/review/review.test.ts +85 -29
  193. package/src/workflows/review/review.ts +3 -1
  194. package/src/workflows/shared/config.fixture.ts +12 -0
  195. package/src/workflows/shared/reserved.ts +11 -0
  196. package/src/workflows/shared/workflows.steps.ts +7 -0
@@ -0,0 +1,117 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { formatCommitMessage, parseCommitMessage, parseSubject } from "./Trailers.js"
3
+
4
+ describe("the commit-message codec", () => {
5
+ it("round-trips a step commit's subject and trailers", () => {
6
+ const message = formatCommitMessage({
7
+ actor: "judge",
8
+ from: "health.judge",
9
+ to: "health.escalate",
10
+ step: { name: "health.judge", occurrence: 2 },
11
+ cost: { cost: 1450, model: "smart" },
12
+ judge: [{ id: "verdict", answer: "identical", p: 0.9 }],
13
+ })
14
+ expect(message.split("\n")[0]).toBe("gtd(judge): health.judge → health.escalate")
15
+ const parsed = parseCommitMessage(message)
16
+ expect(parsed.parsed).toEqual({ actor: "judge", from: "health.judge", to: "health.escalate" })
17
+ expect(parsed.step).toEqual({ name: "health.judge", occurrence: 2 })
18
+ expect(parsed.cost).toEqual([{ cost: 1450, model: "smart" }])
19
+ expect(parsed.judge).toEqual([{ id: "verdict", answer: "identical", p: 0.9 }])
20
+ })
21
+
22
+ it("collapses a self-loop to the bare subject and keeps entry trailers", () => {
23
+ const message = formatCommitMessage({
24
+ actor: "human",
25
+ to: "review-gate.check",
26
+ reviewBase: "abc",
27
+ vars: { reviewBase: "main=x" },
28
+ })
29
+ const parsed = parseCommitMessage(message)
30
+ expect(parsed.subject).toBe("gtd(human): review-gate.check")
31
+ expect(parsed.step).toBeUndefined()
32
+ expect(parsed.reviewBase).toBe("abc")
33
+ expect(parsed.vars).toEqual({ reviewBase: "main=x" })
34
+ })
35
+
36
+ it("round-trips the workflow trailer, written after Gtd-Step and before Gtd-Review-Base", () => {
37
+ const message = formatCommitMessage({
38
+ actor: "human",
39
+ to: "fix-precheck",
40
+ workflow: "fix",
41
+ reviewBase: "abc",
42
+ })
43
+ expect(message).toBe(
44
+ "gtd(human): fix-precheck\n\nGtd-Workflow: fix\nGtd-Review-Base: abc\nGtd-Format: 1",
45
+ )
46
+ expect(parseCommitMessage(message).workflow).toBe("fix")
47
+ expect(parseCommitMessage("gtd(human): fix-precheck").workflow).toBeUndefined()
48
+ })
49
+
50
+ it("writes Gtd-Var trailers sorted by name, whatever the insertion order", () => {
51
+ const one = formatCommitMessage({ actor: "human", to: "x", vars: { b: "2", a: "1", c: "" } })
52
+ const two = formatCommitMessage({ actor: "human", to: "x", vars: { c: "", a: "1", b: "2" } })
53
+ expect(one).toBe(two)
54
+ expect(one).toBe("gtd(human): x\n\nGtd-Var: a=1\nGtd-Var: b=2\nGtd-Var: c=\nGtd-Format: 1")
55
+ expect(parseCommitMessage(one).vars).toEqual({ a: "1", b: "2", c: "" })
56
+ })
57
+
58
+ it("writes Gtd-Config trailers sorted by path, after Gtd-Var, and reads paths with spaces back", () => {
59
+ const message = formatCommitMessage({
60
+ actor: "human",
61
+ to: "x",
62
+ vars: { a: "1" },
63
+ config: { "lib/my flow.ts": "0123456789abcdef", "gtd.config.ts": "deadbeefdeadbeef" },
64
+ })
65
+ expect(message).toBe(
66
+ "gtd(human): x\n\nGtd-Var: a=1\nGtd-Config: deadbeefdeadbeef gtd.config.ts\nGtd-Config: 0123456789abcdef lib/my flow.ts\nGtd-Format: 1",
67
+ )
68
+ expect(parseCommitMessage(message).config).toEqual({
69
+ "gtd.config.ts": "deadbeefdeadbeef",
70
+ "lib/my flow.ts": "0123456789abcdef",
71
+ })
72
+ })
73
+
74
+ it("round-trips a Gtd-Config path that git or the trailer syntax would alter", () => {
75
+ const config = {
76
+ " lead.ts": "0123456789abcdef",
77
+ "trail.ts ": "0123456789abcdef",
78
+ '"q.ts': "0123456789abcdef",
79
+ }
80
+ const message = formatCommitMessage({ actor: "human", to: "x", config })
81
+ expect(message).toContain('Gtd-Config: 0123456789abcdef " lead.ts"')
82
+ expect(parseCommitMessage(message).config).toEqual(config)
83
+ })
84
+
85
+ it("ignores a Gtd-Config trailer without a 16-digit hex hash, without a path, or with a control character in its path", () => {
86
+ const read = (value: string) =>
87
+ parseCommitMessage(`gtd(human): x\n\nGtd-Config: ${value}`).config
88
+ expect(read("zzzzzzzzzzzzzzzz gtd.config.ts")).toEqual({})
89
+ expect(read("deadbeef gtd.config.ts")).toEqual({})
90
+ expect(read("deadbeefdeadbeef")).toEqual({})
91
+ expect(read("deadbeefdeadbeef a\x1b]0;x\x07.ts")).toEqual({})
92
+ expect(read('deadbeefdeadbeef "a\\u001b.ts"')).toEqual({})
93
+ })
94
+
95
+ it("writes Gtd-Format last on every message, even one with no other trailer", () => {
96
+ const message = formatCommitMessage({ actor: "agent", to: "building" })
97
+ expect(message).toBe("gtd(agent): building\n\nGtd-Format: 1")
98
+ expect(parseCommitMessage(message).format).toBe(1)
99
+ })
100
+
101
+ it("reads a message without Gtd-Format as format 1, and an explicit one as written", () => {
102
+ expect(parseCommitMessage("gtd(agent): a → b\n\nGtd-Step: a#1").format).toBe(1)
103
+ expect(parseCommitMessage("gtd(agent): a → b\n\nGtd-Format: 2").format).toBe(2)
104
+ expect(parseCommitMessage("gtd(agent): a → b\n\nGtd-Format: two").format).toBe(1)
105
+ })
106
+
107
+ it("skips a malformed trailer instead of failing", () => {
108
+ const parsed = parseCommitMessage("gtd(judge): a → b\n\nGtd-Judge: {not json\nGtd-Step: nope")
109
+ expect(parsed.judge).toEqual([])
110
+ expect(parsed.step).toBeUndefined()
111
+ })
112
+
113
+ it("parses only gtd subjects", () => {
114
+ expect(parseSubject("feat: add calculator")).toBeUndefined()
115
+ expect(parseSubject("gtd(agent): fix")).toEqual({ actor: "agent", to: "fix" })
116
+ })
117
+ })
@@ -0,0 +1,213 @@
1
+ // The commit-message codec: the `gtd(<actor>): <from> → <to>` subject plus
2
+ // the `Gtd-*` trailers a step commit carries. A malformed trailer (never
3
+ // written by gtd) is skipped, not fatal — a corrupt line must never make every
4
+ // command unusable.
5
+
6
+ import { HISTORY_FORMAT } from "./Format.js"
7
+
8
+ const TRANSITION_SEP = " → "
9
+
10
+ export interface ParsedSubject {
11
+ readonly actor: string
12
+ readonly to: string
13
+ readonly from?: string
14
+ }
15
+
16
+ const SUBJECT_RE = /^gtd\(([^()]+)\): (.+)$/
17
+
18
+ /** `from` equal to `to` (or absent) collapses to the bare `gtd(<actor>): <to>` form. */
19
+ export const formatSubject = (actor: string, to: string, from?: string): string =>
20
+ from === undefined || from === to
21
+ ? `gtd(${actor}): ${to}`
22
+ : `gtd(${actor}): ${from}${TRANSITION_SEP}${to}`
23
+
24
+ export const parseSubject = (subject: string): ParsedSubject | undefined => {
25
+ const match = SUBJECT_RE.exec(subject.trim())
26
+ const actor = match?.[1]
27
+ const rest = match?.[2]
28
+ if (actor === undefined || rest === undefined) return undefined
29
+ const sep = rest.lastIndexOf(TRANSITION_SEP)
30
+ if (sep === -1) return { actor, to: rest }
31
+ const from = rest.slice(0, sep)
32
+ const to = rest.slice(sep + TRANSITION_SEP.length)
33
+ if (from === "" || to === "") return undefined
34
+ return { actor, to, from }
35
+ }
36
+
37
+ export interface StepId {
38
+ readonly name: string
39
+ readonly occurrence: number
40
+ }
41
+
42
+ export const formatStepId = (id: StepId): string => `${id.name}#${id.occurrence}`
43
+
44
+ const STEP_ID_RE = /^(.+)#([1-9][0-9]*)$/
45
+
46
+ const parseStepId = (raw: string): StepId | undefined => {
47
+ const match = STEP_ID_RE.exec(raw.trim())
48
+ if (match === null) return undefined
49
+ return { name: match[1]!, occurrence: Number(match[2]) }
50
+ }
51
+
52
+ export interface JudgeVerdict {
53
+ readonly id: string
54
+ readonly answer: string | number | boolean
55
+ readonly p: number
56
+ }
57
+
58
+ export interface CostEntry {
59
+ readonly cost: number
60
+ readonly model: string | undefined
61
+ }
62
+
63
+ export interface CommitMessage {
64
+ readonly subject: string
65
+ readonly parsed: ParsedSubject | undefined
66
+ readonly step: StepId | undefined
67
+ /** Written only on a process's opening commit. */
68
+ readonly workflow: string | undefined
69
+ readonly judge: readonly JudgeVerdict[]
70
+ readonly vars: Readonly<Record<string, string>>
71
+ /** Written only on a process's first commit. */
72
+ readonly config: Readonly<Record<string, string>>
73
+ readonly reviewBase: string | undefined
74
+ readonly cost: readonly CostEntry[]
75
+ readonly format: number
76
+ }
77
+
78
+ const isVerdict = (value: unknown): value is JudgeVerdict => {
79
+ if (typeof value !== "object" || value === null) return false
80
+ const v = value as Record<string, unknown>
81
+ return (
82
+ typeof v.id === "string" &&
83
+ ["string", "number", "boolean"].includes(typeof v.answer) &&
84
+ typeof v.p === "number"
85
+ )
86
+ }
87
+
88
+ const parseJson = (raw: string): unknown => {
89
+ try {
90
+ return JSON.parse(raw) as unknown
91
+ } catch {
92
+ return undefined
93
+ }
94
+ }
95
+
96
+ const TRAILER_RE = /^(Gtd-[A-Za-z-]+):[ \t]*(.*?)[ \t]*$/gm
97
+ const COST_RE = /^([0-9]+(?:\.[0-9]+)?)(?:[ \t]+(.+))?$/
98
+ const VAR_RE = /^([^=\s]+)=(.*)$/
99
+ const CONFIG_RE = /^([0-9a-f]{16}) (.+)$/
100
+ // A path git's message cleanup or the trailer syntax would alter travels as a
101
+ // JSON string. A control character is refused on read: history written by
102
+ // someone else must never put escape sequences on a terminal.
103
+ // eslint-disable-next-line no-control-regex -- the control range IS the point
104
+ const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f]/
105
+ const encodePath = (path: string): string =>
106
+ path.trim() !== path || path.startsWith('"') || CONTROL_CHARACTER.test(path)
107
+ ? JSON.stringify(path)
108
+ : path
109
+ const decodePath = (raw: string): string | undefined => {
110
+ const path = raw.startsWith('"') ? parseJson(raw) : raw
111
+ return typeof path === "string" && path !== "" && !CONTROL_CHARACTER.test(path) ? path : undefined
112
+ }
113
+ const FORMAT_RE = /^[1-9][0-9]*$/
114
+
115
+ interface Trailers {
116
+ step: StepId | undefined
117
+ workflow: string | undefined
118
+ reviewBase: string | undefined
119
+ readonly judge: JudgeVerdict[]
120
+ readonly vars: Record<string, string>
121
+ readonly config: Record<string, string>
122
+ readonly cost: CostEntry[]
123
+ format: number | undefined
124
+ }
125
+
126
+ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) => void>> = {
127
+ "Gtd-Step": (value, into) => {
128
+ into.step ??= parseStepId(value)
129
+ },
130
+ "Gtd-Workflow": (value, into) => {
131
+ if (value !== "") into.workflow ??= value
132
+ },
133
+ "Gtd-Judge": (value, into) => {
134
+ const parsed = parseJson(value)
135
+ if (isVerdict(parsed)) into.judge.push(parsed)
136
+ },
137
+ "Gtd-Var": (value, into) => {
138
+ const kv = VAR_RE.exec(value)
139
+ if (kv !== null) into.vars[kv[1]!] = kv[2]!
140
+ },
141
+ "Gtd-Config": (value, into) => {
142
+ const entry = CONFIG_RE.exec(value)
143
+ const path = entry === null ? undefined : decodePath(entry[2]!)
144
+ if (path !== undefined) into.config[path] = entry![1]!
145
+ },
146
+ "Gtd-Review-Base": (value, into) => {
147
+ into.reviewBase ??= value.split(/\s/)[0]
148
+ },
149
+ "Gtd-Format": (value, into) => {
150
+ if (FORMAT_RE.test(value)) into.format ??= Number(value)
151
+ },
152
+ "Gtd-Cost": (value, into) => {
153
+ const c = COST_RE.exec(value)
154
+ if (c !== null) into.cost.push({ cost: Number(c[1]), model: c[2]?.trim() || undefined })
155
+ },
156
+ }
157
+
158
+ export const parseCommitMessage = (message: string): CommitMessage => {
159
+ const newline = message.indexOf("\n")
160
+ const subject = (newline === -1 ? message : message.slice(0, newline)).trim()
161
+ const trailers: Trailers = {
162
+ step: undefined,
163
+ workflow: undefined,
164
+ reviewBase: undefined,
165
+ judge: [],
166
+ vars: {},
167
+ config: {},
168
+ cost: [],
169
+ format: undefined,
170
+ }
171
+ if (newline !== -1) {
172
+ for (const match of message.slice(newline).matchAll(TRAILER_RE)) {
173
+ TRAILER_READERS[match[1]!]?.(match[2]!, trailers)
174
+ }
175
+ }
176
+ return { subject, parsed: parseSubject(subject), ...trailers, format: trailers.format ?? 1 }
177
+ }
178
+
179
+ export interface CommitSpec {
180
+ readonly actor: string
181
+ readonly to: string
182
+ readonly from?: string
183
+ readonly step?: StepId
184
+ readonly workflow?: string
185
+ readonly reviewBase?: string
186
+ readonly vars?: Readonly<Record<string, string>>
187
+ readonly config?: Readonly<Record<string, string>>
188
+ readonly cost?: { readonly cost: number; readonly model?: string }
189
+ readonly judge?: readonly JudgeVerdict[]
190
+ }
191
+
192
+ const sortedEntries = (record: Readonly<Record<string, string>> = {}): [string, string][] =>
193
+ Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
194
+
195
+ /** Trailer order is fixed so the same spec always yields the same bytes. */
196
+ export const formatCommitMessage = (spec: CommitSpec): string => {
197
+ const lines: string[] = []
198
+ if (spec.step !== undefined) lines.push(`Gtd-Step: ${formatStepId(spec.step)}`)
199
+ if (spec.workflow !== undefined) lines.push(`Gtd-Workflow: ${spec.workflow}`)
200
+ if (spec.reviewBase !== undefined) lines.push(`Gtd-Review-Base: ${spec.reviewBase}`)
201
+ for (const [name, value] of sortedEntries(spec.vars)) lines.push(`Gtd-Var: ${name}=${value}`)
202
+ for (const [path, hash] of sortedEntries(spec.config))
203
+ lines.push(`Gtd-Config: ${hash} ${encodePath(path)}`)
204
+ if (spec.cost !== undefined) {
205
+ lines.push(
206
+ `Gtd-Cost: ${spec.cost.cost}${spec.cost.model !== undefined ? ` ${spec.cost.model}` : ""}`,
207
+ )
208
+ }
209
+ for (const verdict of spec.judge ?? []) lines.push(`Gtd-Judge: ${JSON.stringify(verdict)}`)
210
+ lines.push(`Gtd-Format: ${HISTORY_FORMAT}`)
211
+ const subject = formatSubject(spec.actor, spec.to, spec.from)
212
+ return `${subject}\n\n${lines.join("\n")}`
213
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { diffTrees, type TreeView } from "./Tree.js"
3
+
4
+ const tree = (files: Record<string, { id?: string; content: string }>): TreeView => ({
5
+ paths: () => Object.keys(files).sort(),
6
+ read: (path) => files[path]?.content,
7
+ id: (path) => files[path]?.id,
8
+ })
9
+
10
+ describe("diffTrees", () => {
11
+ it("compares blob ids where both sides have one, whatever the bytes read", () => {
12
+ const before = tree({ "a.txt": { id: "1", content: "one\n" } })
13
+ const after = tree({ "a.txt": { id: "1", content: "one\r\n" } })
14
+ expect(diffTrees(before, after).modified).toEqual([])
15
+ })
16
+
17
+ it("falls back to contents for a path one side has no id for", () => {
18
+ const before = tree({
19
+ "a.txt": { id: "1", content: "one\n" },
20
+ "b.txt": { id: "2", content: "b" },
21
+ })
22
+ const after = tree({ "a.txt": { content: "one\n" }, "b.txt": { content: "changed" } })
23
+ expect(diffTrees(before, after).modified).toEqual(["b.txt"])
24
+ })
25
+ })
@@ -0,0 +1,45 @@
1
+ /**
2
+ * One committed (or about-to-be-committed) tree, read synchronously: flow code
3
+ * calls helpers between two awaits and cannot wait on IO. A git-backed view
4
+ * reads objects on demand; `id` lets `diffTrees` compare blob ids instead of
5
+ * contents, and a path whose id is `undefined` is compared by contents.
6
+ */
7
+ export interface TreeView {
8
+ readonly paths: () => readonly string[]
9
+ readonly read: (path: string) => string | undefined
10
+ readonly id?: (path: string) => string | undefined
11
+ }
12
+
13
+ export const treeFromRecord = (files: Readonly<Record<string, string>>): TreeView => ({
14
+ paths: () => Object.keys(files).sort(),
15
+ read: (path) => (Object.hasOwn(files, path) ? files[path] : undefined),
16
+ })
17
+
18
+ export interface TreeDiff {
19
+ readonly added: readonly string[]
20
+ readonly modified: readonly string[]
21
+ readonly deleted: readonly string[]
22
+ }
23
+
24
+ export const diffTrees = (before: TreeView, after: TreeView): TreeDiff => {
25
+ // Blob ids only compare against blob ids; otherwise fall back to contents.
26
+ const same = (path: string): boolean => {
27
+ const a = before.id?.(path)
28
+ const b = after.id?.(path)
29
+ return a !== undefined && b !== undefined ? a === b : before.read(path) === after.read(path)
30
+ }
31
+ const beforePaths = new Set(before.paths())
32
+ const afterPaths = new Set(after.paths())
33
+ const added: string[] = []
34
+ const modified: string[] = []
35
+ const deleted: string[] = []
36
+ for (const path of afterPaths) {
37
+ if (!beforePaths.has(path)) added.push(path)
38
+ else if (!same(path)) modified.push(path)
39
+ }
40
+ for (const path of beforePaths) if (!afterPaths.has(path)) deleted.push(path)
41
+ return { added: added.sort(), modified: modified.sort(), deleted: deleted.sort() }
42
+ }
43
+
44
+ export const isEmptyDiff = (diff: TreeDiff): boolean =>
45
+ diff.added.length === 0 && diff.modified.length === 0 && diff.deleted.length === 0
@@ -0,0 +1,11 @@
1
+ export { replay, type EpisodeCommit, type ReachedStep, type ReplayOutcome } from "./Replay.js"
2
+ export { treeFromRecord, type TreeView } from "./Tree.js"
3
+ export {
4
+ formatCommitMessage,
5
+ formatSubject,
6
+ parseCommitMessage,
7
+ type CommitSpec,
8
+ type JudgeVerdict,
9
+ } from "./Trailers.js"
10
+ export { accessShapeFault, foldAccess } from "./Access.js"
11
+ export { formatFault } from "./Format.js"
@@ -0,0 +1,6 @@
1
+ // Test-support for `Blocks.test.ts`: `blockNodesOf` takes an already-parsed
2
+ // tree, but a test only has raw markdown source — re-exporting the shared
3
+ // parser here (rather than `Blocks.test.ts` reaching into `MarkdownTree.ts`
4
+ // directly) keeps the boundary rule "a test reaches a neighbour only through
5
+ // its own fixture or the boundary's index.ts" satisfied.
6
+ export { parseMarkdown } from "./MarkdownTree.js"
@@ -0,0 +1,167 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { blockNodesOf, blockRunInline } from "./Blocks.js"
3
+ import { parseMarkdown } from "./Blocks.fixture.js"
4
+
5
+ describe("blockNodesOf — T1 blast-radius: blockTitle is byte-identical for a list item containing ~~strike~~ and a bare URL", () => {
6
+ it("a list item's own title keeps the literal markers, unaffected by wiring the two new GFM extensions", () => {
7
+ const content = ["- A ~~gone~~ item https://example.com/x", ""].join("\n")
8
+ const tree = parseMarkdown(content)
9
+ const nodes = blockNodesOf(content, tree)
10
+ expect(nodes[0]?.title).toBe("A ~~gone~~ item https://example.com/x")
11
+ expect(nodes[0]?.block?.items?.[0]?.text).toBe("A ~~gone~~ item https://example.com/x")
12
+ })
13
+ })
14
+
15
+ describe("blockRunInline — spec-feedback regression: a nested list item's own text is not dropped", () => {
16
+ it("a nested item's own run joins the flattened detailInline path, unlike a hand-picked top-level-only walk", () => {
17
+ const content = ["- outer", " - inner deep text", ""].join("\n")
18
+ const tree = parseMarkdown(content)
19
+ const inline = blockRunInline(content, tree.children)
20
+ const joined = inline.map((n) => ("value" in n ? n.value : "")).join("")
21
+ expect(joined).toBe("outer inner deep text")
22
+ })
23
+
24
+ it("still recurses at three levels deep, in document order", () => {
25
+ const content = ["- a", " - b", " - c", ""].join("\n")
26
+ const tree = parseMarkdown(content)
27
+ const inline = blockRunInline(content, tree.children)
28
+ const joined = inline.map((n) => ("value" in n ? n.value : "")).join("")
29
+ expect(joined).toBe("a b c")
30
+ })
31
+
32
+ it("a nested item's own text still stays OUT of its parent's structural BlockListItem.inline — only the flattened detailInline path recurses", () => {
33
+ const content = ["- outer", " - inner", ""].join("\n")
34
+ const tree = parseMarkdown(content)
35
+ const nodes = blockNodesOf(content, tree)
36
+ const topItem = nodes[0]?.block?.items?.[0]
37
+ expect(topItem?.inline).toEqual([{ kind: "text", value: "outer" }])
38
+ expect(topItem?.items).toEqual([{ text: "inner", inline: [{ kind: "text", value: "inner" }] }])
39
+ })
40
+ })
41
+
42
+ describe("blockNodesOf — default walk, no skip predicates", () => {
43
+ it("yields every top-level block, in document order, when called with no options", () => {
44
+ const content = [
45
+ "# Heading",
46
+ "",
47
+ "- Item one",
48
+ " - Nested item",
49
+ "",
50
+ "```",
51
+ "line one",
52
+ "line two",
53
+ "```",
54
+ "",
55
+ "> A blockquote.",
56
+ "",
57
+ "Paragraph one.",
58
+ "",
59
+ "Paragraph two.",
60
+ "",
61
+ ].join("\n")
62
+ const tree = parseMarkdown(content)
63
+ const nodes = blockNodesOf(content, tree)
64
+ expect(nodes.map((n) => n.block?.kind)).toEqual([
65
+ "heading",
66
+ "list",
67
+ "code",
68
+ "blockquote",
69
+ "paragraph",
70
+ "paragraph",
71
+ ])
72
+ })
73
+
74
+ it("excludes a footnoteDefinition node unconditionally", () => {
75
+ const content = ["Paragraph with a marker[^fn1].", "", "[^fn1]: The note body.", ""].join("\n")
76
+ const tree = parseMarkdown(content)
77
+ const nodes = blockNodesOf(content, tree)
78
+ expect(nodes.map((n) => n.block?.kind)).toEqual(["paragraph"])
79
+ })
80
+
81
+ it("carries a {kind:'paragraph', line} anchor for every node, at its own start line", () => {
82
+ const content = ["# Heading", "", "Paragraph.", ""].join("\n")
83
+ const tree = parseMarkdown(content)
84
+ const nodes = blockNodesOf(content, tree)
85
+ expect(nodes.map((n) => n.anchor)).toEqual([
86
+ { kind: "paragraph", line: 0 },
87
+ { kind: "paragraph", line: 2 },
88
+ ])
89
+ })
90
+
91
+ it("surfaces a footnote marker anchored at a block's own start line as that block's own note", () => {
92
+ const content = ["# Heading[^fn1]", "", "[^fn1]: A note on the heading.", ""].join("\n")
93
+ const tree = parseMarkdown(content)
94
+ const nodes = blockNodesOf(content, tree)
95
+ expect(nodes[0]?.note).toBe("A note on the heading.")
96
+ })
97
+
98
+ it("strips the '> ' continuation marker from a soft-broken blockquote's title and block.text", () => {
99
+ const content = ["> quoted line", "> more", ""].join("\n")
100
+ const tree = parseMarkdown(content)
101
+ const nodes = blockNodesOf(content, tree)
102
+ expect(nodes[0]?.title).not.toContain(">")
103
+ expect(nodes[0]?.block).toMatchObject({ kind: "blockquote", text: "quoted line more" })
104
+ })
105
+
106
+ it("keeps a two-paragraph blockquote's title unchanged (blank '>' line between paragraphs)", () => {
107
+ const content = ["> First line.", ">", "> Second para.", ""].join("\n")
108
+ const tree = parseMarkdown(content)
109
+ const nodes = blockNodesOf(content, tree)
110
+ expect(nodes[0]?.title).toBe("First line. Second para.")
111
+ })
112
+
113
+ it("keeps a literal '>' typed mid-prose inside a blockquote", () => {
114
+ const content = ["> a > b", ""].join("\n")
115
+ const tree = parseMarkdown(content)
116
+ const nodes = blockNodesOf(content, tree)
117
+ expect(nodes[0]?.title).toBe("a > b")
118
+ })
119
+
120
+ it("keeps an inline link's [label](url) syntax intact across a multi-line blockquote", () => {
121
+ const content = ["> see [label](url)", "> more text", ""].join("\n")
122
+ const tree = parseMarkdown(content)
123
+ const nodes = blockNodesOf(content, tree)
124
+ expect(nodes[0]?.title).toBe("see [label](url) more text")
125
+ })
126
+
127
+ it("keeps a line-leading '>' inside a list item's own fenced code block — the blockquote strip is scoped to blockquotes only", () => {
128
+ const content = ["- item", "", " ```", " a", " > b", " ```", ""].join("\n")
129
+ const tree = parseMarkdown(content)
130
+ const nodes = blockNodesOf(content, tree)
131
+ expect(nodes[0]?.block).toMatchObject({ kind: "list", items: [{ text: "item ``` a > b ```" }] })
132
+ })
133
+ })
134
+
135
+ describe("blockNodesOf — caller-supplied skip predicates", () => {
136
+ it("skipNode excludes a top-level node the caller identifies by content", () => {
137
+ const content = ["# Skip me", "", "Paragraph.", ""].join("\n")
138
+ const tree = parseMarkdown(content)
139
+ const nodes = blockNodesOf(content, tree, {
140
+ skipNode: (_content, node) => node.type === "heading",
141
+ })
142
+ expect(nodes.map((n) => n.block?.kind)).toEqual(["paragraph"])
143
+ })
144
+
145
+ it("skipLine excludes a top-level node by its own start line", () => {
146
+ const content = ["# Heading", "", "Paragraph.", ""].join("\n")
147
+ const tree = parseMarkdown(content)
148
+ const nodes = blockNodesOf(content, tree, { skipLine: (line) => line === 0 })
149
+ expect(nodes.map((n) => n.block?.kind)).toEqual(["paragraph"])
150
+ })
151
+ })
152
+
153
+ describe("blockNodesOf — block.text", () => {
154
+ it("omits block.text entirely for heading/list/paragraph", () => {
155
+ const content = ["# A heading", "", "Plain paragraph.", ""].join("\n")
156
+ const tree = parseMarkdown(content)
157
+ const nodes = blockNodesOf(content, tree)
158
+ expect(nodes.map((n) => n.block?.text)).toEqual([undefined, undefined])
159
+ })
160
+
161
+ it("omits block entirely for a thematicBreak — unchanged from qa/review's own behaviour", () => {
162
+ const content = ["Above.", "", "---", "", "Below.", ""].join("\n")
163
+ const tree = parseMarkdown(content)
164
+ const nodes = blockNodesOf(content, tree)
165
+ expect(nodes.map((n) => n.block?.kind)).toEqual(["paragraph", undefined, "paragraph"])
166
+ })
167
+ })