ruvnet-brain 4.7.22 → 4.7.24

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 (39) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +59 -1
  3. package/kb/lifecycle-evidence-retention.mjs +1 -1
  4. package/kb/update-storage-transaction.mjs +161 -1
  5. package/package.json +2 -1
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/scripts/brain-footprint.mjs +5 -1
  9. package/plugin/scripts/learn-capture.mjs +26 -3
  10. package/scripts/generation-census.mjs +220 -0
  11. package/scripts/issue-autofix-agents.mjs +172 -0
  12. package/scripts/issue-autofix-comment.mjs +224 -0
  13. package/scripts/issue-autofix-diff.mjs +108 -0
  14. package/scripts/issue-autofix-exec.mjs +128 -0
  15. package/scripts/issue-autofix-fix.mjs +104 -0
  16. package/scripts/issue-autofix-gate.mjs +137 -0
  17. package/scripts/issue-autofix-night.mjs +164 -0
  18. package/scripts/issue-autofix-notes.mjs +346 -0
  19. package/scripts/issue-autofix-policy.mjs +349 -0
  20. package/scripts/issue-autofix-prompts.mjs +105 -0
  21. package/scripts/issue-autofix-release-ship.mjs +226 -0
  22. package/scripts/issue-autofix-release-util.mjs +141 -0
  23. package/scripts/issue-autofix-release.mjs +272 -0
  24. package/scripts/issue-autofix-report.mjs +121 -0
  25. package/scripts/issue-autofix-runner.mjs +75 -0
  26. package/scripts/issue-autofix-sandbox.mjs +108 -0
  27. package/scripts/issue-autofix-security.mjs +346 -0
  28. package/scripts/issue-autofix-selftest.mjs +101 -0
  29. package/scripts/issue-autofix-state.mjs +61 -0
  30. package/scripts/issue-autofix.mjs +382 -0
  31. package/scripts/issue-fix-lock.mjs +40 -0
  32. package/scripts/issue-fix.mjs +33 -20
  33. package/scripts/no-silent-substitution.mjs +1 -1
  34. package/scripts/ntfy-inbox.mjs +15 -0
  35. package/scripts/onboarding-console.mjs +6 -1
  36. package/scripts/release-prepush-checks.mjs +251 -0
  37. package/scripts/release-prepush.mjs +168 -0
  38. package/scripts/release-qualification-contract.mjs +32 -0
  39. package/scripts/wired-check.mjs +11 -5
@@ -0,0 +1,346 @@
1
+ // scripts/issue-autofix-notes.mjs — guards and the regenerate loop for PERSONAL thank-you notes (ADR-107).
2
+ //
3
+ // Owner's standing order: a thank-you note is never a form letter. Each note is written fresh by a model from the
4
+ // person's OWN words in the thread, is specific about what they did, apologises plainly when they waited more than
5
+ // WAIT_APOLOGY_DAYS, and is truthful about what was and was not verified. One note per person per night.
6
+ //
7
+ // This module decides whether a model-written note may be posted (checkNote) and drives up to MAX_REGENERATIONS
8
+ // retries with feedback (generatePersonNote). If no attempt passes it refuses; the caller then posts nothing
9
+ // personal and pages the owner. Pure: no fs, no child_process, no network — every input is passed in.
10
+ import { BOT_MARKER, OWNER_LOGIN } from './issue-watch.mjs';
11
+
12
+ export const MAX_REGENERATIONS = 3; // total attempts = 1 + MAX_REGENERATIONS
13
+ export const WAIT_APOLOGY_DAYS = 7;
14
+ const DAY_MS = 86_400_000;
15
+ const THANKS_MAX = 700;
16
+ const THANKS_MIN = 40;
17
+ const FIELD_MAX = 600;
18
+ const TRIGRAM_LIMIT = 0.45;
19
+ const GENERIC_RATIO = 0.6;
20
+ const FIELDS = ['whatWasWrong', 'whatChanged', 'whatItNeeds'];
21
+
22
+ export const BOILERPLATE = Object.freeze([
23
+ 'thank you for reporting', 'thanks for reporting', 'thanks for taking the time', 'thank you for taking the time',
24
+ 'we appreciate', 'i appreciate you', 'great catch', 'good catch', 'hope this helps', 'let us know', 'let me know if',
25
+ "don't hesitate", 'do not hesitate', 'happy to help', 'sorry for the inconvenience', 'thanks again',
26
+ 'thank you for your patience', 'feel free to', 'your feedback', 'valuable feedback', 'keep up', 'much appreciated',
27
+ 'we value',
28
+ ]);
29
+
30
+ const STOP = new Set(('a an and are as at be been being but by can could did do does for from had has have he her his '
31
+ + 'how i if in into is it its just like me more most my no not now of on once only or our out over she so some such '
32
+ + 'than that the their them then there these they this those to too up us very was we were what when where which '
33
+ + 'while who whom why will with would you your yours every all any also about after again because before both each '
34
+ + 'few here off other own same should through under until').split(' '));
35
+ // Words that carry no information about WHICH issue a sentence is about: thanks, praise, apology filler, "the project".
36
+ const GENERIC_VOCAB = new Set(('thanks thank report reports reported reporting issue issues bug bugs problem problems '
37
+ + 'contribution contributions contribute contributing help helps helped helpful helping project projects community '
38
+ + 'everyone everybody users user use uses using improve improves improving improvement better best appreciate '
39
+ + 'appreciated appreciation really great much many effort efforts time times work works taking take took detailed '
40
+ + 'detail details clear write writing written filing filed file open opened opening raise raised raising kind matters '
41
+ + 'matter important valuable feedback make makes made sure things thing glad happy awesome amazing nice good '
42
+ + 'wonderful excellent fantastic difference team grateful thoughtful input others people future sorry wait waiting '
43
+ + 'waited delay delayed slow reply response long while lot truly sincerely definitely certainly look looked looking '
44
+ + 'into get got able way ways keep going someone anyone something anything know let hope want wanted').split(' '));
45
+
46
+ const str = (v) => (typeof v === 'string' ? v : '');
47
+ const lower = (s) => str(s).toLowerCase();
48
+ const normWords = (s) => lower(s).replace(/[^\p{L}\p{N}\s]+/gu, ' ').split(/\s+/).filter(Boolean);
49
+ const uniq = (a) => [...new Set(a)];
50
+ const alnumCount = (s) => (s.match(/[A-Za-z0-9]/g) || []).length;
51
+ const sentences = (s) => str(s).split(/(?<=[.!?])\s+|\n+/).map((x) => x.trim()).filter(Boolean);
52
+
53
+ // ---- interface helpers -------------------------------------------------------------------------------------------
54
+
55
+ export function reporterWords(issue, login) {
56
+ const who = lower(login);
57
+ const own = (Array.isArray(issue?.comments) ? issue.comments : []).filter((c) => {
58
+ const author = lower(c?.author?.login);
59
+ return author && author === who && author !== lower(OWNER_LOGIN);
60
+ });
61
+ return [str(issue?.title), str(issue?.body), ...own.map((c) => str(c.body))].filter(Boolean).join('\n\n');
62
+ }
63
+
64
+ export function waitedDays(issue, now) {
65
+ const t = Date.parse(str(issue?.createdAt));
66
+ if (!Number.isFinite(t) || !Number.isFinite(now)) return 0;
67
+ return Math.max(0, Math.floor((now - t) / DAY_MS));
68
+ }
69
+
70
+ // Items whose issue has no author login are dropped: there is nobody to thank.
71
+ export function groupByPerson(items) {
72
+ const groups = new Map();
73
+ for (const item of Array.isArray(items) ? items : []) {
74
+ const login = str(item?.issue?.author?.login).trim();
75
+ if (!login) continue;
76
+ const key = login.toLowerCase();
77
+ if (!groups.has(key)) groups.set(key, { login, items: [] });
78
+ groups.get(key).items.push(item);
79
+ }
80
+ const ts = (i) => { const t = Date.parse(str(i.issue.createdAt)); return Number.isFinite(t) ? t : Infinity; };
81
+ return [...groups.values()].map((g) => ({
82
+ login: g.login,
83
+ items: g.items.slice().sort((a, b) => ts(a) - ts(b) || (a.issue.number || 0) - (b.issue.number || 0)),
84
+ }));
85
+ }
86
+
87
+ export function noteText(note) {
88
+ const per = Array.isArray(note?.perIssue) ? note.perIssue : [];
89
+ return [str(note?.thanks), ...per.flatMap((e) => FIELDS.map((f) => str(e?.[f])))]
90
+ .map((s) => s.trim()).filter(Boolean).join('\n\n');
91
+ }
92
+
93
+ // ---- what counts as the person's own, distinctive words -------------------------------------------------------------
94
+
95
+ function codeTokens(text) {
96
+ const out = [];
97
+ for (const raw of str(text).split(/\s+/)) {
98
+ let t = raw.replace(/^["'`(<[{“‘]+/, '').replace(/[.,;:!?"'`>\]}”’]+$/, '');
99
+ while (t.endsWith(')') && (t.match(/\(/g) || []).length < (t.match(/\)/g) || []).length) t = t.slice(0, -1).replace(/[.,;:!?]+$/, '');
100
+ if (alnumCount(t) < 4) continue;
101
+ const codeLike = /[./_():\\]/.test(t) || /[a-z][A-Z]/.test(t) || /^--?[A-Za-z]/.test(t) || /^E[A-Z]{3,}$/.test(t);
102
+ if (codeLike) out.push(t);
103
+ }
104
+ return out;
105
+ }
106
+
107
+ function quotedSpans(text) {
108
+ const out = [];
109
+ const res = [/`([^`\n]+)`/g, /"([^"\n]+)"/g, /“([^”\n]+)”/g, /(?:^|[\s(])'([^'\n]+?)'(?=[\s.,;:!?)]|$)/g];
110
+ for (const re of res) {
111
+ for (const m of str(text).matchAll(re)) {
112
+ const span = m[1].trim().replace(/\s+/g, ' ');
113
+ if (span.length < 8) continue;
114
+ if (!normWords(span).some((w) => w.length >= 3 && !STOP.has(w))) continue; // stop-words only
115
+ out.push(span);
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+
121
+ // Extension beyond tokens and quotes: a reporter who writes plain English with no code and no quotes would otherwise
122
+ // be unquotable and always refused. Any run of 6 consecutive words of theirs with >= 3 content words also counts.
123
+ function phraseGrams(text) {
124
+ const w = normWords(text);
125
+ const grams = new Set();
126
+ for (let i = 0; i + 6 <= w.length; i++) {
127
+ const g = w.slice(i, i + 6);
128
+ if (g.filter((x) => x.length >= 3 && !STOP.has(x)).length >= 3) grams.add(g.join(' '));
129
+ }
130
+ return grams;
131
+ }
132
+
133
+ function personSpans(login, items) {
134
+ const text = items.map((i) => reporterWords(i.issue, login)).join('\n\n');
135
+ return { literal: uniq([...codeTokens(text), ...quotedSpans(text)]), grams: phraseGrams(text), text };
136
+ }
137
+
138
+ const collapse = (s) => str(s).replace(/\s+/g, ' ');
139
+ function hasSpan(s, spans) {
140
+ const flat = collapse(s);
141
+ if (spans.literal.some((t) => flat.includes(t))) return true;
142
+ const w = normWords(s);
143
+ for (let i = 0; i + 6 <= w.length; i++) if (spans.grams.has(w.slice(i, i + 6).join(' '))) return true;
144
+ return false;
145
+ }
146
+
147
+ // GENERIC RULE (documented choice). The thanks is generic when EITHER it carries no verbatim span of the person's
148
+ // words at all (then it is equally true of any issue), OR more than 60% of its sentences are generic. A sentence that
149
+ // carries a verbatim span counts as specific (equivalent to deleting the span and keeping the sentence's credit); any
150
+ // other sentence is generic when, after removing stop-words and the GENERIC_VOCAB of praise/apology/"the project"
151
+ // filler, fewer than 2 content words (>= 3 letters) remain. This catches a form letter with one token pasted in.
152
+ function isGenericSentence(s) {
153
+ return normWords(s).filter((w) => w.length >= 3 && !/^\d+$/.test(w) && !STOP.has(w) && !GENERIC_VOCAB.has(w)).length < 2;
154
+ }
155
+ function genericThanks(thanks, spans) {
156
+ const ss = sentences(thanks);
157
+ if (!ss.length) return true;
158
+ if (!hasSpan(thanks, spans)) return true;
159
+ const generic = ss.filter((s) => !hasSpan(s, spans) && isGenericSentence(s)).length;
160
+ return generic / ss.length > GENERIC_RATIO;
161
+ }
162
+
163
+ // ---- text guards --------------------------------------------------------------------------------------------------
164
+
165
+ const stripCode = (s) => str(s).replace(/`[^`\n]*`/g, ' ');
166
+ const MENTION_RE = /(^|[^\w@`])@([A-Za-z0-9][A-Za-z0-9-]*)/g;
167
+ const CLOSING_RE = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*(?:#\d+|[\w.-]+\/[\w.-]+#\d+|https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+)/i;
168
+ const PATH_RE = [/(^|[^\w.])\/Users\/[^\s/]+/, /(^|[^\w.])\/home\/[^\s/]+/, /\b[A-Za-z]:\\Users\b/i, /~\/\.\w/, /\/private\/var\b/, /(^|[\s`'"(=])\/tmp\//];
169
+ const SECRET_RE = [/\bgh[pos]_[A-Za-z0-9]{20,}/, /\bgithub_pat_[A-Za-z0-9_]{20,}/, /\bnpm_[A-Za-z0-9]{20,}/,
170
+ /\bsk-ant-[A-Za-z0-9_-]{10,}/, /\bsk-[A-Za-z0-9_-]{20,}/, /\bAKIA[0-9A-Z]{16}\b/, /-----BEGIN [A-Z ]*-----/];
171
+ const MODEL_RE = /\b(claude|opus|sonnet|haiku|anthropic|gpt|openai|llms?|language models?|system prompts?|prompts?|prompted|as an ai|automated pipeline|autofix|the bot|token budget)\b/i;
172
+ const CLAIM_RE = /\b(fixed|shipped|released|published|resolved|is live|are live|now live)\b/gi;
173
+ const HEDGE_RE = /\b(not|never|no|nothing|none|neither|nor|without|until|once|when|if|after|yet|unless|before|will|would|should|could|may|might|to be)\b|n't\b/i;
174
+ const VERIFY_RE = /\b(verified|verify|install-verified|tested|tests? pass(?:ed|es)?|passes|passed|confirmed|checked|reproduced|ran|runs|works|working)\b/i;
175
+ const NEG_RE = /\b(not|never|no|unverified|untested|without)\b|n't\b/i;
176
+ const PLATFORM_RE = /\b(windows|macos|mac|linux|codex|grok)\b/gi;
177
+ const platformKey = (p) => (p === 'mac' ? 'macos' : p);
178
+ const platformsIn = (s) => new Set([...lower(s).matchAll(PLATFORM_RE)].map((m) => platformKey(m[1])));
179
+
180
+ // Code the note quotes is not a claim: `resolved.projectRoot`, { db: resolved } and backticked spans are removed first
181
+ // (measured 2026-10-30: a true cause quoting resolveProjectStore's `resolved` read as "resolved").
182
+ const proseOnly = (t) => str(t).replace(/`[^`]*`/g, ' ').replace(/\{[^{}]*\}/g, ' ').replace(/[\w$]+(?:\.[\w$]+)+/g, ' ').replace(/\b\w+\(/g, ' ');
183
+ function claimsDone(raw) {
184
+ const text = proseOnly(raw);
185
+ for (const m of text.matchAll(CLAIM_RE)) {
186
+ const after = text.slice(m.index + m[0].length, m.index + m[0].length + 24);
187
+ if (/^\s+yet\b/i.test(after)) continue; // "shipped yet"
188
+ // Only an ASSERTED state is a claim: "is fixed", "it shipped", "now live", "fixed in v4.7.23". The adjective in
189
+ // "carry the resolved project root" is not (measured 2026-10-30 on a real note).
190
+ const lead = text.slice(Math.max(0, m.index - 30), m.index).toLowerCase().split(/\s+/).filter(Boolean).slice(-3);
191
+ const asserted = lead.some((w) => /^(is|are|was|were|be|been|being|has|have|had|it|this|that|now|got|gets|it's|that's|its)$/.test(w.replace(/[^\w']/g, '')))
192
+ || /^\s+(in|as of)\s+(v?\d|the latest|version)/i.test(after) || /^(is|are|now)\s+live$/i.test(m[0]);
193
+ if (!asserted) continue;
194
+ let before = text.slice(Math.max(0, m.index - 40), m.index);
195
+ const cut = Math.max(...[',', ';', ':', '.', '!', '?'].map((c) => before.lastIndexOf(c)));
196
+ if (cut >= 0) before = before.slice(cut + 1);
197
+ if (!HEDGE_RE.test(before)) return true;
198
+ }
199
+ return false;
200
+ }
201
+ // Platforms a text says were verified, ignoring negated sentences ("not tested on Windows" is truthful).
202
+ function claimedPlatforms(text) {
203
+ const out = new Set();
204
+ for (const s of sentences(text)) {
205
+ if (!VERIFY_RE.test(s) || NEG_RE.test(s)) continue;
206
+ for (const p of platformsIn(s)) out.add(p);
207
+ }
208
+ return out;
209
+ }
210
+ const verifiedPlatforms = (its) => new Set(its.flatMap((i) => (Array.isArray(i?.facts?.verified) ? i.facts.verified : []).flatMap((v) => [...platformsIn(v)])));
211
+
212
+ // ---- similarity ---------------------------------------------------------------------------------------------------
213
+
214
+ function ngrams(words, n) { const s = new Set(); for (let i = 0; i + n <= words.length; i++) s.add(words.slice(i, i + n).join(' ')); return s; }
215
+ function charTrigrams(text) { const t = normWords(text).join(' '); const s = new Set(); for (let i = 0; i + 3 <= t.length; i++) s.add(t.slice(i, i + 3)); return s; }
216
+ function jaccard(a, b) { if (!a.size && !b.size) return 0; let inter = 0; for (const x of a) if (b.has(x)) inter++; return inter / (a.size + b.size - inter); }
217
+
218
+ function similarityReasons(text, priorNotes) {
219
+ const reasons = [];
220
+ const w = normWords(text);
221
+ const g6 = ngrams(w, 6);
222
+ const tri = charTrigrams(text);
223
+ for (const p of Array.isArray(priorNotes) ? priorNotes : []) {
224
+ const pt = str(p?.text);
225
+ if (!pt.trim()) continue;
226
+ const pw = normWords(pt);
227
+ if ([...ngrams(pw, 6)].some((g) => g6.has(g))) reasons.push('too-similar:ngram');
228
+ if (w.length >= 5 && pw.length >= 5 && w.slice(0, 5).join(' ') === pw.slice(0, 5).join(' ')) reasons.push('too-similar:opening');
229
+ if (w.length >= 5 && pw.length >= 5 && w.slice(-5).join(' ') === pw.slice(-5).join(' ')) reasons.push('too-similar:closing');
230
+ if (jaccard(tri, charTrigrams(pt)) >= TRIGRAM_LIMIT) reasons.push('too-similar:trigram');
231
+ }
232
+ return reasons;
233
+ }
234
+
235
+ // ---- the gate -----------------------------------------------------------------------------------------------------
236
+
237
+ export function checkNote({ note, login, items, priorNotes = [], now = Date.now() } = {}) {
238
+ const reasons = [];
239
+ const its = (Array.isArray(items) ? items : []).filter((i) => i && i.issue);
240
+ const thanks = str(note?.thanks);
241
+ const per = Array.isArray(note?.perIssue) ? note.perIssue : [];
242
+ const numbers = new Set(its.map((i) => i.issue.number));
243
+ const entryFor = (n) => per.find((e) => e && e.number === n);
244
+ const text = noteText(note);
245
+ const spans = personSpans(login, its);
246
+
247
+ // structure
248
+ if (!thanks.trim()) reasons.push('empty');
249
+ for (const n of numbers) if (!str(entryFor(n)?.whatWasWrong).trim()) reasons.push(`missing-issue:${n}`);
250
+ for (const e of per) if (!numbers.has(e?.number)) reasons.push(`extra-issue:${e?.number}`);
251
+ if (thanks.length > THANKS_MAX || per.some((e) => FIELDS.some((f) => str(e?.[f]).length > FIELD_MAX))) reasons.push('too-long');
252
+ if (thanks.trim() && thanks.trim().length < THANKS_MIN) reasons.push('too-short');
253
+
254
+ // personal, not a form letter
255
+ if (thanks.trim() && !hasSpan(thanks, spans)) reasons.push('not-specific');
256
+ if (thanks.trim() && genericThanks(thanks, spans)) reasons.push('generic');
257
+ if (BOILERPLATE.some((p) => lower(text).includes(p))) reasons.push('boilerplate');
258
+ reasons.push(...similarityReasons(text, priorNotes));
259
+ if (its.some((i) => waitedDays(i.issue, now) > WAIT_APOLOGY_DAYS) && !/\b(sorry|apolog)/i.test(text)) reasons.push('no-apology');
260
+
261
+ // safety
262
+ const who = lower(login);
263
+ if ([...stripCode(text).matchAll(MENTION_RE)].some((m) => m[2].toLowerCase() !== who)) reasons.push('mentions-other');
264
+ if (CLOSING_RE.test(text)) reasons.push('closing-keyword');
265
+ if (text.includes(BOT_MARKER) || text.includes('<!--') || text.includes('-->')) reasons.push('marker');
266
+ if (PATH_RE.some((re) => re.test(text))) reasons.push('path-leak');
267
+ if (SECRET_RE.some((re) => re.test(text))) reasons.push('secret');
268
+ // The person's own backticked words (e.g. `claude mcp add`) are theirs to quote; everything else is checked.
269
+ const ownCode = text.replace(/`([^`\n]*)`/g, (m, inner) => (spans.text.includes(inner) ? ' ' : m));
270
+ if (MODEL_RE.test(ownCode)) reasons.push('model-internals');
271
+
272
+ // truthfulness. A thanks sentence is attributed to an issue when it names #N; with no fixed item at all, the whole
273
+ // thanks speaks for every issue. Platform claims in the thanks are checked against every item's verified facts.
274
+ const anyFixed = its.some((i) => i.kind === 'fixed');
275
+ const thanksFor = (n) => (anyFixed ? sentences(thanks).filter((s) => new RegExp(`#${n}\\b`).test(s)).join(' ') : thanks);
276
+ const thanksClaims = claimedPlatforms(thanks);
277
+ for (const i of its) {
278
+ const n = i.issue.number;
279
+ const entry = entryFor(n);
280
+ const own = FIELDS.map((f) => str(entry?.[f])).join('\n');
281
+ const doneClaim = i.kind !== 'fixed' && (claimsDone(own) || claimsDone(thanksFor(n)));
282
+ const ok = verifiedPlatforms([i]);
283
+ const named = sentences(thanks).filter((s) => new RegExp(`#${n}\\b`).test(s)).join(' ');
284
+ const platformClaim = [...claimedPlatforms(own), ...claimedPlatforms(named)].some((p) => !ok.has(p))
285
+ || [...thanksClaims].some((p) => !verifiedPlatforms(its).has(p) && !ok.has(p));
286
+ if (doneClaim || platformClaim) reasons.push(`overclaim:${n}`);
287
+ }
288
+ const out = uniq(reasons);
289
+ return { ok: out.length === 0, reasons: out };
290
+ }
291
+
292
+ // ---- regenerate loop ----------------------------------------------------------------------------------------------
293
+
294
+ function feedbackFor(reasons, { login, items, text }) {
295
+ const spans = personSpans(login, items).literal.slice(0, 3).map((s) => (s.length > 60 ? s.slice(0, 60) : s));
296
+ const msgs = [];
297
+ for (const r of reasons) {
298
+ const [id, arg] = r.split(':');
299
+ const item = items.find((i) => String(i.issue.number) === arg);
300
+ switch (id) {
301
+ case 'empty': msgs.push('The "thanks" field was empty; write a real thank-you paragraph.'); break;
302
+ case 'missing-issue': msgs.push(`Add a perIssue entry for #${arg} with a plain, non-empty whatWasWrong.`); break;
303
+ case 'extra-issue': msgs.push(`Remove the perIssue entry for #${arg}; this person did not file it.`); break;
304
+ case 'not-specific': msgs.push(`Quote, word for word, something specific this person wrote${spans.length ? `, for example: ${spans.map((s) => `"${s}"`).join(', ')}` : ''}.`); break;
305
+ case 'generic': msgs.push('Most sentences would fit any issue; make each sentence about what this person actually found or did.'); break;
306
+ case 'boilerplate': msgs.push(`Drop stock phrases (${BOILERPLATE.filter((p) => lower(text).includes(p)).map((p) => `"${p}"`).join(', ')}); say it in your own words.`); break;
307
+ case 'too-similar':
308
+ msgs.push({ ngram: 'A run of six words repeats an earlier note; rephrase in fresh words.',
309
+ opening: 'Your opening repeats an earlier note; start differently.',
310
+ closing: 'Your ending repeats an earlier note; end differently.',
311
+ trigram: 'The note reads too much like an earlier one; change its structure, length and wording.' }[arg] || 'Too similar to an earlier note.');
312
+ break;
313
+ case 'no-apology': msgs.push('This person waited more than a week; say plainly that you are sorry it took so long.'); break;
314
+ case 'mentions-other': msgs.push(`Do not @mention anyone other than @${login}; put package names in backticks.`); break;
315
+ case 'closing-keyword': msgs.push('Do not write fix/close/resolve next to an issue number or issue link.'); break;
316
+ case 'marker': msgs.push('Remove the robot marker and any HTML comments.'); break;
317
+ case 'path-leak': msgs.push('Remove local file paths (home directories, temp folders).'); break;
318
+ case 'secret': msgs.push('Remove anything that looks like a token or key.'); break;
319
+ case 'model-internals': msgs.push('Do not mention the AI model, prompts or the automation behind this note.'); break;
320
+ case 'overclaim': msgs.push(`#${arg} is "${item?.kind}": do not say it is fixed, shipped, released or live, and only name a platform as verified if it appears in: ${(item?.facts?.verified || []).join('; ') || 'nothing was verified'}.`); break;
321
+ case 'too-long': msgs.push(`Shorten it: thanks at most ${THANKS_MAX} characters, each per-issue field at most ${FIELD_MAX}.`); break;
322
+ case 'too-short': msgs.push('The thanks is too short; write at least a couple of real sentences.'); break;
323
+ case 'generate-failed': msgs.push('The previous attempt produced no usable note; return a complete note object.'); break;
324
+ default: msgs.push(`Fix: ${r}.`);
325
+ }
326
+ }
327
+ return uniq(msgs);
328
+ }
329
+
330
+ export async function generatePersonNote({ login, items, priorNotes = [], now = Date.now(), generate, max = MAX_REGENERATIONS } = {}) {
331
+ const its = Array.isArray(items) ? items : [];
332
+ const rejections = [];
333
+ let feedback = [];
334
+ const total = 1 + Math.max(0, Number.isInteger(max) ? max : MAX_REGENERATIONS);
335
+ for (let attempt = 0; attempt < total; attempt++) {
336
+ let note = null;
337
+ try { note = await generate({ attempt, feedback }); } catch { note = null; }
338
+ const reasons = note && typeof note === 'object'
339
+ ? checkNote({ note, login, items: its, priorNotes, now }).reasons
340
+ : ['generate-failed'];
341
+ if (!reasons.length) return { ok: true, note, attempts: attempt + 1, rejections };
342
+ rejections.push(reasons);
343
+ feedback = feedbackFor(reasons, { login, items: its, text: noteText(note) });
344
+ }
345
+ return { ok: false, note: null, attempts: total, rejections };
346
+ }