@pmelab/gtd 51.0.1 → 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 (62) 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 +198 -43
  8. package/dist/steering.mjs +2 -2
  9. package/dist/testkit.mjs +1 -1
  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/steering/FORMATS.md +4 -1
  21. package/src/steering/SteeringFormat.ts +7 -1
  22. package/src/steering/contract.test.ts +2 -1
  23. package/src/steering/fixtures/review/sectioned.md +31 -0
  24. package/src/steering/fixtures/review/sectioned.read.json +119 -0
  25. package/src/steering/index.ts +4 -0
  26. package/src/steering/qa.ts +1 -1
  27. package/src/steering/review.test.ts +270 -4
  28. package/src/steering/review.ts +328 -69
  29. package/src/workflows/feature/criteria.test.ts +39 -1
  30. package/src/workflows/feature/criteria.ts +21 -7
  31. package/src/workflows/feature/fast-path.feature +1 -6
  32. package/src/workflows/feature/feature.ts +1 -2
  33. package/src/workflows/feature/flag-mode.feature +1 -6
  34. package/src/workflows/feature/green.ts +22 -0
  35. package/src/workflows/feature/hands-on-skip.feature +344 -0
  36. package/src/workflows/feature/lens-models.feature +0 -4
  37. package/src/workflows/feature/lens-review.feature +6 -8
  38. package/src/workflows/feature/one-step-land.feature +1 -6
  39. package/src/workflows/feature/prototype-rounds.feature +1 -1
  40. package/src/workflows/feature/prototype.feature +10 -9
  41. package/src/workflows/feature/prototype.ts +48 -17
  42. package/src/workflows/feature/refactor-absorbs.feature +3 -8
  43. package/src/workflows/feature/refactor-first.feature +3 -8
  44. package/src/workflows/feature/refactor-rebuild.feature +7 -12
  45. package/src/workflows/feature/refactor-review.feature +52 -72
  46. package/src/workflows/feature/review-answers.feature +90 -5
  47. package/src/workflows/feature/review-gate.feature +11 -53
  48. package/src/workflows/feature/reviewer-walkthrough.feature +29 -12
  49. package/src/workflows/feature/seam-rejection.feature +1 -6
  50. package/src/workflows/feature/seam.feature +1 -6
  51. package/src/workflows/feature/seam.test.ts +1 -1
  52. package/src/workflows/feature/seam.ts +2 -3
  53. package/src/workflows/feature/severity-budget.feature +7 -7
  54. package/src/workflows/feature/severity-sorted-review.feature +50 -54
  55. package/src/workflows/feature/slice-findings.feature +5 -15
  56. package/src/workflows/feature/slicing.feature +4 -24
  57. package/src/workflows/feature/standards.feature +0 -4
  58. package/src/workflows/shared/review.ts +26 -6
  59. package/src/workflows/shared/text.test.ts +45 -11
  60. package/src/workflows/shared/text.ts +45 -38
  61. package/src/workflows/feature/coverage.feature +0 -165
  62. package/src/workflows/feature/coverage.ts +0 -120
@@ -15396,8 +15396,8 @@ const splitSeverity = (content, run) => {
15396
15396
  };
15397
15397
  };
15398
15398
  /** 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). */
15399
- const parseChunkBody = (content, lines, title, body) => {
15400
- const answer = answerListOf(content, body);
15399
+ const parseChunkBody = (content, lines, title, body, answerable) => {
15400
+ const answer = answerable ? answerListOf(content, body) : void 0;
15401
15401
  const isAnswer = (n) => answer !== void 0 && (n === answer.list || n === body[answer.lead]);
15402
15402
  const firstPointerIndex = body.findIndex((n) => !isAnswer(n) && taskItems(n).some((item) => hasPointerToken(content, item)));
15403
15403
  const run = (firstPointerIndex === -1 ? body : body.slice(0, firstPointerIndex)).filter((n) => !isAnswer(n));
@@ -15419,45 +15419,78 @@ const parseChunkBody = (content, lines, title, body) => {
15419
15419
  descriptionInline,
15420
15420
  files,
15421
15421
  errors,
15422
- ...answer === void 0 ? {} : { answers: answer.options },
15422
+ ...answer === void 0 ? {} : {
15423
+ answers: answer.options,
15424
+ answerLine: toLspPosition(body[answer.lead].position.start).line
15425
+ },
15423
15426
  ...severity === void 0 ? {} : { severity },
15424
15427
  boxed: body.some((n) => !isAnswer(n) && taskItems(n).length > 0)
15425
15428
  };
15426
15429
  };
15427
- /** 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. */
15428
- const splitChunks = (tree) => {
15429
- const blocks = [];
15430
- let i = 0;
15431
- while (i < tree.children.length) {
15432
- const node = tree.children[i];
15433
- if (node.type !== "heading" || node.depth !== 2) {
15434
- i += 1;
15435
- continue;
15436
- }
15437
- const heading = node;
15438
- const headingLine = toLspPosition(heading.position.start).line;
15439
- i += 1;
15440
- const body = [];
15441
- while (i < tree.children.length) {
15442
- const next = tree.children[i];
15443
- if (next.type === "heading" && next.depth <= 2) break;
15444
- body.push(next);
15445
- i += 1;
15430
+ /** The `## ` headings gtd groups the review's chunks under; any other `## ` is a chunk of its own. */
15431
+ const REVIEW_SECTIONS = {
15432
+ findings: "Findings",
15433
+ walkthrough: "Walkthrough"
15434
+ };
15435
+ const sectionOf = (content, node) => {
15436
+ if (node.type !== "heading" || node.depth !== 2) return void 0;
15437
+ const text = headingText$1(content, node);
15438
+ return Object.keys(REVIEW_SECTIONS).find((s) => REVIEW_SECTIONS[s] === text);
15439
+ };
15440
+ /** The nodes after `from` up to the next heading at `depth` or shallower. */
15441
+ const bodyFrom = (children, from, depth) => {
15442
+ const end = children.findIndex((n, i) => i >= from && n.type === "heading" && n.depth <= depth);
15443
+ return children.slice(from, end === -1 ? void 0 : end);
15444
+ };
15445
+ /**
15446
+ * Splits the tree's top-level nodes into chunk blocks — a `## ` line found only
15447
+ * inside a fence never becomes a heading node at all. A document with a
15448
+ * `## Findings` or `## Walkthrough` heading nests `### ` chunks under them;
15449
+ * one without is the flat layout, every `## ` a chunk.
15450
+ */
15451
+ const splitChunks = (tree, content) => {
15452
+ const sectioned = tree.children.some((n) => sectionOf(content, n) !== void 0);
15453
+ const chunks = [];
15454
+ const sections = [];
15455
+ let section;
15456
+ tree.children.forEach((node, i) => {
15457
+ if (node.type !== "heading") return;
15458
+ const headingLine = toLspPosition(node.position.start).line;
15459
+ if (sectioned && node.depth <= 2) {
15460
+ const s = sectionOf(content, node);
15461
+ section = s === void 0 ? void 0 : {
15462
+ heading: node,
15463
+ headingLine,
15464
+ section: s,
15465
+ body: []
15466
+ };
15467
+ if (section !== void 0) {
15468
+ sections.push(section);
15469
+ return;
15470
+ }
15446
15471
  }
15447
- blocks.push({
15448
- heading,
15472
+ const depth = section === void 0 ? 2 : 3;
15473
+ if (node.depth !== depth) return;
15474
+ const body = bodyFrom(tree.children, i + 1, depth);
15475
+ chunks.push({
15476
+ heading: node,
15449
15477
  headingLine,
15450
- body
15478
+ body,
15479
+ ...section && { section: section.section }
15451
15480
  });
15452
- }
15453
- return blocks;
15481
+ section?.body.push(node, ...body);
15482
+ });
15483
+ return {
15484
+ chunks,
15485
+ sections
15486
+ };
15454
15487
  };
15455
15488
  const parseChangesets = (tree, content, lines) => {
15456
15489
  const changesets = [];
15457
15490
  const errors = [];
15458
- for (const { heading, headingLine, body } of splitChunks(tree)) {
15491
+ for (const { heading, headingLine, body, section } of splitChunks(tree, content).chunks) {
15459
15492
  const title = headingText$1(content, heading);
15460
- const { description, descriptionNodes, descriptionInline, files, answers, severity, boxed, errors: bodyErrors } = parseChunkBody(content, lines, title, body);
15493
+ const { description, descriptionNodes, descriptionInline, files, answers, answerLine, severity, boxed, errors: bodyErrors } = parseChunkBody(content, lines, title, body, section !== "walkthrough");
15461
15494
  errors.push(...bodyErrors);
15462
15495
  if (files.length === 0 && boxed) errors.push({
15463
15496
  message: `Chunk "${title}" has no file pointers`,
@@ -15471,8 +15504,12 @@ const parseChangesets = (tree, content, lines) => {
15471
15504
  descriptionInline,
15472
15505
  files,
15473
15506
  headingLine,
15474
- ...answers === void 0 ? {} : { answers },
15475
- ...severity === void 0 ? {} : { severity }
15507
+ ...answers === void 0 ? {} : {
15508
+ answers,
15509
+ answerLine
15510
+ },
15511
+ ...severity === void 0 ? {} : { severity },
15512
+ ...section === void 0 ? {} : { section }
15476
15513
  });
15477
15514
  }
15478
15515
  if (changesets.length === 0) errors.push({ message: "REVIEW.md has no '##' chunks" });
@@ -15554,7 +15591,7 @@ const isHunkPointerItem = (content, item) => {
15554
15591
  */
15555
15592
  const reviewClearTicks = (content) => {
15556
15593
  const tree = parseMarkdown(content);
15557
- const answers = new Set(splitChunks(tree).flatMap(({ body }) => answerListOf(content, body)?.list.children ?? []));
15594
+ const answers = new Set(splitChunks(tree, content).chunks.flatMap(({ body }) => answerListOf(content, body)?.list.children ?? []));
15558
15595
  const offsets = taskItems(tree).filter((item) => item.checked === true && !answers.has(item) && isHunkPointerItem(content, item)).map((item) => checkboxOffset(content, item)).filter((o) => o !== void 0).sort((a, b) => a - b);
15559
15596
  if (offsets.length === 0) return content;
15560
15597
  let result = "";
@@ -15591,42 +15628,80 @@ const toggleFilePointer = (content, line) => {
15591
15628
  const chunkEndLines = (content) => {
15592
15629
  const tree = parseMarkdown(content);
15593
15630
  const map = /* @__PURE__ */ new Map();
15594
- for (const block of splitChunks(tree)) map.set(block.headingLine, blockEndLine(block));
15631
+ for (const block of splitChunks(tree, content).chunks) map.set(block.headingLine, blockEndLine(block));
15595
15632
  return map;
15596
15633
  };
15634
+ const CHOICE_NAMES = {
15635
+ fix: ANSWER_FIX,
15636
+ wontfix: ANSWER_WONT_FIX,
15637
+ answer: ""
15638
+ };
15639
+ /** A finding's decision as an outline node of its own; none for a chunk with no answer boxes. */
15640
+ const decisionNodes = (lines, chunk, end) => {
15641
+ if (chunk.answerLine === void 0) return [];
15642
+ const d = decisionOf(chunk);
15643
+ return [{
15644
+ name: `Decision: ${d === void 0 ? "open" : CHOICE_NAMES[d.choice] || d.text}`,
15645
+ range: spanRange(lines, chunk.answerLine, Math.max(chunk.answerLine, end)),
15646
+ selectionRange: lineRange(lines, chunk.answerLine)
15647
+ }];
15648
+ };
15649
+ const isOpenChunk = (chunk, markers) => chunk.files.some((file) => !file.checked) || markers > 0 || chunk.answers !== void 0 && decisionOf(chunk) === void 0;
15597
15650
  /**
15598
- * Document outline for `.gtd/REVIEW.md`: the headlines of chunks (the
15599
- * user-facing "work packages") that still carry at least one unchecked hunk
15600
- * — the outline is the list of packages left to review — PLUS any chunk
15601
- * that carries at least one footnote, even when every hunk in it is ticked:
15602
- * `reviewOutline` otherwise drops a fully-checked chunk, which would make a
15603
- * human's own comment vanish the moment they tick the last box. A chunk's
15604
- * footnotes are `leaf: true` children; a chunk with none has no children.
15651
+ * Document outline for `.gtd/REVIEW.md`: the chunks still left to review — an
15652
+ * unchecked hunk, a footnote (so a human's comment never vanishes with the
15653
+ * last tick) or an open decision. A finding's decision is a node of its own.
15654
+ * In the sectioned layout `Findings` and `Walkthrough` are containers, and
15655
+ * every other `## ` (`Fixed automatically`, …) stays listed.
15605
15656
  */
15606
15657
  const reviewOutline = (content) => {
15607
15658
  const { changesets } = parseReviewDoc(content);
15659
+ const { sections } = splitChunks(parseMarkdown(content), content);
15608
15660
  const { markers, definitions } = parseFootnotes(content);
15609
15661
  const definitionByName = new Map(definitions.map((d) => [d.name, d.body]));
15610
15662
  const lines = content.split(/\r?\n/);
15611
15663
  const endLines = chunkEndLines(content);
15612
15664
  const threads = threadOutlineNodes(content);
15613
15665
  const nested = /* @__PURE__ */ new Set();
15614
- return [...changesets.map((chunk) => {
15666
+ const chunkNodes = changesets.flatMap((chunk) => {
15615
15667
  const start = chunk.headingLine;
15616
15668
  const end = Math.max(start, endLines.get(start) ?? start);
15617
15669
  const checkedCount = chunk.files.filter((file) => file.checked).length;
15618
15670
  const chunkMarkers = markers.filter((m) => m.line >= start && m.line <= end);
15619
15671
  const chunkThreads = threads.filter((t) => t.markerLine !== void 0 && t.markerLine >= start && t.markerLine <= end).map((t) => t.node);
15620
15672
  chunkThreads.forEach((n) => nested.add(n));
15621
- const children = [...chunkMarkers.map((m) => footnoteLeaf(lines, definitionByName, m)), ...chunkThreads];
15622
- return {
15623
- name: `${chunk.title} (${checkedCount}/${chunk.files.length})`,
15673
+ const children = [
15674
+ ...decisionNodes(lines, chunk, end),
15675
+ ...chunkMarkers.map((m) => footnoteLeaf(lines, definitionByName, m)),
15676
+ ...chunkThreads
15677
+ ];
15678
+ if (!(sections.length > 0 && chunk.section === void 0) && !isOpenChunk(chunk, chunkMarkers.length)) return [];
15679
+ const node = {
15680
+ name: chunk.files.length === 0 ? chunk.title : `${chunk.title} (${checkedCount}/${chunk.files.length})`,
15624
15681
  range: spanRange(lines, start, end),
15625
15682
  selectionRange: lineRange(lines, start),
15626
- ...children.length > 0 ? { children } : {},
15627
- unchecked: checkedCount < chunk.files.length || chunkMarkers.length > 0
15683
+ ...children.length > 0 ? { children } : {}
15628
15684
  };
15629
- }).filter((node) => node.unchecked).map(({ unchecked: _unchecked, ...node }) => node), ...threads.map((t) => t.node).filter((n) => !nested.has(n))];
15685
+ return [{
15686
+ line: start,
15687
+ section: chunk.section,
15688
+ node
15689
+ }];
15690
+ });
15691
+ const containers = sections.map((section) => ({
15692
+ line: section.headingLine,
15693
+ section: void 0,
15694
+ node: {
15695
+ name: REVIEW_SECTIONS[section.section],
15696
+ range: spanRange(lines, section.headingLine, blockEndLine(section)),
15697
+ selectionRange: lineRange(lines, section.headingLine),
15698
+ children: chunkNodes.filter((c) => c.section === section.section && c.line > section.headingLine).filter((c) => {
15699
+ const next = sections.find((o) => o.headingLine > section.headingLine);
15700
+ return next === void 0 || c.line < next.headingLine;
15701
+ }).map((c) => c.node)
15702
+ }
15703
+ }));
15704
+ return [...[...chunkNodes.filter((c) => c.section === void 0), ...containers].sort((a, b) => a.line - b.line).map((c) => c.node), ...threads.map((t) => t.node).filter((n) => !nested.has(n))];
15630
15705
  };
15631
15706
  /**
15632
15707
  * The target state a whole-chunk toggle drives every hunk to: `true` (check
@@ -15678,6 +15753,7 @@ const reviewActions = (content, range) => {
15678
15753
  const tree = parseMarkdown(content);
15679
15754
  const lines = content.split(/\r?\n/);
15680
15755
  const cursorLine = range.start.line;
15756
+ const endLines = chunkEndLines(content);
15681
15757
  const actions = [...threadReplyActions(content, range.start)];
15682
15758
  changesets.forEach((chunk, i) => {
15683
15759
  const hunk = chunk.files.find((file) => cursorLine >= file.sourceLine && cursorLine <= file.endLine);
@@ -15688,7 +15764,7 @@ const reviewActions = (content, range) => {
15688
15764
  edits: [edit]
15689
15765
  });
15690
15766
  }
15691
- const chunkEnd = Math.max(chunk.headingLine, (changesets[i + 1]?.headingLine ?? lines.length) - 1);
15767
+ const chunkEnd = chunk.section === void 0 ? Math.max(chunk.headingLine, (changesets[i + 1]?.headingLine ?? lines.length) - 1) : endLines.get(chunk.headingLine) ?? chunk.headingLine;
15692
15768
  if (chunk.files.length > 0 && cursorLine >= chunk.headingLine && cursorLine <= chunkEnd) {
15693
15769
  const edits = toggleChunkEdits(content, chunk.headingLine);
15694
15770
  if (edits.length > 0) {
@@ -15756,7 +15832,7 @@ const hunkLinkFor = (content, item) => {
15756
15832
  const reviewDocumentLinks = (content) => {
15757
15833
  const tree = parseMarkdown(content);
15758
15834
  const links = [];
15759
- for (const block of splitChunks(tree)) for (const item of block.body.flatMap((n) => taskItems(n))) {
15835
+ for (const block of splitChunks(tree, content).chunks) for (const item of block.body.flatMap((n) => taskItems(n))) {
15760
15836
  const link = hunkLinkFor(content, item);
15761
15837
  if (link) links.push(link);
15762
15838
  }
@@ -15778,6 +15854,19 @@ const reviewView = (content) => {
15778
15854
  kind: "chunk",
15779
15855
  index: chunkIndex
15780
15856
  },
15857
+ ...chunk.section === void 0 ? {} : { group: REVIEW_SECTIONS[chunk.section] },
15858
+ ...chunk.answers === void 0 ? {} : {
15859
+ answered: decisionOf(chunk) !== void 0,
15860
+ decision: chunk.answers.map((option, index) => ({
15861
+ title: option.text,
15862
+ checked: option.checked,
15863
+ anchor: {
15864
+ kind: "option",
15865
+ questionIndex: chunkIndex,
15866
+ index
15867
+ }
15868
+ }))
15869
+ },
15781
15870
  ...noteAt(chunk.headingLine),
15782
15871
  children: chunk.files.map((file, index) => ({
15783
15872
  title: pointerDisplay(file),
@@ -15875,8 +15964,66 @@ const setFileTickEdits = (content, files, checked) => {
15875
15964
  }
15876
15965
  return edits;
15877
15966
  };
15878
- /** 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. */
15967
+ /**
15968
+ * A finding's answer boxes are radio: ticking one unticks the others, and
15969
+ * `text` replaces the ticked box's label in the same edit set — the phone's
15970
+ * free-text answer, normalized as `qa`'s is.
15971
+ */
15972
+ const decisionEdits = (content, chunkIndex, index, opts) => {
15973
+ const items = answerItemsOf(content, chunkIndex);
15974
+ const target = items[index];
15975
+ if (target === void 0) return {
15976
+ ok: false,
15977
+ reason: "anchor-not-found"
15978
+ };
15979
+ const checked = opts.checked ?? true;
15980
+ const ticks = items.flatMap((item) => {
15981
+ const want = item === target ? checked : !checked && item.checked === true;
15982
+ return item.checked === true === want ? [] : tickEdit(content, item, want);
15983
+ });
15984
+ const text = opts.text === void 0 ? [] : labelEdit(content, target, opts.text);
15985
+ return {
15986
+ ok: true,
15987
+ edits: [...ticks, ...text]
15988
+ };
15989
+ };
15990
+ /** A finding's answer boxes; none for a walkthrough chunk or a stale index. */
15991
+ const answerItemsOf = (content, chunkIndex) => {
15992
+ const chunk = splitChunks(parseMarkdown(content), content).chunks[chunkIndex];
15993
+ if (chunk === void 0 || chunk.section === "walkthrough") return [];
15994
+ return answerListOf(content, chunk.body)?.list.children ?? [];
15995
+ };
15996
+ const tickEdit = (content, item, checked) => {
15997
+ const offset = checkboxOffset(content, item);
15998
+ if (offset === void 0) return [];
15999
+ const start = toLspPositionFromOffset(content, offset);
16000
+ return [{
16001
+ range: {
16002
+ start,
16003
+ end: {
16004
+ ...start,
16005
+ character: start.character + 1
16006
+ }
16007
+ },
16008
+ newText: checked ? "x" : " "
16009
+ }];
16010
+ };
16011
+ /** Replaces a box's label, from after its marker to the end of its first paragraph. */
16012
+ const labelEdit = (content, item, text) => {
16013
+ const from = firstParagraphContentOffset(item);
16014
+ const to = item.children.find((c) => c.type === "paragraph")?.position?.end.offset;
16015
+ if (from === void 0 || to === void 0) return [];
16016
+ return [{
16017
+ range: {
16018
+ start: toLspPositionFromOffset(content, from),
16019
+ end: toLspPositionFromOffset(content, to)
16020
+ },
16021
+ newText: normalizeFreeTextAnswer(text)
16022
+ }];
16023
+ };
16024
+ /** 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. */
15879
16025
  const reviewApply = (content, anchor, opts) => {
16026
+ if (anchor.kind === "option") return decisionEdits(content, anchor.questionIndex, anchor.index, opts);
15880
16027
  const { changesets } = parseReviewDoc(content);
15881
16028
  const checked = opts.checked ?? true;
15882
16029
  if (anchor.kind === "hunk") {
@@ -16043,11 +16190,12 @@ const reviewDecisions = (before, after) => {
16043
16190
  return old?.choice === decision.choice && old.text === decision.text ? [] : [decision];
16044
16191
  });
16045
16192
  };
16046
- /** Each `##` chunk in document order: its title, 0-based heading line and how many hunk pointers it holds. */
16193
+ /** Each chunk in document order: its title, 0-based heading line and how many hunk pointers it holds. */
16047
16194
  const reviewChunks = (content) => parseReviewDoc(content).changesets.map((c) => ({
16048
16195
  title: c.title,
16049
16196
  line: c.headingLine,
16050
16197
  pointers: c.files.length,
16198
+ ...c.section === void 0 ? {} : { section: c.section },
16051
16199
  ...c.severity === void 0 ? {} : { severityLine: c.severity.line },
16052
16200
  files: c.files.map((f) => ({
16053
16201
  path: f.path,
@@ -16055,6 +16203,68 @@ const reviewChunks = (content) => parseReviewDoc(content).changesets.map((c) =>
16055
16203
  ...f.rangeEnd === void 0 ? {} : { rangeEnd: f.rangeEnd }
16056
16204
  }))
16057
16205
  }));
16206
+ /** The `## Findings` / `## Walkthrough` headings in `content`, with their 0-based lines. */
16207
+ const reviewSections = (content) => splitChunks(parseMarkdown(content), content).sections.map((s) => ({
16208
+ section: s.section,
16209
+ line: s.headingLine
16210
+ }));
16211
+ const isBlankLine = (line) => line !== void 0 && line.trim() === "";
16212
+ /** How many lines a chunk's heading takes: two for a setext heading and its underline. */
16213
+ const headingSize = ({ heading, headingLine }) => toLspPosition(heading.position.end).line - headingLine + 1;
16214
+ /** \`block\` with its heading an ATX \`### \` on one line, and every heading in its body one level deeper. */
16215
+ const demoted = (chunk, block) => {
16216
+ const out = [...block];
16217
+ for (const node of chunk.body) {
16218
+ if (node.type !== "heading" || node.depth < 3 || node.depth > 5) continue;
16219
+ const at = toLspPosition(node.position.start).line - chunk.headingLine;
16220
+ out[at] = out[at].replace(/^( {0,3})#/, "$1##");
16221
+ }
16222
+ const size = headingSize(chunk);
16223
+ return [size > 1 ? `### ${out[0].trim()}` : out[0].replace(/^ {0,3}##/, "###"), ...out.slice(size)];
16224
+ };
16225
+ /** A chunk's \`size\` heading lines, then its body with the blank lines around it trimmed. */
16226
+ const chunkText = (block, size) => {
16227
+ const rest = block.slice(size);
16228
+ while (rest.length > 0 && isBlankLine(rest[0])) rest.shift();
16229
+ while (rest.length > 0 && isBlankLine(rest.at(-1))) rest.pop();
16230
+ const heading = block.slice(0, size).join("\n");
16231
+ return rest.length === 0 ? heading : `${heading}\n\n${rest.join("\n")}`;
16232
+ };
16233
+ /**
16234
+ * A flat review regrouped: every chunk with answer boxes under `## Findings`
16235
+ * and every chunk titled in `walkthrough` under `## Walkthrough`, each in its
16236
+ * current order and one heading level deeper. Any other chunk stays a `## ` of
16237
+ * its own, before the sections when it came before them, after otherwise. An
16238
+ * already sectioned review comes back unchanged.
16239
+ */
16240
+ const sectionedReview = (content, walkthrough) => {
16241
+ const tree = parseMarkdown(content);
16242
+ const { chunks, sections } = splitChunks(tree, content);
16243
+ if (sections.length > 0 || chunks.length === 0) return content;
16244
+ const { changesets } = parseReviewDoc(content);
16245
+ const lines = content.split("\n");
16246
+ const sectionOfChunk = (i) => changesets[i].answers !== void 0 ? "findings" : walkthrough.has(changesets[i].title) ? "walkthrough" : void 0;
16247
+ const text = (i, demote) => {
16248
+ const chunk = chunks[i];
16249
+ const block = lines.slice(chunk.headingLine, chunks[i + 1]?.headingLine ?? lines.length);
16250
+ return chunkText(demote ? demoted(chunk, block) : block, demote ? 1 : headingSize(chunk));
16251
+ };
16252
+ const grouped = chunks.map((_, i) => sectionOfChunk(i));
16253
+ const first = grouped.findIndex((g) => g !== void 0);
16254
+ if (first === -1) return content;
16255
+ const others = (keep) => chunks.flatMap((_, i) => grouped[i] === void 0 && keep(i) ? [text(i, false)] : []);
16256
+ const section = (s) => {
16257
+ const members = chunks.flatMap((_, i) => grouped[i] === s ? [text(i, true)] : []);
16258
+ return members.length === 0 ? [] : [`## ${REVIEW_SECTIONS[s]}`, ...members];
16259
+ };
16260
+ return `${[
16261
+ lines.slice(0, chunks[0].headingLine).join("\n").trim(),
16262
+ ...others((i) => i < first),
16263
+ ...section("findings"),
16264
+ ...section("walkthrough"),
16265
+ ...others((i) => i > first)
16266
+ ].filter((p) => p !== "").join("\n\n")}\n`;
16267
+ };
16058
16268
  //#endregion
16059
16269
  //#region src/steering/Edits.ts
16060
16270
  /** Splices `edits` into `content`, sorted last-to-first so an earlier range's offset is never invalidated by a later edit. Positions are 0-based line/character over `\n`-split lines. */
@@ -16423,4 +16633,4 @@ const viewOf = (format, content) => ({
16423
16633
  /** Every OPEN thread — last entry is the agent's — as `{ name, line, firstMe }` (0-based line). `[]` for a format with no thread concept. */
16424
16634
  const openThreads = (format, content) => format.openThreads?.(content) ?? [];
16425
16635
  //#endregion
16426
- export { headingSections as A, THREAD_REPLY_ACTION_TITLE as C, parseThreadsWithFindings as D, parseThreads as E, getParseCount as O, FOOTNOTE_ACTION_TITLE as S, parseFootnotes as T, reviewRisks as _, unansweredQuestions as a, FREE_TEXT_PLACEHOLDER as b, stripCodeThreads as c, ANSWER_FIX as d, ANSWER_LEAD as f, reviewNotes as g, reviewDecisions as h, steeringFormatOrFreeForm as i, formatFinding as j, headingSectionBodies as k, freeFormFormat as l, reviewChunks as m, openThreads as n, viewOf as o, ANSWER_WONT_FIX as p, steeringFormatFor as r, parseCodeThreads as s, BUILT_IN_MODE_NAMES as t, applySteeringEdits as u, severityLine as v, openThreadFindings as w, isAnswered as x, unansweredNotes as y };
16636
+ export { parseThreadsWithFindings as A, FREE_TEXT_PLACEHOLDER as C, openThreadFindings as D, THREAD_REPLY_ACTION_TITLE as E, headingSectionBodies as M, headingSections as N, parseFootnotes as O, formatFinding as P, unansweredNotes as S, FOOTNOTE_ACTION_TITLE as T, reviewNotes as _, unansweredQuestions as a, sectionedReview as b, stripCodeThreads as c, ANSWER_FIX as d, ANSWER_LEAD as f, reviewDecisions as g, reviewChunks as h, steeringFormatOrFreeForm as i, getParseCount as j, parseThreads as k, freeFormFormat as l, REVIEW_SECTIONS as m, openThreads as n, viewOf as o, ANSWER_WONT_FIX as p, steeringFormatFor as r, parseCodeThreads as s, BUILT_IN_MODE_NAMES as t, applySteeringEdits as u, reviewRisks as v, isAnswered as w, severityLine as x, reviewSections as y };
@@ -1,6 +1,6 @@
1
1
  import { answered, head, judge, mark, numeric, read, refuse, removeScript, run, scope, sectionBodies, showScript, vars, writeScript } from "../flows.mjs";
2
2
  import { lensEntriesOf, lensOf, qualityLap, reviewToHandOff } from "../workflow-kit.mjs";
3
- import { A as reviewMissing, D as fixQuality, M as reviewing, O as fixRisks, Pt as withLapFindings, S as awaitReview, T as fixCheck, X as handOffAwaitReviewMessage, ct as qualityFindingsFile, d as healthy, h as QUALITY, j as reviewQuality, lt as qualityTriageMessage, u as guarded, y as REVIEW } from "./reserved-CUkk9XgL.mjs";
3
+ import { A as reviewMissing, D as fixQuality, M as reviewing, Mt as withLapFindings, O as fixRisks, S as awaitReview, T as fixCheck, ct as qualityTriageMessage, d as healthy, h as QUALITY, j as reviewQuality, q as handOffAwaitReviewMessage, st as qualityFindingsFile, u as guarded, y as REVIEW } from "./reserved-D0MKVFFa.mjs";
4
4
  //#region src/workflows/shared/lenses.ts
5
5
  const NO_FINDINGS = {
6
6
  notes: [],