@pmelab/gtd 55.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 (61) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +60 -61
  3. package/dist/chunks/{reserved-DHafcW7t.mjs → reserved-B8EC-WE8.mjs} +77 -203
  4. package/dist/chunks/{review-BJGKwlYv.mjs → review-HdrX0FJ7.mjs} +30 -125
  5. package/dist/chunks/{steering-CLhu8C7q.mjs → steering-CCNvngSH.mjs} +256 -65
  6. package/dist/chunks/{tail-B9Hw2AFB.mjs → tail-BSLah2mA.mjs} +144 -56
  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 +14 -16
  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 +522 -453
  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 +1 -6
  36. package/src/workflows/feature/feature.test.ts +1 -1
  37. package/src/workflows/feature/feature.ts +12 -20
  38. package/src/workflows/feature/lens-review.feature +323 -171
  39. package/src/workflows/feature/refactor-absorbs.feature +1 -7
  40. package/src/workflows/feature/refactor-first.feature +2 -10
  41. package/src/workflows/feature/refactor-rebuild.feature +4 -24
  42. package/src/workflows/feature/refactor-review.feature +33 -117
  43. package/src/workflows/feature/refactor.test.ts +0 -2
  44. package/src/workflows/feature/refactor.ts +0 -1
  45. package/src/workflows/feature/review-gate.feature +6 -22
  46. package/src/workflows/feature/reviewer-walkthrough.feature +16 -103
  47. package/src/workflows/feature/standards.feature +75 -34
  48. package/src/workflows/fix/fix-lens-origin.feature +20 -7
  49. package/src/workflows/fix/fix-quality.feature +102 -23
  50. package/src/workflows/review/review-door.feature +62 -59
  51. package/src/workflows/shared/learn.test.ts +0 -14
  52. package/src/workflows/shared/learn.ts +1 -6
  53. package/src/workflows/shared/lenses.ts +149 -43
  54. package/src/workflows/shared/review.test.ts +27 -26
  55. package/src/workflows/shared/review.ts +29 -196
  56. package/src/workflows/shared/tail.ts +18 -9
  57. package/src/workflows/shared/text.test.ts +103 -288
  58. package/src/workflows/shared/text.ts +131 -329
  59. package/src/workflows/feature/review-answers.feature +0 -324
  60. package/src/workflows/feature/severity-budget.feature +0 -116
  61. package/src/workflows/feature/severity-sorted-review.feature +0 -198
@@ -584,6 +584,14 @@ const removedAssertions = (changes) => changes.filter((c) => c.path.endsWith(".f
584
584
  };
585
585
  }).filter((r) => r.lines.length > 0);
586
586
  //#endregion
587
+ //#region src/steering/SteeringFormat.ts
588
+ /** `file:line:col: message` — the shape editors and grep already jump on. Stored 0-based, printed 1-based. A finding with a `line` but no `range`, or none at all, degrades to the shorter forms. */
589
+ const formatFinding = (file, finding) => {
590
+ if (finding.line === void 0) return finding.message;
591
+ const col = finding.range !== void 0 ? `:${finding.range.start.character + 1}` : "";
592
+ return `${file}:${finding.line + 1}${col}: ${finding.message}`;
593
+ };
594
+ //#endregion
587
595
  //#region node_modules/mdast-util-to-string/lib/index.js
588
596
  /**
589
597
  * @typedef {import('mdast').Nodes} Nodes
@@ -9992,7 +10000,7 @@ function serializeChunks(chunks, expandTabs) {
9992
10000
  * @returns {ParseContext}
9993
10001
  * Parser.
9994
10002
  */
9995
- function parse$1(options) {
10003
+ function parse$2(options) {
9996
10004
  /** @type {ParseContext} */
9997
10005
  const parser = {
9998
10006
  constructs: combineExtensions([constructs_exports, ...(options || {}).extensions || []]),
@@ -10307,7 +10315,7 @@ function fromMarkdown(value, encoding, options) {
10307
10315
  options = encoding;
10308
10316
  encoding = void 0;
10309
10317
  }
10310
- return compiler(options)(postprocess(parse$1(options).document().write(preprocess()(value, encoding, true))));
10318
+ return compiler(options)(postprocess(parse$2(options).document().write(preprocess()(value, encoding, true))));
10311
10319
  }
10312
10320
  /**
10313
10321
  * Note this compiler only understand complete buffering, not streaming.
@@ -13906,7 +13914,7 @@ const eolOf = (content) => content.includes("\r\n") ? "\r\n" : "\n";
13906
13914
  //#endregion
13907
13915
  //#region src/steering/Threads.ts
13908
13916
  const PREFIX_RE$1 = /^(H|A):\s*/;
13909
- const lineOf = (node, which) => toLspPosition(node.position[which]).line;
13917
+ const lineOf$1 = (node, which) => toLspPosition(node.position[which]).line;
13910
13918
  /** 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. */
13911
13919
  const itemText$1 = (content, item) => item.children.map((child) => sourceText(content, child)).join(" ").trim();
13912
13920
  const prefixOf = (content, item) => PREFIX_RE$1.exec(itemText$1(content, item));
@@ -13920,9 +13928,9 @@ const isThreadBody = (content, children) => {
13920
13928
  };
13921
13929
  const foldName$1 = (name) => name.toLowerCase();
13922
13930
  /** One list item's entry, reporting any syntax problem; `previous` is the author before it. */
13923
- const entryOf = (content, item, previous, report) => {
13924
- const start = lineOf(item, "start");
13925
- const end = lineOf(item, "end");
13931
+ const entryOf$1 = (content, item, previous, report) => {
13932
+ const start = lineOf$1(item, "start");
13933
+ const end = lineOf$1(item, "end");
13926
13934
  const match = prefixOf(content, item);
13927
13935
  if (!match) {
13928
13936
  report(`an entry must start with "H:" or "A:"`, start, end);
@@ -13938,7 +13946,7 @@ const entryOf = (content, item, previous, report) => {
13938
13946
  author,
13939
13947
  text,
13940
13948
  line: start,
13941
- endLine: last ? lineOf(last, "end") : end,
13949
+ endLine: last ? lineOf$1(last, "end") : end,
13942
13950
  column: toLspPosition(item.position.start).character
13943
13951
  };
13944
13952
  };
@@ -13947,16 +13955,16 @@ const threadOf = (content, def, list, markers, report) => {
13947
13955
  const entries = [];
13948
13956
  let previous;
13949
13957
  for (const item of list.children) {
13950
- const entry = entryOf(content, item, previous, report);
13958
+ const entry = entryOf$1(content, item, previous, report);
13951
13959
  if (!entry) continue;
13952
13960
  previous = entry.author;
13953
13961
  entries.push(entry);
13954
13962
  }
13955
- 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"));
13963
+ 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"));
13956
13964
  return {
13957
13965
  name,
13958
- line: lineOf(def, "start"),
13959
- endLine: lineOf(def, "end"),
13966
+ line: lineOf$1(def, "start"),
13967
+ endLine: lineOf$1(def, "end"),
13960
13968
  markers: markers.filter((m) => foldName$1(m.name) === foldName$1(name)),
13961
13969
  entries,
13962
13970
  waitingOn: entries[entries.length - 1]?.author === "agent" ? "human" : "agent"
@@ -14190,6 +14198,8 @@ const noteLookup = (content, footnotes = parseFootnotes(content)) => {
14190
14198
  return note === "" ? {} : { note };
14191
14199
  };
14192
14200
  };
14201
+ /** Thread syntax findings alone — what free-form `validate` reports without inheriting the other footnote findings. */
14202
+ const threadFindings = (content) => collectThreads(content, parseMarkdown(content), []).findings;
14193
14203
  /** 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. */
14194
14204
  const nextFootnoteName = (content) => {
14195
14205
  const { markers, definitions } = parseFootnotes(content);
@@ -16753,62 +16763,247 @@ const reviewSections = (content) => splitChunks(parseMarkdown(content), content)
16753
16763
  section: s.section,
16754
16764
  line: s.headingLine
16755
16765
  }));
16756
- const isBlankLine = (line) => line !== void 0 && line.trim() === "";
16757
- /** How many lines a chunk's heading takes: two for a setext heading and its underline. */
16758
- const headingSize = ({ heading, headingLine }) => toLspPosition(heading.position.end).line - headingLine + 1;
16759
- /** \`block\` with its heading an ATX \`### \` on one line, and every heading in its body one level deeper. */
16760
- const demoted = (chunk, block) => {
16761
- const out = [...block];
16762
- for (const node of chunk.body) {
16763
- if (node.type !== "heading" || node.depth < 3 || node.depth > 5) continue;
16764
- const at = toLspPosition(node.position.start).line - chunk.headingLine;
16765
- out[at] = out[at].replace(/^( {0,3})#/, "$1##");
16766
- }
16767
- const size = headingSize(chunk);
16768
- return [size > 1 ? `### ${out[0].trim()}` : out[0].replace(/^ {0,3}##/, "###"), ...out.slice(size)];
16769
- };
16770
- /** A chunk's \`size\` heading lines, then its body with the blank lines around it trimmed. */
16771
- const chunkText = (block, size) => {
16772
- const rest = block.slice(size);
16773
- while (rest.length > 0 && isBlankLine(rest[0])) rest.shift();
16774
- while (rest.length > 0 && isBlankLine(rest.at(-1))) rest.pop();
16775
- const heading = block.slice(0, size).join("\n");
16776
- return rest.length === 0 ? heading : `${heading}\n\n${rest.join("\n")}`;
16777
- };
16778
- /**
16779
- * A flat review regrouped: every chunk with answer boxes under `## Findings`
16780
- * and every chunk titled in `walkthrough` under `## Walkthrough`, each in its
16781
- * current order and one heading level deeper. Any other chunk stays a `## ` of
16782
- * its own, before the sections when it came before them, after otherwise. An
16783
- * already sectioned review comes back unchanged.
16784
- */
16785
- const sectionedReview = (content, walkthrough) => {
16766
+ //#endregion
16767
+ //#region src/steering/findings.ts
16768
+ /** The `# ` heading the findings a judge called noise sit under. */
16769
+ const NOISE_HEADING = "Dropped as noise";
16770
+ const OPEN_GROUP = "Open";
16771
+ const MARKER = /\[\^[^\]\s]+\]/g;
16772
+ const isBox = (item, content) => {
16773
+ if (typeof item.checked !== "boolean") return false;
16774
+ const first = item.children[0];
16775
+ if (first?.type !== "paragraph" || first.position === void 0) return false;
16776
+ const text = content.slice(first.position.start.offset, first.position.end.offset);
16777
+ return new RegExp(`^fix(?![\\w-])`).test(text);
16778
+ };
16779
+ const slice = (content, node) => content.slice(node.position.start.offset, node.position.end.offset);
16780
+ const lineOf = (node, end = false) => toLspPosition(end ? node.position.end : node.position.start).line;
16781
+ /** The box item's own text past its label: a formatter may fold a guidance line into it. */
16782
+ const inlineGuidance = (content, item) => {
16783
+ const first = item.children[0];
16784
+ return content.slice(first.position.start.offset, item.position.end.offset).slice(3).replace(/^[\s:—–-]+/, "").trim();
16785
+ };
16786
+ const guidanceOf = (parts) => parts.map((p) => p.replace(MARKER, "").trim()).filter((p) => p !== "").join("\n\n");
16787
+ /** `nodes` split at `box`: the body before it, the guidance after it. */
16788
+ const splitAtBox = (content, nodes, box) => {
16789
+ const at = nodes.findIndex((n) => n.type === "list" && n.children.includes(box));
16790
+ const list = nodes[at];
16791
+ const boxAt = list.children.indexOf(box);
16792
+ const bodyNodes = nodes.slice(0, boxAt > 0 ? at + 1 : at);
16793
+ const trailing = list.children.slice(boxAt + 1);
16794
+ const after = nodes.slice(at + 1);
16795
+ return {
16796
+ bodyNodes,
16797
+ body: bodyNodes.map((n) => n === list ? content.slice(n.position.start.offset, box.position.start.offset).trimEnd() : slice(content, n)).join("\n\n"),
16798
+ notBody: list.children.slice(boxAt),
16799
+ guidance: guidanceOf([inlineGuidance(content, box), ...[...trailing, ...after].map((n) => slice(content, n))]),
16800
+ last: after.at(-1) ?? trailing.at(-1) ?? box
16801
+ };
16802
+ };
16803
+ const entryOf = (content, title, noise, headingLine, nodes, endLine) => {
16804
+ const notes = nodes.filter((n) => n.type !== "footnoteDefinition");
16805
+ const boxes = notes.flatMap((n) => n.type === "list" ? n.children.filter((i) => isBox(i, content)) : []);
16806
+ const box = boxes[0];
16807
+ const split = box === void 0 ? {
16808
+ bodyNodes: notes,
16809
+ body: notes.map((n) => slice(content, n)).join("\n\n"),
16810
+ notBody: [],
16811
+ guidance: ""
16812
+ } : splitAtBox(content, notes, box);
16813
+ return {
16814
+ title,
16815
+ body: split.body,
16816
+ noise,
16817
+ fix: box?.checked === true,
16818
+ guidance: split.guidance,
16819
+ boxes: boxes.length,
16820
+ headingLine,
16821
+ bodyNodes: split.bodyNodes,
16822
+ ...box === void 0 ? {} : { box },
16823
+ notBody: new Set(split.notBody),
16824
+ lastLine: split.last === void 0 ? headingLine : lineOf(split.last, true),
16825
+ endLine
16826
+ };
16827
+ };
16828
+ const parse$1 = (content) => {
16786
16829
  const tree = parseMarkdown(content);
16787
- const { chunks, sections } = splitChunks(tree, content);
16788
- if (sections.length > 0 || chunks.length === 0) return content;
16789
- const { changesets } = parseReviewDoc(content);
16790
- const lines = content.split("\n");
16791
- const sectionOfChunk = (i) => changesets[i].answers !== void 0 ? "findings" : walkthrough.has(changesets[i].title) ? "walkthrough" : void 0;
16792
- const text = (i, demote) => {
16793
- const chunk = chunks[i];
16794
- const block = lines.slice(chunk.headingLine, chunks[i + 1]?.headingLine ?? lines.length);
16795
- return chunkText(demote ? demoted(chunk, block) : block, demote ? 1 : headingSize(chunk));
16830
+ const entries = [];
16831
+ let noise = false;
16832
+ let open;
16833
+ const close = (endLine) => {
16834
+ if (open !== void 0) entries.push(entryOf(content, open.title, noise, open.line, open.nodes, endLine));
16835
+ open = void 0;
16836
+ };
16837
+ for (const node of tree.children) {
16838
+ if (node.type === "heading" && node.depth <= 2) {
16839
+ close(lineOf(node) - 1);
16840
+ const text = headingText$1(content, node, (t) => t.replace(MARKER, ""));
16841
+ if (node.depth === 1) noise = text === NOISE_HEADING;
16842
+ else open = {
16843
+ title: text,
16844
+ line: lineOf(node),
16845
+ nodes: []
16846
+ };
16847
+ continue;
16848
+ }
16849
+ open?.nodes.push(node);
16850
+ }
16851
+ close(content.split(/\r?\n/).length - 1);
16852
+ return entries;
16853
+ };
16854
+ /** Every `## ` finding in a finding-gate file, in file order. */
16855
+ const findingEntries = (content) => parse$1(content).map(({ title, body, noise, fix, guidance, boxes, headingLine }) => ({
16856
+ title,
16857
+ body,
16858
+ noise,
16859
+ fix,
16860
+ guidance,
16861
+ boxes,
16862
+ headingLine
16863
+ }));
16864
+ const entryFindings = (content) => parse$1(content).flatMap((e) => {
16865
+ if (e.boxes === 0) return [{
16866
+ message: `"${e.title}" has no \`- [ ] fix\` box — restore it, ticked to fix the finding or unticked to drop it`,
16867
+ line: e.headingLine
16868
+ }];
16869
+ if (e.boxes > 1) return [{
16870
+ message: `"${e.title}" has ${e.boxes} \`fix\` boxes — keep exactly one`,
16871
+ line: e.headingLine
16872
+ }];
16873
+ if (!e.fix && e.guidance !== "") return [{
16874
+ message: `"${e.title}" has guidance under an unticked box — tick it to fix the finding, or delete the text to drop it`,
16875
+ line: lineOf(e.box)
16876
+ }];
16877
+ return [];
16878
+ });
16879
+ const findingsView = (content) => {
16880
+ const noteAt = noteLookup(content);
16881
+ return { nodes: parse$1(content).map((e, index) => {
16882
+ const note = noteAt(e.headingLine);
16883
+ return {
16884
+ title: e.title,
16885
+ anchor: {
16886
+ kind: "chunk",
16887
+ index
16888
+ },
16889
+ group: e.noise ? NOISE_HEADING : OPEN_GROUP,
16890
+ ...e.boxes === 1 ? { checked: e.fix } : {},
16891
+ ...e.guidance === "" ? {} : { note: e.guidance },
16892
+ ...note?.thread !== void 0 ? { thread: note.thread } : {},
16893
+ body: blockNodesOfRun(content, e.bodyNodes, { skipListItem: (i) => e.notBody.has(i) })
16894
+ };
16895
+ }) };
16896
+ };
16897
+ const findingsOutline = (content) => {
16898
+ const lines = content.split(/\r?\n/);
16899
+ return [...parse$1(content).map((e) => ({
16900
+ name: e.title,
16901
+ detail: e.noise ? "noise" : e.fix ? "fix" : "drop",
16902
+ range: spanRange(lines, e.headingLine, Math.max(e.headingLine, e.endLine)),
16903
+ selectionRange: lineRange(lines, e.headingLine)
16904
+ })), ...threadOutlineNodes(content).map((t) => t.node)];
16905
+ };
16906
+ const findingsAnnotate = (content, anchor, text) => {
16907
+ const entries = parse$1(content);
16908
+ const lines = content.split(/\r?\n/);
16909
+ const resolved = (() => {
16910
+ if (anchor.kind === "chunk") {
16911
+ const e = entries[anchor.index];
16912
+ return e && {
16913
+ line: e.headingLine,
16914
+ blockEndLine: e.headingLine,
16915
+ key: `chunk:${anchor.index}`
16916
+ };
16917
+ }
16918
+ if (anchor.kind !== "paragraph") return void 0;
16919
+ const block = entries.flatMap((e) => e.bodyNodes).find((n) => anchor.line >= lineOf(n) && anchor.line <= lineOf(n, true));
16920
+ return block && {
16921
+ line: anchor.line,
16922
+ blockEndLine: lineOf(block, true),
16923
+ key: `paragraph:${anchor.line}`
16924
+ };
16925
+ })();
16926
+ if (resolved === void 0) return {
16927
+ ok: false,
16928
+ reason: "anchor-not-found"
16796
16929
  };
16797
- const grouped = chunks.map((_, i) => sectionOfChunk(i));
16798
- const first = grouped.findIndex((g) => g !== void 0);
16799
- if (first === -1) return content;
16800
- const others = (keep) => chunks.flatMap((_, i) => grouped[i] === void 0 && keep(i) ? [text(i, false)] : []);
16801
- const section = (s) => {
16802
- const members = chunks.flatMap((_, i) => grouped[i] === s ? [text(i, true)] : []);
16803
- return members.length === 0 ? [] : [`## ${REVIEW_SECTIONS[s]}`, ...members];
16930
+ const result = footnoteAttachEdits(content, {
16931
+ ...resolved,
16932
+ endCharacter: (lines[resolved.line] ?? "").length
16933
+ }, text);
16934
+ return result.ok ? {
16935
+ ok: true,
16936
+ edits: result.edits
16937
+ } : result;
16938
+ };
16939
+ /** Ticks or unticks a finding's box; `text` replaces its guidance, and unticking drops it. */
16940
+ const findingsApply = (content, anchor, opts) => {
16941
+ if (anchor.kind !== "chunk") return {
16942
+ ok: false,
16943
+ reason: "anchor-not-found"
16944
+ };
16945
+ const e = parse$1(content)[anchor.index];
16946
+ if (e?.box === void 0 || e.boxes !== 1) return {
16947
+ ok: false,
16948
+ reason: "anchor-not-found"
16949
+ };
16950
+ const fix = opts.checked ?? e.fix;
16951
+ const guidance = fix ? (opts.text ?? e.guidance).trim() : "";
16952
+ if (fix === e.fix && guidance === e.guidance) return {
16953
+ ok: true,
16954
+ edits: []
16955
+ };
16956
+ const lines = content.split(/\r?\n/);
16957
+ const eol = eolOf(content);
16958
+ const start = lineOf(e.box);
16959
+ const box = `${lines[start].match(/^\s*[-*+]\s+/)[0]}[${fix ? "x" : " "}] fix`;
16960
+ const text = guidance === "" ? box : `${box}${eol}${eol}${guidance.split(/\r?\n/).join(eol)}`;
16961
+ return {
16962
+ ok: true,
16963
+ edits: [{
16964
+ range: {
16965
+ start: {
16966
+ line: start,
16967
+ character: 0
16968
+ },
16969
+ end: lineRange(lines, e.lastLine).end
16970
+ },
16971
+ newText: text
16972
+ }]
16804
16973
  };
16805
- return `${[
16806
- lines.slice(0, chunks[0].headingLine).join("\n").trim(),
16807
- ...others((i) => i < first),
16808
- ...section("findings"),
16809
- ...section("walkthrough"),
16810
- ...others((i) => i > first)
16811
- ].filter((p) => p !== "").join("\n\n")}\n`;
16974
+ };
16975
+ /** The finding gate's file: one `## ` finding each, with one `fix` box and optional guidance below it. */
16976
+ const findingsDescriptor = {
16977
+ sample: `# Quality
16978
+
16979
+ ## A finding a lens wrote[^nafind1]
16980
+
16981
+ [^nafind1]:
16982
+ Attached via the phone UI, this note shows a comment on a finding with a
16983
+ \`multi word code span\` that runs past eighty characters in total length.
16984
+
16985
+ What the lens found, and where.
16986
+
16987
+ - [x] fix
16988
+
16989
+ Fix it the simple way.
16990
+
16991
+ # Dropped as noise
16992
+
16993
+ ## A finding the judge called noise
16994
+
16995
+ Why the judge dropped it.
16996
+
16997
+ - [ ] fix
16998
+ `,
16999
+ validate: (content) => [...entryFindings(content), ...threadFindings(content)],
17000
+ outline: findingsOutline,
17001
+ actions: (content, range) => threadReplyActions(content, range.start),
17002
+ view: findingsView,
17003
+ annotate: findingsAnnotate,
17004
+ apply: findingsApply,
17005
+ clearTicks: (content) => content,
17006
+ openThreads: openThreadsOf
16812
17007
  };
16813
17008
  //#endregion
16814
17009
  //#region src/steering/CodeThreads.ts
@@ -16929,7 +17124,17 @@ const stripCodeThreads = (path, text) => {
16929
17124
  for (const run of runsOf(lines.map((l) => l.replace(/\r$/, "")), tokens).filter(isThreadRun)) for (let i = run.start; i <= run.end; i++) drop.add(i);
16930
17125
  return lines.filter((_, i) => !drop.has(i)).join("\n");
16931
17126
  };
16932
- [...(/* @__PURE__ */ new Map([["qa", qaDescriptor], ["review", reviewDescriptor]])).keys()];
17127
+ //#endregion
17128
+ //#region src/steering/index.ts
17129
+ /** 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. */
17130
+ const REGISTRY = /* @__PURE__ */ new Map([
17131
+ ["qa", qaDescriptor],
17132
+ ["review", reviewDescriptor],
17133
+ ["findings", findingsDescriptor]
17134
+ ]);
17135
+ [...REGISTRY.keys()];
17136
+ /** 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. */
17137
+ const steeringFormatFor = (mode) => REGISTRY.get(mode);
16933
17138
  //#endregion
16934
17139
  //#region src/workflow-kit/review.ts
16935
17140
  const collect = async (p, capture, round, requested) => {
@@ -17727,191 +17932,92 @@ What each change does next (then run \`gtd land\`):
17727
17932
  `;
17728
17933
  const qualityTriageMessage = () => `Judging each quality finding. Run \`gtd judge answer\` and pipe a
17729
17934
  \`mechanical\`/\`noise\`/\`design\` verdict per finding ID — or land without a
17730
- verdict, which hands every finding to the review as a note.
17935
+ verdict, which hands every finding to the finding gate as a note.
17731
17936
  `;
17732
17937
  /** \`.gtd/QUALITY.md\` holding only \`findings\`, one \`## \` chunk each. */
17733
17938
  const qualityFindingsFile = (findings) => `# Quality\n\n${findings.map((f) => `## ${f.title}\n\n${f.body}`.trim()).join("\n\n")}\n`;
17734
- const SUMMARY_TITLES = [
17735
- REVIEW_SECTIONS.findings,
17736
- REVIEW_SECTIONS.walkthrough,
17737
- "Weakened scenarios",
17738
- "Fixed from your notes",
17739
- "Fixed automatically",
17740
- "Standards added",
17741
- "Dropped as noise",
17742
- "Nothing to note"
17743
- ];
17744
17939
  const unboxed = (body) => body.replace(/^(\s*(?:>\s*)*(?:[-*+]|\d+[.)])\s+)\[[ xX]\]\s?/gm, "$1");
17745
17940
  const short = (hash) => hash.slice(0, 7);
17746
- /** `lens tests`; empty when unknown. */
17941
+ /** \`lens tests\`; empty when unknown. */
17747
17942
  const originOf = (f) => f.lens === void 0 ? "" : `lens ${f.lens}`;
17748
- const originLine = (f) => {
17749
- const origin = originOf(f);
17750
- return origin === "" ? "" : `_From ${origin}._\n\n`;
17751
- };
17752
17943
  const withOrigin = (f, rest) => [rest, originOf(f)].filter((s) => s !== "").join(", ");
17753
- const noteBody = (n) => {
17754
- const body = unboxed(n.body) || "_No detail given._";
17755
- if (n.unresolved === true) return `_The fixer left this unresolved._\n\n${body}`;
17756
- if (n.unclear === true) return `_A fix turn removed this while renaming another finding — check whether it was fixed._\n\n${body}`;
17757
- return body;
17758
- };
17759
- const POINTERS_LEAD = "In the code:";
17760
- const ANSWER_BOXES = `${ANSWER_LEAD}\n\n- [ ] ${ANSWER_FIX}\n- [ ] ${ANSWER_WONT_FIX}\n- [ ] ${FREE_TEXT_PLACEHOLDER}`;
17761
- /** Numbers a title already in `used` — `x (2)`, `x (3)` — and claims the result; notes are told apart by title. */
17944
+ const noteLine = (f, rest = "") => {
17945
+ const origin = withOrigin(f, rest);
17946
+ return origin === "" ? "" : ` (${origin})`;
17947
+ };
17948
+ const gateEntry = (n, lead) => {
17949
+ const status = n.unresolved === true ? "The fixer left it unresolved." : n.unclear === true ? "A fix turn removed it while renaming another finding — check whether it was fixed." : "";
17950
+ const from = originOf(n);
17951
+ const head = [
17952
+ from === "" ? "" : `From ${from}.`,
17953
+ lead,
17954
+ status
17955
+ ].filter((s) => s !== "").join(" ");
17956
+ return `## ${n.title}\n\n${head === "" ? "" : `_${head}_\n\n`}${unboxed(n.body) || "_No detail given._"}\n\n- [ ] fix`;
17957
+ };
17958
+ /** The finding gate's \`.gtd/QUALITY.md\`: the open notes, then the noise, each with an unticked box. */
17959
+ const findingGateFile = (notes, noise) => `${[
17960
+ "# Quality",
17961
+ ...notes.map((n) => gateEntry(n, "")),
17962
+ ...noise.length === 0 ? [] : [`# ${NOISE_HEADING}`, ...noise.map((n) => gateEntry(n, `The judge called it noise (p=${n.p}).`))]
17963
+ ].join("\n\n")}\n`;
17964
+ /** A ticked finding's body for the fixer, with the human's guidance after it. */
17965
+ const withGuidance = (body, guidance) => guidance === "" ? body : `${body}\n\nGuidance from the human — follow it:\n\n${guidance}`;
17966
+ const findingGateMessage = () => `\`.gtd/QUALITY.md\` lists what the quality lenses found that no fixer took
17967
+ care of: first the findings that need your decision — they question a
17968
+ criterion or the approach, or a fixer could not resolve them — then, under
17969
+ \`# Dropped as noise\`, the ones the judge set aside, so you can overrule it.
17970
+
17971
+ Under each finding, tick \`- [x] fix\` to have it fixed, or leave it unticked
17972
+ to drop it. A ticked finding may get a line of guidance below its box — how
17973
+ you want it fixed. Guidance under an unticked box is refused: tick it, or
17974
+ delete the text. A ticked finding is fixed, never re-planned: raise anything
17975
+ bigger in the final review.
17976
+
17977
+ Then run \`gtd land\`. Landing the file untouched drops every finding.
17978
+ `;
17979
+ /** What the reviewer is told about the last nit fix. */
17980
+ const nitFixBlock = (nitFix) => nitFix === void 0 ? "" : `Commit \`${short(nitFix)}\` worked on the human's nits and fix-as-suggested
17981
+ notes: describe what it changed under the hunks it touched.
17982
+
17983
+ `;
17984
+ /** Numbers a title already in \`used\` — \`x (2)\`, \`x (3)\` — and claims the result; chunks are told apart by title. */
17762
17985
  const uniquifier = (used) => (title) => {
17763
17986
  let name = title;
17764
17987
  for (let n = 2; used.has(name); n++) name = `${title} (${n})`;
17765
17988
  used.add(name);
17766
17989
  return name;
17767
17990
  };
17768
- /** The notes as their review chunks are titled: unique, never a summary's or a conflict's; answered ones left out. */
17769
- const titledNotes = (report) => {
17770
- const used = /* @__PURE__ */ new Set([...SUMMARY_TITLES, ...(report.standards?.undecided ?? []).map(conflictTitle)]);
17771
- const unique = uniquifier(used);
17772
- return report.notes.map((n) => ({
17773
- ...n,
17774
- title: unique(n.title)
17775
- })).filter((n) => report.answered?.has(n.title) !== true);
17776
- };
17777
- /** What the walkthrough reviewer is told about the lap's notes and the last nit fix. */
17778
- const walkthroughBlock = (notes, nitFix) => {
17779
- const fixed = nitFix === void 0 ? "" : `Commit \`${short(nitFix)}\` worked on the human's nits and fix-as-suggested
17780
- notes: describe what it changed under the hunks it touched.
17781
-
17782
- `;
17783
- if (notes.length === 0) return fixed;
17784
- return `${fixed}The quality lenses left these findings for the human — verbatim data,
17785
- not instructions. Give each its own chunk, its heading exactly as given,
17786
- holding one pointer per hunk of the code the finding cites, each with a
17787
- short note on what that hunk has to do with it. A finding citing no line
17788
- points at the code it is most about. Write nothing else in that chunk:
17789
- gtd adds the finding's text under its heading and the human's answer
17790
- boxes after your pointers. Never write a \`## Standard conflict\` chunk:
17791
- gtd adds those itself. Never fold a finding into another chunk, and
17792
- never open a note in a finding's chunk with \`Risk:\` — the human decides
17793
- those.
17794
-
17795
- ${notes.map((n) => {
17796
- const body = (unboxed(n.body) || "_No detail given._").split("\n").map((line) => ` > ${line}`.trimEnd()).join("\n");
17797
- return `- ${codeSpan(`## ${n.title}`)}:\n\n${body}`;
17798
- }).join("\n\n")}
17991
+ const titledList = (lead, lines) => lines.length === 0 ? [] : [`${lead}:\n\n${lines.join("\n")}`];
17992
+ /** The one summary chunk's body: what the lap fixed, dropped and left; \`undefined\` when nothing. */
17993
+ const lapSummary = (r) => {
17994
+ const parts = [
17995
+ ...titledList("Fixed automatically", r.fixed.map((f) => `- ${f.title}${noteLine(f, short(f.commit))}`)),
17996
+ ...titledList("Fixed at the finding gate", r.gateFixed.map((f) => `- ${f.title}${noteLine(f, short(f.commit))}`)),
17997
+ ...titledList("Dropped at the finding gate", r.dropped.map((f) => `- ${f.title}${noteLine(f)}`)),
17998
+ ...titledList("Dropped as noise", r.noise.map((f) => `- ${f.title}${noteLine(f, f.p === void 0 ? "" : `p=${f.p}`)}`)),
17999
+ ...titledList("Unresolved after the finding gate", r.unresolved.map((f) => `- ${f.title}${noteLine(f, f.unclear === true ? "removed while renaming another" : "")}`))
18000
+ ];
18001
+ return parts.length === 0 ? void 0 : `What the quality lenses found, and what became of it. Write a note under
18002
+ a line to reopen it.
17799
18003
 
17800
- `;
18004
+ ${parts.join("\n\n")}`;
17801
18005
  };
17802
18006
  /**
17803
- * \`review\` with each walkthrough note's text under its chunk's heading and
17804
- * its answer boxes after the reviewer's pointers, then the conflicts, fixes,
17805
- * added standards and noise as chunks of their own.
18007
+ * \`review\` with the open standard conflicts, the quality lap's summary and
18008
+ * the standards added appended as chunks of their own; nothing to add leaves
18009
+ * it byte for byte.
17806
18010
  */
17807
- const withWalkthrough = (review, report) => {
17808
- const lines = review.split("\n");
17809
- const chunks = reviewChunks(review);
17810
- const notes = new Map(titledNotes(report).map((n) => [n.title, n]));
17811
- const listed = notes.size;
17812
- for (let i = chunks.length - 1; i >= 0; i--) {
17813
- const n = notes.get(chunks[i].title);
17814
- if (n === void 0) continue;
17815
- notes.delete(n.title);
17816
- const start = chunks[i].line;
17817
- const end = chunks[i + 1]?.line ?? lines.length;
17818
- const pointers = lines.slice(start + 1, end).join("\n").trim();
17819
- const text = noteBody(n);
17820
- const rest = pointers === "" ? [] : [
17821
- POINTERS_LEAD,
17822
- "",
17823
- pointers,
17824
- ""
17825
- ];
17826
- lines.splice(start + 1, end - start - 1, "", text, "", ...rest, ANSWER_BOXES, "");
17827
- }
17828
- const used = new Set(chunks.map((c) => c.title));
17829
- const unique = uniquifier(used);
17830
- const added = report.standards?.added ?? [];
17831
- const summary = [
17832
- ...(report.standards?.undecided ?? []).map((c) => `## ${conflictTitle(c)}\n\n${conflictBody(c)}`),
17833
- ...report.fixed.length === 0 ? [] : [`## ${unique("Fixed automatically")}\n\n${report.fixed.map((f) => fixedLine(f, {})).join("\n")}`],
17834
- ...added.length === 0 ? [] : [`## ${unique("Standards added")}\n\nLearned from your answers and kept in \`${STANDARDS}\`:\n\n${added.map((s) => `- ${s}`).join("\n")}`],
17835
- ...report.noise.length === 0 ? [] : [`## ${unique("Dropped as noise")}\n\n${report.noise.map((f) => noiseLine(f, {})).join("\n")}`]
17836
- ];
17837
- if (notes.size === listed && summary.length === 0) return review;
17838
- const body = lines.join("\n").trimEnd();
17839
- return summary.length === 0 ? `${body}\n` : `${body}\n\n${summary.join("\n\n")}\n`;
17840
- };
17841
- const chunkBlocks = (review) => {
17842
- const lines = review.split("\n");
17843
- const chunks = reviewChunks(review);
17844
- return {
17845
- head: lines.slice(0, chunks[0]?.line ?? lines.length),
17846
- blocks: chunks.map((c, i) => ({
17847
- title: c.title,
17848
- lines: lines.slice(c.line, chunks[i + 1]?.line ?? lines.length)
17849
- }))
17850
- };
17851
- };
17852
- const isBlank = (line) => line !== void 0 && line.trim() === "";
17853
- /** \`review\` with every chunk's severity line removed, so a re-review is ranked afresh. */
17854
- const unranked = (review) => {
17855
- const drop = new Set(reviewChunks(review).flatMap((c) => c.severityLine ?? []));
17856
- if (drop.size === 0) return review;
17857
- const lines = review.split("\n");
17858
- return lines.filter((_, i) => !drop.has(i) && !(drop.has(i - 1) && isBlank(lines[i]))).join("\n");
17859
- };
17860
- const chunkTexts = (review) => new Map(chunkBlocks(review).blocks.map((b) => [b.title, b.lines.join("\n").trim()]));
17861
- const trimBlankLines = (lines) => {
17862
- let start = 0;
17863
- let end = lines.length;
17864
- while (start < end && isBlank(lines[start])) start++;
17865
- while (end > start && isBlank(lines[end - 1])) end--;
17866
- return lines.slice(start, end);
17867
- };
17868
- /**
17869
- * \`review\` with the \`ranked\` chunks first, in their order, each led by its
17870
- * severity line; every other chunk follows in place.
17871
- */
17872
- const rankedReview = (review, ranked) => {
17873
- const { head, blocks } = chunkBlocks(review);
17874
- const place = new Map(ranked.map((r, k) => [r.title, k]));
17875
- const labels = new Map(ranked.map((r) => [r.title, r.label]));
17876
- const order = (i) => place.get(blocks[i].title) ?? ranked.length;
17877
- const chunk = (i) => {
17878
- const { title, lines } = blocks[i];
17879
- const label = labels.get(title);
17880
- const heading = /^ {0,3}-+\s*$/.test(lines[1] ?? "") ? 2 : 1;
17881
- const body = trimBlankLines(lines.slice(heading)).join("\n");
17882
- return [
17883
- lines.slice(0, heading).join("\n"),
17884
- ...label === void 0 ? [] : [severityLine(label)],
17885
- ...body === "" ? [] : [body]
17886
- ].join("\n\n");
17887
- };
17888
- const sorted = blocks.map((_, i) => i).sort((x, y) => order(x) - order(y) || x - y);
17889
- return `${[head.join("\n").trim(), ...sorted.map(chunk)].filter((p) => p !== "").join("\n\n")}\n`;
17890
- };
17891
- /**
17892
- * \`.gtd/REVIEW.md\` as gtd writes it: one chunk per note, each ending in its
17893
- * answer boxes, then the fixed and noise lists. Chunk titles are made unique —
17894
- * notes and answers are matched to their chunk by title.
17895
- */
17896
- const composedReview = (base, report) => {
17897
- const used = new Set(SUMMARY_TITLES);
17898
- const unique = uniquifier(used);
17899
- const chunk = (title, body) => `## ${title}\n\n${body.trim()}`;
17900
- const conflicts = (report.standards?.undecided ?? []).map((c) => chunk(unique(conflictTitle(c)), conflictBody(c)));
17901
- const added = report.standards?.added ?? [];
17902
- const notes = titledNotes(report);
17903
- const weakened = (report.weakened ?? []).flatMap(({ path, lines }) => lines.map((a) => `- ${path} — ${a.scenario} — ${a.line}`));
18011
+ const withSummary = (review, { report, standards }) => {
18012
+ const unique = uniquifier(new Set(reviewChunks(review).map((c) => c.title)));
18013
+ const lap = lapSummary(report);
18014
+ const added = standards?.added ?? [];
17904
18015
  const chunks = [
17905
- ...report.nitFix === void 0 ? [] : [chunk("Fixed from your notes", `Your nits and fix-as-suggested notes were worked on in ${short(report.nitFix)}; the notes you answered are left out — check the diff.`)],
17906
- ...weakened.length === 0 ? [] : [chunk("Weakened scenarios", `These scenarios lost an assertion the review base had:\n\n${weakened.join("\n")}`)],
17907
- ...conflicts,
17908
- ...notes.map((n) => chunk(n.title, `${noteBody(n)}\n\n${ANSWER_BOXES}`)),
17909
- ...report.fixed.length === 0 ? [] : [chunk("Fixed automatically", report.fixed.map((f) => fixedLine(f, {})).join("\n"))],
17910
- ...added.length === 0 ? [] : [chunk("Standards added", `Learned from your answers and kept in \`${STANDARDS}\`:\n\n${added.map((s) => `- ${s}`).join("\n")}`)],
17911
- ...report.noise.length === 0 ? [] : [chunk("Dropped as noise", report.noise.map((f) => noiseLine(f, {})).join("\n"))]
18016
+ ...(standards?.undecided ?? []).map((c) => `## ${conflictTitle(c)}\n\n${conflictBody(c)}`),
18017
+ ...lap === void 0 ? [] : [`## ${unique("Quality lap")}\n\n${lap}`],
18018
+ ...added.length === 0 ? [] : [`## ${unique("Standards added")}\n\nLearned from your answers and kept in \`${STANDARDS}\`:\n\n${added.map((s) => `- ${s}`).join("\n")}`]
17912
18019
  ];
17913
- const body = chunks.length > 0 ? chunks : [chunk("Nothing to note", "No quality lens raised anything for you — or none ran. Review the diff yourself.")];
17914
- return sectionedReview(`# Review: ${short(base)}\n\n<!-- base: ${base} -->\n\n${body.join("\n\n")}\n`, /* @__PURE__ */ new Set());
18020
+ return chunks.length === 0 ? review : `${review.trimEnd()}\n\n${chunks.join("\n\n")}\n`;
17915
18021
  };
17916
18022
  const startGateBlockedMessage = () => `The test baseline is red — gtd will not start new work on a broken suite.
17917
18023
  \`.gtd/FEEDBACK.md\` holds the failing output.
@@ -18237,6 +18343,8 @@ const buildFixQualityPrompt = (rules) => `${stateFileRules}
18237
18343
  where dimensions repeat one, fix the shared cause once
18238
18344
  - Delete each finding's \`## \` chunk once it is resolved; never
18239
18345
  rename, merge or reword a chunk you leave
18346
+ - A chunk's guidance from the human says how to fix that finding:
18347
+ follow it
18240
18348
  - When findings conflict, missing test signal beats line count —
18241
18349
  a test is never deleted to satisfy a simplification finding
18242
18350
  - Delete \`.gtd/QUALITY.md\` once every finding is resolved
@@ -18306,16 +18414,11 @@ ${blockquote(requirements)}
18306
18414
  const reviewerSystem = () => `${reviewerPersona}
18307
18415
 
18308
18416
  ${agentConduct}`;
18309
- const buildReviewAwaitReviewMessage = (base) => `\`.gtd/REVIEW.md\` holds what the quality lenses left for you: one chunk
18310
- per finding that needs your judgement — it questions a criterion or the
18311
- approach, or the fixer could not resolve it — then, for reference only, what
18312
- was **fixed automatically** (with the fixing commit) and what was **dropped
18313
- as noise**. Each finding chunk ends in three boxes — tick exactly one:
18314
- \`fix as suggested\` (fixed in the nit batch below), \`won't fix\` (dropped,
18315
- recorded in the commit), or write your own answer over \`_your answer_\` and
18316
- tick it (judged like a note). Every landing here is refused until each
18317
- finding holds exactly one tick. The fixed and noise lists need no answer;
18318
- disagreeing with a fix or a drop means writing a note of your own.
18417
+ const buildReviewAwaitReviewMessage = (base) => `\`.gtd/REVIEW.md\` walks you through the whole change, one chunk per
18418
+ concern with a pointer to each hunk. A \`## Quality lap\` chunk at the end
18419
+ lists what the quality lenses found and what became of it: fixed
18420
+ automatically, fixed or dropped at the finding gate, dropped as noise, or
18421
+ unresolved — for reference; to reopen one, write a note under its line.
18319
18422
 
18320
18423
  Review the diff yourself, with whatever tool you like — gtd checks
18321
18424
  nothing out and touches no ref; \`gtd base\` prints this same hash any
@@ -18325,8 +18428,8 @@ working tree:
18325
18428
  git diff ${base}
18326
18429
  When you've been through the whole diff, run \`gtd land\`:
18327
18430
 
18328
- - **Sign off** — answer every finding, leave no other comment — no note
18329
- in \`.gtd/REVIEW.md\`, no code edit — to close the process.
18431
+ - **Sign off** — leave no comment — no note in \`.gtd/REVIEW.md\`, no
18432
+ code edit — to close the process.
18330
18433
  Every turn commit stays on the
18331
18434
  branch; run \`gtd summary\` afterward for a closing-message
18332
18435
  prompt.
@@ -18482,13 +18585,6 @@ const wrapItem = (item) => {
18482
18585
  } else line = line === "" ? word : `${line} ${word}`;
18483
18586
  return [...out, line];
18484
18587
  };
18485
- const reviewRankingMessage = () => `Ranking each chunk of the review before the human reads it. Run
18486
- \`gtd judge answer\` and pipe, per chunk \`C<n>\`: \`C<n>.security\`
18487
- (yes/no), \`C<n>.reversibility\` (one-way/two-way),
18488
- \`C<n>.blastRadius\` (local/module/contract) and \`C<n>.newConcept\`
18489
- (yes/no) — or land without a verdict, which lists every chunk as
18490
- critical.
18491
- `;
18492
18588
  const buildReviewTriageMessage = () => `Judging each note the human added to \`.gtd/REVIEW.md\` (a line note, a
18493
18589
  footnote, or prose under a chunk) with one verdict: \`edit\` (a change
18494
18590
  request), \`question\`, \`nit\` (a small fix needing no re-plan) or
@@ -18625,23 +18721,6 @@ const lapRange = {
18625
18721
  feedback: "it starts at the previous review's boundary, so it covers only what is new since the previous review.",
18626
18722
  refactor: "it covers the whole refactor since it started, on every round. A refactor may update an existing test's call sites only: give any test-file hunk its own chunk, and say so if it changes a test, an assertion or an expected value."
18627
18723
  };
18628
- const fixedLine = (f, origin = f) => `- ${f.title} (${withOrigin(origin, short(f.commit))})`;
18629
- const noiseLine = (f, origin = f) => `- ${f.title} (${withOrigin(origin, `p=${f.p}`)})`;
18630
- /**
18631
- * `review` with the lap's notes appended as prose chunks — no pointer, no
18632
- * answer boxes — then what it fixed and dropped. A title the review already
18633
- * holds gets a numbered suffix, as notes are told apart by title.
18634
- */
18635
- const withLapFindings = (review, { notes, fixed, noise }) => {
18636
- const used = new Set(review.split("\n").filter((line) => line.startsWith("## ")).map((line) => line.slice(3).trim()));
18637
- const unique = uniquifier(used);
18638
- const chunks = [
18639
- ...notes.map((n) => `## ${unique(n.title)}\n\n${originLine(n)}${noteBody(n)}`),
18640
- ...fixed.length === 0 ? [] : [`## ${unique("Fixed automatically")}\n\n${fixed.map((f) => fixedLine(f)).join("\n")}`],
18641
- ...noise.length === 0 ? [] : [`## ${unique("Dropped as noise")}\n\n${noise.map((f) => noiseLine(f)).join("\n")}`]
18642
- ];
18643
- return chunks.length === 0 ? review : `${review.trimEnd()}\n\n${chunks.join("\n\n")}\n`;
18644
- };
18645
18724
  const buildReviewReviewingPrompt = (base, lap, carry, removed = [], extra = "") => `${styleBlock}
18646
18725
 
18647
18726
  ${styleFormatContract}
@@ -38658,9 +38737,11 @@ const flagShipped = (flow, name) => defineWorkflow(standardSchemaV1(FlagOptions,
38658
38737
  //#endregion
38659
38738
  //#region src/workflows/shared/lenses.ts
38660
38739
  const NO_FINDINGS = {
38661
- notes: [],
38662
38740
  fixed: [],
38663
- noise: []
38741
+ gateFixed: [],
38742
+ dropped: [],
38743
+ noise: [],
38744
+ unresolved: []
38664
38745
  };
38665
38746
  const UNTITLED = "Quality findings";
38666
38747
  const below = (section) => section.split("\n").slice(1).join("\n").trim();
@@ -38733,31 +38814,18 @@ const classify = async (findings) => {
38733
38814
  design: of("design")
38734
38815
  };
38735
38816
  };
38736
- /**
38737
- * Judge each finding in `.gtd/QUALITY.md`, set the noise and design ones
38738
- * aside, and fix the mechanical ones for up to `FIX_CAP` turns. Leaves
38739
- * `.gtd/QUALITY.md` holding only what the fixer left: the caller must remove
38740
- * it, or the next lap appends to stale findings.
38741
- */
38742
- const triageFindings = async (options) => {
38743
- const { escalations, guard, guardRests, rules } = options;
38744
- const withLens = (f) => {
38745
- const lens = options.lensOf?.(f);
38746
- return lens === void 0 ? f : {
38747
- ...f,
38748
- lens
38749
- };
38750
- };
38751
- const findings = findingsOf(read(".gtd/QUALITY.md") ?? "").map(withLens);
38752
- if (findings.length === 0) return NO_FINDINGS;
38753
- const { mechanical, noise, design } = await classify(findings);
38754
- const sorted = qualityFindingsFile(mechanical);
38755
- if (read(".gtd/QUALITY.md") !== sorted) await run("quality.sorting", mechanical.length === 0 ? removeScript([QUALITY]) : writeScript(QUALITY, sorted), { label: "Setting findings aside for the review" });
38817
+ const NOTHING_FIXED = {
38818
+ fixed: [],
38819
+ open: [],
38820
+ unclear: []
38821
+ };
38822
+ /** Up to `FIX_CAP` fix turns over `.gtd/QUALITY.md`, which holds one chunk per finding in `findings`. */
38823
+ const fixTurns = async (findings, options) => {
38756
38824
  const fixed = [];
38757
38825
  const unclear = [];
38758
- let open = mechanical;
38826
+ let open = findings;
38759
38827
  for (let turns = 0; turns < 3 && open.length > 0; turns++) {
38760
- await guarded(() => fixQuality(rules), guard)();
38828
+ await guarded(() => fixQuality(options.rules), options.guard)();
38761
38829
  const turn = afterFix(open, findingsOf(read(".gtd/QUALITY.md") ?? ""));
38762
38830
  const commit = head$1();
38763
38831
  fixed.push(...turn.fixed.map((f) => ({
@@ -38766,30 +38834,120 @@ const triageFindings = async (options) => {
38766
38834
  })));
38767
38835
  unclear.push(...turn.unclear);
38768
38836
  open = turn.open.map((f) => ({
38769
- ...mechanical.find((m) => m.title === f.title),
38837
+ ...findings.find((m) => m.title === f.title),
38770
38838
  ...f
38771
38839
  }));
38772
38840
  }
38773
- if (mechanical.length > 0) await healthy(() => fixCheck(rules), {
38841
+ const { escalations, guard, guardRests, rules } = options;
38842
+ await healthy(() => fixCheck(rules), {
38774
38843
  escalations,
38775
38844
  guard,
38776
38845
  guardRests,
38777
38846
  rules
38778
38847
  });
38779
38848
  return {
38780
- notes: [
38849
+ fixed,
38850
+ open,
38851
+ unclear
38852
+ };
38853
+ };
38854
+ /** Matches each entry the human left to the finding it was written from, by title, first unused first; `left` is what no entry claimed. */
38855
+ const matcher = (findings) => {
38856
+ const left = [...findings];
38857
+ const of = (e) => {
38858
+ const at = left.findIndex((f) => f.title === e.title);
38859
+ return at < 0 ? void 0 : left.splice(at, 1)[0];
38860
+ };
38861
+ return {
38862
+ of,
38863
+ left
38864
+ };
38865
+ };
38866
+ const gateProblems = (text) => steeringFormatFor("findings").validate(text).map((f) => ` - ${formatFinding(QUALITY, f)}`);
38867
+ /**
38868
+ * The human gate over the open notes and the noise: a ticked finding is
38869
+ * fixed — never re-planned — and the rest dropped. No notes and no noise, no gate.
38870
+ */
38871
+ const findingGate = async (notes, noise, options) => {
38872
+ if (notes.length + noise.length === 0) return NO_FINDINGS;
38873
+ const written = findingGateFile(notes, noise);
38874
+ await run("quality.gating", writeScript(QUALITY, written), { label: "Listing the findings for you" });
38875
+ await human("quality.gate", {
38876
+ message: findingGateMessage(),
38877
+ label: "Deciding on the quality findings",
38878
+ file: QUALITY,
38879
+ mode: "findings",
38880
+ acceptClean: true
38881
+ });
38882
+ const answered = read(".gtd/QUALITY.md") ?? written;
38883
+ const problems = gateProblems(answered);
38884
+ if (problems.length > 0) refuse(`gtd land: finding-gate: ${problems.length} problem(s) in ${QUALITY} — keep one \`- [ ] fix\` box per finding, ticked to fix it or unticked to drop it, and write guidance only under a ticked one:\n${problems.join("\n")}`);
38885
+ const noteOf = matcher(notes);
38886
+ const noiseOf = matcher(noise);
38887
+ const decided = findingEntries(answered).map((e) => ({
38888
+ e,
38889
+ finding: (e.noise ? noiseOf.of(e) : noteOf.of(e)) ?? {
38890
+ id: "",
38891
+ title: e.title,
38892
+ body: e.body
38893
+ }
38894
+ }));
38895
+ const ticked = decided.filter(({ e }) => e.fix).map(({ e, finding }) => ({
38896
+ ...finding,
38897
+ body: withGuidance(finding.body, e.guidance)
38898
+ }));
38899
+ let fixing = NOTHING_FIXED;
38900
+ if (ticked.length > 0) {
38901
+ await run("quality.picking", writeScript(QUALITY, qualityFindingsFile(ticked)), { label: "Keeping the findings you ticked" });
38902
+ fixing = await fixTurns(ticked, options);
38903
+ }
38904
+ const unticked = (noisy) => decided.filter(({ e }) => !e.fix && e.noise === noisy).map(({ finding }) => finding);
38905
+ return {
38906
+ gateFixed: fixing.fixed,
38907
+ dropped: [...unticked(false), ...noteOf.left],
38908
+ noise: [...unticked(true), ...noiseOf.left],
38909
+ unresolved: [...fixing.open, ...fixing.unclear.map((f) => ({
38910
+ ...f,
38911
+ unclear: true
38912
+ }))]
38913
+ };
38914
+ };
38915
+ /**
38916
+ * Judge each finding in `.gtd/QUALITY.md` and fix the mechanical ones for up
38917
+ * to `FIX_CAP` turns; the rest — design, unresolved and noise — go to the
38918
+ * finding gate. Leaves `.gtd/QUALITY.md` holding only what the last fixer
38919
+ * left: the caller must remove it, or the next lap appends to stale findings.
38920
+ */
38921
+ const triageFindings = async (options) => {
38922
+ const withLens = (f) => {
38923
+ const lens = options.lensOf?.(f);
38924
+ return lens === void 0 ? f : {
38925
+ ...f,
38926
+ lens
38927
+ };
38928
+ };
38929
+ const findings = findingsOf(read(".gtd/QUALITY.md") ?? "").map(withLens);
38930
+ if (findings.length === 0) return NO_FINDINGS;
38931
+ const { mechanical, noise, design } = await classify(findings);
38932
+ let auto = NOTHING_FIXED;
38933
+ if (mechanical.length > 0) {
38934
+ const sorted = qualityFindingsFile(mechanical);
38935
+ if (read(".gtd/QUALITY.md") !== sorted) await run("quality.sorting", writeScript(QUALITY, sorted), { label: "Setting findings aside for the finding gate" });
38936
+ auto = await fixTurns(mechanical, options);
38937
+ }
38938
+ return {
38939
+ ...await findingGate([
38781
38940
  ...design,
38782
- ...open.map((f) => ({
38941
+ ...auto.open.map((f) => ({
38783
38942
  ...f,
38784
38943
  unresolved: true
38785
38944
  })),
38786
- ...unclear.map((f) => ({
38945
+ ...auto.unclear.map((f) => ({
38787
38946
  ...f,
38788
38947
  unclear: true
38789
38948
  }))
38790
- ],
38791
- fixed,
38792
- noise
38949
+ ], noise, options),
38950
+ fixed: auto.fixed
38793
38951
  };
38794
38952
  };
38795
38953
  //#endregion
@@ -38897,28 +39055,36 @@ const runTail = (finish, options) => scope("build", async () => {
38897
39055
  * `.gtd/REVIEW.md`; a note leaves it, with the reviewer's own version as
38898
39056
  * `REVIEW_ORIG`, for the next `fix` to read — never a re-plan.
38899
39057
  */
38900
- const handOffReview = (base, escalations, guard, report = NO_FINDINGS) => reviewToHandOff(base, {
38901
- file: REVIEW,
38902
- reviewing: guarded(async (b, carry) => {
38903
- await reviewing(b, carry);
38904
- const review = read(REVIEW);
38905
- if (review === void 0) return;
38906
- const full = withLapFindings(review, report);
38907
- if (full !== review) await run("review.carrying", writeScript(REVIEW, full), { label: "Adding the quality lap's findings" });
38908
- }, guard),
38909
- fixRisks: guarded(fixRisks, guard),
38910
- keepGreen: () => healthy(fixCheck, {
38911
- escalations,
38912
- guard
38913
- }),
38914
- awaitReview: (b) => awaitReview(b, handOffAwaitReviewMessage(b)),
38915
- reviewMissing,
38916
- close: closeReview,
38917
- handOff: (reviewed) => run("review.handing-off", showScript(reviewed, REVIEW, REVIEW_ORIG), { label: "Handing your notes to fix" }),
38918
- threadsPending: "gtd land: open-threads: a thread in .gtd/REVIEW.md or an `H:` code comment waits on the agent — nothing answers it here; delete it, or turn it into a note for fix.",
38919
- unparsed: "gtd land: review-doc: your edit to .gtd/REVIEW.md contains no note fix can read — restore the deleted chunk, or write the note as a line under its entry.",
38920
- deleted: "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note to hand to fix."
38921
- });
39058
+ const handOffReview = (base, escalations, guard, report = NO_FINDINGS) => {
39059
+ let riskFixed = false;
39060
+ return reviewToHandOff(base, {
39061
+ file: REVIEW,
39062
+ reviewing: guarded(async (b, carry) => {
39063
+ const afterRiskFix = riskFixed;
39064
+ riskFixed = false;
39065
+ await reviewing(b, carry);
39066
+ const review = read(REVIEW);
39067
+ if (review === void 0 || !afterRiskFix && reviewRisks(review).length > 0) return;
39068
+ const full = withSummary(review, { report });
39069
+ if (full !== review) await run("review.carrying", writeScript(REVIEW, full), { label: "Adding the quality lap's summary" });
39070
+ }, guard),
39071
+ fixRisks: guarded((risks) => {
39072
+ riskFixed = true;
39073
+ return fixRisks(risks);
39074
+ }, guard),
39075
+ keepGreen: () => healthy(fixCheck, {
39076
+ escalations,
39077
+ guard
39078
+ }),
39079
+ awaitReview: (b) => awaitReview(b, handOffAwaitReviewMessage(b)),
39080
+ reviewMissing,
39081
+ close: closeReview,
39082
+ handOff: (reviewed) => run("review.handing-off", showScript(reviewed, REVIEW, REVIEW_ORIG), { label: "Handing your notes to fix" }),
39083
+ threadsPending: "gtd land: open-threads: a thread in .gtd/REVIEW.md or an `H:` code comment waits on the agent — nothing answers it here; delete it, or turn it into a note for fix.",
39084
+ unparsed: "gtd land: review-doc: your edit to .gtd/REVIEW.md contains no note fix can read — restore the deleted chunk, or write the note as a line under its entry.",
39085
+ deleted: "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note to hand to fix."
39086
+ });
39087
+ };
38922
39088
  /** The tail the fix workflow runs: ends at the hand-off review since `base`, which gets what the lap left. */
38923
39089
  const handOffTail = (fixFirst, base, guard) => tail((escalations, report) => handOffReview(base, escalations, guard, report), {
38924
39090
  fixFirst,
@@ -39226,9 +39392,8 @@ const design = (base, guard) => scope("design", async () => {
39226
39392
  //#endregion
39227
39393
  //#region src/workflows/shared/learn.ts
39228
39394
  const SUMMARY_MAX = 200;
39229
- const BANNER = /^_(?:The fixer left this unresolved\.|A fix turn removed this while renaming another finding — check whether it was fixed\.|No detail given\.)_\s*/;
39230
39395
  const summary$1 = (body) => {
39231
- const text = oneLine(body).replace(BANNER, "");
39396
+ const text = oneLine(body);
39232
39397
  return text.length > SUMMARY_MAX ? `${text.slice(0, SUMMARY_MAX - 1)}…` : text;
39233
39398
  };
39234
39399
  /** The standard a won't-fix or an own answer would make; a fix states none. */
@@ -39497,139 +39662,41 @@ const triage$1 = (severities) => async (notes) => {
39497
39662
  return [n.id, "edit"];
39498
39663
  }));
39499
39664
  };
39500
- /** Review order: security, one-way doors, the judge unsure, then should, then could. */
39501
- const chunkKey = (s) => s.tier === "could" ? 4 : s.tier === "should" ? 3 : s.security === true ? 0 : s.rank === 0 ? 1 : 2;
39502
- /** A chunk's text, then the code each of its pointers covers: a bare path the whole file. */
39503
- const chunkEvidence = (text, files) => [text, ...files.map(({ path, line, rangeEnd }) => {
39504
- const where = `${path}${line === void 0 ? "" : `#${line}${rangeEnd === void 0 ? "" : `-${rangeEnd}`}`}`;
39505
- const lines = read(path.replace(/^\.\//, ""))?.split("\n");
39506
- if (lines === void 0) return `${where}: no longer exists`;
39507
- return `${where}:\n${(line === void 0 ? lines : lines.slice(line - 1, rangeEnd ?? line)).join("\n")}`;
39508
- })].join("\n\n");
39509
- const bytes = (text) => new TextEncoder().encode(text).length;
39510
- /** Fills each batch while the budget's even share still holds its widest evidence, so nothing is cut that fits alone. */
39511
- const batched = (items, budget) => {
39512
- const batches = [];
39513
- let batch = [];
39514
- let widest = 0;
39515
- for (const item of items) {
39516
- const size = bytes(item.evidence);
39517
- if (batch.length > 0 && Math.floor(budget / (batch.length + 1)) < Math.max(widest, size)) {
39518
- batches.push(batch);
39519
- batch = [];
39520
- widest = 0;
39521
- }
39522
- batch.push(item);
39523
- widest = Math.max(widest, size);
39524
- }
39525
- return batch.length === 0 ? batches : [...batches, batch];
39526
- };
39527
- /**
39528
- * Rates every chunk the reviewer wrote and sorts the \`findings\` among them
39529
- * first in \`full\`, each led by its tier and reason; a walkthrough chunk
39530
- * keeps its place and carries no tier. It is still rated, so a review with no
39531
- * findings still reaches \`review.ranking\` and older histories replay.
39532
- */
39533
- const ranked = async (review, full, findings) => {
39534
- const texts = chunkTexts(full);
39535
- const chunks = reviewChunks(review).map((c, i) => ({
39536
- id: `C${i + 1}`,
39537
- title: c.title,
39538
- evidence: chunkEvidence(texts.get(c.title) ?? "", c.files)
39539
- }));
39540
- if (chunks.length === 0) return full;
39541
- const minP = numeric(vars.reviewSeverityMinP, Infinity);
39542
- const batches = batched(chunks, numeric(vars.judgeBudgetBytes, 32768));
39543
- const severities = /* @__PURE__ */ new Map();
39544
- for (const [i, batch] of batches.entries()) {
39545
- const judgment = await judge("review.ranking", {
39546
- questions: batch.flatMap((c) => severityQuestions(c.id, `the change review chunk "${c.id}" ("${c.title}") walks through`, `the change in review chunk "${c.id}"`)),
39547
- evidence: Object.fromEntries(batch.map((c) => [c.id, c.evidence])),
39548
- message: reviewRankingMessage(),
39549
- label: batches.length === 1 ? "Ranking the review" : `Ranking the review (${i + 1}/${batches.length})`
39550
- });
39551
- for (const c of batch) severities.set(c.id, severityOf(c, judgment, minP));
39552
- }
39553
- return rankedReview(full, chunks.map((c, i) => ({
39554
- c,
39555
- i,
39556
- s: severities.get(c.id)
39557
- })).filter(({ c }) => findings.has(c.title)).sort((x, y) => chunkKey(x.s) - chunkKey(y.s) || x.i - y.i).map(({ c, s }) => ({
39558
- title: c.title,
39559
- label: s.reason
39560
- })));
39561
- };
39562
39665
  const isCode$1 = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
39563
- const composedOf = (base, report, nitFix, answered) => ({
39564
- ...report,
39565
- nitFix,
39566
- answered,
39567
- standards: {
39568
- added: standardsAddedSince(base),
39569
- undecided: parseStandards(read(".gtd/STANDARDS.md") ?? "").undecided
39570
- }
39571
- });
39572
- /**
39573
- * gtd writes `.gtd/REVIEW.md` from the quality lap's `report` — no reviewer
39574
- * turn. A re-review
39575
- * after fixed nits restores the doc as committed at `carry`, answers kept;
39576
- * a fresh one leaves out the notes the human already answered.
39577
- */
39578
- const composing = (base, report, carry, nitFix, answered = /* @__PURE__ */ new Set()) => run("review.composing", carry === void 0 ? writeScript(REVIEW, composedReview(base, {
39579
- ...composedOf(base, report, nitFix, answered),
39580
- weakened: removedAssertions(changesSince(base))
39581
- })) : showScript(carry, REVIEW, REVIEW), {
39582
- label: "Composing the review",
39583
- file: REVIEW,
39584
- base
39585
- });
39586
- /**
39587
- * Refuses a review with a chunk pointing at no code, or a lap note without a
39588
- * chunk of its own. A range with no code change has nothing to point at, so
39589
- * there only a note's chunk needs a pointer.
39590
- */
39591
- const requirePointers = (review, notes, codeChanged) => {
39666
+ /** Refuses a review with a chunk pointing at no code; a range with no code change has nothing to point at. */
39667
+ const requirePointers = (review, codeChanged) => {
39592
39668
  const chunks = reviewChunks(review);
39593
- const titles = new Set(chunks.map((c) => c.title));
39594
- const noted = new Set(notes.map((n) => n.title));
39595
39669
  const problems = [
39596
- ...chunks.filter((c) => c.pointers === 0 && (codeChanged || noted.has(c.title))).map((c) => ` - ${REVIEW}:${c.line + 1}: ${c.title} — points at no code`),
39670
+ ...chunks.filter((c) => c.pointers === 0 && codeChanged).map((c) => ` - ${REVIEW}:${c.line + 1}: ${c.title} — points at no code`),
39597
39671
  ...chunks.filter((c) => isConflictTitle(c.title)).map((c) => ` - ${REVIEW}:${c.line + 1}: ${c.title} — gtd writes conflicts; leave it out`),
39598
- ...notes.filter((n) => !titles.has(n.title)).map((n) => ` - ${n.title} — no chunk of its own`),
39599
39672
  ...chunks.filter((c, i) => chunks.findIndex((d) => d.title === c.title) !== i).map((c) => ` - ${REVIEW}:${c.line + 1}: ${c.title} — a second chunk with this title`),
39600
- ...reviewSections(review).map((s) => ` - ${REVIEW}:${s.line + 1}: ${REVIEW_SECTIONS[s.section]} — gtd sorts the chunks into sections; write \`## \` chunks only`),
39601
- ...reviewRisks(review).filter((r) => [...noted].some((title) => r.anchor.startsWith(`${title} ./`))).map((r) => ` - ${r.anchor} — no \`Risk:\` in a quality finding's chunk`)
39673
+ ...reviewSections(review).map((s) => ` - ${REVIEW}:${s.line + 1}: ${REVIEW_SECTIONS[s.section]} — write \`## \` chunks only`)
39602
39674
  ];
39603
39675
  if (problems.length === 0) return;
39604
- refuse(`gtd land: review-pointers: ${problems.length} problem(s) in ${REVIEW} — every chunk needs a pointer to its code, and every quality finding its own chunk, headed exactly as the prompt gave it:\n${problems.join("\n")}`);
39676
+ refuse(`gtd land: review-pointers: ${problems.length} problem(s) in ${REVIEW} — every chunk needs a pointer to its code:\n${problems.join("\n")}`);
39605
39677
  };
39606
39678
  /**
39607
39679
  * A reviewer walks the human through everything since `base`, every chunk
39608
- * pointing at its code and each of the lap's notes a chunk of its own; gtd
39609
- * then adds each note's text and answer boxes, and the summary chunks.
39680
+ * pointing at its code; gtd then appends the summary chunks.
39610
39681
  */
39611
- const walkthrough = async (base, report, lap, carry, afterRiskFix) => {
39612
- const notes = titledNotes(report);
39613
- await reviewing(base, carry, lap, walkthroughBlock(notes, report.nitFix));
39682
+ const walkthrough = async (base, summary, lap, carry, nitFix, afterRiskFix) => {
39683
+ await reviewing(base, carry, lap, nitFixBlock(nitFix));
39614
39684
  const written = read(REVIEW);
39615
39685
  if (written === void 0) return;
39616
- const review = unranked(written);
39617
- requirePointers(written, notes, changesSince(base).some((c) => isCode$1(c.path)));
39686
+ requirePointers(written, changesSince(base).some((c) => isCode$1(c.path)));
39618
39687
  if (!afterRiskFix && reviewRisks(written).length > 0) return;
39619
- const full = sectionedReview(await ranked(review, withWalkthrough(review, report), new Set(notes.map((n) => n.title))), new Set(reviewChunks(review).map((c) => c.title)));
39688
+ const full = withSummary(written, summary);
39620
39689
  if (full !== written) await run("review.carrying", writeScript(REVIEW, full), {
39621
- label: "Ordering the review and adding the quality lap's findings",
39690
+ label: "Adding the quality lap's summary",
39622
39691
  file: REVIEW,
39623
39692
  base
39624
39693
  });
39625
39694
  };
39626
39695
  /**
39627
- * The quality lap's notes reach a human over everything since `base`, who
39628
- * answers each one — fix as suggested, won't fix, or their own answer — and
39629
- * may comment. A fix joins the nit batch, a won't-fix is dropped, and an own
39630
- * answer or comment is judged: edits end the review as `feedback` for the
39631
- * caller to build, questions are answered in place, nits fixed in one batch,
39632
- * praise dropped.
39696
+ * A reviewer's walkthrough of everything since `base` reaches a human, who
39697
+ * may comment: each note is judged — edits end the review as `feedback` for
39698
+ * the caller to build, questions are answered in place, nits fixed in one
39699
+ * batch, praise dropped.
39633
39700
  */
39634
39701
  const humanReview = (base, escalations = { rounds: 0 }, options = {}) => {
39635
39702
  const { guard, guardRests, fixAccess, rules } = options;
@@ -39640,7 +39707,6 @@ const humanReview = (base, escalations = { rounds: 0 }, options = {}) => {
39640
39707
  };
39641
39708
  const severities = /* @__PURE__ */ new Map();
39642
39709
  let nitFix;
39643
- const answered = /* @__PURE__ */ new Set();
39644
39710
  let riskFixed = false;
39645
39711
  return review$1(base, {
39646
39712
  file: REVIEW,
@@ -39650,8 +39716,14 @@ const humanReview = (base, escalations = { rounds: 0 }, options = {}) => {
39650
39716
  const fixedAt = nitFix;
39651
39717
  const afterRiskFix = riskFixed;
39652
39718
  riskFixed = false;
39653
- const report = options.report ?? NO_FINDINGS;
39654
- return options.walkthrough === true ? guarded(() => walkthrough(b, composedOf(b, report, fixedAt, answered), options.lap ?? "first", carry, afterRiskFix), guard)() : composing(b, report, carry, fixedAt, answered);
39719
+ const summary = {
39720
+ report: options.report ?? NO_FINDINGS,
39721
+ standards: {
39722
+ added: standardsAddedSince(b),
39723
+ undecided: parseStandards(read(".gtd/STANDARDS.md") ?? "").undecided
39724
+ }
39725
+ };
39726
+ return guarded(() => walkthrough(b, summary, options.lap ?? "first", carry, fixedAt, afterRiskFix), guard)();
39655
39727
  },
39656
39728
  fixRisks: guarded((notes) => {
39657
39729
  riskFixed = true;
@@ -39670,7 +39742,6 @@ const humanReview = (base, escalations = { rounds: 0 }, options = {}) => {
39670
39742
  reviewMissing,
39671
39743
  collecting: feedback.collecting,
39672
39744
  triage: triage$1(severities),
39673
- decided: (decisions) => decisions.forEach((d) => answered.add(d.anchor)),
39674
39745
  settles: settlesStandard,
39675
39746
  learn: learnStandards,
39676
39747
  pending: standardsPending,
@@ -40134,7 +40205,6 @@ const reviewedRefactor = async (shape, options = {}) => {
40134
40205
  fixAccess: strayAccess(p.strays),
40135
40206
  lensBase: entry,
40136
40207
  rules: refactorRules(p.strays, p.pattern),
40137
- walkthrough: true,
40138
40208
  lap: "refactor"
40139
40209
  });
40140
40210
  if (outcome.verdict === "signoff") return true;
@@ -40434,7 +40504,11 @@ const buildFeature = async (firstBase) => {
40434
40504
  });
40435
40505
  const carried = seam.rebuild?.acceptance?.tests ?? [];
40436
40506
  const base = seam.refactored === true && !changesSince(seam.base).some((c) => isCode$1(c.path) && !carried.includes(c.path)) ? firstBase : seam.base;
40437
- const outcome = await reviewedTail(base, base, requirements);
40507
+ const outcome = await buildTail(base, {
40508
+ green: true,
40509
+ lensBase: base,
40510
+ requirements
40511
+ });
40438
40512
  await featureFeedback(outcome, base, {
40439
40513
  closed,
40440
40514
  used
@@ -40445,16 +40519,6 @@ const gather = (handedOut) => {
40445
40519
  for (const id of listedIds()) handedOut.add(id);
40446
40520
  return [...handedOut];
40447
40521
  };
40448
- /** The quality lap, then a reviewer's walkthrough since `base` for the human. */
40449
- const reviewedTail = (base, lensBase, requirements, lap = "first") => tail((escalations, report) => humanReview(base, escalations, {
40450
- report,
40451
- walkthrough: true,
40452
- lap
40453
- }), {
40454
- green: true,
40455
- lensBase,
40456
- requirements
40457
- });
40458
40522
  const featureFeedback = async (first, lensBase, ids) => {
40459
40523
  let outcome = first;
40460
40524
  let closed = ids.closed;
@@ -40475,7 +40539,12 @@ const featureFeedback = async (first, lensBase, ids) => {
40475
40539
  tier,
40476
40540
  amend: true
40477
40541
  });
40478
- outcome = await reviewedTail(outcome.base, lensBase, requirements, "feedback");
40542
+ outcome = await buildTail(outcome.base, {
40543
+ green: true,
40544
+ lensBase,
40545
+ requirements,
40546
+ lap: "feedback"
40547
+ });
40479
40548
  }
40480
40549
  };
40481
40550
  /** Design — skipped when the sketch's own criteria are judged clear — then `buildFeature`. */