@pmelab/gtd 44.0.0 → 45.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "44.0.0",
3
+ "version": "45.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
@@ -564,6 +564,22 @@ noted in steps 3 and 4 below.
564
564
  `gtd judge answer` / conservative-default shape as step 3's own judged
565
565
  points.
566
566
 
567
+ **The review learns your taste.** Each won't-fix and each own answer is a
568
+ candidate standard. A judge (`build.review.standards.judge`) asks whether it
569
+ is reusable beyond this note; at `standardMinP` (`0.8`) or above it is kept
570
+ in `.gtd/STANDARDS.md` with no question asked, and the next review lists it
571
+ under `## Standards added`. Every later agent turn — the lenses, the naive
572
+ pass, the build, every fix — is handed the kept standards, so a finding you
573
+ rejected once is not raised again and a rule you wrote is followed, in this
574
+ process and every later one. One the judge does not confidently rule out
575
+ contradicting a standard already kept is not applied: it waits under
576
+ `## Undecided` and becomes a `## Standard conflict <id>` note in the review,
577
+ with its own three boxes — **keep the new one (drop the old)**, **keep the
578
+ old one (discard the new)**, or **your own wording**, which replaces both —
579
+ and the review does not sign off until it is answered. `.gtd/STANDARDS.md` is
580
+ committed and yours to edit; gtd rewrites it as one bullet per standard when
581
+ it learns.
582
+
567
583
  You never talk to it. Every exchange is a file in `.gtd/` that you edit in your
568
584
  own editor, and every answer you give is a commit. Your test suite is the gate
569
585
  throughout: a red baseline stops the process before it starts, and a red build
@@ -113,6 +113,108 @@ const requireFlag = (workflow, registryPath, name, defaultOn) => {
113
113
  if (entry.defaultOn !== defaultOn) return refuse(defaultOn ? `gtd: ${workflow}: flag "${name}" is not default-on — roll it out first` : `gtd: ${workflow}: flag "${name}" is already default-on`);
114
114
  };
115
115
  //#endregion
116
+ //#region src/workflows/shared/standards.ts
117
+ const STANDARDS = ".gtd/STANDARDS.md";
118
+ const UNDECIDED = "Undecided";
119
+ const oneLine = (text) => text.replace(/[\s\x00-\x1f\x7f-\x9f\u2028\u2029]+/g, " ").trim();
120
+ const ITEM = /^(?:[-*+]|\d+[.)])\s+(.*)$/;
121
+ const CONTRADICTS = /^\s+[-*+]\s+contradicts:\s*(.*)$/i;
122
+ const wrapped = (item, line) => {
123
+ const olds = item.olds;
124
+ if (olds !== void 0 && olds.length > 0) olds[olds.length - 1] += ` ${line.trim()}`;
125
+ else item.text += ` ${line.trim()}`;
126
+ };
127
+ /** What tells two standards apart: not the emphasis or spacing a formatter may rewrite. */
128
+ const standardKey = (text) => oneLine(text.replace(/[*_`\\]/g, "")).toLowerCase();
129
+ const toStandards = (items) => {
130
+ const seen = /* @__PURE__ */ new Set();
131
+ const fresh = (text) => {
132
+ const key = standardKey(text);
133
+ if (key === "" || seen.has(key)) return false;
134
+ seen.add(key);
135
+ return true;
136
+ };
137
+ const decided = items.filter((i) => i.olds === void 0).map((i) => oneLine(i.text));
138
+ const undecided = items.flatMap((i) => i.olds === void 0 ? [] : [{
139
+ text: oneLine(i.text),
140
+ olds: i.olds.map(oneLine).filter((o) => o !== "")
141
+ }]);
142
+ return {
143
+ decided: decided.filter(fresh),
144
+ undecided: undecided.filter((c) => fresh(c.text))
145
+ };
146
+ };
147
+ /**
148
+ * Every top-level bullet is one standard, those under `## Undecided` waiting
149
+ * on the human; a hand-edited file may group the rest under any heading.
150
+ */
151
+ const parseStandards = (text) => {
152
+ const items = [];
153
+ let pending = false;
154
+ let last;
155
+ for (const line of text.split("\n")) {
156
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
157
+ const item = ITEM.exec(line);
158
+ const old = CONTRADICTS.exec(line);
159
+ if (heading !== null) {
160
+ pending = heading[1].length > 1 && heading[2].trim() === UNDECIDED;
161
+ last = void 0;
162
+ } else if (item !== null) {
163
+ last = pending ? {
164
+ text: item[1],
165
+ olds: []
166
+ } : { text: item[1] };
167
+ items.push(last);
168
+ } else if (old !== null && last?.olds !== void 0) last.olds.push(old[1]);
169
+ else if (/^\s+\S/.test(line) && last !== void 0) wrapped(last, line);
170
+ else if (line.trim() !== "") last = void 0;
171
+ }
172
+ return toStandards(items);
173
+ };
174
+ const renderStandards = ({ decided, undecided }) => {
175
+ return `${[
176
+ "# Standards",
177
+ "Kept from your review answers for every agent turn. One standard per bullet.",
178
+ ...decided.length === 0 ? [] : [decided.map((s) => `- ${s}`).join("\n")],
179
+ ...undecided.length === 0 ? [] : [`## ${UNDECIDED}`, undecided.map((c) => [`- ${c.text}`, ...c.olds.map((o) => ` - contradicts: ${o}`)].join("\n")).join("\n")]
180
+ ].join("\n\n")}\n`;
181
+ };
182
+ const hash = (raw) => {
183
+ const text = standardKey(raw);
184
+ let h = 2166136261;
185
+ for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619);
186
+ return (h >>> 0).toString(16).padStart(8, "0");
187
+ };
188
+ const CONFLICT_TITLE = /^Standard conflict ([0-9a-f]{8})$/;
189
+ const conflictTitle = (c) => `Standard conflict ${hash(c.text)}`;
190
+ const conflictFor = (standards, title) => {
191
+ const id = CONFLICT_TITLE.exec(title)?.[1];
192
+ return id === void 0 ? void 0 : standards.undecided.find((c) => hash(c.text) === id);
193
+ };
194
+ const isConflictTitle = (title) => CONFLICT_TITLE.test(title);
195
+ const KEEP_NEW = "keep the new one (drop the old)";
196
+ const KEEP_OLD = "keep the old one (discard the new)";
197
+ /** A conflict's chunk body; its three boxes read as fix / won't fix / own answer by position. */
198
+ const conflictBody = (c) => [...c.olds.length === 0 ? [`A standard learned from your answers contradicted ones another answer since replaced in \`${STANDARDS}\`. Keep it, discard it, or reword it.`, `New: ${c.text}`] : [
199
+ `A standard learned from your answers contradicts ${c.olds.length === 1 ? "one" : "some"} already in \`${STANDARDS}\`. Your own answer replaces both with its wording.`,
200
+ `New: ${c.text}`,
201
+ `Old:\n\n${c.olds.map((o) => `- ${o}`).join("\n")}`
202
+ ], `${ANSWER_LEAD}\n\n- [ ] ${KEEP_NEW}\n- [ ] ${KEEP_OLD}\n- [ ] ${FREE_TEXT_PLACEHOLDER}`].join("\n\n");
203
+ /** What every agent turn is told while `.gtd/STANDARDS.md` holds decided standards. */
204
+ const standardsBlock = (text) => {
205
+ const { decided } = parseStandards(text ?? "");
206
+ if (decided.length === 0) return "";
207
+ return `## Standards
208
+
209
+ \`${STANDARDS}\` holds the standards the human set in earlier reviews.
210
+ Follow every one in what you write. Reviewing, never raise a finding a
211
+ standard rules out — a \`Won't fix:\` standard is a finding the human
212
+ rejected, so never raise it or one like it again — and raise breaking a
213
+ standard as a finding.
214
+
215
+ ${decided.map((s) => `- ${s}`).join("\n")}`;
216
+ };
217
+ //#endregion
116
218
  //#region src/workflows/shared/vars.ts
117
219
  /** The refactor guard's fixed list, and `seamAllow`'s default minus `.gtd/**`. */
118
220
  const testFileGlobs = [
@@ -132,6 +234,7 @@ const defaults = {
132
234
  reviewNoteActionable: "0.7",
133
235
  reviewSeverityMinP: "0.7",
134
236
  qualityTriageMinP: "0.7",
237
+ standardMinP: "0.8",
135
238
  refactorFlagPattern: "\\bGTD_FLAG_\\w+",
136
239
  seamAllow: [...testFileGlobs, ".gtd/**"].join(", "),
137
240
  flagMode: "true",
@@ -391,8 +494,11 @@ const withSkills = (skills, prompt) => {
391
494
  };
392
495
  /** An `agent()` step whose prompt opens with the preamble naming its scope's skills, then its restricted file access. */
393
496
  const agentWithSkills = (name, prompt, options = {}) => {
394
- const access = accessPreamble(accessFor(name, options.file));
395
- const body = access === "" ? prompt : `${access}\n\n${prompt}`;
497
+ const body = [
498
+ accessPreamble(accessFor(name, options.file)),
499
+ prompt,
500
+ standardsBlock(read(STANDARDS))
501
+ ].filter((part) => part !== "").join("\n\n");
396
502
  return agent(name, withSkills(skillsFor(name).join(", "), body), options);
397
503
  };
398
504
  const unwindFailure = (commit) => `gtd could not unwind ${commit} out of your working tree.`;
@@ -432,6 +538,7 @@ const SUMMARY_TITLES = [
432
538
  "Weakened scenarios",
433
539
  "Fixed from your notes",
434
540
  "Fixed automatically",
541
+ "Standards added",
435
542
  "Dropped as noise",
436
543
  "Nothing to note"
437
544
  ];
@@ -452,6 +559,8 @@ const composedReview = (base, report) => {
452
559
  };
453
560
  const chunk = (title, body) => `## ${title}\n\n${body.trim()}`;
454
561
  const answerBoxes = `${ANSWER_LEAD}\n\n- [ ] ${ANSWER_FIX}\n- [ ] ${ANSWER_WONT_FIX}\n- [ ] ${FREE_TEXT_PLACEHOLDER}`;
562
+ const conflicts = (report.standards?.undecided ?? []).map((c) => chunk(unique(conflictTitle(c)), conflictBody(c)));
563
+ const added = report.standards?.added ?? [];
455
564
  const notes = report.notes.map((n) => ({
456
565
  ...n,
457
566
  title: unique(n.title)
@@ -460,8 +569,10 @@ const composedReview = (base, report) => {
460
569
  const chunks = [
461
570
  ...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.`)],
462
571
  ...weakened.length === 0 ? [] : [chunk("Weakened scenarios", `These scenarios lost an assertion the review base had:\n\n${weakened.join("\n")}`)],
572
+ ...conflicts,
463
573
  ...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}`)),
464
574
  ...report.fixed.length === 0 ? [] : [chunk("Fixed automatically", report.fixed.map((f) => `- ${f.title} (${short(f.commit)})`).join("\n"))],
575
+ ...added.length === 0 ? [] : [chunk("Standards added", `Learned from your answers and kept in \`${STANDARDS}\`:\n\n${added.map((s) => `- ${s}`).join("\n")}`)],
465
576
  ...report.noise.length === 0 ? [] : [chunk("Dropped as noise", report.noise.map((f) => `- ${f.title} (p=${f.p})`).join("\n"))]
466
577
  ];
467
578
  const body = chunks.length > 0 ? chunks : [chunk("Nothing to note", "No quality lens raised anything for you — or none ran. Review the diff yourself.")];
@@ -1765,6 +1876,7 @@ const ShippedOptions = Struct({
1765
1876
  reviewNoteActionable: setting,
1766
1877
  reviewSeverityMinP: setting,
1767
1878
  qualityTriageMinP: setting,
1879
+ standardMinP: setting,
1768
1880
  refactorFlagPattern: setting,
1769
1881
  seamAllow: setting,
1770
1882
  sliceMaxCriteria: setting,
@@ -1874,4 +1986,4 @@ const steering = {
1874
1986
  [REVIEW]: "review"
1875
1987
  };
1876
1988
  //#endregion
1877
- export { naiveApplyFailure as $, reviewMissing as A, registryEntry as At, coverageMessage as B, coder as C, unwindFailure as Ct, fixQuality as D, flagMentions as Dt, fixNits as E, testFileGlobs as Et, buildReviewTriageMessage as F, designSystem as G, coveringPrompt as H, builderSystem as I, fixReviewNotesPrompt as J, designTriagePrompt as K, clearCriteriaMessage as L, reviewing as M, agentWithSkills as N, fixRisks as O, flagReferences as Ot, buildReviewCollectingPrompt as P, implementFixPrompt as Q, clearCriteriaQuestion as R, awaitReview as S, unwindFailedMessage as St, fixCheck as T, envDefaults as Tt, criterionLine as U, coverageQuestion as V, designGateAnswerMessage as W, idleMessage as X, handOffAwaitReviewMessage as Y, implementBuildingPrompt as Z, REFACTOR_NOTE as _, seamByHandMessage as _t, flagModeOn as a, qualityUnresolvedMessage as at, SLICES as b, startGateBlockedMessage as bt, baseline as c, refactorFixPrompt as ct, guarded as d, reviewEditNotesCapture as dt, naivePassPrompt as et, healthy as f, reviewEditsCapture as ft, REFACTOR as g, reviewerSystem as gt, QUALITY as h, reviewNotesCapture as ht, skills as i, qualityTriageMessage as it, reviewQuality as j, requireFlag as jt, planner as k, flipProblems as kt, escalation as l, refactorNotePrompt as lt, HANDOFF as m, reviewGateBlockedMessage as mt, summary as n, oversizedMessage as nt, flagShipped as o, refactorBuildingPrompt as ot, FEEDBACK 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, gate as u, refactorRules as ut, REQUIREMENTS as v, seamSteerMessage as vt, collecting as w, defaults as wt, answerReviewQuestions as x, uncoveredMessage as xt, REVIEW as y, slicingPrompt as yt, composedReview as z };
1989
+ export { naiveApplyFailure as $, reviewMissing as A, conflictTitle as At, coverageMessage as B, requireFlag as Bt, coder as C, unwindFailure as Ct, fixQuality as D, STANDARDS as Dt, fixNits as E, testFileGlobs as Et, buildReviewTriageMessage as F, standardKey as Ft, designSystem as G, coveringPrompt as H, builderSystem as I, flagMentions as It, fixReviewNotesPrompt as J, designTriagePrompt as K, clearCriteriaMessage as L, flagReferences as Lt, reviewing as M, oneLine as Mt, agentWithSkills as N, parseStandards as Nt, fixRisks as O, conflictBody as Ot, buildReviewCollectingPrompt as P, renderStandards as Pt, implementFixPrompt as Q, clearCriteriaQuestion as R, flipProblems as Rt, awaitReview as S, unwindFailedMessage as St, fixCheck as T, envDefaults as Tt, criterionLine as U, coverageQuestion as V, designGateAnswerMessage as W, idleMessage as X, handOffAwaitReviewMessage as Y, implementBuildingPrompt as Z, REFACTOR_NOTE as _, seamByHandMessage as _t, flagModeOn as a, qualityUnresolvedMessage as at, SLICES as b, startGateBlockedMessage as bt, baseline as c, refactorFixPrompt as ct, guarded as d, reviewEditNotesCapture as dt, naivePassPrompt as et, healthy as f, reviewEditsCapture as ft, REFACTOR as g, reviewerSystem as gt, QUALITY as h, reviewNotesCapture as ht, skills as i, qualityTriageMessage as it, reviewQuality as j, isConflictTitle as jt, planner as k, conflictFor as kt, escalation as l, refactorNotePrompt as lt, HANDOFF as m, reviewGateBlockedMessage as mt, summary as n, oversizedMessage as nt, flagShipped as o, refactorBuildingPrompt as ot, FEEDBACK 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, gate as u, refactorRules as ut, REQUIREMENTS as v, seamSteerMessage as vt, collecting as w, defaults as wt, answerReviewQuestions as x, uncoveredMessage as xt, REVIEW as y, slicingPrompt as yt, composedReview as z, registryEntry as zt };
@@ -1,7 +1,159 @@
1
- import { answered, changesSince, head, judge, numeric, requireRevert, restoreScript, run, showScript, vars, writeScript } from "../flows.mjs";
1
+ import { answered, changesSince, head, judge, numeric, read, requireRevert, restoreScript, run, showScript, vars, writeScript } from "../flows.mjs";
2
2
  import { removedAssertions, review } from "../workflow-kit.mjs";
3
- import { A as reviewMissing, E as fixNits, F as buildReviewTriageMessage, O as fixRisks, S as awaitReview, T as fixCheck, d as guarded, dt as reviewEditNotesCapture, f as healthy, ft as reviewEditsCapture, h as QUALITY, ht as reviewNotesCapture, w as collecting, x as answerReviewQuestions, y as REVIEW, z as composedReview } from "./reserved-DJk_6-ju.mjs";
4
- import { i as isOptionalObject, n as closeReview, o as tail, s as NO_FINDINGS } from "./tail-5w5mxGTq.mjs";
3
+ import { A as reviewMissing, At as conflictTitle, Dt as STANDARDS, E as fixNits, F as buildReviewTriageMessage, Ft as standardKey, Mt as oneLine, Nt as parseStandards, O as fixRisks, Ot as conflictBody, Pt as renderStandards, S as awaitReview, T as fixCheck, d as guarded, dt as reviewEditNotesCapture, f as healthy, ft as reviewEditsCapture, h as QUALITY, ht as reviewNotesCapture, jt as isConflictTitle, kt as conflictFor, w as collecting, x as answerReviewQuestions, y as REVIEW, z as composedReview } from "./reserved-ty1mtlnS.mjs";
4
+ import { i as isOptionalObject, n as closeReview, o as tail, s as NO_FINDINGS } from "./tail-CM3sVoAb.mjs";
5
+ //#region src/workflows/shared/learn.ts
6
+ const SUMMARY_MAX = 200;
7
+ 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*/;
8
+ const summary = (body) => {
9
+ const text = oneLine(body).replace(BANNER, "");
10
+ return text.length > SUMMARY_MAX ? `${text.slice(0, SUMMARY_MAX - 1)}…` : text;
11
+ };
12
+ /** The standard a won't-fix or an own answer would make; a fix states none. */
13
+ const candidateOf = (d) => {
14
+ const anchor = oneLine(d.anchor);
15
+ if (d.choice === "wontfix") {
16
+ const why = summary(d.body);
17
+ return [{
18
+ decision: d,
19
+ text: `Won't fix: ${anchor}${why === "" ? "" : ` — ${why}`}`
20
+ }];
21
+ }
22
+ if (d.choice === "answer") return [{
23
+ decision: d,
24
+ text: `${oneLine(d.text)} (re: ${anchor})`
25
+ }];
26
+ return [];
27
+ };
28
+ const without = (list, drop) => {
29
+ const keys = new Set(drop.map(standardKey));
30
+ return list.filter((s) => !keys.has(standardKey(s)));
31
+ };
32
+ const adding = (list, text) => list.some((s) => standardKey(s) === standardKey(text)) ? [...list] : [...list, text];
33
+ /**
34
+ * Keep the new one, keep the old one, or replace both with the human's own
35
+ * wording. Another open conflict stops naming a replaced standard: the human
36
+ * never judged it against the replacement, so keeping it must not drop that.
37
+ */
38
+ const resolve = (standards, d) => {
39
+ const c = conflictFor(standards, d.anchor);
40
+ if (c === void 0) return standards;
41
+ const others = standards.undecided.filter((u) => u !== c);
42
+ if (d.choice === "wontfix") return {
43
+ decided: standards.decided,
44
+ undecided: others
45
+ };
46
+ const kept = d.choice === "fix" ? c.text : oneLine(d.text);
47
+ const undecided = others.map((u) => ({
48
+ text: u.text,
49
+ olds: without(u.olds, c.olds)
50
+ }));
51
+ return {
52
+ decided: adding(without(standards.decided, c.olds), kept),
53
+ undecided
54
+ };
55
+ };
56
+ const contradicts = (i, id, other) => ({
57
+ id: `standard-${i}.contradicts-${id}`,
58
+ primitive: "noul",
59
+ instructions: `Does the "Standard:" line of evidence "standard-${i}" contradict ${other}?`,
60
+ criteria: "yes: following one breaks the other. no: both can be followed — they are compatible or unrelated."
61
+ });
62
+ const questionsFor = (i, anchor, decided) => [
63
+ {
64
+ id: `standard-${i}`,
65
+ primitive: "noul",
66
+ instructions: `Is the human's answer to review note "${anchor}" (evidence "standard-${i}") a reusable standard for this codebase — a rule later changes and reviews should follow — rather than a call about this one note? Its "Standard:" line is the wording gtd would keep.`,
67
+ criteria: "yes: it states a rule or preference that applies beyond this note. no: it is specific to this note, a one-off call, or unclear."
68
+ },
69
+ ...decided.map((_, k) => contradicts(i, `${k + 1}`, `kept standard ${k + 1} (evidence "kept-${k + 1}")`)),
70
+ ...Array.from({ length: i - 1 }, (_, j) => contradicts(i, `new-${j + 1}`, `the "Standard:" line of evidence "standard-${j + 1}"`))
71
+ ];
72
+ const evidenceOf = (c) => [
73
+ `Note: ${c.decision.anchor}`,
74
+ `Reviewer's text: ${c.decision.body}`,
75
+ `Human's answer: ${c.decision.choice === "wontfix" ? "won't fix" : c.decision.text}`,
76
+ `Standard: ${c.text}`
77
+ ].join("\n");
78
+ /**
79
+ * One judge rest over every candidate. Only a confident `yes` on uncut
80
+ * evidence makes a standard; a contradiction the judge does not confidently
81
+ * rule out goes to the human instead of overwriting what they decided before.
82
+ * A cut kept standard leaves the round unlearned: raise `judgeBudgetBytes`.
83
+ */
84
+ const judged = async (candidates, decided, minP) => {
85
+ const { answers, truncated } = await judge("review.standards.judge", {
86
+ questions: candidates.flatMap((c, i) => questionsFor(i + 1, c.decision.anchor, decided)),
87
+ evidence: Object.fromEntries([...candidates.map((c, i) => [`standard-${i + 1}`, evidenceOf(c)]), ...decided.map((s, k) => [`kept-${k + 1}`, s])]),
88
+ message: "Judging whether your answers are standards to keep: run `gtd judge run`, or land without a verdict, which keeps none.",
89
+ label: "Learning your standards"
90
+ });
91
+ const ruledOut = (i, id) => answered(answers[`standard-${i}.contradicts-${id}`], "no", minP);
92
+ const added = [];
93
+ const raised = [];
94
+ candidates.forEach((c, n) => {
95
+ const i = n + 1;
96
+ if (truncated.includes(`standard-${i}`)) return;
97
+ if (decided.some((_, k) => truncated.includes(`kept-${k + 1}`))) return;
98
+ if (!answered(answers[`standard-${i}`], "yes", minP)) return;
99
+ const olds = [...decided.filter((_, k) => !ruledOut(i, `${k + 1}`)), ...added.filter((a) => !ruledOut(i, `new-${a.i}`)).map((a) => a.text)];
100
+ if (olds.length === 0) added.push({
101
+ i,
102
+ text: c.text
103
+ });
104
+ else raised.push({
105
+ text: c.text,
106
+ olds
107
+ });
108
+ });
109
+ return {
110
+ added: added.map((a) => a.text),
111
+ raised
112
+ };
113
+ };
114
+ /**
115
+ * Settles every answer to a standard conflict, then learns standards from
116
+ * the won't-fix ticks and own answers: kept in `.gtd/STANDARDS.md`, a
117
+ * contradicting one as a new conflict note in `.gtd/REVIEW.md`.
118
+ */
119
+ const learnStandards = async (decisions) => {
120
+ const before = parseStandards(read(".gtd/STANDARDS.md") ?? "");
121
+ const rest = decisions.filter((d) => !isConflictTitle(d.anchor));
122
+ const settled = decisions.filter((d) => isConflictTitle(d.anchor)).reduce(resolve, before);
123
+ const known = new Set([...settled.decided, ...settled.undecided.map((c) => c.text)].map(standardKey));
124
+ const candidates = rest.flatMap(candidateOf).filter((c) => {
125
+ const key = standardKey(c.text);
126
+ if (known.has(key)) return false;
127
+ known.add(key);
128
+ return true;
129
+ });
130
+ const minP = numeric(vars.standardMinP, Infinity);
131
+ const { added, raised } = candidates.length === 0 || !Number.isFinite(minP) ? {
132
+ added: [],
133
+ raised: []
134
+ } : await judged(candidates, settled.decided, minP);
135
+ const after = {
136
+ decided: [...settled.decided, ...added],
137
+ undecided: [...settled.undecided, ...raised]
138
+ };
139
+ if (renderStandards(after) !== renderStandards(before)) await run("review.standards.recording", writeScript(STANDARDS, renderStandards(after)), { label: "Recording your standards" });
140
+ if (raised.length > 0) {
141
+ const chunks = raised.map((c) => `## ${conflictTitle(c)}\n\n${conflictBody(c)}`);
142
+ await run("review.standards.raising", writeScript(REVIEW, `${(read(".gtd/REVIEW.md") ?? "").trimEnd()}\n\n${chunks.join("\n\n")}\n`), {
143
+ label: "Asking about a contradicting standard",
144
+ file: REVIEW
145
+ });
146
+ }
147
+ };
148
+ const settlesStandard = (d) => isConflictTitle(d.anchor);
149
+ const standardsPending = () => parseStandards(read(".gtd/STANDARDS.md") ?? "").undecided.length > 0;
150
+ const standardsAddedSince = (base) => {
151
+ const change = changesSince(base).get(STANDARDS);
152
+ if (change === void 0) return [];
153
+ const was = new Set(parseStandards(change.before ?? "").decided);
154
+ return parseStandards(change.after ?? "").decided.filter((s) => !was.has(s));
155
+ };
156
+ //#endregion
5
157
  //#region src/workflows/shared/review.ts
6
158
  const VERDICTS = [
7
159
  "edit",
@@ -132,7 +284,11 @@ const composing = (base, report, carry, nitFix, answered = /* @__PURE__ */ new S
132
284
  ...report,
133
285
  weakened: removedAssertions(changesSince(base)),
134
286
  nitFix,
135
- answered
287
+ answered,
288
+ standards: {
289
+ added: standardsAddedSince(base),
290
+ undecided: parseStandards(read(".gtd/STANDARDS.md") ?? "").undecided
291
+ }
136
292
  }), [QUALITY]) : showScript(carry, REVIEW, REVIEW), {
137
293
  label: "Composing the review",
138
294
  file: REVIEW,
@@ -177,6 +333,9 @@ const humanReview = (base, escalations = { rounds: 0 }, options = {}) => {
177
333
  collecting: feedback.collecting,
178
334
  triage: triage(severities),
179
335
  decided: (decisions) => decisions.forEach((d) => answered.add(d.anchor)),
336
+ settles: settlesStandard,
337
+ learn: learnStandards,
338
+ pending: standardsPending,
180
339
  answerQuestions: answerReviewQuestions,
181
340
  fixNits: async (notes) => {
182
341
  await guarded((n) => fixNits(n, fixAccess, rules), guard)(notes);
@@ -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 { A as reviewMissing, D as fixQuality, M as reviewing, O as fixRisks, S as awaitReview, T as fixCheck, Y as handOffAwaitReviewMessage, d as guarded, f as healthy, h as QUALITY, it as qualityTriageMessage, j as reviewQuality, l as escalation, rt as qualityFindingsFile, y as REVIEW } from "./reserved-DJk_6-ju.mjs";
3
+ import { A as reviewMissing, D as fixQuality, M as reviewing, O as fixRisks, S as awaitReview, T as fixCheck, Y as handOffAwaitReviewMessage, d as guarded, f as healthy, h as QUALITY, it as qualityTriageMessage, j as reviewQuality, l as escalation, rt as qualityFindingsFile, y as REVIEW } from "./reserved-ty1mtlnS.mjs";
4
4
  //#region src/workflows/shared/lenses.ts
5
5
  const NO_FINDINGS = {
6
6
  notes: [],
@@ -233,14 +233,20 @@ const record = (p, decisions) => {
233
233
  p.decided?.(decisions);
234
234
  for (const d of decisions) if (d.choice === "wontfix") mark("wontfix", oneLine(d.anchor));
235
235
  };
236
+ const routed = (p, answers) => answers.filter((d) => p.settles?.(d) !== true);
237
+ /** Record the routed answers on the gate's commit, then hand every answer to `learn`. */
238
+ const recordAndLearn = async (p, decisions, answers) => {
239
+ record(p, decisions);
240
+ if (answers.length > 0 && p.learn !== void 0) await p.learn(answers);
241
+ };
236
242
  /**
237
243
  * Judge every note, then run each verdict's route: answers, then nit fixes,
238
244
  * then the edits' lap. Of the human's `decisions`, a fix joins the nit batch
239
245
  * unjudged, an own answer is judged like a note, and a won't-fix is dropped
240
246
  * with a `Gtd-Mark` on the gate's commit.
241
247
  */
242
- const routeNotes = async (p, notes, r, decisions = []) => {
243
- record(p, decisions);
248
+ const routeNotes = async (p, notes, r, decisions = [], answers = decisions) => {
249
+ await recordAndLearn(p, decisions, answers);
244
250
  const of = (c) => decisions.filter((d) => d.choice === c);
245
251
  const fixes = of("fix").map((d, i) => decisionNote(d, `fix-${i + 1}`, d.body));
246
252
  const judged = [...notes, ...of("answer").map((d, i) => decisionNote(d, `answer-${i + 1}`, d.text))];
@@ -294,9 +300,8 @@ const settledAfter = (p, collectedAt) => ({
294
300
  folded: collectedAt !== void 0 && read(p.feedback) !== void 0,
295
301
  settled: collectedAt !== void 0 && changesSince(collectedAt).every((c) => !isCodeEdit(p, c)) && !notedSince(p, collectedAt)
296
302
  });
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);
303
+ const finishNotes = async (p, notes, answers, reopened, ctx) => {
304
+ if (notes.length + answers.length > 0) return routeNotes(p, notes, ctx, routed(p, answers), answers);
300
305
  if (reopened) return ctx.unfolded();
301
306
  await ctx.close();
302
307
  return ctx.outcome(await collect(p, p.captures.notes(ctx.round), "notes"));
@@ -311,6 +316,7 @@ const decisionsSince = (p, since) => {
311
316
  const after = read(p.file) ?? "";
312
317
  return reviewDecisions(since.get(p.file)?.before ?? after, after);
313
318
  };
319
+ const notesSince = (p, since) => reviewNotes(since.get(p.file)?.before ?? "", read(p.file) ?? "");
314
320
  const finish = async (p, reviewed, collectedAt) => {
315
321
  const round = head();
316
322
  const since = changesSince(reviewed);
@@ -331,23 +337,29 @@ const finish = async (p, reviewed, collectedAt) => {
331
337
  settled,
332
338
  folded
333
339
  });
334
- const decisions = decisionsSince(p, since);
335
- if (settled && !reopened && decisions.length === 0 || edited.length === 0 && !noted) return unfolded();
340
+ const answers = decisionsSince(p, since);
341
+ if (settled && !reopened && answers.length === 0 || edited.length === 0 && !noted) return unfolded();
342
+ const notes = notesSince(p, since);
336
343
  if (edited.length > 0) {
337
- record(p, decisions);
344
+ await recordAndLearn(p, routed(p, answers), answers);
338
345
  await close();
339
346
  return outcome(await collect(p, p.captures.edits(round, reopened ? reviewed : void 0), "edits", {
340
347
  notes: [],
341
348
  paths: edited.map((c) => c.path)
342
349
  }));
343
350
  }
344
- return finishNotes(p, since.get(p.file)?.before, decisions, reopened, {
351
+ return finishNotes(p, notes, answers, reopened, {
345
352
  round,
346
353
  close,
347
354
  outcome,
348
355
  unfolded
349
356
  });
350
357
  };
358
+ /** A sign-off while something waits on the human writes the review again instead. */
359
+ const unlessPending = (p, result) => "verdict" in result && result.verdict === "signoff" && p.pending?.() === true ? {
360
+ next: "rereview",
361
+ carry: void 0
362
+ } : result;
351
363
  /** Write the review; if it marks risks, fix them, keep green, and write it again — once, so the re-review's own risks reach the human unfixed. */
352
364
  const reviewOnce = async (p, base, carry) => {
353
365
  await p.reviewing(base, carry);
@@ -373,7 +385,7 @@ const review = async (base, p) => {
373
385
  for (;;) {
374
386
  collectedAt = await converse(p, base, reviewed, collectedAt);
375
387
  if (collectedAt === "missing") break;
376
- const result = await finish(p, reviewed, collectedAt);
388
+ const result = unlessPending(p, await finish(p, reviewed, collectedAt));
377
389
  if (!("next" in result)) return result;
378
390
  if (result.next === "rereview") {
379
391
  carry = result.carry;
@@ -1,6 +1,6 @@
1
1
  import { agent, changes, glob, read, refuse, scope, start, vars } from "../flows.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";
2
+ import { Bt as requireFlag, C as coder, Lt as flagReferences, Tt as envDefaults, i as skills, n as summary, o as flagShipped, r as access, t as steering, wt as defaults, zt as registryEntry } from "../chunks/reserved-ty1mtlnS.mjs";
3
+ import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-CM3sVoAb.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,8 +1,8 @@
1
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
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";
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-ty1mtlnS.mjs";
4
+ import { a as refusePendingHandOff, o as tail } from "../chunks/tail-CM3sVoAb.mjs";
5
+ import { i as reUnwind, n as humanReview, r as isCode$1, t as buildTail } from "../chunks/review-BmoH-9im.mjs";
6
6
  import { posix } from "node:path";
7
7
  //#region src/workflows/shared/fences.ts
8
8
  const markFenced = (text) => {
@@ -1,7 +1,7 @@
1
1
  import { read, refuse, removeScript, run, scope, start } from "../flows.mjs";
2
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";
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-ty1mtlnS.mjs";
4
+ import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-CM3sVoAb.mjs";
5
5
  //#region src/workflows/fix/fix.ts
6
6
  /**
7
7
  * Fix every note a human left in a handed-off `.gtd/REVIEW.md` and every
@@ -1,7 +1,7 @@
1
1
  import { read, refuse, removeScript, run, start } from "../flows.mjs";
2
- import { C as coder, N as agentWithSkills, Tt as envDefaults, i as skills, mt as reviewGateBlockedMessage, n as summary, pt as reviewFeedbackFixPrompt, q as finisherSystem, r as access, s as shipped, t as steering, u as gate, v as REQUIREMENTS, wt as defaults } from "../chunks/reserved-DJk_6-ju.mjs";
3
- import { a as refusePendingHandOff } from "../chunks/tail-5w5mxGTq.mjs";
4
- import { i as reUnwind, t as buildTail } from "../chunks/review-X7Jfz0dY.mjs";
2
+ import { C as coder, N as agentWithSkills, Tt as envDefaults, i as skills, mt as reviewGateBlockedMessage, n as summary, pt as reviewFeedbackFixPrompt, q as finisherSystem, r as access, s as shipped, t as steering, u as gate, v as REQUIREMENTS, wt as defaults } from "../chunks/reserved-ty1mtlnS.mjs";
3
+ import { a as refusePendingHandOff } from "../chunks/tail-CM3sVoAb.mjs";
4
+ import { i as reUnwind, t as buildTail } from "../chunks/review-BmoH-9im.mjs";
5
5
  //#region src/workflows/review/review.ts
6
6
  /** Build the concerns a review round collected into `.gtd/REQUIREMENTS.md`, then remove it. */
7
7
  const fixFeedback = async () => {
@@ -1,5 +1,5 @@
1
1
  import { agent, changesSince, glob, head, mark, read, refuse, removeScript, run, scope, vars } from "../flows.mjs";
2
- import { C as coder, Dt as flagMentions, Tt as envDefaults, i as skills, jt as requireFlag, kt as flipProblems, n as summary, o as flagShipped, p as FEEDBACK, r as access, t as steering, u as gate, wt as defaults } from "../chunks/reserved-DJk_6-ju.mjs";
2
+ import { Bt as requireFlag, C as coder, It as flagMentions, Rt as flipProblems, Tt as envDefaults, i as skills, n as summary, o as flagShipped, p as FEEDBACK, r as access, t as steering, u as gate, wt as defaults } from "../chunks/reserved-ty1mtlnS.mjs";
3
3
  //#region src/workflows/rollout/rollout.ts
4
4
  const flippingPrompt = (name, registry) => `Roll out the release flag "${name}": in ${registry}, set \`defaultOn: true\` on its entry. Change nothing else — no other entry, no other field, no other file, no comments.`;
5
5
  const suiteBlockedMessage = () => "The suite is red after the rollout. Fix it by hand — a rollout may only touch the flag's registry entry — then land.";