@erclx/aitk 3.7.0 → 3.9.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.
@@ -54,6 +54,7 @@ export function createGovAdapter(root: string): SyncAdapter {
54
54
  locateSource: (file: InstalledFile) =>
55
55
  index.get(basename(file.path, '.md')),
56
56
  collectRetired: (target: string) => collectRetiredGov(target),
57
+ projectSubdir: 'project',
57
58
  stamp: { domain: 'governance', toolkitRoot: root },
58
59
  }
59
60
  }
@@ -0,0 +1,688 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ /** The always-loaded file whose bullets are the subjects this sweep matches. */
5
+ export const INSTRUCTIONS_REL = 'CLAUDE.md'
6
+
7
+ /** The seed a target receives, authored from the file above. */
8
+ export const SEED_REL = join('tooling', 'claude', 'seeds', 'CLAUDE.md')
9
+
10
+ /** The shipped plugin bodies, which is where a rule restated in prose lands. */
11
+ export const SHIPPED_SKILLS_REL = join('claude', 'skills')
12
+
13
+ /**
14
+ * Path pairs whose duplication is deliberate and already recorded.
15
+ *
16
+ * The seed is authored from the always-loaded file and `claude-seed-sync`
17
+ * exists to reconcile the two, so a bullet appearing in both is the design
18
+ * rather than a defect. Excluding by pair rather than by content is what the
19
+ * plan settled on: the duplication is a location fact this repository already
20
+ * records, and a content test would have to rediscover it on every run.
21
+ *
22
+ * The exclusion reaches a repetition alone. A mirror that disagrees is the one
23
+ * shape the pairing cannot absorb, since the two files are meant to agree, so
24
+ * a polarity split on a declared pair stays a finding.
25
+ */
26
+ const MIRRORS: readonly (readonly [string, string])[] = [
27
+ [INSTRUCTIONS_REL, SEED_REL],
28
+ ]
29
+
30
+ /**
31
+ * A token appearing in more than this many statements carries no signal.
32
+ *
33
+ * Under one percent of the 2750 statements this repository offers. Tuned
34
+ * against that corpus rather than reasoned to, which is what the plan asked of
35
+ * the first run: `.claude/plans/` sits at 14 and is the anchor the motivating
36
+ * case turns on, while `file` sits at 371 and matches most of the tree.
37
+ */
38
+ export const COMMON_CEILING = 20
39
+
40
+ /** Weight two statements must share before they are read as one rule. */
41
+ export const ANCHOR_FLOOR = 3
42
+
43
+ /**
44
+ * What a backticked token is worth against a plain word.
45
+ *
46
+ * An author marking a span as code named an identifier rather than describing
47
+ * one, so `.claude/plans/archive/` says more about what a statement governs
48
+ * than any two prose words do. Weighting it is what lets the floor rise high
49
+ * enough to drop a coincidental word pair without losing a rule two surfaces
50
+ * spelled entirely differently around one shared path.
51
+ */
52
+ const SPAN_WEIGHT = 2
53
+
54
+ /**
55
+ * Weight a match needs before a polarity split is called a contradiction.
56
+ *
57
+ * Above the match floor on purpose. A thin match says two statements touch the
58
+ * same subject, which is not enough to claim one forbids what the other
59
+ * prescribes, so a weak pair reports as a repetition and the loudest class is
60
+ * reserved for a pair sharing real identity.
61
+ */
62
+ export const CONTRADICTION_FLOOR = 5
63
+
64
+ /**
65
+ * Words carrying no subject, dropped before anchors are counted.
66
+ *
67
+ * Short rather than exhaustive. The document-frequency ceiling above removes
68
+ * the rest on its own, and a hand-written list long enough to do that job
69
+ * would be a second corpus nobody maintains.
70
+ */
71
+ const STOPWORDS = new Set([
72
+ 'about',
73
+ 'after',
74
+ 'against',
75
+ 'already',
76
+ 'also',
77
+ 'and',
78
+ 'any',
79
+ 'are',
80
+ 'because',
81
+ 'been',
82
+ 'before',
83
+ 'being',
84
+ 'both',
85
+ 'but',
86
+ 'can',
87
+ 'each',
88
+ 'either',
89
+ 'else',
90
+ 'every',
91
+ 'for',
92
+ 'from',
93
+ 'has',
94
+ 'have',
95
+ 'her',
96
+ 'here',
97
+ 'his',
98
+ 'how',
99
+ 'into',
100
+ 'its',
101
+ 'itself',
102
+ 'more',
103
+ 'most',
104
+ 'much',
105
+ 'must',
106
+ 'once',
107
+ 'one',
108
+ 'only',
109
+ 'other',
110
+ 'our',
111
+ 'out',
112
+ 'over',
113
+ 'own',
114
+ 'per',
115
+ 'rather',
116
+ 'same',
117
+ 'she',
118
+ 'should',
119
+ 'since',
120
+ 'some',
121
+ 'such',
122
+ 'than',
123
+ 'that',
124
+ 'the',
125
+ 'their',
126
+ 'them',
127
+ 'then',
128
+ 'there',
129
+ 'these',
130
+ 'they',
131
+ 'this',
132
+ 'those',
133
+ 'through',
134
+ 'too',
135
+ 'under',
136
+ 'until',
137
+ 'upon',
138
+ 'very',
139
+ 'was',
140
+ 'were',
141
+ 'what',
142
+ 'when',
143
+ 'where',
144
+ 'which',
145
+ 'while',
146
+ 'who',
147
+ 'whose',
148
+ 'why',
149
+ 'will',
150
+ 'with',
151
+ 'would',
152
+ 'you',
153
+ 'your',
154
+ ])
155
+
156
+ /**
157
+ * Markers reading as a prohibition, which is the one polarity signal available
158
+ * without understanding the sentence.
159
+ *
160
+ * Deliberately narrow. `no` and `not` are excluded because both appear inside
161
+ * ordinary qualifying clauses, and widening the set turns most of the corpus
162
+ * into a suspected contradiction.
163
+ */
164
+ const PROHIBITIONS = ['never', 'do not', "don't", 'avoid', 'refuse']
165
+
166
+ export type RestatedRefusal = 'no-instructions' | 'no-surfaces'
167
+
168
+ export type Restatement = 'mirror' | 'repetition' | 'contradiction'
169
+
170
+ /** Which surface a restatement was found on, before any class is assigned. */
171
+ export type SurfaceKind = 'seed' | 'skill'
172
+
173
+ /**
174
+ * Which surface a later edit starts from.
175
+ *
176
+ * `unknown` is a first-class answer rather than a gap. The content-ownership
177
+ * table assigns a cross-domain rule and a domain-triggered one, and reaches
178
+ * nothing stated in a skill body the always-loaded file never names, so
179
+ * guessing there would put a reader on a surface nobody decided.
180
+ */
181
+ export type Authority = 'claude-md' | 'skill-body' | 'unknown'
182
+
183
+ export interface Statement {
184
+ /** Repository-relative, so a record reads the same from any working root. */
185
+ readonly file: string
186
+ /** One-based, matching the `file:line` form a reader clicks. */
187
+ readonly line: number
188
+ readonly text: string
189
+ }
190
+
191
+ export interface Surface extends Statement {
192
+ readonly kind: SurfaceKind
193
+ readonly restatement: Restatement
194
+ /** The distinctive tokens this match rested on, so a finding is auditable. */
195
+ readonly anchors: readonly string[]
196
+ /** Those anchors scored, with a backticked one counting double. */
197
+ readonly weight: number
198
+ readonly authority: Authority
199
+ /** Why the class and the authority read the way they do. */
200
+ readonly reason: string
201
+ }
202
+
203
+ export interface RestatedEntry {
204
+ readonly subject: Statement
205
+ readonly surfaces: readonly Surface[]
206
+ }
207
+
208
+ export interface RestatedCounts {
209
+ readonly contradictions: number
210
+ readonly repetitions: number
211
+ readonly mirrors: number
212
+ /** Subjects carried by two further surfaces, which is the title's count. */
213
+ readonly threeSurface: number
214
+ }
215
+
216
+ export type RestatedReport =
217
+ | {
218
+ readonly kind: 'measured'
219
+ readonly corpus: {
220
+ readonly instructions: number
221
+ readonly seed: number
222
+ readonly bodies: number
223
+ /** Statements the two further surfaces offered, which bounds recall. */
224
+ readonly candidates: number
225
+ }
226
+ readonly matcher: {
227
+ readonly anchors: number
228
+ readonly common: number
229
+ readonly contradiction: number
230
+ }
231
+ readonly restatements: readonly RestatedEntry[]
232
+ readonly counts: RestatedCounts
233
+ }
234
+ | { readonly kind: 'unreadable'; readonly reason: RestatedRefusal }
235
+
236
+ interface Candidate extends Statement {
237
+ readonly kind: SurfaceKind
238
+ /** The skill folder this statement sits in, present on a body alone. */
239
+ readonly skill?: string
240
+ }
241
+
242
+ export interface Analysis {
243
+ readonly tokens: ReadonlySet<string>
244
+ /** The subset an author backticked, which weighs more than a plain word. */
245
+ readonly spans: ReadonlySet<string>
246
+ }
247
+
248
+ /** A statement paired with the tokens it carries. */
249
+ interface Indexed<T extends Statement> {
250
+ readonly statement: T
251
+ readonly analysis: Analysis
252
+ }
253
+
254
+ /**
255
+ * Splits text into the tokens an anchor can be drawn from.
256
+ *
257
+ * A code span keeps its inner text whole, since `.claude/plans/archive/` is the
258
+ * strongest anchor this corpus offers and splitting it on the punctuation would
259
+ * leave three words every second bullet also carries.
260
+ */
261
+ export function analyze(text: string): Analysis {
262
+ const spans: string[] = []
263
+ const withoutSpans = text.replace(/`([^`]+)`/g, (_match, inner: string) => {
264
+ spans.push(inner.toLowerCase())
265
+ return ' '
266
+ })
267
+
268
+ const words = withoutSpans
269
+ .toLowerCase()
270
+ .replace(/\[([^\]]*)\]\(([^)]*)\)/g, '$1 $2')
271
+ .split(/[^a-z0-9/._<>-]+/)
272
+ .map((word) => word.replace(/^[-._/]+|[-._/,;:]+$/g, ''))
273
+
274
+ const keep = (token: string): boolean =>
275
+ token.length >= 3 && !STOPWORDS.has(token)
276
+
277
+ return {
278
+ tokens: new Set([...spans, ...words].filter(keep)),
279
+ spans: new Set(spans.filter(keep)),
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Whether a clause instructs against something, rather than merely describing
285
+ * something that does not happen.
286
+ *
287
+ * The marker has to open the clause. A prohibition is an instruction, and an
288
+ * instruction leads with its verb, so `Never delete a task file` prohibits
289
+ * where `a fallback never fires` reports. Reading the marker anywhere in the
290
+ * clause cannot separate those two, and this corpus writes both: the third
291
+ * false contradiction found here was `so a || fallback never fires` against a
292
+ * seed clause saying the same thing in other words.
293
+ *
294
+ * What it costs is a prohibition written mid-clause, as in `edit with the tool,
295
+ * never a stream editor`, which now reads as description. That miss lands the
296
+ * pair in the repetition class rather than dropping it, so both surfaces still
297
+ * reach the report and only the label is weaker.
298
+ */
299
+ function prohibits(text: string): boolean {
300
+ const opening = text
301
+ .toLowerCase()
302
+ .replace(/^[^a-z]*/, '')
303
+ .replace(/^(and|but|so|then|also|however)[\s,]+/, '')
304
+
305
+ return PROHIBITIONS.some((marker) => opening.startsWith(marker))
306
+ }
307
+
308
+ /**
309
+ * The clauses a statement's polarity is read against.
310
+ *
311
+ * The trailing span is kept, where `splitSentences` in `src/markdown/structure.ts`
312
+ * drops one no punctuation closes. A bullet routinely ends without a period and
313
+ * its last clause is routinely the one carrying the prohibition, so dropping it
314
+ * would lose exactly the half this reads. The two contracts differ, which is why
315
+ * this is a second splitter rather than a shared one.
316
+ */
317
+ function clauses(text: string): string[] {
318
+ return text
319
+ .split(/(?<=[.!?])\s+/)
320
+ .map((clause) => clause.trim())
321
+ .filter((clause) => clause !== '')
322
+ }
323
+
324
+ /**
325
+ * The clause a match landed in, which is where a prohibition has to sit before
326
+ * it says anything about the rule the two statements share.
327
+ *
328
+ * The densest clause rather than every clause carrying an anchor. A statement
329
+ * states one rule across several clauses, and a union answers true whenever any
330
+ * clause anywhere carries a marker, which is the whole statement again under
331
+ * another name.
332
+ *
333
+ * Both contradictions this repository reported were that defect. The
334
+ * always-loaded file splits the stream-editor rule across two bullets and the
335
+ * seed folds them into one, so the subject was the exception half alone while
336
+ * the seed's bullet carried the `never` from a clause the anchors never
337
+ * touched, and the two agreed completely.
338
+ */
339
+ function anchoredClause(text: string, anchors: ReadonlySet<string>): string {
340
+ const parts = clauses(text)
341
+ if (parts.length <= 1) return text
342
+
343
+ let best = text
344
+ let bestHits = 0
345
+
346
+ for (const part of parts) {
347
+ const tokens = analyze(part).tokens
348
+ let hits = 0
349
+ for (const anchor of anchors) if (tokens.has(anchor)) hits += 1
350
+
351
+ if (hits > bestHits) {
352
+ bestHits = hits
353
+ best = part
354
+ }
355
+ }
356
+
357
+ // No clause carries an anchor, which a boundary landing inside a code span
358
+ // can produce. Reading the whole statement is what this did before the clause
359
+ // scope, so it degrades to that rather than to no polarity at all.
360
+ return bestHits === 0 ? text : best
361
+ }
362
+
363
+ function isMirrorPair(subject: string, surface: string): boolean {
364
+ return MIRRORS.some(
365
+ ([left, right]) =>
366
+ (subject === left && surface === right) ||
367
+ (subject === right && surface === left),
368
+ )
369
+ }
370
+
371
+ /**
372
+ * Bullets at the top level of a markdown file, which is the unit an instruction
373
+ * takes in the always-loaded file and in the seed.
374
+ */
375
+ function readBullets(root: string, relative: string): Statement[] {
376
+ const full = join(root, relative)
377
+ if (!existsSync(full)) return []
378
+
379
+ const statements: Statement[] = []
380
+ let fenced = false
381
+
382
+ readFileSync(full, 'utf8')
383
+ .split('\n')
384
+ .forEach((line, index) => {
385
+ if (line.trimStart().startsWith('```')) {
386
+ fenced = !fenced
387
+ return
388
+ }
389
+ if (fenced || !line.startsWith('- ')) return
390
+
391
+ statements.push({
392
+ file: relative.replaceAll('\\', '/'),
393
+ line: index + 1,
394
+ text: line.slice(2).trim(),
395
+ })
396
+ })
397
+
398
+ return statements
399
+ }
400
+
401
+ /**
402
+ * Every prose line and bullet in a shipped body.
403
+ *
404
+ * Wider than the bullet rule above because the motivating case was stated in a
405
+ * body as a paragraph, so a bullet-only read would miss the one instance this
406
+ * sweep exists for. Headings, tables, and fenced blocks are read past: a
407
+ * heading names a section rather than stating a rule, and a fenced block is an
408
+ * example whose words are the surrounding prose's by construction.
409
+ */
410
+ function readBodyLines(root: string, skillsRoot: string): Candidate[] {
411
+ const candidates: Candidate[] = []
412
+
413
+ const files = [
414
+ ...new Bun.Glob('*/SKILL.md').scanSync({
415
+ cwd: skillsRoot,
416
+ onlyFiles: true,
417
+ }),
418
+ ].sort()
419
+
420
+ for (const file of files) {
421
+ const posix = file.replaceAll('\\', '/')
422
+ const skill = posix.split('/')[0]
423
+ const relative = `${SHIPPED_SKILLS_REL.replaceAll('\\', '/')}/${posix}`
424
+
425
+ const lines = readFileSync(join(root, relative), 'utf8').split('\n')
426
+ let fenced = false
427
+ let frontmatter = lines[0]?.trim() === '---'
428
+
429
+ for (const [index, line] of lines.entries()) {
430
+ const trimmed = line.trim()
431
+
432
+ // Frontmatter is metadata rather than instruction, and every body's
433
+ // `description` restates that skill's own purpose, so sweeping it makes
434
+ // each skill match any subject naming its domain.
435
+ if (frontmatter) {
436
+ if (index > 0 && trimmed === '---') frontmatter = false
437
+ continue
438
+ }
439
+
440
+ if (trimmed.startsWith('```')) {
441
+ fenced = !fenced
442
+ continue
443
+ }
444
+ if (fenced || trimmed === '') continue
445
+ if (trimmed.startsWith('#') || trimmed.startsWith('|')) continue
446
+ if (trimmed.startsWith('---')) continue
447
+
448
+ candidates.push({
449
+ file: relative,
450
+ line: index + 1,
451
+ text: trimmed.replace(/^[-*>]\s+/, ''),
452
+ kind: 'skill',
453
+ skill,
454
+ })
455
+ }
456
+ }
457
+
458
+ return candidates
459
+ }
460
+
461
+ /** How many statements each token appears in, which is what rarity is read off. */
462
+ function documentFrequency(
463
+ groups: readonly (readonly Indexed<Statement>[])[],
464
+ ): Map<string, number> {
465
+ const frequency = new Map<string, number>()
466
+
467
+ for (const group of groups) {
468
+ for (const entry of group) {
469
+ for (const token of entry.analysis.tokens) {
470
+ frequency.set(token, (frequency.get(token) ?? 0) + 1)
471
+ }
472
+ }
473
+ }
474
+
475
+ return frequency
476
+ }
477
+
478
+ function index<T extends Statement>(statement: T): Indexed<T> {
479
+ return { statement, analysis: analyze(statement.text) }
480
+ }
481
+
482
+ /**
483
+ * Which surface a later edit starts from, and why.
484
+ *
485
+ * A skill body earns authority only where the subject names that skill, which
486
+ * is the content-ownership table's rule that behavior triggered when editing
487
+ * domain X belongs to X's skill. Everything else the table does not reach is
488
+ * reported as unknown.
489
+ */
490
+ function authorityFor(
491
+ subject: Statement,
492
+ candidate: Candidate,
493
+ ): { authority: Authority; reason: string } {
494
+ if (candidate.kind === 'seed') {
495
+ return {
496
+ authority: 'claude-md',
497
+ reason: `${INSTRUCTIONS_REL} is authored first and the seed carries it to a target, so an edit starts there and reaches the seed through claude-seed-sync`,
498
+ }
499
+ }
500
+
501
+ if (
502
+ candidate.skill !== undefined &&
503
+ subject.text.toLowerCase().includes(candidate.skill.toLowerCase())
504
+ ) {
505
+ return {
506
+ authority: 'skill-body',
507
+ reason: `the subject names ${candidate.skill}, and behavior triggered only when editing one domain belongs to that domain's skill`,
508
+ }
509
+ }
510
+
511
+ return {
512
+ authority: 'unknown',
513
+ reason:
514
+ 'the content-ownership table assigns a cross-domain rule and a domain-triggered one, and reaches neither from here',
515
+ }
516
+ }
517
+
518
+ function classify(
519
+ subject: Statement,
520
+ candidate: Candidate,
521
+ weight: number,
522
+ anchors: ReadonlySet<string>,
523
+ ): { restatement: Restatement; reason: string } {
524
+ const split =
525
+ prohibits(anchoredClause(subject.text, anchors)) !==
526
+ prohibits(anchoredClause(candidate.text, anchors))
527
+
528
+ if (split && weight >= CONTRADICTION_FLOOR) {
529
+ return {
530
+ restatement: 'contradiction',
531
+ reason:
532
+ 'the clause each surface was matched on states this as a prohibition on one side alone, which is a polarity reading rather than a judgment about meaning',
533
+ }
534
+ }
535
+
536
+ if (isMirrorPair(subject.file, candidate.file)) {
537
+ return {
538
+ restatement: 'mirror',
539
+ reason:
540
+ 'both files sit on a declared mirror pair, where repeating the rule is the design',
541
+ }
542
+ }
543
+
544
+ return {
545
+ restatement: 'repetition',
546
+ reason: split
547
+ ? 'the matched clause carries a prohibition on one surface alone, on a match too thin to read that as a disagreement'
548
+ : 'two surfaces state one rule and neither is declared a copy of the other',
549
+ }
550
+ }
551
+
552
+ /**
553
+ * Every instruction in the always-loaded file that a second surface also states.
554
+ *
555
+ * Matching is recall-first, keyed on distinctive tokens two statements share
556
+ * rather than on a phrase they spell the same way. The motivating case was one
557
+ * rule written three different ways, so a near-exact matcher would miss the
558
+ * defect the sweep exists for, and a recall-first reading can be narrowed from
559
+ * real output where the reverse cannot.
560
+ *
561
+ * It reports and never gates. A restatement is legitimate more often than not,
562
+ * so a push failing on one would fail on the ordinary case.
563
+ */
564
+ export function readRestated(root: string): RestatedReport {
565
+ const instructions = readBullets(root, INSTRUCTIONS_REL)
566
+ if (instructions.length === 0) {
567
+ return { kind: 'unreadable', reason: 'no-instructions' }
568
+ }
569
+
570
+ const seed: Candidate[] = readBullets(root, SEED_REL).map((statement) => ({
571
+ ...statement,
572
+ kind: 'seed' as const,
573
+ }))
574
+
575
+ const skillsRoot = join(root, SHIPPED_SKILLS_REL)
576
+ const bodies = existsSync(skillsRoot) ? readBodyLines(root, skillsRoot) : []
577
+
578
+ const bodyFiles = new Set(bodies.map((candidate) => candidate.file)).size
579
+ if (seed.length === 0 && bodies.length === 0) {
580
+ return { kind: 'unreadable', reason: 'no-surfaces' }
581
+ }
582
+
583
+ const subjects = instructions.map(index)
584
+ const candidates = [...seed, ...bodies].map(index)
585
+ const frequency = documentFrequency([subjects, candidates])
586
+
587
+ const distinctive = (analysis: Analysis): Set<string> =>
588
+ new Set(
589
+ [...analysis.tokens].filter(
590
+ (token) => (frequency.get(token) ?? 0) <= COMMON_CEILING,
591
+ ),
592
+ )
593
+
594
+ // Every candidate's rare set is invariant across the subject loop, so it is
595
+ // built once here rather than per pair. The corpora multiply out to hundreds
596
+ // of thousands of pairings, and rebuilding a set inside that is the shape
597
+ // `.claude/rules/core/040-performance.md` names.
598
+ const rareCandidates = candidates.map((candidate) => ({
599
+ ...candidate,
600
+ rare: distinctive(candidate.analysis),
601
+ }))
602
+
603
+ const restatements: RestatedEntry[] = []
604
+ let contradictions = 0
605
+ let repetitions = 0
606
+ let mirrors = 0
607
+ let threeSurface = 0
608
+
609
+ for (const subject of subjects) {
610
+ const rare = distinctive(subject.analysis)
611
+ const surfaces: Surface[] = []
612
+
613
+ for (const candidate of rareCandidates) {
614
+ const shared = [...candidate.rare]
615
+ .filter((token) => rare.has(token))
616
+ .sort()
617
+
618
+ const weight = shared.reduce(
619
+ (total, token) =>
620
+ total +
621
+ (subject.analysis.spans.has(token) &&
622
+ candidate.analysis.spans.has(token)
623
+ ? SPAN_WEIGHT
624
+ : 1),
625
+ 0,
626
+ )
627
+
628
+ if (weight < ANCHOR_FLOOR) continue
629
+
630
+ const { restatement, reason } = classify(
631
+ subject.statement,
632
+ candidate.statement,
633
+ weight,
634
+ new Set(shared),
635
+ )
636
+ const { authority, reason: why } = authorityFor(
637
+ subject.statement,
638
+ candidate.statement,
639
+ )
640
+
641
+ surfaces.push({
642
+ file: candidate.statement.file,
643
+ line: candidate.statement.line,
644
+ text: candidate.statement.text,
645
+ kind: candidate.statement.kind,
646
+ restatement,
647
+ anchors: shared,
648
+ weight,
649
+ authority,
650
+ reason: `${reason}; ${why}`,
651
+ })
652
+ }
653
+
654
+ if (surfaces.length === 0) continue
655
+
656
+ for (const surface of surfaces) {
657
+ if (surface.restatement === 'contradiction') contradictions += 1
658
+ else if (surface.restatement === 'mirror') mirrors += 1
659
+ else repetitions += 1
660
+ }
661
+
662
+ // Every surface counts here, a declared mirror included. The motivating
663
+ // case was the always-loaded file, the seed, and a body, so dropping the
664
+ // mirror would read that exact shape as a rule stated twice. The mirror
665
+ // exclusion is a rule about which class is a finding, not about how far an
666
+ // instruction reached.
667
+ if (surfaces.length >= 2) threeSurface += 1
668
+
669
+ restatements.push({ subject: subject.statement, surfaces })
670
+ }
671
+
672
+ return {
673
+ kind: 'measured',
674
+ corpus: {
675
+ instructions: instructions.length,
676
+ seed: seed.length,
677
+ bodies: bodyFiles,
678
+ candidates: candidates.length,
679
+ },
680
+ matcher: {
681
+ anchors: ANCHOR_FLOOR,
682
+ common: COMMON_CEILING,
683
+ contradiction: CONTRADICTION_FLOOR,
684
+ },
685
+ restatements,
686
+ counts: { contradictions, repetitions, mirrors, threeSurface },
687
+ }
688
+ }
@@ -21,6 +21,7 @@ export function createSnippetsAdapter(root: string): SyncAdapter {
21
21
  unit: 'snippets',
22
22
  installedRoot: (target: string) => join(target, '.claude', 'snippets'),
23
23
  locateSource: (file: InstalledFile) => locateSource(sourceDir, file),
24
+ projectSubdir: 'project',
24
25
  stamp: { domain: 'snippets', toolkitRoot: root },
25
26
  }
26
27
  }