@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "42.0.0",
3
+ "version": "43.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
@@ -339,10 +339,10 @@ Then, in any repository:
339
339
  ```
340
340
 
341
341
  That starts a process from your requirements and drives it until it needs you.
342
- The requirements go to the top of `.gtd/TODO.md`; deferred review notes already
343
- there stay. A long, multi-line requirements paste still starts a process; if
344
- Claude Code never expanded the paste, gtd says it could not read it, and you add
345
- the requirements to the top of `.gtd/TODO.md` and run `/gtd`. `/gtd fix`,
342
+ The requirements go to the top of `.gtd/TODO.md`, above whatever it already
343
+ holds. A long, multi-line requirements paste still starts a process; if Claude
344
+ Code never expanded the paste, gtd says it could not read it, and you add the
345
+ requirements to the top of `.gtd/TODO.md` and run `/gtd`. `/gtd fix`,
346
346
  `/gtd review [base]`, `/gtd rollout <flag>` and `/gtd cleanup <flag>` (where the
347
347
  config exports them) take the doors described below; text whose word count does
348
348
  not fit a door's args (bare `/gtd rollout` included) starts a process from that
@@ -522,37 +522,41 @@ noted in steps 3 and 4 below.
522
522
 
523
523
  4. **You review.** After the last slice's quality lap gtd writes the review
524
524
  document itself (`build.review.composing`; no reviewer turn runs): one `## `
525
- chunk per design note the lap left, then — for reference, no tick boxes, no
526
- answer needed — what was **fixed automatically** (with the fixing commit) and
527
- what was **dropped as noise**. On `refactor-first` the refactor slice gets
528
- one of its own first, before the feature is built; see
529
- [the naive pass](#the-naive-pass). Review the diff yourself (`gtd base`
530
- prints its base). Land with no change to approve, or write what is wrong —
531
- disagreeing with a fix or a drop means writing a note of your own. Approving
532
- ends the process (at the refactor review, it moves on to the feature);
533
- feedback is judged note by note, each as `edit`, `question`, `nit` or
534
- `praise`. An `edit` sends the process back to step 2 for re-derived
535
- requirements — the built criteria keep their IDs (reworded in place when
536
- their behaviour changes) and new behaviour gets new ones — then the
537
- implementation is amended to meet them, with no new naive pass or seam judge,
538
- every criterion's coverage is judged again, the quality lap runs, and you
539
- review again. At the refactor review an `edit` re-derives the refactor
540
- instead. A `question` is answered inline under your note and the process
541
- stops at the review again — no new plan. A `nit` is fixed in one batch, the
542
- suite must go green (a red one gets fix turns first), then a fresh review of
543
- the change stops at the review again. `praise` is dropped; a round of only
544
- praise signs off. When a round mixes them, questions are answered and nits
545
- fixed first, then the edits are planned — risk: the planning lap may redo nit
546
- fixes it touches.
525
+ chunk per design note the lap left, each ending in three tick boxes — **fix
526
+ as suggested**, **won't fix**, or **your own answer** written over
527
+ `_your answer_` — then, for reference, no tick boxes, no answer needed — what
528
+ was **fixed automatically** (with the fixing commit) and what was **dropped
529
+ as noise**. On `refactor-first` the refactor slice gets one of its own first,
530
+ before the feature is built; see [the naive pass](#the-naive-pass). Review
531
+ the diff yourself (`gtd base` prints its base). **Every note needs exactly
532
+ one tick before `gtd land` accepts the review** (`answer-completeness`; an
533
+ open `- H:` thread waiting on the agent is the one exception). Fix as
534
+ suggested joins the nit batch below, unjudged; won't fix drops the note and
535
+ records it as `Gtd-Mark: wontfix=<title>` on the gate's commit; your own
536
+ answer is judged like a note. A note you answered is left out of the fresh
537
+ review after a nit fix; a round that also answered a question restores the
538
+ review instead, your ticks kept. Land with every note answered and nothing
539
+ else changed to approve, or write what is wrong — disagreeing with a fix or a
540
+ drop means writing a note of your own. Approving ends the process (at the
541
+ refactor review, it moves on to the feature); feedback is judged note by
542
+ note, each as `edit`, `question`, `nit` or `praise`. An `edit` sends the
543
+ process back to step 2 for re-derived requirements — the built criteria keep
544
+ their IDs (reworded in place when their behaviour changes) and new behaviour
545
+ gets new ones — then the implementation is amended to meet them, with no new
546
+ naive pass or seam judge, every criterion's coverage is judged again, the
547
+ quality lap runs, and you review again. At the refactor review an `edit`
548
+ re-derives the refactor instead. A `question` is answered inline under your
549
+ note and the process stops at the review again — no new plan. A `nit` is
550
+ fixed in one batch, the suite must go green (a red one gets fix turns first),
551
+ then a fresh review of the change stops at the review again. `praise` is
552
+ dropped; a round of only praise signs off. When a round mixes them, questions
553
+ are answered and nits fixed first, then the edits are planned — risk: the
554
+ planning lap may redo nit fixes it touches.
547
555
 
548
556
  Each `edit` note also gets a severity tier from four judge questions
549
- (security, reversibility, blast radius, new concept). `critical` and `should`
550
- notes are re-planned this round; `could` notes are deferred. A leading `!` on
551
- a note forces `critical` and skips the judge. Deferral appends to the
552
- committed `.gtd/TODO.md`; it never starts a process on its own, and it ships
553
- in the branch/PR unless you delete it. Landing clean at the gate after a
554
- deferral signs off without the deferred notes; the gate lists them. To pull
555
- one in, edit its line in `.gtd/TODO.md` when you start a process.
557
+ (security, reversibility, blast radius, new concept). Every tier is
558
+ re-planned this round, `critical` first, then `should`, then `could`; nothing
559
+ is deferred. A leading `!` on a note forces `critical` and skips the judge.
556
560
  `reviewSeverityMinP` sets the confidence floor (see
557
561
  [configuration](docs/configuration.md)). A hand-edit to code always plans a
558
562
  lap. Only a confident non-`edit` verdict skips the replan — a note whose
@@ -740,6 +744,7 @@ full trail with `gtd history fetch | sh`, then `gtd history show <ref>`.
740
744
  gtd history list # one line per kept process
741
745
  gtd history show <ref> # replay its steps, judge verdicts and approvals
742
746
  gtd history metrics [ref...] # how the kept processes went, per process and in total
747
+ gtd history judges [ref...] # how often each judge's verdicts were overturned
743
748
  gtd history fetch | sh # pull every kept ref from origin
744
749
  ```
745
750
 
@@ -772,6 +777,45 @@ process kept from that branch. A rate is `null` without a decided slice. Each
772
777
  process names its `workflow` (`null` for the default one); the total spans every
773
778
  ref measured, so name the refs to leave another workflow's processes out.
774
779
 
780
+ `gtd history judges` calibrates three of the bundled `feature` workflow's judges
781
+ from the `Gtd-Judge` verdicts the kept refs recorded. It never replays and asks
782
+ no model. A verdict is **overturned** when later history contradicts it:
783
+
784
+ - **retry** (`health.judge`) — an `identical` verdict, when the next fix turn's
785
+ check went green anyway (no outcome when a person edited code in between)
786
+ - **triage** (`quality.triage`) — a `mechanical` or `noise` verdict the review
787
+ listed under _Fixed automatically_ or _Dropped as noise_, when the human
788
+ edited that finding's line there or wrote under it
789
+ - **clear** (`design.clear`) — a `yes` that skipped design (landed on
790
+ `design.normalise`), when a later human design step in the same process
791
+ changed the acceptance criteria
792
+
793
+ Any other verdict has no outcome. The report counts verdicts, scored verdicts
794
+ and overturns per judge and per `p` bucket (`0.8-0.9: 1/2` — one of two
795
+ overturned). `--var <threshold>=<value>` re-decides every past verdict under
796
+ another threshold and prints the decisions that would change, against the
797
+ threshold each process recorded (a process that recorded none is left out):
798
+
799
+ ```sh
800
+ gtd history judges --var qualityTriageMinP=0.9
801
+ # what-if qualityTriageMinP=0.9: 7 noise drops become notes (2 overturned)
802
+ ```
803
+
804
+ The thresholds are `judgeIdenticalMinP` (retry) and `qualityTriageMinP`
805
+ (triage). The judge's evidence-budget cut is not recorded, so a cut verdict is
806
+ scored and re-decides as if it was whole. A retry verdict on an empty report is
807
+ not scored, as the flow drops it. `--json` prints
808
+ `{ processes, total, whatIf }`, each process and the total with its `judges`.
809
+ `--jsonl` prints one line per verdict instead — `process`, `judge`, `commit`,
810
+ `step`, `question`, `state` (the evidence the tree still shows: the finding, the
811
+ two check reports, the sketch without its numbered criteria), `answer`, `p` and
812
+ `outcome` (`overturned`, `held` or `null`) — ready to fine-tune a local model
813
+ on:
814
+
815
+ ```sh
816
+ gtd history judges --jsonl > judges.jsonl
817
+ ```
818
+
775
819
  `gtd history fetch` only prints a fetch script; pipe it into `sh` to run it.
776
820
  Beyond that one ship push, refs are not pushed or fetched by default. The ref
777
821
  holds every step commit, including steering files and code a later step removed,
@@ -828,8 +872,8 @@ install for any of these but `./testkit`: gtd resolves a `gtd.config.ts`'s
828
872
  The `gtd` bin's commands — `next`, `land`, `abandon`, `restore`, `validate`,
829
873
  `check`, `uncheck`, `install`, `summary`, `base`, `door`, `doors`, `slices`,
830
874
  `version`, `help`, `history list`, `history show`, `history metrics`,
831
- `history fetch`, `strays`, and the tools `ui`, `lsp`, `loop`, `context-hook`,
832
- `judge` (`judge answer`, `judge run`) — are listed in the
875
+ `history judges`, `history fetch`, `strays`, and the tools `ui`, `lsp`, `loop`,
876
+ `context-hook`, `judge` (`judge answer`, `judge run`) — are listed in the
833
877
  [CLI reference](https://github.com/pmelab/gtd/blob/main/docs/cli.md).
834
878
  `gtd slices` lists the spans a flow's `slice()` calls mark. It refuses (exit 1)
835
879
  when HEAD neither closes nor sits inside a process, or when today's config
@@ -1,5 +1,5 @@
1
1
  // New requirements go above what the steering file already holds, untouched:
2
- // a deferred review note the start diff alters reads as the human's edit.
2
+ // anything already there that the start diff alters reads as the human's edit.
3
3
  export function withRequirements(existing: string, requirements: string): string {
4
4
  const head = `${requirements.trimEnd()}\n`
5
5
  return existing.trim() ? `${head}\n${existing}` : head
@@ -1,4 +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, p as ANSWER_WONT_FIX, v as FREE_TEXT_PLACEHOLDER } from "./steering-D4LUQ-av.mjs";
2
3
  import { escalation as escalation$1, gate as gate$1, healthy as healthy$1, lensScopes, lensesOf, removedAssertions } from "../workflow-kit.mjs";
3
4
  import { c as String$, g as standardSchemaV1, h as optionalWith, l as Struct, n as Boolean$, s as Number$ } from "./Schema-C6fLlQzx.mjs";
4
5
  //#region src/workflows/shared/registry.ts
@@ -437,9 +438,9 @@ const SUMMARY_TITLES = [
437
438
  const unboxed = (body) => body.replace(/^(\s*(?:>\s*)*(?:[-*+]|\d+[.)])\s+)\[[ xX]\]\s?/gm, "$1");
438
439
  const short = (hash) => hash.slice(0, 7);
439
440
  /**
440
- * \`.gtd/REVIEW.md\` as gtd writes it: one chunk per note, then the fixed and
441
- * noise lists, with no tick boxes. Chunk titles are made unique — notes are
442
- * matched to their chunk by title.
441
+ * \`.gtd/REVIEW.md\` as gtd writes it: one chunk per note, each ending in its
442
+ * answer boxes, then the fixed and noise lists. Chunk titles are made unique —
443
+ * notes and answers are matched to their chunk by title.
443
444
  */
444
445
  const composedReview = (base, report) => {
445
446
  const used = new Set(SUMMARY_TITLES);
@@ -450,11 +451,16 @@ const composedReview = (base, report) => {
450
451
  return name;
451
452
  };
452
453
  const chunk = (title, body) => `## ${title}\n\n${body.trim()}`;
454
+ const answerBoxes = `${ANSWER_LEAD}\n\n- [ ] ${ANSWER_FIX}\n- [ ] ${ANSWER_WONT_FIX}\n- [ ] ${FREE_TEXT_PLACEHOLDER}`;
455
+ const notes = report.notes.map((n) => ({
456
+ ...n,
457
+ title: unique(n.title)
458
+ })).filter((n) => report.answered?.has(n.title) !== true);
453
459
  const weakened = (report.weakened ?? []).flatMap(({ path, lines }) => lines.map((a) => `- ${path} — ${a.scenario} — ${a.line}`));
454
460
  const chunks = [
455
- ...report.nitFix === void 0 ? [] : [chunk("Fixed from your notes", `Your nits were fixed in ${short(report.nitFix)}; the list below is from before.`)],
461
+ ...report.nitFix === void 0 ? [] : [chunk("Fixed from your notes", `Your nits and fix-as-suggested notes were worked on in ${short(report.nitFix)}; the notes you answered are left out — check the diff.`)],
456
462
  ...weakened.length === 0 ? [] : [chunk("Weakened scenarios", `These scenarios lost an assertion the review base had:\n\n${weakened.join("\n")}`)],
457
- ...report.notes.map((n) => chunk(unique(n.title), n.unresolved === true ? `_The fixer left this unresolved._\n\n${unboxed(n.body)}` : n.unclear === true ? `_A fix turn removed this while renaming another finding — check whether it was fixed._\n\n${unboxed(n.body)}` : unboxed(n.body) || "_No detail given._")),
463
+ ...notes.map((n) => chunk(n.title, `${n.unresolved === true ? `_The fixer left this unresolved._\n\n${unboxed(n.body)}` : n.unclear === true ? `_A fix turn removed this while renaming another finding — check whether it was fixed._\n\n${unboxed(n.body)}` : unboxed(n.body) || "_No detail given._"}\n\n${answerBoxes}`)),
458
464
  ...report.fixed.length === 0 ? [] : [chunk("Fixed automatically", report.fixed.map((f) => `- ${f.title} (${short(f.commit)})`).join("\n"))],
459
465
  ...report.noise.length === 0 ? [] : [chunk("Dropped as noise", report.noise.map((f) => `- ${f.title} (p=${f.p})`).join("\n"))]
460
466
  ];
@@ -537,10 +543,6 @@ ${stateFileRules}
537
543
  ${footnoteFoldIn}${codeThreadReplies()}
538
544
  - The only state file this turn touches is \`.gtd/REQUIREMENTS.md\`
539
545
  — no other files for notes or output
540
- - Lines of a \`## Deferred review notes\` section of \`.gtd/TODO.md\`
541
- that the start diff does not touch are parked by review, never folded;
542
- the human's edits to such a line (or its removal) are input like any
543
- other part of the start diff
544
546
  - \`.gtd/TODO.md\` is the likely home of the sketch that started
545
547
  this process — the human's input, folded into the concerns
546
548
  below like any other part of the start diff; never a state
@@ -833,8 +835,12 @@ const buildReviewAwaitReviewMessage = (base) => `\`.gtd/REVIEW.md\` holds what t
833
835
  per finding that needs your judgement — it questions a criterion or the
834
836
  approach, or the fixer could not resolve it — then, for reference only, what
835
837
  was **fixed automatically** (with the fixing commit) and what was **dropped
836
- as noise**. Nothing there needs an answer; disagreeing with a fix or a drop
837
- means writing a note of your own.
838
+ as noise**. Each finding chunk ends in three boxes — tick exactly one:
839
+ \`fix as suggested\` (fixed in the nit batch below), \`won't fix\` (dropped,
840
+ recorded in the commit), or write your own answer over \`_your answer_\` and
841
+ tick it (judged like a note). Every landing here is refused until each
842
+ finding holds exactly one tick. The fixed and noise lists need no answer;
843
+ disagreeing with a fix or a drop means writing a note of your own.
838
844
 
839
845
  Review the diff yourself, with whatever tool you like — gtd checks
840
846
  nothing out and touches no ref; \`gtd base\` prints this same hash any
@@ -844,8 +850,8 @@ working tree:
844
850
  git diff ${base}
845
851
  When you've been through the whole diff, run \`gtd land\`:
846
852
 
847
- - **Sign off** — leave no comment — no note in
848
- \`.gtd/REVIEW.md\`, no code edit — to close the process.
853
+ - **Sign off** — answer every finding, leave no other comment — no note
854
+ in \`.gtd/REVIEW.md\`, no code edit — to close the process.
849
855
  Every turn commit stays on the
850
856
  branch; run \`gtd summary\` afterward for a closing-message
851
857
  prompt.
@@ -992,27 +998,6 @@ const wrapItem = (item) => {
992
998
  } else line = line === "" ? word : `${line} ${word}`;
993
999
  return [...out, line];
994
1000
  };
995
- /** Appends one section of `notes` to `.gtd/TODO.md`, wrapped the way oxfmt writes it; an existing file is only ever appended to. */
996
- const deferNotesScript = (notes, round) => {
997
- return `mkdir -p .gtd
998
- if [ -s .gtd/TODO.md ]; then
999
- [ -z "$(tail -c1 .gtd/TODO.md)" ] || printf '\\n' >> .gtd/TODO.md
1000
- printf '\\n' >> .gtd/TODO.md
1001
- fi
1002
- printf '%s\\n' ${[
1003
- `## Deferred review notes (${round.slice(0, 7)})`,
1004
- "",
1005
- ...notes.flatMap((n) => wrapItem(`- ${noteHead(n)}: ${n.text.replace(/\s+/g, " ")}`))
1006
- ].map(quote).join(" ")} >> .gtd/TODO.md
1007
- `;
1008
- };
1009
- /** The gate right after a deferral: landing clean signs off without these notes. */
1010
- const buildReviewAwaitAfterDeferMessage = (base, deferred) => `${buildReviewAwaitReviewMessage(base)}
1011
- Deferred to \`.gtd/TODO.md\` just now, NOT acted on in this process — landing
1012
- clean signs off without them:
1013
-
1014
- ${deferred.map((n) => `- ${noteHead(n)}: ${n.text.replace(/\s*\n\s*/g, " ")}`).join("\n")}
1015
- `;
1016
1001
  const buildReviewTriageMessage = () => `Judging each note the human added to \`.gtd/REVIEW.md\` (a line note, a
1017
1002
  footnote, or prose under a chunk) with one verdict: \`edit\` (a change
1018
1003
  request), \`question\`, \`nit\` (a small fix needing no re-plan) or
@@ -1022,16 +1007,18 @@ default; a skipped judgment never dismisses a note).
1022
1007
 
1023
1008
  Also judge each note's severity, one id per note: \`note-<n>.security\`,
1024
1009
  \`note-<n>.reversibility\`, \`note-<n>.blastRadius\` and
1025
- \`note-<n>.newConcept\`.
1010
+ \`note-<n>.newConcept\`. An own answer under a finding's boxes is judged
1011
+ the same way, keyed \`answer-<n>\`.
1026
1012
  `;
1027
1013
  const closedFold = (round, target) => round === "edits" ? `- Fold every hand-edit and every note the human left that round into
1028
- \`${target}\`
1014
+ \`${target}\` — a note ticked \`fix as suggested\` or answered in its
1015
+ own words included; one ticked \`won't fix\` is dropped
1029
1016
  ` : round === "notes" ? `- Fold each note that asks for a change into \`${target}\`
1030
1017
  ` : `- Fold each \`edit\` note the capture lists, no other note, and every
1031
1018
  hand-edit, into \`${target}\`
1032
1019
  `;
1033
1020
  /** `refactor` retargets the prompt at `.gtd/REFACTOR.md` and fences it to the strays. A concern outside the fence is not folded; if nothing at all is written the landing is refused, but a partly buildable round drops the rest silently. */
1034
- const buildReviewCollectingPrompt = (capture, refactor) => {
1021
+ const buildReviewCollectingPrompt = (capture, refactor, round) => {
1035
1022
  const target = refactor ? ".gtd/REFACTOR.md" : ".gtd/REQUIREMENTS.md";
1036
1023
  const fence = refactor ? `- The next refactor may change only these paths, plus an existing test
1037
1024
  file's call sites, and never a flag read:
@@ -1106,12 +1093,16 @@ ${refactor ? refactor.round === "reply" ? `- This turn replies inside \`.gtd/REV
1106
1093
  ${closedFold(refactor.round, target)}` : `- This turn writes \`${target}\` (the folded concerns) and
1107
1094
  replies inside \`.gtd/REVIEW.md\` (thread replies only; the file stays
1108
1095
  in the tree) — you classify, you do not build
1109
- - Fold every concluded thread, ticked answer and hand-edit — and each
1110
- \`edit\` note the capture lists, no other note — into
1111
- \`${target}\` and delete the folded threads from
1112
- \`.gtd/REVIEW.md\`; append one \`- A:\` reply to each thread
1113
- whose last entry is a \`- H:\` question
1114
- `}- When the capture names an answering commit, carry each answered
1096
+ - Fold every concluded thread and hand-edit — and each \`edit\` note
1097
+ the capture lists, no other note — into \`${target}\` and
1098
+ delete the folded threads from \`.gtd/REVIEW.md\`; append one \`- A:\`
1099
+ reply to each thread whose last entry is a \`- H:\` question
1100
+ ${round === "edits" ? `- A hand-edit round routes no answer box itself: fold each note
1101
+ ticked \`fix as suggested\` or answered in its own words, too; drop one
1102
+ ticked \`won't fix\`
1103
+ ` : `- Never fold a note's ticked answer box the capture does not list: gtd
1104
+ routes those itself
1105
+ `}`}- When the capture names an answering commit, carry each answered
1115
1106
  question into \`${target}\`'s \`## Answered Questions\` as
1116
1107
  \`### <the note>\` plus its answer — the lap replaces \`.gtd/REVIEW.md\`
1117
1108
  - Keep capture order, and carry each note's \`(tier: reason)\`
@@ -1829,11 +1820,9 @@ const skills = (vars) => {
1829
1820
  const access = (vars) => ({
1830
1821
  design: { write: [] },
1831
1822
  "build.review": { write: [".gtd/REVIEW.md"] },
1832
- "build.review.defer": { write: [".gtd/TODO.md"] },
1833
1823
  "build.review.fix.nits": {},
1834
1824
  "build.review.fix.risks": {},
1835
1825
  "refactor.build.review": { write: [".gtd/REVIEW.md"] },
1836
- "refactor.build.review.defer": { write: [".gtd/TODO.md"] },
1837
1826
  ...lensScopes("build.quality", lensesOf(vars.qualityReviews ?? ""), () => ({ write: [] })),
1838
1827
  ...lensScopes("refactor.build.quality", lensesOf(vars.refactorQualityReviews ?? ""), () => ({ write: [] }))
1839
1828
  });
@@ -1845,4 +1834,4 @@ const steering = {
1845
1834
  [REVIEW]: "review"
1846
1835
  };
1847
1836
  //#endregion
1848
- export { implementFixPrompt as $, agentWithSkills as A, registryEntry as At, coverageMessage as B, fixCheck as C, unwindFailure as Ct, reviewMissing as D, flagMentions as Dt, planner as E, testFileGlobs as Et, buildReviewTriageMessage as F, designGateAnswerMessage as G, coveringPrompt as H, builderSystem as I, finisherSystem as J, designSystem as K, clearCriteriaMessage as L, buildReviewAwaitAfterDeferMessage as M, buildReviewCollectingPrompt as N, reviewQuality as O, flagReferences as Ot, buildReviewFixNitsPrompt as P, implementBuildingPrompt as Q, clearCriteriaQuestion as R, coder as S, unwindFailedMessage as St, fixRisks as T, envDefaults as Tt, criterionLine as U, coverageQuestion as V, deferNotesScript as W, handOffAwaitReviewMessage as X, fixReviewNotesPrompt as Y, idleMessage as Z, REFACTOR_NOTE as _, seamByHandMessage as _t, flagModeOn as a, qualityTriageMessage as at, SLICES as b, startGateBlockedMessage as bt, baseline as c, refactorDroppedRefusal as ct, guarded as d, refactorRules as dt, naiveApplyFailure as et, healthy as f, reviewEditNotesCapture as ft, REFACTOR as g, reviewerSystem as gt, QUALITY as h, reviewNotesCapture as ht, skills as i, qualityFindingsFile as it, buildReviewAnswerQuestionsPrompt as j, requireFlag as jt, reviewing as k, flipProblems as kt, escalation as l, refactorFixPrompt as lt, HANDOFF as m, reviewGateBlockedMessage as mt, summary as n, normaliseCriteriaScript as nt, flagShipped as o, qualityUnresolvedMessage as ot, FEEDBACK as p, reviewEditsCapture as pt, designTriagePrompt as q, access as r, oversizedMessage as rt, shipped as s, refactorBuildingPrompt as st, steering as t, naivePassPrompt as tt, gate as u, refactorNotePrompt as ut, REQUIREMENTS as v, seamSteerMessage as vt, fixQuality as w, defaults as wt, awaitReview as x, uncoveredMessage as xt, REVIEW as y, slicingPrompt as yt, composedReview as z };
1837
+ export { naivePassPrompt as $, agentWithSkills as A, coverageQuestion as B, fixCheck as C, envDefaults as Ct, reviewMissing as D, flipProblems as Dt, planner as E, flagReferences as Et, builderSystem as F, designTriagePrompt as G, criterionLine as H, clearCriteriaMessage as I, handOffAwaitReviewMessage as J, finisherSystem as K, clearCriteriaQuestion as L, buildReviewCollectingPrompt as M, buildReviewFixNitsPrompt as N, reviewQuality as O, registryEntry as Ot, buildReviewTriageMessage as P, naiveApplyFailure as Q, composedReview as R, coder as S, defaults as St, fixRisks as T, flagMentions as Tt, designGateAnswerMessage as U, coveringPrompt as V, designSystem as W, implementBuildingPrompt as X, idleMessage as Y, implementFixPrompt as Z, REFACTOR_NOTE as _, slicingPrompt as _t, flagModeOn as a, refactorBuildingPrompt as at, SLICES as b, unwindFailedMessage as bt, baseline as c, refactorNotePrompt as ct, guarded as d, reviewEditsCapture as dt, normaliseCriteriaScript as et, healthy as f, reviewGateBlockedMessage as ft, REFACTOR as g, seamSteerMessage as gt, QUALITY as h, seamByHandMessage as ht, skills as i, qualityUnresolvedMessage as it, buildReviewAnswerQuestionsPrompt as j, reviewing as k, requireFlag as kt, escalation as l, refactorRules as lt, HANDOFF as m, reviewerSystem as mt, summary as n, qualityFindingsFile as nt, flagShipped as o, refactorDroppedRefusal as ot, FEEDBACK as p, reviewNotesCapture as pt, fixReviewNotesPrompt as q, access as r, qualityTriageMessage as rt, shipped as s, refactorFixPrompt as st, steering as t, oversizedMessage as tt, gate as u, reviewEditNotesCapture as ut, REQUIREMENTS as v, startGateBlockedMessage as vt, fixQuality as w, testFileGlobs as wt, awaitReview as x, unwindFailure as xt, REVIEW as y, uncoveredMessage as yt, coverageMessage as z };
@@ -13349,8 +13349,8 @@ const eolOf = (content) => content.includes("\r\n") ? "\r\n" : "\n";
13349
13349
  const PREFIX_RE$1 = /^(H|A):\s*/;
13350
13350
  const lineOf = (node, which) => toLspPosition(node.position[which]).line;
13351
13351
  /** The item's own blocks joined — never the item node's span, which can run past its last line into the next bullet's marker. */
13352
- const itemText = (content, item) => item.children.map((child) => sourceText(content, child)).join(" ").trim();
13353
- const prefixOf = (content, item) => PREFIX_RE$1.exec(itemText(content, item));
13352
+ const itemText$1 = (content, item) => item.children.map((child) => sourceText(content, child)).join(" ").trim();
13353
+ const prefixOf = (content, item) => PREFIX_RE$1.exec(itemText$1(content, item));
13354
13354
  /** A thread is a definition whose body OPENS with a list whose first item carries a prefix — anything else stays a one-shot footnote. */
13355
13355
  const isThreadBody = (content, children) => {
13356
13356
  const first = children[0];
@@ -13370,7 +13370,7 @@ const entryOf = (content, item, previous, report) => {
13370
13370
  return;
13371
13371
  }
13372
13372
  const author = match[1] === "H" ? "me" : "agent";
13373
- const text = itemText(content, item).slice(match[0].length).trim();
13373
+ const text = itemText$1(content, item).slice(match[0].length).trim();
13374
13374
  if (text === "") report(`empty "${match[1]}:" entry — write your own words there`, start, end);
13375
13375
  if (previous === void 0 && author === "agent") report(`the agent never starts a thread — the first entry must be "H:"`, start, end);
13376
13376
  else if (previous === author) report(`two consecutive "${match[1]}:" entries — entries must alternate`, start, end);
@@ -15086,6 +15086,9 @@ const qaDescriptor = {
15086
15086
  };
15087
15087
  //#endregion
15088
15088
  //#region src/steering/review.ts
15089
+ const ANSWER_LEAD = "Tick exactly one:";
15090
+ const ANSWER_FIX = "fix as suggested";
15091
+ const ANSWER_WONT_FIX = "won't fix";
15089
15092
  /**
15090
15093
  * The canonical sample. Every detail here is load-bearing for the formatter
15091
15094
  * round-trip: both footnote bodies exceed 80 characters so a reflow actually
@@ -15352,16 +15355,42 @@ const parseHunk = (content, lines, title, item) => {
15352
15355
  errors: [hunkSecondPointerFinding(content, lines, item, token, sourceLine, title, file), hunkLineWithoutRangeFinding(content, item, token, sourceLine, title, file)].filter((e) => e !== void 0)
15353
15356
  };
15354
15357
  };
15358
+ const itemText = (content, item) => item.children.map((c) => sourceText(content, c)).join(" ").replace(/^\[[ xX]\]\s*/, "").trim();
15359
+ /**
15360
+ * The task list right after the `Tick exactly one:` paragraph, with that
15361
+ * paragraph's index. Read by position, never by label, so an edited label or
15362
+ * a free text opening with `./path` is still an answer, not a pointer. Any
15363
+ * count but three boxes is kept, and reads as unanswered.
15364
+ */
15365
+ const answerListOf = (content, body) => {
15366
+ const lead = body.findIndex((n, i) => n.type === "paragraph" && sourceText(content, n) === "Tick exactly one:" && body[i + 1]?.type === "list" && body[i + 1].children.every((item) => item.checked != null));
15367
+ if (lead === -1) return void 0;
15368
+ const list = body[lead + 1];
15369
+ const last = list.children.length - 1;
15370
+ return {
15371
+ lead,
15372
+ list,
15373
+ options: list.children.map((item, i) => {
15374
+ const text = itemText(content, item);
15375
+ return {
15376
+ checked: item.checked === true,
15377
+ text: i === last && text.toLowerCase() === "_your answer_".toLowerCase() ? "" : text
15378
+ };
15379
+ })
15380
+ };
15381
+ };
15355
15382
  /** Splits one chunk's body nodes into its file pointers (hunk pointers are task items, collected recursively at ANY nesting depth via `taskItems` — a nested hunk is the same kind of hunk as a top-level one) and description prose. The description is the chunk's own leading run and never a node that contains a hunk pointer: it stops at the first node whose `taskItems` include a real pointer (at any depth — a blockquote wrapping a pointer list stops it just as a top-level list would). Every node kind in what's left before that point contributes — a `footnoteDefinition` is the one exception, excluded by `blockNodesOfRun` itself (it's a note, never document content). */
15356
15383
  const parseChunkBody = (content, lines, title, body) => {
15357
- const firstPointerIndex = body.findIndex((n) => taskItems(n).some((item) => hasPointerToken(content, item)));
15358
- const leadingRun = firstPointerIndex === -1 ? body : body.slice(0, firstPointerIndex);
15384
+ const answer = answerListOf(content, body);
15385
+ const isAnswer = (n) => answer !== void 0 && (n === answer.list || n === body[answer.lead]);
15386
+ const firstPointerIndex = body.findIndex((n) => !isAnswer(n) && taskItems(n).some((item) => hasPointerToken(content, item)));
15387
+ const leadingRun = (firstPointerIndex === -1 ? body : body.slice(0, firstPointerIndex)).filter((n) => !isAnswer(n));
15359
15388
  const descriptionNodes = blockNodesOfRun(content, leadingRun);
15360
15389
  const descriptionInline = collapseImages(blockRunInline(content, leadingRun));
15361
15390
  const description = leadingRun.filter((n) => n.type !== "footnoteDefinition").map((n) => sourceText(content, n)).join(" ").trim();
15362
15391
  const files = [];
15363
15392
  const errors = [];
15364
- for (const item of body.flatMap((n) => taskItems(n))) {
15393
+ for (const item of body.filter((n) => !isAnswer(n)).flatMap((n) => taskItems(n))) {
15365
15394
  const parsed = parseHunk(content, lines, title, item);
15366
15395
  if (!parsed) continue;
15367
15396
  files.push(parsed.file);
@@ -15372,7 +15401,9 @@ const parseChunkBody = (content, lines, title, body) => {
15372
15401
  descriptionNodes,
15373
15402
  descriptionInline,
15374
15403
  files,
15375
- errors
15404
+ errors,
15405
+ ...answer === void 0 ? {} : { answers: answer.options },
15406
+ boxed: body.some((n) => !isAnswer(n) && taskItems(n).length > 0)
15376
15407
  };
15377
15408
  };
15378
15409
  /** Splits the tree's top-level nodes into consecutive `##` chunk blocks — a `## ` line found only inside a fence never becomes a heading node at all, so it's never mistaken for a chunk. */
@@ -15408,9 +15439,9 @@ const parseChangesets = (tree, content, lines) => {
15408
15439
  const errors = [];
15409
15440
  for (const { heading, headingLine, body } of splitChunks(tree)) {
15410
15441
  const title = headingText$1(content, heading);
15411
- const { description, descriptionNodes, descriptionInline, files, errors: bodyErrors } = parseChunkBody(content, lines, title, body);
15442
+ const { description, descriptionNodes, descriptionInline, files, answers, boxed, errors: bodyErrors } = parseChunkBody(content, lines, title, body);
15412
15443
  errors.push(...bodyErrors);
15413
- if (files.length === 0 && body.some((n) => taskItems(n).length > 0)) errors.push({
15444
+ if (files.length === 0 && boxed) errors.push({
15414
15445
  message: `Chunk "${title}" has no file pointers`,
15415
15446
  line: headingLine,
15416
15447
  range: nodeRange(heading)
@@ -15421,7 +15452,8 @@ const parseChangesets = (tree, content, lines) => {
15421
15452
  descriptionNodes,
15422
15453
  descriptionInline,
15423
15454
  files,
15424
- headingLine
15455
+ headingLine,
15456
+ ...answers === void 0 ? {} : { answers }
15425
15457
  });
15426
15458
  }
15427
15459
  if (changesets.length === 0) errors.push({ message: "REVIEW.md has no '##' chunks" });
@@ -15502,7 +15534,9 @@ const isHunkPointerItem = (content, item) => {
15502
15534
  * clear, so a caller can skip the write and leave the mtime untouched.
15503
15535
  */
15504
15536
  const reviewClearTicks = (content) => {
15505
- const offsets = taskItems(parseMarkdown(content)).filter((item) => item.checked === true && isHunkPointerItem(content, item)).map((item) => checkboxOffset(content, item)).filter((o) => o !== void 0).sort((a, b) => a - b);
15537
+ const tree = parseMarkdown(content);
15538
+ const answers = new Set(splitChunks(tree).flatMap(({ body }) => answerListOf(content, body)?.list.children ?? []));
15539
+ const offsets = taskItems(tree).filter((item) => item.checked === true && !answers.has(item) && isHunkPointerItem(content, item)).map((item) => checkboxOffset(content, item)).filter((o) => o !== void 0).sort((a, b) => a - b);
15506
15540
  if (offsets.length === 0) return content;
15507
15541
  let result = "";
15508
15542
  let cursor = 0;
@@ -15936,6 +15970,55 @@ const reviewRisks = (content) => parseReviewDoc(content).changesets.flatMap((chu
15936
15970
  id: `risk-${i + 1}`,
15937
15971
  ...r
15938
15972
  }));
15973
+ const CHOICES = [
15974
+ "fix",
15975
+ "wontfix",
15976
+ "answer"
15977
+ ];
15978
+ const decisionOf = (chunk) => {
15979
+ if (chunk.answers?.length !== 3) return void 0;
15980
+ const ticked = chunk.answers.flatMap((o, i) => o.checked ? [i] : []);
15981
+ if (ticked.length !== 1) return void 0;
15982
+ const i = ticked[0];
15983
+ const text = i === 2 ? chunk.answers[2].text : "";
15984
+ if (i === 2 && text === "") return void 0;
15985
+ return {
15986
+ anchor: chunk.title,
15987
+ body: chunk.description,
15988
+ choice: CHOICES[i],
15989
+ text
15990
+ };
15991
+ };
15992
+ /**
15993
+ * Notes whose answer boxes do not hold exactly one tick (a ticked free-text
15994
+ * slot filled in), with their 1-based heading line and why; and, given the
15995
+ * file `before` the edit, every note whose boxes were deleted.
15996
+ */
15997
+ const unansweredNotes = (content, before) => {
15998
+ const now = parseReviewDoc(content).changesets;
15999
+ const boxed = new Set(now.filter((c) => c.answers !== void 0).map((c) => c.title));
16000
+ const removed = (before === void 0 ? [] : parseReviewDoc(before).changesets).filter((c) => c.answers !== void 0 && !boxed.has(c.title)).map((c) => ({
16001
+ title: c.title,
16002
+ line: 0,
16003
+ reason: "its answer boxes were deleted — restore them"
16004
+ }));
16005
+ return [...now.filter((c) => c.answers !== void 0 && decisionOf(c) === void 0).map((c) => ({
16006
+ title: c.title,
16007
+ line: c.headingLine + 1,
16008
+ reason: c.answers.length === 3 ? "tick exactly one box" : `it has ${c.answers.length} boxes — restore the three gtd wrote`
16009
+ })), ...removed];
16010
+ };
16011
+ /** Every answer in `after` that `before` did not already hold for the same note, in document order. */
16012
+ const reviewDecisions = (before, after) => {
16013
+ const prior = new Map(parseReviewDoc(before).changesets.map((c) => [c.title, c]));
16014
+ return parseReviewDoc(after).changesets.flatMap((chunk) => {
16015
+ const decision = decisionOf(chunk);
16016
+ if (decision === void 0) return [];
16017
+ const was = prior.get(chunk.title);
16018
+ const old = was === void 0 ? void 0 : decisionOf(was);
16019
+ return old?.choice === decision.choice && old.text === decision.text ? [] : [decision];
16020
+ });
16021
+ };
15939
16022
  //#endregion
15940
16023
  //#region src/steering/Edits.ts
15941
16024
  /** Splices `edits` into `content`, sorted last-to-first so an earlier range's offset is never invalidated by a later edit. Positions are 0-based line/character over `\n`-split lines. */
@@ -16304,4 +16387,4 @@ const viewOf = (format, content) => ({
16304
16387
  /** Every OPEN thread — last entry is the agent's — as `{ name, line, firstMe }` (0-based line). `[]` for a format with no thread concept. */
16305
16388
  const openThreads = (format, content) => format.openThreads?.(content) ?? [];
16306
16389
  //#endregion
16307
- export { headingSections as C, headingSectionBodies as S, openThreadFindings as _, unansweredQuestions as a, parseThreadsWithFindings as b, stripCodeThreads as c, reviewNotes as d, reviewRisks as f, THREAD_REPLY_ACTION_TITLE as g, FOOTNOTE_ACTION_TITLE as h, steeringFormatOrFreeForm as i, freeFormFormat as l, isAnswered as m, openThreads as n, viewOf as o, FREE_TEXT_PLACEHOLDER as p, steeringFormatFor as r, parseCodeThreads as s, BUILT_IN_MODE_NAMES as t, applySteeringEdits as u, parseFootnotes as v, formatFinding as w, getParseCount as x, parseThreads as y };
16390
+ export { parseFootnotes as C, headingSectionBodies as D, getParseCount as E, headingSections as O, openThreadFindings as S, parseThreadsWithFindings as T, unansweredNotes as _, unansweredQuestions as a, FOOTNOTE_ACTION_TITLE as b, stripCodeThreads as c, ANSWER_FIX as d, ANSWER_LEAD as f, reviewRisks as g, reviewNotes as h, steeringFormatOrFreeForm as i, formatFinding as k, freeFormFormat as l, reviewDecisions as m, openThreads as n, viewOf as o, ANSWER_WONT_FIX as p, steeringFormatFor as r, parseCodeThreads as s, BUILT_IN_MODE_NAMES as t, applySteeringEdits as u, FREE_TEXT_PLACEHOLDER as v, parseThreads as w, THREAD_REPLY_ACTION_TITLE as x, isAnswered as y };
@@ -1,6 +1,6 @@
1
1
  import { answered, head, judge, mark, numeric, read, refuse, removeScript, run, scope, sectionBodies, showScript, vars, writeScript } from "../flows.mjs";
2
2
  import { fixFindings, lensEntriesOf, lensOf, qualityLap as qualityLap$1, reviewToHandOff } from "../workflow-kit.mjs";
3
- import { C as fixCheck, D as reviewMissing, O as reviewQuality, T as fixRisks, X as handOffAwaitReviewMessage, at as qualityTriageMessage, d as guarded, f as healthy, h as QUALITY, it as qualityFindingsFile, k as reviewing, l as escalation, w as fixQuality, x as awaitReview, y as REVIEW } from "./reserved-D7MNY9QJ.mjs";
3
+ import { C as fixCheck, D as reviewMissing, J as handOffAwaitReviewMessage, O as reviewQuality, T as fixRisks, d as guarded, f as healthy, h as QUALITY, k as reviewing, l as escalation, nt as qualityFindingsFile, rt as qualityTriageMessage, w as fixQuality, x as awaitReview, y as REVIEW } from "./reserved-COEMFEpy.mjs";
4
4
  //#region src/workflows/shared/lenses.ts
5
5
  const NO_FINDINGS = {
6
6
  notes: [],