@pmelab/gtd 51.0.0 → 52.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 (66) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +41 -38
  3. package/dist/chunks/{reserved-CUkk9XgL.mjs → reserved-D0MKVFFa.mjs} +28 -34
  4. package/dist/chunks/{review-BaCXlIT2.mjs → review-ZzUQH1KZ.mjs} +11 -9
  5. package/dist/chunks/{steering-DtUVUz9l.mjs → steering-CLhu8C7q.mjs} +262 -52
  6. package/dist/chunks/{tail-CX6Pdsql.mjs → tail-I1-vVq0P.mjs} +1 -1
  7. package/dist/gtd.bundle.mjs +224 -68
  8. package/dist/steering.mjs +2 -2
  9. package/dist/testkit.mjs +27 -26
  10. package/dist/web/main.css +1 -1
  11. package/dist/web/main.js +510 -147
  12. package/dist/workflow-kit.mjs +1 -1
  13. package/dist/workflows/cleanup.mjs +2 -2
  14. package/dist/workflows/feature.mjs +181 -222
  15. package/dist/workflows/fix.mjs +3 -3
  16. package/dist/workflows/review.mjs +3 -3
  17. package/dist/workflows/rollout.mjs +1 -1
  18. package/dist/workflows.mjs +478 -313
  19. package/package.json +1 -1
  20. package/src/edge/Edge.ts +1 -30
  21. package/src/replay/Replay.test.ts +4 -8
  22. package/src/replay/Replay.ts +35 -3
  23. package/src/replay/index.ts +7 -1
  24. package/src/steering/FORMATS.md +4 -1
  25. package/src/steering/SteeringFormat.ts +7 -1
  26. package/src/steering/contract.test.ts +2 -1
  27. package/src/steering/fixtures/review/sectioned.md +31 -0
  28. package/src/steering/fixtures/review/sectioned.read.json +119 -0
  29. package/src/steering/index.ts +4 -0
  30. package/src/steering/qa.ts +1 -1
  31. package/src/steering/review.test.ts +270 -4
  32. package/src/steering/review.ts +328 -69
  33. package/src/workflows/feature/criteria.test.ts +39 -1
  34. package/src/workflows/feature/criteria.ts +21 -7
  35. package/src/workflows/feature/fast-path.feature +1 -6
  36. package/src/workflows/feature/feature.ts +1 -2
  37. package/src/workflows/feature/flag-mode.feature +1 -6
  38. package/src/workflows/feature/green.ts +22 -0
  39. package/src/workflows/feature/hands-on-skip.feature +344 -0
  40. package/src/workflows/feature/lens-models.feature +0 -4
  41. package/src/workflows/feature/lens-review.feature +6 -8
  42. package/src/workflows/feature/one-step-land.feature +1 -6
  43. package/src/workflows/feature/prototype-rounds.feature +1 -1
  44. package/src/workflows/feature/prototype.feature +10 -9
  45. package/src/workflows/feature/prototype.ts +48 -17
  46. package/src/workflows/feature/refactor-absorbs.feature +3 -8
  47. package/src/workflows/feature/refactor-first.feature +3 -8
  48. package/src/workflows/feature/refactor-rebuild.feature +7 -12
  49. package/src/workflows/feature/refactor-review.feature +52 -72
  50. package/src/workflows/feature/review-answers.feature +90 -5
  51. package/src/workflows/feature/review-gate.feature +11 -53
  52. package/src/workflows/feature/reviewer-walkthrough.feature +29 -12
  53. package/src/workflows/feature/seam-rejection.feature +1 -6
  54. package/src/workflows/feature/seam.feature +1 -6
  55. package/src/workflows/feature/seam.test.ts +1 -1
  56. package/src/workflows/feature/seam.ts +2 -3
  57. package/src/workflows/feature/severity-budget.feature +7 -7
  58. package/src/workflows/feature/severity-sorted-review.feature +50 -54
  59. package/src/workflows/feature/slice-findings.feature +5 -15
  60. package/src/workflows/feature/slicing.feature +4 -24
  61. package/src/workflows/feature/standards.feature +0 -4
  62. package/src/workflows/shared/review.ts +26 -6
  63. package/src/workflows/shared/text.test.ts +45 -11
  64. package/src/workflows/shared/text.ts +45 -38
  65. package/src/workflows/feature/coverage.feature +0 -165
  66. package/src/workflows/feature/coverage.ts +0 -120
@@ -35,7 +35,7 @@ import {
35
35
  toLspPositionFromOffset,
36
36
  type HeadingBlock,
37
37
  } from "./MarkdownTree.js"
38
- import { FREE_TEXT_PLACEHOLDER } from "./qa.js"
38
+ import { FREE_TEXT_PLACEHOLDER, normalizeFreeTextAnswer } from "./qa.js"
39
39
  import type {
40
40
  SteeringAnchor,
41
41
  SteeringAnnotateResult,
@@ -79,6 +79,9 @@ interface Changeset {
79
79
  readonly answers?: readonly AnswerOption[]
80
80
  /** The tier and reason gtd ranked the chunk with, from its leading severity line. */
81
81
  readonly severity?: { readonly label: string; readonly line: number }
82
+ readonly section?: ReviewSection
83
+ /** 0-based line of the `Tick exactly one:` lead, when the chunk has answer boxes. */
84
+ readonly answerLine?: number
82
85
  }
83
86
 
84
87
  interface AnswerOption {
@@ -553,6 +556,7 @@ const parseChunkBody = (
553
556
  lines: readonly string[],
554
557
  title: string,
555
558
  body: readonly RootContent[],
559
+ answerable: boolean,
556
560
  ): {
557
561
  readonly description: string
558
562
  readonly descriptionNodes: readonly SteeringViewNode[]
@@ -560,11 +564,13 @@ const parseChunkBody = (
560
564
  readonly files: readonly ReviewFile[]
561
565
  readonly errors: readonly SteeringFinding[]
562
566
  readonly answers?: readonly AnswerOption[]
567
+ readonly answerLine?: number
563
568
  readonly severity?: { readonly label: string; readonly line: number } | undefined
564
569
  /** Whether a task box outside the answer list exists. */
565
570
  readonly boxed: boolean
566
571
  } => {
567
- const answer = answerListOf(content, body)
572
+ // A walkthrough chunk takes no decision: boxes there are not answers.
573
+ const answer = answerable ? answerListOf(content, body) : undefined
568
574
  const isAnswer = (n: RootContent): boolean =>
569
575
  answer !== undefined && (n === answer.list || n === body[answer.lead])
570
576
  const firstPointerIndex = body.findIndex(
@@ -596,40 +602,81 @@ const parseChunkBody = (
596
602
  descriptionInline,
597
603
  files,
598
604
  errors,
599
- ...(answer === undefined ? {} : { answers: answer.options }),
605
+ ...(answer === undefined
606
+ ? {}
607
+ : {
608
+ answers: answer.options,
609
+ answerLine: toLspPosition(body[answer.lead]!.position!.start).line,
610
+ }),
600
611
  ...(severity === undefined ? {} : { severity }),
601
612
  boxed: body.some((n) => !isAnswer(n) && taskItems(n).length > 0),
602
613
  }
603
614
  }
604
615
 
605
- /** One `##` chunk heading node with its raw body block nodes (up to the next `##`-or-shallower heading). */
616
+ export type ReviewSection = "findings" | "walkthrough"
617
+
618
+ /** The `## ` headings gtd groups the review's chunks under; any other `## ` is a chunk of its own. */
619
+ export const REVIEW_SECTIONS: Readonly<Record<ReviewSection, string>> = {
620
+ findings: "Findings",
621
+ walkthrough: "Walkthrough",
622
+ }
623
+
624
+ const sectionOf = (content: string, node: RootContent): ReviewSection | undefined => {
625
+ if (node.type !== "heading" || node.depth !== 2) return undefined
626
+ const text = headingText(content, node)
627
+ return (Object.keys(REVIEW_SECTIONS) as ReviewSection[]).find((s) => REVIEW_SECTIONS[s] === text)
628
+ }
629
+
630
+ /** One chunk heading node with its raw body block nodes (up to the next heading as deep or shallower). */
606
631
  interface ChunkBlock extends HeadingBlock {
607
632
  readonly headingLine: number
633
+ readonly section?: ReviewSection
608
634
  }
609
635
 
610
- /** Splits the tree's top-level nodes into consecutive `##` chunk blocks — a `## ` line found only inside a fence never becomes a heading node at all, so it's never mistaken for a chunk. */
611
- const splitChunks = (tree: Root): readonly ChunkBlock[] => {
612
- const blocks: ChunkBlock[] = []
613
- let i = 0
614
- while (i < tree.children.length) {
615
- const node = tree.children[i]!
616
- if (node.type !== "heading" || node.depth !== 2) {
617
- i += 1
618
- continue
619
- }
620
- const heading = node
621
- const headingLine = toLspPosition(heading.position!.start).line
622
- i += 1
623
- const body: RootContent[] = []
624
- while (i < tree.children.length) {
625
- const next = tree.children[i]!
626
- if (next.type === "heading" && next.depth <= 2) break
627
- body.push(next)
628
- i += 1
636
+ interface SectionBlock extends HeadingBlock {
637
+ readonly section: ReviewSection
638
+ readonly headingLine: number
639
+ readonly body: RootContent[]
640
+ }
641
+
642
+ /** The nodes after `from` up to the next heading at `depth` or shallower. */
643
+ const bodyFrom = (children: readonly RootContent[], from: number, depth: number): RootContent[] => {
644
+ const end = children.findIndex((n, i) => i >= from && n.type === "heading" && n.depth <= depth)
645
+ return children.slice(from, end === -1 ? undefined : end)
646
+ }
647
+
648
+ /**
649
+ * Splits the tree's top-level nodes into chunk blocks — a `## ` line found only
650
+ * inside a fence never becomes a heading node at all. A document with a
651
+ * `## Findings` or `## Walkthrough` heading nests `### ` chunks under them;
652
+ * one without is the flat layout, every `## ` a chunk.
653
+ */
654
+ const splitChunks = (
655
+ tree: Root,
656
+ content: string,
657
+ ): { readonly chunks: readonly ChunkBlock[]; readonly sections: readonly SectionBlock[] } => {
658
+ const sectioned = tree.children.some((n) => sectionOf(content, n) !== undefined)
659
+ const chunks: ChunkBlock[] = []
660
+ const sections: SectionBlock[] = []
661
+ let section: SectionBlock | undefined
662
+ tree.children.forEach((node, i) => {
663
+ if (node.type !== "heading") return
664
+ const headingLine = toLspPosition(node.position!.start).line
665
+ if (sectioned && node.depth <= 2) {
666
+ const s = sectionOf(content, node)
667
+ section = s === undefined ? undefined : { heading: node, headingLine, section: s, body: [] }
668
+ if (section !== undefined) {
669
+ sections.push(section)
670
+ return
671
+ }
629
672
  }
630
- blocks.push({ heading, headingLine, body })
631
- }
632
- return blocks
673
+ const depth = section === undefined ? 2 : 3
674
+ if (node.depth !== depth) return
675
+ const body = bodyFrom(tree.children, i + 1, depth)
676
+ chunks.push({ heading: node, headingLine, body, ...(section && { section: section.section }) })
677
+ section?.body.push(node, ...body)
678
+ })
679
+ return { chunks, sections }
633
680
  }
634
681
 
635
682
  const parseChangesets = (
@@ -639,7 +686,7 @@ const parseChangesets = (
639
686
  ): { readonly changesets: readonly Changeset[]; readonly errors: readonly SteeringFinding[] } => {
640
687
  const changesets: Changeset[] = []
641
688
  const errors: SteeringFinding[] = []
642
- for (const { heading, headingLine, body } of splitChunks(tree)) {
689
+ for (const { heading, headingLine, body, section } of splitChunks(tree, content).chunks) {
643
690
  const title = headingText(content, heading)
644
691
  const {
645
692
  description,
@@ -647,10 +694,11 @@ const parseChangesets = (
647
694
  descriptionInline,
648
695
  files,
649
696
  answers,
697
+ answerLine,
650
698
  severity,
651
699
  boxed,
652
700
  errors: bodyErrors,
653
- } = parseChunkBody(content, lines, title, body)
701
+ } = parseChunkBody(content, lines, title, body, section !== "walkthrough")
654
702
  errors.push(...bodyErrors)
655
703
  // A prose-only chunk is a note gtd composed; a box with no pointer is a pointer gone wrong.
656
704
  if (files.length === 0 && boxed) {
@@ -667,8 +715,9 @@ const parseChangesets = (
667
715
  descriptionInline,
668
716
  files,
669
717
  headingLine,
670
- ...(answers === undefined ? {} : { answers }),
718
+ ...(answers === undefined ? {} : { answers, answerLine: answerLine! }),
671
719
  ...(severity === undefined ? {} : { severity }),
720
+ ...(section === undefined ? {} : { section }),
672
721
  })
673
722
  }
674
723
  if (changesets.length === 0) errors.push({ message: "REVIEW.md has no '##' chunks" })
@@ -754,7 +803,9 @@ const isHunkPointerItem = (content: string, item: ListItem): boolean => {
754
803
  const reviewClearTicks = (content: string): string => {
755
804
  const tree = parseMarkdown(content)
756
805
  const answers = new Set(
757
- splitChunks(tree).flatMap(({ body }) => answerListOf(content, body)?.list.children ?? []),
806
+ splitChunks(tree, content).chunks.flatMap(
807
+ ({ body }) => answerListOf(content, body)?.list.children ?? [],
808
+ ),
758
809
  )
759
810
  const offsets = taskItems(tree)
760
811
  .filter(
@@ -798,51 +849,102 @@ const toggleFilePointer = (content: string, line: number): SteeringEdit | undefi
798
849
  const chunkEndLines = (content: string): ReadonlyMap<number, number> => {
799
850
  const tree = parseMarkdown(content)
800
851
  const map = new Map<number, number>()
801
- for (const block of splitChunks(tree)) map.set(block.headingLine, blockEndLine(block))
852
+ for (const block of splitChunks(tree, content).chunks)
853
+ map.set(block.headingLine, blockEndLine(block))
802
854
  return map
803
855
  }
804
856
 
857
+ const CHOICE_NAMES: Readonly<Record<ReviewChoice, string>> = {
858
+ fix: ANSWER_FIX,
859
+ wontfix: ANSWER_WONT_FIX,
860
+ answer: "",
861
+ }
862
+
863
+ /** A finding's decision as an outline node of its own; none for a chunk with no answer boxes. */
864
+ const decisionNodes = (
865
+ lines: readonly string[],
866
+ chunk: Changeset,
867
+ end: number,
868
+ ): SteeringOutlineNode[] => {
869
+ if (chunk.answerLine === undefined) return []
870
+ const d = decisionOf(chunk)
871
+ return [
872
+ {
873
+ name: `Decision: ${d === undefined ? "open" : CHOICE_NAMES[d.choice] || d.text}`,
874
+ range: spanRange(lines, chunk.answerLine, Math.max(chunk.answerLine, end)),
875
+ selectionRange: lineRange(lines, chunk.answerLine),
876
+ },
877
+ ]
878
+ }
879
+
880
+ const isOpenChunk = (chunk: Changeset, markers: number): boolean =>
881
+ chunk.files.some((file) => !file.checked) ||
882
+ markers > 0 ||
883
+ (chunk.answers !== undefined && decisionOf(chunk) === undefined)
884
+
805
885
  /**
806
- * Document outline for `.gtd/REVIEW.md`: the headlines of chunks (the
807
- * user-facing "work packages") that still carry at least one unchecked hunk
808
- * — the outline is the list of packages left to review — PLUS any chunk
809
- * that carries at least one footnote, even when every hunk in it is ticked:
810
- * `reviewOutline` otherwise drops a fully-checked chunk, which would make a
811
- * human's own comment vanish the moment they tick the last box. A chunk's
812
- * footnotes are `leaf: true` children; a chunk with none has no children.
886
+ * Document outline for `.gtd/REVIEW.md`: the chunks still left to review — an
887
+ * unchecked hunk, a footnote (so a human's comment never vanishes with the
888
+ * last tick) or an open decision. A finding's decision is a node of its own.
889
+ * In the sectioned layout `Findings` and `Walkthrough` are containers, and
890
+ * every other `## ` (`Fixed automatically`, …) stays listed.
813
891
  */
814
892
  const reviewOutline = (content: string): readonly SteeringOutlineNode[] => {
815
893
  const { changesets } = parseReviewDoc(content)
894
+ const { sections } = splitChunks(parseMarkdown(content), content)
816
895
  const { markers, definitions } = parseFootnotes(content)
817
896
  const definitionByName = new Map(definitions.map((d) => [d.name, d.body]))
818
897
  const lines = content.split(/\r?\n/)
819
898
  const endLines = chunkEndLines(content)
820
899
  const threads = threadOutlineNodes(content)
821
900
  const nested = new Set<SteeringOutlineNode>()
822
- const nodes = changesets
823
- .map((chunk) => {
824
- const start = chunk.headingLine
825
- const end = Math.max(start, endLines.get(start) ?? start)
826
- const checkedCount = chunk.files.filter((file) => file.checked).length
827
- const chunkMarkers = markers.filter((m) => m.line >= start && m.line <= end)
828
- const chunkThreads = threads
829
- .filter((t) => t.markerLine !== undefined && t.markerLine >= start && t.markerLine <= end)
830
- .map((t) => t.node)
831
- chunkThreads.forEach((n) => nested.add(n))
832
- const children = [
833
- ...chunkMarkers.map((m) => footnoteLeaf(lines, definitionByName, m)),
834
- ...chunkThreads,
835
- ]
836
- return {
837
- name: `${chunk.title} (${checkedCount}/${chunk.files.length})`,
838
- range: spanRange(lines, start, end),
839
- selectionRange: lineRange(lines, start),
840
- ...(children.length > 0 ? { children } : {}),
841
- unchecked: checkedCount < chunk.files.length || chunkMarkers.length > 0,
842
- }
843
- })
844
- .filter((node) => node.unchecked)
845
- .map(({ unchecked: _unchecked, ...node }) => node)
901
+ const chunkNodes = changesets.flatMap((chunk) => {
902
+ const start = chunk.headingLine
903
+ const end = Math.max(start, endLines.get(start) ?? start)
904
+ const checkedCount = chunk.files.filter((file) => file.checked).length
905
+ const chunkMarkers = markers.filter((m) => m.line >= start && m.line <= end)
906
+ const chunkThreads = threads
907
+ .filter((t) => t.markerLine !== undefined && t.markerLine >= start && t.markerLine <= end)
908
+ .map((t) => t.node)
909
+ chunkThreads.forEach((n) => nested.add(n))
910
+ const children = [
911
+ ...decisionNodes(lines, chunk, end),
912
+ ...chunkMarkers.map((m) => footnoteLeaf(lines, definitionByName, m)),
913
+ ...chunkThreads,
914
+ ]
915
+ // In the sectioned layout every other `## ` is a section of its own, always listed.
916
+ const listed = sections.length > 0 && chunk.section === undefined
917
+ if (!listed && !isOpenChunk(chunk, chunkMarkers.length)) return []
918
+ const node: SteeringOutlineNode = {
919
+ name:
920
+ chunk.files.length === 0
921
+ ? chunk.title
922
+ : `${chunk.title} (${checkedCount}/${chunk.files.length})`,
923
+ range: spanRange(lines, start, end),
924
+ selectionRange: lineRange(lines, start),
925
+ ...(children.length > 0 ? { children } : {}),
926
+ }
927
+ return [{ line: start, section: chunk.section, node }]
928
+ })
929
+ const containers = sections.map((section) => ({
930
+ line: section.headingLine,
931
+ section: undefined,
932
+ node: {
933
+ name: REVIEW_SECTIONS[section.section],
934
+ range: spanRange(lines, section.headingLine, blockEndLine(section)),
935
+ selectionRange: lineRange(lines, section.headingLine),
936
+ children: chunkNodes
937
+ .filter((c) => c.section === section.section && c.line > section.headingLine)
938
+ .filter((c) => {
939
+ const next = sections.find((o) => o.headingLine > section.headingLine)
940
+ return next === undefined || c.line < next.headingLine
941
+ })
942
+ .map((c) => c.node),
943
+ } satisfies SteeringOutlineNode,
944
+ }))
945
+ const nodes = [...chunkNodes.filter((c) => c.section === undefined), ...containers]
946
+ .sort((a, b) => a.line - b.line)
947
+ .map((c) => c.node)
846
948
  return [...nodes, ...threads.map((t) => t.node).filter((n) => !nested.has(n))]
847
949
  }
848
950
 
@@ -902,6 +1004,7 @@ const reviewActions: SteeringFormat["actions"] = (content, range) => {
902
1004
  const tree = parseMarkdown(content)
903
1005
  const lines = content.split(/\r?\n/)
904
1006
  const cursorLine = range.start.line
1007
+ const endLines = chunkEndLines(content)
905
1008
  const actions: Array<{ readonly title: string; readonly edits: readonly SteeringEdit[] }> = [
906
1009
  ...threadReplyActions(content, range.start),
907
1010
  ]
@@ -921,10 +1024,11 @@ const reviewActions: SteeringFormat["actions"] = (content, range) => {
921
1024
  }
922
1025
  }
923
1026
 
924
- const chunkEnd = Math.max(
925
- chunk.headingLine,
926
- (changesets[i + 1]?.headingLine ?? lines.length) - 1,
927
- )
1027
+ // A sectioned chunk ends at its own last block, never at the next section's heading.
1028
+ const chunkEnd =
1029
+ chunk.section === undefined
1030
+ ? Math.max(chunk.headingLine, (changesets[i + 1]?.headingLine ?? lines.length) - 1)
1031
+ : (endLines.get(chunk.headingLine) ?? chunk.headingLine)
928
1032
  if (chunk.files.length > 0 && cursorLine >= chunk.headingLine && cursorLine <= chunkEnd) {
929
1033
  const edits = toggleChunkEdits(content, chunk.headingLine)
930
1034
  if (edits.length > 0) {
@@ -1008,7 +1112,7 @@ const hunkLinkFor = (content: string, item: ListItem): SteeringLink | undefined
1008
1112
  const reviewDocumentLinks = (content: string): readonly SteeringLink[] => {
1009
1113
  const tree = parseMarkdown(content)
1010
1114
  const links: SteeringLink[] = []
1011
- for (const block of splitChunks(tree)) {
1115
+ for (const block of splitChunks(tree, content).chunks) {
1012
1116
  for (const item of block.body.flatMap((n) => taskItems(n))) {
1013
1117
  const link = hunkLinkFor(content, item)
1014
1118
  if (link) links.push(link)
@@ -1030,6 +1134,17 @@ const reviewView = (content: string): SteeringView => {
1030
1134
  body: chunk.descriptionNodes,
1031
1135
  ...(chunk.severity === undefined ? {} : { status: chunk.severity.label }),
1032
1136
  anchor: { kind: "chunk", index: chunkIndex },
1137
+ ...(chunk.section === undefined ? {} : { group: REVIEW_SECTIONS[chunk.section] }),
1138
+ ...(chunk.answers === undefined
1139
+ ? {}
1140
+ : {
1141
+ answered: decisionOf(chunk) !== undefined,
1142
+ decision: chunk.answers.map((option, index) => ({
1143
+ title: option.text,
1144
+ checked: option.checked,
1145
+ anchor: { kind: "option" as const, questionIndex: chunkIndex, index },
1146
+ })),
1147
+ }),
1033
1148
  ...noteAt(chunk.headingLine),
1034
1149
  children: chunk.files.map((file, index) => ({
1035
1150
  title: pointerDisplay(file),
@@ -1158,8 +1273,68 @@ const setFileTickEdits = (
1158
1273
  return edits
1159
1274
  }
1160
1275
 
1161
- /** A `hunk` anchor sets one tick; a `chunk` anchor sets every hunk beneath it to the SAME target state — never `toggleChunkEdits`'s majority-flip heuristic, since the caller already knows the state it wants. */
1276
+ /**
1277
+ * A finding's answer boxes are radio: ticking one unticks the others, and
1278
+ * `text` replaces the ticked box's label in the same edit set — the phone's
1279
+ * free-text answer, normalized as `qa`'s is.
1280
+ */
1281
+ const decisionEdits = (
1282
+ content: string,
1283
+ chunkIndex: number,
1284
+ index: number,
1285
+ opts: { readonly checked?: boolean; readonly text?: string },
1286
+ ): SteeringAnnotateResult => {
1287
+ const items = answerItemsOf(content, chunkIndex)
1288
+ const target = items[index]
1289
+ if (target === undefined) return { ok: false, reason: "anchor-not-found" }
1290
+ const checked = opts.checked ?? true
1291
+ const ticks = items.flatMap((item) => {
1292
+ const want = item === target ? checked : !checked && item.checked === true
1293
+ return (item.checked === true) === want ? [] : tickEdit(content, item, want)
1294
+ })
1295
+ const text = opts.text === undefined ? [] : labelEdit(content, target, opts.text)
1296
+ return { ok: true, edits: [...ticks, ...text] }
1297
+ }
1298
+
1299
+ /** A finding's answer boxes; none for a walkthrough chunk or a stale index. */
1300
+ const answerItemsOf = (content: string, chunkIndex: number): readonly ListItem[] => {
1301
+ const chunk = splitChunks(parseMarkdown(content), content).chunks[chunkIndex]
1302
+ if (chunk === undefined || chunk.section === "walkthrough") return []
1303
+ return answerListOf(content, chunk.body)?.list.children ?? []
1304
+ }
1305
+
1306
+ const tickEdit = (content: string, item: ListItem, checked: boolean): SteeringEdit[] => {
1307
+ const offset = checkboxOffset(content, item)
1308
+ if (offset === undefined) return []
1309
+ const start = toLspPositionFromOffset(content, offset)
1310
+ return [
1311
+ {
1312
+ range: { start, end: { ...start, character: start.character + 1 } },
1313
+ newText: checked ? "x" : " ",
1314
+ },
1315
+ ]
1316
+ }
1317
+
1318
+ /** Replaces a box's label, from after its marker to the end of its first paragraph. */
1319
+ const labelEdit = (content: string, item: ListItem, text: string): SteeringEdit[] => {
1320
+ const from = firstParagraphContentOffset(item)
1321
+ const to = item.children.find((c) => c.type === "paragraph")?.position?.end.offset
1322
+ if (from === undefined || to === undefined) return []
1323
+ return [
1324
+ {
1325
+ range: {
1326
+ start: toLspPositionFromOffset(content, from),
1327
+ end: toLspPositionFromOffset(content, to),
1328
+ },
1329
+ newText: normalizeFreeTextAnswer(text),
1330
+ },
1331
+ ]
1332
+ }
1333
+
1334
+ /** A `hunk` anchor sets one tick; a `chunk` anchor sets every hunk beneath it to the SAME target state — never `toggleChunkEdits`'s majority-flip heuristic, since the caller already knows the state it wants. An `option` anchor is a finding's decision. */
1162
1335
  const reviewApply: SteeringFormat["apply"] = (content, anchor, opts) => {
1336
+ if (anchor.kind === "option")
1337
+ return decisionEdits(content, anchor.questionIndex, anchor.index, opts)
1163
1338
  const { changesets } = parseReviewDoc(content)
1164
1339
  const checked = opts.checked ?? true
1165
1340
  if (anchor.kind === "hunk") {
@@ -1383,7 +1558,7 @@ export const reviewDecisions = (before: string, after: string): readonly ReviewD
1383
1558
  })
1384
1559
  }
1385
1560
 
1386
- /** Each `##` chunk in document order: its title, 0-based heading line and how many hunk pointers it holds. */
1561
+ /** Each chunk in document order: its title, 0-based heading line and how many hunk pointers it holds. */
1387
1562
  export const reviewChunks = (
1388
1563
  content: string,
1389
1564
  ): readonly {
@@ -1392,12 +1567,14 @@ export const reviewChunks = (
1392
1567
  pointers: number
1393
1568
  /** 0-based line of the chunk's severity line, when it has one. */
1394
1569
  severityLine?: number
1570
+ section?: ReviewSection
1395
1571
  files: readonly { path: string; line?: number; rangeEnd?: number }[]
1396
1572
  }[] =>
1397
1573
  parseReviewDoc(content).changesets.map((c) => ({
1398
1574
  title: c.title,
1399
1575
  line: c.headingLine,
1400
1576
  pointers: c.files.length,
1577
+ ...(c.section === undefined ? {} : { section: c.section }),
1401
1578
  ...(c.severity === undefined ? {} : { severityLine: c.severity.line }),
1402
1579
  files: c.files.map((f) => ({
1403
1580
  path: f.path,
@@ -1405,3 +1582,85 @@ export const reviewChunks = (
1405
1582
  ...(f.rangeEnd === undefined ? {} : { rangeEnd: f.rangeEnd }),
1406
1583
  })),
1407
1584
  }))
1585
+
1586
+ /** The `## Findings` / `## Walkthrough` headings in `content`, with their 0-based lines. */
1587
+ export const reviewSections = (
1588
+ content: string,
1589
+ ): readonly { readonly section: ReviewSection; readonly line: number }[] =>
1590
+ splitChunks(parseMarkdown(content), content).sections.map((s) => ({
1591
+ section: s.section,
1592
+ line: s.headingLine,
1593
+ }))
1594
+
1595
+ const isBlankLine = (line: string | undefined): boolean => line !== undefined && line.trim() === ""
1596
+
1597
+ /** How many lines a chunk's heading takes: two for a setext heading and its underline. */
1598
+ const headingSize = ({ heading, headingLine }: ChunkBlock): number =>
1599
+ toLspPosition(heading.position!.end).line - headingLine + 1
1600
+
1601
+ /** \`block\` with its heading an ATX \`### \` on one line, and every heading in its body one level deeper. */
1602
+ const demoted = (chunk: ChunkBlock, block: readonly string[]): string[] => {
1603
+ const out = [...block]
1604
+ for (const node of chunk.body) {
1605
+ if (node.type !== "heading" || node.depth < 3 || node.depth > 5) continue
1606
+ const at = toLspPosition(node.position!.start).line - chunk.headingLine
1607
+ out[at] = out[at]!.replace(/^( {0,3})#/, "$1##")
1608
+ }
1609
+ const size = headingSize(chunk)
1610
+ const title = size > 1 ? `### ${out[0]!.trim()}` : out[0]!.replace(/^ {0,3}##/, "###")
1611
+ return [title, ...out.slice(size)]
1612
+ }
1613
+
1614
+ /** A chunk's \`size\` heading lines, then its body with the blank lines around it trimmed. */
1615
+ const chunkText = (block: readonly string[], size: number): string => {
1616
+ const rest = block.slice(size)
1617
+ while (rest.length > 0 && isBlankLine(rest[0])) rest.shift()
1618
+ while (rest.length > 0 && isBlankLine(rest.at(-1))) rest.pop()
1619
+ const heading = block.slice(0, size).join("\n")
1620
+ return rest.length === 0 ? heading : `${heading}\n\n${rest.join("\n")}`
1621
+ }
1622
+
1623
+ /**
1624
+ * A flat review regrouped: every chunk with answer boxes under `## Findings`
1625
+ * and every chunk titled in `walkthrough` under `## Walkthrough`, each in its
1626
+ * current order and one heading level deeper. Any other chunk stays a `## ` of
1627
+ * its own, before the sections when it came before them, after otherwise. An
1628
+ * already sectioned review comes back unchanged.
1629
+ */
1630
+ export const sectionedReview = (content: string, walkthrough: ReadonlySet<string>): string => {
1631
+ const tree = parseMarkdown(content)
1632
+ const { chunks, sections } = splitChunks(tree, content)
1633
+ if (sections.length > 0 || chunks.length === 0) return content
1634
+ const { changesets } = parseReviewDoc(content)
1635
+ const lines = content.split("\n")
1636
+ const sectionOfChunk = (i: number): ReviewSection | undefined =>
1637
+ changesets[i]!.answers !== undefined
1638
+ ? "findings"
1639
+ : walkthrough.has(changesets[i]!.title)
1640
+ ? "walkthrough"
1641
+ : undefined
1642
+ const text = (i: number, demote: boolean): string => {
1643
+ const chunk = chunks[i]!
1644
+ const block = lines.slice(chunk.headingLine, chunks[i + 1]?.headingLine ?? lines.length)
1645
+ return chunkText(demote ? demoted(chunk, block) : block, demote ? 1 : headingSize(chunk))
1646
+ }
1647
+ const grouped = chunks.map((_, i) => sectionOfChunk(i))
1648
+ const first = grouped.findIndex((g) => g !== undefined)
1649
+ if (first === -1) return content
1650
+ const others = (keep: (i: number) => boolean): string[] =>
1651
+ chunks.flatMap((_, i) => (grouped[i] === undefined && keep(i) ? [text(i, false)] : []))
1652
+ const section = (s: ReviewSection): string[] => {
1653
+ const members = chunks.flatMap((_, i) => (grouped[i] === s ? [text(i, true)] : []))
1654
+ return members.length === 0 ? [] : [`## ${REVIEW_SECTIONS[s]}`, ...members]
1655
+ }
1656
+ const head = lines.slice(0, chunks[0]!.headingLine).join("\n").trim()
1657
+ return `${[
1658
+ head,
1659
+ ...others((i) => i < first),
1660
+ ...section("findings"),
1661
+ ...section("walkthrough"),
1662
+ ...others((i) => i > first),
1663
+ ]
1664
+ .filter((p) => p !== "")
1665
+ .join("\n\n")}\n`
1666
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import type { Change } from "../../flows/index.js"
3
- import { criteriaOf, criterionIds, statedCriteria, unstableIds } from "./criteria.js"
3
+ import { criteriaOf, criterionIds, handsOnIds, statedCriteria, unstableIds } from "./criteria.js"
4
4
 
5
5
  const added = (path: string, after: string): Change => ({
6
6
  path,
@@ -48,6 +48,17 @@ describe("criteriaOf", () => {
48
48
  ).toEqual([{ id: "AC-1", text: "exits 2 when config is bad - missing file" }])
49
49
  })
50
50
 
51
+ it("reads the (no hands-on) tag between an ID and its colon", () => {
52
+ expect(
53
+ criteriaOf(
54
+ "## Acceptance Criteria\n\n- AC-1: runs\n- AC-2 (no hands-on): the prompt says so\n",
55
+ ),
56
+ ).toEqual([
57
+ { id: "AC-1", text: "runs" },
58
+ { id: "AC-2", text: "the prompt says so", noHandsOn: true },
59
+ ])
60
+ })
61
+
51
62
  it("is undefined without the section", () => {
52
63
  expect(criteriaOf("# R\n\n- AC-1: x\n")).toBeUndefined()
53
64
  })
@@ -66,6 +77,19 @@ describe("statedCriteria", () => {
66
77
  })
67
78
  })
68
79
 
80
+ it("keeps a stated (no hands-on) tag, renumbered or not", () => {
81
+ const tagged = "## Acceptance criteria\n\n- AC-1: a\n- AC-2 (no hands-on): b\n"
82
+ expect(statedCriteria([added("TODO.md", tagged)], "abc")?.criteria).toEqual([
83
+ { id: "AC-1", text: "a" },
84
+ { id: "AC-2", text: "b", noHandsOn: true },
85
+ ])
86
+ const partial = "## Acceptance criteria\n\n- a\n- AC-7 (no hands-on): b\n"
87
+ expect(statedCriteria([added("TODO.md", partial)], "abc")?.criteria).toEqual([
88
+ { id: "AC-1", text: "a" },
89
+ { id: "AC-2", text: "b", noHandsOn: true },
90
+ ])
91
+ })
92
+
69
93
  it("renumbers when the stated IDs are partial or clash", () => {
70
94
  const sketch = [added("TODO.md", "## Acceptance criteria\n\n- AC-4: a\n- b\n")]
71
95
  expect(statedCriteria(sketch, "abc")?.criteria).toEqual([
@@ -121,6 +145,20 @@ describe("criterionIds", () => {
121
145
  })
122
146
  })
123
147
 
148
+ describe("handsOnIds", () => {
149
+ const requirements =
150
+ "## Acceptance Criteria\n\n- AC-1: a\n- AC-2 (no hands-on): b\n- AC-3: c\n- AC-4 (no hands-on): d\n"
151
+
152
+ it("leaves out every criterion tagged (no hands-on)", () => {
153
+ expect(handsOnIds(requirements)).toEqual(["AC-1", "AC-3"])
154
+ })
155
+
156
+ it("keeps to the slice", () => {
157
+ expect(handsOnIds(requirements, { only: ["AC-2", "AC-3"] })).toEqual(["AC-3"])
158
+ expect(handsOnIds(requirements, { only: ["AC-2", "AC-4"] })).toEqual([])
159
+ })
160
+ })
161
+
124
162
  describe("unstableIds", () => {
125
163
  const ids = { closed: ["AC-1", "AC-3"], used: ["AC-1", "AC-2", "AC-3"] }
126
164