renor 0.1.0 → 0.2.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.
@@ -0,0 +1,390 @@
1
+ /**
2
+ * The owner's standing rules in every review: the pure core of `renor review-rules`. Nothing here touches the network or
3
+ * the disk; the CLI (review-rules-cli.mjs) fetches rules from Renor (review-rules-source.mjs) and hands them in.
4
+ *
5
+ * A rule, as every function here takes it:
6
+ * { id, date, text, kind: 'fact' | 'memory', subject?, predicate?, scope?, current?, replaced?, untrusted? }
7
+ * `id` is the Renor memory id (a fact's event id, `mem_…`, or a note's id from memory_map), `date` the day it was saved
8
+ * (YYYY-MM-DD), `text` the stored words, quoted byte for byte. `current: false` (a superseded fact), `replaced: true` (a
9
+ * note a later correction replaced) and `untrusted: true` (an import, or a reserved source) are never quoted.
10
+ *
11
+ * The plain function to run on a private set (firstmate's laptop gate) is `buildReviewRulesBlock({ rules, diff, maxTokens })`.
12
+ */
13
+
14
+ /**
15
+ * The predicate words a standing rule, preference or decision is saved under. The first group is the engine's own
16
+ * GOVERNANCE_PREDICATES (engine/src/mvp/agentMemory.ts), the words its facts lane reads as a standing rule; the second is
17
+ * what a preference is saved under. A predicate counts when it is one of these or has one as a word (`test_policy`,
18
+ * `ui preference`).
19
+ */
20
+ export const RULE_PREDICATE_WORDS = [
21
+ 'rule', 'rules', 'policy', 'policies', 'decision', 'decisions', 'guideline', 'guidelines',
22
+ 'constraint', 'constraints', 'requirement', 'requirements', 'must', 'convention', 'conventions',
23
+ 'principle', 'principles', 'guardrail', 'guardrails', 'permission', 'permissions', 'approval', 'approvals',
24
+ 'preference', 'preferences', 'prefer', 'prefers', 'preferred', 'style', 'avoid', 'never', 'always', 'standard', 'standards',
25
+ ]
26
+
27
+ export function isRulePredicate(predicate) {
28
+ if (typeof predicate !== 'string') return false
29
+ return predicate.toLowerCase().split(/[^\p{L}\p{N}]+/u).some((word) => word !== '' && RULE_PREDICATE_WORDS.includes(word))
30
+ }
31
+
32
+ /** Whether a plain-words memory reads as a standing rule, preference or decision rather than a note of what happened. */
33
+ export function looksLikeRule(text) {
34
+ if (typeof text !== 'string') return false
35
+ if (/^\s*(rule|preference|decision|policy|convention|guideline|standing rule)s?\s*[:\-—]/i.test(text)) return true
36
+ return /\b(always|never|must|must not|do not|don't|prefer|prefers|avoid|decided)\b/i.test(text)
37
+ }
38
+
39
+ /** About how many tokens a text costs: one per four UTF-8 bytes, rounded up (o200k averages a little over that on English). */
40
+ export function estimateTokens(text) {
41
+ return Math.ceil(Buffer.byteLength(text, 'utf8') / 4)
42
+ }
43
+
44
+ const STOP_WORDS = new Set(('the a an and or but if then else for to of in on at by with from as is are was were be been it its this that these '
45
+ + 'those not no do does did done can could should would will shall may might must always never only also any all each every '
46
+ + 'one two our we you your they them their there here what when where which who why how than too very more most such into '
47
+ + 'out over under about after before again just use used using new old get set add const let var return true false null '
48
+ + 'undefined import export function class type interface diff git index file files line lines same like way thing').split(' '))
49
+
50
+ /** Words of a text, for matching: lower case, camelCase and snake_case split, stop words and one- or two-letter words out, light stemming. */
51
+ export function terms(text) {
52
+ const words = String(text)
53
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
54
+ .toLowerCase()
55
+ .split(/[^\p{L}\p{N}]+/u)
56
+ const out = []
57
+ for (const word of words) {
58
+ if (word.length < 3 || STOP_WORDS.has(word) || /^\d+$/.test(word)) continue
59
+ const stemmed = stem(word)
60
+ if (!STOP_WORDS.has(stemmed)) out.push(stemmed)
61
+ }
62
+ return out
63
+ }
64
+
65
+ /** A doubled final consonant left by cutting -ing or -ed (`logg`, `mapp`) back to one, as the plain word has it. */
66
+ const undouble = (word) => (word.length >= 4 && /([b-df-hj-np-tv-z])\1$/.test(word) && !/(ss|ll|ff|zz)$/.test(word) ? word.slice(0, -1) : word)
67
+
68
+ function stem(word) {
69
+ if (word.length > 5 && word.endsWith('ing')) return undouble(word.slice(0, -3))
70
+ if (word.length > 4 && word.endsWith('ies')) return word.slice(0, -3) + 'y'
71
+ if (word.length > 4 && word.endsWith('ed')) return undouble(word.slice(0, -2))
72
+ if (word.length > 4 && word.endsWith('es') && /(ss|x|ch|sh)es$/.test(word)) return word.slice(0, -2)
73
+ if (word.length > 3 && word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1)
74
+ return word
75
+ }
76
+
77
+ /** The language a file extension names, as a rule would say it. */
78
+ const LANGUAGES = { ts: 'typescript', tsx: 'typescript', mts: 'typescript', cts: 'typescript', js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript', py: 'python', rs: 'rust', go: 'golang', sql: 'sql', md: 'markdown', css: 'css' }
79
+
80
+ function pathTerms(path) {
81
+ const ext = /\.([a-z]+)$/i.exec(path)?.[1]?.toLowerCase()
82
+ const language = ext && LANGUAGES[ext]
83
+ return terms(language ? `${path} ${language}` : path)
84
+ }
85
+
86
+ /**
87
+ * The diff's words, path words apart: `{ paths: Set, body: Set }`. Only changed lines (+/-) and file names count, never
88
+ * context lines. Two things a rule names that no word of the diff says are added as words: an added line indented with a
89
+ * tab (`tab`, `indentation`), and a gendered pronoun in an added line (`pronoun`).
90
+ */
91
+ export function diffTerms(diff) {
92
+ const paths = new Set()
93
+ const body = new Set()
94
+ for (const line of String(diff || '').split('\n')) {
95
+ let m
96
+ if ((m = /^diff --git a\/(.*) b\/(.*)$/.exec(line))) {
97
+ for (const t of pathTerms(m[1]).concat(pathTerms(m[2]))) paths.add(t)
98
+ } else if ((m = /^(?:\+\+\+|---) (?:[ab]\/)?(.*)$/.exec(line))) {
99
+ if (m[1] !== '/dev/null') for (const t of pathTerms(m[1])) paths.add(t)
100
+ } else if (/^[+-]/.test(line)) {
101
+ for (const t of terms(line.slice(1))) body.add(t)
102
+ if (line[0] === '+' && line[1] === '\t') body.add('tab').add(stem('indentation'))
103
+ if (line[0] === '+' && /\b(he|she|him|her|his|hers)\b/i.test(line.slice(1))) body.add(stem('pronoun'))
104
+ }
105
+ }
106
+ return { paths, body }
107
+ }
108
+
109
+ /** The changed files of a diff, in order. */
110
+ export function diffFiles(diff) {
111
+ const files = []
112
+ for (const line of String(diff || '').split('\n')) {
113
+ const m = /^diff --git a\/(.*) b\/(.*)$/.exec(line)
114
+ if (m && !files.includes(m[2])) files.push(m[2])
115
+ }
116
+ return files
117
+ }
118
+
119
+ /**
120
+ * The subjects a rule can name that make it bear on every review: a rule about reviews, PRs or the code as a whole. Such a
121
+ * rule is kept even when no word of the diff matches it, and then ranks after every rule that one did.
122
+ */
123
+ const GENERAL_SUBJECTS = new Set(['review', 'reviews', 'code review', 'code reviews', 'reviewer', 'reviewers', 'pr', 'prs',
124
+ 'pull request', 'pull requests', 'code', 'codebase', 'all code', 'everything', 'general', 'repo', 'repository'])
125
+
126
+ /** Whether a rule may be quoted at all: current, not replaced, not untrusted, with an id and words. */
127
+ export function quotable(rule) {
128
+ return Boolean(rule && typeof rule.id === 'string' && rule.id !== '' && typeof rule.text === 'string' && rule.text.trim() !== ''
129
+ && rule.current !== false && rule.replaced !== true && rule.untrusted !== true)
130
+ }
131
+
132
+ /**
133
+ * Every quotable rule with its relevance to `diff`, most relevant first; a rule the diff does not bear on is left out.
134
+ *
135
+ * A shared word weighs more the fewer rules have it (so `migration` outweighs `test` when most rules mention tests). A word
136
+ * of a changed file's path counts double, and so does a word of a fact's predicate that is not itself a rule word (the
137
+ * `migration` of `migration_rule`): the predicate names what the rule is about. A rule whose score is under `minShare` of
138
+ * the best rule's is left out, so one coincidental word (`default`, `tenant`) does not bring in an unrelated rule; a rule
139
+ * about reviews or the code as a whole (GENERAL_SUBJECTS) is always kept, after the matched ones. Ties go to the newer
140
+ * rule, then the id, so the order is stable. Without a diff every quotable rule is kept, newest first.
141
+ */
142
+ export function rankRules(rules, diff, { minShare = 0.3 } = {}) {
143
+ const usable = dedupe(rules.filter(quotable))
144
+ const ruleTerms = usable.map((rule) => new Set(terms([rule.subject, rule.predicate, rule.text].filter(Boolean).join(' '))))
145
+ const topicTerms = usable.map((rule) => new Set(terms(rule.predicate || '').filter((t) => !RULE_PREDICATE_WORDS.includes(t))))
146
+ const df = new Map()
147
+ for (const set of ruleTerms) for (const t of set) df.set(t, (df.get(t) || 0) + 1)
148
+ const n = usable.length
149
+ const haveDiff = typeof diff === 'string' && diff.trim() !== ''
150
+ const { paths, body } = diffTerms(diff)
151
+ const ranked = usable.map((rule, i) => {
152
+ let score = 0
153
+ const matched = []
154
+ if (haveDiff) {
155
+ for (const t of ruleTerms[i]) {
156
+ const weight = (paths.has(t) ? 2 : 0) + (body.has(t) ? 1 : 0)
157
+ if (weight === 0) continue
158
+ score += weight * (topicTerms[i].has(t) ? 2 : 1) * Math.log(1 + n / df.get(t))
159
+ matched.push(t)
160
+ }
161
+ }
162
+ const general = GENERAL_SUBJECTS.has(String(rule.subject || '').trim().toLowerCase())
163
+ return { rule, score, matched, general }
164
+ })
165
+ const best = Math.max(0, ...ranked.map((entry) => entry.score))
166
+ const kept = haveDiff
167
+ ? ranked.filter((entry) => entry.score > 0 && entry.score >= minShare * best).concat(
168
+ ranked.filter((entry) => entry.general && !(entry.score > 0 && entry.score >= minShare * best)).map((entry) => ({ ...entry, score: 0 })))
169
+ : ranked
170
+ return kept
171
+ .sort((a, b) => b.score - a.score || String(b.rule.date || '').localeCompare(String(a.rule.date || '')) || a.rule.id.localeCompare(b.rule.id))
172
+ .map(({ general: _general, ...entry }) => entry)
173
+ }
174
+
175
+ /** One rule per id; a second copy (the same fact found twice) is dropped. */
176
+ function dedupe(rules) {
177
+ const seen = new Set()
178
+ return rules.filter((rule) => (seen.has(rule.id) ? false : (seen.add(rule.id), true)))
179
+ }
180
+
181
+ export const CITE_INSTRUCTION = 'When a finding rests on one of these rules, cite its id in that finding as "(rule <id>)", the id in brackets above the rule. '
182
+ + 'Cite a rule only for a finding it supports, and never quote a rule other than as written here.'
183
+
184
+ const BLOCK_INTRO = 'The owner\'s standing rules that bear on this change, from Renor, most relevant first. Each is quoted exactly as it was saved, with the day it was saved and its memory id.'
185
+
186
+ /** One rule as the block shows it: a header line, then the stored text, untouched (one line as a `>` quote, more in a fence). */
187
+ export function formatRule(rule) {
188
+ const where = rule.kind === 'fact' && rule.subject ? ` · ${rule.subject} · ${rule.predicate}` : ''
189
+ const head = `[${rule.id}] saved ${rule.date || 'on an unknown day'}${where}`
190
+ if (!/[\r\n]/.test(rule.text)) return `${head}\n> ${rule.text}\n`
191
+ const longest = Math.max(0, ...[...rule.text.matchAll(/`+/g)].map((m) => m[0].length))
192
+ const fence = '`'.repeat(Math.max(3, longest + 1))
193
+ return `${head}\n${fence}\n${rule.text}${rule.text.endsWith('\n') ? '' : '\n'}${fence}\n`
194
+ }
195
+
196
+ /**
197
+ * Rules in rank order, as many as fit `maxTokens` with the frame around them. A rule that does not fit is skipped whole
198
+ * (never cut) and a later, shorter one may still fit. Returns `{ text, included, omitted }`; `text` is '' when no rule fits.
199
+ */
200
+ function fitRules(ranked, { maxTokens, intro, outro }) {
201
+ const frame = `${intro}\n\n${outro}\n`
202
+ let used = estimateTokens(frame)
203
+ const included = []
204
+ const omitted = []
205
+ for (const entry of ranked) {
206
+ const piece = formatRule(entry.rule) + '\n'
207
+ const cost = estimateTokens(piece)
208
+ if (used + cost > maxTokens) {
209
+ omitted.push(entry.rule)
210
+ continue
211
+ }
212
+ used += cost
213
+ included.push(entry.rule)
214
+ }
215
+ if (included.length === 0) return { text: '', included, omitted }
216
+ return { text: `${intro}\n\n${included.map((rule) => formatRule(rule) + '\n').join('')}${outro}\n`, included, omitted }
217
+ }
218
+
219
+ export const DEFAULT_MAX_TOKENS = 600
220
+
221
+ /**
222
+ * The block to put at the top of a review prompt.
223
+ *
224
+ * buildReviewRulesBlock({ rules: Rule[], diff: string, maxTokens?: number = 600 })
225
+ * → { block: string, included: Rule[], omitted: Rule[], ranked: Array<{ rule, score, matched }> }
226
+ *
227
+ * `block` is '' when no current rule bears on the diff. Superseded, replaced and untrusted rules are never in it.
228
+ */
229
+ export function buildReviewRulesBlock({ rules, diff, maxTokens = DEFAULT_MAX_TOKENS }) {
230
+ const ranked = rankRules(rules, diff)
231
+ const fitted = fitRules(ranked, { maxTokens, intro: BLOCK_INTRO, outro: CITE_INSTRUCTION })
232
+ return { block: fitted.text, included: fitted.included, omitted: fitted.omitted, ranked }
233
+ }
234
+
235
+ // --- --export: one marked section of a guidelines file -----------------------------------------------------------------
236
+
237
+ export const SECTION_START = '<!-- renor:review-rules:start -->'
238
+ export const SECTION_END = '<!-- renor:review-rules:end -->'
239
+ const SECTION_NOTE = '<!-- Written by `npx renor review-rules --export`. Everything between these markers is replaced on the next export; edit the rules in Renor. -->'
240
+ const SECTION_HEADING = '## The owner\'s standing rules (from Renor)'
241
+ const SECTION_INTRO = 'Each rule is quoted exactly as the owner saved it in Renor, with the day it was saved and its memory id.'
242
+
243
+ export const DEFAULT_EXPORT_MAX_TOKENS = 1500
244
+
245
+ /** The section's text, markers included, with `\n` line ends. Same rules in, same bytes out: nothing in it depends on the clock. */
246
+ export function renderRulesSection({ rules, diff, maxTokens = DEFAULT_EXPORT_MAX_TOKENS }) {
247
+ const ranked = rankRules(rules, diff)
248
+ const fitted = fitRules(ranked, { maxTokens, intro: `${SECTION_HEADING}\n\n${SECTION_INTRO}`, outro: CITE_INSTRUCTION })
249
+ const body = fitted.text || `${SECTION_HEADING}\n\nRenor holds no standing rule to list yet.\n`
250
+ return { section: `${SECTION_START}\n${SECTION_NOTE}\n\n${body}${SECTION_END}\n`, included: fitted.included, omitted: fitted.omitted }
251
+ }
252
+
253
+ /**
254
+ * `fileText` with its marked section replaced by `section`, or with `section` appended when it has none. Nothing outside
255
+ * the markers changes. Throws when the markers are not one well-formed pair, rather than guess which text is ours. The
256
+ * file's own line ending (CRLF or LF) is kept.
257
+ */
258
+ export function updateMarkedSection(fileText, section) {
259
+ const crlf = /\r\n/.test(fileText)
260
+ const ours = crlf ? section.replace(/\n/g, '\r\n') : section
261
+ const eol = crlf ? '\r\n' : '\n'
262
+ const lineStarts = (marker) => {
263
+ const at = []
264
+ const re = new RegExp(`(^|\\n)${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[ \\t]*(\\r?\\n|$)`, 'g')
265
+ let m
266
+ while ((m = re.exec(fileText))) {
267
+ at.push({ index: m.index + m[1].length, end: m.index + m[0].length })
268
+ re.lastIndex = m.index + m[0].length - m[2].length
269
+ }
270
+ return at
271
+ }
272
+ const starts = lineStarts(SECTION_START)
273
+ const ends = lineStarts(SECTION_END)
274
+ if (starts.length === 0 && ends.length === 0) {
275
+ if (fileText === '') return ours
276
+ const sep = fileText.endsWith(eol + eol) ? '' : fileText.endsWith(eol) ? eol : eol + eol
277
+ return fileText + sep + ours
278
+ }
279
+ if (starts.length !== 1 || ends.length !== 1 || ends[0].index < starts[0].index) {
280
+ throw new Error(`the file has ${starts.length} start and ${ends.length} end marker(s) for the Renor rules section; expected one pair, start first. Fix it by hand, then export again.`)
281
+ }
282
+ // The section is replaced through the end marker's line end; a file whose end marker is its last line, with no line
283
+ // end, keeps having none after it.
284
+ const tail = fileText.slice(ends[0].end)
285
+ const endHadEol = /\r?\n$/.test(fileText.slice(ends[0].index, ends[0].end))
286
+ const replacement = endHadEol ? ours : ours.replace(/\r?\n$/, '')
287
+ return fileText.slice(0, starts[0].index) + replacement + tail
288
+ }
289
+
290
+ // --- --propose: recurring findings of past reviews ---------------------------------------------------------------------
291
+
292
+ /** The findings of one review: its list items and its bold-led lines, with their text as written. */
293
+ export function findingsOf(text) {
294
+ const out = []
295
+ for (const raw of String(text).split(/\r?\n/)) {
296
+ const m = /^\s*(?:[-*+]|\d+[.)])\s+(.*\S)\s*$/.exec(raw) || /^\s*(\*\*.*\S)\s*$/.exec(raw)
297
+ if (!m) continue
298
+ const finding = m[1]
299
+ if (terms(cleanFinding(finding)).length < 3) continue
300
+ out.push(finding)
301
+ }
302
+ return out
303
+ }
304
+
305
+ /** A finding without its severity tag, emphasis, `file:line` references or code spans: the words a rule would keep. */
306
+ export function cleanFinding(finding) {
307
+ return finding
308
+ .replace(/\[(?:P\d|nit|blocker|major|minor)\]/gi, '')
309
+ .replace(/`[^`]*`/g, '')
310
+ .replace(/\b[\w./-]+\.[a-z]{1,5}(?::\d+(?:-\d+)?)?\b/gi, '')
311
+ .replace(/[*_]+/g, '')
312
+ .replace(/\s+/g, ' ')
313
+ .replace(/^[\s:.\-—]+|[\s:—-]+$/g, '')
314
+ .trim()
315
+ }
316
+
317
+ /**
318
+ * Recurring findings across past reviews, as proposed rules for the owner to approve. Reads only what it is given and
319
+ * saves nothing anywhere.
320
+ *
321
+ * proposeRules(reviews: Array<{ name, text }>, { minReviews = 2, similarity = 0.5 })
322
+ * → Array<{ rule, reviews: string[], findings: Array<{ review, text }>, shared: string[] }>, most reviews first
323
+ *
324
+ * Two findings are the same finding when they share at least `minShared` words and at least `similarity` of the shorter
325
+ * one's words (findings that chain this way are one group); a group counts when its findings come from at least
326
+ * `minReviews` different reviews. Words are matched as written, so two findings that say one thing in different words stay
327
+ * apart: this proposes what recurs in so many words, and the owner judges the rest.
328
+ */
329
+ export function proposeRules(reviews, { minReviews = 2, similarity = 0.33, minShared = 2 } = {}) {
330
+ const items = []
331
+ for (const review of reviews) {
332
+ for (const text of findingsOf(review.text)) items.push({ review: review.name, text, terms: new Set(terms(cleanFinding(text))) })
333
+ }
334
+ const parent = items.map((_, i) => i)
335
+ const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i])))
336
+ const sim = (a, b) => {
337
+ let shared = 0
338
+ for (const t of a) if (b.has(t)) shared++
339
+ return shared >= minShared ? shared / Math.min(a.size, b.size) : 0
340
+ }
341
+ for (let i = 0; i < items.length; i++) {
342
+ for (let j = i + 1; j < items.length; j++) {
343
+ if (sim(items[i].terms, items[j].terms) >= similarity) parent[find(i)] = find(j)
344
+ }
345
+ }
346
+ const groups = new Map()
347
+ items.forEach((item, i) => {
348
+ const root = find(i)
349
+ if (!groups.has(root)) groups.set(root, [])
350
+ groups.get(root).push(item)
351
+ })
352
+ const proposals = []
353
+ for (const group of groups.values()) {
354
+ const names = [...new Set(group.map((item) => item.review))].sort()
355
+ if (names.length < minReviews) continue
356
+ // The finding closest to all the others speaks for the group; ties go to the shorter one.
357
+ const central = group
358
+ .map((item) => ({ item, total: group.reduce((sum, other) => sum + (other === item ? 0 : sim(item.terms, other.terms)), 0) }))
359
+ .sort((a, b) => b.total - a.total || a.item.text.length - b.item.text.length)[0].item
360
+ const counts = new Map()
361
+ for (const item of group) for (const t of item.terms) counts.set(t, (counts.get(t) || 0) + 1)
362
+ const surface = surfaceWords(group.map((item) => cleanFinding(item.text)).join(' '))
363
+ const shared = [...counts].filter(([, c]) => c * 2 >= group.length).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([t]) => surface.get(t) || t)
364
+ proposals.push({ rule: cleanFinding(central.text), reviews: names, findings: group.map(({ review, text }) => ({ review, text })), shared })
365
+ }
366
+ return proposals.sort((a, b) => b.reviews.length - a.reviews.length || b.findings.length - a.findings.length || a.rule.localeCompare(b.rule))
367
+ }
368
+
369
+ /** Each stem of a text, mapped to the first word it was cut from, so a proposal shows `enabled`, not `enabl`. */
370
+ function surfaceWords(text) {
371
+ const map = new Map()
372
+ for (const word of text.toLowerCase().split(/[^\p{L}\p{N}]+/u)) {
373
+ const [t] = terms(word)
374
+ if (t && !map.has(t)) map.set(t, word)
375
+ }
376
+ return map
377
+ }
378
+
379
+ /** Proposals as the owner reads them in the terminal. */
380
+ export function formatProposals(proposals, totalReviews) {
381
+ if (proposals.length === 0) return `No finding recurs across ${totalReviews} review(s); nothing to propose.\n`
382
+ const lines = [`${proposals.length} finding(s) recur across ${totalReviews} review(s). Nothing was saved. To make one a standing rule, tell your agent "Remember this review rule: …" in the words you approve.`, '']
383
+ proposals.forEach((p, i) => {
384
+ lines.push(`${i + 1}. Proposed rule, in one review's words: ${p.rule}`)
385
+ lines.push(` Seen in ${p.reviews.length} reviews (${p.reviews.join(', ')}), ${p.findings.length} finding(s). Shared words: ${p.shared.slice(0, 8).join(', ')}`)
386
+ for (const f of p.findings) lines.push(` - ${f.review}: ${f.text}`)
387
+ lines.push('')
388
+ })
389
+ return lines.join('\n')
390
+ }
package/lib/setup.mjs CHANGED
@@ -4,6 +4,8 @@
4
4
  */
5
5
  import { CLIENTS } from './clients.mjs'
6
6
  import { FIRST_MEMORY_RECALL, FIRST_MEMORY_SAVE, GATEWAY_URL, SKILL_INSTALL_ARGS } from './constants.mjs'
7
+ import { checkCode, cleanupClient, probeGateway, verifyClient } from './verify.mjs'
8
+ import { askAboutSharing, outcomeLine } from './sharing.mjs'
7
9
  import { confirm, interactive, which } from './system.mjs'
8
10
 
9
11
  /**
@@ -12,6 +14,7 @@ import { confirm, interactive, which } from './system.mjs'
12
14
  * @param {boolean} options.dryRun say what would happen, change nothing
13
15
  * @param {boolean} options.login run the sign-ins the apps offer from the terminal
14
16
  * @param {boolean} options.skill install the Renor skill
17
+ * @param {boolean} options.verify also have Claude Code save and recall one test memory (the gateway is always probed)
15
18
  * @param {boolean} options.force replace a renor entry that points somewhere else
16
19
  * @param {string[]|null} options.only limit to these app ids
17
20
  */
@@ -35,7 +38,7 @@ export async function setup(options, io = defaultIo()) {
35
38
  }
36
39
 
37
40
  if (found.length === 0) {
38
- say('No supported app was found: Claude Code, Codex, Cursor, OpenCode, Pi or VS Code.')
41
+ say('No supported app was found: Claude Code, Codex, Cursor, OpenCode, Gemini CLI, Pi or VS Code.')
39
42
  say('For ChatGPT or Claude in the browser, open https://app.renor.cloud and follow the steps there.')
40
43
  return { code: 0, results: [] }
41
44
  }
@@ -66,7 +69,14 @@ export async function setup(options, io = defaultIo()) {
66
69
 
67
70
  const results = []
68
71
  for (const { client, detection } of found) {
69
- results.push(await setUpOne(client, detection, { ...options, canSignIn }, { env, say }))
72
+ try {
73
+ results.push(await setUpOne(client, detection, { ...options, canSignIn }, { env, say }))
74
+ } catch (error) {
75
+ // One app's config that cannot be written (a permission, a file where a folder should be) is that app's failure,
76
+ // not the end of the run: the other apps are still set up and the report still says what is left.
77
+ const detail = error instanceof Error ? error.message : String(error)
78
+ results.push({ id: client.id, label: client.label, state: 'failed', next: `${detail} Add Renor by hand: ${client.byHand}` })
79
+ }
70
80
  }
71
81
 
72
82
  let skill = null
@@ -80,14 +90,80 @@ export async function setup(options, io = defaultIo()) {
80
90
  }
81
91
  }
82
92
 
93
+ await verifyAll(found, results, { env, say, paint, io, options })
94
+
83
95
  report(results, skill, { say, paint })
84
- const failures = results.filter((result) => result.state === 'failed').length + (skill && !skill.ok ? 1 : 0)
96
+ const sharing = await askAboutSharing(io, options)
97
+ const sharingLine = outcomeLine(sharing, env)
98
+ if (sharingLine) say(sharingLine)
99
+ const failures = results.filter((result) => result.state === 'failed' || result.verified === false).length + (skill && !skill.ok ? 1 : 0)
85
100
  return { code: failures > 0 ? 1 : 0, results, skill }
86
101
  }
87
102
 
103
+ /** The proof: the gateway answers; with --verify, Claude Code (the app that can be limited to Renor's tools) saves and recalls one test memory. */
104
+ async function verifyAll(found, results, { env, say, paint, io, options }) {
105
+ say()
106
+ const gateway = await probeGateway(io.fetch)
107
+ if (gateway.ok) say(`${paint.ok('✓')} Renor’s gateway answers.`)
108
+ else {
109
+ say(`${paint.warn('!')} ${gateway.detail}`)
110
+ say(` Fix: ${gateway.fix}`)
111
+ }
112
+ if (!options.verify) return gateway.ok
113
+
114
+ const hasCheck = (result) => Boolean(found.find(({ client }) => client.id === result.id).client.check)
115
+ for (const result of results) {
116
+ if ((result.state === 'already' || result.state === 'added') && !hasCheck(result)) result.unchecked = true
117
+ }
118
+ const checkable = results.filter((result) => (result.state === 'already' || (result.state === 'added' && result.signedIn)) && hasCheck(result))
119
+ if (checkable.length === 0) {
120
+ say(paint.dim('Nothing to check from here yet: Claude Code is the app setup can test, and it is not signed in to Renor. Sign in as listed below, then run npx renor setup --verify.'))
121
+ return gateway.ok
122
+ }
123
+ if (!gateway.ok) {
124
+ for (const result of checkable) result.verified = false
125
+ return false
126
+ }
127
+ say(paint.dim('Checking Claude Code: it saves one test memory to Renor and recalls it. This can take a minute and uses a little of its model quota.'))
128
+ for (const result of checkable) {
129
+ const { client } = found.find((item) => item.client.id === result.id)
130
+ const code = checkCode()
131
+ result.checkCode = code
132
+ const check = verifyClient(client, { env, code, run: io.run })
133
+ result.verified = check.ok
134
+ let retiredId = check.retiredId
135
+ result.noteRetired = check.retired
136
+ if (!check.retired) {
137
+ const cleanup = cleanupClient(client, { env, code, savedId: check.savedId, run: io.run })
138
+ result.noteRetired = cleanup.retired
139
+ retiredId = cleanup.retiredId ?? retiredId
140
+ }
141
+ if (check.wrongRetire) {
142
+ check.ok = false
143
+ check.detail = `${client.label} retired a different memory (id ${check.wrongRetire.retired}) than the one it saved (id ${check.wrongRetire.saved}).`
144
+ check.fix = `put it back with restore_memory and the id ${check.wrongRetire.retired} (ask any connected AI, or use the dashboard's memory list). Setup then retired the test note itself.`
145
+ } else if (result.noteRetired && check.recalledOk) {
146
+ // The recall worked and the cleanup pass confirmed the retire the app had not.
147
+ check.ok = true
148
+ } else if (!result.noteRetired) {
149
+ check.ok = false
150
+ check.detail = `${check.detail ?? 'The check finished'}; setup could not retire the test note Renor setup check ${code}.`
151
+ check.fix = `Run npx renor setup --verify again; if it persists, write to hello@renor.cloud with the code ${code}.`
152
+ }
153
+ result.noteId = retiredId
154
+ result.verified = check.ok
155
+ if (!check.ok) {
156
+ result.verifyDetail = check.detail
157
+ result.verifyFix = check.fix
158
+ }
159
+ }
160
+ if (checkable.every((result) => result.noteRetired)) say(`${paint.ok('✓')} The test note${checkable.length === 1 && checkable[0].noteId ? ` (id ${checkable[0].noteId})` : ''} was retired again: it no longer comes back in search.`)
161
+ return gateway.ok
162
+ }
163
+
88
164
  function planFor(client, detection, now, options) {
89
165
  if (detection.blocked) return detection.blocked
90
- if (now.configured && now.url === GATEWAY_URL) return 'already has Renor; nothing to do.'
166
+ if (now.configured && now.url === GATEWAY_URL && !options.force) return 'already has Renor; nothing to do.'
91
167
  if (now.configured && !options.force) return `has a renor server at ${now.url ?? 'another address'}; it would be left alone (use --force to replace it).`
92
168
  if (now.configured) return 'its renor server would be replaced with Renor’s address.'
93
169
  return client.login ? 'Renor would be added, then signed in.' : 'Renor would be added.'
@@ -136,6 +212,9 @@ async function setUpOne(client, detection, options, { env, say }) {
136
212
  function report(results, skill, { say, paint }) {
137
213
  say()
138
214
  say(paint.bold('Done.'))
215
+ const wired = results.filter((result) => result.state === 'already' || result.state === 'added').length
216
+ const verified = results.filter((result) => result.verified === true).length
217
+ say(`${wired} of ${results.length} wired${results.some((result) => result.verified !== undefined) ? `, ${verified} verified` : ''}.`)
139
218
  const width = Math.max(...results.map((result) => result.label.length))
140
219
  for (const result of results) {
141
220
  const name = result.label.padEnd(width)
@@ -144,6 +223,12 @@ function report(results, skill, { say, paint }) {
144
223
  else if (result.state === 'added') say(` ${paint.todo('•')} ${name} added. Left for you: ${result.next}`)
145
224
  else say(` ${paint.warn('!')} ${name} ${result.next}`)
146
225
  if (result.detail) say(` ${paint.dim(result.detail)}`)
226
+ if (result.verified === true) say(` ${paint.ok('✓')} verified: saved a test memory and recalled it`)
227
+ if (result.unchecked) say(` ${paint.dim(`not checked by setup: tell it "${FIRST_MEMORY_SAVE}", then ask "${FIRST_MEMORY_RECALL}" in a new chat.`)}`)
228
+ if (result.verified === false) {
229
+ say(` ${paint.warn('!')} not verified: ${result.verifyDetail ?? 'Renor’s gateway could not be reached.'}`)
230
+ if (result.verifyFix) say(` Fix: ${result.verifyFix}`)
231
+ }
147
232
  }
148
233
  if (skill) {
149
234
  say(skill.ok ? ` ${paint.ok('✓')} ${'Skill'.padEnd(width)} installed` : ` ${paint.warn('!')} ${'Skill'.padEnd(width)} not installed: ${skill.detail}`)
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Whether `renor setup` may ask the sharing question (#778): only while the operator's gate is open. The switch lives on the
3
+ * server (app_settings 'learning_sharing', supabase/migrations/20261024120000_learning_sharing_gate.sql) and the gateway reports
4
+ * it, as one public bit, at GET /sharing/status ({"open": true|false}). Anything but an answer of exactly {"open": true} (no
5
+ * network, a timeout, an older gateway without the route, a malformed body) means closed: the question is not asked.
6
+ *
7
+ * Opening the switch only lets setup ASK. Whether a row is ever stored is decided again by the server from the authenticated
8
+ * identity and that user's recorded consent; client-supplied workspace ids never decide it.
9
+ */
10
+ import { GATEWAY_URL } from './constants.mjs'
11
+
12
+ export const SHARING_STATUS_URL = new URL('/sharing/status', GATEWAY_URL).href
13
+
14
+ /** True only when the gateway answers {"open": true} within `timeoutMs`. */
15
+ export async function sharingPromptOpen({ fetchImpl = globalThis.fetch, url = SHARING_STATUS_URL, timeoutMs = 3000 } = {}) {
16
+ if (typeof fetchImpl !== 'function') return false
17
+ const controller = new AbortController()
18
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
19
+ try {
20
+ const response = await fetchImpl(url, {
21
+ headers: { accept: 'application/json' },
22
+ redirect: 'error',
23
+ signal: controller.signal,
24
+ })
25
+ if (!response.ok) return false
26
+ const body = await response.json()
27
+ return body !== null && typeof body === 'object' && body.open === true
28
+ } catch {
29
+ return false
30
+ } finally {
31
+ clearTimeout(timer)
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Runs `ask` (the question #778 asks) only while the gate is open; otherwise returns 'closed' without asking or writing anything.
37
+ * #778's askAboutSharing calls it as its first step after its own not-interactive / environment / answered checks.
38
+ */
39
+ export async function whenSharingOpen(ask, options = {}) {
40
+ if (!(await sharingPromptOpen(options))) return 'closed'
41
+ return ask()
42
+ }