@erclx/canon 5.63.0 → 5.64.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/references/adopted.md +1 -1
  4. package/claude/skills/context-fold/REQUIREMENT.md +2 -3
  5. package/claude/skills/context-fold/SKILL.md +1 -11
  6. package/claude/skills/context-fold/references/completion-report.md +8 -9
  7. package/claude/skills/create-skill/references/skill-practice.md +16 -1
  8. package/claude/skills/design-taste/references/adopted.md +17 -97
  9. package/claude/skills/design-taste/references/tells.md +2 -2
  10. package/claude/skills/document-health/REQUIREMENT.md +1 -1
  11. package/claude/skills/document-health/SKILL.md +3 -14
  12. package/claude/skills/document-health/references/axes.md +14 -16
  13. package/docs/agents/audits.md +2 -6
  14. package/docs/agents/context-audit.md +1 -1
  15. package/governance/ledger/skills/api-design/SKILL.md +62 -0
  16. package/governance/ledger/skills/code-craft/SKILL.md +53 -0
  17. package/governance/ledger/skills/deprecation-migration/SKILL.md +68 -0
  18. package/governance/ledger/skills/design-taste/SKILL.md +64 -0
  19. package/governance/ledger/skills/design-taste/craft.md +66 -0
  20. package/governance/ledger/skills/design-taste/kinds.md +58 -0
  21. package/governance/ledger/skills/design-taste/systems.md +42 -0
  22. package/governance/ledger/skills/design-taste/tells.md +103 -0
  23. package/package.json +1 -1
  24. package/src/audits/catalog.ts +0 -52
  25. package/src/claude/skills-audit.ts +117 -41
  26. package/src/cli/retired.ts +8 -0
  27. package/src/commands/claude.ts +11 -2
  28. package/src/commands/context.ts +0 -425
  29. package/src/context/citations.ts +9 -3
  30. package/src/gate/measures.ts +1 -1
  31. package/src/git/files.ts +8 -3
  32. package/src/markdown/gated.ts +1 -1
  33. package/src/markdown/scope.ts +0 -1
  34. package/claude/skills/context-fold/references/classify.md +0 -87
  35. package/src/context/classify/extract.ts +0 -443
  36. package/src/context/classify/ollama.ts +0 -172
  37. package/src/context/classify/patterns.ts +0 -75
  38. package/src/context/classify/prompts.ts +0 -74
  39. package/src/context/classify/run.ts +0 -348
  40. package/src/context/classify/settings.ts +0 -196
@@ -1,443 +0,0 @@
1
- import { existsSync, readFileSync } from 'node:fs'
2
- import { basename, relative, resolve } from 'node:path'
3
- import { $ } from 'bun'
4
- import { resolveFolders } from '@/context/folders'
5
- import { gitEnv } from '@/git/env'
6
- import { listChangedFiles, resolveBaseRef } from '@/git/files'
7
- import { surfaceDir } from '@/roots/surface'
8
-
9
- /**
10
- * The three canonical doc types the classifier reaches, matching the file
11
- * pattern the groundwork spike scoped its fixture to
12
- * (`scripts/hunks.py`'s `DOC_PATH`) and the file-type catalog both system
13
- * prompts state.
14
- */
15
- export const CANONICAL_DOC_TYPES = [
16
- 'context',
17
- 'design',
18
- 'requirements',
19
- ] as const
20
-
21
- export type CanonicalDocType = (typeof CANONICAL_DOC_TYPES)[number]
22
-
23
- const DOC_PATTERNS: Record<CanonicalDocType, RegExp> = {
24
- context: /(^|\/)context\/.+\.md$/,
25
- design: /(^|\/)DESIGN\.md$/,
26
- requirements: /(^|\/)REQUIREMENTS\.md$/,
27
- }
28
-
29
- const INDEX_NAME = 'index.md'
30
-
31
- /** The doc type `resolveFolders` discovers, paired with the folder name it takes. */
32
- const MULTI_DOC_TYPES: readonly { type: CanonicalDocType; name: string }[] = [
33
- { type: 'context', name: 'context' },
34
- ]
35
-
36
- /** The two doc types that resolve to one file each, paired with `surfaceDir`'s entry name. */
37
- const SINGLE_DOC_TYPES: readonly { type: CanonicalDocType; entry: string }[] = [
38
- { type: 'design', entry: 'DESIGN.md' },
39
- { type: 'requirements', entry: 'REQUIREMENTS.md' },
40
- ]
41
-
42
- /**
43
- * Which canonical doc type a repo-relative path belongs to, regardless of
44
- * which surface root (`canon/` or `.claude/`) it resolved under.
45
- */
46
- export function docTypeOf(file: string): CanonicalDocType | undefined {
47
- if (basename(file) === INDEX_NAME) return undefined
48
-
49
- return CANONICAL_DOC_TYPES.find((type) => DOC_PATTERNS[type].test(file))
50
- }
51
-
52
- /**
53
- * A hunk of 25 words or more of added text, the groundwork fixture's own
54
- * floor (`hunks.py`'s `MIN_ADDED_WORDS`). A trivial edit, a typo fix, a
55
- * one-line link repair, is not worth a model call or a regex read, and the
56
- * spike's own labelled set never sampled anything smaller.
57
- */
58
- export const MIN_ADDED_WORDS = 25
59
-
60
- /** `hunks.py`'s `MAX_SECTION_WORDS`, so a chunk's context never blows the model's context window. */
61
- const MAX_SECTION_WORDS = 900
62
-
63
- export interface DiffChunk {
64
- /** Repo-relative path. */
65
- readonly file: string
66
- readonly docType: CanonicalDocType
67
- /** Empty string when the hunk added text with nothing removed. */
68
- readonly removed: string
69
- readonly added: string
70
- /** The heading-delimited section the hunk's added lines landed in, current content. */
71
- readonly sectionAfter: string
72
- }
73
-
74
- export type ExtractRefusal = 'bad-range' | 'unreadable-file'
75
-
76
- export type DiffExtraction =
77
- | { readonly kind: 'ok'; readonly chunks: readonly DiffChunk[] }
78
- | {
79
- readonly kind: 'refused'
80
- readonly reason: ExtractRefusal
81
- readonly message: string
82
- }
83
-
84
- async function gitRaw(
85
- root: string,
86
- args: string[],
87
- ): Promise<string | undefined> {
88
- const result = await $`git -C ${root} ${args}`.env(gitEnv()).quiet().nothrow()
89
- return result.exitCode === 0 ? result.text() : undefined
90
- }
91
-
92
- interface RawHunk {
93
- /** 1-based line number in the new (working tree) file where the hunk starts. */
94
- readonly newStart: number
95
- readonly added: readonly string[]
96
- readonly removed: readonly string[]
97
- }
98
-
99
- const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/
100
-
101
- /**
102
- * Reads a single-file, zero-context unified diff into its hunks. Ported from
103
- * `hunks.py`'s header-and-accumulate loop.
104
- */
105
- function parseHunks(diffText: string): RawHunk[] {
106
- const hunks: { newStart: number; added: string[]; removed: string[] }[] = []
107
-
108
- for (const line of diffText.split('\n')) {
109
- const header = HUNK_HEADER.exec(line)
110
- if (header) {
111
- hunks.push({ newStart: Number(header[1]), added: [], removed: [] })
112
- continue
113
- }
114
-
115
- const current = hunks.at(-1)
116
- if (!current) continue
117
-
118
- if (line.startsWith('+') && !line.startsWith('+++')) {
119
- current.added.push(line.slice(1))
120
- } else if (line.startsWith('-') && !line.startsWith('---')) {
121
- current.removed.push(line.slice(1))
122
- }
123
- }
124
-
125
- return hunks
126
- }
127
-
128
- function wordCount(text: string): number {
129
- return text.split(/\s+/).filter(Boolean).length
130
- }
131
-
132
- const HEADING = /^(#{1,6})\s/
133
-
134
- /**
135
- * The heading-delimited section a zero-based line index sits in, in the
136
- * current file content: the nearest heading at or above the line, through
137
- * the next heading at the same level or shallower. Ported from `hunks.py`'s
138
- * `_section_around`.
139
- *
140
- * Returns an empty string when the index sits above every heading, which a
141
- * malformed doc or one carrying no heading at all can produce and which the
142
- * caller reads as "no section to show," never as a refusal.
143
- */
144
- export function sectionAround(
145
- lines: readonly string[],
146
- lineIndex: number,
147
- ): string {
148
- let headingIndex: number | undefined
149
- for (let j = Math.min(lineIndex, lines.length - 1); j >= 0; j--) {
150
- if (HEADING.test(lines[j])) {
151
- headingIndex = j
152
- break
153
- }
154
- }
155
- if (headingIndex === undefined) return ''
156
-
157
- const level = HEADING.exec(lines[headingIndex])?.[1].length ?? 1
158
- let end = lines.length
159
- for (let k = headingIndex + 1; k < lines.length; k++) {
160
- const match = HEADING.exec(lines[k])
161
- if (match && match[1].length <= level) {
162
- end = k
163
- break
164
- }
165
- }
166
-
167
- const section = lines.slice(headingIndex, end).join('\n')
168
- const words = section.split(/\s+/).filter(Boolean)
169
- if (words.length <= MAX_SECTION_WORDS) return section
170
-
171
- return `${words.slice(0, MAX_SECTION_WORDS).join(' ')} …[section truncated]`
172
- }
173
-
174
- /**
175
- * Every diff-mode chunk in the range `ref..working tree`, across the
176
- * requested canonical doc types.
177
- *
178
- * Reads the working tree rather than `HEAD`, matching `listChangedFiles`: a
179
- * `context-fold` run checking a session's own edits sees them before they are
180
- * committed. A file the range deleted contributes nothing, since there is no
181
- * current section left to read a hunk's chunk against.
182
- */
183
- export async function extractDiffChunks(
184
- root: string,
185
- ref: string | undefined,
186
- docTypes: readonly CanonicalDocType[] = CANONICAL_DOC_TYPES,
187
- ): Promise<DiffExtraction> {
188
- const base = await resolveBaseRef(root, ref)
189
- if (base === undefined) {
190
- return {
191
- kind: 'refused',
192
- reason: 'bad-range',
193
- message: `could not resolve a merge base for ${ref ?? 'the trunk'}`,
194
- }
195
- }
196
-
197
- const changed = await listChangedFiles(root, base)
198
- if (changed === undefined) {
199
- return {
200
- kind: 'refused',
201
- reason: 'bad-range',
202
- message: 'git could not list the files changed since the base',
203
- }
204
- }
205
-
206
- const scoped = changed
207
- .map((file) => ({ file, docType: docTypeOf(file) }))
208
- .filter(
209
- (entry): entry is { file: string; docType: CanonicalDocType } =>
210
- entry.docType !== undefined && docTypes.includes(entry.docType),
211
- )
212
-
213
- const chunks: DiffChunk[] = []
214
-
215
- for (const { file, docType } of scoped) {
216
- const absolute = resolve(root, file)
217
- if (!existsSync(absolute)) continue
218
-
219
- let content: string
220
- try {
221
- content = readFileSync(absolute, 'utf8')
222
- } catch {
223
- return {
224
- kind: 'refused',
225
- reason: 'unreadable-file',
226
- message: `could not read ${file}`,
227
- }
228
- }
229
-
230
- const patch = await gitRaw(root, ['diff', '-U0', base, '--', file])
231
- if (patch === undefined) continue
232
-
233
- const lines = content.split('\n')
234
- for (const hunk of parseHunks(patch)) {
235
- const added = hunk.added.join('\n')
236
- if (wordCount(added) < MIN_ADDED_WORDS) continue
237
-
238
- chunks.push({
239
- file,
240
- docType,
241
- removed: hunk.removed.join('\n'),
242
- added,
243
- sectionAfter: sectionAround(lines, hunk.newStart - 1),
244
- })
245
- }
246
- }
247
-
248
- return { kind: 'ok', chunks }
249
- }
250
-
251
- export interface SweepSection {
252
- /** Repo-relative path. */
253
- readonly file: string
254
- readonly docType: CanonicalDocType
255
- readonly heading: string
256
- readonly body: string
257
- }
258
-
259
- /**
260
- * Splits a file's headings into sweep sections, at H3 where a document
261
- * carries H3 headings and at H2 otherwise, per the groundwork decision that
262
- * a large H2 section splits further. An H2 carrying its own prose ahead of
263
- * its first child H3 contributes that prose as a section of its own, headed
264
- * by the H2, since it is content nothing else will sweep.
265
- */
266
- export function sweepSections(
267
- lines: readonly string[],
268
- ): { heading: string; body: string }[] {
269
- const headings: { index: number; level: number }[] = []
270
- for (let i = 0; i < lines.length; i++) {
271
- const match = HEADING.exec(lines[i])
272
- if (match && match[1].length >= 2 && match[1].length <= 3) {
273
- headings.push({ index: i, level: match[1].length })
274
- }
275
- }
276
-
277
- const sections: { heading: string; body: string }[] = []
278
-
279
- // An H3 sitting before the document's first H2, or in a document that
280
- // carries no H2 at all, has no enclosing section for the main loop below
281
- // to attribute it to. Sweep it as a section of its own instead of
282
- // dropping it, which is the shape of an entry whose H1 is followed
283
- // directly by H3s with no H2 anywhere.
284
- const firstH2 = headings.findIndex((h) => h.level === 2)
285
- const leadingBound = firstH2 === -1 ? headings.length : firstH2
286
-
287
- for (let i = 0; i < leadingBound; i++) {
288
- const { index, level } = headings[i]
289
- if (level !== 3) continue
290
-
291
- const nextBoundary = headings.find((h, k) => k > i && h.level <= 3)
292
- const end = nextBoundary?.index ?? lines.length
293
- sections.push(bodyOf(lines, index, end))
294
- }
295
-
296
- for (let i = 0; i < headings.length; i++) {
297
- const { index, level } = headings[i]
298
- if (level !== 2) continue
299
-
300
- const nextH2 = headings.findIndex((h, j) => j > i && h.level === 2)
301
- const sectionEnd = nextH2 === -1 ? lines.length : headings[nextH2].index
302
-
303
- const firstChildH3 = headings.findIndex(
304
- (h, j) => j > i && h.level === 3 && h.index < sectionEnd,
305
- )
306
-
307
- if (firstChildH3 === -1) {
308
- sections.push(bodyOf(lines, index, sectionEnd))
309
- continue
310
- }
311
-
312
- if (
313
- firstChildH3 > i + 1 ||
314
- hasContent(lines, index + 1, headings[firstChildH3].index)
315
- ) {
316
- sections.push(bodyOf(lines, index, headings[firstChildH3].index))
317
- }
318
-
319
- for (let j = firstChildH3; j < headings.length; j++) {
320
- const { index: h3Index, level: h3Level } = headings[j]
321
- if (h3Index >= sectionEnd) break
322
- if (h3Level !== 3) continue
323
-
324
- const nextBoundary = headings.find(
325
- (h, k) => k > j && h.index < sectionEnd && h.level <= 3,
326
- )
327
- const end = nextBoundary?.index ?? sectionEnd
328
- sections.push(bodyOf(lines, h3Index, end))
329
- }
330
- }
331
-
332
- return sections
333
- }
334
-
335
- function hasContent(
336
- lines: readonly string[],
337
- start: number,
338
- end: number,
339
- ): boolean {
340
- return lines.slice(start, end).some((line) => line.trim() !== '')
341
- }
342
-
343
- function bodyOf(
344
- lines: readonly string[],
345
- start: number,
346
- end: number,
347
- ): { heading: string; body: string } {
348
- const headingText = lines[start].replace(/^#+\s*/, '').trim()
349
- return {
350
- heading: headingText,
351
- body: lines.slice(start, end).join('\n').trim(),
352
- }
353
- }
354
-
355
- export type SweepExtraction =
356
- | { readonly kind: 'ok'; readonly sections: readonly SweepSection[] }
357
- | {
358
- readonly kind: 'refused'
359
- readonly reason: ExtractRefusal
360
- readonly message: string
361
- }
362
-
363
- function readSections(
364
- path: string,
365
- docType: CanonicalDocType,
366
- rel: string,
367
- ): SweepSection[] | undefined {
368
- let content: string
369
- try {
370
- content = readFileSync(path, 'utf8')
371
- } catch {
372
- return undefined
373
- }
374
-
375
- return sweepSections(content.split('\n')).map((section) => ({
376
- file: rel,
377
- docType,
378
- ...section,
379
- }))
380
- }
381
-
382
- /**
383
- * Every sweep-mode section across the requested canonical doc types.
384
- *
385
- * `context` resolves through `resolveFolders`, the same
386
- * folder discovery `canon context audit` uses, so a domain split into a
387
- * folder of its own is swept the same way it is audited. The two
388
- * single-file types resolve through `surfaceDir`.
389
- */
390
- export async function extractSweepSections(
391
- root: string,
392
- docTypes: readonly CanonicalDocType[] = CANONICAL_DOC_TYPES,
393
- ): Promise<SweepExtraction> {
394
- const sections: SweepSection[] = []
395
-
396
- const multi = MULTI_DOC_TYPES.filter((entry) => docTypes.includes(entry.type))
397
-
398
- if (multi.length > 0) {
399
- const { folders } = await resolveFolders(
400
- root,
401
- multi.map((entry) => entry.name),
402
- )
403
- for (const folder of folders) {
404
- const docType = multi.find((entry) => entry.name === folder.name)?.type
405
- if (!docType) continue
406
-
407
- for (const path of folder.entries) {
408
- const rel = relative(root, path)
409
- const found = readSections(path, docType, rel)
410
- if (found === undefined) {
411
- return {
412
- kind: 'refused',
413
- reason: 'unreadable-file',
414
- message: `could not read ${rel}`,
415
- }
416
- }
417
- sections.push(...found)
418
- }
419
- }
420
- }
421
-
422
- const single = SINGLE_DOC_TYPES.filter((candidate) =>
423
- docTypes.includes(candidate.type),
424
- )
425
-
426
- for (const { type, entry } of single) {
427
- const path = surfaceDir(root, entry)
428
- if (!existsSync(path)) continue
429
-
430
- const rel = relative(root, path)
431
- const found = readSections(path, type, rel)
432
- if (found === undefined) {
433
- return {
434
- kind: 'refused',
435
- reason: 'unreadable-file',
436
- message: `could not read ${rel}`,
437
- }
438
- }
439
- sections.push(...found)
440
- }
441
-
442
- return { kind: 'ok', sections }
443
- }
@@ -1,172 +0,0 @@
1
- /**
2
- * The local Ollama backend: a reachability probe and one chat call per item.
3
- *
4
- * One chunk or section per call, never batched. The groundwork spike measured
5
- * batching 16 hunks into a single call returning KEEP for every one, so a
6
- * caller here (`run.ts`) invokes `chat` once per item rather than folding a
7
- * set into one prompt.
8
- */
9
-
10
- export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434'
11
-
12
- /**
13
- * Nothing in the groundwork pins a request timeout: the spike scripts used a
14
- * 600s ceiling meant for a batch research run, not a per-call budget for an
15
- * interactive verb. 30s is chosen here as a deviation, wide enough that a
16
- * shared GPU under another local session's load (measured coexisting at 12GB)
17
- * still has room, while short enough that a `context-fold` run does not hang
18
- * indefinitely on a backend that stopped responding mid-call.
19
- */
20
- export const OLLAMA_TIMEOUT_MS = 30_000
21
-
22
- /** Parsed straight off the model's own JSON, upper-cased for a loose model. */
23
- export interface ParsedVerdict {
24
- readonly verdict: string
25
- readonly quote: string
26
- readonly reason: string
27
- }
28
-
29
- export type ChatOutcome =
30
- | {
31
- readonly kind: 'ok'
32
- readonly parsed: ParsedVerdict
33
- readonly raw: string
34
- }
35
- | { readonly kind: 'unparsed'; readonly raw: string }
36
- | { readonly kind: 'unreachable'; readonly message: string }
37
- | { readonly kind: 'timeout' }
38
-
39
- function describeError(error: unknown): string {
40
- return error instanceof Error ? error.message : String(error)
41
- }
42
-
43
- function isTimeout(error: unknown): boolean {
44
- return error instanceof DOMException && error.name === 'TimeoutError'
45
- }
46
-
47
- /**
48
- * Reads the model's reply out of its own JSON body.
49
- *
50
- * Ported from `classify.py`'s `_parse`: find the first `{` and the last `}`,
51
- * so a model that wraps its JSON in a sentence or a fence still parses. Diff
52
- * mode returns a single `quote` string and sweep mode returns a `quotes`
53
- * array, and both are read here since the caller decides which mode it asked
54
- * for by which prompt it sent, not by which shape came back.
55
- */
56
- export function parseVerdictText(text: string): ParsedVerdict | undefined {
57
- const start = text.indexOf('{')
58
- const end = text.lastIndexOf('}')
59
- if (start === -1 || end === -1 || end < start) return undefined
60
-
61
- let data: unknown
62
- try {
63
- data = JSON.parse(text.slice(start, end + 1))
64
- } catch {
65
- return undefined
66
- }
67
-
68
- if (typeof data !== 'object' || data === null) return undefined
69
- const record = data as Record<string, unknown>
70
-
71
- const verdict = record.verdict
72
- if (typeof verdict !== 'string' || verdict === '') return undefined
73
-
74
- const quote =
75
- typeof record.quote === 'string'
76
- ? record.quote
77
- : Array.isArray(record.quotes)
78
- ? record.quotes.map((entry) => String(entry)).join(' | ')
79
- : ''
80
-
81
- const reason = typeof record.reason === 'string' ? record.reason : ''
82
-
83
- return { verdict: verdict.toUpperCase(), quote, reason }
84
- }
85
-
86
- /**
87
- * Whether the backend answers at all, checked before the real call so a
88
- * per-item timeout is never the thing that discovers an unreachable Ollama.
89
- */
90
- export async function probeOllama(
91
- baseUrl: string,
92
- timeoutMs = OLLAMA_TIMEOUT_MS,
93
- ): Promise<boolean> {
94
- try {
95
- const response = await fetch(`${baseUrl}/api/tags`, {
96
- signal: AbortSignal.timeout(timeoutMs),
97
- })
98
- return response.ok
99
- } catch {
100
- return false
101
- }
102
- }
103
-
104
- /**
105
- * One chat call: JSON output format, temperature 0, thinking off.
106
- *
107
- * Thinking is always off. The groundwork spike measured it never catching a
108
- * flag thinking-off missed, at roughly five times the latency, and in sweep
109
- * mode it lost three real flags by reasoning itself past them. There is no
110
- * option to turn it on here, matching the groundwork decision to defer that
111
- * rather than build an unused knob.
112
- */
113
- export async function chat(opts: {
114
- readonly baseUrl: string
115
- readonly model: string
116
- readonly system: string
117
- readonly user: string
118
- readonly timeoutMs?: number
119
- }): Promise<ChatOutcome> {
120
- const timeoutMs = opts.timeoutMs ?? OLLAMA_TIMEOUT_MS
121
-
122
- let response: Response
123
- try {
124
- response = await fetch(`${opts.baseUrl}/api/chat`, {
125
- method: 'POST',
126
- headers: { 'Content-Type': 'application/json' },
127
- body: JSON.stringify({
128
- model: opts.model,
129
- stream: false,
130
- think: false,
131
- format: 'json',
132
- options: { temperature: 0, num_ctx: 16384 },
133
- messages: [
134
- { role: 'system', content: opts.system },
135
- { role: 'user', content: opts.user },
136
- ],
137
- }),
138
- signal: AbortSignal.timeout(timeoutMs),
139
- })
140
- } catch (error) {
141
- if (isTimeout(error)) return { kind: 'timeout' }
142
- return { kind: 'unreachable', message: describeError(error) }
143
- }
144
-
145
- if (!response.ok) {
146
- return {
147
- kind: 'unreachable',
148
- message: `ollama returned ${response.status}`,
149
- }
150
- }
151
-
152
- let body: unknown
153
- try {
154
- body = await response.json()
155
- } catch (error) {
156
- return { kind: 'unparsed', raw: describeError(error) }
157
- }
158
-
159
- const content =
160
- typeof body === 'object' && body !== null
161
- ? (body as { message?: { content?: unknown } }).message?.content
162
- : undefined
163
-
164
- if (typeof content !== 'string') {
165
- return { kind: 'unparsed', raw: JSON.stringify(body) }
166
- }
167
-
168
- const parsed = parseVerdictText(content)
169
- return parsed === undefined
170
- ? { kind: 'unparsed', raw: content }
171
- : { kind: 'ok', parsed, raw: content }
172
- }
@@ -1,75 +0,0 @@
1
- /**
2
- * Regex layer for the doc classifier, ported from the groundwork spike at
3
- * `.canon/groundwork/93-canonical-doc-drift/scripts/heuristic.py`. It never
4
- * needs a model installed, so it is the layer that always runs.
5
- *
6
- * Diff mode reads one changed chunk and answers KEEP, HISTORY, or MOVE. It
7
- * never answers REPLACE, since telling a restated figure from a genuinely new
8
- * one needs the section the hunk landed in, which the model layer reads and
9
- * this one does not. Sweep mode reads one whole section and answers KEEP,
10
- * REWRITE, or MOVE, collapsing REPLACE and HISTORY the way the sweep prompt
11
- * does, since a section already carries its own history in view.
12
- */
13
-
14
- export type DiffVerdict = 'KEEP' | 'REPLACE' | 'HISTORY' | 'MOVE'
15
- export type SweepVerdict = 'KEEP' | 'REWRITE' | 'MOVE'
16
-
17
- export interface PatternVerdict<V extends string> {
18
- readonly verdict: V
19
- /** Absent on KEEP, since nothing decided against the text. */
20
- readonly quote?: string
21
- readonly reason: string
22
- }
23
-
24
- /**
25
- * Narrates how a fact got here rather than stating it: a branch or PR name, a
26
- * closed or retired marker, a review-pass story. Ported from
27
- * `heuristic.py`'s `NARRATION` pattern with one change: the bare
28
- * `on \d{4}-\d{2}-\d{2}` alternative is dropped.
29
- *
30
- * That alternative was measured to false-flag a gotcha ending "Measured at
31
- * `<sha>` on <date>.", a dating anchor both prompts call correct on a current
32
- * statement. Every tuned hunk the bare date branch caught also carries a
33
- * narration verb elsewhere in the same text (`closed on`, `moved again`,
34
- * `did not survive`), so dropping it costs no measured catch and removes the
35
- * one measured false flag.
36
- */
37
- const NARRATION =
38
- /moved (again|twice|on)|has since|at this branch|on `feat|Measured in PR|did not survive|the operator picked|picked arm|\barm \d|closed on|was retired|no longer|used to|\bround\b|a review pass|this pass/i
39
-
40
- function narrationQuote(text: string): string | undefined {
41
- return text.match(NARRATION)?.[0]
42
- }
43
-
44
- /** Classifies one diff-mode chunk from its added text alone. */
45
- export function diffPatternVerdict(added: string): PatternVerdict<DiffVerdict> {
46
- const quote = narrationQuote(added)
47
- if (quote !== undefined) {
48
- return { verdict: 'HISTORY', quote, reason: 'narrates how this got here' }
49
- }
50
-
51
- return { verdict: 'KEEP', reason: 'no narration or wrong-surface pattern' }
52
- }
53
-
54
- /**
55
- * Classifies one sweep-mode section from its whole body.
56
- *
57
- * REWRITE stands in for both REPLACE and HISTORY, matching `sweep-v1.md`'s
58
- * own three-verdict vocabulary: a section already shows its own history in
59
- * full view, so the model layer does not need the diff-mode split and the
60
- * regex layer follows it.
61
- */
62
- export function sweepPatternVerdict(
63
- body: string,
64
- ): PatternVerdict<SweepVerdict> {
65
- const quote = narrationQuote(body)
66
- if (quote !== undefined) {
67
- return {
68
- verdict: 'REWRITE',
69
- quote,
70
- reason: 'carries its own history rather than the current state alone',
71
- }
72
- }
73
-
74
- return { verdict: 'KEEP', reason: 'no narration or wrong-surface pattern' }
75
- }