@pmelab/gtd 51.0.1 → 52.1.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 (74) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +46 -40
  3. package/dist/chunks/{reserved-CUkk9XgL.mjs → reserved-DxB5CROj.mjs} +73 -38
  4. package/dist/chunks/{review-BaCXlIT2.mjs → review-CKMSdvgW.mjs} +11 -9
  5. package/dist/chunks/{steering-DtUVUz9l.mjs → steering-CLhu8C7q.mjs} +262 -52
  6. package/dist/chunks/{tail-CX6Pdsql.mjs → tail-QZc7Uz2U.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 +228 -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 +570 -317
  19. package/package.json +5 -4
  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/testkit/steps.ts +13 -0
  30. package/src/workflows/feature/acceptance-criteria.feature +159 -3
  31. package/src/workflows/feature/continuation-cap.feature +4 -0
  32. package/src/workflows/feature/continuation-rebuild.feature +4 -0
  33. package/src/workflows/feature/continuation-slices.feature +4 -0
  34. package/src/workflows/feature/continuation.feature +4 -0
  35. package/src/workflows/feature/criteria.test.ts +60 -1
  36. package/src/workflows/feature/criteria.ts +87 -8
  37. package/src/workflows/feature/fast-path.feature +5 -6
  38. package/src/workflows/feature/feature.test.ts +6 -5
  39. package/src/workflows/feature/feature.ts +1 -2
  40. package/src/workflows/feature/flag-mode.feature +5 -6
  41. package/src/workflows/feature/green.ts +22 -0
  42. package/src/workflows/feature/hands-on-skip.feature +384 -0
  43. package/src/workflows/feature/lens-models.feature +4 -4
  44. package/src/workflows/feature/lens-review.feature +10 -8
  45. package/src/workflows/feature/naive-acceptance.feature +4 -0
  46. package/src/workflows/feature/one-step-land.feature +5 -6
  47. package/src/workflows/feature/planning.ts +2 -1
  48. package/src/workflows/feature/prototype-rounds.feature +5 -1
  49. package/src/workflows/feature/prototype.feature +18 -9
  50. package/src/workflows/feature/prototype.ts +48 -17
  51. package/src/workflows/feature/refactor-absorbs.feature +7 -8
  52. package/src/workflows/feature/refactor-first.feature +7 -8
  53. package/src/workflows/feature/refactor-lens-models.feature +4 -0
  54. package/src/workflows/feature/refactor-rebuild.feature +11 -12
  55. package/src/workflows/feature/refactor-review.feature +72 -72
  56. package/src/workflows/feature/review-answers.feature +94 -5
  57. package/src/workflows/feature/review-gate.feature +23 -53
  58. package/src/workflows/feature/reviewer-walkthrough.feature +33 -12
  59. package/src/workflows/feature/risk-tier.feature +32 -0
  60. package/src/workflows/feature/seam-rejection.feature +9 -6
  61. package/src/workflows/feature/seam.feature +9 -6
  62. package/src/workflows/feature/seam.test.ts +5 -3
  63. package/src/workflows/feature/seam.ts +2 -3
  64. package/src/workflows/feature/severity-budget.feature +11 -7
  65. package/src/workflows/feature/severity-sorted-review.feature +54 -54
  66. package/src/workflows/feature/slice-findings.feature +9 -15
  67. package/src/workflows/feature/slicing.feature +36 -24
  68. package/src/workflows/feature/standards.feature +4 -4
  69. package/src/workflows/feature/tier-pin.feature +4 -0
  70. package/src/workflows/shared/review.ts +26 -6
  71. package/src/workflows/shared/text.test.ts +85 -11
  72. package/src/workflows/shared/text.ts +95 -42
  73. package/src/workflows/feature/coverage.feature +0 -165
  74. package/src/workflows/feature/coverage.ts +0 -120
package/dist/web/main.js CHANGED
@@ -31565,6 +31565,8 @@ const qaDescriptor = {
31565
31565
  unansweredQuestions,
31566
31566
  openThreads: openThreadsOf
31567
31567
  };
31568
+ const ANSWER_FIX = "fix as suggested";
31569
+ const ANSWER_WONT_FIX = "won't fix";
31568
31570
  /** A chunk's leading `_critical: security_` paragraph: the tier gtd ranked it with, and why. */
31569
31571
  const SEVERITY_LINE_RE = /^_((?:critical|should|could)(?::[^_\n]*)?)_$/;
31570
31572
  /**
@@ -31871,8 +31873,8 @@ const splitSeverity = (content, run) => {
31871
31873
  };
31872
31874
  };
31873
31875
  /** 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). */
31874
- const parseChunkBody = (content, lines, title, body) => {
31875
- const answer = answerListOf(content, body);
31876
+ const parseChunkBody = (content, lines, title, body, answerable) => {
31877
+ const answer = answerable ? answerListOf(content, body) : void 0;
31876
31878
  const isAnswer = (n) => answer !== void 0 && (n === answer.list || n === body[answer.lead]);
31877
31879
  const firstPointerIndex = body.findIndex((n) => !isAnswer(n) && taskItems(n).some((item) => hasPointerToken(content, item)));
31878
31880
  const run = (firstPointerIndex === -1 ? body : body.slice(0, firstPointerIndex)).filter((n) => !isAnswer(n));
@@ -31894,45 +31896,78 @@ const parseChunkBody = (content, lines, title, body) => {
31894
31896
  descriptionInline,
31895
31897
  files,
31896
31898
  errors,
31897
- ...answer === void 0 ? {} : { answers: answer.options },
31899
+ ...answer === void 0 ? {} : {
31900
+ answers: answer.options,
31901
+ answerLine: toLspPosition(body[answer.lead].position.start).line
31902
+ },
31898
31903
  ...severity === void 0 ? {} : { severity },
31899
31904
  boxed: body.some((n) => !isAnswer(n) && taskItems(n).length > 0)
31900
31905
  };
31901
31906
  };
31902
- /** 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. */
31903
- const splitChunks = (tree) => {
31904
- const blocks = [];
31905
- let i = 0;
31906
- while (i < tree.children.length) {
31907
- const node = tree.children[i];
31908
- if (node.type !== "heading" || node.depth !== 2) {
31909
- i += 1;
31910
- continue;
31911
- }
31912
- const heading = node;
31913
- const headingLine = toLspPosition(heading.position.start).line;
31914
- i += 1;
31915
- const body = [];
31916
- while (i < tree.children.length) {
31917
- const next = tree.children[i];
31918
- if (next.type === "heading" && next.depth <= 2) break;
31919
- body.push(next);
31920
- i += 1;
31907
+ /** The `## ` headings gtd groups the review's chunks under; any other `## ` is a chunk of its own. */
31908
+ const REVIEW_SECTIONS = {
31909
+ findings: "Findings",
31910
+ walkthrough: "Walkthrough"
31911
+ };
31912
+ const sectionOf = (content, node) => {
31913
+ if (node.type !== "heading" || node.depth !== 2) return void 0;
31914
+ const text = headingText$1(content, node);
31915
+ return Object.keys(REVIEW_SECTIONS).find((s) => REVIEW_SECTIONS[s] === text);
31916
+ };
31917
+ /** The nodes after `from` up to the next heading at `depth` or shallower. */
31918
+ const bodyFrom = (children, from, depth) => {
31919
+ const end = children.findIndex((n, i) => i >= from && n.type === "heading" && n.depth <= depth);
31920
+ return children.slice(from, end === -1 ? void 0 : end);
31921
+ };
31922
+ /**
31923
+ * Splits the tree's top-level nodes into chunk blocks — a `## ` line found only
31924
+ * inside a fence never becomes a heading node at all. A document with a
31925
+ * `## Findings` or `## Walkthrough` heading nests `### ` chunks under them;
31926
+ * one without is the flat layout, every `## ` a chunk.
31927
+ */
31928
+ const splitChunks = (tree, content) => {
31929
+ const sectioned = tree.children.some((n) => sectionOf(content, n) !== void 0);
31930
+ const chunks = [];
31931
+ const sections = [];
31932
+ let section;
31933
+ tree.children.forEach((node, i) => {
31934
+ if (node.type !== "heading") return;
31935
+ const headingLine = toLspPosition(node.position.start).line;
31936
+ if (sectioned && node.depth <= 2) {
31937
+ const s = sectionOf(content, node);
31938
+ section = s === void 0 ? void 0 : {
31939
+ heading: node,
31940
+ headingLine,
31941
+ section: s,
31942
+ body: []
31943
+ };
31944
+ if (section !== void 0) {
31945
+ sections.push(section);
31946
+ return;
31947
+ }
31921
31948
  }
31922
- blocks.push({
31923
- heading,
31949
+ const depth = section === void 0 ? 2 : 3;
31950
+ if (node.depth !== depth) return;
31951
+ const body = bodyFrom(tree.children, i + 1, depth);
31952
+ chunks.push({
31953
+ heading: node,
31924
31954
  headingLine,
31925
- body
31955
+ body,
31956
+ ...section && { section: section.section }
31926
31957
  });
31927
- }
31928
- return blocks;
31958
+ section?.body.push(node, ...body);
31959
+ });
31960
+ return {
31961
+ chunks,
31962
+ sections
31963
+ };
31929
31964
  };
31930
31965
  const parseChangesets = (tree, content, lines) => {
31931
31966
  const changesets = [];
31932
31967
  const errors = [];
31933
- for (const { heading, headingLine, body } of splitChunks(tree)) {
31968
+ for (const { heading, headingLine, body, section } of splitChunks(tree, content).chunks) {
31934
31969
  const title = headingText$1(content, heading);
31935
- const { description, descriptionNodes, descriptionInline, files, answers, severity, boxed, errors: bodyErrors } = parseChunkBody(content, lines, title, body);
31970
+ const { description, descriptionNodes, descriptionInline, files, answers, answerLine, severity, boxed, errors: bodyErrors } = parseChunkBody(content, lines, title, body, section !== "walkthrough");
31936
31971
  errors.push(...bodyErrors);
31937
31972
  if (files.length === 0 && boxed) errors.push({
31938
31973
  message: `Chunk "${title}" has no file pointers`,
@@ -31946,8 +31981,12 @@ const parseChangesets = (tree, content, lines) => {
31946
31981
  descriptionInline,
31947
31982
  files,
31948
31983
  headingLine,
31949
- ...answers === void 0 ? {} : { answers },
31950
- ...severity === void 0 ? {} : { severity }
31984
+ ...answers === void 0 ? {} : {
31985
+ answers,
31986
+ answerLine
31987
+ },
31988
+ ...severity === void 0 ? {} : { severity },
31989
+ ...section === void 0 ? {} : { section }
31951
31990
  });
31952
31991
  }
31953
31992
  if (changesets.length === 0) errors.push({ message: "REVIEW.md has no '##' chunks" });
@@ -32029,7 +32068,7 @@ const isHunkPointerItem = (content, item) => {
32029
32068
  */
32030
32069
  const reviewClearTicks = (content) => {
32031
32070
  const tree = parseMarkdown(content);
32032
- const answers = new Set(splitChunks(tree).flatMap(({ body }) => answerListOf(content, body)?.list.children ?? []));
32071
+ const answers = new Set(splitChunks(tree, content).chunks.flatMap(({ body }) => answerListOf(content, body)?.list.children ?? []));
32033
32072
  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);
32034
32073
  if (offsets.length === 0) return content;
32035
32074
  let result = "";
@@ -32066,42 +32105,80 @@ const toggleFilePointer = (content, line) => {
32066
32105
  const chunkEndLines = (content) => {
32067
32106
  const tree = parseMarkdown(content);
32068
32107
  const map = /* @__PURE__ */ new Map();
32069
- for (const block of splitChunks(tree)) map.set(block.headingLine, blockEndLine(block));
32108
+ for (const block of splitChunks(tree, content).chunks) map.set(block.headingLine, blockEndLine(block));
32070
32109
  return map;
32071
32110
  };
32111
+ const CHOICE_NAMES = {
32112
+ fix: ANSWER_FIX,
32113
+ wontfix: ANSWER_WONT_FIX,
32114
+ answer: ""
32115
+ };
32116
+ /** A finding's decision as an outline node of its own; none for a chunk with no answer boxes. */
32117
+ const decisionNodes = (lines, chunk, end) => {
32118
+ if (chunk.answerLine === void 0) return [];
32119
+ const d = decisionOf(chunk);
32120
+ return [{
32121
+ name: `Decision: ${d === void 0 ? "open" : CHOICE_NAMES[d.choice] || d.text}`,
32122
+ range: spanRange(lines, chunk.answerLine, Math.max(chunk.answerLine, end)),
32123
+ selectionRange: lineRange(lines, chunk.answerLine)
32124
+ }];
32125
+ };
32126
+ const isOpenChunk = (chunk, markers) => chunk.files.some((file) => !file.checked) || markers > 0 || chunk.answers !== void 0 && decisionOf(chunk) === void 0;
32072
32127
  /**
32073
- * Document outline for `.gtd/REVIEW.md`: the headlines of chunks (the
32074
- * user-facing "work packages") that still carry at least one unchecked hunk
32075
- * — the outline is the list of packages left to review — PLUS any chunk
32076
- * that carries at least one footnote, even when every hunk in it is ticked:
32077
- * `reviewOutline` otherwise drops a fully-checked chunk, which would make a
32078
- * human's own comment vanish the moment they tick the last box. A chunk's
32079
- * footnotes are `leaf: true` children; a chunk with none has no children.
32128
+ * Document outline for `.gtd/REVIEW.md`: the chunks still left to review — an
32129
+ * unchecked hunk, a footnote (so a human's comment never vanishes with the
32130
+ * last tick) or an open decision. A finding's decision is a node of its own.
32131
+ * In the sectioned layout `Findings` and `Walkthrough` are containers, and
32132
+ * every other `## ` (`Fixed automatically`, …) stays listed.
32080
32133
  */
32081
32134
  const reviewOutline = (content) => {
32082
32135
  const { changesets } = parseReviewDoc(content);
32136
+ const { sections } = splitChunks(parseMarkdown(content), content);
32083
32137
  const { markers, definitions } = parseFootnotes(content);
32084
32138
  const definitionByName = new Map(definitions.map((d) => [d.name, d.body]));
32085
32139
  const lines = content.split(/\r?\n/);
32086
32140
  const endLines = chunkEndLines(content);
32087
32141
  const threads = threadOutlineNodes(content);
32088
32142
  const nested = /* @__PURE__ */ new Set();
32089
- return [...changesets.map((chunk) => {
32143
+ const chunkNodes = changesets.flatMap((chunk) => {
32090
32144
  const start = chunk.headingLine;
32091
32145
  const end = Math.max(start, endLines.get(start) ?? start);
32092
32146
  const checkedCount = chunk.files.filter((file) => file.checked).length;
32093
32147
  const chunkMarkers = markers.filter((m) => m.line >= start && m.line <= end);
32094
32148
  const chunkThreads = threads.filter((t) => t.markerLine !== void 0 && t.markerLine >= start && t.markerLine <= end).map((t) => t.node);
32095
32149
  chunkThreads.forEach((n) => nested.add(n));
32096
- const children = [...chunkMarkers.map((m) => footnoteLeaf(lines, definitionByName, m)), ...chunkThreads];
32097
- return {
32098
- name: `${chunk.title} (${checkedCount}/${chunk.files.length})`,
32150
+ const children = [
32151
+ ...decisionNodes(lines, chunk, end),
32152
+ ...chunkMarkers.map((m) => footnoteLeaf(lines, definitionByName, m)),
32153
+ ...chunkThreads
32154
+ ];
32155
+ if (!(sections.length > 0 && chunk.section === void 0) && !isOpenChunk(chunk, chunkMarkers.length)) return [];
32156
+ const node = {
32157
+ name: chunk.files.length === 0 ? chunk.title : `${chunk.title} (${checkedCount}/${chunk.files.length})`,
32099
32158
  range: spanRange(lines, start, end),
32100
32159
  selectionRange: lineRange(lines, start),
32101
- ...children.length > 0 ? { children } : {},
32102
- unchecked: checkedCount < chunk.files.length || chunkMarkers.length > 0
32160
+ ...children.length > 0 ? { children } : {}
32103
32161
  };
32104
- }).filter((node) => node.unchecked).map(({ unchecked: _unchecked, ...node }) => node), ...threads.map((t) => t.node).filter((n) => !nested.has(n))];
32162
+ return [{
32163
+ line: start,
32164
+ section: chunk.section,
32165
+ node
32166
+ }];
32167
+ });
32168
+ const containers = sections.map((section) => ({
32169
+ line: section.headingLine,
32170
+ section: void 0,
32171
+ node: {
32172
+ name: REVIEW_SECTIONS[section.section],
32173
+ range: spanRange(lines, section.headingLine, blockEndLine(section)),
32174
+ selectionRange: lineRange(lines, section.headingLine),
32175
+ children: chunkNodes.filter((c) => c.section === section.section && c.line > section.headingLine).filter((c) => {
32176
+ const next = sections.find((o) => o.headingLine > section.headingLine);
32177
+ return next === void 0 || c.line < next.headingLine;
32178
+ }).map((c) => c.node)
32179
+ }
32180
+ }));
32181
+ 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))];
32105
32182
  };
32106
32183
  /**
32107
32184
  * The target state a whole-chunk toggle drives every hunk to: `true` (check
@@ -32153,6 +32230,7 @@ const reviewActions = (content, range) => {
32153
32230
  const tree = parseMarkdown(content);
32154
32231
  const lines = content.split(/\r?\n/);
32155
32232
  const cursorLine = range.start.line;
32233
+ const endLines = chunkEndLines(content);
32156
32234
  const actions = [...threadReplyActions(content, range.start)];
32157
32235
  changesets.forEach((chunk, i) => {
32158
32236
  const hunk = chunk.files.find((file) => cursorLine >= file.sourceLine && cursorLine <= file.endLine);
@@ -32163,7 +32241,7 @@ const reviewActions = (content, range) => {
32163
32241
  edits: [edit]
32164
32242
  });
32165
32243
  }
32166
- const chunkEnd = Math.max(chunk.headingLine, (changesets[i + 1]?.headingLine ?? lines.length) - 1);
32244
+ const chunkEnd = chunk.section === void 0 ? Math.max(chunk.headingLine, (changesets[i + 1]?.headingLine ?? lines.length) - 1) : endLines.get(chunk.headingLine) ?? chunk.headingLine;
32167
32245
  if (chunk.files.length > 0 && cursorLine >= chunk.headingLine && cursorLine <= chunkEnd) {
32168
32246
  const edits = toggleChunkEdits(content, chunk.headingLine);
32169
32247
  if (edits.length > 0) {
@@ -32231,7 +32309,7 @@ const hunkLinkFor = (content, item) => {
32231
32309
  const reviewDocumentLinks = (content) => {
32232
32310
  const tree = parseMarkdown(content);
32233
32311
  const links = [];
32234
- for (const block of splitChunks(tree)) for (const item of block.body.flatMap((n) => taskItems(n))) {
32312
+ for (const block of splitChunks(tree, content).chunks) for (const item of block.body.flatMap((n) => taskItems(n))) {
32235
32313
  const link = hunkLinkFor(content, item);
32236
32314
  if (link) links.push(link);
32237
32315
  }
@@ -32253,6 +32331,19 @@ const reviewView = (content) => {
32253
32331
  kind: "chunk",
32254
32332
  index: chunkIndex
32255
32333
  },
32334
+ ...chunk.section === void 0 ? {} : { group: REVIEW_SECTIONS[chunk.section] },
32335
+ ...chunk.answers === void 0 ? {} : {
32336
+ answered: decisionOf(chunk) !== void 0,
32337
+ decision: chunk.answers.map((option, index) => ({
32338
+ title: option.text,
32339
+ checked: option.checked,
32340
+ anchor: {
32341
+ kind: "option",
32342
+ questionIndex: chunkIndex,
32343
+ index
32344
+ }
32345
+ }))
32346
+ },
32256
32347
  ...noteAt(chunk.headingLine),
32257
32348
  children: chunk.files.map((file, index) => ({
32258
32349
  title: pointerDisplay(file),
@@ -32350,8 +32441,66 @@ const setFileTickEdits = (content, files, checked) => {
32350
32441
  }
32351
32442
  return edits;
32352
32443
  };
32353
- /** 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. */
32444
+ /**
32445
+ * A finding's answer boxes are radio: ticking one unticks the others, and
32446
+ * `text` replaces the ticked box's label in the same edit set — the phone's
32447
+ * free-text answer, normalized as `qa`'s is.
32448
+ */
32449
+ const decisionEdits = (content, chunkIndex, index, opts) => {
32450
+ const items = answerItemsOf(content, chunkIndex);
32451
+ const target = items[index];
32452
+ if (target === void 0) return {
32453
+ ok: false,
32454
+ reason: "anchor-not-found"
32455
+ };
32456
+ const checked = opts.checked ?? true;
32457
+ const ticks = items.flatMap((item) => {
32458
+ const want = item === target ? checked : !checked && item.checked === true;
32459
+ return item.checked === true === want ? [] : tickEdit(content, item, want);
32460
+ });
32461
+ const text = opts.text === void 0 ? [] : labelEdit(content, target, opts.text);
32462
+ return {
32463
+ ok: true,
32464
+ edits: [...ticks, ...text]
32465
+ };
32466
+ };
32467
+ /** A finding's answer boxes; none for a walkthrough chunk or a stale index. */
32468
+ const answerItemsOf = (content, chunkIndex) => {
32469
+ const chunk = splitChunks(parseMarkdown(content), content).chunks[chunkIndex];
32470
+ if (chunk === void 0 || chunk.section === "walkthrough") return [];
32471
+ return answerListOf(content, chunk.body)?.list.children ?? [];
32472
+ };
32473
+ const tickEdit = (content, item, checked) => {
32474
+ const offset = checkboxOffset(content, item);
32475
+ if (offset === void 0) return [];
32476
+ const start = toLspPositionFromOffset(content, offset);
32477
+ return [{
32478
+ range: {
32479
+ start,
32480
+ end: {
32481
+ ...start,
32482
+ character: start.character + 1
32483
+ }
32484
+ },
32485
+ newText: checked ? "x" : " "
32486
+ }];
32487
+ };
32488
+ /** Replaces a box's label, from after its marker to the end of its first paragraph. */
32489
+ const labelEdit = (content, item, text) => {
32490
+ const from = firstParagraphContentOffset(item);
32491
+ const to = item.children.find((c) => c.type === "paragraph")?.position?.end.offset;
32492
+ if (from === void 0 || to === void 0) return [];
32493
+ return [{
32494
+ range: {
32495
+ start: toLspPositionFromOffset(content, from),
32496
+ end: toLspPositionFromOffset(content, to)
32497
+ },
32498
+ newText: normalizeFreeTextAnswer(text)
32499
+ }];
32500
+ };
32501
+ /** 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. */
32354
32502
  const reviewApply = (content, anchor, opts) => {
32503
+ if (anchor.kind === "option") return decisionEdits(content, anchor.questionIndex, anchor.index, opts);
32355
32504
  const { changesets } = parseReviewDoc(content);
32356
32505
  const checked = opts.checked ?? true;
32357
32506
  if (anchor.kind === "hunk") {
@@ -32381,10 +32530,8 @@ const reviewApply = (content, anchor, opts) => {
32381
32530
  reason: "anchor-not-found"
32382
32531
  };
32383
32532
  };
32384
- //#endregion
32385
- //#region src/steering/index.ts
32386
- /** The built-in registry: mode name → its format, in registry order. The single place `qa` and `review` are wired into names — everything else in this module dispatches over a resolved `SteeringFormat`, never a mode-name string. */
32387
- const REGISTRY = /* @__PURE__ */ new Map([["qa", qaDescriptor], ["review", {
32533
+ /** The `review` format: `.gtd/REVIEW.md`'s in-process format — validation, outline, code actions, footnote + hunk `pointerAt`/`documentLinks`, and a hunk-pointer-scoped `clearTicks` (never "every checked task item" — see `reviewClearTicks`). */
32534
+ const reviewDescriptor = {
32388
32535
  sample: REVIEW_SAMPLE,
32389
32536
  validate: (content) => [...parseReviewDoc(content).findings, ...parseFootnotes(content).findings],
32390
32537
  outline: reviewOutline,
@@ -32396,7 +32543,30 @@ const REGISTRY = /* @__PURE__ */ new Map([["qa", qaDescriptor], ["review", {
32396
32543
  apply: reviewApply,
32397
32544
  clearTicks: reviewClearTicks,
32398
32545
  openThreads: openThreadsOf
32399
- }]]);
32546
+ };
32547
+ const CHOICES = [
32548
+ "fix",
32549
+ "wontfix",
32550
+ "answer"
32551
+ ];
32552
+ const decisionOf = (chunk) => {
32553
+ if (chunk.answers?.length !== 3) return void 0;
32554
+ const ticked = chunk.answers.flatMap((o, i) => o.checked ? [i] : []);
32555
+ if (ticked.length !== 1) return void 0;
32556
+ const i = ticked[0];
32557
+ const text = i === 2 ? chunk.answers[2].text : "";
32558
+ if (i === 2 && text === "") return void 0;
32559
+ return {
32560
+ anchor: chunk.title,
32561
+ body: chunk.description,
32562
+ choice: CHOICES[i],
32563
+ text
32564
+ };
32565
+ };
32566
+ //#endregion
32567
+ //#region src/steering/index.ts
32568
+ /** The built-in registry: mode name → its format, in registry order. The single place `qa` and `review` are wired into names — everything else in this module dispatches over a resolved `SteeringFormat`, never a mode-name string. */
32569
+ const REGISTRY = /* @__PURE__ */ new Map([["qa", qaDescriptor], ["review", reviewDescriptor]]);
32400
32570
  [...REGISTRY.keys()];
32401
32571
  /** The built-in `SteeringFormat` registered under `mode`'s name, or `undefined` when it isn't a built-in mode at all. The one place a bare mode-name string is ever looked up — every consumer takes the RESOLVED value this returns, never a name, so "mode is required, never inferred from a file's basename" is a type, not a rule to remember. */
32402
32572
  const steeringFormatFor = (mode) => REGISTRY.get(mode);
@@ -33836,7 +34006,7 @@ const useScrollRestoration = () => {
33836
34006
  //#endregion
33837
34007
  //#region src/web/screens/Question.tsx
33838
34008
  /** `""` for an untouched/placeholder-only answer (case-insensitive) — the SAME sentinel and the SAME normalization the completeness gate and the open-questions check both apply server-side (`OpenQuestions.ts#FREE_TEXT_PLACEHOLDER`), redone here so the client never has to round-trip through a write to know if it's answered. */
33839
- const normalizeAnswerText = (text) => {
34009
+ const normalizeAnswerText$1 = (text) => {
33840
34010
  const trimmed = text.trim();
33841
34011
  return trimmed.toLowerCase() === "_your answer_".toLowerCase() ? "" : trimmed;
33842
34012
  };
@@ -33946,7 +34116,7 @@ const Question = ({ node, filePath, onCommitAnswer, noteOverrides, onOpenNote })
33946
34116
  const openAnswerSheet = () => setAnswerSheetOpen(true);
33947
34117
  /** Fired only by a deliberate Save tap — both fields go in one write, one cell. */
33948
34118
  const commitFreeText = (text) => {
33949
- const current = normalizeAnswerText(text);
34119
+ const current = normalizeAnswerText$1(text);
33950
34120
  const anchor = freeTextAnchor();
33951
34121
  if (anchor === void 0) return;
33952
34122
  const checked = current.length > 0;
@@ -33967,7 +34137,7 @@ const Question = ({ node, filePath, onCommitAnswer, noteOverrides, onOpenNote })
33967
34137
  };
33968
34138
  const answered = isAnswered(options.map((option, index) => ({
33969
34139
  checked: selected === index,
33970
- text: index === lastIndex ? normalizeAnswerText(freeText) : option.title,
34140
+ text: index === lastIndex ? normalizeAnswerText$1(freeText) : option.title,
33971
34141
  freeText: index === lastIndex
33972
34142
  })));
33973
34143
  const freeTextOptionAnchor = freeTextAnchor();
@@ -34612,6 +34782,19 @@ const Hunk = ({ node, diff, index, total, checked, hasNote, note, thread, onTogg
34612
34782
  //#region src/web/screens/Review.tsx
34613
34783
  /** Every hunk-anchored descendant of a chunk node, at any depth. */
34614
34784
  const hunksOf = (node) => (node.children ?? []).flatMap((child) => [...child.anchor.kind === "hunk" ? [child] : [], ...hunksOf(child)]);
34785
+ /** The free-text slot's index among a finding's three boxes. */
34786
+ const ANSWER_SLOT = 2;
34787
+ const defaultDecisionFor = ({ decision = [] }) => {
34788
+ const ticked = decision.flatMap((option, i) => option.checked === true ? [i] : []);
34789
+ return {
34790
+ selected: ticked.length === 1 ? ticked[0] : void 0,
34791
+ freeText: decision[ANSWER_SLOT]?.title ?? ""
34792
+ };
34793
+ };
34794
+ const normalizeAnswerText = (text) => {
34795
+ const trimmed = text.trim();
34796
+ return trimmed.toLowerCase() === "_your answer_".toLowerCase() ? "" : trimmed;
34797
+ };
34615
34798
  /**
34616
34799
  * All of `ReviewView`'s local/optimistic UI state plus the derived helpers
34617
34800
  * every branch below needs. `ticked`/notes read straight off the write
@@ -34619,17 +34802,56 @@ const hunksOf = (node) => (node.children ?? []).flatMap((child) => [...child.anc
34619
34802
  * falls back to the node's own last-server-confirmed value automatically —
34620
34803
  * no local `useState`, no per-hunk seq guard of its own.
34621
34804
  */
34622
- const useReviewState = (filePath, scrollRef, onSaveNote, onDoneNote, onSetValue) => {
34805
+ const useReviewState = (filePath, scrollRef, onSaveNote, onDoneNote, onSetValue, onCommitDecision) => {
34623
34806
  const overlay = useWriteStore((s) => s.overlay);
34624
34807
  const save = useWriteStore((s) => s.save);
34625
34808
  const shadow = useWriteStore((s) => s.shadow);
34626
34809
  const [openChunkIndex, setOpenChunkIndex] = (0, import_react.useState)(void 0);
34627
34810
  const [deckIndex, setDeckIndex] = (0, import_react.useState)(0);
34628
34811
  const [noteSheet, setNoteSheet] = (0, import_react.useState)(void 0);
34812
+ const [answerSheet, setAnswerSheet] = (0, import_react.useState)(void 0);
34629
34813
  const scroll = useScrollRestoration();
34630
34814
  const checkedCell = (anchor) => cellKey(filePath, anchor, "checked");
34631
34815
  const noteCell = (anchor) => cellKey(filePath, anchor, "note");
34632
34816
  const doneCell = (anchor) => cellKey(filePath, anchor, "done");
34817
+ const decisionCell = (chunk) => cellKey(filePath, chunk.anchor, "decision");
34818
+ const decisionOf = (chunk) => overlay[decisionCell(chunk)]?.value ?? defaultDecisionFor(chunk);
34819
+ /** The same rule the landing's `answer-completeness` check applies: three boxes, one ticked, a ticked answer filled in. */
34820
+ const isDecided = (chunk) => {
34821
+ const options = chunk.decision ?? [];
34822
+ const { selected, freeText } = decisionOf(chunk);
34823
+ return options.length === 3 && isAnswered(options.map((option, i) => ({
34824
+ checked: selected === i,
34825
+ text: i === ANSWER_SLOT ? normalizeAnswerText(freeText) : option.title,
34826
+ freeText: i === ANSWER_SLOT
34827
+ })));
34828
+ };
34829
+ const writeDecision = (chunk, index, optimistic, opts) => {
34830
+ const anchor = chunk.decision?.[index]?.anchor;
34831
+ if (anchor === void 0) return;
34832
+ save({
34833
+ cell: decisionCell(chunk),
34834
+ optimistic,
34835
+ write: onCommitDecision === void 0 ? void 0 : onCommitDecision(anchor, opts)
34836
+ }).catch(() => {});
34837
+ };
34838
+ const chooseDecision = (chunk, index) => writeDecision(chunk, index, {
34839
+ ...decisionOf(chunk),
34840
+ selected: index
34841
+ }, { checked: true });
34842
+ /** A Save in the answer sheet: an empty answer unticks the slot rather than ticking a blank one. */
34843
+ const commitAnswer = (chunk, text) => {
34844
+ setAnswerSheet(void 0);
34845
+ const answer = normalizeAnswerText(text);
34846
+ const { selected } = decisionOf(chunk);
34847
+ writeDecision(chunk, ANSWER_SLOT, {
34848
+ selected: answer === "" ? selected === ANSWER_SLOT ? void 0 : selected : ANSWER_SLOT,
34849
+ freeText: answer
34850
+ }, {
34851
+ checked: answer !== "",
34852
+ text: answer
34853
+ });
34854
+ };
34633
34855
  const isChecked = (hunk) => overlay[checkedCell(hunk.anchor)]?.value ?? hunk.checked === true;
34634
34856
  /** Reads BOTH a plain save's own `"note"` cell and that same anchor's `"done"` cell (a Save & Done) — only one of the two is ever written per interaction, so whichever is newer is what should show. */
34635
34857
  const savedNoteOf = (node) => latestOverlayValue(overlay, [noteCell(node.anchor), doneCell(node.anchor)]);
@@ -34706,7 +34928,13 @@ const useReviewState = (filePath, scrollRef, onSaveNote, onDoneNote, onSetValue)
34706
34928
  setDeckIndex,
34707
34929
  noteSheet,
34708
34930
  setNoteSheet,
34931
+ answerSheet,
34932
+ setAnswerSheet,
34709
34933
  exitToChunkList,
34934
+ decisionOf,
34935
+ isDecided,
34936
+ chooseDecision,
34937
+ commitAnswer,
34710
34938
  isChecked,
34711
34939
  hasNoteText,
34712
34940
  noteTextOf,
@@ -34744,7 +34972,7 @@ const hunkPropsFor = (hunk, index, hunks, state) => ({
34744
34972
  onApprove: () => state.approveAndAdvance(hunks),
34745
34973
  onOpenNote: () => state.openNoteSheet(hunk)
34746
34974
  });
34747
- const HunkDeck = ({ chunk, live, state, onDone, busy }) => {
34975
+ const HunkDeck = ({ chunk, live, state, onDone, busy, undecided }) => {
34748
34976
  const hunks = hunksOf(chunk);
34749
34977
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
34750
34978
  "data-testid": "hunk-deck",
@@ -34757,7 +34985,7 @@ const HunkDeck = ({ chunk, live, state, onDone, busy }) => {
34757
34985
  ...onDone !== void 0 ? {
34758
34986
  onDone,
34759
34987
  doneLabel: "Done",
34760
- doneDisabled: busy
34988
+ doneDisabled: busy === true || undecided > 0
34761
34989
  } : {},
34762
34990
  renderItem: (hunk, i) => {
34763
34991
  const props = hunkPropsFor(hunk, i, hunks, state);
@@ -34770,85 +34998,184 @@ const HunkDeck = ({ chunk, live, state, onDone, busy }) => {
34770
34998
  })
34771
34999
  });
34772
35000
  };
35001
+ const DECISION_LABELS = [
35002
+ "Fix as suggested",
35003
+ "Won't fix",
35004
+ "Your answer"
35005
+ ];
35006
+ /** A finding's decision: three radios, the last opening the answer sheet. */
35007
+ const DecisionControl = ({ chunk, chunkIndex, state }) => {
35008
+ const { selected, freeText } = state.decisionOf(chunk);
35009
+ const choose = (i) => i === ANSWER_SLOT ? state.setAnswerSheet(chunk) : state.chooseDecision(chunk, i);
35010
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("fieldset", {
35011
+ "data-testid": `chunk-decision-${chunkIndex}`,
35012
+ className: "m-0 mt-1 flex flex-col border-0 p-0 pl-13",
35013
+ children: [
35014
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("legend", {
35015
+ className: "sr-only",
35016
+ children: ["Decision for ", chunk.title]
35017
+ }),
35018
+ (chunk.decision ?? []).map((option, i) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", {
35019
+ className: "flex min-h-11 items-center gap-3",
35020
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", {
35021
+ type: "radio",
35022
+ name: `decision-${chunkIndex}`,
35023
+ "data-testid": `chunk-decision-${chunkIndex}-${i}`,
35024
+ checked: selected === i,
35025
+ onChange: () => choose(i)
35026
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
35027
+ className: selected === i ? "font-medium" : void 0,
35028
+ children: DECISION_LABELS[i] ?? option.title
35029
+ })]
35030
+ }, i)),
35031
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
35032
+ variant: "ghost",
35033
+ "data-testid": `chunk-decision-answer-${chunkIndex}`,
35034
+ onClick: () => state.setAnswerSheet(chunk),
35035
+ className: "w-full px-0 pl-7 text-left text-small font-normal text-muted",
35036
+ children: freeText.trim().length > 0 ? freeText : "Tap to write your answer"
35037
+ })
35038
+ ]
35039
+ });
35040
+ };
35041
+ /** A chunk with neither hunks nor a decision (`Fixed automatically`, …) is a note to read, not an item to tick. */
35042
+ const isPlainNote = (chunk) => chunk.decision === void 0 && hunksOf(chunk).length === 0;
35043
+ const ChunkSummary = ({ chunk, chunkIndex }) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
35044
+ chunk.status !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
35045
+ "data-testid": `chunk-severity-${chunkIndex}`,
35046
+ className: cn("text-small font-semibold", chunk.status.startsWith("critical") ? "text-warning" : "text-muted"),
35047
+ children: chunk.status
35048
+ }),
35049
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
35050
+ className: "font-semibold",
35051
+ children: chunk.title
35052
+ }),
35053
+ chunk.detail !== void 0 && chunk.detail.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
35054
+ className: "text-small text-muted",
35055
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Inline, {
35056
+ inline: chunk.detailInline,
35057
+ fallback: chunk.detail
35058
+ })
35059
+ })
35060
+ ] });
35061
+ /** A finding's done mark, a walkthrough chunk's tick-all box, nothing for a plain note. */
35062
+ const ChunkMark = ({ chunk, chunkIndex, state }) => {
35063
+ const hunks = hunksOf(chunk);
35064
+ if (chunk.decision !== void 0) {
35065
+ const decided = state.isDecided(chunk);
35066
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
35067
+ "data-testid": `chunk-decision-status-${chunkIndex}`,
35068
+ "aria-label": decided ? "Decided" : "Needs a decision",
35069
+ className: cn("flex min-h-11 min-w-11 shrink-0 items-center justify-center font-semibold", decided ? "text-heading-c" : "text-warning"),
35070
+ children: decided ? "✓" : "●"
35071
+ });
35072
+ }
35073
+ if (hunks.length === 0) return null;
35074
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", {
35075
+ "data-testid": `chunk-check-all-label-${chunkIndex}`,
35076
+ className: "flex min-h-11 min-w-11 shrink-0 items-center justify-center",
35077
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", {
35078
+ type: "checkbox",
35079
+ "data-testid": `chunk-check-all-${chunkIndex}`,
35080
+ checked: hunks.every(state.isChecked),
35081
+ onChange: () => state.toggleChunk(chunk)
35082
+ })
35083
+ });
35084
+ };
34773
35085
  const ChunkRow = ({ chunk, chunkIndex, state }) => {
34774
35086
  const hunks = hunksOf(chunk);
34775
- const allChecked = hunks.length > 0 && hunks.every(state.isChecked);
34776
35087
  const footnoteKeepsRoundOpen = state.hasNoteText(chunk);
34777
35088
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
34778
35089
  "data-testid": `chunk-card-${chunkIndex}`,
34779
35090
  className: "border-b border-divider px-3 py-2.5",
34780
- children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
34781
- className: "flex items-start gap-2",
34782
- children: [
34783
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", {
34784
- "data-testid": `chunk-check-all-label-${chunkIndex}`,
34785
- className: "flex min-h-11 min-w-11 shrink-0 items-center justify-center",
34786
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", {
34787
- type: "checkbox",
34788
- "data-testid": `chunk-check-all-${chunkIndex}`,
34789
- checked: allChecked,
35091
+ children: [
35092
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
35093
+ className: "flex items-start gap-2",
35094
+ children: [
35095
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ChunkMark, {
35096
+ chunk,
35097
+ chunkIndex,
35098
+ state
35099
+ }),
35100
+ isPlainNote(chunk) ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
35101
+ "data-testid": `chunk-plain-note-${chunkIndex}`,
35102
+ className: "flex-1 py-1",
35103
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ChunkSummary, {
35104
+ chunk,
35105
+ chunkIndex
35106
+ })
35107
+ }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Button, {
35108
+ variant: "ghost",
35109
+ "data-testid": `chunk-open-${chunkIndex}`,
35110
+ onClick: () => state.openChunk(chunkIndex),
34790
35111
  disabled: hunks.length === 0,
34791
- onChange: () => state.toggleChunk(chunk)
34792
- })
34793
- }),
34794
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Button, {
34795
- variant: "ghost",
34796
- "data-testid": `chunk-open-${chunkIndex}`,
34797
- onClick: () => state.openChunk(chunkIndex),
34798
- disabled: hunks.length === 0,
34799
- className: "flex-1 text-left",
34800
- children: [
34801
- chunk.status !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
34802
- "data-testid": `chunk-severity-${chunkIndex}`,
34803
- className: cn("text-small font-semibold", chunk.status.startsWith("critical") ? "text-warning" : "text-muted"),
34804
- children: chunk.status
34805
- }),
34806
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
34807
- className: "font-semibold",
34808
- children: chunk.title
34809
- }),
34810
- chunk.detail !== void 0 && chunk.detail.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
34811
- className: "text-small text-muted",
34812
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Inline, {
34813
- inline: chunk.detailInline,
34814
- fallback: chunk.detail
35112
+ className: "flex-1 text-left",
35113
+ children: [
35114
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ChunkSummary, {
35115
+ chunk,
35116
+ chunkIndex
35117
+ }),
35118
+ hunks.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
35119
+ className: "mt-0.5 text-small text-muted",
35120
+ children: "No file pointers"
35121
+ }),
35122
+ footnoteKeepsRoundOpen && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
35123
+ "data-testid": `chunk-footnote-badge-${chunkIndex}`,
35124
+ className: "mt-1 text-small text-warning",
35125
+ children: "Note keeps this round open"
34815
35126
  })
34816
- }),
34817
- hunks.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
34818
- className: "mt-0.5 text-small text-muted",
34819
- children: "No file pointers"
34820
- }),
34821
- footnoteKeepsRoundOpen && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
34822
- "data-testid": `chunk-footnote-badge-${chunkIndex}`,
34823
- className: "mt-1 text-small text-warning",
34824
- children: "Note keeps this round open"
34825
- })
34826
- ]
34827
- }),
34828
- !footnoteKeepsRoundOpen && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
34829
- variant: "secondary",
34830
- "data-testid": `chunk-note-${chunkIndex}`,
34831
- onClick: () => state.openNoteSheet(chunk),
34832
- children: "Note"
34833
- })
34834
- ]
34835
- }), footnoteKeepsRoundOpen && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
34836
- variant: "ghost",
34837
- "data-testid": `chunk-note-${chunkIndex}`,
34838
- onClick: () => state.openNoteSheet(chunk),
34839
- className: "w-full rounded border border-divider px-2 py-1 text-left text-small font-normal text-muted",
34840
- children: state.threadOf(chunk) !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Thread, {
34841
- thread: state.threadOf(chunk),
34842
- testId: `chunk-thread-${chunkIndex}`
34843
- }) : state.noteTextOf(chunk)
34844
- })]
35127
+ ]
35128
+ }),
35129
+ !footnoteKeepsRoundOpen && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
35130
+ variant: "secondary",
35131
+ "data-testid": `chunk-note-${chunkIndex}`,
35132
+ onClick: () => state.openNoteSheet(chunk),
35133
+ children: "Note"
35134
+ })
35135
+ ]
35136
+ }),
35137
+ chunk.decision !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DecisionControl, {
35138
+ chunk,
35139
+ chunkIndex,
35140
+ state
35141
+ }),
35142
+ footnoteKeepsRoundOpen && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
35143
+ variant: "ghost",
35144
+ "data-testid": `chunk-note-${chunkIndex}`,
35145
+ onClick: () => state.openNoteSheet(chunk),
35146
+ className: "w-full rounded border border-divider px-2 py-1 text-left text-small font-normal text-muted",
35147
+ children: state.threadOf(chunk) !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Thread, {
35148
+ thread: state.threadOf(chunk),
35149
+ testId: `chunk-thread-${chunkIndex}`
35150
+ }) : state.noteTextOf(chunk)
35151
+ })
35152
+ ]
34845
35153
  });
34846
35154
  };
34847
- const approvedCount = (nodes, state) => nodes.filter((chunk) => {
35155
+ /** A finding is done once decided, any other chunk once every hunk is ticked; a plain note never counts. */
35156
+ const isChunkDone = (chunk, state) => {
35157
+ if (chunk.decision !== void 0) return state.isDecided(chunk);
34848
35158
  const hunks = hunksOf(chunk);
34849
35159
  return hunks.length > 0 && hunks.every(state.isChecked);
34850
- }).length;
34851
- const ChunkList = ({ nodes, state, scrollRef, onDone, busy }) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
35160
+ };
35161
+ const undecidedCount = (nodes, state) => nodes.filter((chunk) => chunk.decision !== void 0 && !state.isDecided(chunk)).length;
35162
+ /** Consecutive chunks sharing a `group` (the review's Findings / Walkthrough), chunk indexes kept for their anchors. */
35163
+ const groupsOf = (nodes) => nodes.reduce((groups, chunk, chunkIndex) => {
35164
+ const last = groups.at(-1);
35165
+ if (last !== void 0 && last.title === chunk.group) last.chunks.push({
35166
+ chunk,
35167
+ chunkIndex
35168
+ });
35169
+ else groups.push({
35170
+ title: chunk.group,
35171
+ chunks: [{
35172
+ chunk,
35173
+ chunkIndex
35174
+ }]
35175
+ });
35176
+ return groups;
35177
+ }, []);
35178
+ const ChunkList = ({ nodes, state, scrollRef, onDone, busy, undecided }) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
34852
35179
  "data-testid": "review-screen",
34853
35180
  className: "flex h-full min-h-0 flex-1 flex-col",
34854
35181
  children: [
@@ -34861,9 +35188,10 @@ const ChunkList = ({ nodes, state, scrollRef, onDone, busy }) => /* @__PURE__ */
34861
35188
  }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
34862
35189
  className: "text-small text-muted tabular-nums",
34863
35190
  children: [
34864
- approvedCount(nodes, state),
34865
- " / ",
34866
- nodes.length,
35191
+ nodes.filter((chunk) => isChunkDone(chunk, state)).length,
35192
+ " /",
35193
+ " ",
35194
+ nodes.filter((chunk) => !isPlainNote(chunk)).length,
34867
35195
  " chunks approved"
34868
35196
  ]
34869
35197
  })]
@@ -34872,22 +35200,32 @@ const ChunkList = ({ nodes, state, scrollRef, onDone, busy }) => /* @__PURE__ */
34872
35200
  ref: scrollRef,
34873
35201
  "data-testid": "review-scroll-container",
34874
35202
  className: "min-h-0 flex-1 overflow-auto",
34875
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CardList, { children: nodes.map((chunk, chunkIndex) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ChunkRow, {
34876
- chunk,
34877
- chunkIndex,
34878
- state
34879
- }, chunkIndex)) })
35203
+ children: groupsOf(nodes).map((group) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", {
35204
+ ...group.title === void 0 ? {} : { "data-testid": `review-section-${group.title.toLowerCase()}` },
35205
+ children: [group.title !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", {
35206
+ className: "m-0 border-b border-divider bg-surface px-3 py-1.5 text-small font-semibold text-muted",
35207
+ children: group.title
35208
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CardList, { children: group.chunks.map(({ chunk, chunkIndex }) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ChunkRow, {
35209
+ chunk,
35210
+ chunkIndex,
35211
+ state
35212
+ }, chunkIndex)) })]
35213
+ }, group.chunks[0].chunkIndex))
34880
35214
  }),
34881
- onDone !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
35215
+ onDone !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
34882
35216
  "data-testid": "review-done-row",
34883
- className: "flex shrink-0 items-center justify-end border-t border-border p-3",
34884
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
35217
+ className: "flex shrink-0 items-center justify-end gap-3 border-t border-border p-3",
35218
+ children: [undecided > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
35219
+ "data-testid": "review-done-blocked",
35220
+ className: "text-small text-warning",
35221
+ children: [undecided === 1 ? "1 finding needs" : `${undecided} findings need`, " a decision"]
35222
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
34885
35223
  variant: "primary",
34886
35224
  "data-testid": "review-done",
34887
35225
  onClick: onDone,
34888
- disabled: busy,
35226
+ disabled: busy === true || undecided > 0,
34889
35227
  children: "Done"
34890
- })
35228
+ })]
34891
35229
  })
34892
35230
  ]
34893
35231
  });
@@ -34895,10 +35233,10 @@ const ChunkList = ({ nodes, state, scrollRef, onDone, busy }) => /* @__PURE__ */
34895
35233
  * Presentational review screen — chunk list drilling into a per-chunk deck
34896
35234
  * of hunks, mirroring `Plan.tsx`'s `PlanView`/`Plan` split.
34897
35235
  */
34898
- const ReviewView = ({ view, filePath, isLoading, readError, live, onSaveNote, onDoneNote, onDone, onSetValue, busy }) => {
35236
+ const ReviewView = ({ view, filePath, isLoading, readError, live, onSaveNote, onDoneNote, onDone, onSetValue, onCommitDecision, busy }) => {
34899
35237
  const scrollRef = (0, import_react.useRef)(null);
34900
35238
  const save = useWriteStore((s) => s.save);
34901
- const state = useReviewState(filePath, scrollRef, onSaveNote, onDoneNote, onSetValue);
35239
+ const state = useReviewState(filePath, scrollRef, onSaveNote, onDoneNote, onSetValue, onCommitDecision);
34902
35240
  if (view === void 0) {
34903
35241
  const refusal = readError !== void 0 ? readRefusalFrom(readError) : void 0;
34904
35242
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Notice, {
@@ -34906,8 +35244,19 @@ const ReviewView = ({ view, filePath, isLoading, readError, live, onSaveNote, on
34906
35244
  children: isLoading ? "Loading the review…" : refusal !== void 0 ? messageForReadRefusal(refusal) : "Could not load the review."
34907
35245
  });
34908
35246
  }
35247
+ const answerChunk = state.answerSheet;
35248
+ const answerAnchor = answerChunk?.decision?.[ANSWER_SLOT]?.anchor;
35249
+ const answerSheet = answerChunk !== void 0 && answerAnchor !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NoteSheet, {
35250
+ anchor: answerAnchor,
35251
+ title: "Your answer",
35252
+ label: "Answer text",
35253
+ note: state.decisionOf(answerChunk).freeText,
35254
+ onSave: (_anchor, text) => state.commitAnswer(answerChunk, text),
35255
+ onDismiss: () => state.setAnswerSheet(void 0)
35256
+ }) : null;
35257
+ const undecided = undecidedCount(view.nodes, state);
34909
35258
  const noteSheet = (() => {
34910
- if (state.noteSheet === void 0) return null;
35259
+ if (state.noteSheet === void 0) return answerSheet;
34911
35260
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NoteSheet, {
34912
35261
  anchor: state.noteSheet.anchor,
34913
35262
  ...state.noteSheet.initialNote !== void 0 ? { note: state.noteSheet.initialNote } : {},
@@ -34933,14 +35282,16 @@ const ReviewView = ({ view, filePath, isLoading, readError, live, onSaveNote, on
34933
35282
  live: live === true,
34934
35283
  state,
34935
35284
  onDone: onDone === void 0 ? void 0 : runDone,
34936
- busy
35285
+ busy,
35286
+ undecided
34937
35287
  }), noteSheet] });
34938
35288
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(ChunkList, {
34939
35289
  nodes: view.nodes,
34940
35290
  state,
34941
35291
  scrollRef,
34942
35292
  onDone: onDone === void 0 ? void 0 : runDone,
34943
- busy
35293
+ busy,
35294
+ undecided
34944
35295
  }), noteSheet] });
34945
35296
  };
34946
35297
  /** The terminal panel after `done` resolves. */
@@ -34972,6 +35323,16 @@ const useReviewMutations = (filePath, onFormatNotice) => {
34972
35323
  onFormatNotice(result.formatNotice);
34973
35324
  return result;
34974
35325
  });
35326
+ const onCommitDecision = (anchor, opts) => (cas) => setValue.mutateAsync({
35327
+ filePath,
35328
+ ...cas,
35329
+ mode: "review",
35330
+ anchor,
35331
+ ...opts
35332
+ }).then((result) => {
35333
+ onFormatNotice(result.formatNotice);
35334
+ return result;
35335
+ });
34975
35336
  const onSaveNote = (anchor, text) => (cas) => writeNote.mutateAsync({
34976
35337
  filePath,
34977
35338
  ...cas,
@@ -34995,6 +35356,7 @@ const useReviewMutations = (filePath, onFormatNotice) => {
34995
35356
  onDoneNote,
34996
35357
  onDone,
34997
35358
  onSetValue,
35359
+ onCommitDecision,
34998
35360
  isDone: done.isSuccess
34999
35361
  };
35000
35362
  };
@@ -35003,7 +35365,7 @@ const ReviewInner = ({ filePath, data, isLoading, error }) => {
35003
35365
  const indicatorProps = useSaveIndicatorProps();
35004
35366
  const pending = useWriteStore((s) => s.pendingCount);
35005
35367
  const [formatNotice, setFormatNotice] = (0, import_react.useState)(void 0);
35006
- const { onSaveNote, onDoneNote, onDone, onSetValue, isDone } = useReviewMutations(filePath, setFormatNotice);
35368
+ const { onSaveNote, onDoneNote, onDone, onSetValue, onCommitDecision, isDone } = useReviewMutations(filePath, setFormatNotice);
35007
35369
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
35008
35370
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SaveIndicator, { ...indicatorProps }),
35009
35371
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FormatNoticeBanner, {
@@ -35020,6 +35382,7 @@ const ReviewInner = ({ filePath, data, isLoading, error }) => {
35020
35382
  onDoneNote,
35021
35383
  onDone,
35022
35384
  onSetValue,
35385
+ onCommitDecision,
35023
35386
  busy: pending > 0
35024
35387
  })
35025
35388
  ] });