@erclx/aitk 3.8.0 → 3.10.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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/create-skill/REQUIREMENT.md +5 -1
- package/claude/skills/create-skill/SKILL.md +10 -4
- package/claude/skills/toolkit-cli/SKILL.md +0 -2
- package/docs/agents/audits.md +2 -2
- package/docs/agents/census.md +23 -0
- package/docs/agents/commands.md +2 -0
- package/docs/agents/index.md +2 -0
- package/docs/agents/restated.md +78 -0
- package/governance/rules/claude/570-skill.md +2 -0
- package/package.json +1 -1
- package/src/audits/catalog.ts +77 -0
- package/src/census/count.ts +113 -0
- package/src/cli.ts +5 -0
- package/src/commands/census.ts +105 -0
- package/src/commands/gov.ts +160 -0
- package/src/gov/restated.ts +688 -0
- package/standards/skill.md +1 -0
- package/tooling/base/manifest.toml +0 -2
- package/tooling/base/reference.md +12 -12
- package/tooling/base/seeds/.claude/context/development.md +4 -6
- package/tooling/base/configs/scripts/clean.sh +0 -45
- package/tooling/base/configs/scripts/update.sh +0 -49
|
@@ -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
|
+
}
|
package/standards/skill.md
CHANGED
|
@@ -21,6 +21,7 @@ Does not govern:
|
|
|
21
21
|
- Punctuation, formatting, and word choice in a skill body: `markdown.md`
|
|
22
22
|
- The transform from a branch name to a slug a skill carries in a filename: `slug.md`
|
|
23
23
|
- The domain conventions a skill cites, each of which belongs to the standard that owns it
|
|
24
|
+
- Whether a new skill earns its place: the three-question test in the `create-skill` skill and the clause in `570-skill.md`
|
|
24
25
|
|
|
25
26
|
## Changing a skill
|
|
26
27
|
|
|
@@ -23,8 +23,6 @@ packages = [
|
|
|
23
23
|
"format" = "prettier --write --log-level warn --ignore-path .gitignore --ignore-path .prettierignore . && shfmt --write --indent 2 scripts/"
|
|
24
24
|
"prepare" = "husky"
|
|
25
25
|
"check" = "./scripts/verify.sh"
|
|
26
|
-
"clean" = "./scripts/clean.sh"
|
|
27
|
-
"update" = "./scripts/update.sh"
|
|
28
26
|
|
|
29
27
|
[gitignore]
|
|
30
28
|
"# System" = [".DS_Store"]
|