@pmelab/gtd 42.0.0 → 43.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 (44) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +79 -35
  3. package/claude/hooks/sketch.ts +1 -1
  4. package/dist/chunks/{reserved-D7MNY9QJ.mjs → reserved-COEMFEpy.mjs} +35 -46
  5. package/dist/chunks/{steering-oCDdQlpP.mjs → steering-D4LUQ-av.mjs} +95 -12
  6. package/dist/chunks/{tail-D2o2h5cc.mjs → tail-C_xn8U7U.mjs} +1 -1
  7. package/dist/gtd.bundle.mjs +488 -30
  8. package/dist/steering.mjs +2 -2
  9. package/dist/testkit.mjs +447 -27
  10. package/dist/web/main.js +42 -13
  11. package/dist/workflow-kit.mjs +61 -25
  12. package/dist/workflows/cleanup.mjs +2 -2
  13. package/dist/workflows/feature.mjs +31 -42
  14. package/dist/workflows/fix.mjs +3 -3
  15. package/dist/workflows/review.mjs +2 -2
  16. package/dist/workflows/rollout.mjs +1 -1
  17. package/dist/workflows.mjs +217 -121
  18. package/package.json +1 -1
  19. package/src/cli/Cli.test.ts +33 -1
  20. package/src/cli/Cli.ts +70 -6
  21. package/src/cli/History.ts +15 -0
  22. package/src/cli/Judges.test.ts +373 -0
  23. package/src/cli/Judges.ts +624 -0
  24. package/src/cli/Metrics.ts +3 -16
  25. package/src/cli/Program.test.ts +3 -1
  26. package/src/cli/Program.ts +9 -0
  27. package/src/steering/index.ts +12 -1
  28. package/src/steering/review.test.ts +107 -0
  29. package/src/steering/review.ts +159 -9
  30. package/src/testkit/EmittedScriptRecognizer.ts +0 -14
  31. package/src/testkit/steps.ts +4 -0
  32. package/src/workflow-kit/review.test.ts +170 -26
  33. package/src/workflow-kit/review.ts +95 -19
  34. package/src/workflows/feature/lens-review.feature +16 -4
  35. package/src/workflows/feature/review-answers.feature +179 -0
  36. package/src/workflows/feature/review.test.ts +5 -53
  37. package/src/workflows/feature/review.ts +31 -42
  38. package/src/workflows/feature/steps.test.ts +0 -12
  39. package/src/workflows/feature/steps.ts +3 -10
  40. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +6 -5
  41. package/src/workflows/shared/access.test.ts +2 -11
  42. package/src/workflows/shared/access.ts +0 -2
  43. package/src/workflows/shared/text.test.ts +40 -89
  44. package/src/workflows/shared/text.ts +52 -54
package/dist/web/main.js CHANGED
@@ -29844,8 +29844,8 @@ const eolOf = (content) => content.includes("\r\n") ? "\r\n" : "\n";
29844
29844
  const PREFIX_RE = /^(H|A):\s*/;
29845
29845
  const lineOf = (node, which) => toLspPosition(node.position[which]).line;
29846
29846
  /** The item's own blocks joined — never the item node's span, which can run past its last line into the next bullet's marker. */
29847
- const itemText = (content, item) => item.children.map((child) => sourceText(content, child)).join(" ").trim();
29848
- const prefixOf = (content, item) => PREFIX_RE.exec(itemText(content, item));
29847
+ const itemText$1 = (content, item) => item.children.map((child) => sourceText(content, child)).join(" ").trim();
29848
+ const prefixOf = (content, item) => PREFIX_RE.exec(itemText$1(content, item));
29849
29849
  /** A thread is a definition whose body OPENS with a list whose first item carries a prefix — anything else stays a one-shot footnote. */
29850
29850
  const isThreadBody = (content, children) => {
29851
29851
  const first = children[0];
@@ -29865,7 +29865,7 @@ const entryOf = (content, item, previous, report) => {
29865
29865
  return;
29866
29866
  }
29867
29867
  const author = match[1] === "H" ? "me" : "agent";
29868
- const text = itemText(content, item).slice(match[0].length).trim();
29868
+ const text = itemText$1(content, item).slice(match[0].length).trim();
29869
29869
  if (text === "") report(`empty "${match[1]}:" entry — write your own words there`, start, end);
29870
29870
  if (previous === void 0 && author === "agent") report(`the agent never starts a thread — the first entry must be "H:"`, start, end);
29871
29871
  else if (previous === author) report(`two consecutive "${match[1]}:" entries — entries must alternate`, start, end);
@@ -31565,8 +31565,6 @@ const qaDescriptor = {
31565
31565
  unansweredQuestions,
31566
31566
  openThreads: openThreadsOf
31567
31567
  };
31568
- //#endregion
31569
- //#region src/steering/review.ts
31570
31568
  /**
31571
31569
  * The canonical sample. Every detail here is load-bearing for the formatter
31572
31570
  * round-trip: both footnote bodies exceed 80 characters so a reflow actually
@@ -31833,16 +31831,42 @@ const parseHunk = (content, lines, title, item) => {
31833
31831
  errors: [hunkSecondPointerFinding(content, lines, item, token, sourceLine, title, file), hunkLineWithoutRangeFinding(content, item, token, sourceLine, title, file)].filter((e) => e !== void 0)
31834
31832
  };
31835
31833
  };
31834
+ const itemText = (content, item) => item.children.map((c) => sourceText(content, c)).join(" ").replace(/^\[[ xX]\]\s*/, "").trim();
31835
+ /**
31836
+ * The task list right after the `Tick exactly one:` paragraph, with that
31837
+ * paragraph's index. Read by position, never by label, so an edited label or
31838
+ * a free text opening with `./path` is still an answer, not a pointer. Any
31839
+ * count but three boxes is kept, and reads as unanswered.
31840
+ */
31841
+ const answerListOf = (content, body) => {
31842
+ 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));
31843
+ if (lead === -1) return void 0;
31844
+ const list = body[lead + 1];
31845
+ const last = list.children.length - 1;
31846
+ return {
31847
+ lead,
31848
+ list,
31849
+ options: list.children.map((item, i) => {
31850
+ const text = itemText(content, item);
31851
+ return {
31852
+ checked: item.checked === true,
31853
+ text: i === last && text.toLowerCase() === "_your answer_".toLowerCase() ? "" : text
31854
+ };
31855
+ })
31856
+ };
31857
+ };
31836
31858
  /** 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). */
31837
31859
  const parseChunkBody = (content, lines, title, body) => {
31838
- const firstPointerIndex = body.findIndex((n) => taskItems(n).some((item) => hasPointerToken(content, item)));
31839
- const leadingRun = firstPointerIndex === -1 ? body : body.slice(0, firstPointerIndex);
31860
+ const answer = answerListOf(content, body);
31861
+ const isAnswer = (n) => answer !== void 0 && (n === answer.list || n === body[answer.lead]);
31862
+ const firstPointerIndex = body.findIndex((n) => !isAnswer(n) && taskItems(n).some((item) => hasPointerToken(content, item)));
31863
+ const leadingRun = (firstPointerIndex === -1 ? body : body.slice(0, firstPointerIndex)).filter((n) => !isAnswer(n));
31840
31864
  const descriptionNodes = blockNodesOfRun(content, leadingRun);
31841
31865
  const descriptionInline = collapseImages(blockRunInline(content, leadingRun));
31842
31866
  const description = leadingRun.filter((n) => n.type !== "footnoteDefinition").map((n) => sourceText(content, n)).join(" ").trim();
31843
31867
  const files = [];
31844
31868
  const errors = [];
31845
- for (const item of body.flatMap((n) => taskItems(n))) {
31869
+ for (const item of body.filter((n) => !isAnswer(n)).flatMap((n) => taskItems(n))) {
31846
31870
  const parsed = parseHunk(content, lines, title, item);
31847
31871
  if (!parsed) continue;
31848
31872
  files.push(parsed.file);
@@ -31853,7 +31877,9 @@ const parseChunkBody = (content, lines, title, body) => {
31853
31877
  descriptionNodes,
31854
31878
  descriptionInline,
31855
31879
  files,
31856
- errors
31880
+ errors,
31881
+ ...answer === void 0 ? {} : { answers: answer.options },
31882
+ boxed: body.some((n) => !isAnswer(n) && taskItems(n).length > 0)
31857
31883
  };
31858
31884
  };
31859
31885
  /** 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. */
@@ -31889,9 +31915,9 @@ const parseChangesets = (tree, content, lines) => {
31889
31915
  const errors = [];
31890
31916
  for (const { heading, headingLine, body } of splitChunks(tree)) {
31891
31917
  const title = headingText$1(content, heading);
31892
- const { description, descriptionNodes, descriptionInline, files, errors: bodyErrors } = parseChunkBody(content, lines, title, body);
31918
+ const { description, descriptionNodes, descriptionInline, files, answers, boxed, errors: bodyErrors } = parseChunkBody(content, lines, title, body);
31893
31919
  errors.push(...bodyErrors);
31894
- if (files.length === 0 && body.some((n) => taskItems(n).length > 0)) errors.push({
31920
+ if (files.length === 0 && boxed) errors.push({
31895
31921
  message: `Chunk "${title}" has no file pointers`,
31896
31922
  line: headingLine,
31897
31923
  range: nodeRange(heading)
@@ -31902,7 +31928,8 @@ const parseChangesets = (tree, content, lines) => {
31902
31928
  descriptionNodes,
31903
31929
  descriptionInline,
31904
31930
  files,
31905
- headingLine
31931
+ headingLine,
31932
+ ...answers === void 0 ? {} : { answers }
31906
31933
  });
31907
31934
  }
31908
31935
  if (changesets.length === 0) errors.push({ message: "REVIEW.md has no '##' chunks" });
@@ -31983,7 +32010,9 @@ const isHunkPointerItem = (content, item) => {
31983
32010
  * clear, so a caller can skip the write and leave the mtime untouched.
31984
32011
  */
31985
32012
  const reviewClearTicks = (content) => {
31986
- 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);
32013
+ const tree = parseMarkdown(content);
32014
+ const answers = new Set(splitChunks(tree).flatMap(({ body }) => answerListOf(content, body)?.list.children ?? []));
32015
+ 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);
31987
32016
  if (offsets.length === 0) return content;
31988
32017
  let result = "";
31989
32018
  let cursor = 0;
@@ -1,5 +1,5 @@
1
- import { changes, changesSince, hasThreadFor, head, read, refuse, requireReplies, requireThreadsClosed, scope } from "./flows.mjs";
2
- import { c as stripCodeThreads, d as reviewNotes, f as reviewRisks, y as parseThreads } from "./chunks/steering-oCDdQlpP.mjs";
1
+ import { changes, changesSince, hasThreadFor, head, mark, read, refuse, requireReplies, requireThreadsClosed, scope } from "./flows.mjs";
2
+ import { _ as unansweredNotes, c as stripCodeThreads, g as reviewRisks, h as reviewNotes, m as reviewDecisions, w as parseThreads } from "./chunks/steering-D4LUQ-av.mjs";
3
3
  //#region src/workflow-kit/health.ts
4
4
  /** Hand a red suite that repeated attempts could not fix to a person, until `limit` rounds are spent. */
5
5
  const escalation = async (count, steps) => {
@@ -172,6 +172,13 @@ const collect = async (p, capture, round, requested) => {
172
172
  }
173
173
  return refuse(p.refusals.unmatched);
174
174
  };
175
+ /** Refuse while a note's answer boxes hold anything but exactly one tick, or a note's boxes were deleted. */
176
+ const requireDecisions = (file, reviewed) => {
177
+ const open = unansweredNotes(read(file) ?? "", changesSince(reviewed).get(file)?.before);
178
+ if (open.length === 0) return;
179
+ const list = open.map((n) => ` - ${file}${n.line > 0 ? `:${n.line}` : ""}: ${n.title} — ${n.reason}`).join("\n");
180
+ 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}`);
181
+ };
175
182
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
176
183
  /**
177
184
  * A review with ticks and THREAD footnotes (definition plus markers) removed:
@@ -191,7 +198,7 @@ const notesOf = (text) => {
191
198
  * commit of the last collecting turn, `"missing"` when the round left no
192
199
  * review, or `undefined` when no such turn ran.
193
200
  */
194
- const converse = async (p, base, collectedBefore) => {
201
+ const converse = async (p, base, reviewed, collectedBefore) => {
195
202
  let collectedAt = collectedBefore;
196
203
  for (;;) {
197
204
  await p.awaitReview(base);
@@ -201,24 +208,47 @@ const converse = async (p, base, collectedBefore) => {
201
208
  return "missing";
202
209
  }
203
210
  requireThreadsClosed(p.file);
204
- if (!hasThreadFor("agent", p.file)) return collectedAt;
211
+ if (!hasThreadFor("agent", p.file)) {
212
+ requireDecisions(p.file, reviewed);
213
+ return collectedAt;
214
+ }
205
215
  await p.collecting(p.captures.edits(head()), "reply");
206
216
  collectedAt = head();
207
217
  requireReplies(p.file);
208
- if (!hasThreadFor("human", p.file)) return collectedAt;
218
+ if (!hasThreadFor("human", p.file) && unansweredNotes(read(p.file) ?? "", changesSince(reviewed).get(p.file)?.before).length === 0) return collectedAt;
209
219
  }
210
220
  };
211
- /** Judge every note, then run each verdict's route: answers, then nit fixes, then deferrals, then the edits' lap. */
212
- const routeNotes = async (p, notes, r) => {
213
- const verdicts = await p.triage(notes);
214
- const of = (v) => notes.filter((n) => (verdicts.get(n.id) ?? "edit") === v);
215
- const [edits, questions, nits, deferred] = [
216
- of("edit"),
217
- of("question"),
218
- of("nit"),
219
- of("defer")
220
- ];
221
- if (edits.length + questions.length + nits.length + deferred.length === 0) return r.unfolded();
221
+ const decisionNote = (d, id, text) => ({
222
+ id,
223
+ kind: "chunk",
224
+ anchor: d.anchor,
225
+ before: d.body,
226
+ text,
227
+ forced: /^!(\s|$)/.test(text)
228
+ });
229
+ /** One `Gtd-Mark` value per line: a control character would make mark() throw on every replay. */
230
+ const oneLine = (text) => text.replace(/[\s\x00-\x1f\x7f-\x9f\u2028\u2029]+/g, " ").trim() || "untitled";
231
+ /** Tell the policy, and mark each won't-fix on the gate's commit. */
232
+ const record = (p, decisions) => {
233
+ p.decided?.(decisions);
234
+ for (const d of decisions) if (d.choice === "wontfix") mark("wontfix", oneLine(d.anchor));
235
+ };
236
+ /**
237
+ * Judge every note, then run each verdict's route: answers, then nit fixes,
238
+ * then the edits' lap. Of the human's `decisions`, a fix joins the nit batch
239
+ * unjudged, an own answer is judged like a note, and a won't-fix is dropped
240
+ * with a `Gtd-Mark` on the gate's commit.
241
+ */
242
+ const routeNotes = async (p, notes, r, decisions = []) => {
243
+ record(p, decisions);
244
+ const of = (c) => decisions.filter((d) => d.choice === c);
245
+ const fixes = of("fix").map((d, i) => decisionNote(d, `fix-${i + 1}`, d.body));
246
+ const judged = [...notes, ...of("answer").map((d, i) => decisionNote(d, `answer-${i + 1}`, d.text))];
247
+ const verdicts = judged.length === 0 ? /* @__PURE__ */ new Map() : await p.triage(judged);
248
+ const ofVerdict = (v) => judged.filter((n) => (verdicts.get(n.id) ?? "edit") === v);
249
+ const [edits, questions] = [ofVerdict("edit"), ofVerdict("question")];
250
+ const nits = [...ofVerdict("nit"), ...fixes];
251
+ if (edits.length + questions.length + nits.length === 0) return r.unfolded();
222
252
  let answeredAt;
223
253
  if (questions.length > 0) {
224
254
  await p.answerQuestions(questions);
@@ -228,7 +258,6 @@ const routeNotes = async (p, notes, r) => {
228
258
  await p.fixNits(nits);
229
259
  await p.keepGreen();
230
260
  }
231
- if (deferred.length > 0) await p.defer(deferred);
232
261
  if (edits.length > 0) {
233
262
  await r.close();
234
263
  return r.outcome(await collect(p, p.captures.editNotes(r.round, edits, answeredAt), "triaged", {
@@ -250,8 +279,8 @@ const routeNotes = async (p, notes, r) => {
250
279
  };
251
280
  /** A code edit, not a thread-only change: lines that only add, answer or remove code threads don't count. */
252
281
  const isCodeEdit = (p, c) => p.isCode(c.path) && stripCodeThreads(c.path, c.before ?? "") !== stripCodeThreads(c.path, c.after ?? "");
253
- /** Whether anything but thread edits and ticks landed in the review file since `commit`. */
254
- const notedSince = (p, commit) => changesSince(commit).some((c) => c.path === p.file && notesOf(c.before ?? "") !== notesOf(read(p.file) ?? ""));
282
+ /** Whether a note or an answer, not just thread edits and pointer ticks, landed in the review file since `commit`. */
283
+ 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));
255
284
  /**
256
285
  * What the in-loop collecting turn left: `folded` when it wrote `feedback`
257
286
  * (those folds need their lap whatever is judged later), `settled` when only
@@ -265,9 +294,9 @@ const settledAfter = (p, collectedAt) => ({
265
294
  folded: collectedAt !== void 0 && read(p.feedback) !== void 0,
266
295
  settled: collectedAt !== void 0 && changesSince(collectedAt).every((c) => !isCodeEdit(p, c)) && !notedSince(p, collectedAt)
267
296
  });
268
- const finishNotes = async (p, before, reopened, ctx) => {
269
- const notes = reviewNotes(before, read(p.file) ?? "");
270
- if (notes.length > 0) return routeNotes(p, notes, ctx);
297
+ const finishNotes = async (p, before, decisions, reopened, ctx) => {
298
+ const notes = reviewNotes(before ?? "", read(p.file) ?? "");
299
+ if (notes.length + decisions.length > 0) return routeNotes(p, notes, ctx, decisions);
271
300
  if (reopened) return ctx.unfolded();
272
301
  await ctx.close();
273
302
  return ctx.outcome(await collect(p, p.captures.notes(ctx.round), "notes"));
@@ -277,6 +306,11 @@ const toOutcome = (verdict, rest) => verdict === "signoff" ? { verdict } : {
277
306
  ...rest
278
307
  };
279
308
  const isReopened = (p, r) => r.settled && !r.folded && p.refusals.dropped !== void 0;
309
+ /** A carried answer is unchanged, never routed twice; one ticked beside a thread still routes once the thread has settled. */
310
+ const decisionsSince = (p, since) => {
311
+ const after = read(p.file) ?? "";
312
+ return reviewDecisions(since.get(p.file)?.before ?? after, after);
313
+ };
280
314
  const finish = async (p, reviewed, collectedAt) => {
281
315
  const round = head();
282
316
  const since = changesSince(reviewed);
@@ -297,15 +331,17 @@ const finish = async (p, reviewed, collectedAt) => {
297
331
  settled,
298
332
  folded
299
333
  });
300
- if (settled && !reopened || edited.length === 0 && !noted) return unfolded();
334
+ const decisions = decisionsSince(p, since);
335
+ if (settled && !reopened && decisions.length === 0 || edited.length === 0 && !noted) return unfolded();
301
336
  if (edited.length > 0) {
337
+ record(p, decisions);
302
338
  await close();
303
339
  return outcome(await collect(p, p.captures.edits(round, reopened ? reviewed : void 0), "edits", {
304
340
  notes: [],
305
341
  paths: edited.map((c) => c.path)
306
342
  }));
307
343
  }
308
- return finishNotes(p, since.get(p.file)?.before ?? "", reopened, {
344
+ return finishNotes(p, since.get(p.file)?.before, decisions, reopened, {
309
345
  round,
310
346
  close,
311
347
  outcome,
@@ -335,7 +371,7 @@ const review = async (base, p) => {
335
371
  let reviewed = head();
336
372
  let collectedAt;
337
373
  for (;;) {
338
- collectedAt = await converse(p, base, collectedAt);
374
+ collectedAt = await converse(p, base, reviewed, collectedAt);
339
375
  if (collectedAt === "missing") break;
340
376
  const result = await finish(p, reviewed, collectedAt);
341
377
  if (!("next" in result)) return result;
@@ -1,6 +1,6 @@
1
1
  import { agent, changes, glob, read, refuse, scope, start, vars } from "../flows.mjs";
2
- import { At as registryEntry, Ot as flagReferences, S as coder, Tt as envDefaults, i as skills, jt as requireFlag, n as summary, o as flagShipped, r as access, t as steering, wt as defaults } from "../chunks/reserved-D7MNY9QJ.mjs";
3
- import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-D2o2h5cc.mjs";
2
+ import { Ct as envDefaults, Et as flagReferences, Ot as registryEntry, S as coder, St as defaults, i as skills, kt as requireFlag, n as summary, o as flagShipped, r as access, t as steering } from "../chunks/reserved-COEMFEpy.mjs";
3
+ import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-C_xn8U7U.mjs";
4
4
  //#region src/workflows/cleanup/cleanup.ts
5
5
  const removingPrompt = (name, registry) => `Clean up the release flag "${name}", which is default-on: delete its entry in ${registry}, and remove every \`GTD_FLAG_${name.toUpperCase()}\` check from the shipped sources. Keep the flag-on path; delete code only the off path reached, including tests that pin the off path.`;
6
6
  const cleanupFlow = async () => {
@@ -1,7 +1,7 @@
1
1
  import { agent, answered, applyScript, changes, changesSince, check, codeChanges, env, hasThreadFor, head, human, judge, judgeDiffShape, kept, mark, matches, numeric, quote, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restoreScript, revertScript, run, scope, showScript, slice, start, vars, writeScript, wrote } from "../flows.mjs";
2
2
  import { lensEntriesOf, removedAssertions, review as review$1 } from "../workflow-kit.mjs";
3
- import { $ as implementFixPrompt, A as agentWithSkills, B as coverageMessage, C as fixCheck, Ct as unwindFailure, D as reviewMissing, E as planner, Et as testFileGlobs, F as buildReviewTriageMessage, G as designGateAnswerMessage, H as coveringPrompt, I as builderSystem, K as designSystem, L as clearCriteriaMessage, M as buildReviewAwaitAfterDeferMessage, N as buildReviewCollectingPrompt, P as buildReviewFixNitsPrompt, Q as implementBuildingPrompt, R as clearCriteriaQuestion, S as coder, St as unwindFailedMessage, T as fixRisks, Tt as envDefaults, U as criterionLine, V as coverageQuestion, W as deferNotesScript, Z as idleMessage, _ as REFACTOR_NOTE, _t as seamByHandMessage, a as flagModeOn, b as SLICES, bt as startGateBlockedMessage, ct as refactorDroppedRefusal, d as guarded, dt as refactorRules, et as naiveApplyFailure, f as healthy, ft as reviewEditNotesCapture, g as REFACTOR, gt as reviewerSystem, h as QUALITY, ht as reviewNotesCapture, i as skills, j as buildReviewAnswerQuestionsPrompt, lt as refactorFixPrompt, m as HANDOFF, n as summary, nt as normaliseCriteriaScript, ot as qualityUnresolvedMessage, p as FEEDBACK, pt as reviewEditsCapture, q as designTriagePrompt, r as access, rt as oversizedMessage, s as shipped, st as refactorBuildingPrompt, t as steering, tt as naivePassPrompt, u as gate, ut as refactorNotePrompt, v as REQUIREMENTS, vt as seamSteerMessage, wt as defaults, x as awaitReview, xt as uncoveredMessage, y as REVIEW, yt as slicingPrompt, z as composedReview } from "../chunks/reserved-D7MNY9QJ.mjs";
4
- import { a as refusePendingHandOff, i as isOptionalObject, n as closeReview, o as tail, s as NO_FINDINGS } from "../chunks/tail-D2o2h5cc.mjs";
3
+ import { $ as naivePassPrompt, A as agentWithSkills, B as coverageQuestion, C as fixCheck, Ct as envDefaults, D as reviewMissing, E as planner, F as builderSystem, G as designTriagePrompt, H as criterionLine, I as clearCriteriaMessage, L as clearCriteriaQuestion, M as buildReviewCollectingPrompt, N as buildReviewFixNitsPrompt, P as buildReviewTriageMessage, Q as naiveApplyFailure, R as composedReview, S as coder, St as defaults, T as fixRisks, U as designGateAnswerMessage, V as coveringPrompt, W as designSystem, X as implementBuildingPrompt, Y as idleMessage, Z as implementFixPrompt, _ as REFACTOR_NOTE, _t as slicingPrompt, a as flagModeOn, at as refactorBuildingPrompt, b as SLICES, bt as unwindFailedMessage, ct as refactorNotePrompt, d as guarded, dt as reviewEditsCapture, et as normaliseCriteriaScript, f as healthy, g as REFACTOR, gt as seamSteerMessage, h as QUALITY, ht as seamByHandMessage, i as skills, it as qualityUnresolvedMessage, j as buildReviewAnswerQuestionsPrompt, lt as refactorRules, m as HANDOFF, mt as reviewerSystem, n as summary, ot as refactorDroppedRefusal, p as FEEDBACK, pt as reviewNotesCapture, r as access, s as shipped, st as refactorFixPrompt, t as steering, tt as oversizedMessage, u as gate, ut as reviewEditNotesCapture, v as REQUIREMENTS, vt as startGateBlockedMessage, wt as testFileGlobs, x as awaitReview, xt as unwindFailure, y as REVIEW, yt as uncoveredMessage, z as coverageMessage } from "../chunks/reserved-COEMFEpy.mjs";
4
+ import { a as refusePendingHandOff, i as isOptionalObject, n as closeReview, o as tail, s as NO_FINDINGS } from "../chunks/tail-C_xn8U7U.mjs";
5
5
  import { posix } from "node:path";
6
6
  //#region src/workflows/shared/fences.ts
7
7
  const markFenced = (text) => {
@@ -241,7 +241,7 @@ const fixNits = (notes, access, rules) => {
241
241
  }, () => fixing("fixing"));
242
242
  };
243
243
  /** Turn the review round `capture` describes into `.gtd/REQUIREMENTS.md`, or change nothing. */
244
- const collecting = (capture) => agentWithSkills("review.collecting", buildReviewCollectingPrompt(capture), {
244
+ const collecting = (capture, round) => agentWithSkills("review.collecting", buildReviewCollectingPrompt(capture, void 0, round), {
245
245
  label: "Collecting your feedback",
246
246
  file: REQUIREMENTS,
247
247
  mode: "qa",
@@ -260,11 +260,6 @@ const refactorCollecting = (capture, round, strays) => agentWithSkills("review.c
260
260
  system: reviewerSystem(),
261
261
  allowEmpty: true
262
262
  });
263
- /** `build.review.defer`: append `notes` to `.gtd/TODO.md` as one section; the step's landing is the commit. */
264
- const deferNotes = (notes, round) => run("review.defer", deferNotesScript(notes, round), {
265
- label: "Deferring could-notes to TODO.md",
266
- file: ".gtd/TODO.md"
267
- });
268
263
  //#endregion
269
264
  //#region src/workflows/feature/planning.ts
270
265
  /**
@@ -305,6 +300,13 @@ const verdictQuestion = (note) => ({
305
300
  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.`,
306
301
  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."
307
302
  });
303
+ const TIER_ORDER = {
304
+ critical: 0,
305
+ should: 1,
306
+ could: 2
307
+ };
308
+ /** Capture order: critical, should, could; within a tier one-way doors, then new concepts. */
309
+ const captureKey = (s) => s === void 0 ? 2 : TIER_ORDER[s.tier] * 3 + s.rank;
308
310
  const SEVERITY_ANSWERS = {
309
311
  security: ["yes", "no"],
310
312
  reversibility: ["one-way", "two-way"],
@@ -315,10 +317,7 @@ const SEVERITY_ANSWERS = {
315
317
  ],
316
318
  newConcept: ["yes", "no"]
317
319
  };
318
- /**
319
- * Deferring is the risky direction: anything unanswered, unrecognised, cut or
320
- * below `minP` is `critical`, never `could`.
321
- */
320
+ /** Anything unanswered, unrecognised, cut or below `minP` is `critical`, never `could`. */
322
321
  const severityOf = (note, judgment, minP) => {
323
322
  const ans = (q) => judgment.answers[`${note.id}.${q}`];
324
323
  const a = (q) => ans(q)?.answer;
@@ -382,7 +381,7 @@ const severityQuestions = (note) => [
382
381
  * nothing) plus four severity answers. Dismissing a note is the risky
383
382
  * direction, so only a confident non-`edit` verdict on uncut evidence counts;
384
383
  * a cut, unanswered or below-floor note is an `edit`. Severity answers on a
385
- * non-`edit` note are ignored. A confident `could` edit becomes `defer`.
384
+ * non-`edit` note are ignored; on an `edit` they order the collecting turn.
386
385
  */
387
386
  const triage$1 = (severities) => async (notes) => {
388
387
  const asked = notes.filter((n) => !n.forced);
@@ -402,21 +401,22 @@ const triage$1 = (severities) => async (notes) => {
402
401
  return new Map(notes.map((n) => {
403
402
  const verdict = n.forced ? void 0 : VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP));
404
403
  if (verdict !== void 0 && !truncated.includes(n.id)) return [n.id, verdict];
405
- const severity = severityOf(n, judgment, severityMinP);
406
- severities.set(n.id, severity);
407
- return [n.id, severity.tier === "could" ? "defer" : "edit"];
404
+ severities.set(n.id, severityOf(n, judgment, severityMinP));
405
+ return [n.id, "edit"];
408
406
  }));
409
407
  };
410
408
  const isCode$1 = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
411
409
  /**
412
410
  * gtd writes `.gtd/REVIEW.md` from the quality lap's `report` — no reviewer
413
411
  * turn — and removes what the fixer left in `.gtd/QUALITY.md`. A re-review
414
- * after fixed nits restores the doc as committed at `carry`, answers kept.
412
+ * after fixed nits restores the doc as committed at `carry`, answers kept;
413
+ * a fresh one leaves out the notes the human already answered.
415
414
  */
416
- const composing = (base, report, carry, nitFix) => run("review.composing", carry === void 0 ? writeScript(REVIEW, composedReview(base, {
415
+ const composing = (base, report, carry, nitFix, answered = /* @__PURE__ */ new Set()) => run("review.composing", carry === void 0 ? writeScript(REVIEW, composedReview(base, {
417
416
  ...report,
418
417
  weakened: removedAssertions(changesSince(base)),
419
- nitFix
418
+ nitFix,
419
+ answered
420
420
  }), [QUALITY]) : showScript(carry, REVIEW, REVIEW), {
421
421
  label: "Composing the review",
422
422
  file: REVIEW,
@@ -424,9 +424,10 @@ const composing = (base, report, carry, nitFix) => run("review.composing", carry
424
424
  });
425
425
  /**
426
426
  * The quality lap's notes reach a human over everything since `base`, who
427
- * signs off or comments, and each note is judged: edits go to another
428
- * planning lap, questions are answered in place, nits fixed in one batch,
429
- * praise dropped.
427
+ * answers each one — fix as suggested, won't fix, or their own answer — and
428
+ * may comment. A fix joins the nit batch, a won't-fix is dropped, and an own
429
+ * answer or comment is judged: edits go to another planning lap, questions
430
+ * are answered in place, nits fixed in one batch, praise dropped.
430
431
  */
431
432
  const review = (base, escalations = { rounds: 0 }, options = {}) => {
432
433
  const { guard, guardRests, fixAccess, rules } = options;
@@ -436,17 +437,16 @@ const review = (base, escalations = { rounds: 0 }, options = {}) => {
436
437
  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"
437
438
  };
438
439
  const severities = /* @__PURE__ */ new Map();
439
- let deferred = [];
440
440
  let nitFix;
441
+ const answered = /* @__PURE__ */ new Set();
441
442
  return review$1(base, {
442
443
  file: REVIEW,
443
444
  feedback: feedback.file,
444
445
  isCode: isCode$1,
445
446
  reviewing: (b, carry) => {
446
- deferred = [];
447
447
  const fixedAt = nitFix;
448
448
  nitFix = void 0;
449
- return composing(b, options.report ?? NO_FINDINGS, carry, fixedAt);
449
+ return composing(b, options.report ?? NO_FINDINGS, carry, fixedAt, answered);
450
450
  },
451
451
  fixRisks: guarded((notes) => fixRisks(notes, fixAccess, rules), guard),
452
452
  keepGreen: () => healthy(() => fixCheck(rules), {
@@ -455,23 +455,11 @@ const review = (base, escalations = { rounds: 0 }, options = {}) => {
455
455
  guardRests,
456
456
  rules
457
457
  }),
458
- awaitReview: (b) => {
459
- const notes = deferred;
460
- deferred = [];
461
- return notes.length > 0 ? awaitReview(b, buildReviewAwaitAfterDeferMessage(b, notes)) : awaitReview(b);
462
- },
458
+ awaitReview: (b) => awaitReview(b),
463
459
  reviewMissing,
464
460
  collecting: feedback.collecting,
465
461
  triage: triage$1(severities),
466
- defer: (notes) => {
467
- deferred = notes.map((n) => ({
468
- id: n.id,
469
- anchor: n.anchor,
470
- text: n.text,
471
- label: severities.get(n.id).reason
472
- }));
473
- return deferNotes(deferred, head());
474
- },
462
+ decided: (decisions) => decisions.forEach((d) => answered.add(d.anchor)),
475
463
  answerQuestions: answerReviewQuestions,
476
464
  fixNits: async (notes) => {
477
465
  await guarded((n) => fixNits(n, fixAccess, rules), guard)(notes);
@@ -484,8 +472,9 @@ const review = (base, escalations = { rounds: 0 }, options = {}) => {
484
472
  editNotes: (round, edits, answeredAt) => reviewEditNotesCapture(round, edits.map((n, i) => ({
485
473
  n,
486
474
  i,
487
- s: severities.get(n.id)
488
- })).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 }) => ({
475
+ s: severities.get(n.id),
476
+ key: captureKey(severities.get(n.id))
477
+ })).sort((x, y) => x.key - y.key || x.i - y.i).map(({ n, s }) => ({
489
478
  id: n.id,
490
479
  anchor: n.anchor,
491
480
  text: n.forced ? n.text.replace(/^!\s*/, "") : n.text,
@@ -1525,4 +1514,4 @@ const feature = shipped(ordinaryStart);
1525
1514
  //#region src/workflows/feature/index.ts
1526
1515
  var feature_default = feature;
1527
1516
  //#endregion
1528
- export { COMMENT, REFACTOR_PACKAGE, acceptanceFault, access, afterTail, answerProductQuestions, answerReviewQuestions, buildFeature, buildTail, closeOut, collecting, criteriaOf, criterionIds, feature_default as default, defaults, deferNotes, design, envDefaults, feature, fixNits, holdsCallSites, implement, implementing, isTestFile, judgeSeam, naiveAcceptance, naivePass, ordinaryStart, planAndBuild, reUnwind, refactorCollecting, refactorFirst, refactorNote, refactorStrays, requireAcceptance, requireAcceptanceKept, requireCriteria, requireCriteriaFrozen, requireCriteriaIfEdited, requireNoNewFlags, requireNoTestFiles, requireSlices, requireStableIds, requireTestCallSitesOnly, requireWithin, reviewedRefactor, seamBuild, skills, skipDesign, sliceCap, sliceCriteria, sliceIds, slicesOf, slicing, statedCriteria, steering, summary, triage, unstableIds, unwind };
1517
+ export { COMMENT, REFACTOR_PACKAGE, acceptanceFault, access, afterTail, answerProductQuestions, answerReviewQuestions, buildFeature, buildTail, closeOut, collecting, criteriaOf, criterionIds, feature_default as default, defaults, design, envDefaults, feature, fixNits, holdsCallSites, implement, implementing, isTestFile, judgeSeam, naiveAcceptance, naivePass, ordinaryStart, planAndBuild, reUnwind, refactorCollecting, refactorFirst, refactorNote, refactorStrays, requireAcceptance, requireAcceptanceKept, requireCriteria, requireCriteriaFrozen, requireCriteriaIfEdited, requireNoNewFlags, requireNoTestFiles, requireSlices, requireStableIds, requireTestCallSitesOnly, requireWithin, reviewedRefactor, seamBuild, skills, skipDesign, sliceCap, sliceCriteria, sliceIds, slicesOf, slicing, statedCriteria, steering, summary, triage, unstableIds, unwind };
@@ -1,7 +1,7 @@
1
1
  import { read, refuse, removeScript, run, scope, start } from "../flows.mjs";
2
- import { d as reviewNotes, f as reviewRisks } from "../chunks/steering-oCDdQlpP.mjs";
3
- import { A as agentWithSkills, J as finisherSystem, S as coder, Tt as envDefaults, Y as fixReviewNotesPrompt, c as baseline, i as skills, n as summary, r as access, s as shipped, t as steering, wt as defaults, y as REVIEW } from "../chunks/reserved-D7MNY9QJ.mjs";
4
- import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-D2o2h5cc.mjs";
2
+ import { g as reviewRisks, h as reviewNotes } from "../chunks/steering-D4LUQ-av.mjs";
3
+ import { A as agentWithSkills, Ct as envDefaults, K as finisherSystem, S as coder, St as defaults, c as baseline, i as skills, n as summary, q as fixReviewNotesPrompt, r as access, s as shipped, t as steering, y as REVIEW } from "../chunks/reserved-COEMFEpy.mjs";
4
+ import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-C_xn8U7U.mjs";
5
5
  //#region src/workflows/fix/fix.ts
6
6
  /**
7
7
  * Fix every note a human left in a handed-off `.gtd/REVIEW.md` and every
@@ -1,6 +1,6 @@
1
1
  import { start } from "../flows.mjs";
2
- import { Tt as envDefaults, i as skills, mt as reviewGateBlockedMessage, n as summary, r as access, s as shipped, t as steering, u as gate, wt as defaults } from "../chunks/reserved-D7MNY9QJ.mjs";
3
- import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-D2o2h5cc.mjs";
2
+ import { Ct as envDefaults, St as defaults, ft as reviewGateBlockedMessage, i as skills, n as summary, r as access, s as shipped, t as steering, u as gate } from "../chunks/reserved-COEMFEpy.mjs";
3
+ import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-C_xn8U7U.mjs";
4
4
  //#region src/workflows/review/review.ts
5
5
  /**
6
6
  * Pure review of everything since `reviewBase`: a green gate, the quality
@@ -1,5 +1,5 @@
1
1
  import { agent, changesSince, glob, head, mark, read, refuse, removeScript, run, scope, vars } from "../flows.mjs";
2
- import { Dt as flagMentions, S as coder, Tt as envDefaults, i as skills, jt as requireFlag, kt as flipProblems, n as summary, o as flagShipped, p as FEEDBACK, r as access, t as steering, u as gate, wt as defaults } from "../chunks/reserved-D7MNY9QJ.mjs";
2
+ import { Ct as envDefaults, Dt as flipProblems, S as coder, St as defaults, Tt as flagMentions, i as skills, kt as requireFlag, n as summary, o as flagShipped, p as FEEDBACK, r as access, t as steering, u as gate } from "../chunks/reserved-COEMFEpy.mjs";
3
3
  //#region src/workflows/rollout/rollout.ts
4
4
  const flippingPrompt = (name, registry) => `Roll out the release flag "${name}": in ${registry}, set \`defaultOn: true\` on its entry. Change nothing else — no other entry, no other field, no other file, no comments.`;
5
5
  const suiteBlockedMessage = () => "The suite is red after the rollout. Fix it by hand — a rollout may only touch the flag's registry entry — then land.";