@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.
@@ -16773,11 +16773,17 @@ const decisionNote = (d, id, text) => ({
16773
16773
  forced: /^!(\s|$)/.test(text)
16774
16774
  });
16775
16775
  /** One `Gtd-Mark` value per line: a control character would make mark() throw on every replay. */
16776
- const oneLine = (text) => text.replace(/[\s\x00-\x1f\x7f-\x9f\u2028\u2029]+/g, " ").trim() || "untitled";
16776
+ const oneLine$1 = (text) => text.replace(/[\s\x00-\x1f\x7f-\x9f\u2028\u2029]+/g, " ").trim() || "untitled";
16777
16777
  /** Tell the policy, and mark each won't-fix on the gate's commit. */
16778
16778
  const record$2 = (p, decisions) => {
16779
16779
  p.decided?.(decisions);
16780
- for (const d of decisions) if (d.choice === "wontfix") mark("wontfix", oneLine(d.anchor));
16780
+ for (const d of decisions) if (d.choice === "wontfix") mark("wontfix", oneLine$1(d.anchor));
16781
+ };
16782
+ const routed = (p, answers) => answers.filter((d) => p.settles?.(d) !== true);
16783
+ /** Record the routed answers on the gate's commit, then hand every answer to `learn`. */
16784
+ const recordAndLearn = async (p, decisions, answers) => {
16785
+ record$2(p, decisions);
16786
+ if (answers.length > 0 && p.learn !== void 0) await p.learn(answers);
16781
16787
  };
16782
16788
  /**
16783
16789
  * Judge every note, then run each verdict's route: answers, then nit fixes,
@@ -16785,8 +16791,8 @@ const record$2 = (p, decisions) => {
16785
16791
  * unjudged, an own answer is judged like a note, and a won't-fix is dropped
16786
16792
  * with a `Gtd-Mark` on the gate's commit.
16787
16793
  */
16788
- const routeNotes = async (p, notes, r, decisions = []) => {
16789
- record$2(p, decisions);
16794
+ const routeNotes = async (p, notes, r, decisions = [], answers = decisions) => {
16795
+ await recordAndLearn(p, decisions, answers);
16790
16796
  const of = (c) => decisions.filter((d) => d.choice === c);
16791
16797
  const fixes = of("fix").map((d, i) => decisionNote(d, `fix-${i + 1}`, d.body));
16792
16798
  const judged = [...notes, ...of("answer").map((d, i) => decisionNote(d, `answer-${i + 1}`, d.text))];
@@ -16840,9 +16846,8 @@ const settledAfter = (p, collectedAt) => ({
16840
16846
  folded: collectedAt !== void 0 && read(p.feedback) !== void 0,
16841
16847
  settled: collectedAt !== void 0 && changesSince(collectedAt).every((c) => !isCodeEdit(p, c)) && !notedSince(p, collectedAt)
16842
16848
  });
16843
- const finishNotes = async (p, before, decisions, reopened, ctx) => {
16844
- const notes = reviewNotes(before ?? "", read(p.file) ?? "");
16845
- if (notes.length + decisions.length > 0) return routeNotes(p, notes, ctx, decisions);
16849
+ const finishNotes = async (p, notes, answers, reopened, ctx) => {
16850
+ if (notes.length + answers.length > 0) return routeNotes(p, notes, ctx, routed(p, answers), answers);
16846
16851
  if (reopened) return ctx.unfolded();
16847
16852
  await ctx.close();
16848
16853
  return ctx.outcome(await collect(p, p.captures.notes(ctx.round), "notes"));
@@ -16857,6 +16862,7 @@ const decisionsSince = (p, since) => {
16857
16862
  const after = read(p.file) ?? "";
16858
16863
  return reviewDecisions(since.get(p.file)?.before ?? after, after);
16859
16864
  };
16865
+ const notesSince = (p, since) => reviewNotes(since.get(p.file)?.before ?? "", read(p.file) ?? "");
16860
16866
  const finish = async (p, reviewed, collectedAt) => {
16861
16867
  const round = head$1();
16862
16868
  const since = changesSince(reviewed);
@@ -16877,23 +16883,29 @@ const finish = async (p, reviewed, collectedAt) => {
16877
16883
  settled,
16878
16884
  folded
16879
16885
  });
16880
- const decisions = decisionsSince(p, since);
16881
- if (settled && !reopened && decisions.length === 0 || edited.length === 0 && !noted) return unfolded();
16886
+ const answers = decisionsSince(p, since);
16887
+ if (settled && !reopened && answers.length === 0 || edited.length === 0 && !noted) return unfolded();
16888
+ const notes = notesSince(p, since);
16882
16889
  if (edited.length > 0) {
16883
- record$2(p, decisions);
16890
+ await recordAndLearn(p, routed(p, answers), answers);
16884
16891
  await close();
16885
16892
  return outcome(await collect(p, p.captures.edits(round, reopened ? reviewed : void 0), "edits", {
16886
16893
  notes: [],
16887
16894
  paths: edited.map((c) => c.path)
16888
16895
  }));
16889
16896
  }
16890
- return finishNotes(p, since.get(p.file)?.before, decisions, reopened, {
16897
+ return finishNotes(p, notes, answers, reopened, {
16891
16898
  round,
16892
16899
  close,
16893
16900
  outcome,
16894
16901
  unfolded
16895
16902
  });
16896
16903
  };
16904
+ /** A sign-off while something waits on the human writes the review again instead. */
16905
+ const unlessPending = (p, result) => "verdict" in result && result.verdict === "signoff" && p.pending?.() === true ? {
16906
+ next: "rereview",
16907
+ carry: void 0
16908
+ } : result;
16897
16909
  /** 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. */
16898
16910
  const reviewOnce = async (p, base, carry) => {
16899
16911
  await p.reviewing(base, carry);
@@ -16919,7 +16931,7 @@ const review$1 = async (base, p) => {
16919
16931
  for (;;) {
16920
16932
  collectedAt = await converse(p, base, reviewed, collectedAt);
16921
16933
  if (collectedAt === "missing") break;
16922
- const result = await finish(p, reviewed, collectedAt);
16934
+ const result = unlessPending(p, await finish(p, reviewed, collectedAt));
16923
16935
  if (!("next" in result)) return result;
16924
16936
  if (result.next === "rereview") {
16925
16937
  carry = result.carry;
@@ -17072,6 +17084,108 @@ const requireFlag = (workflow, registryPath, name, defaultOn) => {
17072
17084
  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`);
17073
17085
  };
17074
17086
  //#endregion
17087
+ //#region src/workflows/shared/standards.ts
17088
+ const STANDARDS = ".gtd/STANDARDS.md";
17089
+ const UNDECIDED = "Undecided";
17090
+ const oneLine = (text) => text.replace(/[\s\x00-\x1f\x7f-\x9f\u2028\u2029]+/g, " ").trim();
17091
+ const ITEM$1 = /^(?:[-*+]|\d+[.)])\s+(.*)$/;
17092
+ const CONTRADICTS = /^\s+[-*+]\s+contradicts:\s*(.*)$/i;
17093
+ const wrapped = (item, line) => {
17094
+ const olds = item.olds;
17095
+ if (olds !== void 0 && olds.length > 0) olds[olds.length - 1] += ` ${line.trim()}`;
17096
+ else item.text += ` ${line.trim()}`;
17097
+ };
17098
+ /** What tells two standards apart: not the emphasis or spacing a formatter may rewrite. */
17099
+ const standardKey = (text) => oneLine(text.replace(/[*_`\\]/g, "")).toLowerCase();
17100
+ const toStandards = (items) => {
17101
+ const seen = /* @__PURE__ */ new Set();
17102
+ const fresh = (text) => {
17103
+ const key = standardKey(text);
17104
+ if (key === "" || seen.has(key)) return false;
17105
+ seen.add(key);
17106
+ return true;
17107
+ };
17108
+ const decided = items.filter((i) => i.olds === void 0).map((i) => oneLine(i.text));
17109
+ const undecided = items.flatMap((i) => i.olds === void 0 ? [] : [{
17110
+ text: oneLine(i.text),
17111
+ olds: i.olds.map(oneLine).filter((o) => o !== "")
17112
+ }]);
17113
+ return {
17114
+ decided: decided.filter(fresh),
17115
+ undecided: undecided.filter((c) => fresh(c.text))
17116
+ };
17117
+ };
17118
+ /**
17119
+ * Every top-level bullet is one standard, those under `## Undecided` waiting
17120
+ * on the human; a hand-edited file may group the rest under any heading.
17121
+ */
17122
+ const parseStandards = (text) => {
17123
+ const items = [];
17124
+ let pending = false;
17125
+ let last;
17126
+ for (const line of text.split("\n")) {
17127
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
17128
+ const item = ITEM$1.exec(line);
17129
+ const old = CONTRADICTS.exec(line);
17130
+ if (heading !== null) {
17131
+ pending = heading[1].length > 1 && heading[2].trim() === UNDECIDED;
17132
+ last = void 0;
17133
+ } else if (item !== null) {
17134
+ last = pending ? {
17135
+ text: item[1],
17136
+ olds: []
17137
+ } : { text: item[1] };
17138
+ items.push(last);
17139
+ } else if (old !== null && last?.olds !== void 0) last.olds.push(old[1]);
17140
+ else if (/^\s+\S/.test(line) && last !== void 0) wrapped(last, line);
17141
+ else if (line.trim() !== "") last = void 0;
17142
+ }
17143
+ return toStandards(items);
17144
+ };
17145
+ const renderStandards = ({ decided, undecided }) => {
17146
+ return `${[
17147
+ "# Standards",
17148
+ "Kept from your review answers for every agent turn. One standard per bullet.",
17149
+ ...decided.length === 0 ? [] : [decided.map((s) => `- ${s}`).join("\n")],
17150
+ ...undecided.length === 0 ? [] : [`## ${UNDECIDED}`, undecided.map((c) => [`- ${c.text}`, ...c.olds.map((o) => ` - contradicts: ${o}`)].join("\n")).join("\n")]
17151
+ ].join("\n\n")}\n`;
17152
+ };
17153
+ const hash$1 = (raw) => {
17154
+ const text = standardKey(raw);
17155
+ let h = 2166136261;
17156
+ for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619);
17157
+ return (h >>> 0).toString(16).padStart(8, "0");
17158
+ };
17159
+ const CONFLICT_TITLE = /^Standard conflict ([0-9a-f]{8})$/;
17160
+ const conflictTitle = (c) => `Standard conflict ${hash$1(c.text)}`;
17161
+ const conflictFor = (standards, title) => {
17162
+ const id = CONFLICT_TITLE.exec(title)?.[1];
17163
+ return id === void 0 ? void 0 : standards.undecided.find((c) => hash$1(c.text) === id);
17164
+ };
17165
+ const isConflictTitle = (title) => CONFLICT_TITLE.test(title);
17166
+ const KEEP_NEW = "keep the new one (drop the old)";
17167
+ const KEEP_OLD = "keep the old one (discard the new)";
17168
+ /** A conflict's chunk body; its three boxes read as fix / won't fix / own answer by position. */
17169
+ 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}`] : [
17170
+ `A standard learned from your answers contradicts ${c.olds.length === 1 ? "one" : "some"} already in \`${STANDARDS}\`. Your own answer replaces both with its wording.`,
17171
+ `New: ${c.text}`,
17172
+ `Old:\n\n${c.olds.map((o) => `- ${o}`).join("\n")}`
17173
+ ], `${ANSWER_LEAD}\n\n- [ ] ${KEEP_NEW}\n- [ ] ${KEEP_OLD}\n- [ ] ${FREE_TEXT_PLACEHOLDER}`].join("\n\n");
17174
+ /** What every agent turn is told while `.gtd/STANDARDS.md` holds decided standards. */
17175
+ const standardsBlock = (text) => {
17176
+ const { decided } = parseStandards(text ?? "");
17177
+ if (decided.length === 0) return "";
17178
+ return `## Standards
17179
+
17180
+ \`${STANDARDS}\` holds the standards the human set in earlier reviews.
17181
+ Follow every one in what you write. Reviewing, never raise a finding a
17182
+ standard rules out — a \`Won't fix:\` standard is a finding the human
17183
+ rejected, so never raise it or one like it again — and raise breaking a
17184
+ standard as a finding.
17185
+
17186
+ ${decided.map((s) => `- ${s}`).join("\n")}`;
17187
+ };
17188
+ //#endregion
17075
17189
  //#region src/workflows/shared/vars.ts
17076
17190
  /** The refactor guard's fixed list, and `seamAllow`'s default minus `.gtd/**`. */
17077
17191
  const testFileGlobs = [
@@ -17091,6 +17205,7 @@ const defaults = {
17091
17205
  reviewNoteActionable: "0.7",
17092
17206
  reviewSeverityMinP: "0.7",
17093
17207
  qualityTriageMinP: "0.7",
17208
+ standardMinP: "0.8",
17094
17209
  refactorFlagPattern: "\\bGTD_FLAG_\\w+",
17095
17210
  seamAllow: [...testFileGlobs, ".gtd/**"].join(", "),
17096
17211
  flagMode: "true",
@@ -17350,8 +17465,11 @@ const withSkills = (skills, prompt) => {
17350
17465
  };
17351
17466
  /** An `agent()` step whose prompt opens with the preamble naming its scope's skills, then its restricted file access. */
17352
17467
  const agentWithSkills = (name, prompt, options = {}) => {
17353
- const access = accessPreamble(accessFor(name, options.file));
17354
- const body = access === "" ? prompt : `${access}\n\n${prompt}`;
17468
+ const body = [
17469
+ accessPreamble(accessFor(name, options.file)),
17470
+ prompt,
17471
+ standardsBlock(read(STANDARDS))
17472
+ ].filter((part) => part !== "").join("\n\n");
17355
17473
  return agent(name, withSkills(skillsFor(name).join(", "), body), options);
17356
17474
  };
17357
17475
  const unwindFailure = (commit) => `gtd could not unwind ${commit} out of your working tree.`;
@@ -17391,6 +17509,7 @@ const SUMMARY_TITLES = [
17391
17509
  "Weakened scenarios",
17392
17510
  "Fixed from your notes",
17393
17511
  "Fixed automatically",
17512
+ "Standards added",
17394
17513
  "Dropped as noise",
17395
17514
  "Nothing to note"
17396
17515
  ];
@@ -17411,6 +17530,8 @@ const composedReview = (base, report) => {
17411
17530
  };
17412
17531
  const chunk = (title, body) => `## ${title}\n\n${body.trim()}`;
17413
17532
  const answerBoxes = `${ANSWER_LEAD}\n\n- [ ] ${ANSWER_FIX}\n- [ ] ${ANSWER_WONT_FIX}\n- [ ] ${FREE_TEXT_PLACEHOLDER}`;
17533
+ const conflicts = (report.standards?.undecided ?? []).map((c) => chunk(unique(conflictTitle(c)), conflictBody(c)));
17534
+ const added = report.standards?.added ?? [];
17414
17535
  const notes = report.notes.map((n) => ({
17415
17536
  ...n,
17416
17537
  title: unique(n.title)
@@ -17419,8 +17540,10 @@ const composedReview = (base, report) => {
17419
17540
  const chunks = [
17420
17541
  ...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.`)],
17421
17542
  ...weakened.length === 0 ? [] : [chunk("Weakened scenarios", `These scenarios lost an assertion the review base had:\n\n${weakened.join("\n")}`)],
17543
+ ...conflicts,
17422
17544
  ...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}`)),
17423
17545
  ...report.fixed.length === 0 ? [] : [chunk("Fixed automatically", report.fixed.map((f) => `- ${f.title} (${short(f.commit)})`).join("\n"))],
17546
+ ...added.length === 0 ? [] : [chunk("Standards added", `Learned from your answers and kept in \`${STANDARDS}\`:\n\n${added.map((s) => `- ${s}`).join("\n")}`)],
17424
17547
  ...report.noise.length === 0 ? [] : [chunk("Dropped as noise", report.noise.map((f) => `- ${f.title} (p=${f.p})`).join("\n"))]
17425
17548
  ];
17426
17549
  const body = chunks.length > 0 ? chunks : [chunk("Nothing to note", "No quality lens raised anything for you — or none ran. Review the diff yourself.")];
@@ -25619,9 +25742,9 @@ const HashMapProto = {
25619
25742
  return new HashMapIterator(this, (k, v) => [k, v]);
25620
25743
  },
25621
25744
  [symbol$1]() {
25622
- let hash$1 = hash(HashMapSymbolKey);
25623
- for (const item of this) hash$1 ^= pipe(hash(item[0]), combine$5(hash(item[1])));
25624
- return cached(this, hash$1);
25745
+ let hash$2 = hash(HashMapSymbolKey);
25746
+ for (const item of this) hash$2 ^= pipe(hash(item[0]), combine$5(hash(item[1])));
25747
+ return cached(this, hash$2);
25625
25748
  },
25626
25749
  [symbol](that) {
25627
25750
  if (isHashMap(that)) {
@@ -31627,7 +31750,7 @@ const gauge$1 = (count) => new GaugeState(count);
31627
31750
  /** @internal */
31628
31751
  const histogram$2 = (options) => new HistogramState(options.buckets, options.count, options.min, options.max, options.sum);
31629
31752
  /** @internal */
31630
- const summary$2 = (options) => new SummaryState(options.error, options.quantiles, options.count, options.min, options.max, options.sum);
31753
+ const summary$3 = (options) => new SummaryState(options.error, options.quantiles, options.count, options.min, options.max, options.sum);
31631
31754
  /** @internal */
31632
31755
  const isCounterState = (u) => hasProperty(u, CounterStateTypeId);
31633
31756
  /**
@@ -31760,7 +31883,7 @@ const histogram$1 = (key) => {
31760
31883
  });
31761
31884
  };
31762
31885
  /** @internal */
31763
- const summary$1 = (key) => {
31886
+ const summary$2 = (key) => {
31764
31887
  const { error, maxAge, maxSize, quantiles } = key.keyType;
31765
31888
  const sortedQuantiles = pipe(quantiles, sort(Order$1));
31766
31889
  const values = allocate(maxSize);
@@ -31795,7 +31918,7 @@ const summary$1 = (key) => {
31795
31918
  sum = sum + value;
31796
31919
  };
31797
31920
  return make$5({
31798
- get: () => summary$2({
31921
+ get: () => summary$3({
31799
31922
  error,
31800
31923
  quantiles: snapshot(Date.now()),
31801
31924
  count,
@@ -31995,7 +32118,7 @@ var MetricRegistryImpl = class {
31995
32118
  getSummary(key) {
31996
32119
  let value = pipe(this.map, get$1(key), getOrUndefined);
31997
32120
  if (value == null) {
31998
- const summary = summary$1(key);
32121
+ const summary = summary$2(key);
31999
32122
  if (!pipe(this.map, has(key))) pipe(this.map, set(key, summary));
32000
32123
  value = summary;
32001
32124
  }
@@ -38042,6 +38165,7 @@ const ShippedOptions = Struct({
38042
38165
  reviewNoteActionable: setting$1,
38043
38166
  reviewSeverityMinP: setting$1,
38044
38167
  qualityTriageMinP: setting$1,
38168
+ standardMinP: setting$1,
38045
38169
  refactorFlagPattern: setting$1,
38046
38170
  seamAllow: setting$1,
38047
38171
  sliceMaxCriteria: setting$1,
@@ -38600,6 +38724,158 @@ const design = (base, guard) => scope("design", async () => {
38600
38724
  } while (await noteGate(REQUIREMENTS, answerProductQuestions));
38601
38725
  });
38602
38726
  //#endregion
38727
+ //#region src/workflows/shared/learn.ts
38728
+ const SUMMARY_MAX = 200;
38729
+ 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*/;
38730
+ const summary$1 = (body) => {
38731
+ const text = oneLine(body).replace(BANNER, "");
38732
+ return text.length > SUMMARY_MAX ? `${text.slice(0, SUMMARY_MAX - 1)}…` : text;
38733
+ };
38734
+ /** The standard a won't-fix or an own answer would make; a fix states none. */
38735
+ const candidateOf = (d) => {
38736
+ const anchor = oneLine(d.anchor);
38737
+ if (d.choice === "wontfix") {
38738
+ const why = summary$1(d.body);
38739
+ return [{
38740
+ decision: d,
38741
+ text: `Won't fix: ${anchor}${why === "" ? "" : ` — ${why}`}`
38742
+ }];
38743
+ }
38744
+ if (d.choice === "answer") return [{
38745
+ decision: d,
38746
+ text: `${oneLine(d.text)} (re: ${anchor})`
38747
+ }];
38748
+ return [];
38749
+ };
38750
+ const without = (list, drop) => {
38751
+ const keys = new Set(drop.map(standardKey));
38752
+ return list.filter((s) => !keys.has(standardKey(s)));
38753
+ };
38754
+ const adding = (list, text) => list.some((s) => standardKey(s) === standardKey(text)) ? [...list] : [...list, text];
38755
+ /**
38756
+ * Keep the new one, keep the old one, or replace both with the human's own
38757
+ * wording. Another open conflict stops naming a replaced standard: the human
38758
+ * never judged it against the replacement, so keeping it must not drop that.
38759
+ */
38760
+ const resolve = (standards, d) => {
38761
+ const c = conflictFor(standards, d.anchor);
38762
+ if (c === void 0) return standards;
38763
+ const others = standards.undecided.filter((u) => u !== c);
38764
+ if (d.choice === "wontfix") return {
38765
+ decided: standards.decided,
38766
+ undecided: others
38767
+ };
38768
+ const kept = d.choice === "fix" ? c.text : oneLine(d.text);
38769
+ const undecided = others.map((u) => ({
38770
+ text: u.text,
38771
+ olds: without(u.olds, c.olds)
38772
+ }));
38773
+ return {
38774
+ decided: adding(without(standards.decided, c.olds), kept),
38775
+ undecided
38776
+ };
38777
+ };
38778
+ const contradicts = (i, id, other) => ({
38779
+ id: `standard-${i}.contradicts-${id}`,
38780
+ primitive: "noul",
38781
+ instructions: `Does the "Standard:" line of evidence "standard-${i}" contradict ${other}?`,
38782
+ criteria: "yes: following one breaks the other. no: both can be followed — they are compatible or unrelated."
38783
+ });
38784
+ const questionsFor = (i, anchor, decided) => [
38785
+ {
38786
+ id: `standard-${i}`,
38787
+ primitive: "noul",
38788
+ 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.`,
38789
+ 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."
38790
+ },
38791
+ ...decided.map((_, k) => contradicts(i, `${k + 1}`, `kept standard ${k + 1} (evidence "kept-${k + 1}")`)),
38792
+ ...Array.from({ length: i - 1 }, (_, j) => contradicts(i, `new-${j + 1}`, `the "Standard:" line of evidence "standard-${j + 1}"`))
38793
+ ];
38794
+ const evidenceOf = (c) => [
38795
+ `Note: ${c.decision.anchor}`,
38796
+ `Reviewer's text: ${c.decision.body}`,
38797
+ `Human's answer: ${c.decision.choice === "wontfix" ? "won't fix" : c.decision.text}`,
38798
+ `Standard: ${c.text}`
38799
+ ].join("\n");
38800
+ /**
38801
+ * One judge rest over every candidate. Only a confident `yes` on uncut
38802
+ * evidence makes a standard; a contradiction the judge does not confidently
38803
+ * rule out goes to the human instead of overwriting what they decided before.
38804
+ * A cut kept standard leaves the round unlearned: raise `judgeBudgetBytes`.
38805
+ */
38806
+ const judged = async (candidates, decided, minP) => {
38807
+ const { answers, truncated } = await judge("review.standards.judge", {
38808
+ questions: candidates.flatMap((c, i) => questionsFor(i + 1, c.decision.anchor, decided)),
38809
+ evidence: Object.fromEntries([...candidates.map((c, i) => [`standard-${i + 1}`, evidenceOf(c)]), ...decided.map((s, k) => [`kept-${k + 1}`, s])]),
38810
+ message: "Judging whether your answers are standards to keep: run `gtd judge run`, or land without a verdict, which keeps none.",
38811
+ label: "Learning your standards"
38812
+ });
38813
+ const ruledOut = (i, id) => answered(answers[`standard-${i}.contradicts-${id}`], "no", minP);
38814
+ const added = [];
38815
+ const raised = [];
38816
+ candidates.forEach((c, n) => {
38817
+ const i = n + 1;
38818
+ if (truncated.includes(`standard-${i}`)) return;
38819
+ if (decided.some((_, k) => truncated.includes(`kept-${k + 1}`))) return;
38820
+ if (!answered(answers[`standard-${i}`], "yes", minP)) return;
38821
+ const olds = [...decided.filter((_, k) => !ruledOut(i, `${k + 1}`)), ...added.filter((a) => !ruledOut(i, `new-${a.i}`)).map((a) => a.text)];
38822
+ if (olds.length === 0) added.push({
38823
+ i,
38824
+ text: c.text
38825
+ });
38826
+ else raised.push({
38827
+ text: c.text,
38828
+ olds
38829
+ });
38830
+ });
38831
+ return {
38832
+ added: added.map((a) => a.text),
38833
+ raised
38834
+ };
38835
+ };
38836
+ /**
38837
+ * Settles every answer to a standard conflict, then learns standards from
38838
+ * the won't-fix ticks and own answers: kept in `.gtd/STANDARDS.md`, a
38839
+ * contradicting one as a new conflict note in `.gtd/REVIEW.md`.
38840
+ */
38841
+ const learnStandards = async (decisions) => {
38842
+ const before = parseStandards(read(".gtd/STANDARDS.md") ?? "");
38843
+ const rest = decisions.filter((d) => !isConflictTitle(d.anchor));
38844
+ const settled = decisions.filter((d) => isConflictTitle(d.anchor)).reduce(resolve, before);
38845
+ const known = new Set([...settled.decided, ...settled.undecided.map((c) => c.text)].map(standardKey));
38846
+ const candidates = rest.flatMap(candidateOf).filter((c) => {
38847
+ const key = standardKey(c.text);
38848
+ if (known.has(key)) return false;
38849
+ known.add(key);
38850
+ return true;
38851
+ });
38852
+ const minP = numeric(vars.standardMinP, Infinity);
38853
+ const { added, raised } = candidates.length === 0 || !Number.isFinite(minP) ? {
38854
+ added: [],
38855
+ raised: []
38856
+ } : await judged(candidates, settled.decided, minP);
38857
+ const after = {
38858
+ decided: [...settled.decided, ...added],
38859
+ undecided: [...settled.undecided, ...raised]
38860
+ };
38861
+ if (renderStandards(after) !== renderStandards(before)) await run("review.standards.recording", writeScript(STANDARDS, renderStandards(after)), { label: "Recording your standards" });
38862
+ if (raised.length > 0) {
38863
+ const chunks = raised.map((c) => `## ${conflictTitle(c)}\n\n${conflictBody(c)}`);
38864
+ await run("review.standards.raising", writeScript(REVIEW, `${(read(".gtd/REVIEW.md") ?? "").trimEnd()}\n\n${chunks.join("\n\n")}\n`), {
38865
+ label: "Asking about a contradicting standard",
38866
+ file: REVIEW
38867
+ });
38868
+ }
38869
+ };
38870
+ const settlesStandard = (d) => isConflictTitle(d.anchor);
38871
+ const standardsPending = () => parseStandards(read(".gtd/STANDARDS.md") ?? "").undecided.length > 0;
38872
+ const standardsAddedSince = (base) => {
38873
+ const change = changesSince(base).get(STANDARDS);
38874
+ if (change === void 0) return [];
38875
+ const was = new Set(parseStandards(change.before ?? "").decided);
38876
+ return parseStandards(change.after ?? "").decided.filter((s) => !was.has(s));
38877
+ };
38878
+ //#endregion
38603
38879
  //#region src/workflows/shared/review.ts
38604
38880
  const VERDICTS = [
38605
38881
  "edit",
@@ -38730,7 +39006,11 @@ const composing = (base, report, carry, nitFix, answered = /* @__PURE__ */ new S
38730
39006
  ...report,
38731
39007
  weakened: removedAssertions(changesSince(base)),
38732
39008
  nitFix,
38733
- answered
39009
+ answered,
39010
+ standards: {
39011
+ added: standardsAddedSince(base),
39012
+ undecided: parseStandards(read(".gtd/STANDARDS.md") ?? "").undecided
39013
+ }
38734
39014
  }), [QUALITY]) : showScript(carry, REVIEW, REVIEW), {
38735
39015
  label: "Composing the review",
38736
39016
  file: REVIEW,
@@ -38775,6 +39055,9 @@ const humanReview = (base, escalations = { rounds: 0 }, options = {}) => {
38775
39055
  collecting: feedback.collecting,
38776
39056
  triage: triage$1(severities),
38777
39057
  decided: (decisions) => decisions.forEach((d) => answered.add(d.anchor)),
39058
+ settles: settlesStandard,
39059
+ learn: learnStandards,
39060
+ pending: standardsPending,
38778
39061
  answerQuestions: answerReviewQuestions,
38779
39062
  fixNits: async (notes) => {
38780
39063
  await guarded((n) => fixNits(n, fixAccess, rules), guard)(notes);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "44.0.0",
3
+ "version": "45.0.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -199,8 +199,12 @@ const driveRound = async (
199
199
  /** Nothing but thread edits lands after the in-gate collecting turn. */
200
200
  settled?: boolean
201
201
  noDropped?: boolean
202
+ learn?: ReviewPolicy["learn"]
203
+ settles?: ReviewPolicy["settles"]
204
+ pending?: () => boolean
202
205
  } = {},
203
206
  ) => {
207
+ let reviews = 0
204
208
  const triaged: string[][] = []
205
209
  const captured: string[] = []
206
210
  const editRanges: (string | undefined)[] = []
@@ -211,9 +215,15 @@ const driveRound = async (
211
215
  file: "r.md",
212
216
  feedback: "f.md",
213
217
  isCode: (path: string) => path.endsWith(".ts"),
214
- reviewing: async () => void (state.files["r.md"] = before),
218
+ reviewing: async () => {
219
+ reviews++
220
+ state.files["r.md"] = before
221
+ },
215
222
  fixRisks: async () => {},
216
223
  keepGreen: async () => {},
224
+ learn: opts.learn,
225
+ settles: opts.settles,
226
+ pending: opts.pending,
217
227
  awaitReview: async () => {
218
228
  awaits++
219
229
  if (awaits > 2 || (awaits > 1 && opts.inGate === undefined)) throw new Error("stop")
@@ -270,7 +280,7 @@ const driveRound = async (
270
280
  },
271
281
  } as unknown as ReviewPolicy
272
282
  const result = await review("base", p).catch((e: Error) => e)
273
- return { result, triaged, dropped, captured, rounds, editRanges }
283
+ return { result, triaged, dropped, captured, rounds, editRanges, reviews }
274
284
  }
275
285
 
276
286
  describe("a one-shot footnote is a note, a thread footnote is not", () => {
@@ -561,6 +571,74 @@ describe("a note's answer boxes", () => {
561
571
  })
562
572
  })
563
573
 
574
+ describe("learning from the answers", () => {
575
+ const boxes = (wont: string, own = "_your answer_"): string =>
576
+ doc(
577
+ POINTER,
578
+ "",
579
+ "## Rounding",
580
+ "",
581
+ "Should subtract round?",
582
+ "",
583
+ "Tick exactly one:",
584
+ "",
585
+ "- [ ] fix as suggested",
586
+ `- [${wont}] won't fix`,
587
+ `- [${own === "_your answer_" ? " " : "x"}] ${own}`,
588
+ )
589
+
590
+ it("learn reads every answer once it is marked, before any is routed", async () => {
591
+ const seen: ReviewDecision[][] = []
592
+ let marked: string[] = []
593
+ const r = await driveRound(boxes(" "), boxes("x"), {
594
+ learn: async (ds) => {
595
+ seen.push([...ds])
596
+ marked = [...state.marks]
597
+ },
598
+ })
599
+ expect(seen).toEqual([[expect.objectContaining({ anchor: "Rounding", choice: "wontfix" })]])
600
+ expect(marked).toEqual(["wontfix=Rounding"])
601
+ expect(r.result).toEqual({ verdict: "signoff" })
602
+ })
603
+
604
+ it("an answer the policy settles is learned from, never marked, routed or collected", async () => {
605
+ const seen: ReviewDecision[][] = []
606
+ const r = await driveRound(boxes(" "), boxes("x"), {
607
+ settles: () => true,
608
+ learn: async (ds) => void seen.push([...ds]),
609
+ })
610
+ expect(seen).toHaveLength(1)
611
+ expect(state.marks).toEqual([])
612
+ expect(r.triaged).toEqual([])
613
+ expect(r.rounds).toEqual([])
614
+ expect(r.result).toEqual({ verdict: "signoff" })
615
+ })
616
+
617
+ it("a hand-edit round learns from its answers too", async () => {
618
+ const seen: ReviewDecision[][] = []
619
+ await driveRound(boxes(" "), boxes("x"), {
620
+ handEdit: true,
621
+ learn: async (ds) => void seen.push([...ds]),
622
+ })
623
+ expect(seen).toHaveLength(1)
624
+ })
625
+
626
+ it("learn is not asked when no note was answered", async () => {
627
+ let asked = false
628
+ await driveRound(boxes(" "), `${boxes(" ")}\n\nstray prose`, {
629
+ silent: true,
630
+ learn: async () => void (asked = true),
631
+ })
632
+ expect(asked).toBe(false)
633
+ })
634
+
635
+ it("a sign-off while something is pending writes the review again", async () => {
636
+ const r = await driveRound(boxes(" "), boxes("x"), { pending: () => true })
637
+ expect(r.reviews).toBe(2)
638
+ expect((r.result as Error).message).toBe("stop")
639
+ })
640
+ })
641
+
564
642
  describe("deleted answer boxes", () => {
565
643
  it("are refused even when a reply turn landed them", async () => {
566
644
  const withBoxes = doc(