@pmelab/gtd 54.0.0 → 56.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 (72) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +109 -104
  3. package/dist/chunks/{reserved-CQ7amcKG.mjs → reserved-B8EC-WE8.mjs} +124 -230
  4. package/dist/chunks/{review-CXzUXJHL.mjs → review-HdrX0FJ7.mjs} +30 -125
  5. package/dist/chunks/{steering-CLhu8C7q.mjs → steering-CCNvngSH.mjs} +256 -65
  6. package/dist/chunks/{tail-JZ9PAOwp.mjs → tail-BSLah2mA.mjs} +155 -63
  7. package/dist/gtd.bundle.mjs +259 -22
  8. package/dist/steering.mjs +2 -2
  9. package/dist/testkit.mjs +6 -6
  10. package/dist/web/main.css +1 -1
  11. package/dist/web/main.js +551 -66
  12. package/dist/workflow-kit.mjs +1 -1
  13. package/dist/workflows/cleanup.mjs +2 -2
  14. package/dist/workflows/feature.mjs +47 -102
  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 +614 -574
  19. package/package.json +1 -1
  20. package/skills/authoring/SKILL.md +3 -3
  21. package/src/cli/Cli.ts +5 -5
  22. package/src/edge/Edge.test.ts +1 -1
  23. package/src/steering/FORMATS.md +5 -1
  24. package/src/steering/SteeringFormats.test.ts +2 -2
  25. package/src/steering/contract.test.ts +1 -0
  26. package/src/steering/findings.test.ts +47 -0
  27. package/src/steering/findings.ts +350 -0
  28. package/src/steering/fixtures/findings/gate.md +34 -0
  29. package/src/steering/fixtures/findings/gate.read.json +87 -0
  30. package/src/steering/index.test.ts +2 -2
  31. package/src/steering/index.ts +4 -3
  32. package/src/steering/review.test.ts +1 -75
  33. package/src/steering/review.ts +1 -74
  34. package/src/workflow/compile.test.ts +1 -0
  35. package/src/workflows/feature/criteria-coverage.feature +104 -0
  36. package/src/workflows/feature/feature.test.ts +1 -1
  37. package/src/workflows/feature/feature.ts +17 -20
  38. package/src/workflows/feature/green.ts +2 -5
  39. package/src/workflows/feature/lens-review.feature +323 -171
  40. package/src/workflows/feature/naive-acceptance.feature +31 -32
  41. package/src/workflows/feature/prototype.ts +5 -8
  42. package/src/workflows/feature/refactor-absorbs.feature +1 -7
  43. package/src/workflows/feature/refactor-first.feature +2 -10
  44. package/src/workflows/feature/refactor-rebuild.feature +20 -36
  45. package/src/workflows/feature/refactor-review.feature +33 -117
  46. package/src/workflows/feature/refactor.test.ts +0 -2
  47. package/src/workflows/feature/refactor.ts +0 -1
  48. package/src/workflows/feature/review-gate.feature +6 -22
  49. package/src/workflows/feature/reviewer-walkthrough.feature +16 -103
  50. package/src/workflows/feature/seam.test.ts +19 -101
  51. package/src/workflows/feature/seam.ts +13 -89
  52. package/src/workflows/feature/standards.feature +75 -34
  53. package/src/workflows/fix/fix-lens-origin.feature +20 -7
  54. package/src/workflows/fix/fix-quality.feature +104 -23
  55. package/src/workflows/review/review-door.feature +62 -59
  56. package/src/workflows/shared/access.test.ts +1 -0
  57. package/src/workflows/shared/learn.test.ts +0 -14
  58. package/src/workflows/shared/learn.ts +1 -6
  59. package/src/workflows/shared/lenses.ts +149 -43
  60. package/src/workflows/shared/review.test.ts +30 -27
  61. package/src/workflows/shared/review.ts +29 -196
  62. package/src/workflows/shared/skills.test.ts +3 -1
  63. package/src/workflows/shared/steps.test.ts +9 -6
  64. package/src/workflows/shared/steps.ts +12 -3
  65. package/src/workflows/shared/tail.test.ts +20 -2
  66. package/src/workflows/shared/tail.ts +38 -16
  67. package/src/workflows/shared/text.test.ts +122 -294
  68. package/src/workflows/shared/text.ts +181 -358
  69. package/src/workflows/shared/vars.ts +1 -1
  70. package/src/workflows/feature/review-answers.feature +0 -324
  71. package/src/workflows/feature/severity-budget.feature +0 -116
  72. package/src/workflows/feature/severity-sorted-review.feature +0 -198
package/dist/web/main.js CHANGED
@@ -25928,7 +25928,7 @@ function serializeChunks(chunks, expandTabs) {
25928
25928
  * @returns {ParseContext}
25929
25929
  * Parser.
25930
25930
  */
25931
- function parse(options) {
25931
+ function parse$1(options) {
25932
25932
  /** @type {ParseContext} */
25933
25933
  const parser = {
25934
25934
  constructs: combineExtensions([constructs_exports, ...(options || {}).extensions || []]),
@@ -26243,7 +26243,7 @@ function fromMarkdown(value, encoding, options) {
26243
26243
  options = encoding;
26244
26244
  encoding = void 0;
26245
26245
  }
26246
- return compiler(options)(postprocess(parse(options).document().write(preprocess()(value, encoding, true))));
26246
+ return compiler(options)(postprocess(parse$1(options).document().write(preprocess()(value, encoding, true))));
26247
26247
  }
26248
26248
  /**
26249
26249
  * Note this compiler only understand complete buffering, not streaming.
@@ -29842,7 +29842,7 @@ const eolOf = (content) => content.includes("\r\n") ? "\r\n" : "\n";
29842
29842
  //#endregion
29843
29843
  //#region src/steering/Threads.ts
29844
29844
  const PREFIX_RE = /^(H|A):\s*/;
29845
- const lineOf = (node, which) => toLspPosition(node.position[which]).line;
29845
+ const lineOf$1 = (node, which) => toLspPosition(node.position[which]).line;
29846
29846
  /** The item's own blocks joined — never the item node's span, which can run past its last line into the next bullet's marker. */
29847
29847
  const itemText$1 = (content, item) => item.children.map((child) => sourceText(content, child)).join(" ").trim();
29848
29848
  const prefixOf = (content, item) => PREFIX_RE.exec(itemText$1(content, item));
@@ -29856,9 +29856,9 @@ const isThreadBody = (content, children) => {
29856
29856
  };
29857
29857
  const foldName$1 = (name) => name.toLowerCase();
29858
29858
  /** One list item's entry, reporting any syntax problem; `previous` is the author before it. */
29859
- const entryOf = (content, item, previous, report) => {
29860
- const start = lineOf(item, "start");
29861
- const end = lineOf(item, "end");
29859
+ const entryOf$1 = (content, item, previous, report) => {
29860
+ const start = lineOf$1(item, "start");
29861
+ const end = lineOf$1(item, "end");
29862
29862
  const match = prefixOf(content, item);
29863
29863
  if (!match) {
29864
29864
  report(`an entry must start with "H:" or "A:"`, start, end);
@@ -29874,7 +29874,7 @@ const entryOf = (content, item, previous, report) => {
29874
29874
  author,
29875
29875
  text,
29876
29876
  line: start,
29877
- endLine: last ? lineOf(last, "end") : end,
29877
+ endLine: last ? lineOf$1(last, "end") : end,
29878
29878
  column: toLspPosition(item.position.start).character
29879
29879
  };
29880
29880
  };
@@ -29883,16 +29883,16 @@ const threadOf = (content, def, list, markers, report) => {
29883
29883
  const entries = [];
29884
29884
  let previous;
29885
29885
  for (const item of list.children) {
29886
- const entry = entryOf(content, item, previous, report);
29886
+ const entry = entryOf$1(content, item, previous, report);
29887
29887
  if (!entry) continue;
29888
29888
  previous = entry.author;
29889
29889
  entries.push(entry);
29890
29890
  }
29891
- for (const extra of def.children.slice(1)) report(`only one list of "H:"/"A:" entries may make up the body`, lineOf(extra, "start"), lineOf(extra, "end"));
29891
+ for (const extra of def.children.slice(1)) report(`only one list of "H:"/"A:" entries may make up the body`, lineOf$1(extra, "start"), lineOf$1(extra, "end"));
29892
29892
  return {
29893
29893
  name,
29894
- line: lineOf(def, "start"),
29895
- endLine: lineOf(def, "end"),
29894
+ line: lineOf$1(def, "start"),
29895
+ endLine: lineOf$1(def, "end"),
29896
29896
  markers: markers.filter((m) => foldName$1(m.name) === foldName$1(name)),
29897
29897
  entries,
29898
29898
  waitingOn: entries[entries.length - 1]?.author === "agent" ? "human" : "agent"
@@ -30126,6 +30126,8 @@ const noteLookup = (content, footnotes = parseFootnotes(content)) => {
30126
30126
  return note === "" ? {} : { note };
30127
30127
  };
30128
30128
  };
30129
+ /** Thread syntax findings alone — what free-form `validate` reports without inheriting the other footnote findings. */
30130
+ const threadFindings = (content) => collectThreads(content, parseMarkdown(content), []).findings;
30129
30131
  /** The first integer unused by any `fnN` marker or definition already in the document — deterministic (no clock, no randomness), so "add a footnote" is testable and idempotent under re-run: applying it twice yields `fn1` then `fn2`, never a collision. Counts orphan markers too (via `parseFootnotes`), so it never reuses a name that's already written but undefined. */
30130
30132
  const nextFootnoteName = (content) => {
30131
30133
  const { markers, definitions } = parseFootnotes(content);
@@ -32116,14 +32118,14 @@ const CHOICE_NAMES = {
32116
32118
  /** A finding's decision as an outline node of its own; none for a chunk with no answer boxes. */
32117
32119
  const decisionNodes = (lines, chunk, end) => {
32118
32120
  if (chunk.answerLine === void 0) return [];
32119
- const d = decisionOf(chunk);
32121
+ const d = decisionOf$1(chunk);
32120
32122
  return [{
32121
32123
  name: `Decision: ${d === void 0 ? "open" : CHOICE_NAMES[d.choice] || d.text}`,
32122
32124
  range: spanRange(lines, chunk.answerLine, Math.max(chunk.answerLine, end)),
32123
32125
  selectionRange: lineRange(lines, chunk.answerLine)
32124
32126
  }];
32125
32127
  };
32126
- const isOpenChunk = (chunk, markers) => chunk.files.some((file) => !file.checked) || markers > 0 || chunk.answers !== void 0 && decisionOf(chunk) === void 0;
32128
+ const isOpenChunk = (chunk, markers) => chunk.files.some((file) => !file.checked) || markers > 0 || chunk.answers !== void 0 && decisionOf$1(chunk) === void 0;
32127
32129
  /**
32128
32130
  * Document outline for `.gtd/REVIEW.md`: the chunks still left to review — an
32129
32131
  * unchecked hunk, a footnote (so a human's comment never vanishes with the
@@ -32333,7 +32335,7 @@ const reviewView = (content) => {
32333
32335
  },
32334
32336
  ...chunk.section === void 0 ? {} : { group: REVIEW_SECTIONS[chunk.section] },
32335
32337
  ...chunk.answers === void 0 ? {} : {
32336
- answered: decisionOf(chunk) !== void 0,
32338
+ answered: decisionOf$1(chunk) !== void 0,
32337
32339
  decision: chunk.answers.map((option, index) => ({
32338
32340
  title: option.text,
32339
32341
  checked: option.checked,
@@ -32549,7 +32551,7 @@ const CHOICES = [
32549
32551
  "wontfix",
32550
32552
  "answer"
32551
32553
  ];
32552
- const decisionOf = (chunk) => {
32554
+ const decisionOf$1 = (chunk) => {
32553
32555
  if (chunk.answers?.length !== 3) return void 0;
32554
32556
  const ticked = chunk.answers.flatMap((o, i) => o.checked ? [i] : []);
32555
32557
  if (ticked.length !== 1) return void 0;
@@ -32564,9 +32566,243 @@ const decisionOf = (chunk) => {
32564
32566
  };
32565
32567
  };
32566
32568
  //#endregion
32569
+ //#region src/steering/findings.ts
32570
+ /** The `# ` heading the findings a judge called noise sit under. */
32571
+ const NOISE_HEADING = "Dropped as noise";
32572
+ const OPEN_GROUP = "Open";
32573
+ const MARKER = /\[\^[^\]\s]+\]/g;
32574
+ const isBox = (item, content) => {
32575
+ if (typeof item.checked !== "boolean") return false;
32576
+ const first = item.children[0];
32577
+ if (first?.type !== "paragraph" || first.position === void 0) return false;
32578
+ const text = content.slice(first.position.start.offset, first.position.end.offset);
32579
+ return new RegExp(`^fix(?![\\w-])`).test(text);
32580
+ };
32581
+ const slice = (content, node) => content.slice(node.position.start.offset, node.position.end.offset);
32582
+ const lineOf = (node, end = false) => toLspPosition(end ? node.position.end : node.position.start).line;
32583
+ /** The box item's own text past its label: a formatter may fold a guidance line into it. */
32584
+ const inlineGuidance = (content, item) => {
32585
+ const first = item.children[0];
32586
+ return content.slice(first.position.start.offset, item.position.end.offset).slice(3).replace(/^[\s:—–-]+/, "").trim();
32587
+ };
32588
+ const guidanceOf = (parts) => parts.map((p) => p.replace(MARKER, "").trim()).filter((p) => p !== "").join("\n\n");
32589
+ /** `nodes` split at `box`: the body before it, the guidance after it. */
32590
+ const splitAtBox = (content, nodes, box) => {
32591
+ const at = nodes.findIndex((n) => n.type === "list" && n.children.includes(box));
32592
+ const list = nodes[at];
32593
+ const boxAt = list.children.indexOf(box);
32594
+ const bodyNodes = nodes.slice(0, boxAt > 0 ? at + 1 : at);
32595
+ const trailing = list.children.slice(boxAt + 1);
32596
+ const after = nodes.slice(at + 1);
32597
+ return {
32598
+ bodyNodes,
32599
+ body: bodyNodes.map((n) => n === list ? content.slice(n.position.start.offset, box.position.start.offset).trimEnd() : slice(content, n)).join("\n\n"),
32600
+ notBody: list.children.slice(boxAt),
32601
+ guidance: guidanceOf([inlineGuidance(content, box), ...[...trailing, ...after].map((n) => slice(content, n))]),
32602
+ last: after.at(-1) ?? trailing.at(-1) ?? box
32603
+ };
32604
+ };
32605
+ const entryOf = (content, title, noise, headingLine, nodes, endLine) => {
32606
+ const notes = nodes.filter((n) => n.type !== "footnoteDefinition");
32607
+ const boxes = notes.flatMap((n) => n.type === "list" ? n.children.filter((i) => isBox(i, content)) : []);
32608
+ const box = boxes[0];
32609
+ const split = box === void 0 ? {
32610
+ bodyNodes: notes,
32611
+ body: notes.map((n) => slice(content, n)).join("\n\n"),
32612
+ notBody: [],
32613
+ guidance: ""
32614
+ } : splitAtBox(content, notes, box);
32615
+ return {
32616
+ title,
32617
+ body: split.body,
32618
+ noise,
32619
+ fix: box?.checked === true,
32620
+ guidance: split.guidance,
32621
+ boxes: boxes.length,
32622
+ headingLine,
32623
+ bodyNodes: split.bodyNodes,
32624
+ ...box === void 0 ? {} : { box },
32625
+ notBody: new Set(split.notBody),
32626
+ lastLine: split.last === void 0 ? headingLine : lineOf(split.last, true),
32627
+ endLine
32628
+ };
32629
+ };
32630
+ const parse = (content) => {
32631
+ const tree = parseMarkdown(content);
32632
+ const entries = [];
32633
+ let noise = false;
32634
+ let open;
32635
+ const close = (endLine) => {
32636
+ if (open !== void 0) entries.push(entryOf(content, open.title, noise, open.line, open.nodes, endLine));
32637
+ open = void 0;
32638
+ };
32639
+ for (const node of tree.children) {
32640
+ if (node.type === "heading" && node.depth <= 2) {
32641
+ close(lineOf(node) - 1);
32642
+ const text = headingText$1(content, node, (t) => t.replace(MARKER, ""));
32643
+ if (node.depth === 1) noise = text === NOISE_HEADING;
32644
+ else open = {
32645
+ title: text,
32646
+ line: lineOf(node),
32647
+ nodes: []
32648
+ };
32649
+ continue;
32650
+ }
32651
+ open?.nodes.push(node);
32652
+ }
32653
+ close(content.split(/\r?\n/).length - 1);
32654
+ return entries;
32655
+ };
32656
+ const entryFindings = (content) => parse(content).flatMap((e) => {
32657
+ if (e.boxes === 0) return [{
32658
+ message: `"${e.title}" has no \`- [ ] fix\` box — restore it, ticked to fix the finding or unticked to drop it`,
32659
+ line: e.headingLine
32660
+ }];
32661
+ if (e.boxes > 1) return [{
32662
+ message: `"${e.title}" has ${e.boxes} \`fix\` boxes — keep exactly one`,
32663
+ line: e.headingLine
32664
+ }];
32665
+ if (!e.fix && e.guidance !== "") return [{
32666
+ message: `"${e.title}" has guidance under an unticked box — tick it to fix the finding, or delete the text to drop it`,
32667
+ line: lineOf(e.box)
32668
+ }];
32669
+ return [];
32670
+ });
32671
+ const findingsView = (content) => {
32672
+ const noteAt = noteLookup(content);
32673
+ return { nodes: parse(content).map((e, index) => {
32674
+ const note = noteAt(e.headingLine);
32675
+ return {
32676
+ title: e.title,
32677
+ anchor: {
32678
+ kind: "chunk",
32679
+ index
32680
+ },
32681
+ group: e.noise ? NOISE_HEADING : OPEN_GROUP,
32682
+ ...e.boxes === 1 ? { checked: e.fix } : {},
32683
+ ...e.guidance === "" ? {} : { note: e.guidance },
32684
+ ...note?.thread !== void 0 ? { thread: note.thread } : {},
32685
+ body: blockNodesOfRun(content, e.bodyNodes, { skipListItem: (i) => e.notBody.has(i) })
32686
+ };
32687
+ }) };
32688
+ };
32689
+ const findingsOutline = (content) => {
32690
+ const lines = content.split(/\r?\n/);
32691
+ return [...parse(content).map((e) => ({
32692
+ name: e.title,
32693
+ detail: e.noise ? "noise" : e.fix ? "fix" : "drop",
32694
+ range: spanRange(lines, e.headingLine, Math.max(e.headingLine, e.endLine)),
32695
+ selectionRange: lineRange(lines, e.headingLine)
32696
+ })), ...threadOutlineNodes(content).map((t) => t.node)];
32697
+ };
32698
+ const findingsAnnotate = (content, anchor, text) => {
32699
+ const entries = parse(content);
32700
+ const lines = content.split(/\r?\n/);
32701
+ const resolved = (() => {
32702
+ if (anchor.kind === "chunk") {
32703
+ const e = entries[anchor.index];
32704
+ return e && {
32705
+ line: e.headingLine,
32706
+ blockEndLine: e.headingLine,
32707
+ key: `chunk:${anchor.index}`
32708
+ };
32709
+ }
32710
+ if (anchor.kind !== "paragraph") return void 0;
32711
+ const block = entries.flatMap((e) => e.bodyNodes).find((n) => anchor.line >= lineOf(n) && anchor.line <= lineOf(n, true));
32712
+ return block && {
32713
+ line: anchor.line,
32714
+ blockEndLine: lineOf(block, true),
32715
+ key: `paragraph:${anchor.line}`
32716
+ };
32717
+ })();
32718
+ if (resolved === void 0) return {
32719
+ ok: false,
32720
+ reason: "anchor-not-found"
32721
+ };
32722
+ const result = footnoteAttachEdits(content, {
32723
+ ...resolved,
32724
+ endCharacter: (lines[resolved.line] ?? "").length
32725
+ }, text);
32726
+ return result.ok ? {
32727
+ ok: true,
32728
+ edits: result.edits
32729
+ } : result;
32730
+ };
32731
+ /** Ticks or unticks a finding's box; `text` replaces its guidance, and unticking drops it. */
32732
+ const findingsApply = (content, anchor, opts) => {
32733
+ if (anchor.kind !== "chunk") return {
32734
+ ok: false,
32735
+ reason: "anchor-not-found"
32736
+ };
32737
+ const e = parse(content)[anchor.index];
32738
+ if (e?.box === void 0 || e.boxes !== 1) return {
32739
+ ok: false,
32740
+ reason: "anchor-not-found"
32741
+ };
32742
+ const fix = opts.checked ?? e.fix;
32743
+ const guidance = fix ? (opts.text ?? e.guidance).trim() : "";
32744
+ if (fix === e.fix && guidance === e.guidance) return {
32745
+ ok: true,
32746
+ edits: []
32747
+ };
32748
+ const lines = content.split(/\r?\n/);
32749
+ const eol = eolOf(content);
32750
+ const start = lineOf(e.box);
32751
+ const box = `${lines[start].match(/^\s*[-*+]\s+/)[0]}[${fix ? "x" : " "}] fix`;
32752
+ const text = guidance === "" ? box : `${box}${eol}${eol}${guidance.split(/\r?\n/).join(eol)}`;
32753
+ return {
32754
+ ok: true,
32755
+ edits: [{
32756
+ range: {
32757
+ start: {
32758
+ line: start,
32759
+ character: 0
32760
+ },
32761
+ end: lineRange(lines, e.lastLine).end
32762
+ },
32763
+ newText: text
32764
+ }]
32765
+ };
32766
+ };
32767
+ //#endregion
32567
32768
  //#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]]);
32769
+ /** The built-in registry: mode name → its format, in registry order. The single place the built-in formats are wired into names — everything else in this module dispatches over a resolved `SteeringFormat`, never a mode-name string. */
32770
+ const REGISTRY = /* @__PURE__ */ new Map([
32771
+ ["qa", qaDescriptor],
32772
+ ["review", reviewDescriptor],
32773
+ ["findings", {
32774
+ sample: `# Quality
32775
+
32776
+ ## A finding a lens wrote[^nafind1]
32777
+
32778
+ [^nafind1]:
32779
+ Attached via the phone UI, this note shows a comment on a finding with a
32780
+ \`multi word code span\` that runs past eighty characters in total length.
32781
+
32782
+ What the lens found, and where.
32783
+
32784
+ - [x] fix
32785
+
32786
+ Fix it the simple way.
32787
+
32788
+ # Dropped as noise
32789
+
32790
+ ## A finding the judge called noise
32791
+
32792
+ Why the judge dropped it.
32793
+
32794
+ - [ ] fix
32795
+ `,
32796
+ validate: (content) => [...entryFindings(content), ...threadFindings(content)],
32797
+ outline: findingsOutline,
32798
+ actions: (content, range) => threadReplyActions(content, range.start),
32799
+ view: findingsView,
32800
+ annotate: findingsAnnotate,
32801
+ apply: findingsApply,
32802
+ clearTicks: (content) => content,
32803
+ openThreads: openThreadsOf
32804
+ }]
32805
+ ]);
32570
32806
  [...REGISTRY.keys()];
32571
32807
  /** 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. */
32572
32808
  const steeringFormatFor = (mode) => REGISTRY.get(mode);
@@ -32830,53 +33066,6 @@ const NoteSheet = ({ anchor, note, thread, onSave, onDismiss, onDone, busy, titl
32830
33066
  });
32831
33067
  };
32832
33068
  //#endregion
32833
- //#region src/web/notes.ts
32834
- /** The thread with the human's saved reply applied the way the server applies it: appended to an open thread, replacing the last `H` entry of one waiting on the agent. Idempotent against a refetch that already carries the reply. */
32835
- const withReply = (thread, text) => {
32836
- const entries = thread.waitingOn === "human" ? [...thread.entries, {
32837
- author: "me",
32838
- text
32839
- }] : [...thread.entries.slice(0, -1), {
32840
- author: "me",
32841
- text
32842
- }];
32843
- return {
32844
- ...thread,
32845
- entries,
32846
- waitingOn: "agent"
32847
- };
32848
- };
32849
- const sameAnchor = (a, b) => JSON.stringify(a) === JSON.stringify(b);
32850
- /** Every node, its `body` blocks and its `children`, depth-first. */
32851
- const flatten = (nodes) => nodes.flatMap((node) => [
32852
- node,
32853
- ...flatten(node.body ?? []),
32854
- ...flatten(node.children ?? [])
32855
- ]);
32856
- const nodeAt = (nodes, anchor) => flatten(nodes).find((n) => sameAnchor(n.anchor, anchor));
32857
- /**
32858
- * The note text a sheet should open with. A `paragraph` anchor can name
32859
- * either a top-level prose block OR a block riding in one of `body` (a
32860
- * question's own body), so the lookup walks both — searching `nodes` alone
32861
- * reopens a body block's note with an empty field, silently offering to
32862
- * replace text the reader can still see behind the sheet.
32863
- *
32864
- * A thread opens EMPTY for a reply when the agent spoke last, and prefilled
32865
- * with the last `H` entry when it waits on the agent (saving replaces it).
32866
- */
32867
- const existingNoteFor = (nodes, anchor, overrides) => {
32868
- const node = nodeAt(nodes, anchor);
32869
- if (anchor.kind === "paragraph") {
32870
- const saved = overrides[anchor.line];
32871
- if (saved !== void 0) return saved;
32872
- }
32873
- if (node?.thread) {
32874
- if (node.thread.waitingOn === "human") return "";
32875
- return node.thread.entries.findLast((e) => e.author === "me")?.text ?? "";
32876
- }
32877
- return anchor.kind === "paragraph" ? node?.note : void 0;
32878
- };
32879
- //#endregion
32880
33069
  //#region node_modules/zustand/esm/vanilla.mjs
32881
33070
  const createStoreImpl = (createState) => {
32882
33071
  let state;
@@ -33354,6 +33543,53 @@ const SaveIndicator = ({ refusal, saveStatus, expanded, failedCount, otherFailed
33354
33543
  });
33355
33544
  };
33356
33545
  //#endregion
33546
+ //#region src/web/notes.ts
33547
+ /** The thread with the human's saved reply applied the way the server applies it: appended to an open thread, replacing the last `H` entry of one waiting on the agent. Idempotent against a refetch that already carries the reply. */
33548
+ const withReply = (thread, text) => {
33549
+ const entries = thread.waitingOn === "human" ? [...thread.entries, {
33550
+ author: "me",
33551
+ text
33552
+ }] : [...thread.entries.slice(0, -1), {
33553
+ author: "me",
33554
+ text
33555
+ }];
33556
+ return {
33557
+ ...thread,
33558
+ entries,
33559
+ waitingOn: "agent"
33560
+ };
33561
+ };
33562
+ const sameAnchor = (a, b) => JSON.stringify(a) === JSON.stringify(b);
33563
+ /** Every node, its `body` blocks and its `children`, depth-first. */
33564
+ const flatten = (nodes) => nodes.flatMap((node) => [
33565
+ node,
33566
+ ...flatten(node.body ?? []),
33567
+ ...flatten(node.children ?? [])
33568
+ ]);
33569
+ const nodeAt = (nodes, anchor) => flatten(nodes).find((n) => sameAnchor(n.anchor, anchor));
33570
+ /**
33571
+ * The note text a sheet should open with. A `paragraph` anchor can name
33572
+ * either a top-level prose block OR a block riding in one of `body` (a
33573
+ * question's own body), so the lookup walks both — searching `nodes` alone
33574
+ * reopens a body block's note with an empty field, silently offering to
33575
+ * replace text the reader can still see behind the sheet.
33576
+ *
33577
+ * A thread opens EMPTY for a reply when the agent spoke last, and prefilled
33578
+ * with the last `H` entry when it waits on the agent (saving replaces it).
33579
+ */
33580
+ const existingNoteFor = (nodes, anchor, overrides) => {
33581
+ const node = nodeAt(nodes, anchor);
33582
+ if (anchor.kind === "paragraph") {
33583
+ const saved = overrides[anchor.line];
33584
+ if (saved !== void 0) return saved;
33585
+ }
33586
+ if (node?.thread) {
33587
+ if (node.thread.waitingOn === "human") return "";
33588
+ return node.thread.entries.findLast((e) => e.author === "me")?.text ?? "";
33589
+ }
33590
+ return anchor.kind === "paragraph" ? node?.note : void 0;
33591
+ };
33592
+ //#endregion
33357
33593
  //#region src/web/screens/InlineRun.tsx
33358
33594
  /**
33359
33595
  * One projected `image`'s own rendering — its own component (not a case
@@ -33594,6 +33830,255 @@ const ProseBlocks = ({ nodes, noteOverrides, onOpenNote, readOnly }) => /* @__PU
33594
33830
  }, node.anchor.kind === "paragraph" ? node.anchor.line : index))]
33595
33831
  });
33596
33832
  //#endregion
33833
+ //#region src/web/screens/Findings.tsx
33834
+ const MODE = "findings";
33835
+ const NOISE_GROUP = "Dropped as noise";
33836
+ const decisionOf = (node) => ({
33837
+ fix: node.checked === true,
33838
+ guidance: node.note ?? ""
33839
+ });
33840
+ const FindingRow = ({ node, index, decision, onToggle, onEditGuidance }) => {
33841
+ const id = `finding-fix-${index}`;
33842
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", {
33843
+ "data-testid": `finding-${index}`,
33844
+ className: `border-b border-divider px-3 py-3 ${decision.fix ? "border-l-4 border-l-accent bg-surface" : ""}`,
33845
+ children: [
33846
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", {
33847
+ htmlFor: id,
33848
+ className: "flex min-h-11 items-start gap-3",
33849
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", {
33850
+ id,
33851
+ type: "checkbox",
33852
+ "data-testid": `finding-checkbox-${index}`,
33853
+ className: "mt-1 size-5 shrink-0",
33854
+ checked: decision.fix,
33855
+ onChange: (e) => onToggle(e.target.checked)
33856
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
33857
+ className: "min-w-0 flex-1",
33858
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
33859
+ className: "block font-medium",
33860
+ children: node.title
33861
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
33862
+ className: "block text-small text-muted",
33863
+ children: decision.fix ? "Fix" : "Drop"
33864
+ })]
33865
+ })]
33866
+ }),
33867
+ node.body !== void 0 && node.body.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
33868
+ className: "pl-8",
33869
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ProseBlocks, {
33870
+ nodes: node.body,
33871
+ noteOverrides: {},
33872
+ onOpenNote: () => {},
33873
+ readOnly: true
33874
+ })
33875
+ }),
33876
+ decision.fix && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
33877
+ className: "pl-8",
33878
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
33879
+ variant: "ghost",
33880
+ "data-testid": `finding-guidance-${index}`,
33881
+ onClick: onEditGuidance,
33882
+ "aria-label": decision.guidance === "" ? void 0 : `Edit guidance: ${decision.guidance}`,
33883
+ className: "w-full px-0 text-left text-small font-normal text-muted",
33884
+ children: decision.guidance !== "" ? decision.guidance : "Add guidance for the fixer (optional)"
33885
+ })
33886
+ })
33887
+ ]
33888
+ });
33889
+ };
33890
+ const Group = ({ title, entries, render }) => entries.length === 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", {
33891
+ "aria-label": title,
33892
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", {
33893
+ className: "m-0 px-3 pt-4 pb-1 text-small font-semibold text-muted uppercase",
33894
+ children: title
33895
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ul", {
33896
+ className: "m-0 list-none p-0",
33897
+ children: entries.map(({ node, index }) => render(node, index))
33898
+ })]
33899
+ });
33900
+ /** The finding gate: tick a finding to fix it, with optional guidance; unticked ones are dropped. */
33901
+ const FindingsView = ({ view, filePath, content, isLoading, readError, onDecide, onDone, busy }) => {
33902
+ const overlay = useWriteStore((s) => s.overlay);
33903
+ const save = useWriteStore((s) => s.save);
33904
+ const [editing, setEditing] = (0, import_react.useState)(void 0);
33905
+ if (view === void 0) {
33906
+ const refusal = readError === void 0 ? void 0 : readRefusalFrom(readError);
33907
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Notice, {
33908
+ tone: isLoading ? "info" : "error",
33909
+ children: isLoading ? "Loading the findings…" : refusal !== void 0 ? messageForReadRefusal(refusal) : "Could not load the findings."
33910
+ });
33911
+ }
33912
+ const decisionAt = (node) => overlay[cellKey(filePath, node.anchor, "decision")]?.value ?? decisionOf(node);
33913
+ const decide = (node, next) => {
33914
+ save({
33915
+ cell: cellKey(filePath, node.anchor, "decision"),
33916
+ optimistic: next,
33917
+ write: onDecide?.(node.anchor, next.fix ? {
33918
+ checked: true,
33919
+ text: next.guidance
33920
+ } : { checked: false })
33921
+ }).catch(() => {});
33922
+ };
33923
+ const entries = view.nodes.map((node, index) => ({
33924
+ node,
33925
+ index
33926
+ }));
33927
+ const problems = content === void 0 ? [] : steeringFormatFor(MODE).validate(content);
33928
+ const editingNode = editing === void 0 ? void 0 : view.nodes[editing];
33929
+ const fixing = view.nodes.filter((n) => decisionAt(n).fix).length;
33930
+ const row = (node, index) => {
33931
+ const decision = decisionAt(node);
33932
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FindingRow, {
33933
+ node,
33934
+ index,
33935
+ decision,
33936
+ onToggle: (fix) => decide(node, {
33937
+ fix,
33938
+ guidance: fix ? decision.guidance : ""
33939
+ }),
33940
+ onEditGuidance: () => setEditing(index)
33941
+ }, index);
33942
+ };
33943
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
33944
+ "data-testid": "findings-screen",
33945
+ className: "flex h-full min-h-0 flex-1 flex-col",
33946
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
33947
+ className: "min-h-0 flex-1 overflow-auto",
33948
+ children: [
33949
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
33950
+ className: "m-0 px-3 pt-3 text-small text-muted",
33951
+ children: "Tick a finding to have it fixed; an unticked one is dropped. Anything bigger belongs in the final review."
33952
+ }),
33953
+ view.nodes.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Notice, {
33954
+ "data-testid": "findings-empty",
33955
+ children: "No findings to decide on."
33956
+ }),
33957
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Group, {
33958
+ title: "Open",
33959
+ entries: entries.filter(({ node }) => node.group !== NOISE_GROUP),
33960
+ render: row
33961
+ }),
33962
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Group, {
33963
+ title: NOISE_GROUP,
33964
+ entries: entries.filter(({ node }) => node.group === NOISE_GROUP),
33965
+ render: row
33966
+ }),
33967
+ problems.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Notice, {
33968
+ tone: "error",
33969
+ "data-testid": "findings-problems",
33970
+ role: "status",
33971
+ children: problems.map((p) => p.message).join(" ")
33972
+ })
33973
+ ]
33974
+ }), onDone !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
33975
+ className: "flex shrink-0 items-center justify-between gap-3 border-t border-border p-3",
33976
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
33977
+ "data-testid": "findings-summary",
33978
+ className: "text-small text-muted",
33979
+ children: `${fixing} to fix, ${view.nodes.length - fixing} to drop`
33980
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
33981
+ variant: "primary",
33982
+ "data-testid": "findings-done",
33983
+ onClick: () => {
33984
+ save({
33985
+ cell: cellKey(filePath, void 0, "done"),
33986
+ optimistic: true,
33987
+ mutate: onDone
33988
+ }).catch(() => {});
33989
+ },
33990
+ disabled: busy === true || problems.length > 0,
33991
+ children: "Done"
33992
+ })]
33993
+ })]
33994
+ }), editingNode !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NoteSheet, {
33995
+ anchor: editingNode.anchor,
33996
+ title: "Guidance for the fixer",
33997
+ label: "Guidance",
33998
+ note: decisionAt(editingNode).guidance,
33999
+ onSave: (_anchor, text) => {
34000
+ setEditing(void 0);
34001
+ decide(editingNode, {
34002
+ fix: true,
34003
+ guidance: text.trim()
34004
+ });
34005
+ },
34006
+ onDismiss: () => setEditing(void 0)
34007
+ })] });
34008
+ };
34009
+ const HandedBackPanel$3 = () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Notice, {
34010
+ "data-testid": "handed-back-panel",
34011
+ role: "status",
34012
+ "aria-live": "polite",
34013
+ children: "Handed back — this turn is done."
34014
+ });
34015
+ const FindingsInner = ({ filePath, data, isLoading, error }) => {
34016
+ const indicatorProps = useSaveIndicatorProps();
34017
+ const pending = useWriteStore((s) => s.pendingCount);
34018
+ const [formatNotice, setFormatNotice] = (0, import_react.useState)(void 0);
34019
+ const utils = trpc.useUtils();
34020
+ const setValue = trpc.setValue.useMutation({ onSettled: () => utils.readSteeringFile.invalidate({
34021
+ filePath,
34022
+ mode: MODE
34023
+ }) });
34024
+ const done = trpc.done.useMutation();
34025
+ const onDecide = (anchor, opts) => (cas) => setValue.mutateAsync({
34026
+ filePath,
34027
+ ...cas,
34028
+ mode: MODE,
34029
+ anchor,
34030
+ ...opts
34031
+ }).then((result) => {
34032
+ setFormatNotice(result.formatNotice);
34033
+ return result;
34034
+ });
34035
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
34036
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SaveIndicator, { ...indicatorProps }),
34037
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FormatNoticeBanner, {
34038
+ notice: formatNotice,
34039
+ onDismiss: () => setFormatNotice(void 0)
34040
+ }),
34041
+ done.isSuccess ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(HandedBackPanel$3, {}) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FindingsView, {
34042
+ view: data?.view,
34043
+ content: data?.content,
34044
+ filePath,
34045
+ isLoading,
34046
+ readError: error,
34047
+ onDecide,
34048
+ onDone: () => done.mutateAsync({}),
34049
+ busy: pending > 0
34050
+ })
34051
+ ] });
34052
+ };
34053
+ /** The finding gate's screen: reads `.gtd/QUALITY.md` as the `findings` format and writes each decision back. */
34054
+ const Findings = ({ filePath }) => {
34055
+ const utils = trpc.useUtils();
34056
+ const query = trpc.readSteeringFile.useQuery({
34057
+ filePath,
34058
+ mode: MODE
34059
+ });
34060
+ const refetchTokens = async () => {
34061
+ const fresh = await utils.readSteeringFile.fetch({
34062
+ filePath,
34063
+ mode: MODE
34064
+ });
34065
+ return {
34066
+ expectedHeadSha: fresh.headSha,
34067
+ expectedContentHash: fresh.contentHash
34068
+ };
34069
+ };
34070
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(WriteStoreProvider, {
34071
+ tokens: query.data,
34072
+ refetchTokens,
34073
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FindingsInner, {
34074
+ filePath,
34075
+ data: query.data,
34076
+ isLoading: query.isLoading,
34077
+ error: query.error
34078
+ })
34079
+ });
34080
+ };
34081
+ //#endregion
33597
34082
  //#region src/web/screens/FreeForm.tsx
33598
34083
  /**
33599
34084
  * The empty-document save's own anchor line: `freeform.ts#freeFormApply`'s
@@ -35432,7 +35917,7 @@ const openable = (step) => step?.status === "ok" && step.file !== void 0;
35432
35917
  * and no route back to one. `trpc.step`'s own `mode` picks the screen —
35433
35918
  * `"review"` always means `Review` (that screen's own `readSteeringFile` call
35434
35919
  * is hardcoded to `mode: "review"`, matching `.gtd/REVIEW.md`'s one format);
35435
- * `"qa"` means `Plan`; anything else (absent, or an unregistered mode name)
35920
+ * `"qa"` means `Plan`; `"findings"` means `Findings`; anything else (absent, or an unregistered mode name)
35436
35921
  * falls through to `FreeForm`, which reads the file as plain markdown and
35437
35922
  * offers only a note per block, plus a textfield when it's empty. Neither
35438
35923
  * screen offers a way back to a list — `done` is the only
@@ -35479,7 +35964,7 @@ const App = () => {
35479
35964
  children: step.mode === "review" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Review, { filePath: step.file }) : step.mode === "qa" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Plan, {
35480
35965
  filePath: step.file,
35481
35966
  mode: "qa"
35482
- }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FreeForm, {
35967
+ }) : step.mode === "findings" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Findings, { filePath: step.file }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FreeForm, {
35483
35968
  filePath: step.file,
35484
35969
  mode: step.mode
35485
35970
  })