@pmelab/gtd 42.0.0 → 44.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +96 -48
  3. package/claude/hooks/sketch.ts +1 -1
  4. package/dist/chunks/{reserved-D7MNY9QJ.mjs → reserved-DJk_6-ju.mjs} +75 -46
  5. package/dist/chunks/review-X7Jfz0dY.mjs +249 -0
  6. package/dist/chunks/{steering-oCDdQlpP.mjs → steering-D4LUQ-av.mjs} +95 -12
  7. package/dist/chunks/{tail-D2o2h5cc.mjs → tail-5w5mxGTq.mjs} +3 -3
  8. package/dist/gtd.bundle.mjs +488 -30
  9. package/dist/steering.mjs +2 -2
  10. package/dist/testkit.mjs +447 -27
  11. package/dist/web/main.js +42 -13
  12. package/dist/workflow-kit.mjs +61 -25
  13. package/dist/workflows/cleanup.mjs +2 -2
  14. package/dist/workflows/feature.mjs +7 -290
  15. package/dist/workflows/fix.mjs +3 -3
  16. package/dist/workflows/review.mjs +27 -7
  17. package/dist/workflows/rollout.mjs +1 -1
  18. package/dist/workflows.mjs +287 -161
  19. package/package.json +1 -1
  20. package/src/cli/Cli.test.ts +33 -1
  21. package/src/cli/Cli.ts +70 -6
  22. package/src/cli/History.ts +15 -0
  23. package/src/cli/Judges.test.ts +373 -0
  24. package/src/cli/Judges.ts +624 -0
  25. package/src/cli/Metrics.ts +3 -16
  26. package/src/cli/Program.test.ts +3 -1
  27. package/src/cli/Program.ts +9 -0
  28. package/src/steering/FORMATS.md +4 -4
  29. package/src/steering/index.ts +12 -1
  30. package/src/steering/review.test.ts +107 -0
  31. package/src/steering/review.ts +159 -9
  32. package/src/testkit/EmittedScriptRecognizer.ts +0 -14
  33. package/src/testkit/steps.ts +4 -0
  34. package/src/workflow-kit/review.test.ts +170 -26
  35. package/src/workflow-kit/review.ts +95 -19
  36. package/src/workflows/feature/coverage.ts +1 -1
  37. package/src/workflows/feature/feature.test.ts +8 -0
  38. package/src/workflows/feature/feature.ts +2 -2
  39. package/src/workflows/feature/index.ts +1 -1
  40. package/src/workflows/feature/lens-review.feature +16 -4
  41. package/src/workflows/feature/refactor.ts +1 -1
  42. package/src/workflows/feature/review-answers.feature +179 -0
  43. package/src/workflows/feature/seam.ts +1 -1
  44. package/src/workflows/feature/steps.test.ts +2 -76
  45. package/src/workflows/feature/steps.ts +5 -48
  46. package/src/workflows/fix/fix.feature +20 -21
  47. package/src/workflows/index.ts +1 -1
  48. package/src/workflows/review/review-door.feature +149 -0
  49. package/src/workflows/review/review.test.ts +1 -73
  50. package/src/workflows/review/review.ts +34 -6
  51. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +6 -5
  52. package/src/workflows/shared/access.test.ts +2 -11
  53. package/src/workflows/shared/access.ts +0 -2
  54. package/src/workflows/{feature → shared}/review.test.ts +9 -61
  55. package/src/workflows/{feature → shared}/review.ts +40 -48
  56. package/src/workflows/shared/skills.test.ts +2 -0
  57. package/src/workflows/shared/skills.ts +2 -0
  58. package/src/workflows/shared/steps.test.ts +64 -2
  59. package/src/workflows/shared/steps.ts +40 -1
  60. package/src/workflows/shared/tail.ts +2 -2
  61. package/src/workflows/shared/text.test.ts +40 -89
  62. package/src/workflows/shared/text.ts +63 -54
@@ -13931,8 +13931,8 @@ const eolOf = (content) => content.includes("\r\n") ? "\r\n" : "\n";
13931
13931
  const PREFIX_RE$1 = /^(H|A):\s*/;
13932
13932
  const lineOf = (node, which) => toLspPosition(node.position[which]).line;
13933
13933
  /** 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. */
13934
- const itemText = (content, item) => item.children.map((child) => sourceText(content, child)).join(" ").trim();
13935
- const prefixOf = (content, item) => PREFIX_RE$1.exec(itemText(content, item));
13934
+ const itemText$1 = (content, item) => item.children.map((child) => sourceText(content, child)).join(" ").trim();
13935
+ const prefixOf = (content, item) => PREFIX_RE$1.exec(itemText$1(content, item));
13936
13936
  /** A thread is a definition whose body OPENS with a list whose first item carries a prefix — anything else stays a one-shot footnote. */
13937
13937
  const isThreadBody = (content, children) => {
13938
13938
  const first = children[0];
@@ -13952,7 +13952,7 @@ const entryOf = (content, item, previous, report) => {
13952
13952
  return;
13953
13953
  }
13954
13954
  const author = match[1] === "H" ? "me" : "agent";
13955
- const text = itemText(content, item).slice(match[0].length).trim();
13955
+ const text = itemText$1(content, item).slice(match[0].length).trim();
13956
13956
  if (text === "") report(`empty "${match[1]}:" entry — write your own words there`, start, end);
13957
13957
  if (previous === void 0 && author === "agent") report(`the agent never starts a thread — the first entry must be "H:"`, start, end);
13958
13958
  else if (previous === author) report(`two consecutive "${match[1]}:" entries — entries must alternate`, start, end);
@@ -15654,6 +15654,9 @@ const qaDescriptor = {
15654
15654
  };
15655
15655
  //#endregion
15656
15656
  //#region src/steering/review.ts
15657
+ const ANSWER_LEAD = "Tick exactly one:";
15658
+ const ANSWER_FIX = "fix as suggested";
15659
+ const ANSWER_WONT_FIX = "won't fix";
15657
15660
  /**
15658
15661
  * The canonical sample. Every detail here is load-bearing for the formatter
15659
15662
  * round-trip: both footnote bodies exceed 80 characters so a reflow actually
@@ -15920,16 +15923,42 @@ const parseHunk = (content, lines, title, item) => {
15920
15923
  errors: [hunkSecondPointerFinding(content, lines, item, token, sourceLine, title, file), hunkLineWithoutRangeFinding(content, item, token, sourceLine, title, file)].filter((e) => e !== void 0)
15921
15924
  };
15922
15925
  };
15926
+ const itemText = (content, item) => item.children.map((c) => sourceText(content, c)).join(" ").replace(/^\[[ xX]\]\s*/, "").trim();
15927
+ /**
15928
+ * The task list right after the `Tick exactly one:` paragraph, with that
15929
+ * paragraph's index. Read by position, never by label, so an edited label or
15930
+ * a free text opening with `./path` is still an answer, not a pointer. Any
15931
+ * count but three boxes is kept, and reads as unanswered.
15932
+ */
15933
+ const answerListOf = (content, body) => {
15934
+ const lead = body.findIndex((n, i) => n.type === "paragraph" && sourceText(content, n) === "Tick exactly one:" && body[i + 1]?.type === "list" && body[i + 1].children.every((item) => item.checked != null));
15935
+ if (lead === -1) return void 0;
15936
+ const list = body[lead + 1];
15937
+ const last = list.children.length - 1;
15938
+ return {
15939
+ lead,
15940
+ list,
15941
+ options: list.children.map((item, i) => {
15942
+ const text = itemText(content, item);
15943
+ return {
15944
+ checked: item.checked === true,
15945
+ text: i === last && text.toLowerCase() === "_your answer_".toLowerCase() ? "" : text
15946
+ };
15947
+ })
15948
+ };
15949
+ };
15923
15950
  /** Splits one chunk's body nodes into its file pointers (hunk pointers are task items, collected recursively at ANY nesting depth via `taskItems` — a nested hunk is the same kind of hunk as a top-level one) and description prose. The description is the chunk's own leading run and never a node that contains a hunk pointer: it stops at the first node whose `taskItems` include a real pointer (at any depth — a blockquote wrapping a pointer list stops it just as a top-level list would). Every node kind in what's left before that point contributes — a `footnoteDefinition` is the one exception, excluded by `blockNodesOfRun` itself (it's a note, never document content). */
15924
15951
  const parseChunkBody = (content, lines, title, body) => {
15925
- const firstPointerIndex = body.findIndex((n) => taskItems(n).some((item) => hasPointerToken(content, item)));
15926
- const leadingRun = firstPointerIndex === -1 ? body : body.slice(0, firstPointerIndex);
15952
+ const answer = answerListOf(content, body);
15953
+ const isAnswer = (n) => answer !== void 0 && (n === answer.list || n === body[answer.lead]);
15954
+ const firstPointerIndex = body.findIndex((n) => !isAnswer(n) && taskItems(n).some((item) => hasPointerToken(content, item)));
15955
+ const leadingRun = (firstPointerIndex === -1 ? body : body.slice(0, firstPointerIndex)).filter((n) => !isAnswer(n));
15927
15956
  const descriptionNodes = blockNodesOfRun(content, leadingRun);
15928
15957
  const descriptionInline = collapseImages(blockRunInline(content, leadingRun));
15929
15958
  const description = leadingRun.filter((n) => n.type !== "footnoteDefinition").map((n) => sourceText(content, n)).join(" ").trim();
15930
15959
  const files = [];
15931
15960
  const errors = [];
15932
- for (const item of body.flatMap((n) => taskItems(n))) {
15961
+ for (const item of body.filter((n) => !isAnswer(n)).flatMap((n) => taskItems(n))) {
15933
15962
  const parsed = parseHunk(content, lines, title, item);
15934
15963
  if (!parsed) continue;
15935
15964
  files.push(parsed.file);
@@ -15940,7 +15969,9 @@ const parseChunkBody = (content, lines, title, body) => {
15940
15969
  descriptionNodes,
15941
15970
  descriptionInline,
15942
15971
  files,
15943
- errors
15972
+ errors,
15973
+ ...answer === void 0 ? {} : { answers: answer.options },
15974
+ boxed: body.some((n) => !isAnswer(n) && taskItems(n).length > 0)
15944
15975
  };
15945
15976
  };
15946
15977
  /** Splits the tree's top-level nodes into consecutive `##` chunk blocks — a `## ` line found only inside a fence never becomes a heading node at all, so it's never mistaken for a chunk. */
@@ -15976,9 +16007,9 @@ const parseChangesets = (tree, content, lines) => {
15976
16007
  const errors = [];
15977
16008
  for (const { heading, headingLine, body } of splitChunks(tree)) {
15978
16009
  const title = headingText$1(content, heading);
15979
- const { description, descriptionNodes, descriptionInline, files, errors: bodyErrors } = parseChunkBody(content, lines, title, body);
16010
+ const { description, descriptionNodes, descriptionInline, files, answers, boxed, errors: bodyErrors } = parseChunkBody(content, lines, title, body);
15980
16011
  errors.push(...bodyErrors);
15981
- if (files.length === 0 && body.some((n) => taskItems(n).length > 0)) errors.push({
16012
+ if (files.length === 0 && boxed) errors.push({
15982
16013
  message: `Chunk "${title}" has no file pointers`,
15983
16014
  line: headingLine,
15984
16015
  range: nodeRange(heading)
@@ -15989,7 +16020,8 @@ const parseChangesets = (tree, content, lines) => {
15989
16020
  descriptionNodes,
15990
16021
  descriptionInline,
15991
16022
  files,
15992
- headingLine
16023
+ headingLine,
16024
+ ...answers === void 0 ? {} : { answers }
15993
16025
  });
15994
16026
  }
15995
16027
  if (changesets.length === 0) errors.push({ message: "REVIEW.md has no '##' chunks" });
@@ -16070,7 +16102,9 @@ const isHunkPointerItem = (content, item) => {
16070
16102
  * clear, so a caller can skip the write and leave the mtime untouched.
16071
16103
  */
16072
16104
  const reviewClearTicks = (content) => {
16073
- const offsets = taskItems(parseMarkdown(content)).filter((item) => item.checked === true && isHunkPointerItem(content, item)).map((item) => checkboxOffset(content, item)).filter((o) => o !== void 0).sort((a, b) => a - b);
16105
+ const tree = parseMarkdown(content);
16106
+ const answers = new Set(splitChunks(tree).flatMap(({ body }) => answerListOf(content, body)?.list.children ?? []));
16107
+ const offsets = taskItems(tree).filter((item) => item.checked === true && !answers.has(item) && isHunkPointerItem(content, item)).map((item) => checkboxOffset(content, item)).filter((o) => o !== void 0).sort((a, b) => a - b);
16074
16108
  if (offsets.length === 0) return content;
16075
16109
  let result = "";
16076
16110
  let cursor = 0;
@@ -16504,6 +16538,55 @@ const reviewRisks = (content) => parseReviewDoc(content).changesets.flatMap((chu
16504
16538
  id: `risk-${i + 1}`,
16505
16539
  ...r
16506
16540
  }));
16541
+ const CHOICES = [
16542
+ "fix",
16543
+ "wontfix",
16544
+ "answer"
16545
+ ];
16546
+ const decisionOf = (chunk) => {
16547
+ if (chunk.answers?.length !== 3) return void 0;
16548
+ const ticked = chunk.answers.flatMap((o, i) => o.checked ? [i] : []);
16549
+ if (ticked.length !== 1) return void 0;
16550
+ const i = ticked[0];
16551
+ const text = i === 2 ? chunk.answers[2].text : "";
16552
+ if (i === 2 && text === "") return void 0;
16553
+ return {
16554
+ anchor: chunk.title,
16555
+ body: chunk.description,
16556
+ choice: CHOICES[i],
16557
+ text
16558
+ };
16559
+ };
16560
+ /**
16561
+ * Notes whose answer boxes do not hold exactly one tick (a ticked free-text
16562
+ * slot filled in), with their 1-based heading line and why; and, given the
16563
+ * file `before` the edit, every note whose boxes were deleted.
16564
+ */
16565
+ const unansweredNotes = (content, before) => {
16566
+ const now = parseReviewDoc(content).changesets;
16567
+ const boxed = new Set(now.filter((c) => c.answers !== void 0).map((c) => c.title));
16568
+ const removed = (before === void 0 ? [] : parseReviewDoc(before).changesets).filter((c) => c.answers !== void 0 && !boxed.has(c.title)).map((c) => ({
16569
+ title: c.title,
16570
+ line: 0,
16571
+ reason: "its answer boxes were deleted — restore them"
16572
+ }));
16573
+ return [...now.filter((c) => c.answers !== void 0 && decisionOf(c) === void 0).map((c) => ({
16574
+ title: c.title,
16575
+ line: c.headingLine + 1,
16576
+ reason: c.answers.length === 3 ? "tick exactly one box" : `it has ${c.answers.length} boxes — restore the three gtd wrote`
16577
+ })), ...removed];
16578
+ };
16579
+ /** Every answer in `after` that `before` did not already hold for the same note, in document order. */
16580
+ const reviewDecisions = (before, after) => {
16581
+ const prior = new Map(parseReviewDoc(before).changesets.map((c) => [c.title, c]));
16582
+ return parseReviewDoc(after).changesets.flatMap((chunk) => {
16583
+ const decision = decisionOf(chunk);
16584
+ if (decision === void 0) return [];
16585
+ const was = prior.get(chunk.title);
16586
+ const old = was === void 0 ? void 0 : decisionOf(was);
16587
+ return old?.choice === decision.choice && old.text === decision.text ? [] : [decision];
16588
+ });
16589
+ };
16507
16590
  //#endregion
16508
16591
  //#region src/steering/CodeThreads.ts
16509
16592
  const TOKENS = [
@@ -16635,6 +16718,13 @@ const collect = async (p, capture, round, requested) => {
16635
16718
  }
16636
16719
  return refuse(p.refusals.unmatched);
16637
16720
  };
16721
+ /** Refuse while a note's answer boxes hold anything but exactly one tick, or a note's boxes were deleted. */
16722
+ const requireDecisions = (file, reviewed) => {
16723
+ const open = unansweredNotes(read(file) ?? "", changesSince(reviewed).get(file)?.before);
16724
+ if (open.length === 0) return;
16725
+ const list = open.map((n) => ` - ${file}${n.line > 0 ? `:${n.line}` : ""}: ${n.title} — ${n.reason}`).join("\n");
16726
+ refuse(`gtd land: answer-completeness: ${open.length} note(s) in ${file} not answered — tick exactly one box per note: fix as suggested, won't fix, or your own answer written over _your answer_:\n${list}`);
16727
+ };
16638
16728
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
16639
16729
  /**
16640
16730
  * A review with ticks and THREAD footnotes (definition plus markers) removed:
@@ -16654,7 +16744,7 @@ const notesOf = (text) => {
16654
16744
  * commit of the last collecting turn, `"missing"` when the round left no
16655
16745
  * review, or `undefined` when no such turn ran.
16656
16746
  */
16657
- const converse = async (p, base, collectedBefore) => {
16747
+ const converse = async (p, base, reviewed, collectedBefore) => {
16658
16748
  let collectedAt = collectedBefore;
16659
16749
  for (;;) {
16660
16750
  await p.awaitReview(base);
@@ -16664,24 +16754,47 @@ const converse = async (p, base, collectedBefore) => {
16664
16754
  return "missing";
16665
16755
  }
16666
16756
  requireThreadsClosed(p.file);
16667
- if (!hasThreadFor("agent", p.file)) return collectedAt;
16757
+ if (!hasThreadFor("agent", p.file)) {
16758
+ requireDecisions(p.file, reviewed);
16759
+ return collectedAt;
16760
+ }
16668
16761
  await p.collecting(p.captures.edits(head$1()), "reply");
16669
16762
  collectedAt = head$1();
16670
16763
  requireReplies(p.file);
16671
- if (!hasThreadFor("human", p.file)) return collectedAt;
16672
- }
16673
- };
16674
- /** Judge every note, then run each verdict's route: answers, then nit fixes, then deferrals, then the edits' lap. */
16675
- const routeNotes = async (p, notes, r) => {
16676
- const verdicts = await p.triage(notes);
16677
- const of = (v) => notes.filter((n) => (verdicts.get(n.id) ?? "edit") === v);
16678
- const [edits, questions, nits, deferred] = [
16679
- of("edit"),
16680
- of("question"),
16681
- of("nit"),
16682
- of("defer")
16683
- ];
16684
- if (edits.length + questions.length + nits.length + deferred.length === 0) return r.unfolded();
16764
+ if (!hasThreadFor("human", p.file) && unansweredNotes(read(p.file) ?? "", changesSince(reviewed).get(p.file)?.before).length === 0) return collectedAt;
16765
+ }
16766
+ };
16767
+ const decisionNote = (d, id, text) => ({
16768
+ id,
16769
+ kind: "chunk",
16770
+ anchor: d.anchor,
16771
+ before: d.body,
16772
+ text,
16773
+ forced: /^!(\s|$)/.test(text)
16774
+ });
16775
+ /** One `Gtd-Mark` value per line: a control character would make mark() throw on every replay. */
16776
+ const oneLine = (text) => text.replace(/[\s\x00-\x1f\x7f-\x9f\u2028\u2029]+/g, " ").trim() || "untitled";
16777
+ /** Tell the policy, and mark each won't-fix on the gate's commit. */
16778
+ const record$2 = (p, decisions) => {
16779
+ p.decided?.(decisions);
16780
+ for (const d of decisions) if (d.choice === "wontfix") mark("wontfix", oneLine(d.anchor));
16781
+ };
16782
+ /**
16783
+ * Judge every note, then run each verdict's route: answers, then nit fixes,
16784
+ * then the edits' lap. Of the human's `decisions`, a fix joins the nit batch
16785
+ * unjudged, an own answer is judged like a note, and a won't-fix is dropped
16786
+ * with a `Gtd-Mark` on the gate's commit.
16787
+ */
16788
+ const routeNotes = async (p, notes, r, decisions = []) => {
16789
+ record$2(p, decisions);
16790
+ const of = (c) => decisions.filter((d) => d.choice === c);
16791
+ const fixes = of("fix").map((d, i) => decisionNote(d, `fix-${i + 1}`, d.body));
16792
+ const judged = [...notes, ...of("answer").map((d, i) => decisionNote(d, `answer-${i + 1}`, d.text))];
16793
+ const verdicts = judged.length === 0 ? /* @__PURE__ */ new Map() : await p.triage(judged);
16794
+ const ofVerdict = (v) => judged.filter((n) => (verdicts.get(n.id) ?? "edit") === v);
16795
+ const [edits, questions] = [ofVerdict("edit"), ofVerdict("question")];
16796
+ const nits = [...ofVerdict("nit"), ...fixes];
16797
+ if (edits.length + questions.length + nits.length === 0) return r.unfolded();
16685
16798
  let answeredAt;
16686
16799
  if (questions.length > 0) {
16687
16800
  await p.answerQuestions(questions);
@@ -16691,7 +16804,6 @@ const routeNotes = async (p, notes, r) => {
16691
16804
  await p.fixNits(nits);
16692
16805
  await p.keepGreen();
16693
16806
  }
16694
- if (deferred.length > 0) await p.defer(deferred);
16695
16807
  if (edits.length > 0) {
16696
16808
  await r.close();
16697
16809
  return r.outcome(await collect(p, p.captures.editNotes(r.round, edits, answeredAt), "triaged", {
@@ -16713,8 +16825,8 @@ const routeNotes = async (p, notes, r) => {
16713
16825
  };
16714
16826
  /** A code edit, not a thread-only change: lines that only add, answer or remove code threads don't count. */
16715
16827
  const isCodeEdit = (p, c) => p.isCode(c.path) && stripCodeThreads(c.path, c.before ?? "") !== stripCodeThreads(c.path, c.after ?? "");
16716
- /** Whether anything but thread edits and ticks landed in the review file since `commit`. */
16717
- const notedSince = (p, commit) => changesSince(commit).some((c) => c.path === p.file && notesOf(c.before ?? "") !== notesOf(read(p.file) ?? ""));
16828
+ /** Whether a note or an answer, not just thread edits and pointer ticks, landed in the review file since `commit`. */
16829
+ const notedSince = (p, commit) => changesSince(commit).some((c) => c.path === p.file && (notesOf(c.before ?? "") !== notesOf(read(p.file) ?? "") || reviewDecisions(c.before ?? "", read(p.file) ?? "").length > 0));
16718
16830
  /**
16719
16831
  * What the in-loop collecting turn left: `folded` when it wrote `feedback`
16720
16832
  * (those folds need their lap whatever is judged later), `settled` when only
@@ -16728,9 +16840,9 @@ const settledAfter = (p, collectedAt) => ({
16728
16840
  folded: collectedAt !== void 0 && read(p.feedback) !== void 0,
16729
16841
  settled: collectedAt !== void 0 && changesSince(collectedAt).every((c) => !isCodeEdit(p, c)) && !notedSince(p, collectedAt)
16730
16842
  });
16731
- const finishNotes = async (p, before, reopened, ctx) => {
16732
- const notes = reviewNotes(before, read(p.file) ?? "");
16733
- if (notes.length > 0) return routeNotes(p, notes, ctx);
16843
+ const finishNotes = async (p, before, decisions, reopened, ctx) => {
16844
+ const notes = reviewNotes(before ?? "", read(p.file) ?? "");
16845
+ if (notes.length + decisions.length > 0) return routeNotes(p, notes, ctx, decisions);
16734
16846
  if (reopened) return ctx.unfolded();
16735
16847
  await ctx.close();
16736
16848
  return ctx.outcome(await collect(p, p.captures.notes(ctx.round), "notes"));
@@ -16740,6 +16852,11 @@ const toOutcome = (verdict, rest) => verdict === "signoff" ? { verdict } : {
16740
16852
  ...rest
16741
16853
  };
16742
16854
  const isReopened = (p, r) => r.settled && !r.folded && p.refusals.dropped !== void 0;
16855
+ /** A carried answer is unchanged, never routed twice; one ticked beside a thread still routes once the thread has settled. */
16856
+ const decisionsSince = (p, since) => {
16857
+ const after = read(p.file) ?? "";
16858
+ return reviewDecisions(since.get(p.file)?.before ?? after, after);
16859
+ };
16743
16860
  const finish = async (p, reviewed, collectedAt) => {
16744
16861
  const round = head$1();
16745
16862
  const since = changesSince(reviewed);
@@ -16760,15 +16877,17 @@ const finish = async (p, reviewed, collectedAt) => {
16760
16877
  settled,
16761
16878
  folded
16762
16879
  });
16763
- if (settled && !reopened || edited.length === 0 && !noted) return unfolded();
16880
+ const decisions = decisionsSince(p, since);
16881
+ if (settled && !reopened && decisions.length === 0 || edited.length === 0 && !noted) return unfolded();
16764
16882
  if (edited.length > 0) {
16883
+ record$2(p, decisions);
16765
16884
  await close();
16766
16885
  return outcome(await collect(p, p.captures.edits(round, reopened ? reviewed : void 0), "edits", {
16767
16886
  notes: [],
16768
16887
  paths: edited.map((c) => c.path)
16769
16888
  }));
16770
16889
  }
16771
- return finishNotes(p, since.get(p.file)?.before ?? "", reopened, {
16890
+ return finishNotes(p, since.get(p.file)?.before, decisions, reopened, {
16772
16891
  round,
16773
16892
  close,
16774
16893
  outcome,
@@ -16790,7 +16909,7 @@ const reviewOnce = async (p, base, carry) => {
16790
16909
  * another lap, questions are answered in place, nits fixed in one batch,
16791
16910
  * praise dropped.
16792
16911
  */
16793
- const review$2 = async (base, p) => {
16912
+ const review$1 = async (base, p) => {
16794
16913
  let carry;
16795
16914
  for (;;) {
16796
16915
  await reviewOnce(p, base, carry);
@@ -16798,7 +16917,7 @@ const review$2 = async (base, p) => {
16798
16917
  let reviewed = head$1();
16799
16918
  let collectedAt;
16800
16919
  for (;;) {
16801
- collectedAt = await converse(p, base, collectedAt);
16920
+ collectedAt = await converse(p, base, reviewed, collectedAt);
16802
16921
  if (collectedAt === "missing") break;
16803
16922
  const result = await finish(p, reviewed, collectedAt);
16804
16923
  if (!("next" in result)) return result;
@@ -17278,9 +17397,9 @@ const SUMMARY_TITLES = [
17278
17397
  const unboxed = (body) => body.replace(/^(\s*(?:>\s*)*(?:[-*+]|\d+[.)])\s+)\[[ xX]\]\s?/gm, "$1");
17279
17398
  const short = (hash) => hash.slice(0, 7);
17280
17399
  /**
17281
- * \`.gtd/REVIEW.md\` as gtd writes it: one chunk per note, then the fixed and
17282
- * noise lists, with no tick boxes. Chunk titles are made unique — notes are
17283
- * matched to their chunk by title.
17400
+ * \`.gtd/REVIEW.md\` as gtd writes it: one chunk per note, each ending in its
17401
+ * answer boxes, then the fixed and noise lists. Chunk titles are made unique —
17402
+ * notes and answers are matched to their chunk by title.
17284
17403
  */
17285
17404
  const composedReview = (base, report) => {
17286
17405
  const used = new Set(SUMMARY_TITLES);
@@ -17291,11 +17410,16 @@ const composedReview = (base, report) => {
17291
17410
  return name;
17292
17411
  };
17293
17412
  const chunk = (title, body) => `## ${title}\n\n${body.trim()}`;
17413
+ const answerBoxes = `${ANSWER_LEAD}\n\n- [ ] ${ANSWER_FIX}\n- [ ] ${ANSWER_WONT_FIX}\n- [ ] ${FREE_TEXT_PLACEHOLDER}`;
17414
+ const notes = report.notes.map((n) => ({
17415
+ ...n,
17416
+ title: unique(n.title)
17417
+ })).filter((n) => report.answered?.has(n.title) !== true);
17294
17418
  const weakened = (report.weakened ?? []).flatMap(({ path, lines }) => lines.map((a) => `- ${path} — ${a.scenario} — ${a.line}`));
17295
17419
  const chunks = [
17296
- ...report.nitFix === void 0 ? [] : [chunk("Fixed from your notes", `Your nits were fixed in ${short(report.nitFix)}; the list below is from before.`)],
17420
+ ...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.`)],
17297
17421
  ...weakened.length === 0 ? [] : [chunk("Weakened scenarios", `These scenarios lost an assertion the review base had:\n\n${weakened.join("\n")}`)],
17298
- ...report.notes.map((n) => chunk(unique(n.title), n.unresolved === true ? `_The fixer left this unresolved._\n\n${unboxed(n.body)}` : n.unclear === true ? `_A fix turn removed this while renaming another finding — check whether it was fixed._\n\n${unboxed(n.body)}` : unboxed(n.body) || "_No detail given._")),
17422
+ ...notes.map((n) => chunk(n.title, `${n.unresolved === true ? `_The fixer left this unresolved._\n\n${unboxed(n.body)}` : n.unclear === true ? `_A fix turn removed this while renaming another finding — check whether it was fixed._\n\n${unboxed(n.body)}` : unboxed(n.body) || "_No detail given._"}\n\n${answerBoxes}`)),
17299
17423
  ...report.fixed.length === 0 ? [] : [chunk("Fixed automatically", report.fixed.map((f) => `- ${f.title} (${short(f.commit)})`).join("\n"))],
17300
17424
  ...report.noise.length === 0 ? [] : [chunk("Dropped as noise", report.noise.map((f) => `- ${f.title} (p=${f.p})`).join("\n"))]
17301
17425
  ];
@@ -17378,10 +17502,6 @@ ${stateFileRules}
17378
17502
  ${footnoteFoldIn}${codeThreadReplies()}
17379
17503
  - The only state file this turn touches is \`.gtd/REQUIREMENTS.md\`
17380
17504
  — no other files for notes or output
17381
- - Lines of a \`## Deferred review notes\` section of \`.gtd/TODO.md\`
17382
- that the start diff does not touch are parked by review, never folded;
17383
- the human's edits to such a line (or its removal) are input like any
17384
- other part of the start diff
17385
17505
  - \`.gtd/TODO.md\` is the likely home of the sketch that started
17386
17506
  this process — the human's input, folded into the concerns
17387
17507
  below like any other part of the start diff; never a state
@@ -17674,8 +17794,12 @@ const buildReviewAwaitReviewMessage = (base) => `\`.gtd/REVIEW.md\` holds what t
17674
17794
  per finding that needs your judgement — it questions a criterion or the
17675
17795
  approach, or the fixer could not resolve it — then, for reference only, what
17676
17796
  was **fixed automatically** (with the fixing commit) and what was **dropped
17677
- as noise**. Nothing there needs an answer; disagreeing with a fix or a drop
17678
- means writing a note of your own.
17797
+ as noise**. Each finding chunk ends in three boxes — tick exactly one:
17798
+ \`fix as suggested\` (fixed in the nit batch below), \`won't fix\` (dropped,
17799
+ recorded in the commit), or write your own answer over \`_your answer_\` and
17800
+ tick it (judged like a note). Every landing here is refused until each
17801
+ finding holds exactly one tick. The fixed and noise lists need no answer;
17802
+ disagreeing with a fix or a drop means writing a note of your own.
17679
17803
 
17680
17804
  Review the diff yourself, with whatever tool you like — gtd checks
17681
17805
  nothing out and touches no ref; \`gtd base\` prints this same hash any
@@ -17685,8 +17809,8 @@ working tree:
17685
17809
  git diff ${base}
17686
17810
  When you've been through the whole diff, run \`gtd land\`:
17687
17811
 
17688
- - **Sign off** — leave no comment — no note in
17689
- \`.gtd/REVIEW.md\`, no code edit — to close the process.
17812
+ - **Sign off** — answer every finding, leave no other comment — no note
17813
+ in \`.gtd/REVIEW.md\`, no code edit — to close the process.
17690
17814
  Every turn commit stays on the
17691
17815
  branch; run \`gtd summary\` afterward for a closing-message
17692
17816
  prompt.
@@ -17813,6 +17937,15 @@ ${notes.length === 0 ? "(none)\n" : notesCapture(notes)}
17813
17937
  The reviewer's risks are:
17814
17938
 
17815
17939
  ${risks.length === 0 ? "(none)\n" : notesCapture(risks)}`;
17940
+ const reviewFeedbackFixPrompt = () => `${stateFileRules}
17941
+ - Read \`.gtd/REQUIREMENTS.md\` — the concerns the human's review asked for,
17942
+ one \`## \` heading each, in build order
17943
+ - Build every concern in this one turn; an answered question is context,
17944
+ not a concern
17945
+ - Where a change is behavioural, add a test that fails without it
17946
+ - Leave \`.gtd/REQUIREMENTS.md\` untouched — the next step removes it
17947
+ - Leave everything uncommitted and finish your turn
17948
+ `;
17816
17949
  const buildReviewReviewMissingMessage = (commit) => `The review round committed no \`.gtd/REVIEW.md\` (at ${commit}), so there is
17817
17950
  nothing to sign off on.
17818
17951
 
@@ -17833,27 +17966,6 @@ const wrapItem = (item) => {
17833
17966
  } else line = line === "" ? word : `${line} ${word}`;
17834
17967
  return [...out, line];
17835
17968
  };
17836
- /** Appends one section of `notes` to `.gtd/TODO.md`, wrapped the way oxfmt writes it; an existing file is only ever appended to. */
17837
- const deferNotesScript = (notes, round) => {
17838
- return `mkdir -p .gtd
17839
- if [ -s .gtd/TODO.md ]; then
17840
- [ -z "$(tail -c1 .gtd/TODO.md)" ] || printf '\\n' >> .gtd/TODO.md
17841
- printf '\\n' >> .gtd/TODO.md
17842
- fi
17843
- printf '%s\\n' ${[
17844
- `## Deferred review notes (${round.slice(0, 7)})`,
17845
- "",
17846
- ...notes.flatMap((n) => wrapItem(`- ${noteHead(n)}: ${n.text.replace(/\s+/g, " ")}`))
17847
- ].map(quote).join(" ")} >> .gtd/TODO.md
17848
- `;
17849
- };
17850
- /** The gate right after a deferral: landing clean signs off without these notes. */
17851
- const buildReviewAwaitAfterDeferMessage = (base, deferred) => `${buildReviewAwaitReviewMessage(base)}
17852
- Deferred to \`.gtd/TODO.md\` just now, NOT acted on in this process — landing
17853
- clean signs off without them:
17854
-
17855
- ${deferred.map((n) => `- ${noteHead(n)}: ${n.text.replace(/\s*\n\s*/g, " ")}`).join("\n")}
17856
- `;
17857
17969
  const buildReviewTriageMessage = () => `Judging each note the human added to \`.gtd/REVIEW.md\` (a line note, a
17858
17970
  footnote, or prose under a chunk) with one verdict: \`edit\` (a change
17859
17971
  request), \`question\`, \`nit\` (a small fix needing no re-plan) or
@@ -17863,16 +17975,18 @@ default; a skipped judgment never dismisses a note).
17863
17975
 
17864
17976
  Also judge each note's severity, one id per note: \`note-<n>.security\`,
17865
17977
  \`note-<n>.reversibility\`, \`note-<n>.blastRadius\` and
17866
- \`note-<n>.newConcept\`.
17978
+ \`note-<n>.newConcept\`. An own answer under a finding's boxes is judged
17979
+ the same way, keyed \`answer-<n>\`.
17867
17980
  `;
17868
17981
  const closedFold = (round, target) => round === "edits" ? `- Fold every hand-edit and every note the human left that round into
17869
- \`${target}\`
17982
+ \`${target}\` — a note ticked \`fix as suggested\` or answered in its
17983
+ own words included; one ticked \`won't fix\` is dropped
17870
17984
  ` : round === "notes" ? `- Fold each note that asks for a change into \`${target}\`
17871
17985
  ` : `- Fold each \`edit\` note the capture lists, no other note, and every
17872
17986
  hand-edit, into \`${target}\`
17873
17987
  `;
17874
17988
  /** `refactor` retargets the prompt at `.gtd/REFACTOR.md` and fences it to the strays. A concern outside the fence is not folded; if nothing at all is written the landing is refused, but a partly buildable round drops the rest silently. */
17875
- const buildReviewCollectingPrompt = (capture, refactor) => {
17989
+ const buildReviewCollectingPrompt = (capture, refactor, round) => {
17876
17990
  const target = refactor ? ".gtd/REFACTOR.md" : ".gtd/REQUIREMENTS.md";
17877
17991
  const fence = refactor ? `- The next refactor may change only these paths, plus an existing test
17878
17992
  file's call sites, and never a flag read:
@@ -17947,12 +18061,16 @@ ${refactor ? refactor.round === "reply" ? `- This turn replies inside \`.gtd/REV
17947
18061
  ${closedFold(refactor.round, target)}` : `- This turn writes \`${target}\` (the folded concerns) and
17948
18062
  replies inside \`.gtd/REVIEW.md\` (thread replies only; the file stays
17949
18063
  in the tree) — you classify, you do not build
17950
- - Fold every concluded thread, ticked answer and hand-edit — and each
17951
- \`edit\` note the capture lists, no other note — into
17952
- \`${target}\` and delete the folded threads from
17953
- \`.gtd/REVIEW.md\`; append one \`- A:\` reply to each thread
17954
- whose last entry is a \`- H:\` question
17955
- `}- When the capture names an answering commit, carry each answered
18064
+ - Fold every concluded thread and hand-edit — and each \`edit\` note
18065
+ the capture lists, no other note — into \`${target}\` and
18066
+ delete the folded threads from \`.gtd/REVIEW.md\`; append one \`- A:\`
18067
+ reply to each thread whose last entry is a \`- H:\` question
18068
+ ${round === "edits" ? `- A hand-edit round routes no answer box itself: fold each note
18069
+ ticked \`fix as suggested\` or answered in its own words, too; drop one
18070
+ ticked \`won't fix\`
18071
+ ` : `- Never fold a note's ticked answer box the capture does not list: gtd
18072
+ routes those itself
18073
+ `}`}- When the capture names an answering commit, carry each answered
17956
18074
  question into \`${target}\`'s \`## Answered Questions\` as
17957
18075
  \`### <the note>\` plus its answer — the lap replaces \`.gtd/REVIEW.md\`
17958
18076
  - Keep capture order, and carry each note's \`(tier: reason)\`
@@ -18456,6 +18574,36 @@ const awaitReview = (base, message = buildReviewAwaitReviewMessage(base)) => hum
18456
18574
  base,
18457
18575
  acceptClean: true
18458
18576
  });
18577
+ /** Answer every `question` note inline in `.gtd/REVIEW.md`. */
18578
+ const answerReviewQuestions = (notes) => agentWithSkills("review.answer-review-questions", buildReviewAnswerQuestionsPrompt(notes), {
18579
+ label: "Answering your questions",
18580
+ file: REVIEW,
18581
+ mode: "review",
18582
+ model: planner(),
18583
+ system: reviewerSystem()
18584
+ });
18585
+ /** Fix every `nit` note in one turn. */
18586
+ const fixNits = (notes, access, rules) => {
18587
+ const fixing = (name) => agentWithSkills(name, buildReviewFixNitsPrompt(notes, rules), {
18588
+ label: "Fixing your nits",
18589
+ file: REVIEW,
18590
+ model: planner(),
18591
+ system: reviewerSystem()
18592
+ });
18593
+ return access === void 0 ? fixing("review.fix.nits.fixing") : scope({
18594
+ name: "review.fix.nits",
18595
+ access
18596
+ }, () => fixing("fixing"));
18597
+ };
18598
+ /** Turn the review round `capture` describes into `.gtd/REQUIREMENTS.md`, or change nothing. */
18599
+ const collecting = (capture, round) => agentWithSkills("review.collecting", buildReviewCollectingPrompt(capture, void 0, round), {
18600
+ label: "Collecting your feedback",
18601
+ file: REQUIREMENTS,
18602
+ mode: "qa",
18603
+ model: planner(),
18604
+ system: reviewerSystem(),
18605
+ allowEmpty: true
18606
+ });
18459
18607
  /** The round at commit `round` left no `.gtd/REVIEW.md`. */
18460
18608
  const reviewMissing = (round) => human("review.review-missing", {
18461
18609
  message: buildReviewReviewMissingMessage(round),
@@ -38173,7 +38321,7 @@ const runTail = (finish, options) => scope("build", async () => {
38173
38321
  return finish(escalations, NO_FINDINGS);
38174
38322
  });
38175
38323
  /**
38176
- * The review and fix workflows' human review since `base`: a sign-off closes
38324
+ * The fix workflow's human review since `base`: a sign-off closes
38177
38325
  * `.gtd/REVIEW.md`; a note leaves it, with the reviewer's own version as
38178
38326
  * `REVIEW_ORIG`, for the next `fix` to read — never a re-plan.
38179
38327
  */
@@ -38193,7 +38341,7 @@ const handOffReview = (base, escalations, guard) => reviewToHandOff(base, {
38193
38341
  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.",
38194
38342
  deleted: "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note to hand to fix."
38195
38343
  });
38196
- /** The tail the review and fix workflows run: ends at the hand-off review since `base`. */
38344
+ /** The tail the fix workflow runs: ends at the hand-off review since `base`. */
38197
38345
  const handOffTail = (fixFirst, base, guard) => tail((escalations) => handOffReview(base, escalations, guard), {
38198
38346
  fixFirst,
38199
38347
  guard
@@ -38415,36 +38563,6 @@ const answerProductQuestions = () => human("gate.answer", {
38415
38563
  mode: "qa",
38416
38564
  acceptClean: true
38417
38565
  });
38418
- /** Answer every `question` note inline in `.gtd/REVIEW.md`. */
38419
- const answerReviewQuestions = (notes) => agentWithSkills("review.answer-review-questions", buildReviewAnswerQuestionsPrompt(notes), {
38420
- label: "Answering your questions",
38421
- file: REVIEW,
38422
- mode: "review",
38423
- model: planner(),
38424
- system: reviewerSystem()
38425
- });
38426
- /** Fix every `nit` note in one turn. */
38427
- const fixNits = (notes, access, rules) => {
38428
- const fixing = (name) => agentWithSkills(name, buildReviewFixNitsPrompt(notes, rules), {
38429
- label: "Fixing your nits",
38430
- file: REVIEW,
38431
- model: planner(),
38432
- system: reviewerSystem()
38433
- });
38434
- return access === void 0 ? fixing("review.fix.nits.fixing") : scope({
38435
- name: "review.fix.nits",
38436
- access
38437
- }, () => fixing("fixing"));
38438
- };
38439
- /** Turn the review round `capture` describes into `.gtd/REQUIREMENTS.md`, or change nothing. */
38440
- const collecting = (capture) => agentWithSkills("review.collecting", buildReviewCollectingPrompt(capture), {
38441
- label: "Collecting your feedback",
38442
- file: REQUIREMENTS,
38443
- mode: "qa",
38444
- model: planner(),
38445
- system: reviewerSystem(),
38446
- allowEmpty: true
38447
- });
38448
38566
  /** Turn the refactor review round `capture` describes into `.gtd/REFACTOR.md`, or change nothing. */
38449
38567
  const refactorCollecting = (capture, round, strays) => agentWithSkills("review.collecting", buildReviewCollectingPrompt(capture, {
38450
38568
  strays,
@@ -38456,11 +38574,6 @@ const refactorCollecting = (capture, round, strays) => agentWithSkills("review.c
38456
38574
  system: reviewerSystem(),
38457
38575
  allowEmpty: true
38458
38576
  });
38459
- /** `build.review.defer`: append `notes` to `.gtd/TODO.md` as one section; the step's landing is the commit. */
38460
- const deferNotes = (notes, round) => run("review.defer", deferNotesScript(notes, round), {
38461
- label: "Deferring could-notes to TODO.md",
38462
- file: ".gtd/TODO.md"
38463
- });
38464
38577
  //#endregion
38465
38578
  //#region src/workflows/feature/planning.ts
38466
38579
  /**
@@ -38487,7 +38600,7 @@ const design = (base, guard) => scope("design", async () => {
38487
38600
  } while (await noteGate(REQUIREMENTS, answerProductQuestions));
38488
38601
  });
38489
38602
  //#endregion
38490
- //#region src/workflows/feature/review.ts
38603
+ //#region src/workflows/shared/review.ts
38491
38604
  const VERDICTS = [
38492
38605
  "edit",
38493
38606
  "question",
@@ -38501,6 +38614,13 @@ const verdictQuestion = (note) => ({
38501
38614
  instructions: `Classify the human's note on review item "${note.anchor}" (in .gtd/REVIEW.md). Its evidence holds the anchor, the reviewer's text and the human's text.`,
38502
38615
  criteria: "edit: a request to change behaviour, design or scope — anything needing a plan, or any note you are unsure about. question: asks something and wants an answer, with no change requested. nit: a small, local, unambiguous fix (naming, typo, formatting, comment wording) needing no re-plan. praise: an approving remark with nothing to do."
38503
38616
  });
38617
+ const TIER_ORDER = {
38618
+ critical: 0,
38619
+ should: 1,
38620
+ could: 2
38621
+ };
38622
+ /** Capture order: critical, should, could; within a tier one-way doors, then new concepts. */
38623
+ const captureKey = (s) => s === void 0 ? 2 : TIER_ORDER[s.tier] * 3 + s.rank;
38504
38624
  const SEVERITY_ANSWERS = {
38505
38625
  security: ["yes", "no"],
38506
38626
  reversibility: ["one-way", "two-way"],
@@ -38511,10 +38631,7 @@ const SEVERITY_ANSWERS = {
38511
38631
  ],
38512
38632
  newConcept: ["yes", "no"]
38513
38633
  };
38514
- /**
38515
- * Deferring is the risky direction: anything unanswered, unrecognised, cut or
38516
- * below `minP` is `critical`, never `could`.
38517
- */
38634
+ /** Anything unanswered, unrecognised, cut or below `minP` is `critical`, never `could`. */
38518
38635
  const severityOf = (note, judgment, minP) => {
38519
38636
  const ans = (q) => judgment.answers[`${note.id}.${q}`];
38520
38637
  const a = (q) => ans(q)?.answer;
@@ -38578,7 +38695,7 @@ const severityQuestions = (note) => [
38578
38695
  * nothing) plus four severity answers. Dismissing a note is the risky
38579
38696
  * direction, so only a confident non-`edit` verdict on uncut evidence counts;
38580
38697
  * a cut, unanswered or below-floor note is an `edit`. Severity answers on a
38581
- * non-`edit` note are ignored. A confident `could` edit becomes `defer`.
38698
+ * non-`edit` note are ignored; on an `edit` they order the collecting turn.
38582
38699
  */
38583
38700
  const triage$1 = (severities) => async (notes) => {
38584
38701
  const asked = notes.filter((n) => !n.forced);
@@ -38598,21 +38715,22 @@ const triage$1 = (severities) => async (notes) => {
38598
38715
  return new Map(notes.map((n) => {
38599
38716
  const verdict = n.forced ? void 0 : VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP));
38600
38717
  if (verdict !== void 0 && !truncated.includes(n.id)) return [n.id, verdict];
38601
- const severity = severityOf(n, judgment, severityMinP);
38602
- severities.set(n.id, severity);
38603
- return [n.id, severity.tier === "could" ? "defer" : "edit"];
38718
+ severities.set(n.id, severityOf(n, judgment, severityMinP));
38719
+ return [n.id, "edit"];
38604
38720
  }));
38605
38721
  };
38606
38722
  const isCode$1 = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
38607
38723
  /**
38608
38724
  * gtd writes `.gtd/REVIEW.md` from the quality lap's `report` — no reviewer
38609
38725
  * turn — and removes what the fixer left in `.gtd/QUALITY.md`. A re-review
38610
- * after fixed nits restores the doc as committed at `carry`, answers kept.
38726
+ * after fixed nits restores the doc as committed at `carry`, answers kept;
38727
+ * a fresh one leaves out the notes the human already answered.
38611
38728
  */
38612
- const composing = (base, report, carry, nitFix) => run("review.composing", carry === void 0 ? writeScript(REVIEW, composedReview(base, {
38729
+ const composing = (base, report, carry, nitFix, answered = /* @__PURE__ */ new Set()) => run("review.composing", carry === void 0 ? writeScript(REVIEW, composedReview(base, {
38613
38730
  ...report,
38614
38731
  weakened: removedAssertions(changesSince(base)),
38615
- nitFix
38732
+ nitFix,
38733
+ answered
38616
38734
  }), [QUALITY]) : showScript(carry, REVIEW, REVIEW), {
38617
38735
  label: "Composing the review",
38618
38736
  file: REVIEW,
@@ -38620,11 +38738,13 @@ const composing = (base, report, carry, nitFix) => run("review.composing", carry
38620
38738
  });
38621
38739
  /**
38622
38740
  * The quality lap's notes reach a human over everything since `base`, who
38623
- * signs off or comments, and each note is judged: edits go to another
38624
- * planning lap, questions are answered in place, nits fixed in one batch,
38741
+ * answers each one — fix as suggested, won't fix, or their own answer — and
38742
+ * may comment. A fix joins the nit batch, a won't-fix is dropped, and an own
38743
+ * answer or comment is judged: edits end the review as `feedback` for the
38744
+ * caller to build, questions are answered in place, nits fixed in one batch,
38625
38745
  * praise dropped.
38626
38746
  */
38627
- const review$1 = (base, escalations = { rounds: 0 }, options = {}) => {
38747
+ const humanReview = (base, escalations = { rounds: 0 }, options = {}) => {
38628
38748
  const { guard, guardRests, fixAccess, rules } = options;
38629
38749
  const feedback = options.feedback ?? {
38630
38750
  file: ".gtd/REQUIREMENTS.md",
@@ -38632,17 +38752,16 @@ const review$1 = (base, escalations = { rounds: 0 }, options = {}) => {
38632
38752
  unmatched: "gtd land: no declared pattern matches the pending changes — write .gtd/REQUIREMENTS.md from the feedback, or change nothing when it asks for nothing"
38633
38753
  };
38634
38754
  const severities = /* @__PURE__ */ new Map();
38635
- let deferred = [];
38636
38755
  let nitFix;
38637
- return review$2(base, {
38756
+ const answered = /* @__PURE__ */ new Set();
38757
+ return review$1(base, {
38638
38758
  file: REVIEW,
38639
38759
  feedback: feedback.file,
38640
38760
  isCode: isCode$1,
38641
38761
  reviewing: (b, carry) => {
38642
- deferred = [];
38643
38762
  const fixedAt = nitFix;
38644
38763
  nitFix = void 0;
38645
- return composing(b, options.report ?? NO_FINDINGS, carry, fixedAt);
38764
+ return composing(b, options.report ?? NO_FINDINGS, carry, fixedAt, answered);
38646
38765
  },
38647
38766
  fixRisks: guarded((notes) => fixRisks(notes, fixAccess, rules), guard),
38648
38767
  keepGreen: () => healthy(() => fixCheck(rules), {
@@ -38651,23 +38770,11 @@ const review$1 = (base, escalations = { rounds: 0 }, options = {}) => {
38651
38770
  guardRests,
38652
38771
  rules
38653
38772
  }),
38654
- awaitReview: (b) => {
38655
- const notes = deferred;
38656
- deferred = [];
38657
- return notes.length > 0 ? awaitReview(b, buildReviewAwaitAfterDeferMessage(b, notes)) : awaitReview(b);
38658
- },
38773
+ awaitReview: (b) => awaitReview(b),
38659
38774
  reviewMissing,
38660
38775
  collecting: feedback.collecting,
38661
38776
  triage: triage$1(severities),
38662
- defer: (notes) => {
38663
- deferred = notes.map((n) => ({
38664
- id: n.id,
38665
- anchor: n.anchor,
38666
- text: n.text,
38667
- label: severities.get(n.id).reason
38668
- }));
38669
- return deferNotes(deferred, head$1());
38670
- },
38777
+ decided: (decisions) => decisions.forEach((d) => answered.add(d.anchor)),
38671
38778
  answerQuestions: answerReviewQuestions,
38672
38779
  fixNits: async (notes) => {
38673
38780
  await guarded((n) => fixNits(n, fixAccess, rules), guard)(notes);
@@ -38680,8 +38787,9 @@ const review$1 = (base, escalations = { rounds: 0 }, options = {}) => {
38680
38787
  editNotes: (round, edits, answeredAt) => reviewEditNotesCapture(round, edits.map((n, i) => ({
38681
38788
  n,
38682
38789
  i,
38683
- s: severities.get(n.id)
38684
- })).sort((x, y) => Number(x.s?.tier === "should") - Number(y.s?.tier === "should") || (x.s?.rank ?? 2) - (y.s?.rank ?? 2) || x.i - y.i).map(({ n, s }) => ({
38790
+ s: severities.get(n.id),
38791
+ key: captureKey(severities.get(n.id))
38792
+ })).sort((x, y) => x.key - y.key || x.i - y.i).map(({ n, s }) => ({
38685
38793
  id: n.id,
38686
38794
  anchor: n.anchor,
38687
38795
  text: n.forced ? n.text.replace(/^!\s*/, "") : n.text,
@@ -38699,7 +38807,7 @@ function buildTail(first, second, ...rest) {
38699
38807
  const options = callForm(first, second, rest);
38700
38808
  if (options === void 0) return Promise.reject(/* @__PURE__ */ new Error("buildTail takes buildTail(base, options?) or the deprecated buildTail(fixFirst, base, green?, options?); this call fits neither"));
38701
38809
  const [base, form] = options;
38702
- return tail((escalations, report) => review$1(base, escalations, {
38810
+ return tail((escalations, report) => humanReview(base, escalations, {
38703
38811
  ...form,
38704
38812
  report
38705
38813
  }), {
@@ -39652,7 +39760,7 @@ const settleQuality = async () => {
39652
39760
  if (rested) await healthy(() => fixCheck());
39653
39761
  };
39654
39762
  /** The triaged quality lap, then the human review since `base`. */
39655
- const reviewedTail = (base, lensBase) => tail((escalations, report) => review$1(base, escalations, { report }), {
39763
+ const reviewedTail = (base, lensBase) => tail((escalations, report) => humanReview(base, escalations, { report }), {
39656
39764
  green: true,
39657
39765
  lensBase,
39658
39766
  triage: true
@@ -39731,6 +39839,7 @@ const skills = (vars) => {
39731
39839
  return {
39732
39840
  design: ["spec-driven-development", "planning-and-task-breakdown"],
39733
39841
  notes: ["incremental-implementation", "code-simplification"],
39842
+ feedback: ["test-driven-development", "incremental-implementation"],
39734
39843
  ...build,
39735
39844
  ...Object.fromEntries(Object.entries(build).map(([name, list]) => [`refactor.${name}`, list])),
39736
39845
  refactor: ["ponytail"],
@@ -39749,11 +39858,9 @@ const skills = (vars) => {
39749
39858
  const access = (vars) => ({
39750
39859
  design: { write: [] },
39751
39860
  "build.review": { write: [".gtd/REVIEW.md"] },
39752
- "build.review.defer": { write: [".gtd/TODO.md"] },
39753
39861
  "build.review.fix.nits": {},
39754
39862
  "build.review.fix.risks": {},
39755
39863
  "refactor.build.review": { write: [".gtd/REVIEW.md"] },
39756
- "refactor.build.review.defer": { write: [".gtd/TODO.md"] },
39757
39864
  ...lensScopes("build.quality", lensesOf(vars.qualityReviews ?? ""), () => ({ write: [] })),
39758
39865
  ...lensScopes("refactor.build.quality", lensesOf(vars.refactorQualityReviews ?? ""), () => ({ write: [] }))
39759
39866
  });
@@ -39766,15 +39873,34 @@ const steering = {
39766
39873
  };
39767
39874
  //#endregion
39768
39875
  //#region src/workflows/review/review.ts
39876
+ /** Build the concerns a review round collected into `.gtd/REQUIREMENTS.md`, then remove it. */
39877
+ const fixFeedback = async () => {
39878
+ await agentWithSkills("feedback.fixing", reviewFeedbackFixPrompt(), {
39879
+ label: "Fixing your review feedback",
39880
+ file: REQUIREMENTS,
39881
+ model: coder(),
39882
+ system: finisherSystem(),
39883
+ allowEmpty: true
39884
+ });
39885
+ await run("feedback.closing", removeScript([REQUIREMENTS]), { label: "Closing the feedback" });
39886
+ };
39769
39887
  /**
39770
- * Pure review of everything since `reviewBase`: a green gate, the quality
39771
- * lap, then the human review. A note ends the process with `.gtd/REVIEW.md`
39772
- * left for `fix`; it never re-plans.
39888
+ * The feature's review of everything since `reviewBase` — a green gate, the
39889
+ * triaged quality lap, then the human review — with no design phase: a
39890
+ * review that asks for changes is fixed in place and reviewed again, until
39891
+ * sign-off.
39773
39892
  */
39774
39893
  const reviewFlow = async () => {
39775
39894
  refusePendingHandOff();
39895
+ if (read(".gtd/REQUIREMENTS.md") !== void 0) refuse(`gtd: ${REQUIREMENTS} is left over — finish or remove it before a review.`);
39776
39896
  await gate("review-gate", reviewGateBlockedMessage());
39777
- await handOffTail(false, start());
39897
+ const base = start();
39898
+ let outcome = await buildTail(base, { green: true });
39899
+ while (outcome.verdict === "feedback") {
39900
+ await reUnwind(outcome);
39901
+ await fixFeedback();
39902
+ outcome = await buildTail(base, { lensBase: outcome.base });
39903
+ }
39778
39904
  };
39779
39905
  const review = shipped(reviewFlow, { review: {
39780
39906
  args: [{
@@ -39900,4 +40026,4 @@ var workflows_default = feature;
39900
40026
  */
39901
40027
  const doors = {};
39902
40028
  //#endregion
39903
- export { COMMENT, ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, HANDOFF, QUALITY, REFACTOR, REFACTOR_NOTE, REFACTOR_PACKAGE, REQUIREMENTS, REVIEW, REVIEW_ORIG, SLICES, ShippedOptions, acceptanceFault, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, awaitReview, base, baseline, buildFeature, buildTail, builtInLenses, cleanup, cleanupFlow, closeOut, closeReview, coder, collecting, criteriaOf, criterionIds, workflows_default as default, defaults, deferNotes, describeEscalation, design, doors, envDefaults, escalate, escalation, escalationExhausted, feature, fix, fixCheck, fixFlow, fixNits, fixQuality, fixQualityFindings, fixReviewNotes, fixRisks, gate, guarded, handOffReview, handOffTail, healthy, holdsCallSites, implement, implementing, isTestFile, judgeSeam, lensEntriesOf, lensOf, lensesOf, naiveAcceptance, naivePass, ordinaryStart, planAndBuild, planner, qualityLap, reUnwind, recheck, refactorCollecting, refactorFirst, refactorNote, refactorStrays, requireAcceptance, requireAcceptanceKept, requireCriteria, requireCriteriaFrozen, requireCriteriaIfEdited, requireNoNewFlags, requireNoTestFiles, requireSlices, requireStableIds, requireTestCallSitesOnly, requireWithin, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewedRefactor, reviewing, rollout, rolloutFlow, seamBuild, skills, skipDesign, sliceCap, sliceCriteria, sliceIds, slicesOf, slicing, statedCriteria, steering, summary, tail, threads, triage, unstableIds, unwind };
40029
+ export { COMMENT, ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, HANDOFF, QUALITY, REFACTOR, REFACTOR_NOTE, REFACTOR_PACKAGE, REQUIREMENTS, REVIEW, REVIEW_ORIG, SLICES, ShippedOptions, acceptanceFault, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, awaitReview, base, baseline, buildFeature, buildTail, builtInLenses, cleanup, cleanupFlow, closeOut, closeReview, coder, collecting, criteriaOf, criterionIds, workflows_default as default, defaults, describeEscalation, design, doors, envDefaults, escalate, escalation, escalationExhausted, feature, fix, fixCheck, fixFlow, fixNits, fixQuality, fixQualityFindings, fixReviewNotes, fixRisks, gate, guarded, handOffReview, handOffTail, healthy, holdsCallSites, implement, implementing, isTestFile, judgeSeam, lensEntriesOf, lensOf, lensesOf, naiveAcceptance, naivePass, ordinaryStart, planAndBuild, planner, qualityLap, reUnwind, recheck, refactorCollecting, refactorFirst, refactorNote, refactorStrays, requireAcceptance, requireAcceptanceKept, requireCriteria, requireCriteriaFrozen, requireCriteriaIfEdited, requireNoNewFlags, requireNoTestFiles, requireSlices, requireStableIds, requireTestCallSitesOnly, requireWithin, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewedRefactor, reviewing, rollout, rolloutFlow, seamBuild, skills, skipDesign, sliceCap, sliceCriteria, sliceIds, slicesOf, slicing, statedCriteria, steering, summary, tail, threads, triage, unstableIds, unwind };