@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,163 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ applySteeringEdits,
4
+ BUILT_IN_MODE_NAMES,
5
+ parseFootnotes,
6
+ parseThreads,
7
+ steeringFormatFor,
8
+ } from "./index.js"
9
+
10
+ const QA_FORMAT = steeringFormatFor("qa")!
11
+ const REVIEW_FORMAT = steeringFormatFor("review")!
12
+
13
+ describe("BUILT_IN_MODE_NAMES", () => {
14
+ it("lists the two built-in format names", () => {
15
+ expect(BUILT_IN_MODE_NAMES).toEqual(["qa", "review"])
16
+ })
17
+ })
18
+
19
+ describe("steeringFormatFor", () => {
20
+ it("resolves 'qa' and 'review' to their singleton formats", () => {
21
+ expect(steeringFormatFor("qa")).toBe(QA_FORMAT)
22
+ expect(steeringFormatFor("review")).toBe(REVIEW_FORMAT)
23
+ })
24
+
25
+ it("resolves an unknown name to undefined", () => {
26
+ expect(steeringFormatFor("prose")).toBeUndefined()
27
+ expect(steeringFormatFor("adr")).toBeUndefined()
28
+ })
29
+ })
30
+
31
+ describe("every registry entry's sample", () => {
32
+ // Load-bearing, not a nicety (see the package's own doc comment on this
33
+ // acceptance criterion): `src/emit/ModeContradiction.ts` round-trips this exact
34
+ // sample through a mode's declared `format:` command, then re-validates it
35
+ // — a sample that doesn't validate clean to begin with would hard-stop
36
+ // every prompt beat that declares `file:`+`mode:`, at 3 wasted agent turns
37
+ // each, with no way to tell a real contradiction from a drifted fixture.
38
+ it("validates clean under its own format's parser", () => {
39
+ for (const mode of BUILT_IN_MODE_NAMES) {
40
+ const format = steeringFormatFor(mode)!
41
+ expect(format.validate(format.sample)).toEqual([])
42
+ }
43
+ })
44
+ })
45
+
46
+ describe("every registry entry declares apply", () => {
47
+ it("has a function-typed apply member, not just annotate", () => {
48
+ for (const mode of BUILT_IN_MODE_NAMES) {
49
+ const format = steeringFormatFor(mode)!
50
+ expect(typeof format.apply).toBe("function")
51
+ }
52
+ })
53
+ })
54
+
55
+ /** Every `SteeringAnchor` embedded anywhere in `view` — walks whatever shape `view` returns without switching on `kind`, mirroring the server's own "never switch on the mode name" discipline. */
56
+ const anchorsIn = (value: unknown): unknown[] => {
57
+ if (Array.isArray(value)) return value.flatMap(anchorsIn)
58
+ if (value === null || typeof value !== "object") return []
59
+ const record = value as Record<string, unknown>
60
+ const found: unknown[] = []
61
+ for (const [key, val] of Object.entries(record)) {
62
+ if (key === "anchor") found.push(val)
63
+ else found.push(...anchorsIn(val))
64
+ }
65
+ return found
66
+ }
67
+
68
+ describe("every registry entry's view", () => {
69
+ it("parses `format.sample` without throwing", () => {
70
+ for (const mode of BUILT_IN_MODE_NAMES) {
71
+ const format = steeringFormatFor(mode)!
72
+ expect(() => format.view(format.sample)).not.toThrow()
73
+ }
74
+ })
75
+
76
+ // `format.sample` itself, derived from the registry entry alone — never a
77
+ // per-mode fixture table (a third registry entry with no matching key
78
+ // there would fail on a confusing `undefined`, not on the property under
79
+ // test). `format.sample` already carries a note attached at ONE of its own
80
+ // anchors (T7 requires a server-written note in the sample) — annotating
81
+ // that SAME anchor again now EDITS the existing note in place
82
+ // (`Footnotes.ts#footnoteAttachEdits`'s same-anchor update path, T6: "offers
83
+ // editing it, not a second note"), which this treats as a PASS (`ok: true`)
84
+ // same as any other anchor; a genuine `id-collision` (an unrelated anchor's
85
+ // derived id colliding with existing content) remains an acceptable refusal
86
+ // too. Only `anchor-not-found` — the anchor itself failing to resolve — is
87
+ // the failure this property actually guards against.
88
+ it("every anchor `view` reports is one `annotate` accepts (or correctly refuses only as an id-collision, never as anchor-not-found)", () => {
89
+ for (const mode of BUILT_IN_MODE_NAMES) {
90
+ const format = steeringFormatFor(mode)!
91
+ const content = format.sample
92
+ const view = format.view(content)
93
+ const anchors = anchorsIn(view)
94
+ expect(anchors.length).toBeGreaterThan(0)
95
+ for (const anchor of anchors) {
96
+ const result = format.annotate(content, anchor as never, "a note a human typed")
97
+ const acceptable = result.ok || (!result.ok && result.reason === "id-collision")
98
+ expect(
99
+ acceptable,
100
+ `${mode}: ${JSON.stringify(anchor)} was refused: ${JSON.stringify(result)}`,
101
+ ).toBe(true)
102
+ }
103
+ }
104
+ })
105
+ })
106
+
107
+ describe("'gtd: add a footnote' refuses where a marker would corrupt a footnote", () => {
108
+ it("qa: refuses the action with the cursor inside an existing marker's [^name] span, rather than nesting a new marker into it", () => {
109
+ const cursor = { line: 6, character: 19 } // "- [ ] Option A[^fn1]" — between the name and its "]"
110
+ const action = QA_FORMAT.actions(QA_FORMAT.sample, { start: cursor, end: cursor }).find(
111
+ (a) => a.title === "gtd: add a footnote",
112
+ )
113
+ expect(action).toBeUndefined()
114
+ })
115
+
116
+ it("qa: refuses the action with the cursor on an existing definition's own label line, rather than splitting it", () => {
117
+ const cursor = { line: 23, character: 6 } // "[^fn1]:" — the definition's own label line
118
+ const action = QA_FORMAT.actions(QA_FORMAT.sample, { start: cursor, end: cursor }).find(
119
+ (a) => a.title === "gtd: add a footnote",
120
+ )
121
+ expect(action).toBeUndefined()
122
+ })
123
+ })
124
+
125
+ describe("each built-in format's canonical sample (T7: writing into a live worktree)", () => {
126
+ it("contains a note attached the way the server attaches one — a distinct `na`-prefixed id, from `Footnotes.ts#footnoteAttachEdits`", () => {
127
+ for (const mode of BUILT_IN_MODE_NAMES) {
128
+ const format = steeringFormatFor(mode)!
129
+ const { definitions } = parseFootnotes(format.sample)
130
+ expect(definitions.some((d) => /^na[0-9a-z]+$/.test(d.name))).toBe(true)
131
+ }
132
+ })
133
+ })
134
+
135
+ describe("thread-aware attach validates clean under every built-in format", () => {
136
+ const FIRST = "first"
137
+ for (const mode of BUILT_IN_MODE_NAMES) {
138
+ it(`${mode}: new thread, reply to an open thread, edit a waiting thread`, () => {
139
+ const format = steeringFormatFor(mode)!
140
+ const anchors = anchorsIn(format.view(format.sample))
141
+ const anchor = anchors.find((a) => {
142
+ const r = format.annotate(format.sample, a as never, FIRST)
143
+ return r.ok && parseThreads(applySteeringEdits(format.sample, r.edits)).length > 0
144
+ })!
145
+ const attach = (content: string, text: string): string => {
146
+ const r = format.annotate(content, anchor as never, text)
147
+ if (!r.ok) throw new Error("annotate refused")
148
+ return applySteeringEdits(content, r.edits)
149
+ }
150
+ const created = attach(format.sample, FIRST)
151
+ expect(format.validate(created)).toEqual([])
152
+ const edited = attach(created, "second")
153
+ expect(format.validate(edited)).toEqual([])
154
+ const thread = parseThreads(edited).find((t) => t.entries[0]?.text === "second")!
155
+ expect(thread.entries).toHaveLength(1)
156
+ const open = edited.replace(new RegExp(`(\\n - H: second)`), "$1\n - A: why?")
157
+ const replied = attach(open, "because")
158
+ expect(format.validate(replied)).toEqual([])
159
+ const after = parseThreads(replied).find((t) => t.name === thread.name)!
160
+ expect(after.entries.map((e) => e.text)).toEqual(["second", "why?", "because"])
161
+ })
162
+ }
163
+ })
@@ -0,0 +1,163 @@
1
+ import type { List, ListItem, Root } from "mdast"
2
+ import { sourceText, spanRange, toLspPosition } from "./MarkdownTree.js"
3
+ import type { SteeringFinding } from "./SteeringFormat.js"
4
+
5
+ export interface ThreadMarker {
6
+ readonly name: string
7
+ readonly line: number
8
+ readonly character: number
9
+ readonly endCharacter: number
10
+ }
11
+
12
+ export interface ThreadEntry {
13
+ readonly author: "me" | "agent"
14
+ readonly text: string
15
+ readonly line: number
16
+ /** The entry's own last line — its last block's, never the list item's span (see `itemText`). */
17
+ readonly endLine: number
18
+ /** Column of the entry's `-` bullet. */
19
+ readonly column: number
20
+ }
21
+
22
+ /** `waitingOn: "human"` means the last entry is the agent's — the thread is OPEN. */
23
+ export interface Thread {
24
+ readonly name: string
25
+ readonly line: number
26
+ readonly endLine: number
27
+ readonly markers: readonly ThreadMarker[]
28
+ readonly entries: readonly ThreadEntry[]
29
+ readonly waitingOn: "human" | "agent"
30
+ }
31
+
32
+ const PREFIX_RE = /^(H|A):\s*/
33
+
34
+ interface Def {
35
+ readonly node: {
36
+ readonly label?: string | undefined
37
+ readonly identifier?: string | undefined
38
+ readonly children: readonly unknown[]
39
+ readonly position?: unknown
40
+ }
41
+ }
42
+
43
+ const lineOf = (node: { readonly position?: unknown }, which: "start" | "end"): number =>
44
+ toLspPosition((node.position as Record<"start" | "end", { line: number; column: number }>)[which])
45
+ .line
46
+
47
+ /** The item's own blocks joined — never the item node's span, which can run past its last line into the next bullet's marker. */
48
+ const itemText = (content: string, item: ListItem): string =>
49
+ item.children
50
+ .map((child) => sourceText(content, child))
51
+ .join(" ")
52
+ .trim()
53
+
54
+ const prefixOf = (content: string, item: ListItem): RegExpExecArray | null =>
55
+ PREFIX_RE.exec(itemText(content, item))
56
+
57
+ /** A thread is a definition whose body OPENS with a list whose first item carries a prefix — anything else stays a one-shot footnote. */
58
+ const isThreadBody = (content: string, children: readonly unknown[]): List | undefined => {
59
+ const first = children[0] as { type: string } | undefined
60
+ if (first?.type !== "list") return undefined
61
+ const list = first as List
62
+ const item = list.children[0]
63
+ return item && prefixOf(content, item) ? list : undefined
64
+ }
65
+
66
+ const foldName = (name: string): string => name.toLowerCase()
67
+
68
+ type Report = (message: string, from: number, to: number) => void
69
+
70
+ /** One list item's entry, reporting any syntax problem; `previous` is the author before it. */
71
+ const entryOf = (
72
+ content: string,
73
+ item: ListItem,
74
+ previous: ThreadEntry["author"] | undefined,
75
+ report: Report,
76
+ ): ThreadEntry | undefined => {
77
+ const start = lineOf(item, "start")
78
+ const end = lineOf(item, "end")
79
+ const match = prefixOf(content, item)
80
+ if (!match) {
81
+ report(`an entry must start with "H:" or "A:"`, start, end)
82
+ return undefined
83
+ }
84
+ const author = match[1] === "H" ? "me" : "agent"
85
+ const text = itemText(content, item).slice(match[0].length).trim()
86
+ if (text === "") report(`empty "${match[1]}:" entry — write your own words there`, start, end)
87
+ if (previous === undefined && author === "agent") {
88
+ report(`the agent never starts a thread — the first entry must be "H:"`, start, end)
89
+ } else if (previous === author) {
90
+ report(`two consecutive "${match[1]}:" entries — entries must alternate`, start, end)
91
+ }
92
+ const last = item.children[item.children.length - 1]
93
+ return {
94
+ author,
95
+ text,
96
+ line: start,
97
+ endLine: last ? lineOf(last, "end") : end,
98
+ column: toLspPosition(item.position!.start).character,
99
+ }
100
+ }
101
+
102
+ const threadOf = (
103
+ content: string,
104
+ def: Def["node"],
105
+ list: List,
106
+ markers: readonly ThreadMarker[],
107
+ report: Report,
108
+ ): Thread => {
109
+ const name = def.label ?? def.identifier ?? ""
110
+ const entries: ThreadEntry[] = []
111
+ let previous: ThreadEntry["author"] | undefined
112
+ for (const item of list.children) {
113
+ const entry = entryOf(content, item, previous, report)
114
+ if (!entry) continue
115
+ previous = entry.author
116
+ entries.push(entry)
117
+ }
118
+ for (const extra of def.children.slice(1) as { position?: unknown }[]) {
119
+ report(
120
+ `only one list of "H:"/"A:" entries may make up the body`,
121
+ lineOf(extra, "start"),
122
+ lineOf(extra, "end"),
123
+ )
124
+ }
125
+ return {
126
+ name,
127
+ line: lineOf(def, "start"),
128
+ endLine: lineOf(def, "end"),
129
+ markers: markers.filter((m) => foldName(m.name) === foldName(name)),
130
+ entries,
131
+ waitingOn: entries[entries.length - 1]?.author === "agent" ? "human" : "agent",
132
+ }
133
+ }
134
+
135
+ /** Threads and their syntax findings off ONE tree walk; `markers` come from the caller so this module never re-parses footnotes. */
136
+ export const collectThreads = (
137
+ content: string,
138
+ tree: Root,
139
+ markers: readonly ThreadMarker[],
140
+ ): { readonly threads: readonly Thread[]; readonly findings: readonly SteeringFinding[] } => {
141
+ const lines = content.split(/\r?\n/)
142
+ const threads: Thread[] = []
143
+ const findings: SteeringFinding[] = []
144
+ const walk = (node: { type: string; children?: unknown }): void => {
145
+ const def = node.type === "footnoteDefinition" ? (node as unknown as Def["node"]) : undefined
146
+ const list = def && isThreadBody(content, def.children)
147
+ if (def && list) {
148
+ const name = def.label ?? def.identifier ?? ""
149
+ const report: Report = (message, from, to) => {
150
+ findings.push({
151
+ message: `Footnote thread "[^${name}]": ${message}`,
152
+ line: from,
153
+ range: spanRange(lines, from, to),
154
+ })
155
+ }
156
+ threads.push(threadOf(content, def, list, markers, report))
157
+ }
158
+ const children = node.children as readonly (typeof node)[] | undefined
159
+ if (children) children.forEach(walk)
160
+ }
161
+ walk(tree)
162
+ return { threads, findings }
163
+ }
@@ -0,0 +1,264 @@
1
+ /// <reference types="vite/client" />
2
+ import { describe, expect, it } from "vitest"
3
+ import {
4
+ applySteeringEdits,
5
+ freeFormFormat,
6
+ parseCodeThreads,
7
+ parseFootnotes,
8
+ parseThreads,
9
+ reviewNotes,
10
+ reviewRisks,
11
+ steeringFormatFor,
12
+ stripCodeThreads,
13
+ type SteeringAnchor,
14
+ type SteeringFormat,
15
+ type SteeringViewNode,
16
+ } from "./index.js"
17
+
18
+ // The format contract (see FORMATS.md). Fixtures are files steering already
19
+ // reads; a change that makes one of these fail stops older committed files
20
+ // from being read the same way. Loaded by the bundler, so steering stays
21
+ // free of filesystem access even in its tests.
22
+ const FIXTURES = import.meta.glob<string>("./fixtures/**/*", {
23
+ query: "?raw",
24
+ import: "default",
25
+ eager: true,
26
+ })
27
+
28
+ const fixture = (path: string): string => {
29
+ const content = FIXTURES[`./fixtures/${path}`]
30
+ if (content === undefined) throw new Error(`no fixture ${path}`)
31
+ return content
32
+ }
33
+
34
+ const FORMATS: readonly (readonly [dir: string, format: SteeringFormat, ticks: boolean])[] = [
35
+ ["qa", steeringFormatFor("qa")!, true],
36
+ ["review", steeringFormatFor("review")!, true],
37
+ ["free-form", freeFormFormat, false],
38
+ ]
39
+
40
+ const nodesOf = (nodes: readonly SteeringViewNode[]): readonly SteeringViewNode[] =>
41
+ nodes.flatMap((n) => [n, ...nodesOf(n.children ?? []), ...nodesOf(n.body ?? [])])
42
+
43
+ // Presentation-only fields: how a node is rendered, not what the file says.
44
+ const PRESENTATION = new Set(["block", "inline", "detailInline"])
45
+
46
+ /** What `view` reads out of a file, minus presentation — pinned by `<fixture>.read.json`. */
47
+ const readOf = (format: SteeringFormat, content: string): unknown =>
48
+ JSON.parse(
49
+ JSON.stringify(format.view(content), (key, value: unknown) =>
50
+ PRESENTATION.has(key) ? undefined : value,
51
+ ),
52
+ )
53
+
54
+ /** Every note the document carries: thread entries and one-shot footnote bodies. */
55
+ const notesIn = (content: string): readonly string[] => [
56
+ ...parseThreads(content).flatMap((t) => t.entries.map((e) => e.text)),
57
+ ...parseFootnotes(content).definitions.map((d) => d.body),
58
+ ]
59
+
60
+ const write = (
61
+ content: string,
62
+ result: ReturnType<SteeringFormat["apply"]>,
63
+ what: string,
64
+ ): string => {
65
+ if (!result.ok) throw new Error(`${what} refused: ${result.reason}`)
66
+ return applySteeringEdits(content, result.edits)
67
+ }
68
+
69
+ describe.each(FORMATS)("the %s format", (dir, format, ticks) => {
70
+ const files = Object.keys(FIXTURES)
71
+ .map((key) => key.slice("./fixtures/".length))
72
+ .filter((path) => path.startsWith(`${dir}/`) && path.endsWith(".md"))
73
+
74
+ it("has fixtures", () => {
75
+ expect(files.length).toBeGreaterThan(0)
76
+ })
77
+
78
+ it(ticks ? "has a fixture with a ticked box" : "has no ticks to read", () => {
79
+ const ticked = files.some((path) =>
80
+ nodesOf(format.view(fixture(path)).nodes).some((n) => n.checked),
81
+ )
82
+ expect(ticked).toBe(ticks)
83
+ })
84
+
85
+ describe.each(files)("%s", (path) => {
86
+ const content = fixture(path)
87
+
88
+ it("reads clean", () => {
89
+ expect(format.validate(content)).toEqual([])
90
+ })
91
+
92
+ it("parses to its pinned read", () => {
93
+ expect(readOf(format, content)).toEqual(
94
+ JSON.parse(fixture(path.replace(/\.md$/, ".read.json"))),
95
+ )
96
+ })
97
+
98
+ if (ticks)
99
+ it("round-trips every tick: writing back what was read leaves the bytes unchanged", () => {
100
+ for (const node of nodesOf(format.view(content).nodes)) {
101
+ if (node.checked === undefined || node.anchor.kind === "chunk") continue
102
+ const anchor: SteeringAnchor = node.anchor
103
+ const same = write(
104
+ content,
105
+ format.apply(content, anchor, { checked: node.checked }),
106
+ "same",
107
+ )
108
+ expect(same).toBe(content)
109
+ if (!node.checked) continue
110
+ const off = write(content, format.apply(content, anchor, { checked: false }), "off")
111
+ expect(off).not.toBe(content)
112
+ expect(write(off, format.apply(off, anchor, { checked: true }), "on")).toBe(content)
113
+ }
114
+ })
115
+
116
+ it("round-trips a note at every anchor: it reads back, and the document stays clean", () => {
117
+ const text = "A note written through the contract test."
118
+ const anchors = nodesOf(format.view(content).nodes).map((n) => n.anchor)
119
+ expect(anchors.length).toBeGreaterThan(0)
120
+ for (const anchor of anchors) {
121
+ const noted = write(content, format.annotate(content, anchor, text), JSON.stringify(anchor))
122
+ expect(notesIn(noted), JSON.stringify(anchor)).toContain(text)
123
+ expect(format.validate(noted), JSON.stringify(anchor)).toEqual([])
124
+ }
125
+ })
126
+ })
127
+ })
128
+
129
+ describe("footnote threads, shared by every markdown format", () => {
130
+ it.each([
131
+ [
132
+ "review/ticked.md",
133
+ [
134
+ [
135
+ "th1",
136
+ ["Does this also cover the writer?", "No, the writer keeps the input's line endings."],
137
+ "human",
138
+ ],
139
+ ],
140
+ ],
141
+ [
142
+ "qa/answered.md",
143
+ [
144
+ [
145
+ "th1",
146
+ ["Is this the git common dir?", "Yes, the directory every worktree shares."],
147
+ "human",
148
+ ],
149
+ ],
150
+ ],
151
+ ["free-form/notes.md", [["th1", ["Should this become a plan?"], "agent"]]],
152
+ ])("%s parses its threads", (path, expected) => {
153
+ expect(
154
+ parseThreads(fixture(path)).map((t) => [t.name, t.entries.map((e) => e.text), t.waitingOn]),
155
+ ).toEqual(expected)
156
+ })
157
+ })
158
+
159
+ describe("review notes, the review → fix hand-off", () => {
160
+ const baseline = fixture("review-notes/baseline.md")
161
+ const noted = fixture("review-notes/noted.md")
162
+ const review = steeringFormatFor("review")!
163
+
164
+ it("both sides read clean as review documents", () => {
165
+ expect(review.validate(baseline)).toEqual([])
166
+ expect(review.validate(noted)).toEqual([])
167
+ })
168
+
169
+ it("reads chunk prose, pointer and footnote notes in document order, never a thread", () => {
170
+ expect(reviewNotes(baseline, noted)).toEqual([
171
+ {
172
+ id: "note-1",
173
+ kind: "chunk",
174
+ anchor: "Parser accepts CRLF",
175
+ before: "**Line endings are normalized before the split.**",
176
+ text: "**Line endings are normalized before the split.** Also normalize in the writer.",
177
+ },
178
+ {
179
+ id: "note-2",
180
+ kind: "pointer",
181
+ anchor: "Parser accepts CRLF ./src/parse.ts#30-31",
182
+ before: "Risk: a lone `\\r` is still kept as content",
183
+ text: "Risk: a lone `\\r` is still kept as content. Treat it as a line break too.",
184
+ },
185
+ {
186
+ id: "note-3",
187
+ kind: "footnote",
188
+ anchor: "- [x] ./src/parse.test.ts#1-40 — one case per line ending[^fn1]",
189
+ before: "",
190
+ text: "Add a mixed-endings case.",
191
+ },
192
+ ])
193
+ })
194
+
195
+ it("reads the reviewer's Risk: pointer notes", () => {
196
+ expect(reviewRisks(baseline)).toEqual([
197
+ {
198
+ id: "risk-1",
199
+ anchor: "Parser accepts CRLF ./src/parse.ts#30-31",
200
+ text: "Risk: a lone `\\r` is still kept as content",
201
+ },
202
+ ])
203
+ })
204
+
205
+ it("an unchanged document carries no notes", () => {
206
+ expect(reviewNotes(noted, noted)).toEqual([])
207
+ })
208
+
209
+ it("round-trips: a note written at a hunk reads back as a thread, never as a review note", () => {
210
+ const text = "Rename this."
211
+ const after = write(
212
+ baseline,
213
+ review.annotate(baseline, { kind: "hunk", chunkIndex: 1, index: 0 }, text),
214
+ "annotate",
215
+ )
216
+ expect(reviewNotes(baseline, after)).toEqual([])
217
+ expect(parseThreads(after).map((t) => [t.entries.map((e) => e.text), t.waitingOn])).toEqual([
218
+ [[text], "agent"],
219
+ ])
220
+ })
221
+ })
222
+
223
+ describe("code threads", () => {
224
+ const source = /```ts\n([\s\S]*?)```/.exec(fixture("code-threads/cache.ts.md"))![1]!
225
+ const stripped = fixture("code-threads/cache.stripped.ts.txt")
226
+
227
+ it("parses each H:/A: comment run as a thread", () => {
228
+ expect(parseCodeThreads("cache.ts", source)).toEqual({
229
+ threads: [
230
+ {
231
+ path: "cache.ts",
232
+ line: 2,
233
+ endLine: 4,
234
+ entries: [
235
+ {
236
+ author: "me",
237
+ text: "Why not the modification time? A checkout could keep it stable.",
238
+ line: 2,
239
+ },
240
+ { author: "agent", text: "A checkout rewrites every mtime.", line: 4 },
241
+ ],
242
+ waitingOn: "human",
243
+ },
244
+ {
245
+ path: "cache.ts",
246
+ line: 8,
247
+ endLine: 8,
248
+ entries: [{ author: "me", text: "Should this honour $XDG_CACHE_HOME?", line: 8 }],
249
+ waitingOn: "agent",
250
+ },
251
+ ],
252
+ findings: [],
253
+ })
254
+ })
255
+
256
+ it("strips threads and keeps ordinary comments", () => {
257
+ expect(stripCodeThreads("cache.ts", source)).toBe(stripped)
258
+ })
259
+
260
+ it("round-trips: a thread-free file strips to itself", () => {
261
+ expect(stripCodeThreads("cache.ts", stripped)).toBe(stripped)
262
+ expect(parseCodeThreads("cache.ts", stripped).threads).toEqual([])
263
+ })
264
+ })
@@ -0,0 +1,6 @@
1
+ // The cache key is the content hash.
2
+ export const cacheKey = (content: string): string =>
3
+ hash(content)
4
+
5
+ export const cacheDir = (root: string): string =>
6
+ join(root, ".cache")
@@ -0,0 +1,15 @@
1
+ A TypeScript source with two code threads. It lives in markdown because gtd
2
+ scans every other file in a change for live threads.
3
+
4
+ ```ts
5
+ // The cache key is the content hash.
6
+ export const cacheKey = (content: string): string =>
7
+ // H: Why not the modification time? A checkout
8
+ // could keep it stable.
9
+ // A: A checkout rewrites every mtime.
10
+ hash(content)
11
+
12
+ export const cacheDir = (root: string): string =>
13
+ // H: Should this honour $XDG_CACHE_HOME?
14
+ join(root, ".cache")
15
+ ```
@@ -0,0 +1,12 @@
1
+ # Notes
2
+
3
+ Plain prose with no structure to validate.[^th1]
4
+
5
+ - a list item
6
+ - another one
7
+
8
+ ```sh
9
+ npm test
10
+ ```
11
+
12
+ [^th1]: - H: Should this become a plan?
@@ -0,0 +1,42 @@
1
+ {
2
+ "nodes": [
3
+ {
4
+ "title": "Notes",
5
+ "anchor": {
6
+ "kind": "paragraph",
7
+ "line": 0
8
+ }
9
+ },
10
+ {
11
+ "title": "Plain prose with no structure to validate.",
12
+ "anchor": {
13
+ "kind": "paragraph",
14
+ "line": 2
15
+ },
16
+ "thread": {
17
+ "name": "th1",
18
+ "entries": [
19
+ {
20
+ "author": "me",
21
+ "text": "Should this become a plan?"
22
+ }
23
+ ],
24
+ "waitingOn": "agent"
25
+ }
26
+ },
27
+ {
28
+ "title": "a list item another one",
29
+ "anchor": {
30
+ "kind": "paragraph",
31
+ "line": 4
32
+ }
33
+ },
34
+ {
35
+ "title": "npm test",
36
+ "anchor": {
37
+ "kind": "paragraph",
38
+ "line": 7
39
+ }
40
+ }
41
+ ]
42
+ }
@@ -0,0 +1,23 @@
1
+ Move the cache to a shared directory.
2
+
3
+ ## Open Questions
4
+
5
+ ### Where does the cache live?
6
+
7
+ The cache is per worktree today.
8
+
9
+ - [ ] In `$XDG_CACHE_HOME`
10
+ - Shared by every worktree
11
+ - [x] Next to the repository[^th1]
12
+ - Survives a worktree removal
13
+ - [ ] _your answer_
14
+
15
+ [^th1]:
16
+ - H: Is this the git common dir?
17
+ - A: Yes, the directory every worktree shares.
18
+
19
+ ### How is it invalidated?
20
+
21
+ - [ ] By content hash
22
+ - [ ] By modification time
23
+ - [x] Keep both, hash wins on conflict