@pmelab/gtd 42.0.0 → 44.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 (62) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +96 -48
  3. package/claude/hooks/sketch.ts +1 -1
  4. package/dist/chunks/{reserved-D7MNY9QJ.mjs → reserved-DJk_6-ju.mjs} +75 -46
  5. package/dist/chunks/review-X7Jfz0dY.mjs +249 -0
  6. package/dist/chunks/{steering-oCDdQlpP.mjs → steering-D4LUQ-av.mjs} +95 -12
  7. package/dist/chunks/{tail-D2o2h5cc.mjs → tail-5w5mxGTq.mjs} +3 -3
  8. package/dist/gtd.bundle.mjs +488 -30
  9. package/dist/steering.mjs +2 -2
  10. package/dist/testkit.mjs +447 -27
  11. package/dist/web/main.js +42 -13
  12. package/dist/workflow-kit.mjs +61 -25
  13. package/dist/workflows/cleanup.mjs +2 -2
  14. package/dist/workflows/feature.mjs +7 -290
  15. package/dist/workflows/fix.mjs +3 -3
  16. package/dist/workflows/review.mjs +27 -7
  17. package/dist/workflows/rollout.mjs +1 -1
  18. package/dist/workflows.mjs +287 -161
  19. package/package.json +1 -1
  20. package/src/cli/Cli.test.ts +33 -1
  21. package/src/cli/Cli.ts +70 -6
  22. package/src/cli/History.ts +15 -0
  23. package/src/cli/Judges.test.ts +373 -0
  24. package/src/cli/Judges.ts +624 -0
  25. package/src/cli/Metrics.ts +3 -16
  26. package/src/cli/Program.test.ts +3 -1
  27. package/src/cli/Program.ts +9 -0
  28. package/src/steering/FORMATS.md +4 -4
  29. package/src/steering/index.ts +12 -1
  30. package/src/steering/review.test.ts +107 -0
  31. package/src/steering/review.ts +159 -9
  32. package/src/testkit/EmittedScriptRecognizer.ts +0 -14
  33. package/src/testkit/steps.ts +4 -0
  34. package/src/workflow-kit/review.test.ts +170 -26
  35. package/src/workflow-kit/review.ts +95 -19
  36. package/src/workflows/feature/coverage.ts +1 -1
  37. package/src/workflows/feature/feature.test.ts +8 -0
  38. package/src/workflows/feature/feature.ts +2 -2
  39. package/src/workflows/feature/index.ts +1 -1
  40. package/src/workflows/feature/lens-review.feature +16 -4
  41. package/src/workflows/feature/refactor.ts +1 -1
  42. package/src/workflows/feature/review-answers.feature +179 -0
  43. package/src/workflows/feature/seam.ts +1 -1
  44. package/src/workflows/feature/steps.test.ts +2 -76
  45. package/src/workflows/feature/steps.ts +5 -48
  46. package/src/workflows/fix/fix.feature +20 -21
  47. package/src/workflows/index.ts +1 -1
  48. package/src/workflows/review/review-door.feature +149 -0
  49. package/src/workflows/review/review.test.ts +1 -73
  50. package/src/workflows/review/review.ts +34 -6
  51. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +6 -5
  52. package/src/workflows/shared/access.test.ts +2 -11
  53. package/src/workflows/shared/access.ts +0 -2
  54. package/src/workflows/{feature → shared}/review.test.ts +9 -61
  55. package/src/workflows/{feature → shared}/review.ts +40 -48
  56. package/src/workflows/shared/skills.test.ts +2 -0
  57. package/src/workflows/shared/skills.ts +2 -0
  58. package/src/workflows/shared/steps.test.ts +64 -2
  59. package/src/workflows/shared/steps.ts +40 -1
  60. package/src/workflows/shared/tail.ts +2 -2
  61. package/src/workflows/shared/text.test.ts +40 -89
  62. package/src/workflows/shared/text.ts +63 -54
@@ -5,7 +5,6 @@ import { join } from "node:path"
5
5
  import { afterEach, describe, expect, it } from "vitest"
6
6
  import {
7
7
  buildReviewTriageMessage,
8
- deferNotesScript,
9
8
  buildReviewReviewingPrompt,
10
9
  agentWithSkills,
11
10
  buildFixPrompt,
@@ -32,8 +31,7 @@ import {
32
31
  withSkills,
33
32
  composedReview,
34
33
  } from "./text.js"
35
- import { steeringFormatFor } from "../../steering/index.js"
36
- import { applyEmittedScript, InMemRepo } from "../../testkit/index.js"
34
+ import { steeringFormatFor, unansweredNotes } from "../../steering/index.js"
37
35
  import { captureStep, renderText } from "./text.fixture.js"
38
36
 
39
37
  describe("withSkills", () => {
@@ -829,53 +827,6 @@ describe("buildReviewReviewingPrompt risk marking", () => {
829
827
  })
830
828
  })
831
829
 
832
- describe("deferNotesScript", () => {
833
- const run = (notes: Parameters<typeof deferNotesScript>[0], existing?: string): string => {
834
- const dir = mkdtempSync(join(tmpdir(), "defer-"))
835
- mkdirSync(join(dir, ".gtd"))
836
- if (existing !== undefined) writeFileSync(join(dir, ".gtd/TODO.md"), existing)
837
- execFileSync("sh", ["-c", deferNotesScript(notes, "abcdef1234567890")], { cwd: dir })
838
- return readFileSync(join(dir, ".gtd/TODO.md"), "utf8")
839
- }
840
- const notes = [{ id: "note-1", anchor: "a.ts:3", text: "plain", label: "could: tiny" }]
841
-
842
- it("creates the file with one section headed by the round's short sha", () => {
843
- expect(run(notes)).toBe(
844
- "## Deferred review notes (abcdef1)\n\n- note-1 — a.ts:3 (could: tiny): plain\n",
845
- )
846
- })
847
-
848
- it("appends after a blank line, never truncating", () => {
849
- const out = run(notes, "# Sketch\n\nkeep me\n")
850
- expect(out.startsWith("# Sketch\n\nkeep me\n\n## Deferred review notes (abcdef1)\n")).toBe(true)
851
- })
852
-
853
- it("separates from a file lacking a trailing newline", () => {
854
- expect(run(notes, "keep")).toContain("keep\n\n## Deferred")
855
- })
856
-
857
- it("lands quotes, dollars and backticks verbatim", () => {
858
- const text = 'it\'s $HOME `x` "q" \\n'
859
- const out = run([{ id: "note-1", anchor: "a", text }])
860
- expect(out).toContain(text)
861
- })
862
-
863
- it("is an oxfmt fixed point, for a note longer than one line", () => {
864
- const long = [
865
- {
866
- id: "note-3",
867
- anchor: "Parser accepts CRLF ./src/parse.ts#30-31",
868
- text: "rename the helper to something clearer please",
869
- label: "could",
870
- },
871
- ]
872
- const dir = mkdtempSync(join(tmpdir(), "fmt-"))
873
- const file = join(dir, "TODO.md")
874
- writeFileSync(file, run(long, "# Sketch\n"))
875
- execFileSync("npx", ["oxfmt", "--check", file], { cwd: process.cwd() })
876
- })
877
- })
878
-
879
830
  describe("severity wording", () => {
880
831
  it("triage message names the four severity ids", () => {
881
832
  const m = renderText(() => buildReviewTriageMessage())
@@ -888,44 +839,6 @@ describe("severity wording", () => {
888
839
  expect(p).toContain("capture order")
889
840
  expect(p).toContain("(tier: reason)")
890
841
  })
891
-
892
- it("design triage parks deferred review notes", () => {
893
- expect(renderText(() => designTriagePrompt("base"))).toContain("## Deferred review notes")
894
- })
895
- })
896
-
897
- describe("deferNotesScript — the in-memory recognizer agrees with the real sh script", () => {
898
- const notes = [
899
- { id: "note-1", anchor: "a.ts:3", text: 'it\'s $HOME `x` "q" 100%', label: "could" },
900
- {
901
- id: "note-2",
902
- anchor: "Parser accepts CRLF ./src/parse.ts#30-31",
903
- text: "rename the helper to something clearer please",
904
- label: "could",
905
- },
906
- ]
907
- const cases: [string, string | undefined][] = [
908
- ["no file", undefined],
909
- ["an empty file", ""],
910
- ["a file without a trailing newline", "keep"],
911
- ["a file with a trailing newline", "# Sketch\n\nkeep\n"],
912
- ]
913
-
914
- it.each(cases)("%s", (_name, existing) => {
915
- const script = deferNotesScript(notes, "abcdef1234567890")
916
- const dir = mkdtempSync(join(tmpdir(), "defer-contract-"))
917
- if (existing !== undefined) {
918
- mkdirSync(join(dir, ".gtd"))
919
- writeFileSync(join(dir, ".gtd/TODO.md"), existing)
920
- }
921
- execFileSync("sh", ["-c", script], { cwd: dir })
922
- const real = readFileSync(join(dir, ".gtd/TODO.md"), "utf8")
923
-
924
- const repo = new InMemRepo()
925
- if (existing !== undefined) repo.writeFile(".gtd/TODO.md", existing)
926
- expect(applyEmittedScript(repo, new Map(), script).ok).toBe(true)
927
- expect(repo.readFile(".gtd/TODO.md")).toBe(real)
928
- })
929
842
  })
930
843
 
931
844
  describe("buildReviewReviewingPrompt scenarios", () => {
@@ -1319,6 +1232,15 @@ it("asks the refactor review, and only it, to flag a test-file hunk that is more
1319
1232
  expect(first).not.toContain("give any test-file hunk its own chunk")
1320
1233
  })
1321
1234
 
1235
+ describe("buildReviewCollectingPrompt answer boxes", () => {
1236
+ it("a hand-edit round folds fix and own answers, other rounds leave them to gtd", () => {
1237
+ const edits = renderText(() => buildReviewCollectingPrompt("cap", undefined, "edits"))
1238
+ expect(edits).toContain("A hand-edit round routes no answer box itself")
1239
+ const triaged = renderText(() => buildReviewCollectingPrompt("cap", undefined, "triaged"))
1240
+ expect(triaged).toContain("Never fold a note's ticked answer box")
1241
+ })
1242
+ })
1243
+
1322
1244
  describe("composedReview", () => {
1323
1245
  const BASE = "0123456789abcdef0123456789abcdef01234567"
1324
1246
 
@@ -1380,6 +1302,35 @@ describe("composedReview", () => {
1380
1302
 
1381
1303
  it("names the nit fix on the review after it", () => {
1382
1304
  const doc = composedReview(BASE, { notes: [], fixed: [], noise: [], nitFix: "abcdef0123" })
1383
- expect(doc).toContain("## Fixed from your notes\n\nYour nits were fixed in abcdef0;")
1305
+ expect(doc).toContain(
1306
+ "## Fixed from your notes\n\nYour nits and fix-as-suggested notes were worked on in abcdef0;",
1307
+ )
1308
+ })
1309
+
1310
+ it("ends every note, and only a note, in its three answer boxes", () => {
1311
+ const doc = composedReview(BASE, {
1312
+ notes: [{ title: "a", body: "- ends in a list" }],
1313
+ fixed: [{ title: "f", commit: "fedcba9876543210" }],
1314
+ noise: [],
1315
+ })
1316
+ expect(doc).toContain(
1317
+ "## a\n\n- ends in a list\n\nTick exactly one:\n\n- [ ] fix as suggested\n- [ ] won't fix\n- [ ] _your answer_\n\n## Fixed automatically",
1318
+ )
1319
+ expect(doc.match(/fix as suggested/g)).toHaveLength(1)
1320
+ expect(unansweredNotes(doc)).toEqual([{ title: "a", line: 5, reason: "tick exactly one box" }])
1321
+ })
1322
+
1323
+ it("leaves out the notes already answered", () => {
1324
+ const doc = composedReview(BASE, {
1325
+ notes: [
1326
+ { title: "a", body: "1" },
1327
+ { title: "a", body: "2" },
1328
+ ],
1329
+ fixed: [],
1330
+ noise: [],
1331
+ answered: new Set(["a"]),
1332
+ })
1333
+ expect(doc).not.toContain("## a\n")
1334
+ expect(doc).toContain("## a (2)\n\n2")
1384
1335
  })
1385
1336
  })
@@ -12,6 +12,12 @@ import {
12
12
  type JudgeQuestion,
13
13
  type SummaryContext,
14
14
  } from "../../flows/index.js"
15
+ import {
16
+ ANSWER_FIX,
17
+ ANSWER_LEAD,
18
+ ANSWER_WONT_FIX,
19
+ FREE_TEXT_PLACEHOLDER,
20
+ } from "../../steering/index.js"
15
21
  import { escapeRegExp } from "./registry.js"
16
22
  import { defaultFlagRegistry } from "./vars.js"
17
23
  import type { CollectRound, RemovedAssertions, Requested } from "../../workflow-kit/index.js"
@@ -116,6 +122,8 @@ export interface ComposedReview {
116
122
  readonly weakened?: readonly RemovedAssertions[]
117
123
  /** The commit that fixed the human's nits, on the review that follows it. */
118
124
  readonly nitFix?: string | undefined
125
+ /** Titles of notes the human already answered, left out. */
126
+ readonly answered?: ReadonlySet<string> | undefined
119
127
  }
120
128
 
121
129
  const SUMMARY_TITLES = [
@@ -133,9 +141,9 @@ const unboxed = (body: string): string =>
133
141
  const short = (hash: string): string => hash.slice(0, 7)
134
142
 
135
143
  /**
136
- * \`.gtd/REVIEW.md\` as gtd writes it: one chunk per note, then the fixed and
137
- * noise lists, with no tick boxes. Chunk titles are made unique — notes are
138
- * matched to their chunk by title.
144
+ * \`.gtd/REVIEW.md\` as gtd writes it: one chunk per note, each ending in its
145
+ * answer boxes, then the fixed and noise lists. Chunk titles are made unique —
146
+ * notes and answers are matched to their chunk by title.
139
147
  */
140
148
  export const composedReview = (base: string, report: ComposedReview): string => {
141
149
  // Summary titles are taken first, so no note can claim one.
@@ -147,6 +155,11 @@ export const composedReview = (base: string, report: ComposedReview): string =>
147
155
  return name
148
156
  }
149
157
  const chunk = (title: string, body: string): string => `## ${title}\n\n${body.trim()}`
158
+ // The lead-in line keeps a body that ends in a list from absorbing the boxes.
159
+ const answerBoxes = `${ANSWER_LEAD}\n\n- [ ] ${ANSWER_FIX}\n- [ ] ${ANSWER_WONT_FIX}\n- [ ] ${FREE_TEXT_PLACEHOLDER}`
160
+ const notes = report.notes
161
+ .map((n) => ({ ...n, title: unique(n.title) }))
162
+ .filter((n) => report.answered?.has(n.title) !== true)
150
163
  const weakened = (report.weakened ?? []).flatMap(({ path, lines }) =>
151
164
  lines.map((a) => `- ${path} — ${a.scenario} — ${a.line}`),
152
165
  )
@@ -156,7 +169,7 @@ export const composedReview = (base: string, report: ComposedReview): string =>
156
169
  : [
157
170
  chunk(
158
171
  "Fixed from your notes",
159
- `Your nits were fixed in ${short(report.nitFix)}; the list below is from before.`,
172
+ `Your nits and fix-as-suggested notes were worked on in ${short(report.nitFix)}; the notes you answered are left out — check the diff.`,
160
173
  ),
161
174
  ]),
162
175
  ...(weakened.length === 0
@@ -167,14 +180,16 @@ export const composedReview = (base: string, report: ComposedReview): string =>
167
180
  `These scenarios lost an assertion the review base had:\n\n${weakened.join("\n")}`,
168
181
  ),
169
182
  ]),
170
- ...report.notes.map((n) =>
183
+ ...notes.map((n) =>
171
184
  chunk(
172
- unique(n.title),
173
- n.unresolved === true
174
- ? `_The fixer left this unresolved._\n\n${unboxed(n.body)}`
175
- : n.unclear === true
176
- ? `_A fix turn removed this while renaming another finding — check whether it was fixed._\n\n${unboxed(n.body)}`
177
- : unboxed(n.body) || "_No detail given._",
185
+ n.title,
186
+ `${
187
+ n.unresolved === true
188
+ ? `_The fixer left this unresolved._\n\n${unboxed(n.body)}`
189
+ : n.unclear === true
190
+ ? `_A fix turn removed this while renaming another finding — check whether it was fixed._\n\n${unboxed(n.body)}`
191
+ : unboxed(n.body) || "_No detail given._"
192
+ }\n\n${answerBoxes}`,
178
193
  ),
179
194
  ),
180
195
  ...(report.fixed.length === 0
@@ -288,10 +303,6 @@ ${stateFileRules}
288
303
  ${footnoteFoldIn}${codeThreadReplies()}
289
304
  - The only state file this turn touches is \`.gtd/REQUIREMENTS.md\`
290
305
  — no other files for notes or output
291
- - Lines of a \`## Deferred review notes\` section of \`.gtd/TODO.md\`
292
- that the start diff does not touch are parked by review, never folded;
293
- the human's edits to such a line (or its removal) are input like any
294
- other part of the start diff
295
306
  - \`.gtd/TODO.md\` is the likely home of the sketch that started
296
307
  this process — the human's input, folded into the concerns
297
308
  below like any other part of the start diff; never a state
@@ -680,8 +691,12 @@ export const buildReviewAwaitReviewMessage = (base: string): string =>
680
691
  per finding that needs your judgement — it questions a criterion or the
681
692
  approach, or the fixer could not resolve it — then, for reference only, what
682
693
  was **fixed automatically** (with the fixing commit) and what was **dropped
683
- as noise**. Nothing there needs an answer; disagreeing with a fix or a drop
684
- means writing a note of your own.
694
+ as noise**. Each finding chunk ends in three boxes — tick exactly one:
695
+ \`fix as suggested\` (fixed in the nit batch below), \`won't fix\` (dropped,
696
+ recorded in the commit), or write your own answer over \`_your answer_\` and
697
+ tick it (judged like a note). Every landing here is refused until each
698
+ finding holds exactly one tick. The fixed and noise lists need no answer;
699
+ disagreeing with a fix or a drop means writing a note of your own.
685
700
 
686
701
  Review the diff yourself, with whatever tool you like — gtd checks
687
702
  nothing out and touches no ref; \`gtd base\` prints this same hash any
@@ -691,8 +706,8 @@ working tree:
691
706
  git diff ${base}
692
707
  When you've been through the whole diff, run \`gtd land\`:
693
708
 
694
- - **Sign off** — leave no comment — no note in
695
- \`.gtd/REVIEW.md\`, no code edit — to close the process.
709
+ - **Sign off** — answer every finding, leave no other comment — no note
710
+ in \`.gtd/REVIEW.md\`, no code edit — to close the process.
696
711
  Every turn commit stays on the
697
712
  branch; run \`gtd summary\` afterward for a closing-message
698
713
  prompt.
@@ -860,6 +875,17 @@ The reviewer's risks are:
860
875
 
861
876
  ${risks.length === 0 ? "(none)\n" : notesCapture(risks)}`
862
877
 
878
+ export const reviewFeedbackFixPrompt = (): string =>
879
+ `${stateFileRules}
880
+ - Read \`.gtd/REQUIREMENTS.md\` — the concerns the human's review asked for,
881
+ one \`## \` heading each, in build order
882
+ - Build every concern in this one turn; an answered question is context,
883
+ not a concern
884
+ - Where a change is behavioural, add a test that fails without it
885
+ - Leave \`.gtd/REQUIREMENTS.md\` untouched — the next step removes it
886
+ - Leave everything uncommitted and finish your turn
887
+ `
888
+
863
889
  export const buildReviewReviewMissingMessage = (commit: string): string =>
864
890
  `The review round committed no \`.gtd/REVIEW.md\` (at ${commit}), so there is
865
891
  nothing to sign off on.
@@ -894,34 +920,6 @@ const wrapItem = (item: string): string[] => {
894
920
  return [...out, line]
895
921
  }
896
922
 
897
- /** Appends one section of `notes` to `.gtd/TODO.md`, wrapped the way oxfmt writes it; an existing file is only ever appended to. */
898
- export const deferNotesScript = (notes: readonly NoteInput[], round: string): string => {
899
- const lines = [
900
- `## Deferred review notes (${round.slice(0, 7)})`,
901
- "",
902
- ...notes.flatMap((n) => wrapItem(`- ${noteHead(n)}: ${n.text.replace(/\s+/g, " ")}`)),
903
- ]
904
- return `mkdir -p .gtd
905
- if [ -s .gtd/TODO.md ]; then
906
- [ -z "$(tail -c1 .gtd/TODO.md)" ] || printf '\\n' >> .gtd/TODO.md
907
- printf '\\n' >> .gtd/TODO.md
908
- fi
909
- printf '%s\\n' ${lines.map(quote).join(" ")} >> .gtd/TODO.md
910
- `
911
- }
912
-
913
- /** The gate right after a deferral: landing clean signs off without these notes. */
914
- export const buildReviewAwaitAfterDeferMessage = (
915
- base: string,
916
- deferred: readonly NoteInput[],
917
- ): string =>
918
- `${buildReviewAwaitReviewMessage(base)}
919
- Deferred to \`.gtd/TODO.md\` just now, NOT acted on in this process — landing
920
- clean signs off without them:
921
-
922
- ${deferred.map((n) => `- ${noteHead(n)}: ${n.text.replace(/\s*\n\s*/g, " ")}`).join("\n")}
923
- `
924
-
925
923
  export const buildReviewTriageMessage = (): string =>
926
924
  `Judging each note the human added to \`.gtd/REVIEW.md\` (a line note, a
927
925
  footnote, or prose under a chunk) with one verdict: \`edit\` (a change
@@ -932,12 +930,14 @@ default; a skipped judgment never dismisses a note).
932
930
 
933
931
  Also judge each note's severity, one id per note: \`note-<n>.security\`,
934
932
  \`note-<n>.reversibility\`, \`note-<n>.blastRadius\` and
935
- \`note-<n>.newConcept\`.
933
+ \`note-<n>.newConcept\`. An own answer under a finding's boxes is judged
934
+ the same way, keyed \`answer-<n>\`.
936
935
  `
937
936
  const closedFold = (round: CollectRound, target: string): string =>
938
937
  round === "edits"
939
938
  ? `- Fold every hand-edit and every note the human left that round into
940
- \`${target}\`
939
+ \`${target}\` — a note ticked \`fix as suggested\` or answered in its
940
+ own words included; one ticked \`won't fix\` is dropped
941
941
  `
942
942
  : round === "notes"
943
943
  ? `- Fold each note that asks for a change into \`${target}\`
@@ -954,6 +954,7 @@ export const buildReviewCollectingPrompt = (
954
954
  /** `"reply"` is the in-gate reply turn; any other round is closed and folds what that round left. */
955
955
  readonly round: CollectRound
956
956
  },
957
+ round?: CollectRound,
957
958
  ): string => {
958
959
  const target = refactor ? ".gtd/REFACTOR.md" : ".gtd/REQUIREMENTS.md"
959
960
  const fence = refactor
@@ -1038,12 +1039,20 @@ ${closedFold(refactor.round, target)}`
1038
1039
  : `- This turn writes \`${target}\` (the folded concerns) and
1039
1040
  replies inside \`.gtd/REVIEW.md\` (thread replies only; the file stays
1040
1041
  in the tree) — you classify, you do not build
1041
- - Fold every concluded thread, ticked answer and hand-edit — and each
1042
- \`edit\` note the capture lists, no other note — into
1043
- \`${target}\` and delete the folded threads from
1044
- \`.gtd/REVIEW.md\`; append one \`- A:\` reply to each thread
1045
- whose last entry is a \`- H:\` question
1042
+ - Fold every concluded thread and hand-edit — and each \`edit\` note
1043
+ the capture lists, no other note — into \`${target}\` and
1044
+ delete the folded threads from \`.gtd/REVIEW.md\`; append one \`- A:\`
1045
+ reply to each thread whose last entry is a \`- H:\` question
1046
+ ${
1047
+ round === "edits"
1048
+ ? `- A hand-edit round routes no answer box itself: fold each note
1049
+ ticked \`fix as suggested\` or answered in its own words, too; drop one
1050
+ ticked \`won't fix\`
1046
1051
  `
1052
+ : `- Never fold a note's ticked answer box the capture does not list: gtd
1053
+ routes those itself
1054
+ `
1055
+ }`
1047
1056
  }- When the capture names an answering commit, carry each answered
1048
1057
  question into \`${target}\`'s \`## Answered Questions\` as
1049
1058
  \`### <the note>\` plus its answer — the lap replaces \`.gtd/REVIEW.md\`