@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
@@ -0,0 +1,249 @@
1
+ import { answered, changesSince, head, judge, numeric, requireRevert, restoreScript, run, showScript, vars, writeScript } from "../flows.mjs";
2
+ import { removedAssertions, review } from "../workflow-kit.mjs";
3
+ import { A as reviewMissing, E as fixNits, F as buildReviewTriageMessage, O as fixRisks, S as awaitReview, T as fixCheck, d as guarded, dt as reviewEditNotesCapture, f as healthy, ft as reviewEditsCapture, h as QUALITY, ht as reviewNotesCapture, w as collecting, x as answerReviewQuestions, y as REVIEW, z as composedReview } from "./reserved-DJk_6-ju.mjs";
4
+ import { i as isOptionalObject, n as closeReview, o as tail, s as NO_FINDINGS } from "./tail-5w5mxGTq.mjs";
5
+ //#region src/workflows/shared/review.ts
6
+ const VERDICTS = [
7
+ "edit",
8
+ "question",
9
+ "nit",
10
+ "praise"
11
+ ];
12
+ /** One four-way choice per note the human added to `.gtd/REVIEW.md`. */
13
+ const verdictQuestion = (note) => ({
14
+ id: note.id,
15
+ primitive: "choice",
16
+ 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.`,
17
+ 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."
18
+ });
19
+ const TIER_ORDER = {
20
+ critical: 0,
21
+ should: 1,
22
+ could: 2
23
+ };
24
+ /** Capture order: critical, should, could; within a tier one-way doors, then new concepts. */
25
+ const captureKey = (s) => s === void 0 ? 2 : TIER_ORDER[s.tier] * 3 + s.rank;
26
+ const SEVERITY_ANSWERS = {
27
+ security: ["yes", "no"],
28
+ reversibility: ["one-way", "two-way"],
29
+ blastRadius: [
30
+ "local",
31
+ "module",
32
+ "contract"
33
+ ],
34
+ newConcept: ["yes", "no"]
35
+ };
36
+ /** Anything unanswered, unrecognised, cut or below `minP` is `critical`, never `could`. */
37
+ const severityOf = (note, judgment, minP) => {
38
+ const ans = (q) => judgment.answers[`${note.id}.${q}`];
39
+ const a = (q) => ans(q)?.answer;
40
+ const rank = () => a("reversibility") === "one-way" ? 0 : a("newConcept") === "yes" ? 1 : 2;
41
+ if (note.forced) return {
42
+ tier: "critical",
43
+ reason: "critical: forced by `!`",
44
+ rank: 2
45
+ };
46
+ if (judgment.truncated.includes(note.id) || Object.entries(SEVERITY_ANSWERS).some(([q, allowed]) => !allowed.includes(a(q) ?? "") || !((ans(q)?.p ?? NaN) >= minP))) return {
47
+ tier: "critical",
48
+ reason: "critical: judge unsure",
49
+ rank: 2
50
+ };
51
+ const critical = [...a("security") === "yes" ? ["security"] : [], ...a("reversibility") === "one-way" ? ["one-way door"] : []];
52
+ if (critical.length > 0) return {
53
+ tier: "critical",
54
+ reason: `critical: ${critical.join(", ")}`,
55
+ rank: rank()
56
+ };
57
+ const should = [...a("blastRadius") === "contract" ? ["contract"] : [], ...a("newConcept") === "yes" ? ["new concept"] : []];
58
+ if (should.length > 0) return {
59
+ tier: "should",
60
+ reason: `should: ${should.join(", ")}`,
61
+ rank: rank()
62
+ };
63
+ return {
64
+ tier: "could",
65
+ reason: "could",
66
+ rank: 2
67
+ };
68
+ };
69
+ const severityQuestions = (note) => [
70
+ {
71
+ id: `${note.id}.security`,
72
+ primitive: "noul",
73
+ instructions: `Does the change the human asks for in note "${note.id}" (on "${note.anchor}") touch security — authentication, authorisation, secrets, untrusted input or data exposure?`,
74
+ criteria: "yes: it changes or could weaken a security property. no: it does not."
75
+ },
76
+ {
77
+ id: `${note.id}.reversibility`,
78
+ primitive: "choice",
79
+ instructions: `Once the change in note "${note.id}" ships, can it be undone without breaking anyone who already depends on it?`,
80
+ criteria: "one-way: a published API, file format, persisted data, protocol or other contract that cannot be taken back. two-way: can be reverted freely."
81
+ },
82
+ {
83
+ id: `${note.id}.blastRadius`,
84
+ primitive: "choice",
85
+ instructions: `How far does the change in note "${note.id}" reach?`,
86
+ criteria: "local: one function or file. module: a whole module or several files. contract: a public interface other code or users depend on."
87
+ },
88
+ {
89
+ id: `${note.id}.newConcept`,
90
+ primitive: "noul",
91
+ instructions: `Does the change in note "${note.id}" introduce a concept new to this codebase — a new abstraction, entity or term? Judge from the evidence, never a glossary; reusing or extending an existing concept is not new.`,
92
+ criteria: "yes: a genuinely new concept. no: reuses or extends an existing one."
93
+ }
94
+ ];
95
+ /**
96
+ * One judge rest: a verdict per unforced note (a forced note is asked
97
+ * nothing) plus four severity answers. Dismissing a note is the risky
98
+ * direction, so only a confident non-`edit` verdict on uncut evidence counts;
99
+ * a cut, unanswered or below-floor note is an `edit`. Severity answers on a
100
+ * non-`edit` note are ignored; on an `edit` they order the collecting turn.
101
+ */
102
+ const triage = (severities) => async (notes) => {
103
+ const asked = notes.filter((n) => !n.forced);
104
+ const judgment = asked.length === 0 ? {
105
+ answers: {},
106
+ truncated: []
107
+ } : await judge("review.triage", {
108
+ questions: asked.flatMap((n) => [verdictQuestion(n), ...severityQuestions(n)]),
109
+ evidence: Object.fromEntries(asked.map((n) => [n.id, `Anchor: ${n.anchor}\nReviewer's text: ${n.before}\nHuman's text: ${n.text}`])),
110
+ message: buildReviewTriageMessage(),
111
+ label: "Judging each note"
112
+ });
113
+ const { answers, truncated } = judgment;
114
+ const minP = numeric(vars.reviewNoteActionable, Infinity);
115
+ const severityMinP = numeric(vars.reviewSeverityMinP, Infinity);
116
+ severities.clear();
117
+ return new Map(notes.map((n) => {
118
+ const verdict = n.forced ? void 0 : VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP));
119
+ if (verdict !== void 0 && !truncated.includes(n.id)) return [n.id, verdict];
120
+ severities.set(n.id, severityOf(n, judgment, severityMinP));
121
+ return [n.id, "edit"];
122
+ }));
123
+ };
124
+ const isCode = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
125
+ /**
126
+ * gtd writes `.gtd/REVIEW.md` from the quality lap's `report` — no reviewer
127
+ * turn — and removes what the fixer left in `.gtd/QUALITY.md`. A re-review
128
+ * after fixed nits restores the doc as committed at `carry`, answers kept;
129
+ * a fresh one leaves out the notes the human already answered.
130
+ */
131
+ const composing = (base, report, carry, nitFix, answered = /* @__PURE__ */ new Set()) => run("review.composing", carry === void 0 ? writeScript(REVIEW, composedReview(base, {
132
+ ...report,
133
+ weakened: removedAssertions(changesSince(base)),
134
+ nitFix,
135
+ answered
136
+ }), [QUALITY]) : showScript(carry, REVIEW, REVIEW), {
137
+ label: "Composing the review",
138
+ file: REVIEW,
139
+ base
140
+ });
141
+ /**
142
+ * The quality lap's notes reach a human over everything since `base`, who
143
+ * answers each one — fix as suggested, won't fix, or their own answer — and
144
+ * may comment. A fix joins the nit batch, a won't-fix is dropped, and an own
145
+ * answer or comment is judged: edits end the review as `feedback` for the
146
+ * caller to build, questions are answered in place, nits fixed in one batch,
147
+ * praise dropped.
148
+ */
149
+ const humanReview = (base, escalations = { rounds: 0 }, options = {}) => {
150
+ const { guard, guardRests, fixAccess, rules } = options;
151
+ const feedback = options.feedback ?? {
152
+ file: ".gtd/REQUIREMENTS.md",
153
+ collecting,
154
+ 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"
155
+ };
156
+ const severities = /* @__PURE__ */ new Map();
157
+ let nitFix;
158
+ const answered = /* @__PURE__ */ new Set();
159
+ return review(base, {
160
+ file: REVIEW,
161
+ feedback: feedback.file,
162
+ isCode,
163
+ reviewing: (b, carry) => {
164
+ const fixedAt = nitFix;
165
+ nitFix = void 0;
166
+ return composing(b, options.report ?? NO_FINDINGS, carry, fixedAt, answered);
167
+ },
168
+ fixRisks: guarded((notes) => fixRisks(notes, fixAccess, rules), guard),
169
+ keepGreen: () => healthy(() => fixCheck(rules), {
170
+ escalations,
171
+ guard,
172
+ guardRests,
173
+ rules
174
+ }),
175
+ awaitReview: (b) => awaitReview(b),
176
+ reviewMissing,
177
+ collecting: feedback.collecting,
178
+ triage: triage(severities),
179
+ decided: (decisions) => decisions.forEach((d) => answered.add(d.anchor)),
180
+ answerQuestions: answerReviewQuestions,
181
+ fixNits: async (notes) => {
182
+ await guarded((n) => fixNits(n, fixAccess, rules), guard)(notes);
183
+ nitFix = head();
184
+ },
185
+ close: closeReview,
186
+ captures: {
187
+ edits: reviewEditsCapture,
188
+ notes: reviewNotesCapture,
189
+ editNotes: (round, edits, answeredAt) => reviewEditNotesCapture(round, edits.map((n, i) => ({
190
+ n,
191
+ i,
192
+ s: severities.get(n.id),
193
+ key: captureKey(severities.get(n.id))
194
+ })).sort((x, y) => x.key - y.key || x.i - y.i).map(({ n, s }) => ({
195
+ id: n.id,
196
+ anchor: n.anchor,
197
+ text: n.forced ? n.text.replace(/^!\s*/, "") : n.text,
198
+ ...s === void 0 ? {} : { label: s.reason }
199
+ })), answeredAt)
200
+ },
201
+ refusals: {
202
+ deleted: "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note (or edit code) to request changes.",
203
+ unmatched: feedback.unmatched,
204
+ ...feedback.dropped === void 0 ? {} : { dropped: feedback.dropped }
205
+ }
206
+ });
207
+ };
208
+ function buildTail(first, second, ...rest) {
209
+ const options = callForm(first, second, rest);
210
+ 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"));
211
+ const [base, form] = options;
212
+ return tail((escalations, report) => humanReview(base, escalations, {
213
+ ...form,
214
+ report
215
+ }), {
216
+ ...form,
217
+ triage: true
218
+ });
219
+ }
220
+ const isOptionalBoolean = (value) => value == null || typeof value === "boolean";
221
+ const legacyForm = (fixFirst, base, green, options) => isOptionalBoolean(fixFirst) && isOptionalBoolean(green) && isOptionalObject(options) ? [base, {
222
+ ...options,
223
+ fixFirst: fixFirst ?? void 0,
224
+ green: green ?? void 0
225
+ }] : void 0;
226
+ const currentForm = (base, options, third, fourth) => isOptionalObject(options) && third == null && fourth == null ? [base, options ?? {}] : void 0;
227
+ const callForm = (first, second, [third, fourth, ...extra]) => {
228
+ if (extra.some((value) => value != null)) return void 0;
229
+ if (typeof second === "string") return legacyForm(first, second, third, fourth);
230
+ if (typeof first === "string") return currentForm(first, second, third, fourth);
231
+ };
232
+ /**
233
+ * Undo the human's review-round code edits. A path changed since the human
234
+ * left it is not overwritten; requireRevert names it instead.
235
+ */
236
+ const reUnwind = async (feedback) => {
237
+ const { base, restoreFrom, edited } = feedback;
238
+ await run("re-unwind", restoreScript(base, {
239
+ restore: edited.filter((c) => c.status !== "added").map((c) => c.path),
240
+ remove: edited.filter((c) => c.status === "added").map((c) => c.path)
241
+ }, restoreFrom), {
242
+ label: "Re-unwinding your review edit",
243
+ file: REVIEW,
244
+ base
245
+ });
246
+ requireRevert(edited, base);
247
+ };
248
+ //#endregion
249
+ export { reUnwind as i, humanReview as n, isCode as r, buildTail as t };
@@ -13349,8 +13349,8 @@ const eolOf = (content) => content.includes("\r\n") ? "\r\n" : "\n";
13349
13349
  const PREFIX_RE$1 = /^(H|A):\s*/;
13350
13350
  const lineOf = (node, which) => toLspPosition(node.position[which]).line;
13351
13351
  /** 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. */
13352
- const itemText = (content, item) => item.children.map((child) => sourceText(content, child)).join(" ").trim();
13353
- const prefixOf = (content, item) => PREFIX_RE$1.exec(itemText(content, item));
13352
+ const itemText$1 = (content, item) => item.children.map((child) => sourceText(content, child)).join(" ").trim();
13353
+ const prefixOf = (content, item) => PREFIX_RE$1.exec(itemText$1(content, item));
13354
13354
  /** A thread is a definition whose body OPENS with a list whose first item carries a prefix — anything else stays a one-shot footnote. */
13355
13355
  const isThreadBody = (content, children) => {
13356
13356
  const first = children[0];
@@ -13370,7 +13370,7 @@ const entryOf = (content, item, previous, report) => {
13370
13370
  return;
13371
13371
  }
13372
13372
  const author = match[1] === "H" ? "me" : "agent";
13373
- const text = itemText(content, item).slice(match[0].length).trim();
13373
+ const text = itemText$1(content, item).slice(match[0].length).trim();
13374
13374
  if (text === "") report(`empty "${match[1]}:" entry — write your own words there`, start, end);
13375
13375
  if (previous === void 0 && author === "agent") report(`the agent never starts a thread — the first entry must be "H:"`, start, end);
13376
13376
  else if (previous === author) report(`two consecutive "${match[1]}:" entries — entries must alternate`, start, end);
@@ -15086,6 +15086,9 @@ const qaDescriptor = {
15086
15086
  };
15087
15087
  //#endregion
15088
15088
  //#region src/steering/review.ts
15089
+ const ANSWER_LEAD = "Tick exactly one:";
15090
+ const ANSWER_FIX = "fix as suggested";
15091
+ const ANSWER_WONT_FIX = "won't fix";
15089
15092
  /**
15090
15093
  * The canonical sample. Every detail here is load-bearing for the formatter
15091
15094
  * round-trip: both footnote bodies exceed 80 characters so a reflow actually
@@ -15352,16 +15355,42 @@ const parseHunk = (content, lines, title, item) => {
15352
15355
  errors: [hunkSecondPointerFinding(content, lines, item, token, sourceLine, title, file), hunkLineWithoutRangeFinding(content, item, token, sourceLine, title, file)].filter((e) => e !== void 0)
15353
15356
  };
15354
15357
  };
15358
+ const itemText = (content, item) => item.children.map((c) => sourceText(content, c)).join(" ").replace(/^\[[ xX]\]\s*/, "").trim();
15359
+ /**
15360
+ * The task list right after the `Tick exactly one:` paragraph, with that
15361
+ * paragraph's index. Read by position, never by label, so an edited label or
15362
+ * a free text opening with `./path` is still an answer, not a pointer. Any
15363
+ * count but three boxes is kept, and reads as unanswered.
15364
+ */
15365
+ const answerListOf = (content, body) => {
15366
+ 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));
15367
+ if (lead === -1) return void 0;
15368
+ const list = body[lead + 1];
15369
+ const last = list.children.length - 1;
15370
+ return {
15371
+ lead,
15372
+ list,
15373
+ options: list.children.map((item, i) => {
15374
+ const text = itemText(content, item);
15375
+ return {
15376
+ checked: item.checked === true,
15377
+ text: i === last && text.toLowerCase() === "_your answer_".toLowerCase() ? "" : text
15378
+ };
15379
+ })
15380
+ };
15381
+ };
15355
15382
  /** 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). */
15356
15383
  const parseChunkBody = (content, lines, title, body) => {
15357
- const firstPointerIndex = body.findIndex((n) => taskItems(n).some((item) => hasPointerToken(content, item)));
15358
- const leadingRun = firstPointerIndex === -1 ? body : body.slice(0, firstPointerIndex);
15384
+ const answer = answerListOf(content, body);
15385
+ const isAnswer = (n) => answer !== void 0 && (n === answer.list || n === body[answer.lead]);
15386
+ const firstPointerIndex = body.findIndex((n) => !isAnswer(n) && taskItems(n).some((item) => hasPointerToken(content, item)));
15387
+ const leadingRun = (firstPointerIndex === -1 ? body : body.slice(0, firstPointerIndex)).filter((n) => !isAnswer(n));
15359
15388
  const descriptionNodes = blockNodesOfRun(content, leadingRun);
15360
15389
  const descriptionInline = collapseImages(blockRunInline(content, leadingRun));
15361
15390
  const description = leadingRun.filter((n) => n.type !== "footnoteDefinition").map((n) => sourceText(content, n)).join(" ").trim();
15362
15391
  const files = [];
15363
15392
  const errors = [];
15364
- for (const item of body.flatMap((n) => taskItems(n))) {
15393
+ for (const item of body.filter((n) => !isAnswer(n)).flatMap((n) => taskItems(n))) {
15365
15394
  const parsed = parseHunk(content, lines, title, item);
15366
15395
  if (!parsed) continue;
15367
15396
  files.push(parsed.file);
@@ -15372,7 +15401,9 @@ const parseChunkBody = (content, lines, title, body) => {
15372
15401
  descriptionNodes,
15373
15402
  descriptionInline,
15374
15403
  files,
15375
- errors
15404
+ errors,
15405
+ ...answer === void 0 ? {} : { answers: answer.options },
15406
+ boxed: body.some((n) => !isAnswer(n) && taskItems(n).length > 0)
15376
15407
  };
15377
15408
  };
15378
15409
  /** 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. */
@@ -15408,9 +15439,9 @@ const parseChangesets = (tree, content, lines) => {
15408
15439
  const errors = [];
15409
15440
  for (const { heading, headingLine, body } of splitChunks(tree)) {
15410
15441
  const title = headingText$1(content, heading);
15411
- const { description, descriptionNodes, descriptionInline, files, errors: bodyErrors } = parseChunkBody(content, lines, title, body);
15442
+ const { description, descriptionNodes, descriptionInline, files, answers, boxed, errors: bodyErrors } = parseChunkBody(content, lines, title, body);
15412
15443
  errors.push(...bodyErrors);
15413
- if (files.length === 0 && body.some((n) => taskItems(n).length > 0)) errors.push({
15444
+ if (files.length === 0 && boxed) errors.push({
15414
15445
  message: `Chunk "${title}" has no file pointers`,
15415
15446
  line: headingLine,
15416
15447
  range: nodeRange(heading)
@@ -15421,7 +15452,8 @@ const parseChangesets = (tree, content, lines) => {
15421
15452
  descriptionNodes,
15422
15453
  descriptionInline,
15423
15454
  files,
15424
- headingLine
15455
+ headingLine,
15456
+ ...answers === void 0 ? {} : { answers }
15425
15457
  });
15426
15458
  }
15427
15459
  if (changesets.length === 0) errors.push({ message: "REVIEW.md has no '##' chunks" });
@@ -15502,7 +15534,9 @@ const isHunkPointerItem = (content, item) => {
15502
15534
  * clear, so a caller can skip the write and leave the mtime untouched.
15503
15535
  */
15504
15536
  const reviewClearTicks = (content) => {
15505
- 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);
15537
+ const tree = parseMarkdown(content);
15538
+ const answers = new Set(splitChunks(tree).flatMap(({ body }) => answerListOf(content, body)?.list.children ?? []));
15539
+ 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);
15506
15540
  if (offsets.length === 0) return content;
15507
15541
  let result = "";
15508
15542
  let cursor = 0;
@@ -15936,6 +15970,55 @@ const reviewRisks = (content) => parseReviewDoc(content).changesets.flatMap((chu
15936
15970
  id: `risk-${i + 1}`,
15937
15971
  ...r
15938
15972
  }));
15973
+ const CHOICES = [
15974
+ "fix",
15975
+ "wontfix",
15976
+ "answer"
15977
+ ];
15978
+ const decisionOf = (chunk) => {
15979
+ if (chunk.answers?.length !== 3) return void 0;
15980
+ const ticked = chunk.answers.flatMap((o, i) => o.checked ? [i] : []);
15981
+ if (ticked.length !== 1) return void 0;
15982
+ const i = ticked[0];
15983
+ const text = i === 2 ? chunk.answers[2].text : "";
15984
+ if (i === 2 && text === "") return void 0;
15985
+ return {
15986
+ anchor: chunk.title,
15987
+ body: chunk.description,
15988
+ choice: CHOICES[i],
15989
+ text
15990
+ };
15991
+ };
15992
+ /**
15993
+ * Notes whose answer boxes do not hold exactly one tick (a ticked free-text
15994
+ * slot filled in), with their 1-based heading line and why; and, given the
15995
+ * file `before` the edit, every note whose boxes were deleted.
15996
+ */
15997
+ const unansweredNotes = (content, before) => {
15998
+ const now = parseReviewDoc(content).changesets;
15999
+ const boxed = new Set(now.filter((c) => c.answers !== void 0).map((c) => c.title));
16000
+ const removed = (before === void 0 ? [] : parseReviewDoc(before).changesets).filter((c) => c.answers !== void 0 && !boxed.has(c.title)).map((c) => ({
16001
+ title: c.title,
16002
+ line: 0,
16003
+ reason: "its answer boxes were deleted — restore them"
16004
+ }));
16005
+ return [...now.filter((c) => c.answers !== void 0 && decisionOf(c) === void 0).map((c) => ({
16006
+ title: c.title,
16007
+ line: c.headingLine + 1,
16008
+ reason: c.answers.length === 3 ? "tick exactly one box" : `it has ${c.answers.length} boxes — restore the three gtd wrote`
16009
+ })), ...removed];
16010
+ };
16011
+ /** Every answer in `after` that `before` did not already hold for the same note, in document order. */
16012
+ const reviewDecisions = (before, after) => {
16013
+ const prior = new Map(parseReviewDoc(before).changesets.map((c) => [c.title, c]));
16014
+ return parseReviewDoc(after).changesets.flatMap((chunk) => {
16015
+ const decision = decisionOf(chunk);
16016
+ if (decision === void 0) return [];
16017
+ const was = prior.get(chunk.title);
16018
+ const old = was === void 0 ? void 0 : decisionOf(was);
16019
+ return old?.choice === decision.choice && old.text === decision.text ? [] : [decision];
16020
+ });
16021
+ };
15939
16022
  //#endregion
15940
16023
  //#region src/steering/Edits.ts
15941
16024
  /** Splices `edits` into `content`, sorted last-to-first so an earlier range's offset is never invalidated by a later edit. Positions are 0-based line/character over `\n`-split lines. */
@@ -16304,4 +16387,4 @@ const viewOf = (format, content) => ({
16304
16387
  /** Every OPEN thread — last entry is the agent's — as `{ name, line, firstMe }` (0-based line). `[]` for a format with no thread concept. */
16305
16388
  const openThreads = (format, content) => format.openThreads?.(content) ?? [];
16306
16389
  //#endregion
16307
- export { headingSections as C, headingSectionBodies as S, openThreadFindings as _, unansweredQuestions as a, parseThreadsWithFindings as b, stripCodeThreads as c, reviewNotes as d, reviewRisks as f, THREAD_REPLY_ACTION_TITLE as g, FOOTNOTE_ACTION_TITLE as h, steeringFormatOrFreeForm as i, freeFormFormat as l, isAnswered as m, openThreads as n, viewOf as o, FREE_TEXT_PLACEHOLDER as p, steeringFormatFor as r, parseCodeThreads as s, BUILT_IN_MODE_NAMES as t, applySteeringEdits as u, parseFootnotes as v, formatFinding as w, getParseCount as x, parseThreads as y };
16390
+ export { parseFootnotes as C, headingSectionBodies as D, getParseCount as E, headingSections as O, openThreadFindings as S, parseThreadsWithFindings as T, unansweredNotes as _, unansweredQuestions as a, FOOTNOTE_ACTION_TITLE as b, stripCodeThreads as c, ANSWER_FIX as d, ANSWER_LEAD as f, reviewRisks as g, reviewNotes as h, steeringFormatOrFreeForm as i, formatFinding as k, freeFormFormat as l, reviewDecisions as m, openThreads as n, viewOf as o, ANSWER_WONT_FIX as p, steeringFormatFor as r, parseCodeThreads as s, BUILT_IN_MODE_NAMES as t, applySteeringEdits as u, FREE_TEXT_PLACEHOLDER as v, parseThreads as w, THREAD_REPLY_ACTION_TITLE as x, isAnswered as y };
@@ -1,6 +1,6 @@
1
1
  import { answered, head, judge, mark, numeric, read, refuse, removeScript, run, scope, sectionBodies, showScript, vars, writeScript } from "../flows.mjs";
2
2
  import { fixFindings, lensEntriesOf, lensOf, qualityLap as qualityLap$1, reviewToHandOff } from "../workflow-kit.mjs";
3
- import { C as fixCheck, D as reviewMissing, O as reviewQuality, T as fixRisks, X as handOffAwaitReviewMessage, at as qualityTriageMessage, d as guarded, f as healthy, h as QUALITY, it as qualityFindingsFile, k as reviewing, l as escalation, w as fixQuality, x as awaitReview, y as REVIEW } from "./reserved-D7MNY9QJ.mjs";
3
+ import { A as reviewMissing, D as fixQuality, M as reviewing, O as fixRisks, S as awaitReview, T as fixCheck, Y as handOffAwaitReviewMessage, d as guarded, f as healthy, h as QUALITY, it as qualityTriageMessage, j as reviewQuality, l as escalation, rt as qualityFindingsFile, y as REVIEW } from "./reserved-DJk_6-ju.mjs";
4
4
  //#region src/workflows/shared/lenses.ts
5
5
  const NO_FINDINGS = {
6
6
  notes: [],
@@ -217,7 +217,7 @@ const runTail = (finish, options) => scope("build", async () => {
217
217
  return finish(escalations, NO_FINDINGS);
218
218
  });
219
219
  /**
220
- * The review and fix workflows' human review since `base`: a sign-off closes
220
+ * The fix workflow's human review since `base`: a sign-off closes
221
221
  * `.gtd/REVIEW.md`; a note leaves it, with the reviewer's own version as
222
222
  * `REVIEW_ORIG`, for the next `fix` to read — never a re-plan.
223
223
  */
@@ -237,7 +237,7 @@ const handOffReview = (base, escalations, guard) => reviewToHandOff(base, {
237
237
  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.",
238
238
  deleted: "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note to hand to fix."
239
239
  });
240
- /** The tail the review and fix workflows run: ends at the hand-off review since `base`. */
240
+ /** The tail the fix workflow runs: ends at the hand-off review since `base`. */
241
241
  const handOffTail = (fixFirst, base, guard) => tail((escalations) => handOffReview(base, escalations, guard), {
242
242
  fixFirst,
243
243
  guard