renor 0.0.0-stage → 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,144 @@
1
+ /**
2
+ * `renor review-rules`: the owner's standing rules for a review prompt (default), a marked section of a guidelines file
3
+ * (--export), or proposed rules from past reviews (--propose, which never reaches Renor).
4
+ */
5
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import { GATEWAY_URL } from './constants.mjs'
8
+ import { connect } from './mcp-client.mjs'
9
+ import { fetchRules } from './review-rules-source.mjs'
10
+ import {
11
+ DEFAULT_EXPORT_MAX_TOKENS, DEFAULT_MAX_TOKENS, buildReviewRulesBlock, formatProposals, proposeRules, renderRulesSection, updateMarkedSection,
12
+ } from './review-rules.mjs'
13
+
14
+ export const REVIEW_RULES_HELP = `renor review-rules — the owner's standing rules from Renor, for a code review.
15
+
16
+ Usage
17
+ gh pr diff 123 | npx renor review-rules Print the rules that bear on the diff, to put at the top of a review prompt
18
+ npx renor review-rules --diff change.patch The same, from a file
19
+ npx renor review-rules --export [REVIEW.md] Write the rules into one marked section of REVIEW.md (or AGENTS.md)
20
+ npx renor review-rules --propose reviews/ Propose rules from recurring findings in past reviews (Markdown); saves nothing
21
+
22
+ Options
23
+ --diff <file|-> The diff to match rules against (default: standard input when it is not a terminal)
24
+ --max-tokens <n> Cap the block at about n tokens (default ${DEFAULT_MAX_TOKENS}; ${DEFAULT_EXPORT_MAX_TOKENS} for --export)
25
+ --url <url> Renor's MCP address (default RENOR_URL, else ${GATEWAY_URL}); a local engine is http://127.0.0.1:3471/mcp
26
+ --rules-file <json> Read the rules from a JSON file (an array of rules) instead of Renor
27
+ --min-reviews <n> --propose: how many reviews a finding must recur in (default 2)
28
+ --json Print JSON (the block, or the proposals) instead of text
29
+
30
+ The key is RENOR_API_KEY (an API key from https://app.renor.cloud). Every call to Renor is a read.`
31
+
32
+ export function parseReviewRules(argv) {
33
+ const options = { diff: null, maxTokens: null, url: null, rulesFile: null, export: null, propose: null, minReviews: 2, json: false, help: false, unknown: [] }
34
+ for (let i = 0; i < argv.length; i++) {
35
+ const arg = argv[i]
36
+ const value = () => {
37
+ const eq = arg.indexOf('=')
38
+ return eq > 0 ? arg.slice(eq + 1) : argv[++i]
39
+ }
40
+ const name = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg
41
+ if (name === '--diff') options.diff = value()
42
+ else if (name === '--max-tokens') options.maxTokens = Number(value())
43
+ else if (name === '--url') options.url = value()
44
+ else if (name === '--rules-file') options.rulesFile = value()
45
+ else if (name === '--min-reviews') options.minReviews = Number(value())
46
+ else if (name === '--propose') options.propose = value()
47
+ else if (name === '--export') {
48
+ if (arg.includes('=')) options.export = value()
49
+ else options.export = argv[i + 1] && !argv[i + 1].startsWith('-') ? argv[++i] : ''
50
+ } else if (arg === '--json') options.json = true
51
+ else if (arg === '-h' || arg === '--help') options.help = true
52
+ else options.unknown.push(arg)
53
+ }
54
+ return options
55
+ }
56
+
57
+ function readStdin() {
58
+ try {
59
+ return readFileSync(0, 'utf8')
60
+ } catch {
61
+ return ''
62
+ }
63
+ }
64
+
65
+ /** The rules: from --rules-file, or from Renor. */
66
+ async function loadRules(options, { env, diff, err, fetchImpl }) {
67
+ if (options.rulesFile) return JSON.parse(readFileSync(options.rulesFile, 'utf8'))
68
+ const url = options.url || env.RENOR_URL || GATEWAY_URL
69
+ const token = env.RENOR_API_KEY || ''
70
+ if (!token && url === GATEWAY_URL) throw new Error('Set RENOR_API_KEY to an API key from https://app.renor.cloud (or pass --url for a local engine, or --rules-file).')
71
+ const client = await connect({ url, token, fetchImpl })
72
+ return fetchRules(client, { diff, warn: (message) => err.write(`renor review-rules: ${message}\n`) })
73
+ }
74
+
75
+ /**
76
+ * Runs `renor review-rules` with `argv` (the words after the command). Returns the exit code. `io` is for tests:
77
+ * { env, out, err, stdin: () => string, stdinIsTTY, cwd, fetchImpl }.
78
+ */
79
+ export async function reviewRules(argv, io = {}) {
80
+ const env = io.env || process.env
81
+ const out = io.out || process.stdout
82
+ const err = io.err || process.stderr
83
+ const cwd = io.cwd || process.cwd()
84
+ const options = parseReviewRules(argv)
85
+ if (options.help) {
86
+ out.write(REVIEW_RULES_HELP + '\n')
87
+ return 0
88
+ }
89
+ if (options.unknown.length > 0) {
90
+ err.write(`Unknown option: ${options.unknown.join(' ')}\n\n${REVIEW_RULES_HELP}\n`)
91
+ return 2
92
+ }
93
+ if (options.maxTokens !== null && !(Number.isInteger(options.maxTokens) && options.maxTokens > 0)) {
94
+ err.write('--max-tokens takes a whole number above 0\n')
95
+ return 2
96
+ }
97
+
98
+ if (options.propose !== null) {
99
+ // Reads the folder only: no connection to Renor is opened in this mode, so it cannot save anything there.
100
+ const dir = options.propose || '.'
101
+ const reviews = readdirSync(dir).filter((name) => name.toLowerCase().endsWith('.md')).sort()
102
+ .map((name) => ({ name, text: readFileSync(join(dir, name), 'utf8') }))
103
+ const proposals = proposeRules(reviews, { minReviews: Number.isInteger(options.minReviews) && options.minReviews > 0 ? options.minReviews : 2 })
104
+ out.write(options.json ? JSON.stringify(proposals, null, 2) + '\n' : formatProposals(proposals, reviews.length))
105
+ return 0
106
+ }
107
+
108
+ let diff = ''
109
+ if (options.diff === '-') diff = (io.stdin || readStdin)()
110
+ else if (options.diff) diff = readFileSync(options.diff, 'utf8')
111
+ else if (options.export === null && !(io.stdinIsTTY ?? process.stdin.isTTY)) diff = (io.stdin || readStdin)()
112
+ if (options.export === null && diff.trim() === '') {
113
+ err.write('No diff: pipe one in (gh pr diff 123 | npx renor review-rules) or pass --diff <file>.\n')
114
+ return 2
115
+ }
116
+
117
+ const rules = await loadRules(options, { env, diff, err, fetchImpl: io.fetchImpl })
118
+
119
+ if (options.export !== null) {
120
+ const target = options.export || (existsSync(join(cwd, 'REVIEW.md')) || !existsSync(join(cwd, 'AGENTS.md')) ? 'REVIEW.md' : 'AGENTS.md')
121
+ const path = join(cwd, target)
122
+ const before = existsSync(path) ? readFileSync(path, 'utf8') : ''
123
+ const { section, included, omitted } = renderRulesSection({ rules, diff, maxTokens: options.maxTokens ?? DEFAULT_EXPORT_MAX_TOKENS })
124
+ const after = updateMarkedSection(before, section)
125
+ if (after === before) {
126
+ out.write(`${target}: unchanged (${included.length} rule(s)).\n`)
127
+ return 0
128
+ }
129
+ writeFileSync(path, after)
130
+ out.write(`${target}: wrote ${included.length} rule(s) into the Renor section${omitted.length ? `; ${omitted.length} more did not fit --max-tokens` : ''}. Nothing outside the section changed.\n`)
131
+ return 0
132
+ }
133
+
134
+ const { block, included, omitted } = buildReviewRulesBlock({ rules, diff, maxTokens: options.maxTokens ?? DEFAULT_MAX_TOKENS })
135
+ if (options.json) {
136
+ out.write(JSON.stringify({ block, included: included.map((rule) => rule.id), omitted: omitted.map((rule) => rule.id) }, null, 2) + '\n')
137
+ return 0
138
+ }
139
+ out.write(block)
140
+ err.write(block
141
+ ? `renor review-rules: ${included.length} rule(s) quoted${omitted.length ? `, ${omitted.length} left out by --max-tokens` : ''}.\n`
142
+ : 'renor review-rules: no current rule bears on this diff; nothing printed.\n')
143
+ return 0
144
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Where `renor review-rules` gets the owner's rules: Renor's own read tools, over MCP, hosted or local. No engine tool was
3
+ * added for this; the existing ones are enough:
4
+ *
5
+ * memory_map (no query) the newest facts, history included: the subjects that hold a rule-like predicate
6
+ * get_context (the diff) current facts the diff's words reach, beyond the newest ones: more such subjects
7
+ * retrieve_memory (subject) each subject's facts, every one marked current or superseded, with its event id and date,
8
+ * its value whole (get_context may cut a value, and gives no id)
9
+ * memory_map (rule words) plain-words memories that hold "always", "never", "prefer", "rule", ...
10
+ * read_memory (id) each such memory's full text, and whether a later correction replaced it
11
+ *
12
+ * Only `current: true` facts and memories nobody replaced are kept, so a superseded value never reaches the block. Every
13
+ * call is a read: nothing here saves, retires or changes anything in Renor.
14
+ */
15
+ import { diffFiles, isRulePredicate, looksLikeRule, terms } from './review-rules.mjs'
16
+
17
+ const RULE_WORDS = 'rule always never must prefer avoid policy decision decided convention guideline'
18
+
19
+ /** The question get_context is asked: the changed files and the diff's most frequent words, inside its 1000-character limit. */
20
+ export function contextQuery(diff) {
21
+ const counts = new Map()
22
+ for (const line of String(diff || '').split('\n')) {
23
+ if (!/^[+-]/.test(line) || /^(\+\+\+|---) /.test(line)) continue
24
+ for (const t of terms(line.slice(1))) counts.set(t, (counts.get(t) || 0) + 1)
25
+ }
26
+ const top = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 30).map(([t]) => t)
27
+ return `Standing rules, preferences and decisions for a code review of changes to ${diffFiles(diff).slice(0, 20).join(', ')}. Topics: ${top.join(' ')}`.slice(0, 1000)
28
+ }
29
+
30
+ const day = (iso) => (typeof iso === 'string' ? iso.slice(0, 10) : undefined)
31
+
32
+ /**
33
+ * fetchRules(client, { diff?, maxSubjects = 40, maxMemories = 40, warn? }) → Rule[]
34
+ *
35
+ * `client.callTool(name, args)` answers a Renor tool's JSON (mcp-client.mjs). A tool that fails is skipped with a warning
36
+ * (`warn(message)`), so one refused read leaves the rest of the rules; nothing is guessed in its place.
37
+ */
38
+ export async function fetchRules(client, { diff = '', maxSubjects = 40, maxMemories = 40, warn = () => {} } = {}) {
39
+ const call = async (name, args) => {
40
+ try {
41
+ return await client.callTool(name, args)
42
+ } catch (error) {
43
+ warn(`${name} failed: ${error instanceof Error ? error.message : String(error)}`)
44
+ return null
45
+ }
46
+ }
47
+
48
+ const subjects = []
49
+ const addSubject = (subject) => {
50
+ if (typeof subject === 'string' && subject.trim() !== '' && !subjects.includes(subject)) subjects.push(subject)
51
+ }
52
+ const map = await call('memory_map', { limit: 200 })
53
+ for (const fact of map?.facts || []) if (isRulePredicate(fact.predicate)) addSubject(fact.subject)
54
+ if (String(diff).trim() !== '') {
55
+ const context = await call('get_context', { query: contextQuery(diff), max_tokens: 4000 })
56
+ for (const fact of context?.facts || []) if (isRulePredicate(fact.predicate) && fact.untrusted !== true) addSubject(fact.subject)
57
+ }
58
+
59
+ const rules = []
60
+ const seen = new Set()
61
+ for (const subject of subjects.slice(0, maxSubjects)) {
62
+ const read = await call('retrieve_memory', { subject })
63
+ for (const fact of read?.facts || []) {
64
+ if (fact.current !== true || fact.validTo || fact.untrusted === true || !isRulePredicate(fact.predicate)) continue
65
+ if (!fact.eventId || seen.has(fact.eventId)) continue
66
+ seen.add(fact.eventId)
67
+ rules.push({ id: fact.eventId, date: day(fact.validFrom), text: fact.object, kind: 'fact', subject, predicate: fact.predicate, scope: fact.scope, current: true })
68
+ }
69
+ }
70
+
71
+ const found = await call('memory_map', { query: RULE_WORDS, limit: 200 })
72
+ const candidates = (found?.memories || []).filter((memory) => memory.untrusted !== true && memory.origin !== 'import')
73
+ for (const memory of candidates.slice(0, maxMemories)) {
74
+ if (seen.has(memory.id)) continue
75
+ const read = await call('read_memory', { id: memory.id })
76
+ if (!read || read.ok === false || read.replaced || read.textTruncated || typeof read.text !== 'string') continue
77
+ const detail = read.memory || memory
78
+ if (detail.untrusted === true || detail.origin === 'import' || !looksLikeRule(read.text)) continue
79
+ seen.add(memory.id)
80
+ rules.push({ id: memory.id, date: day(detail.session_date || detail.at), text: read.text, kind: 'memory', current: true })
81
+ }
82
+ return rules
83
+ }
@@ -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
+ }