@pmelab/gtd 42.0.0 → 43.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 (44) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +79 -35
  3. package/claude/hooks/sketch.ts +1 -1
  4. package/dist/chunks/{reserved-D7MNY9QJ.mjs → reserved-COEMFEpy.mjs} +35 -46
  5. package/dist/chunks/{steering-oCDdQlpP.mjs → steering-D4LUQ-av.mjs} +95 -12
  6. package/dist/chunks/{tail-D2o2h5cc.mjs → tail-C_xn8U7U.mjs} +1 -1
  7. package/dist/gtd.bundle.mjs +488 -30
  8. package/dist/steering.mjs +2 -2
  9. package/dist/testkit.mjs +447 -27
  10. package/dist/web/main.js +42 -13
  11. package/dist/workflow-kit.mjs +61 -25
  12. package/dist/workflows/cleanup.mjs +2 -2
  13. package/dist/workflows/feature.mjs +31 -42
  14. package/dist/workflows/fix.mjs +3 -3
  15. package/dist/workflows/review.mjs +2 -2
  16. package/dist/workflows/rollout.mjs +1 -1
  17. package/dist/workflows.mjs +217 -121
  18. package/package.json +1 -1
  19. package/src/cli/Cli.test.ts +33 -1
  20. package/src/cli/Cli.ts +70 -6
  21. package/src/cli/History.ts +15 -0
  22. package/src/cli/Judges.test.ts +373 -0
  23. package/src/cli/Judges.ts +624 -0
  24. package/src/cli/Metrics.ts +3 -16
  25. package/src/cli/Program.test.ts +3 -1
  26. package/src/cli/Program.ts +9 -0
  27. package/src/steering/index.ts +12 -1
  28. package/src/steering/review.test.ts +107 -0
  29. package/src/steering/review.ts +159 -9
  30. package/src/testkit/EmittedScriptRecognizer.ts +0 -14
  31. package/src/testkit/steps.ts +4 -0
  32. package/src/workflow-kit/review.test.ts +170 -26
  33. package/src/workflow-kit/review.ts +95 -19
  34. package/src/workflows/feature/lens-review.feature +16 -4
  35. package/src/workflows/feature/review-answers.feature +179 -0
  36. package/src/workflows/feature/review.test.ts +5 -53
  37. package/src/workflows/feature/review.ts +31 -42
  38. package/src/workflows/feature/steps.test.ts +0 -12
  39. package/src/workflows/feature/steps.ts +3 -10
  40. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +6 -5
  41. package/src/workflows/shared/access.test.ts +2 -11
  42. package/src/workflows/shared/access.ts +0 -2
  43. package/src/workflows/shared/text.test.ts +40 -89
  44. package/src/workflows/shared/text.ts +52 -54
@@ -109,6 +109,7 @@ export {
109
109
  } from "./Output.js"
110
110
  import { runStraysCommand } from "./Strays.js"
111
111
  import { runHistoryFetchCommand, runHistoryListCommand, runHistoryShowCommand } from "./History.js"
112
+ import { runHistoryJudgesCommand } from "./Judges.js"
112
113
  import { runHistoryMetricsCommand } from "./Metrics.js"
113
114
 
114
115
  /**
@@ -176,6 +177,12 @@ export type Command =
176
177
  | { readonly kind: "historyList" }
177
178
  | { readonly kind: "historyShow"; readonly ref: string }
178
179
  | { readonly kind: "historyMetrics"; readonly refs: readonly string[] }
180
+ | {
181
+ readonly kind: "historyJudges"
182
+ readonly refs: readonly string[]
183
+ readonly vars: Readonly<Record<string, string>>
184
+ readonly jsonl: boolean
185
+ }
179
186
  | { readonly kind: "historyFetch"; readonly remote: string }
180
187
  | {
181
188
  readonly kind: "strays"
@@ -1324,6 +1331,8 @@ const dispatchVoidCommand = (
1324
1331
  return runHistoryShowCommand(command.ref, json, out)
1325
1332
  case "historyMetrics":
1326
1333
  return runHistoryMetricsCommand(command.refs, json, out)
1334
+ case "historyJudges":
1335
+ return runHistoryJudgesCommand(command.refs, command.vars, command.jsonl, json, out)
1327
1336
  case "historyFetch":
1328
1337
  return runHistoryFetchCommand(command.remote, out)
1329
1338
  }
@@ -8,7 +8,18 @@ import type {
8
8
  export { formatFinding } from "./SteeringFormat.js"
9
9
  import { qaDescriptor } from "./qa.js"
10
10
  import { reviewDescriptor } from "./review.js"
11
- export { reviewNotes, reviewRisks, type ReviewNote } from "./review.js"
11
+ export {
12
+ ANSWER_FIX,
13
+ ANSWER_LEAD,
14
+ ANSWER_WONT_FIX,
15
+ reviewDecisions,
16
+ reviewNotes,
17
+ reviewRisks,
18
+ unansweredNotes,
19
+ type ReviewChoice,
20
+ type ReviewDecision,
21
+ type ReviewNote,
22
+ } from "./review.js"
12
23
  export { applySteeringEdits } from "./Edits.js"
13
24
 
14
25
  export type { InlineNode } from "./Inline.js"
@@ -1839,3 +1839,110 @@ describe("reviewRisks", async () => {
1839
1839
  expect(reviewRisks(doc("Prose.", ["- [ ] ./src/a.ts#1-3 — fine"]))).toEqual([])
1840
1840
  })
1841
1841
  })
1842
+
1843
+ describe("answer boxes", async () => {
1844
+ const { reviewDecisions, reviewNotes, unansweredNotes } = await import("./review.js")
1845
+ const note = (title: string, boxes: readonly [string, string, string]): string =>
1846
+ [
1847
+ `## ${title}`,
1848
+ "",
1849
+ "Should subtract round?",
1850
+ "",
1851
+ "Tick exactly one:",
1852
+ "",
1853
+ `- [${boxes[0]}] fix as suggested`,
1854
+ `- [${boxes[1]}] won't fix`,
1855
+ `- [${boxes[2] === "" ? " " : "x"}] ${boxes[2] === "" || boxes[2] === "x" ? "_your answer_" : boxes[2]}`,
1856
+ ].join("\n")
1857
+ const doc = (...chunks: string[]): string =>
1858
+ ["# Review: abc1234", "", "<!-- base: abc1234 -->", "", ...chunks, ""].join("\n")
1859
+ const open = doc(note("Rounding", [" ", " ", ""]))
1860
+
1861
+ it("are no file pointers, no finding and no part of the note's text", () => {
1862
+ expect(steeringFormatFor("review")!.validate(open)).toEqual([])
1863
+ expect(parseReviewDoc(open).changesets[0]).toMatchObject({
1864
+ description: "Should subtract round?",
1865
+ files: [],
1866
+ })
1867
+ })
1868
+
1869
+ it("survive the gate's tick reset", () => {
1870
+ const ticked = doc(note("Rounding", ["x", " ", ""]))
1871
+ expect(steeringFormatFor("review")!.clearTicks(ticked)).toBe(ticked)
1872
+ })
1873
+
1874
+ it("a tick is never a note", () => {
1875
+ expect(reviewNotes(open, doc(note("Rounding", [" ", "x", ""])))).toEqual([])
1876
+ })
1877
+
1878
+ it("an unticked note, or one with two ticks, is unanswered", () => {
1879
+ expect(unansweredNotes(open)).toEqual([
1880
+ { title: "Rounding", line: 5, reason: "tick exactly one box" },
1881
+ ])
1882
+ expect(unansweredNotes(doc(note("Rounding", ["x", "x", ""])))).toHaveLength(1)
1883
+ })
1884
+
1885
+ it("a ticked free-text slot still holding the placeholder is unanswered", () => {
1886
+ expect(unansweredNotes(doc(note("Rounding", [" ", " ", "x"])))).toHaveLength(1)
1887
+ })
1888
+
1889
+ it("each box decides its note", () => {
1890
+ const after = doc(
1891
+ note("Fix it", ["x", " ", ""]),
1892
+ note("Leave it", [" ", "x", ""]),
1893
+ note("Answer it", [" ", " ", "round half up"]),
1894
+ )
1895
+ expect(unansweredNotes(after)).toEqual([])
1896
+ expect(reviewDecisions("", after)).toEqual([
1897
+ { anchor: "Fix it", body: "Should subtract round?", choice: "fix", text: "" },
1898
+ { anchor: "Leave it", body: "Should subtract round?", choice: "wontfix", text: "" },
1899
+ {
1900
+ anchor: "Answer it",
1901
+ body: "Should subtract round?",
1902
+ choice: "answer",
1903
+ text: "round half up",
1904
+ },
1905
+ ])
1906
+ })
1907
+
1908
+ it("an answer the baseline already held is no new decision", () => {
1909
+ const answered = doc(note("Rounding", ["x", " ", ""]))
1910
+ expect(reviewDecisions(answered, answered)).toEqual([])
1911
+ expect(reviewDecisions(answered, doc(note("Rounding", [" ", "x", ""])))).toMatchObject([
1912
+ { choice: "wontfix" },
1913
+ ])
1914
+ })
1915
+
1916
+ it("are read by position: an edited label or a path-like answer still answers", () => {
1917
+ const edited = doc(
1918
+ note("Rounding", [" ", " ", ""]).replace("- [ ] fix as suggested", "- [x] fix it, but floor"),
1919
+ )
1920
+ expect(reviewDecisions("", edited)).toMatchObject([{ choice: "fix" }])
1921
+ const pathy = doc(note("Rounding", [" ", " ", "./src/calc.ts#1-1 round here"]))
1922
+ expect(steeringFormatFor("review")!.validate(pathy)).toEqual([])
1923
+ expect(reviewDecisions("", pathy)).toMatchObject([
1924
+ { choice: "answer", text: "./src/calc.ts#1-1 round here" },
1925
+ ])
1926
+ expect(steeringFormatFor("review")!.clearTicks(pathy)).toBe(pathy)
1927
+ })
1928
+
1929
+ it("a note whose boxes were deleted is unanswered", () => {
1930
+ const gone = doc("## Rounding\n\nShould subtract round?")
1931
+ expect(unansweredNotes(gone, open)).toEqual([
1932
+ { title: "Rounding", line: 0, reason: "its answer boxes were deleted — restore them" },
1933
+ ])
1934
+ expect(unansweredNotes(doc(), open)).toHaveLength(1)
1935
+ })
1936
+
1937
+ it("a box added or removed leaves the note unanswered", () => {
1938
+ const four = doc(note("Rounding", ["x", " ", ""]).concat("\n- [ ] another"))
1939
+ expect(unansweredNotes(four)).toHaveLength(1)
1940
+ })
1941
+
1942
+ it("a box list other than gtd's three is a pointer gone wrong", () => {
1943
+ const stray = doc("## Chunk\n\n- [ ] fix as suggested\n- [ ] something else\n- [ ] third")
1944
+ expect(steeringFormatFor("review")!.validate(stray)).toEqual([
1945
+ expect.objectContaining({ message: 'Chunk "Chunk" has no file pointers' }),
1946
+ ])
1947
+ })
1948
+ })
@@ -1,4 +1,4 @@
1
- import type { ListItem, Root, RootContent } from "mdast"
1
+ import type { List, ListItem, Root, RootContent } from "mdast"
2
2
  import { blockNodesOfRun, blockRunInline } from "./Blocks.js"
3
3
  import type { FootnoteAnchor } from "./Footnotes.js"
4
4
  import {
@@ -35,6 +35,7 @@ import {
35
35
  toLspPositionFromOffset,
36
36
  type HeadingBlock,
37
37
  } from "./MarkdownTree.js"
38
+ import { FREE_TEXT_PLACEHOLDER } from "./qa.js"
38
39
  import type {
39
40
  SteeringAnchor,
40
41
  SteeringAnnotateResult,
@@ -74,8 +75,20 @@ interface Changeset {
74
75
  readonly files: readonly ReviewFile[]
75
76
  /** 0-based index of this chunk's `##` heading. */
76
77
  readonly headingLine: number
78
+ /** The chunk's answer boxes, when gtd gave it some. */
79
+ readonly answers?: readonly AnswerOption[]
77
80
  }
78
81
 
82
+ interface AnswerOption {
83
+ readonly checked: boolean
84
+ /** The free-text slot's unfilled placeholder normalizes to `""`. */
85
+ readonly text: string
86
+ }
87
+
88
+ export const ANSWER_LEAD = "Tick exactly one:"
89
+ export const ANSWER_FIX = "fix as suggested"
90
+ export const ANSWER_WONT_FIX = "won't fix"
91
+
79
92
  interface ReviewDoc {
80
93
  readonly shortHash?: string
81
94
  readonly fullHash?: string
@@ -470,6 +483,44 @@ const parseHunk = (
470
483
  return { file, errors }
471
484
  }
472
485
 
486
+ const itemText = (content: string, item: ListItem): string =>
487
+ item.children
488
+ .map((c) => sourceText(content, c))
489
+ .join(" ")
490
+ // A ticked item's first paragraph span starts at its `[x]`.
491
+ .replace(/^\[[ xX]\]\s*/, "")
492
+ .trim()
493
+
494
+ /**
495
+ * The task list right after the `Tick exactly one:` paragraph, with that
496
+ * paragraph's index. Read by position, never by label, so an edited label or
497
+ * a free text opening with `./path` is still an answer, not a pointer. Any
498
+ * count but three boxes is kept, and reads as unanswered.
499
+ */
500
+ const answerListOf = (
501
+ content: string,
502
+ body: readonly RootContent[],
503
+ ): { readonly lead: number; readonly list: List; readonly options: AnswerOption[] } | undefined => {
504
+ const lead = body.findIndex(
505
+ (n, i) =>
506
+ n.type === "paragraph" &&
507
+ sourceText(content, n) === ANSWER_LEAD &&
508
+ body[i + 1]?.type === "list" &&
509
+ (body[i + 1] as List).children.every((item) => item.checked != null),
510
+ )
511
+ if (lead === -1) return undefined
512
+ const list = body[lead + 1] as List
513
+ const last = list.children.length - 1
514
+ const options = list.children.map((item, i) => {
515
+ const text = itemText(content, item)
516
+ return {
517
+ checked: item.checked === true,
518
+ text: i === last && text.toLowerCase() === FREE_TEXT_PLACEHOLDER.toLowerCase() ? "" : text,
519
+ }
520
+ })
521
+ return { lead, list, options }
522
+ }
523
+
473
524
  /** Splits one chunk's body nodes into its file pointers (hunk pointers are task items, collected recursively at ANY nesting depth via `taskItems` — a nested hunk is the same kind of hunk as a top-level one) and description prose. The description is the chunk's own leading run and never a node that contains a hunk pointer: it stops at the first node whose `taskItems` include a real pointer (at any depth — a blockquote wrapping a pointer list stops it just as a top-level list would). Every node kind in what's left before that point contributes — a `footnoteDefinition` is the one exception, excluded by `blockNodesOfRun` itself (it's a note, never document content). */
474
525
  const parseChunkBody = (
475
526
  content: string,
@@ -482,11 +533,19 @@ const parseChunkBody = (
482
533
  readonly descriptionInline: readonly InlineNode[]
483
534
  readonly files: readonly ReviewFile[]
484
535
  readonly errors: readonly SteeringFinding[]
536
+ readonly answers?: readonly AnswerOption[]
537
+ /** Whether a task box outside the answer list exists. */
538
+ readonly boxed: boolean
485
539
  } => {
486
- const firstPointerIndex = body.findIndex((n) =>
487
- taskItems(n).some((item) => hasPointerToken(content, item)),
540
+ const answer = answerListOf(content, body)
541
+ const isAnswer = (n: RootContent): boolean =>
542
+ answer !== undefined && (n === answer.list || n === body[answer.lead])
543
+ const firstPointerIndex = body.findIndex(
544
+ (n) => !isAnswer(n) && taskItems(n).some((item) => hasPointerToken(content, item)),
545
+ )
546
+ const leadingRun = (firstPointerIndex === -1 ? body : body.slice(0, firstPointerIndex)).filter(
547
+ (n) => !isAnswer(n),
488
548
  )
489
- const leadingRun = firstPointerIndex === -1 ? body : body.slice(0, firstPointerIndex)
490
549
  const descriptionNodes = blockNodesOfRun(content, leadingRun)
491
550
  const descriptionInline = collapseImages(blockRunInline(content, leadingRun))
492
551
  const description = leadingRun
@@ -497,13 +556,21 @@ const parseChunkBody = (
497
556
 
498
557
  const files: ReviewFile[] = []
499
558
  const errors: SteeringFinding[] = []
500
- for (const item of body.flatMap((n) => taskItems(n))) {
559
+ for (const item of body.filter((n) => !isAnswer(n)).flatMap((n) => taskItems(n))) {
501
560
  const parsed = parseHunk(content, lines, title, item)
502
561
  if (!parsed) continue
503
562
  files.push(parsed.file)
504
563
  errors.push(...parsed.errors)
505
564
  }
506
- return { description, descriptionNodes, descriptionInline, files, errors }
565
+ return {
566
+ description,
567
+ descriptionNodes,
568
+ descriptionInline,
569
+ files,
570
+ errors,
571
+ ...(answer === undefined ? {} : { answers: answer.options }),
572
+ boxed: body.some((n) => !isAnswer(n) && taskItems(n).length > 0),
573
+ }
507
574
  }
508
575
 
509
576
  /** One `##` chunk heading node with its raw body block nodes (up to the next `##`-or-shallower heading). */
@@ -550,18 +617,28 @@ const parseChangesets = (
550
617
  descriptionNodes,
551
618
  descriptionInline,
552
619
  files,
620
+ answers,
621
+ boxed,
553
622
  errors: bodyErrors,
554
623
  } = parseChunkBody(content, lines, title, body)
555
624
  errors.push(...bodyErrors)
556
625
  // A prose-only chunk is a note gtd composed; a box with no pointer is a pointer gone wrong.
557
- if (files.length === 0 && body.some((n) => taskItems(n).length > 0)) {
626
+ if (files.length === 0 && boxed) {
558
627
  errors.push({
559
628
  message: `Chunk "${title}" has no file pointers`,
560
629
  line: headingLine,
561
630
  range: nodeRange(heading),
562
631
  })
563
632
  }
564
- changesets.push({ title, description, descriptionNodes, descriptionInline, files, headingLine })
633
+ changesets.push({
634
+ title,
635
+ description,
636
+ descriptionNodes,
637
+ descriptionInline,
638
+ files,
639
+ headingLine,
640
+ ...(answers === undefined ? {} : { answers }),
641
+ })
565
642
  }
566
643
  if (changesets.length === 0) errors.push({ message: "REVIEW.md has no '##' chunks" })
567
644
  return { changesets, errors }
@@ -645,8 +722,13 @@ const isHunkPointerItem = (content: string, item: ListItem): boolean => {
645
722
  */
646
723
  const reviewClearTicks = (content: string): string => {
647
724
  const tree = parseMarkdown(content)
725
+ const answers = new Set(
726
+ splitChunks(tree).flatMap(({ body }) => answerListOf(content, body)?.list.children ?? []),
727
+ )
648
728
  const offsets = taskItems(tree)
649
- .filter((item) => item.checked === true && isHunkPointerItem(content, item))
729
+ .filter(
730
+ (item) => item.checked === true && !answers.has(item) && isHunkPointerItem(content, item),
731
+ )
650
732
  .map((item) => checkboxOffset(content, item))
651
733
  .filter((o): o is number => o !== undefined)
652
734
  .sort((a, b) => a - b)
@@ -1185,3 +1267,71 @@ export const reviewRisks = (
1185
1267
  ),
1186
1268
  )
1187
1269
  .map((r, i) => ({ id: `risk-${i + 1}`, ...r }))
1270
+
1271
+ export type ReviewChoice = "fix" | "wontfix" | "answer"
1272
+
1273
+ /** A note's answer: one box ticked, and a ticked free-text slot filled in. */
1274
+ export interface ReviewDecision {
1275
+ /** The note's chunk title. */
1276
+ readonly anchor: string
1277
+ /** The note's text as gtd wrote it. */
1278
+ readonly body: string
1279
+ readonly choice: ReviewChoice
1280
+ /** The human's free text; empty unless `choice` is `answer`. */
1281
+ readonly text: string
1282
+ }
1283
+
1284
+ const CHOICES: readonly ReviewChoice[] = ["fix", "wontfix", "answer"]
1285
+
1286
+ const decisionOf = (chunk: Changeset): ReviewDecision | undefined => {
1287
+ if (chunk.answers?.length !== 3) return undefined
1288
+ const ticked = chunk.answers.flatMap((o, i) => (o.checked ? [i] : []))
1289
+ if (ticked.length !== 1) return undefined
1290
+ const i = ticked[0]!
1291
+ const text = i === 2 ? chunk.answers![2]!.text : ""
1292
+ if (i === 2 && text === "") return undefined
1293
+ return { anchor: chunk.title, body: chunk.description, choice: CHOICES[i]!, text }
1294
+ }
1295
+
1296
+ /**
1297
+ * Notes whose answer boxes do not hold exactly one tick (a ticked free-text
1298
+ * slot filled in), with their 1-based heading line and why; and, given the
1299
+ * file `before` the edit, every note whose boxes were deleted.
1300
+ */
1301
+ export const unansweredNotes = (
1302
+ content: string,
1303
+ before?: string,
1304
+ ): readonly { title: string; line: number; reason: string }[] => {
1305
+ const now = parseReviewDoc(content).changesets
1306
+ const boxed = new Set(now.filter((c) => c.answers !== undefined).map((c) => c.title))
1307
+ const removed = (before === undefined ? [] : parseReviewDoc(before).changesets)
1308
+ .filter((c) => c.answers !== undefined && !boxed.has(c.title))
1309
+ .map((c) => ({
1310
+ title: c.title,
1311
+ line: 0,
1312
+ reason: "its answer boxes were deleted — restore them",
1313
+ }))
1314
+ const open = now
1315
+ .filter((c) => c.answers !== undefined && decisionOf(c) === undefined)
1316
+ .map((c) => ({
1317
+ title: c.title,
1318
+ line: c.headingLine + 1,
1319
+ reason:
1320
+ c.answers!.length === 3
1321
+ ? "tick exactly one box"
1322
+ : `it has ${c.answers!.length} boxes — restore the three gtd wrote`,
1323
+ }))
1324
+ return [...open, ...removed]
1325
+ }
1326
+
1327
+ /** Every answer in `after` that `before` did not already hold for the same note, in document order. */
1328
+ export const reviewDecisions = (before: string, after: string): readonly ReviewDecision[] => {
1329
+ const prior = new Map(parseReviewDoc(before).changesets.map((c) => [c.title, c]))
1330
+ return parseReviewDoc(after).changesets.flatMap((chunk) => {
1331
+ const decision = decisionOf(chunk)
1332
+ if (decision === undefined) return []
1333
+ const was = prior.get(chunk.title)
1334
+ const old = was === undefined ? undefined : decisionOf(was)
1335
+ return old?.choice === decision.choice && old.text === decision.text ? [] : [decision]
1336
+ })
1337
+ }
@@ -670,19 +670,6 @@ const splitBlocks = (script: string): readonly string[] => {
670
670
  return blocks
671
671
  }
672
672
 
673
- /** `deferNotesScript`: append its one section to `.gtd/TODO.md`, reading the section's lines off the final `printf`. */
674
- const DEFER_HEAD = "mkdir -p .gtd\nif [ -s .gtd/TODO.md ]; then"
675
- const recognizeDeferNotes = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
676
- if (!block.startsWith(DEFER_HEAD)) return undefined
677
- const at = block.lastIndexOf("\nprintf '%s\\n' ")
678
- if (at === -1) return undefined
679
- const lines = extractQuotedTokens(block.slice(at + "\nprintf '%s\\n' ".length))
680
- const old = repo.readFile(".gtd/TODO.md") ?? ""
681
- const sep = old === "" ? "" : `${old.endsWith("\n") ? "" : "\n"}\n`
682
- repo.writeFile(".gtd/TODO.md", `${old}${sep}${lines.join("\n")}\n`)
683
- return { kind: "applied" }
684
- }
685
-
686
673
  /** `mkdir -p <dir>` then `printf '%s\n' <quoted lines> > <path>`: a flow's one-file write. Re-rendered from what was extracted, so any other shape stays unrecognized. */
687
674
  const recognizeWriteLines = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
688
675
  const [mkdir, printf, ...rest] = block.split("\n")
@@ -715,7 +702,6 @@ const recognizersFor = (
715
702
  (block) => recognizeFileExistsGuard(repo, block),
716
703
  (block) => recognizeBinaryGuard(commands, block),
717
704
  (block) => (git ? recognizeGitBuilders(repo, block) : undefined),
718
- (block) => recognizeDeferNotes(repo, block),
719
705
  (block) => recognizeWriteLines(repo, block),
720
706
  (block) => recognizeFailurePromptWrapper(repo, commands, block),
721
707
  (block) => recognizePresentationSubshell(repo, commands, block, git),
@@ -267,6 +267,10 @@ Then(
267
267
  },
268
268
  )
269
269
 
270
+ Then("landing is refused with {string}", async (world: TestkitWorld, message: string) => {
271
+ await assert.rejects(world.repo().land(), (error: Error) => error.message.includes(message))
272
+ })
273
+
270
274
  Then(
271
275
  "driving the process is refused with {string}",
272
276
  async (world: TestkitWorld, message: string) => {