@pmelab/gtd 41.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 (73) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +107 -53
  3. package/claude/hooks/sketch.ts +1 -1
  4. package/dist/chunks/{reserved-Ch4LLrEj.mjs → reserved-COEMFEpy.mjs} +84 -51
  5. package/dist/chunks/{steering-DVILanVf.mjs → steering-D4LUQ-av.mjs} +95 -12
  6. package/dist/chunks/tail-C_xn8U7U.mjs +246 -0
  7. package/dist/flows.mjs +11 -1
  8. package/dist/gtd.bundle.mjs +498 -30
  9. package/dist/steering.mjs +2 -2
  10. package/dist/testkit.mjs +460 -28
  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 +66 -57
  15. package/dist/workflows/fix.mjs +3 -3
  16. package/dist/workflows/review.mjs +2 -2
  17. package/dist/workflows/rollout.mjs +1 -1
  18. package/dist/workflows.mjs +446 -141
  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/flows/scripts.ts +15 -0
  29. package/src/steering/index.ts +12 -1
  30. package/src/steering/review.test.ts +109 -20
  31. package/src/steering/review.ts +160 -9
  32. package/src/testkit/EmittedScriptRecognizer.ts +0 -14
  33. package/src/testkit/FlowScripts.test.ts +17 -0
  34. package/src/testkit/FlowScripts.ts +15 -0
  35. package/src/testkit/steps.ts +31 -0
  36. package/src/workflow-kit/review.test.ts +170 -26
  37. package/src/workflow-kit/review.ts +95 -19
  38. package/src/workflows/feature/coverage.feature +3 -3
  39. package/src/workflows/feature/fast-path.feature +2 -2
  40. package/src/workflows/feature/feature.test.ts +2 -37
  41. package/src/workflows/feature/feature.ts +12 -14
  42. package/src/workflows/feature/flag-mode.feature +2 -2
  43. package/src/workflows/feature/lens-review.feature +246 -0
  44. package/src/workflows/feature/one-step-land.feature +2 -2
  45. package/src/workflows/feature/quality-unresolved.feature +47 -32
  46. package/src/workflows/feature/refactor-absorbs.feature +3 -3
  47. package/src/workflows/feature/refactor-first.feature +5 -5
  48. package/src/workflows/feature/refactor-rebuild.feature +7 -7
  49. package/src/workflows/feature/refactor-review.feature +19 -100
  50. package/src/workflows/feature/refactor.test.ts +12 -27
  51. package/src/workflows/feature/refactor.ts +0 -1
  52. package/src/workflows/feature/review-answers.feature +179 -0
  53. package/src/workflows/feature/review-gate.feature +6 -6
  54. package/src/workflows/feature/review.test.ts +33 -77
  55. package/src/workflows/feature/review.ts +79 -47
  56. package/src/workflows/feature/seam-rejection.feature +2 -2
  57. package/src/workflows/feature/seam.feature +2 -2
  58. package/src/workflows/feature/seam.test.ts +1 -1
  59. package/src/workflows/feature/slicing.feature +6 -6
  60. package/src/workflows/feature/steps.test.ts +0 -12
  61. package/src/workflows/feature/steps.ts +3 -10
  62. package/src/workflows/index.ts +1 -0
  63. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +6 -5
  64. package/src/workflows/shared/access.test.ts +2 -11
  65. package/src/workflows/shared/access.ts +0 -2
  66. package/src/workflows/shared/lenses.test.ts +39 -0
  67. package/src/workflows/shared/lenses.ts +176 -0
  68. package/src/workflows/shared/options.ts +1 -0
  69. package/src/workflows/shared/tail.ts +19 -4
  70. package/src/workflows/shared/text.test.ts +106 -88
  71. package/src/workflows/shared/text.ts +164 -53
  72. package/src/workflows/shared/vars.ts +1 -0
  73. package/dist/chunks/tail-lY1UmJrU.mjs +0 -112
@@ -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
  }
@@ -131,6 +131,21 @@ export const restoreScript = (
131
131
  export const removeScript = (paths: readonly string[]): string =>
132
132
  ["#!/usr/bin/env sh", ...removal(paths), ""].join("\n")
133
133
 
134
+ /** Write `content` to `path` verbatim, creating its directory, then remove `remove`. */
135
+ export const writeScript = (
136
+ path: string,
137
+ content: string,
138
+ remove: readonly string[] = [],
139
+ ): string =>
140
+ [
141
+ "#!/usr/bin/env sh",
142
+ "set -e",
143
+ `mkdir -p "$(dirname ${quote(path)})"`,
144
+ `printf '%s' ${quote(content)} > ${quote(path)}`,
145
+ ...removal(remove),
146
+ "",
147
+ ].join("\n")
148
+
134
149
  /** Move `from` to `to`, creating `to`'s directory. */
135
150
  export const moveScript = (from: string, to: string): string =>
136
151
  [
@@ -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"
@@ -165,7 +165,7 @@ describe("review — structure (validate)", () => {
165
165
  )
166
166
  })
167
167
 
168
- it("an ordinary chunk still isn't exempted merely by carrying prose that resembles an assumption", () => {
168
+ it("a prose-only chunk, bullets and all, is valid", () => {
169
169
  const content = doc([
170
170
  HEADER,
171
171
  "",
@@ -176,25 +176,7 @@ describe("review — structure (validate)", () => {
176
176
  "- some inferred note with no pointer",
177
177
  "",
178
178
  ])
179
- expect(review.validate(content)).toContainEqual(
180
- expect.objectContaining({ message: 'Chunk "Not Assumptions" has no file pointers' }),
181
- )
182
- })
183
-
184
- it("a literal Assumptions chunk with no file pointers is a finding too — no title is exempt", () => {
185
- const content = doc([
186
- HEADER,
187
- "",
188
- BASE,
189
- "",
190
- "## Assumptions",
191
- "",
192
- "- some inferred note with no pointer",
193
- "",
194
- ])
195
- expect(review.validate(content)).toContainEqual(
196
- expect.objectContaining({ message: 'Chunk "Assumptions" has no file pointers' }),
197
- )
179
+ expect(review.validate(content)).toEqual([])
198
180
  })
199
181
  })
200
182
 
@@ -1857,3 +1839,110 @@ describe("reviewRisks", async () => {
1857
1839
  expect(reviewRisks(doc("Prose.", ["- [ ] ./src/a.ts#1-3 — fine"]))).toEqual([])
1858
1840
  })
1859
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,17 +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
- if (files.length === 0) {
625
+ // A prose-only chunk is a note gtd composed; a box with no pointer is a pointer gone wrong.
626
+ if (files.length === 0 && boxed) {
557
627
  errors.push({
558
628
  message: `Chunk "${title}" has no file pointers`,
559
629
  line: headingLine,
560
630
  range: nodeRange(heading),
561
631
  })
562
632
  }
563
- 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
+ })
564
642
  }
565
643
  if (changesets.length === 0) errors.push({ message: "REVIEW.md has no '##' chunks" })
566
644
  return { changesets, errors }
@@ -644,8 +722,13 @@ const isHunkPointerItem = (content: string, item: ListItem): boolean => {
644
722
  */
645
723
  const reviewClearTicks = (content: string): string => {
646
724
  const tree = parseMarkdown(content)
725
+ const answers = new Set(
726
+ splitChunks(tree).flatMap(({ body }) => answerListOf(content, body)?.list.children ?? []),
727
+ )
647
728
  const offsets = taskItems(tree)
648
- .filter((item) => item.checked === true && isHunkPointerItem(content, item))
729
+ .filter(
730
+ (item) => item.checked === true && !answers.has(item) && isHunkPointerItem(content, item),
731
+ )
649
732
  .map((item) => checkboxOffset(content, item))
650
733
  .filter((o): o is number => o !== undefined)
651
734
  .sort((a, b) => a - b)
@@ -1184,3 +1267,71 @@ export const reviewRisks = (
1184
1267
  ),
1185
1268
  )
1186
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),
@@ -7,6 +7,7 @@ import {
7
7
  restoreScript,
8
8
  revertScript,
9
9
  showScript,
10
+ writeScript,
10
11
  } from "../flows/index.js"
11
12
  import { gitTiers, type GitTier, type ScriptedCommand } from "./index.js"
12
13
 
@@ -69,6 +70,22 @@ for (const makeTier of gitTiers) {
69
70
  })
70
71
  })
71
72
 
73
+ describe("writeScript", () => {
74
+ it("writes the content verbatim, quotes and newlines kept, creating the directory", () => {
75
+ const content = "# It's here\n\n- a 'quoted' $HOME `x`\n"
76
+ expect(run(writeScript("deep/dir/a.md", content)).ok).toBe(true)
77
+ expect(read("deep/dir/a.md")).toBe(content)
78
+ })
79
+
80
+ it("overwrites the file, then removes the paths it names", () => {
81
+ t.seed.writeFile("a.md", "old")
82
+ t.seed.writeFile("gone.md", "g")
83
+ expect(run(writeScript("a.md", "new", ["gone.md", "missing.md"])).ok).toBe(true)
84
+ expect(read("a.md")).toBe("new")
85
+ expect(read("gone.md")).toBeUndefined()
86
+ })
87
+ })
88
+
72
89
  describe("showScript", () => {
73
90
  it("writes the committed blob, not the working copy", () => {
74
91
  t.seed.commit("feat: notes", { "notes.md": "committed" })
@@ -12,6 +12,7 @@ import {
12
12
  restoreScript,
13
13
  revertScript,
14
14
  showScript,
15
+ writeScript,
15
16
  } from "../flows/index.js"
16
17
  import type { InMemRepo } from "./InMemRepo.js"
17
18
  import type { ScriptedCommand } from "./Layers.js"
@@ -60,6 +61,19 @@ const recognizeMove = (repo: InMemRepo, script: string): FlowScriptResult | unde
60
61
  return ok()
61
62
  }
62
63
 
64
+ const recognizeWrite = (repo: InMemRepo, script: string): FlowScriptResult | undefined => {
65
+ const lines = shellLines(script.split("\n"))
66
+ if (!lines[3]?.startsWith("printf '%s' ")) return undefined
67
+ const [path] = words(lines[2] ?? "")
68
+ const [, content, to] = words(lines[3])
69
+ const remove = words(lines.find((l) => l.startsWith("rm -rf -- ")) ?? "")
70
+ if (path === undefined || content === undefined || to !== path) return undefined
71
+ if (writeScript(path, content, remove) !== script) return undefined
72
+ repo.writeFile(path, content)
73
+ removeAll(repo, remove)
74
+ return ok()
75
+ }
76
+
63
77
  const recognizeShow = (repo: InMemRepo, script: string): FlowScriptResult | undefined => {
64
78
  const line = script.split("\n").find((l) => l.startsWith("git show ")) ?? ""
65
79
  const [source, to] = words(line)
@@ -356,6 +370,7 @@ export const applyFlowScript = (
356
370
  recognizeRemove(repo, script) ??
357
371
  recognizeMove(repo, script) ??
358
372
  recognizeShow(repo, script) ??
373
+ recognizeWrite(repo, script) ??
359
374
  recognizePatch(repo, script) ??
360
375
  recognizeRestore(repo, script) ??
361
376
  recognizeCheck(repo, commands, script)
@@ -127,6 +127,19 @@ Given("the file {string} is deleted", (world: TestkitWorld, path: string) => {
127
127
  world.repo().remove(path)
128
128
  })
129
129
 
130
+ // A human's edit in place, so a scenario need not restate a file gtd wrote.
131
+ Given(
132
+ "the text {string} in {string} is replaced with {string}",
133
+ (world: TestkitWorld, text: string, path: string, replacement: string) => {
134
+ const content = world.repo().read(path) ?? ""
135
+ assert.ok(content.includes(text), `${path}: ${content}`)
136
+ world.repo().write(
137
+ path,
138
+ content.replace(text, () => replacement),
139
+ )
140
+ },
141
+ )
142
+
130
143
  Given("the working tree is committed as {string}", (world: TestkitWorld, message: string) => {
131
144
  world.repo().commit(message)
132
145
  })
@@ -254,6 +267,10 @@ Then(
254
267
  },
255
268
  )
256
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
+
257
274
  Then(
258
275
  "driving the process is refused with {string}",
259
276
  async (world: TestkitWorld, message: string) => {
@@ -341,6 +358,20 @@ Then("{string} contains {string}", (world: TestkitWorld, path: string, text: str
341
358
  assert.ok(world.repo().read(path)?.includes(text), `${path}: ${world.repo().read(path)}`)
342
359
  })
343
360
 
361
+ Then("{string} contains:", (world: TestkitWorld, path: string, text: string) => {
362
+ assert.ok(world.repo().read(path)?.includes(text), `${path}: ${world.repo().read(path)}`)
363
+ })
364
+
365
+ Then(
366
+ "{string} contains the short hash of the commit {string}",
367
+ (world: TestkitWorld, path: string, subject: string) => {
368
+ const repo = world.repo()
369
+ const hash = repo.hashes()[repo.subjects().lastIndexOf(subject)]
370
+ assert.ok(hash !== undefined, `no commit "${subject}"`)
371
+ assert.ok(repo.read(path)?.includes(hash.slice(0, 7)), `${path}: ${repo.read(path)}`)
372
+ },
373
+ )
374
+
344
375
  Then("{string} does not contain {string}", (world: TestkitWorld, path: string, text: string) => {
345
376
  const content = world.repo().read(path)
346
377
  assert.ok(content !== undefined && !content.includes(text), `${path}: ${content}`)