@tduyng/pi-timbre 0.0.0-stage → 0.0.2

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.
package/src/index.ts ADDED
@@ -0,0 +1,695 @@
1
+ import { closeSync, existsSync, globSync, openSync, readFileSync, readSync, statSync } from 'node:fs'
2
+ import { isAbsolute, resolve } from 'node:path'
3
+ import type { AssistantMessage, TextContent } from '@earendil-works/pi-ai'
4
+ import {
5
+ BeforeAgentStartEvent,
6
+ BeforeAgentStartEventResult,
7
+ ExtensionAPI,
8
+ ExtensionContext,
9
+ isToolCallEventType,
10
+ MessageEndEvent,
11
+ ToolResultEvent
12
+ } from '@earendil-works/pi-coding-agent'
13
+ import { Validator } from 'ata-validator'
14
+ import { t } from 'ata-validator/t'
15
+
16
+ import { loadConfig, type Config } from './config.js'
17
+ import {
18
+ type Domain,
19
+ type Effect,
20
+ appendNotice,
21
+ effect,
22
+ load,
23
+ memoryPath,
24
+ merge,
25
+ noticed,
26
+ record,
27
+ save,
28
+ type Memory,
29
+ type Settings
30
+ } from './memory.js'
31
+
32
+ import { GUIDE_TOOL_NAME, registerGuide } from './guide.js'
33
+ import { classify, type Profile } from './register.js'
34
+ import { codeCheckProblem, codePath, scanCodeBatch, type CodeFinding } from './code.js'
35
+ import {
36
+ formatCodeFindings,
37
+ formatFindRequest,
38
+ formatFixRequest,
39
+ formatHits,
40
+ formatStats,
41
+ type FixTarget
42
+ } from './report.js'
43
+ import { catalogAvailable, generationRules } from './rules.js'
44
+ import { aggregateSpans } from './segments.js'
45
+ import { type Hit, isIgnored, proseLength, proseLineChars, scanProse } from './scan.js'
46
+ import {
47
+ burstiness,
48
+ hasClauseStack,
49
+ hasLongSentenceRun,
50
+ hasStaccatoRun,
51
+ isMonotone,
52
+ repeatedOpening,
53
+ wordCount
54
+ } from './stats.js'
55
+
56
+ type AssistantContent = AssistantMessage['content']
57
+
58
+ type ToolResultPatch = { content: ToolResultEvent['content'] }
59
+
60
+ const PROSE_FILE = /\.(md|mdx|markdown|txt)$/i
61
+ const PATH_TOKEN = /^@|[/\\]|\.[A-Za-z0-9]{1,8}$/
62
+ const GLOB_TOKEN = /[*?[]/
63
+ const GLOB_EXCLUDE = /(^|\/)(node_modules|\.git|dist|build|out|coverage|target|vendor|\.venv|\.next)(\/|$)/
64
+ const MAX_FIX_FILES = 10
65
+ const MAX_FIX_FILE_BYTES = 2 * 1024 * 1024
66
+ const SUBCOMMANDS = ['on', 'off', 'fix', 'reset', 'stats', 'status']
67
+
68
+ function setGuideTool(pi: ExtensionAPI, enabled: boolean): void {
69
+ const active = pi.getActiveTools()
70
+ if (active.length === 0) return
71
+ if (active.includes(GUIDE_TOOL_NAME) === enabled) return
72
+ pi.setActiveTools(enabled ? [...active, GUIDE_TOOL_NAME] : active.filter((name) => name !== GUIDE_TOOL_NAME))
73
+ }
74
+
75
+ const MIN_CHAT_PROSE = 40
76
+
77
+ const DEFAULT_NOTE = ' (project default until changed again; next turn re-primes the cache)'
78
+
79
+ export default function timbre(pi: ExtensionAPI): void {
80
+ const config = loadConfig()
81
+ if (!config.enabled) return
82
+
83
+ registerGuide(pi)
84
+
85
+ pi.on('session_start', (_event, ctx) => {
86
+ reportedCode.clear()
87
+ try {
88
+ const over = carriedOver(forProject(ctx.cwd), config)
89
+ if (over.length === 0) return
90
+ ctx.ui.notify(
91
+ `timbre: overrides from an earlier /timbre are still active — ${over.join(', ')}. Run /timbre to review, or /timbre reset to clear.`,
92
+ 'info'
93
+ )
94
+ } catch {}
95
+ })
96
+
97
+ let preventing = config.prevent
98
+
99
+ let settingsReady = false
100
+
101
+ let turnPrevented = config.prevent
102
+ const preventedNow = (): boolean => turnPrevented
103
+
104
+ const touchedCode = new Set<string>()
105
+ const reportedCode = new Map<string, string>()
106
+ let noticesHeld = 0
107
+ const reportedNotes = new Set<string>()
108
+ let block: { path: string; text: string } | undefined
109
+
110
+ let cached: { path: string; memory: Memory; mtimeMs: number; size: number } | undefined
111
+ const stamp = (path: string) => {
112
+ const stat = statSync(path, { throwIfNoEntry: false })
113
+ return { mtimeMs: stat?.mtimeMs ?? 0, size: stat?.size ?? 0 }
114
+ }
115
+ const remember = (path: string, memory: Memory): void => {
116
+ cached = { path, memory, ...stamp(path) }
117
+ }
118
+ const forProject = (cwd: string): Memory => {
119
+ const path = memoryPath(cwd)
120
+ const now = stamp(path)
121
+ if (cached?.path === path && cached.mtimeMs === now.mtimeMs && cached.size === now.size) {
122
+ return cached.memory
123
+ }
124
+ const memory = load(path)
125
+ remember(path, memory)
126
+ return memory
127
+ }
128
+
129
+ const commit = (cwd: string, next: Memory): void => {
130
+ const path = memoryPath(cwd)
131
+ const merged = merge(forProject(cwd), next)
132
+ save(path, merged)
133
+ remember(path, merged)
134
+ }
135
+
136
+ const ensureSettings = (cwd: string): void => {
137
+ if (settingsReady) return
138
+ settingsReady = true
139
+ const mem = forProject(cwd)
140
+ if (mem.settings?.prevent !== undefined) preventing = mem.settings.prevent
141
+ }
142
+
143
+ const persistSettings = (cwd: string, settings: Settings): void => {
144
+ const path = memoryPath(cwd)
145
+ const mem = forProject(cwd)
146
+ const next: Memory = { ...mem, settings: { ...mem.settings, ...settings } }
147
+ save(path, next)
148
+ remember(path, next)
149
+ }
150
+
151
+ const healthWarnings = (): string[] => moduleHealthWarnings(config)
152
+
153
+ const statusText = (): string => (preventing ? 'on' : 'off')
154
+ const syncStatus = (ctx: ExtensionContext | undefined): void => {
155
+ try {
156
+ ctx?.ui?.setStatus?.('timbre', `timbre: ${statusText()}`)
157
+ } catch {}
158
+ }
159
+
160
+ pi.on('session_compact', () => {
161
+ reportedNotes.clear()
162
+ })
163
+
164
+ pi.on(
165
+ 'before_agent_start',
166
+ (event: BeforeAgentStartEvent, ctx: ExtensionContext): BeforeAgentStartEventResult | undefined => {
167
+ try {
168
+ ensureSettings(ctx.cwd)
169
+ } catch {}
170
+ turnPrevented = preventing
171
+ syncStatus(ctx)
172
+ // Armed for the whole session: the tool list is the front of every request.
173
+ setGuideTool(pi, true)
174
+ if (!preventing) return undefined
175
+ try {
176
+ const path = memoryPath(ctx.cwd)
177
+ if (block?.path !== path) block = { path, text: generationRules() }
178
+ if (block.text === '') return undefined
179
+ const current = event.systemPromptOptions.appendSystemPrompt ?? ''
180
+ if (current.includes(block.text)) return undefined
181
+ event.systemPromptOptions.appendSystemPrompt = current ? `${current}\n\n${block.text}` : block.text
182
+ return undefined
183
+ } catch {
184
+ return undefined
185
+ }
186
+ }
187
+ )
188
+
189
+ pi.on('message_end', (event: MessageEndEvent, ctx: ExtensionContext): undefined => {
190
+ if (!config.watchChat) return undefined
191
+ try {
192
+ if (event.message.role !== 'assistant') return undefined
193
+ const text = textOf(event.message.content)
194
+ if (proseLength(text) < MIN_CHAT_PROSE) return undefined
195
+
196
+ const { hits, chars } = scanProse(text)
197
+ const memory = forProject(ctx.cwd)
198
+ commit(
199
+ ctx.cwd,
200
+ record(
201
+ memory,
202
+ hits.map((h) => h.id),
203
+ {
204
+ at: Date.now(),
205
+ prevented: preventedNow(),
206
+ domain: 'prose',
207
+ chars,
208
+ hits: hits.length,
209
+ cacheRead: event.message.usage.cacheRead,
210
+ cacheWrite: event.message.usage.cacheWrite
211
+ }
212
+ )
213
+ )
214
+ } catch {}
215
+ return undefined
216
+ })
217
+
218
+ pi.on('tool_call', (event, ctx) => {
219
+ try {
220
+ const code = codePath(event, ctx.cwd)
221
+ if (code !== null) touchedCode.add(code)
222
+
223
+ let path: string
224
+ let text: string
225
+ if (isToolCallEventType('write', event)) {
226
+ path = event.input.path
227
+ text = event.input.content
228
+ } else if (isToolCallEventType('edit', event)) {
229
+ path = event.input.path
230
+ text = event.input.edits.map((e) => e.newText).join('\n\n')
231
+ } else {
232
+ return undefined
233
+ }
234
+ if (!PROSE_FILE.test(path) || text === '') return undefined
235
+
236
+ const head = readHeader(isAbsolute(path) ? path : resolve(ctx.cwd, path))
237
+ if (head !== null && isIgnored(head)) return undefined
238
+
239
+ // One notice per file per session. 86% of prose writes land on ten files, so
240
+ // most writes to a path have already been noticed in this session.
241
+ if (!preventing) return undefined
242
+ const session = ctx.sessionManager.getSessionId()
243
+ const memory = forProject(ctx.cwd)
244
+ if (noticed(memory, session, path)) {
245
+ noticesHeld += 1
246
+ return undefined
247
+ }
248
+ commit(ctx.cwd, appendNotice(memory, { at: Date.now(), session, path }))
249
+ return undefined
250
+ } catch {
251
+ return undefined
252
+ }
253
+ })
254
+
255
+ // One oxlint spawn per turn. The note prefers the model's next write or edit result, which is
256
+ // where it is unambiguous and free. A turn whose last act is a write would otherwise lose it,
257
+ // so what is left goes into the transcript as a custom_message entry: a `continue: true` would
258
+ // ask for a model request, and a follow-up message is the stale report this replaced.
259
+ pi.on('turn_end', async (_event, _ctx: ExtensionContext) => {
260
+ let codeNote: string | undefined
261
+ const paths = [...touchedCode]
262
+ touchedCode.clear()
263
+ if (paths.length > 0 && config.enabled) {
264
+ const findings = await scanCodeBatch(paths)
265
+ if (findings !== null) {
266
+ const fresh = freshFindings(findings, reportedCode)
267
+ // Overwrites whatever an earlier turn left undelivered, so a note can never
268
+ // outlive the turn whose code it describes.
269
+ if (fresh.length > 0) codeNote = formatCodeFindings(fresh)
270
+ }
271
+ }
272
+ /**
273
+ * The note goes into the session rather than out as a message: `continue: true` would
274
+ * ask for a model request, and a follow-up message is the stale report this replaced.
275
+ * An entry costs neither and keeps its own provenance.
276
+ */
277
+ if (codeNote === undefined) return undefined
278
+ return {
279
+ entries: [
280
+ { type: 'custom_message' as const, customType: 'timbre-code-note', content: codeNote, display: false }
281
+ ]
282
+ }
283
+ })
284
+
285
+ pi.on(
286
+ 'tool_result',
287
+ async (event: ToolResultEvent, ctx: ExtensionContext): Promise<ToolResultPatch | undefined> => {
288
+ try {
289
+ const found = inspectProse(event, ctx.cwd)
290
+ if (found === null) return undefined
291
+
292
+ const { path, hits, chars, note: scannedNote, domain } = found
293
+
294
+ commit(
295
+ ctx.cwd,
296
+ record(
297
+ forProject(ctx.cwd),
298
+ hits.map((h) => h.id),
299
+ { at: Date.now(), prevented: preventedNow(), domain, chars, hits: hits.length }
300
+ )
301
+ )
302
+
303
+ const reportKey = `${ctx.sessionManager.getSessionId()}\n${path}\n${scannedNote}`
304
+ const repeat = scannedNote !== '' && reportedNotes.has(reportKey)
305
+ if (scannedNote !== '') reportedNotes.add(reportKey)
306
+ const parts = [scannedNote === '' || repeat ? undefined : scannedNote].filter(
307
+ (part): part is string => part !== undefined
308
+ )
309
+ if (parts.length === 0) return undefined
310
+ return { content: [...event.content, { type: 'text' as const, text: parts.join('\n\n') }] }
311
+ } catch {
312
+ return undefined
313
+ }
314
+ }
315
+ )
316
+
317
+ const setPrevention = async (ctx: ExtensionContext, next: boolean): Promise<void> => {
318
+ preventing = next
319
+ persistSettings(ctx.cwd, { prevent: next })
320
+ syncStatus(ctx)
321
+ ctx.ui.notify(`timbre: prevention ${next ? 'on' : 'off'}${DEFAULT_NOTE}`, 'info')
322
+ }
323
+
324
+ pi.registerShortcut('ctrl+alt+t', {
325
+ description: 'Toggle timbre prevention',
326
+ handler: async (ctx) => {
327
+ try {
328
+ ensureSettings(ctx.cwd)
329
+ } catch {}
330
+ await setPrevention(ctx, !preventing)
331
+ }
332
+ })
333
+
334
+ pi.registerCommand('timbre', {
335
+ description: 'Prose prevention status, effect measurement, and on/off',
336
+ handler: async (args, ctx) => {
337
+ const raw = args.trim()
338
+ const arg = raw.toLowerCase()
339
+
340
+ if (arg === 'on' || arg === 'off') {
341
+ await setPrevention(ctx, arg === 'on')
342
+ return
343
+ }
344
+ const fixArgs = /^fix\b([\s\S]*)$/.exec(raw)
345
+ if (fixArgs) {
346
+ requestFix(pi, ctx, fixArgs[1]!.trim())
347
+ return
348
+ }
349
+ if (arg === 'reset') {
350
+ const ok = await ctx.ui.confirm('timbre', 'Discard all recorded samples for this project?')
351
+ if (ok) {
352
+ const path = memoryPath(ctx.cwd)
353
+ const cleared: Memory = { version: 1, samples: [] }
354
+ save(path, cleared)
355
+ remember(path, cleared)
356
+ block = undefined
357
+ preventing = config.prevent
358
+ settingsReady = false
359
+ syncStatus(ctx)
360
+ ctx.ui.notify('timbre: memory cleared, live overrides reset to the env defaults', 'info')
361
+ }
362
+ return
363
+ }
364
+ if (arg !== '' && arg !== 'stats' && arg !== 'status') {
365
+ ctx.ui.notify(`timbre: no subcommand "${raw}". Try: ${SUBCOMMANDS.join(', ')}`, 'warning')
366
+ return
367
+ }
368
+
369
+ const memory = forProject(ctx.cwd)
370
+ const effects: Array<[Domain, Effect]> = [['prose', effect(memory, 'prose')]]
371
+ const stats = formatStats(memory, effects, preventing)
372
+ const provenance = configProvenance(memory, config)
373
+ const warnings = healthWarnings()
374
+ const body = [...provenance, '', stats].join('\n')
375
+ if (warnings.length === 0) {
376
+ ctx.ui.notify(body, 'info')
377
+ } else {
378
+ ctx.ui.notify(`${warnings.map((w) => `timbre: ${w}`).join('\n')}\n\n${body}`, 'warning')
379
+ }
380
+ },
381
+ getArgumentCompletions: (prefix) =>
382
+ SUBCOMMANDS.filter((c) => c.startsWith(prefix)).map((c) => ({ value: c, label: c }))
383
+ })
384
+ }
385
+
386
+ /**
387
+ * The oxlint paths are parameters so a test can force the fault, the same way `scanCodeBatch`
388
+ * takes them. A spawn whose config is missing returns null, which reads exactly like a clean
389
+ * file, so without this a code check that can never run is indistinguishable from one that does.
390
+ */
391
+ export function moduleHealthWarnings(config: Config, bin?: string, rules?: string): string[] {
392
+ const warnings: string[] = []
393
+ if (!catalogAvailable())
394
+ warnings.push('rule catalog failed to load from disk; layer 0 is injecting nothing this session')
395
+ const codeProblem = codeCheckProblem(bin, rules)
396
+ if (codeProblem !== undefined) warnings.push(codeProblem)
397
+ return warnings
398
+ }
399
+
400
+ type Inspection = { path: string; hits: Hit[]; chars: number; note: string; domain: Domain }
401
+
402
+ function inspectProse(event: ToolResultEvent, cwd: string): Inspection | null {
403
+ const path = prosePath(event, cwd)
404
+ if (path === null) return null
405
+ const text = writtenText(event)
406
+ if (text === null) return null
407
+
408
+ const head = readHeader(path)
409
+ if (head !== null && isIgnored(head)) return null
410
+
411
+ const profile = classify(path)
412
+ const { hits: scanned, chars } = scanProse(text)
413
+ const hits = [
414
+ ...(event.toolName === 'edit' ? scanned.filter((h) => h.id !== 'unclosed-fence') : scanned),
415
+ ...rhythmHits(text, profile)
416
+ ]
417
+ const note = formatHits(path, hits)
418
+ return { path, hits, chars, note: registerNote(note, profile.register), domain: 'prose' }
419
+ }
420
+
421
+ function scanDisk(path: string): FixTarget | { error: string } {
422
+ if (!PROSE_FILE.test(path)) return { error: `${path} is not a prose file (.md, .mdx, .markdown, .txt)` }
423
+
424
+ let text: string
425
+ try {
426
+ if (statSync(path).size > MAX_FIX_FILE_BYTES) return { error: `${path} is too large to scan safely` }
427
+ text = readFileSync(path, 'utf8')
428
+ } catch {
429
+ return { error: `${path} could not be read` }
430
+ }
431
+ if (isIgnored(text)) return { error: `${path} opts out with a lint-disable marker` }
432
+
433
+ const profile = classify(path)
434
+ const { hits } = scanProse(text)
435
+ const allHits = [...hits, ...rhythmHits(text, profile)]
436
+ const segments = aggregateSpans(proseLineChars(text), allHits)
437
+ return { path, register: profile.register, hits: allHits, segments }
438
+ }
439
+
440
+ function namesFile(token: string, cwd: string): boolean {
441
+ if (token.startsWith('@') || GLOB_TOKEN.test(token) || PROSE_FILE.test(token)) return true
442
+ if (!PATH_TOKEN.test(token)) return false
443
+ return existsSync(isAbsolute(token) ? token : resolve(cwd, token))
444
+ }
445
+
446
+ function expandGlob(pattern: string, cwd: string): string[] {
447
+ try {
448
+ return globSync(pattern.includes('/') ? pattern : `**/${pattern}`, {
449
+ cwd,
450
+ exclude: (match) => GLOB_EXCLUDE.test(match)
451
+ })
452
+ .filter((match) => PROSE_FILE.test(match))
453
+ .toSorted()
454
+ .map((match) => resolve(cwd, match))
455
+ } catch {
456
+ return []
457
+ }
458
+ }
459
+
460
+ function requestFix(pi: ExtensionAPI, ctx: ExtensionContext, raw: string): void {
461
+ const tokens = raw.split(/\s+/).filter((token) => token !== '')
462
+ const names = tokens.filter((token) => namesFile(token, ctx.cwd))
463
+ let rest = raw
464
+ for (;;) {
465
+ const token = /^(\S+)\s*/.exec(rest)
466
+ if (token === null || !namesFile(token[1]!, ctx.cwd)) break
467
+ rest = rest.slice(token[0].length)
468
+ }
469
+ const instruction = rest.trim()
470
+
471
+ const problems: string[] = []
472
+ const paths: string[] = []
473
+ for (const name of names) {
474
+ const cleaned = name.replace(/^@/, '')
475
+ if (GLOB_TOKEN.test(cleaned)) {
476
+ const matched = expandGlob(cleaned, ctx.cwd)
477
+ if (matched.length === 0) problems.push(`${cleaned} matches no prose file`)
478
+ paths.push(...matched)
479
+ } else paths.push(isAbsolute(cleaned) ? cleaned : resolve(ctx.cwd, cleaned))
480
+ }
481
+
482
+ const deliver = ctx.isIdle() ? undefined : ({ deliverAs: 'followUp' } as const)
483
+ const fullRules = generationRules()
484
+
485
+ if (paths.length === 0 && problems.length === 0) {
486
+ if (instruction === '') {
487
+ const usage = 'Usage: /timbre fix <path|glob> ... [instructions], as in /timbre fix *.md keep it short'
488
+ ctx.ui.notify(usage, 'warning')
489
+ return
490
+ }
491
+ pi.sendUserMessage(formatFindRequest(instruction, fullRules), deliver)
492
+ return
493
+ }
494
+
495
+ let targets: FixTarget[] = []
496
+ const clean: FixTarget[] = []
497
+ for (const path of new Set(paths)) {
498
+ const found = scanDisk(path)
499
+ if ('error' in found) problems.push(found.error)
500
+ else if (found.hits.length === 0) clean.push(found)
501
+ else targets.push(found)
502
+ }
503
+
504
+ if (targets.length === 0) {
505
+ if (clean.length > 0 && instruction !== '') {
506
+ if (problems.length > 0) ctx.ui.notify(`timbre: skipped\n ${problems.join('\n ')}`, 'warning')
507
+ pi.sendUserMessage(formatFixRequest(clean.slice(0, MAX_FIX_FILES), instruction, fullRules), deliver)
508
+ return
509
+ }
510
+ const cleanNote =
511
+ clean.length > 3
512
+ ? [`${clean.length} files scan clean, nothing to rewrite`]
513
+ : clean.map((file) => `${file.path} scans clean, nothing to rewrite`)
514
+ ctx.ui.notify(`timbre: ${[...problems, ...cleanNote].join('\n')}`, problems.length > 0 ? 'warning' : 'info')
515
+ return
516
+ }
517
+ if (targets.length > MAX_FIX_FILES) {
518
+ problems.push(
519
+ `${targets.length} files carry tells, rewriting the first ${MAX_FIX_FILES}. Run it again for the rest.`
520
+ )
521
+ targets = targets.slice(0, MAX_FIX_FILES)
522
+ }
523
+ if (problems.length > 0) ctx.ui.notify(`timbre: skipped\n ${problems.join('\n ')}`, 'warning')
524
+
525
+ pi.sendUserMessage(formatFixRequest(targets, instruction, fullRules), deliver)
526
+ }
527
+
528
+ const WriteArgs = t.object({ content: t.string() })
529
+ const isWriteArgs = new Validator(WriteArgs).isValidObject
530
+ const EditArgs = t.object({ edits: t.array(t.object({ newText: t.string() })) })
531
+ const isEditArgs = new Validator(EditArgs).isValidObject
532
+
533
+ function writtenText(event: Pick<ToolResultEvent, 'toolName' | 'input'>): string | null {
534
+ if (event.toolName === 'write' && isWriteArgs(event.input)) {
535
+ return event.input.content
536
+ }
537
+ if (event.toolName === 'edit' && isEditArgs(event.input)) {
538
+ return event.input.edits.map((e) => e.newText).join('\n\n')
539
+ }
540
+ return null
541
+ }
542
+
543
+ function rhythmHits(text: string, profile: Profile): Hit[] {
544
+ if (profile.terseIsCorrect) return []
545
+ const hits: Hit[] = []
546
+
547
+ const b = burstiness(text)
548
+ if (wordCount(text) >= 50 && isMonotone(b)) {
549
+ hits.push({
550
+ line: 0,
551
+ id: 'monotone-rhythm',
552
+ tier: 2,
553
+ hint: `Sentence lengths barely vary (${b.count} sentences, mean ${b.mean.toFixed(0)} words). Mix short and long.`,
554
+ text: ''
555
+ })
556
+ }
557
+ if (hasStaccatoRun(text)) {
558
+ hits.push({
559
+ line: 0,
560
+ id: 'staccato-run',
561
+ tier: 2,
562
+ hint: 'Four or more short sentences in a row. One is emphasis, a run is engineering.',
563
+ text: ''
564
+ })
565
+ }
566
+ const opening = repeatedOpening(text)
567
+ if (opening !== null) {
568
+ hits.push({
569
+ line: 0,
570
+ id: 'repeated-opening',
571
+ tier: 2,
572
+ hint: 'Three or more consecutive sentences open the same way. Merge sentences or vary the opening.',
573
+ text: opening
574
+ })
575
+ }
576
+ if (hasLongSentenceRun(text)) {
577
+ hits.push({
578
+ line: 0,
579
+ id: 'long-sentence-run',
580
+ tier: 2,
581
+ hint: 'Three or more long sentences in a row. Split one; one idea per sentence.',
582
+ text: ''
583
+ })
584
+ }
585
+ if (hasClauseStack(text)) {
586
+ hits.push({
587
+ line: 0,
588
+ id: 'clause-stack',
589
+ tier: 2,
590
+ hint: 'Several sentences stack three or more subordinate clauses. Split one; one clause, one idea.',
591
+ text: ''
592
+ })
593
+ }
594
+ if (text.split(/\s+/).filter((w) => w !== '').length >= 120 && !text.includes('\n\n')) {
595
+ hits.push({
596
+ line: 0,
597
+ id: 'wall-paragraph',
598
+ tier: 2,
599
+ hint: 'One paragraph carries the whole argument. Split it; short paragraphs are easier to read.',
600
+ text: ''
601
+ })
602
+ }
603
+ return hits
604
+ }
605
+
606
+ // Both sides key on `hit.path`: oxlint can answer with a relative path where the write recorded an
607
+ // absolute one, and comparing those two bases made every lookup miss, so the check reported nothing.
608
+ export function freshFindings(findings: CodeFinding[], reported: Map<string, string>): CodeFinding[] {
609
+ const signatures = new Map<string, string[]>()
610
+ for (const hit of findings) {
611
+ const rows = signatures.get(hit.path)
612
+ if (rows === undefined) signatures.set(hit.path, [`${hit.id}:${hit.line}`])
613
+ else rows.push(`${hit.id}:${hit.line}`)
614
+ }
615
+ const fresh: CodeFinding[] = []
616
+ for (const [path, rows] of signatures) {
617
+ const signature = rows.toSorted().join(',')
618
+ if (signature === reported.get(path)) continue
619
+ reported.set(path, signature)
620
+ fresh.push(...findings.filter((hit) => hit.path === path))
621
+ }
622
+ return fresh
623
+ }
624
+
625
+ export function prosePath(event: Pick<ToolResultEvent, 'toolName' | 'isError' | 'input'>, cwd: string): string | null {
626
+ if (event.isError) return null
627
+ if (event.toolName !== 'write' && event.toolName !== 'edit') return null
628
+ const path = String(event.input['path'] ?? '')
629
+ if (!PROSE_FILE.test(path)) return null
630
+ return isAbsolute(path) ? path : resolve(cwd, path)
631
+ }
632
+
633
+ export function textOf(content: AssistantContent): string {
634
+ return content
635
+ .filter((block): block is TextContent => block.type === 'text')
636
+ .map((block) => block.text)
637
+ .join('\n\n')
638
+ }
639
+
640
+ function registerNote(note: string, register: string): string {
641
+ if (register === 'reference' || register === 'changelog') {
642
+ return `${note}\nRegister is ${register}: terse and plain is correct here, do not add voice.`
643
+ }
644
+ return note
645
+ }
646
+
647
+ function readHeader(path: string, bytes = 1600): string | null {
648
+ let fd: number | undefined
649
+ try {
650
+ fd = openSync(path, 'r')
651
+ const buf = Buffer.alloc(bytes)
652
+ const n = readSync(fd, buf, 0, bytes, 0)
653
+ return buf.toString('utf8', 0, n)
654
+ } catch {
655
+ return null
656
+ } finally {
657
+ if (fd !== undefined) closeSync(fd)
658
+ }
659
+ }
660
+
661
+ type FieldInfo = {
662
+ name: string
663
+ envName: string
664
+ envValue: boolean
665
+ file?: boolean
666
+ active: boolean
667
+ carried: boolean
668
+ }
669
+
670
+ function settingFields(memory: Memory, config: Config): FieldInfo[] {
671
+ const set = memory.settings ?? {}
672
+ const mk = (name: string, envName: string, envValue: boolean, file: boolean | undefined): FieldInfo => {
673
+ const active = file !== undefined ? file : envValue
674
+ return { name, envName, envValue, file, active, carried: file !== undefined && file !== envValue }
675
+ }
676
+ return [mk('prevent', 'TIMBRE_PREVENT', config.prevent, set.prevent)]
677
+ }
678
+
679
+ function activeLabel(f: FieldInfo): string {
680
+ return f.active ? 'on' : 'off'
681
+ }
682
+
683
+ export function configProvenance(memory: Memory, config: Config): string[] {
684
+ return settingFields(memory, config).map((f) => {
685
+ const source = `· ${f.file !== undefined ? 'state file' : `env ${f.envName}`}`.padEnd(34)
686
+ const mark = f.carried ? '⚠ carried over' : ''
687
+ return ` ${f.name.padEnd(9)}${activeLabel(f).padEnd(11)}${source}${mark}`
688
+ })
689
+ }
690
+
691
+ export function carriedOver(memory: Memory, config: Config): string[] {
692
+ return settingFields(memory, config)
693
+ .filter((f) => f.carried)
694
+ .map((f) => `${f.name} ${activeLabel(f)}`)
695
+ }