@tduyng/pi-timbre 0.0.0-stage → 0.0.1

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