@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
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 { At as registryEntry, C as coder, Ot as flagReferences, 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-DJk_6-ju.mjs";
3
+ import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-5w5mxGTq.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,8 @@
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
- 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";
1
+ import { agent, answered, applyScript, changes, changesSince, check, codeChanges, env, hasThreadFor, head, human, judge, judgeDiffShape, kept, mark, matches, quote, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireThreadsClosed, revertScript, run, scope, slice, start, vars, wrote } from "../flows.mjs";
2
+ import { lensEntriesOf } from "../workflow-kit.mjs";
3
+ import { $ as naiveApplyFailure, B as coverageMessage, C as coder, Ct as unwindFailure, E as fixNits, Et as testFileGlobs, G as designSystem, H as coveringPrompt, I as builderSystem, K as designTriagePrompt, L as clearCriteriaMessage, N as agentWithSkills, P as buildReviewCollectingPrompt, Q as implementFixPrompt, R as clearCriteriaQuestion, St as unwindFailedMessage, T as fixCheck, Tt as envDefaults, U as criterionLine, V as coverageQuestion, W as designGateAnswerMessage, X as idleMessage, Z as implementBuildingPrompt, _ as REFACTOR_NOTE, _t as seamByHandMessage, a as flagModeOn, at as qualityUnresolvedMessage, b as SLICES, bt as startGateBlockedMessage, ct as refactorFixPrompt, et as naivePassPrompt, f as healthy, g as REFACTOR, gt as reviewerSystem, h as QUALITY, i as skills, k as planner, lt as refactorNotePrompt, m as HANDOFF, n as summary, nt as oversizedMessage, ot as refactorBuildingPrompt, p as FEEDBACK, r as access, s as shipped, st as refactorDroppedRefusal, t as steering, tt as normaliseCriteriaScript, u as gate, ut as refactorRules, v as REQUIREMENTS, vt as seamSteerMessage, w as collecting, wt as defaults, x as answerReviewQuestions, xt as uncoveredMessage, yt as slicingPrompt } from "../chunks/reserved-DJk_6-ju.mjs";
4
+ import { a as refusePendingHandOff, o as tail } from "../chunks/tail-5w5mxGTq.mjs";
5
+ import { i as reUnwind, n as humanReview, r as isCode$1, t as buildTail } from "../chunks/review-X7Jfz0dY.mjs";
5
6
  import { posix } from "node:path";
6
7
  //#region src/workflows/shared/fences.ts
7
8
  const markFenced = (text) => {
@@ -219,36 +220,6 @@ const answerProductQuestions = () => human("gate.answer", {
219
220
  mode: "qa",
220
221
  acceptClean: true
221
222
  });
222
- /** Answer every `question` note inline in `.gtd/REVIEW.md`. */
223
- const answerReviewQuestions = (notes) => agentWithSkills("review.answer-review-questions", buildReviewAnswerQuestionsPrompt(notes), {
224
- label: "Answering your questions",
225
- file: REVIEW,
226
- mode: "review",
227
- model: planner(),
228
- system: reviewerSystem()
229
- });
230
- /** Fix every `nit` note in one turn. */
231
- const fixNits = (notes, access, rules) => {
232
- const fixing = (name) => agentWithSkills(name, buildReviewFixNitsPrompt(notes, rules), {
233
- label: "Fixing your nits",
234
- file: REVIEW,
235
- model: planner(),
236
- system: reviewerSystem()
237
- });
238
- return access === void 0 ? fixing("review.fix.nits.fixing") : scope({
239
- name: "review.fix.nits",
240
- access
241
- }, () => fixing("fixing"));
242
- };
243
- /** Turn the review round `capture` describes into `.gtd/REQUIREMENTS.md`, or change nothing. */
244
- const collecting = (capture) => agentWithSkills("review.collecting", buildReviewCollectingPrompt(capture), {
245
- label: "Collecting your feedback",
246
- file: REQUIREMENTS,
247
- mode: "qa",
248
- model: planner(),
249
- system: reviewerSystem(),
250
- allowEmpty: true
251
- });
252
223
  /** Turn the refactor review round `capture` describes into `.gtd/REFACTOR.md`, or change nothing. */
253
224
  const refactorCollecting = (capture, round, strays) => agentWithSkills("review.collecting", buildReviewCollectingPrompt(capture, {
254
225
  strays,
@@ -260,11 +231,6 @@ const refactorCollecting = (capture, round, strays) => agentWithSkills("review.c
260
231
  system: reviewerSystem(),
261
232
  allowEmpty: true
262
233
  });
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
234
  //#endregion
269
235
  //#region src/workflows/feature/planning.ts
270
236
  /**
@@ -291,255 +257,6 @@ const design = (base, guard) => scope("design", async () => {
291
257
  } while (await noteGate(REQUIREMENTS, answerProductQuestions));
292
258
  });
293
259
  //#endregion
294
- //#region src/workflows/feature/review.ts
295
- const VERDICTS = [
296
- "edit",
297
- "question",
298
- "nit",
299
- "praise"
300
- ];
301
- /** One four-way choice per note the human added to `.gtd/REVIEW.md`. */
302
- const verdictQuestion = (note) => ({
303
- id: note.id,
304
- primitive: "choice",
305
- 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
- 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
- });
308
- const SEVERITY_ANSWERS = {
309
- security: ["yes", "no"],
310
- reversibility: ["one-way", "two-way"],
311
- blastRadius: [
312
- "local",
313
- "module",
314
- "contract"
315
- ],
316
- newConcept: ["yes", "no"]
317
- };
318
- /**
319
- * Deferring is the risky direction: anything unanswered, unrecognised, cut or
320
- * below `minP` is `critical`, never `could`.
321
- */
322
- const severityOf = (note, judgment, minP) => {
323
- const ans = (q) => judgment.answers[`${note.id}.${q}`];
324
- const a = (q) => ans(q)?.answer;
325
- const rank = () => a("reversibility") === "one-way" ? 0 : a("newConcept") === "yes" ? 1 : 2;
326
- if (note.forced) return {
327
- tier: "critical",
328
- reason: "critical: forced by `!`",
329
- rank: 2
330
- };
331
- if (judgment.truncated.includes(note.id) || Object.entries(SEVERITY_ANSWERS).some(([q, allowed]) => !allowed.includes(a(q) ?? "") || !((ans(q)?.p ?? NaN) >= minP))) return {
332
- tier: "critical",
333
- reason: "critical: judge unsure",
334
- rank: 2
335
- };
336
- const critical = [...a("security") === "yes" ? ["security"] : [], ...a("reversibility") === "one-way" ? ["one-way door"] : []];
337
- if (critical.length > 0) return {
338
- tier: "critical",
339
- reason: `critical: ${critical.join(", ")}`,
340
- rank: rank()
341
- };
342
- const should = [...a("blastRadius") === "contract" ? ["contract"] : [], ...a("newConcept") === "yes" ? ["new concept"] : []];
343
- if (should.length > 0) return {
344
- tier: "should",
345
- reason: `should: ${should.join(", ")}`,
346
- rank: rank()
347
- };
348
- return {
349
- tier: "could",
350
- reason: "could",
351
- rank: 2
352
- };
353
- };
354
- const severityQuestions = (note) => [
355
- {
356
- id: `${note.id}.security`,
357
- primitive: "noul",
358
- 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?`,
359
- criteria: "yes: it changes or could weaken a security property. no: it does not."
360
- },
361
- {
362
- id: `${note.id}.reversibility`,
363
- primitive: "choice",
364
- instructions: `Once the change in note "${note.id}" ships, can it be undone without breaking anyone who already depends on it?`,
365
- 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."
366
- },
367
- {
368
- id: `${note.id}.blastRadius`,
369
- primitive: "choice",
370
- instructions: `How far does the change in note "${note.id}" reach?`,
371
- criteria: "local: one function or file. module: a whole module or several files. contract: a public interface other code or users depend on."
372
- },
373
- {
374
- id: `${note.id}.newConcept`,
375
- primitive: "noul",
376
- 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.`,
377
- criteria: "yes: a genuinely new concept. no: reuses or extends an existing one."
378
- }
379
- ];
380
- /**
381
- * One judge rest: a verdict per unforced note (a forced note is asked
382
- * nothing) plus four severity answers. Dismissing a note is the risky
383
- * direction, so only a confident non-`edit` verdict on uncut evidence counts;
384
- * 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`.
386
- */
387
- const triage$1 = (severities) => async (notes) => {
388
- const asked = notes.filter((n) => !n.forced);
389
- const judgment = asked.length === 0 ? {
390
- answers: {},
391
- truncated: []
392
- } : await judge("review.triage", {
393
- questions: asked.flatMap((n) => [verdictQuestion(n), ...severityQuestions(n)]),
394
- evidence: Object.fromEntries(asked.map((n) => [n.id, `Anchor: ${n.anchor}\nReviewer's text: ${n.before}\nHuman's text: ${n.text}`])),
395
- message: buildReviewTriageMessage(),
396
- label: "Judging each note"
397
- });
398
- const { answers, truncated } = judgment;
399
- const minP = numeric(vars.reviewNoteActionable, Infinity);
400
- const severityMinP = numeric(vars.reviewSeverityMinP, Infinity);
401
- severities.clear();
402
- return new Map(notes.map((n) => {
403
- const verdict = n.forced ? void 0 : VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP));
404
- 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"];
408
- }));
409
- };
410
- const isCode$1 = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
411
- /**
412
- * gtd writes `.gtd/REVIEW.md` from the quality lap's `report` — no reviewer
413
- * 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.
415
- */
416
- const composing = (base, report, carry, nitFix) => run("review.composing", carry === void 0 ? writeScript(REVIEW, composedReview(base, {
417
- ...report,
418
- weakened: removedAssertions(changesSince(base)),
419
- nitFix
420
- }), [QUALITY]) : showScript(carry, REVIEW, REVIEW), {
421
- label: "Composing the review",
422
- file: REVIEW,
423
- base
424
- });
425
- /**
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.
430
- */
431
- const review = (base, escalations = { rounds: 0 }, options = {}) => {
432
- const { guard, guardRests, fixAccess, rules } = options;
433
- const feedback = options.feedback ?? {
434
- file: ".gtd/REQUIREMENTS.md",
435
- collecting,
436
- 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
- const severities = /* @__PURE__ */ new Map();
439
- let deferred = [];
440
- let nitFix;
441
- return review$1(base, {
442
- file: REVIEW,
443
- feedback: feedback.file,
444
- isCode: isCode$1,
445
- reviewing: (b, carry) => {
446
- deferred = [];
447
- const fixedAt = nitFix;
448
- nitFix = void 0;
449
- return composing(b, options.report ?? NO_FINDINGS, carry, fixedAt);
450
- },
451
- fixRisks: guarded((notes) => fixRisks(notes, fixAccess, rules), guard),
452
- keepGreen: () => healthy(() => fixCheck(rules), {
453
- escalations,
454
- guard,
455
- guardRests,
456
- rules
457
- }),
458
- awaitReview: (b) => {
459
- const notes = deferred;
460
- deferred = [];
461
- return notes.length > 0 ? awaitReview(b, buildReviewAwaitAfterDeferMessage(b, notes)) : awaitReview(b);
462
- },
463
- reviewMissing,
464
- collecting: feedback.collecting,
465
- 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
- },
475
- answerQuestions: answerReviewQuestions,
476
- fixNits: async (notes) => {
477
- await guarded((n) => fixNits(n, fixAccess, rules), guard)(notes);
478
- nitFix = head();
479
- },
480
- close: closeReview,
481
- captures: {
482
- edits: reviewEditsCapture,
483
- notes: reviewNotesCapture,
484
- editNotes: (round, edits, answeredAt) => reviewEditNotesCapture(round, edits.map((n, i) => ({
485
- n,
486
- 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 }) => ({
489
- id: n.id,
490
- anchor: n.anchor,
491
- text: n.forced ? n.text.replace(/^!\s*/, "") : n.text,
492
- ...s === void 0 ? {} : { label: s.reason }
493
- })), answeredAt)
494
- },
495
- refusals: {
496
- deleted: "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note (or edit code) to request changes.",
497
- unmatched: feedback.unmatched,
498
- ...feedback.dropped === void 0 ? {} : { dropped: feedback.dropped }
499
- }
500
- });
501
- };
502
- function buildTail(first, second, ...rest) {
503
- const options = callForm(first, second, rest);
504
- 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"));
505
- const [base, form] = options;
506
- return tail((escalations, report) => review(base, escalations, {
507
- ...form,
508
- report
509
- }), {
510
- ...form,
511
- triage: true
512
- });
513
- }
514
- const isOptionalBoolean = (value) => value == null || typeof value === "boolean";
515
- const legacyForm = (fixFirst, base, green, options) => isOptionalBoolean(fixFirst) && isOptionalBoolean(green) && isOptionalObject(options) ? [base, {
516
- ...options,
517
- fixFirst: fixFirst ?? void 0,
518
- green: green ?? void 0
519
- }] : void 0;
520
- const currentForm = (base, options, third, fourth) => isOptionalObject(options) && third == null && fourth == null ? [base, options ?? {}] : void 0;
521
- const callForm = (first, second, [third, fourth, ...extra]) => {
522
- if (extra.some((value) => value != null)) return void 0;
523
- if (typeof second === "string") return legacyForm(first, second, third, fourth);
524
- if (typeof first === "string") return currentForm(first, second, third, fourth);
525
- };
526
- /**
527
- * Undo the human's review-round code edits. A path changed since the human
528
- * left it is not overwritten; requireRevert names it instead.
529
- */
530
- const reUnwind = async (feedback) => {
531
- const { base, restoreFrom, edited } = feedback;
532
- await run("re-unwind", restoreScript(base, {
533
- restore: edited.filter((c) => c.status !== "added").map((c) => c.path),
534
- remove: edited.filter((c) => c.status === "added").map((c) => c.path)
535
- }, restoreFrom), {
536
- label: "Re-unwinding your review edit",
537
- file: REVIEW,
538
- base
539
- });
540
- requireRevert(edited, base);
541
- };
542
- //#endregion
543
260
  //#region src/workflows/shared/continuation.ts
544
261
  const phase = (name, steered) => ({
545
262
  name,
@@ -1456,7 +1173,7 @@ const settleQuality = async () => {
1456
1173
  if (rested) await healthy(() => fixCheck());
1457
1174
  };
1458
1175
  /** The triaged quality lap, then the human review since `base`. */
1459
- const reviewedTail = (base, lensBase) => tail((escalations, report) => review(base, escalations, { report }), {
1176
+ const reviewedTail = (base, lensBase) => tail((escalations, report) => humanReview(base, escalations, { report }), {
1460
1177
  green: true,
1461
1178
  lensBase,
1462
1179
  triage: true
@@ -1525,4 +1242,4 @@ const feature = shipped(ordinaryStart);
1525
1242
  //#region src/workflows/feature/index.ts
1526
1243
  var feature_default = feature;
1527
1244
  //#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 };
1245
+ 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 { C as coder, J as fixReviewNotesPrompt, N as agentWithSkills, Tt as envDefaults, c as baseline, i as skills, n as summary, q as finisherSystem, r as access, s as shipped, t as steering, wt as defaults, y as REVIEW } from "../chunks/reserved-DJk_6-ju.mjs";
4
+ import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-5w5mxGTq.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