@pmelab/gtd 49.0.0 → 50.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 (32) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +31 -26
  3. package/dist/chunks/{reserved-CYZ1bSBy.mjs → reserved-CdOzKFVX.mjs} +59 -2
  4. package/dist/chunks/{review-BewyJflS.mjs → review-BChXEl2Y.mjs} +84 -20
  5. package/dist/chunks/{steering-BXwjmocB.mjs → steering-DtUVUz9l.mjs} +44 -14
  6. package/dist/chunks/{tail-C1Oi2YEy.mjs → tail-DRM2eC1z.mjs} +1 -1
  7. package/dist/gtd.bundle.mjs +22 -5
  8. package/dist/steering.mjs +2 -2
  9. package/dist/testkit.mjs +1 -1
  10. package/dist/web/main.js +27 -3
  11. package/dist/workflow-kit.mjs +1 -1
  12. package/dist/workflows/cleanup.mjs +2 -2
  13. package/dist/workflows/feature.mjs +3 -3
  14. package/dist/workflows/fix.mjs +3 -3
  15. package/dist/workflows/review.mjs +3 -3
  16. package/dist/workflows/rollout.mjs +1 -1
  17. package/dist/workflows.mjs +181 -30
  18. package/package.json +1 -1
  19. package/src/steering/index.ts +1 -0
  20. package/src/steering/review.test.ts +60 -2
  21. package/src/steering/review.ts +66 -9
  22. package/src/workflows/feature/lens-review.feature +16 -4
  23. package/src/workflows/feature/review-answers.feature +8 -1
  24. package/src/workflows/feature/review-gate.feature +20 -3
  25. package/src/workflows/feature/reviewer-walkthrough.feature +14 -3
  26. package/src/workflows/feature/severity-budget.feature +111 -0
  27. package/src/workflows/feature/severity-sorted-review.feature +197 -0
  28. package/src/workflows/feature/slice-findings.feature +14 -2
  29. package/src/workflows/feature/standards.feature +4 -0
  30. package/src/workflows/shared/review.ts +124 -18
  31. package/src/workflows/shared/text.test.ts +90 -0
  32. package/src/workflows/shared/text.ts +77 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "49.0.0",
3
+ "version": "50.0.0",
4
4
  "description": "Drive the gtd loop inside Claude Code: agent turns run as subagents, gtd decides every beat",
5
5
  "author": {
6
6
  "name": "Philipp Melab"
package/README.md CHANGED
@@ -538,32 +538,37 @@ noted in steps 3 and 4 below.
538
538
  commit) and what was **dropped as noise**. A review with a chunk pointing at
539
539
  no code, or a note left out, is refused (`review-pointers`). A pointer note
540
540
  the reviewer opens with `Risk:` is fixed before you see the review
541
- (`build.review.fix.risks.fixing`), then the review is written again. On
542
- `refactor-first` the refactor slice gets a review of its own first, composed
543
- by gtd from its lap's notes, before the feature is built; see
544
- [the naive pass](#the-naive-pass). **Every note needs exactly one tick before
545
- `gtd land` accepts the review** (`answer-completeness`; an open `- H:` thread
546
- waiting on the agent is the one exception). Fix as suggested joins the nit
547
- batch below, unjudged; won't fix drops the note and records it as
548
- `Gtd-Mark: wontfix=<title>` on the gate's commit; your own answer is judged
549
- like a note. A note you answered is left out of the fresh review after a nit
550
- fix. Land with every note answered and nothing else changed to approve, or
551
- write what is wrong — disagreeing with a fix or a drop means writing a note
552
- of your own. Approving ends the process (at the refactor review, it moves on
553
- to the feature); feedback is judged note by note, each as `edit`, `question`,
554
- `nit` or `praise`. An `edit` sends the process back to step 2 for re-derived
555
- requirements — the built criteria keep their IDs (reworded in place when
556
- their behaviour changes) and new behaviour gets new ones — then the
557
- implementation is amended to meet them, with no new naive pass or seam judge,
558
- every criterion's coverage is judged again, the quality lap runs, and you
559
- review again. At the refactor review an `edit` re-derives the refactor
560
- instead. A `question` is answered inline under your note and the process
561
- stops at the review again — no new plan. A `nit` is fixed in one batch, the
562
- suite must go green (a red one gets fix turns first), then a fresh review of
563
- the change stops at the review again. `praise` is dropped; a round of only
564
- praise signs off. When a round mixes them, questions are answered and nits
565
- fixed first, then the edits are planned — risk: the planning lap may redo nit
566
- fixes it touches.
541
+ (`build.review.fix.risks.fixing`), then the review is written again. **What
542
+ matters most comes first:** a judge (`build.review.ranking`) rates every
543
+ chunk on the four severity questions below, and the review lists `critical`
544
+ chunks first (security, then one-way doors), then `should`, then `could`,
545
+ each led by its tier and reason (`_critical: security, one-way door_`) — the
546
+ phone shows it on the chunk's card. A chunk the judge is unsure of, or whose
547
+ evidence the budget cut, is `critical`. On `refactor-first` the refactor
548
+ slice gets a review of its own first, composed by gtd from its lap's notes,
549
+ before the feature is built; see [the naive pass](#the-naive-pass). **Every
550
+ note needs exactly one tick before `gtd land` accepts the review**
551
+ (`answer-completeness`; an open `- H:` thread waiting on the agent is the one
552
+ exception). Fix as suggested joins the nit batch below, unjudged; won't fix
553
+ drops the note and records it as `Gtd-Mark: wontfix=<title>` on the gate's
554
+ commit; your own answer is judged like a note. A note you answered is left
555
+ out of the fresh review after a nit fix. Land with every note answered and
556
+ nothing else changed to approve, or write what is wrong — disagreeing with a
557
+ fix or a drop means writing a note of your own. Approving ends the process
558
+ (at the refactor review, it moves on to the feature); feedback is judged note
559
+ by note, each as `edit`, `question`, `nit` or `praise`. An `edit` sends the
560
+ process back to step 2 for re-derived requirements — the built criteria keep
561
+ their IDs (reworded in place when their behaviour changes) and new behaviour
562
+ gets new ones — then the implementation is amended to meet them, with no new
563
+ naive pass or seam judge, every criterion's coverage is judged again, the
564
+ quality lap runs, and you review again. At the refactor review an `edit`
565
+ re-derives the refactor instead. A `question` is answered inline under your
566
+ note and the process stops at the review again — no new plan. A `nit` is
567
+ fixed in one batch, the suite must go green (a red one gets fix turns first),
568
+ then a fresh review of the change stops at the review again. `praise` is
569
+ dropped; a round of only praise signs off. When a round mixes them, questions
570
+ are answered and nits fixed first, then the edits are planned — risk: the
571
+ planning lap may redo nit fixes it touches.
567
572
 
568
573
  Each `edit` note also gets a severity tier from four judge questions
569
574
  (security, reversibility, blast radius, new concept). Every tier is
@@ -1,5 +1,5 @@
1
1
  import { accessFor, agent, answered, changesSince, check, codeThreads, defineWorkflow, displayPath, env, head, human, judge, mark, numeric, quote, read, refuse, scope, skillsFor, start, vars } from "../flows.mjs";
2
- import { d as ANSWER_FIX, f as ANSWER_LEAD, m as reviewChunks, p as ANSWER_WONT_FIX, y as FREE_TEXT_PLACEHOLDER } from "./steering-BXwjmocB.mjs";
2
+ import { b as FREE_TEXT_PLACEHOLDER, d as ANSWER_FIX, f as ANSWER_LEAD, m as reviewChunks, p as ANSWER_WONT_FIX, v as severityLine } from "./steering-DtUVUz9l.mjs";
3
3
  import { gate as gate$1, healthy as healthy$1, lensScopes, lensesOf, removedAssertions } from "../workflow-kit.mjs";
4
4
  import { c as String$, g as standardSchemaV1, h as optionalWith, l as Struct, n as Boolean$, s as Number$ } from "./Schema-C6fLlQzx.mjs";
5
5
  //#region src/workflows/shared/registry.ts
@@ -641,6 +641,56 @@ const withWalkthrough = (review, report) => {
641
641
  const body = lines.join("\n").trimEnd();
642
642
  return summary.length === 0 ? `${body}\n` : `${body}\n\n${summary.join("\n\n")}\n`;
643
643
  };
644
+ const chunkBlocks = (review) => {
645
+ const lines = review.split("\n");
646
+ const chunks = reviewChunks(review);
647
+ return {
648
+ head: lines.slice(0, chunks[0]?.line ?? lines.length),
649
+ blocks: chunks.map((c, i) => ({
650
+ title: c.title,
651
+ lines: lines.slice(c.line, chunks[i + 1]?.line ?? lines.length)
652
+ }))
653
+ };
654
+ };
655
+ const isBlank = (line) => line !== void 0 && line.trim() === "";
656
+ /** \`review\` with every chunk's severity line removed, so a re-review is ranked afresh. */
657
+ const unranked = (review) => {
658
+ const drop = new Set(reviewChunks(review).flatMap((c) => c.severityLine ?? []));
659
+ if (drop.size === 0) return review;
660
+ const lines = review.split("\n");
661
+ return lines.filter((_, i) => !drop.has(i) && !(drop.has(i - 1) && isBlank(lines[i]))).join("\n");
662
+ };
663
+ const chunkTexts = (review) => new Map(chunkBlocks(review).blocks.map((b) => [b.title, b.lines.join("\n").trim()]));
664
+ const trimBlankLines = (lines) => {
665
+ let start = 0;
666
+ let end = lines.length;
667
+ while (start < end && isBlank(lines[start])) start++;
668
+ while (end > start && isBlank(lines[end - 1])) end--;
669
+ return lines.slice(start, end);
670
+ };
671
+ /**
672
+ * \`review\` with the \`ranked\` chunks first, in their order, each led by its
673
+ * severity line; every other chunk follows in place.
674
+ */
675
+ const rankedReview = (review, ranked) => {
676
+ const { head, blocks } = chunkBlocks(review);
677
+ const place = new Map(ranked.map((r, k) => [r.title, k]));
678
+ const labels = new Map(ranked.map((r) => [r.title, r.label]));
679
+ const order = (i) => place.get(blocks[i].title) ?? ranked.length;
680
+ const chunk = (i) => {
681
+ const { title, lines } = blocks[i];
682
+ const label = labels.get(title);
683
+ const heading = /^ {0,3}-+\s*$/.test(lines[1] ?? "") ? 2 : 1;
684
+ const body = trimBlankLines(lines.slice(heading)).join("\n");
685
+ return [
686
+ lines.slice(0, heading).join("\n"),
687
+ ...label === void 0 ? [] : [severityLine(label)],
688
+ ...body === "" ? [] : [body]
689
+ ].join("\n\n");
690
+ };
691
+ const sorted = blocks.map((_, i) => i).sort((x, y) => order(x) - order(y) || x - y);
692
+ return `${[head.join("\n").trim(), ...sorted.map(chunk)].filter((p) => p !== "").join("\n\n")}\n`;
693
+ };
644
694
  /**
645
695
  * \`.gtd/REVIEW.md\` as gtd writes it: one chunk per note, each ending in its
646
696
  * answer boxes, then the fixed and noise lists. Chunk titles are made unique —
@@ -1195,6 +1245,13 @@ const wrapItem = (item) => {
1195
1245
  } else line = line === "" ? word : `${line} ${word}`;
1196
1246
  return [...out, line];
1197
1247
  };
1248
+ const reviewRankingMessage = () => `Ranking each chunk of the review before the human reads it. Run
1249
+ \`gtd judge answer\` and pipe, per chunk \`C<n>\`: \`C<n>.security\`
1250
+ (yes/no), \`C<n>.reversibility\` (one-way/two-way),
1251
+ \`C<n>.blastRadius\` (local/module/contract) and \`C<n>.newConcept\`
1252
+ (yes/no) — or land without a verdict, which lists every chunk as
1253
+ critical.
1254
+ `;
1198
1255
  const buildReviewTriageMessage = () => `Judging each note the human added to \`.gtd/REVIEW.md\` (a line note, a
1199
1256
  footnote, or prose under a chunk) with one verdict: \`edit\` (a change
1200
1257
  request), \`question\`, \`nit\` (a small fix needing no re-plan) or
@@ -2074,4 +2131,4 @@ const steering = {
2074
2131
  [REVIEW]: "review"
2075
2132
  };
2076
2133
  //#endregion
2077
- export { naivePassPrompt as $, reviewQuality as A, conflictBody as At, coverageQuestion as B, flipProblems as Bt, collecting as C, walkthroughBlock as Ct, fixRisks as D, envDefaults as Dt, fixQuality as E, defaults as Et, builderSystem as F, parseStandards as Ft, designTriagePrompt as G, criterionLine as H, requireFlag as Ht, clearCriteriaMessage as I, renderStandards as It, handOffAwaitReviewMessage as J, finisherSystem as K, clearCriteriaQuestion as L, standardKey as Lt, agentWithSkills as M, conflictTitle as Mt, buildReviewCollectingPrompt as N, isConflictTitle as Nt, planner as O, testFileGlobs as Ot, buildReviewTriageMessage as P, oneLine as Pt, naiveApplyFailure as Q, composedReview as R, flagMentions as Rt, coder as S, unwindFailure as St, fixNits as T, withWalkthrough as Tt, designGateAnswerMessage as U, coveringPrompt as V, registryEntry as Vt, designSystem as W, implementBuildingPrompt as X, idleMessage as Y, implementFixPrompt as Z, REQUIREMENTS as _, slicingPrompt as _t, flagModeOn as a, refactorDroppedRefusal as at, answerReviewQuestions as b, uncoveredMessage as bt, baseline as c, refactorRules as ct, healthy as d, reviewFeedbackFixPrompt as dt, normaliseCriteriaScript as et, FEEDBACK as f, reviewGateBlockedMessage as ft, REFACTOR_NOTE as g, seamSteerMessage as gt, REFACTOR as h, seamByHandMessage as ht, skills as i, refactorBuildingPrompt as it, reviewing as j, conflictFor as jt, reviewMissing as k, STANDARDS as kt, gate as l, reviewEditNotesCapture as lt, QUALITY as m, reviewerSystem as mt, summary as n, qualityFindingsFile as nt, flagShipped as o, refactorFixPrompt as ot, HANDOFF as p, reviewNotesCapture as pt, fixReviewNotesPrompt as q, access as r, qualityTriageMessage as rt, shipped as s, refactorNotePrompt as st, steering as t, oversizedMessage as tt, guarded as u, reviewEditsCapture as ut, REVIEW as v, startGateBlockedMessage as vt, fixCheck as w, withLapFindings as wt, awaitReview as x, unwindFailedMessage as xt, SLICES as y, titledNotes as yt, coverageMessage as z, flagReferences as zt };
2134
+ export { naiveApplyFailure as $, reviewQuality as A, defaults as At, coverageMessage as B, renderStandards as Bt, collecting as C, uncoveredMessage as Ct, fixRisks as D, walkthroughBlock as Dt, fixQuality as E, unwindFailure as Et, builderSystem as F, conflictFor as Ft, designSystem as G, registryEntry as Gt, coveringPrompt as H, flagMentions as Ht, chunkTexts as I, conflictTitle as It, fixReviewNotesPrompt as J, designTriagePrompt as K, requireFlag as Kt, clearCriteriaMessage as L, isConflictTitle as Lt, agentWithSkills as M, testFileGlobs as Mt, buildReviewCollectingPrompt as N, STANDARDS as Nt, planner as O, withLapFindings as Ot, buildReviewTriageMessage as P, conflictBody as Pt, implementFixPrompt as Q, clearCriteriaQuestion as R, oneLine as Rt, coder as S, titledNotes as St, fixNits as T, unwindFailedMessage as Tt, criterionLine as U, flagReferences as Ut, coverageQuestion as V, standardKey as Vt, designGateAnswerMessage as W, flipProblems as Wt, idleMessage as X, handOffAwaitReviewMessage as Y, implementBuildingPrompt as Z, REQUIREMENTS as _, reviewerSystem as _t, flagModeOn as a, rankedReview as at, answerReviewQuestions as b, slicingPrompt as bt, baseline as c, refactorFixPrompt as ct, healthy as d, reviewEditNotesCapture as dt, naivePassPrompt as et, FEEDBACK as f, reviewEditsCapture as ft, REFACTOR_NOTE as g, reviewRankingMessage as gt, REFACTOR as h, reviewNotesCapture as ht, skills as i, qualityTriageMessage as it, reviewing as j, envDefaults as jt, reviewMissing as k, withWalkthrough as kt, gate as l, refactorNotePrompt as lt, QUALITY as m, reviewGateBlockedMessage as mt, summary as n, oversizedMessage as nt, flagShipped as o, refactorBuildingPrompt as ot, HANDOFF as p, reviewFeedbackFixPrompt as pt, finisherSystem as q, access as r, qualityFindingsFile as rt, shipped as s, refactorDroppedRefusal as st, steering as t, normaliseCriteriaScript as tt, guarded as u, refactorRules as ut, REVIEW as v, seamByHandMessage as vt, fixCheck as w, unranked as wt, awaitReview as x, startGateBlockedMessage as xt, SLICES as y, seamSteerMessage as yt, composedReview as z, parseStandards as zt };
@@ -1,8 +1,8 @@
1
1
  import { answered, changesSince, head, judge, numeric, read, refuse, requireRevert, restoreScript, run, showScript, vars, writeScript } from "../flows.mjs";
2
- import { _ as reviewRisks, m as reviewChunks } from "./steering-BXwjmocB.mjs";
2
+ import { _ as reviewRisks, m as reviewChunks } from "./steering-DtUVUz9l.mjs";
3
3
  import { removedAssertions, review } from "../workflow-kit.mjs";
4
- import { At as conflictBody, C as collecting, Ct as walkthroughBlock, D as fixRisks, Ft as parseStandards, It as renderStandards, Lt as standardKey, Mt as conflictTitle, Nt as isConflictTitle, P as buildReviewTriageMessage, Pt as oneLine, R as composedReview, T as fixNits, Tt as withWalkthrough, b as answerReviewQuestions, d as healthy, j as reviewing, jt as conflictFor, k as reviewMissing, kt as STANDARDS, lt as reviewEditNotesCapture, pt as reviewNotesCapture, u as guarded, ut as reviewEditsCapture, v as REVIEW, w as fixCheck, x as awaitReview, yt as titledNotes } from "./reserved-CYZ1bSBy.mjs";
5
- import { i as isOptionalObject, n as closeReview, o as tail, s as NO_FINDINGS } from "./tail-C1Oi2YEy.mjs";
4
+ import { Bt as renderStandards, C as collecting, D as fixRisks, Dt as walkthroughBlock, Ft as conflictFor, I as chunkTexts, It as conflictTitle, Lt as isConflictTitle, Nt as STANDARDS, P as buildReviewTriageMessage, Pt as conflictBody, Rt as oneLine, St as titledNotes, T as fixNits, Vt as standardKey, at as rankedReview, b as answerReviewQuestions, d as healthy, dt as reviewEditNotesCapture, ft as reviewEditsCapture, gt as reviewRankingMessage, ht as reviewNotesCapture, j as reviewing, k as reviewMissing, kt as withWalkthrough, u as guarded, v as REVIEW, w as fixCheck, wt as unranked, x as awaitReview, z as composedReview, zt as parseStandards } from "./reserved-CdOzKFVX.mjs";
5
+ import { i as isOptionalObject, n as closeReview, o as tail, s as NO_FINDINGS } from "./tail-DRM2eC1z.mjs";
6
6
  //#region src/workflows/shared/learn.ts
7
7
  const SUMMARY_MAX = 200;
8
8
  const BANNER = /^_(?:The fixer left this unresolved\.|A fix turn removed this while renaming another finding — check whether it was fixed\.|No detail given\.)_\s*/;
@@ -205,7 +205,8 @@ const severityOf = (note, judgment, minP) => {
205
205
  if (critical.length > 0) return {
206
206
  tier: "critical",
207
207
  reason: `critical: ${critical.join(", ")}`,
208
- rank: rank()
208
+ rank: rank(),
209
+ ...a("security") === "yes" ? { security: true } : {}
209
210
  };
210
211
  const should = [...a("blastRadius") === "contract" ? ["contract"] : [], ...a("newConcept") === "yes" ? ["new concept"] : []];
211
212
  if (should.length > 0) return {
@@ -219,29 +220,30 @@ const severityOf = (note, judgment, minP) => {
219
220
  rank: 2
220
221
  };
221
222
  };
222
- const severityQuestions = (note) => [
223
+ /** `asks` names the change in full, `change` refers back to it. */
224
+ const severityQuestions = (id, asks, change) => [
223
225
  {
224
- id: `${note.id}.security`,
226
+ id: `${id}.security`,
225
227
  primitive: "noul",
226
- 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?`,
228
+ instructions: `Does ${asks} touch security — authentication, authorisation, secrets, untrusted input or data exposure?`,
227
229
  criteria: "yes: it changes or could weaken a security property. no: it does not."
228
230
  },
229
231
  {
230
- id: `${note.id}.reversibility`,
232
+ id: `${id}.reversibility`,
231
233
  primitive: "choice",
232
- instructions: `Once the change in note "${note.id}" ships, can it be undone without breaking anyone who already depends on it?`,
234
+ instructions: `Once ${change} ships, can it be undone without breaking anyone who already depends on it?`,
233
235
  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."
234
236
  },
235
237
  {
236
- id: `${note.id}.blastRadius`,
238
+ id: `${id}.blastRadius`,
237
239
  primitive: "choice",
238
- instructions: `How far does the change in note "${note.id}" reach?`,
240
+ instructions: `How far does ${change} reach?`,
239
241
  criteria: "local: one function or file. module: a whole module or several files. contract: a public interface other code or users depend on."
240
242
  },
241
243
  {
242
- id: `${note.id}.newConcept`,
244
+ id: `${id}.newConcept`,
243
245
  primitive: "noul",
244
- 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.`,
246
+ instructions: `Does ${change} 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.`,
245
247
  criteria: "yes: a genuinely new concept. no: reuses or extends an existing one."
246
248
  }
247
249
  ];
@@ -258,7 +260,7 @@ const triage = (severities) => async (notes) => {
258
260
  answers: {},
259
261
  truncated: []
260
262
  } : await judge("review.triage", {
261
- questions: asked.flatMap((n) => [verdictQuestion(n), ...severityQuestions(n)]),
263
+ questions: asked.flatMap((n) => [verdictQuestion(n), ...severityQuestions(n.id, `the change the human asks for in note "${n.id}" (on "${n.anchor}")`, `the change in note "${n.id}"`)]),
262
264
  evidence: Object.fromEntries(asked.map((n) => [n.id, `Anchor: ${n.anchor}\nReviewer's text: ${n.before}\nHuman's text: ${n.text}`])),
263
265
  message: buildReviewTriageMessage(),
264
266
  label: "Judging each note"
@@ -274,6 +276,67 @@ const triage = (severities) => async (notes) => {
274
276
  return [n.id, "edit"];
275
277
  }));
276
278
  };
279
+ /** Review order: security, one-way doors, the judge unsure, then should, then could. */
280
+ const chunkKey = (s) => s.tier === "could" ? 4 : s.tier === "should" ? 3 : s.security === true ? 0 : s.rank === 0 ? 1 : 2;
281
+ /** A chunk's text, then the code each of its pointers covers: a bare path the whole file. */
282
+ const chunkEvidence = (text, files) => [text, ...files.map(({ path, line, rangeEnd }) => {
283
+ const where = `${path}${line === void 0 ? "" : `#${line}${rangeEnd === void 0 ? "" : `-${rangeEnd}`}`}`;
284
+ const lines = read(path.replace(/^\.\//, ""))?.split("\n");
285
+ if (lines === void 0) return `${where}: no longer exists`;
286
+ return `${where}:\n${(line === void 0 ? lines : lines.slice(line - 1, rangeEnd ?? line)).join("\n")}`;
287
+ })].join("\n\n");
288
+ const bytes = (text) => new TextEncoder().encode(text).length;
289
+ /** Fills each batch while the budget's even share still holds its widest evidence, so nothing is cut that fits alone. */
290
+ const batched = (items, budget) => {
291
+ const batches = [];
292
+ let batch = [];
293
+ let widest = 0;
294
+ for (const item of items) {
295
+ const size = bytes(item.evidence);
296
+ if (batch.length > 0 && Math.floor(budget / (batch.length + 1)) < Math.max(widest, size)) {
297
+ batches.push(batch);
298
+ batch = [];
299
+ widest = 0;
300
+ }
301
+ batch.push(item);
302
+ widest = Math.max(widest, size);
303
+ }
304
+ return batch.length === 0 ? batches : [...batches, batch];
305
+ };
306
+ /**
307
+ * Rates every chunk the reviewer wrote — a walkthrough chunk or a carried
308
+ * finding — and sorts \`full\` by it, each chunk led by its tier and reason.
309
+ * gtd's summary chunks are not rated and stay last.
310
+ */
311
+ const ranked = async (review, full) => {
312
+ const texts = chunkTexts(full);
313
+ const chunks = reviewChunks(review).map((c, i) => ({
314
+ id: `C${i + 1}`,
315
+ title: c.title,
316
+ evidence: chunkEvidence(texts.get(c.title) ?? "", c.files)
317
+ }));
318
+ if (chunks.length === 0) return full;
319
+ const minP = numeric(vars.reviewSeverityMinP, Infinity);
320
+ const batches = batched(chunks, numeric(vars.judgeBudgetBytes, 32768));
321
+ const severities = /* @__PURE__ */ new Map();
322
+ for (const [i, batch] of batches.entries()) {
323
+ const judgment = await judge("review.ranking", {
324
+ questions: batch.flatMap((c) => severityQuestions(c.id, `the change review chunk "${c.id}" ("${c.title}") walks through`, `the change in review chunk "${c.id}"`)),
325
+ evidence: Object.fromEntries(batch.map((c) => [c.id, c.evidence])),
326
+ message: reviewRankingMessage(),
327
+ label: batches.length === 1 ? "Ranking the review" : `Ranking the review (${i + 1}/${batches.length})`
328
+ });
329
+ for (const c of batch) severities.set(c.id, severityOf(c, judgment, minP));
330
+ }
331
+ return rankedReview(full, chunks.map((c, i) => ({
332
+ c,
333
+ i,
334
+ s: severities.get(c.id)
335
+ })).sort((x, y) => chunkKey(x.s) - chunkKey(y.s) || x.i - y.i).map(({ c, s }) => ({
336
+ title: c.title,
337
+ label: s.reason
338
+ })));
339
+ };
277
340
  const isCode = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
278
341
  const composedOf = (base, report, nitFix, answered) => ({
279
342
  ...report,
@@ -325,12 +388,13 @@ const requirePointers = (review, notes, codeChanged) => {
325
388
  const walkthrough = async (base, report, lap, carry) => {
326
389
  const notes = titledNotes(report);
327
390
  await reviewing(base, carry, lap, walkthroughBlock(notes, report.nitFix));
328
- const review = read(REVIEW);
329
- if (review === void 0) return;
330
- requirePointers(review, notes, changesSince(base).some((c) => isCode(c.path)));
331
- const full = withWalkthrough(review, report);
332
- if (full !== review) await run("review.carrying", writeScript(REVIEW, full), {
333
- label: "Adding the quality lap's findings",
391
+ const written = read(REVIEW);
392
+ if (written === void 0) return;
393
+ const review = unranked(written);
394
+ requirePointers(written, notes, changesSince(base).some((c) => isCode(c.path)));
395
+ const full = await ranked(review, withWalkthrough(review, report));
396
+ if (full !== written) await run("review.carrying", writeScript(REVIEW, full), {
397
+ label: "Ordering the review and adding the quality lap's findings",
334
398
  file: REVIEW,
335
399
  base
336
400
  });
@@ -15089,6 +15089,9 @@ const qaDescriptor = {
15089
15089
  const ANSWER_LEAD = "Tick exactly one:";
15090
15090
  const ANSWER_FIX = "fix as suggested";
15091
15091
  const ANSWER_WONT_FIX = "won't fix";
15092
+ /** A chunk's leading `_critical: security_` paragraph: the tier gtd ranked it with, and why. */
15093
+ const SEVERITY_LINE_RE = /^_((?:critical|should|could)(?::[^_\n]*)?)_$/;
15094
+ const severityLine = (label) => `_${label}_`;
15092
15095
  /**
15093
15096
  * The canonical sample. Every detail here is load-bearing for the formatter
15094
15097
  * round-trip: both footnote bodies exceed 80 characters so a reflow actually
@@ -15379,12 +15382,26 @@ const answerListOf = (content, body) => {
15379
15382
  })
15380
15383
  };
15381
15384
  };
15385
+ /** A leading severity line is the chunk's tier, never its prose. */
15386
+ const splitSeverity = (content, run) => {
15387
+ const first = run[0];
15388
+ const label = first?.type === "paragraph" ? SEVERITY_LINE_RE.exec(sourceText(content, first).trim())?.[1] : void 0;
15389
+ if (label === void 0) return { leadingRun: run };
15390
+ return {
15391
+ severity: {
15392
+ label,
15393
+ line: toLspPosition(first.position.start).line
15394
+ },
15395
+ leadingRun: run.slice(1)
15396
+ };
15397
+ };
15382
15398
  /** 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). */
15383
15399
  const parseChunkBody = (content, lines, title, body) => {
15384
15400
  const answer = answerListOf(content, body);
15385
15401
  const isAnswer = (n) => answer !== void 0 && (n === answer.list || n === body[answer.lead]);
15386
15402
  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));
15403
+ const run = (firstPointerIndex === -1 ? body : body.slice(0, firstPointerIndex)).filter((n) => !isAnswer(n));
15404
+ const { severity, leadingRun } = splitSeverity(content, run);
15388
15405
  const descriptionNodes = blockNodesOfRun(content, leadingRun);
15389
15406
  const descriptionInline = collapseImages(blockRunInline(content, leadingRun));
15390
15407
  const description = leadingRun.filter((n) => n.type !== "footnoteDefinition").map((n) => sourceText(content, n)).join(" ").trim();
@@ -15403,6 +15420,7 @@ const parseChunkBody = (content, lines, title, body) => {
15403
15420
  files,
15404
15421
  errors,
15405
15422
  ...answer === void 0 ? {} : { answers: answer.options },
15423
+ ...severity === void 0 ? {} : { severity },
15406
15424
  boxed: body.some((n) => !isAnswer(n) && taskItems(n).length > 0)
15407
15425
  };
15408
15426
  };
@@ -15439,7 +15457,7 @@ const parseChangesets = (tree, content, lines) => {
15439
15457
  const errors = [];
15440
15458
  for (const { heading, headingLine, body } of splitChunks(tree)) {
15441
15459
  const title = headingText$1(content, heading);
15442
- const { description, descriptionNodes, descriptionInline, files, answers, boxed, errors: bodyErrors } = parseChunkBody(content, lines, title, body);
15460
+ const { description, descriptionNodes, descriptionInline, files, answers, severity, boxed, errors: bodyErrors } = parseChunkBody(content, lines, title, body);
15443
15461
  errors.push(...bodyErrors);
15444
15462
  if (files.length === 0 && boxed) errors.push({
15445
15463
  message: `Chunk "${title}" has no file pointers`,
@@ -15453,7 +15471,8 @@ const parseChangesets = (tree, content, lines) => {
15453
15471
  descriptionInline,
15454
15472
  files,
15455
15473
  headingLine,
15456
- ...answers === void 0 ? {} : { answers }
15474
+ ...answers === void 0 ? {} : { answers },
15475
+ ...severity === void 0 ? {} : { severity }
15457
15476
  });
15458
15477
  }
15459
15478
  if (changesets.length === 0) errors.push({ message: "REVIEW.md has no '##' chunks" });
@@ -15754,6 +15773,7 @@ const reviewView = (content) => {
15754
15773
  detail: chunk.description,
15755
15774
  detailInline: chunk.descriptionInline,
15756
15775
  body: chunk.descriptionNodes,
15776
+ ...chunk.severity === void 0 ? {} : { status: chunk.severity.label },
15757
15777
  anchor: {
15758
15778
  kind: "chunk",
15759
15779
  index: chunkIndex
@@ -15901,15 +15921,19 @@ const reviewDescriptor = {
15901
15921
  openThreads: openThreadsOf
15902
15922
  };
15903
15923
  const pointerLabel = (f) => `${f.path}${f.line === void 0 ? "" : `#${f.line}${f.rangeEnd === void 0 ? "" : `-${f.rangeEnd}`}`}`;
15904
- const chunkNote = (chunk, prior) => chunk.description === (prior?.description ?? "") || prior === void 0 && chunk.answers !== void 0 ? [] : [{
15905
- line: chunk.headingLine,
15906
- note: {
15907
- kind: "chunk",
15908
- anchor: chunk.title,
15909
- before: prior?.description ?? "",
15910
- text: chunk.description
15911
- }
15912
- }];
15924
+ const chunkProse = (chunk, retiered) => [chunk?.description ?? "", retiered && chunk?.severity !== void 0 ? severityLine(chunk.severity.label) : ""].filter((p) => p !== "").join("\n\n");
15925
+ const chunkNote = (chunk, prior) => {
15926
+ const retiered = chunk.severity !== void 0 && chunk.severity.label !== prior?.severity?.label;
15927
+ return chunkProse(chunk, retiered) === chunkProse(prior, retiered) || prior === void 0 && chunk.answers !== void 0 ? [] : [{
15928
+ line: chunk.headingLine,
15929
+ note: {
15930
+ kind: "chunk",
15931
+ anchor: chunk.title,
15932
+ before: chunkProse(prior, retiered),
15933
+ text: chunkProse(chunk, retiered)
15934
+ }
15935
+ }];
15936
+ };
15913
15937
  const pointerNotes = (chunk, prior) => chunk.files.flatMap((file) => {
15914
15938
  const label = pointerLabel(file);
15915
15939
  const was = prior?.files.find((f) => pointerLabel(f) === label);
@@ -16023,7 +16047,13 @@ const reviewDecisions = (before, after) => {
16023
16047
  const reviewChunks = (content) => parseReviewDoc(content).changesets.map((c) => ({
16024
16048
  title: c.title,
16025
16049
  line: c.headingLine,
16026
- pointers: c.files.length
16050
+ pointers: c.files.length,
16051
+ ...c.severity === void 0 ? {} : { severityLine: c.severity.line },
16052
+ files: c.files.map((f) => ({
16053
+ path: f.path,
16054
+ ...f.line === void 0 ? {} : { line: f.line },
16055
+ ...f.rangeEnd === void 0 ? {} : { rangeEnd: f.rangeEnd }
16056
+ }))
16027
16057
  }));
16028
16058
  //#endregion
16029
16059
  //#region src/steering/Edits.ts
@@ -16393,4 +16423,4 @@ const viewOf = (format, content) => ({
16393
16423
  /** Every OPEN thread — last entry is the agent's — as `{ name, line, firstMe }` (0-based line). `[]` for a format with no thread concept. */
16394
16424
  const openThreads = (format, content) => format.openThreads?.(content) ?? [];
16395
16425
  //#endregion
16396
- export { formatFinding as A, openThreadFindings as C, getParseCount as D, parseThreadsWithFindings as E, headingSectionBodies as O, THREAD_REPLY_ACTION_TITLE as S, parseThreads as T, reviewRisks as _, unansweredQuestions as a, isAnswered as b, stripCodeThreads as c, ANSWER_FIX as d, ANSWER_LEAD as f, reviewNotes as g, reviewDecisions as h, steeringFormatOrFreeForm as i, headingSections as k, freeFormFormat as l, reviewChunks 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, unansweredNotes as v, parseFootnotes as w, FOOTNOTE_ACTION_TITLE as x, FREE_TEXT_PLACEHOLDER as y };
16426
+ export { headingSections as A, THREAD_REPLY_ACTION_TITLE as C, parseThreadsWithFindings as D, parseThreads as E, getParseCount as O, FOOTNOTE_ACTION_TITLE as S, parseFootnotes as T, reviewRisks as _, unansweredQuestions as a, FREE_TEXT_PLACEHOLDER as b, stripCodeThreads as c, ANSWER_FIX as d, ANSWER_LEAD as f, reviewNotes as g, reviewDecisions as h, steeringFormatOrFreeForm as i, formatFinding as j, headingSectionBodies as k, freeFormFormat as l, reviewChunks 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, severityLine as v, openThreadFindings as w, isAnswered as x, unansweredNotes 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 { lensEntriesOf, lensOf, qualityLap, reviewToHandOff } from "../workflow-kit.mjs";
3
- import { A as reviewQuality, D as fixRisks, E as fixQuality, J as handOffAwaitReviewMessage, d as healthy, j as reviewing, k as reviewMissing, m as QUALITY, nt as qualityFindingsFile, rt as qualityTriageMessage, u as guarded, v as REVIEW, w as fixCheck, wt as withLapFindings, x as awaitReview } from "./reserved-CYZ1bSBy.mjs";
3
+ import { A as reviewQuality, D as fixRisks, E as fixQuality, Ot as withLapFindings, Y as handOffAwaitReviewMessage, d as healthy, it as qualityTriageMessage, j as reviewing, k as reviewMissing, m as QUALITY, rt as qualityFindingsFile, u as guarded, v as REVIEW, w as fixCheck, x as awaitReview } from "./reserved-CdOzKFVX.mjs";
4
4
  //#region src/workflows/shared/lenses.ts
5
5
  const NO_FINDINGS = {
6
6
  notes: [],