@erclx/aitk 3.8.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "3.8.0",
4
+ "version": "3.9.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -11,9 +11,12 @@ Without this skill, a new skill lands in the wrong shape and the wrong place. A
11
11
 
12
12
  A skill born without its requirement is the second failure, and it surfaces much later. Coverage of the corpus is what the operator reads to decide whether a skill should exist, so every skill created without the sibling decays that reading, and the sweep that closes the gap has to reconstruct what the skill was for from the body it already shipped.
13
13
 
14
+ A third failure is a folder created for a moment a rule, a verb, or an existing skill already reaches, or for a procedure no session would get wrong from first principles. Nothing caught that before the folder existed, so the corpus grew by a skill an inward audit later had to argue for removing, with only a commit body recording the argument.
15
+
14
16
  ## Must
15
17
 
16
18
  - Read the authoring standard and the prose standard before drafting, so the draft starts conformant rather than getting corrected into shape
19
+ - Answer, in writing, whether a rule, a verb, or an existing skill already reaches this moment, and whether the body carries a procedure a session would get wrong from first principles. A folder that fails either belongs to the surface that already covers it, not to a new skill.
17
20
  - Draft the sibling `REQUIREMENT.md` beside the body, from what the skill is for rather than from the drafted body
18
21
  - Confirm the name and both files with the user before writing. The name is the routing key and a folder that disagrees with its frontmatter fails silently.
19
22
  - Write to the conventional skills path, so discovery finds it without configuration
@@ -23,6 +26,7 @@ A skill born without its requirement is the second failure, and it surfaces much
23
26
  - Auto-trigger. Creation is a deliberate act and a skill invented from an ambiguous request is worse than none.
24
27
  - Write either file before the user has seen it
25
28
  - Derive the requirement from the drafted body, which records the draft's overfitting as the requirement
29
+ - Gate creation on whether anything will invoke the new skill beyond the author typing its name. That question has no answer yet.
26
30
 
27
31
  ## Guards
28
32
 
@@ -31,4 +35,4 @@ A skill born without its requirement is the second failure, and it surfaces much
31
35
  ## Out of scope
32
36
 
33
37
  - Editing an existing skill, which the authoring standard and the skill's own requirement govern
34
- - Judging whether the skill is warranted. A skill nobody needed is an audit finding, not a creation-time refusal.
38
+ - Whether anything ends up invoking the new skill: the usage census in `.claude/context/claude-plugin/skill-strategy.md`, read once the skill has run
@@ -18,9 +18,15 @@ Read these files in parallel:
18
18
 
19
19
  ## Steps
20
20
 
21
- 1. Draft the full `SKILL.md` from the user's description
22
- 2. Draft the sibling `REQUIREMENT.md` from what the skill is for, in the shape the standard states. Write the gaps from the user's description rather than from the drafted body, since a requirement derived from the body records whatever the draft overfitted to.
23
- 3. Confirm the skill name and both files with the user before writing
24
- 4. Write to `.claude/skills/<name>/SKILL.md` and `.claude/skills/<name>/REQUIREMENT.md`
21
+ 1. Answer three questions in writing before drafting anything. Tell the user and stop if a rule, a verb, or an existing skill already reaches this moment, or if the body carries no procedure a session would get wrong from first principles:
22
+ - Does a rule, a verb, or an existing skill already reach this moment? A folder that only wraps something already reachable duplicates it rather than adding to the catalog.
23
+ - Does the body carry a procedure a session would get wrong from first principles? A one-line wrapper around a single command needs no skill.
24
+ - Will anything invoke it other than the author typing its name? Carry this one into the drafted `REQUIREMENT.md`'s `Must not` section as a review criterion instead, since nothing can answer it before the skill has run.
25
+ 2. Draft the full `SKILL.md` from the user's description
26
+ 3. Draft the sibling `REQUIREMENT.md` from what the skill is for, in the shape the standard states. Write the gaps from the user's description rather than from the drafted body, since a requirement derived from the body records whatever the draft overfitted to.
27
+ 4. Confirm the skill name and both files with the user before writing
28
+ 5. Write to `.claude/skills/<name>/SKILL.md` and `.claude/skills/<name>/REQUIREMENT.md`
25
29
 
26
30
  Every skill carries a requirement. A skill created without one is a gap someone closes in a later sweep, and the sweep has to reconstruct what the skill was for from the body it already shipped.
31
+
32
+ The two-question check has a shipped precedent. Pull request `#516` dropped `git-stash` for wrapping a single git command that needed no skill body, and `release-changelog` for being low-frequency and invoked by no other skill. The first is question one, and the second is question three read back after the fact, which is why it stays a review criterion rather than something this skill can gate on.
@@ -22,11 +22,11 @@ aitk audits list --json
22
22
 
23
23
  ## What it runs
24
24
 
25
- Sixteen verbs, listed by `aitk audits list`. Each runs once in its fullest form, and the aggregate reads that verb's own record rather than imposing a shared envelope on it. Every one of those records already has consumers naming its keys, so a common shape would be a breaking change bought for tidiness.
25
+ Seventeen verbs, listed by `aitk audits list`. Each runs once in its fullest form, and the aggregate reads that verb's own record rather than imposing a shared envelope on it. Every one of those records already has consumers naming its keys, so a common shape would be a breaking change bought for tidiness.
26
26
 
27
27
  The verbs walk separate trees and share no state, so they run together. Measured on the authoring machine at twelve verbs, a run finished in 0.8 seconds of wall clock against 4.4 seconds of processor, which is under every other stage in `bun run check`. `aitk deps audit` is the one that changes that reading, since it reaches a network rather than a tree and its latency is the index's rather than this machine's.
28
28
 
29
- Thirteen of the sixteen read a tree on this disk. The two added by `state-scoped-risk.md` read committed state rather than an arriving change, which is the gap every review surface here leaves by construction, and the one added by `label-coverage.md` reads a branch range against a map the project declares.
29
+ Fourteen of the seventeen read a tree on this disk, the one added by `restated.md` among them, since it reads three such trees against each other. The two added by `state-scoped-risk.md` read committed state rather than an arriving change, which is the gap every review surface here leaves by construction, and the one added by `label-coverage.md` reads a branch range against a map the project declares.
30
30
 
31
31
  Each is invoked as the CLI the caller is running rather than as a global `aitk`. A globally installed binary resolves to the main checkout no matter which worktree is executing, so the aggregate would measure a tree the branch never touched and report a pass over it.
32
32
 
@@ -50,6 +50,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
50
50
  | `aitk claude routing` | Report per `CLAUDE.md` section how many bullets name a path and how many of those a path-scoped rule already covers (`--json`) |
51
51
  | `aitk gov test-order` | Report where an implementation reached history ahead of the test covering it (`--json`) |
52
52
  | `aitk gov superseded` | Report where the tree still asserts a value a changed convention no longer produces, keyed on the value (`--json`) |
53
+ | `aitk gov restated` | Report every instruction the always-loaded file shares with the seed or a shipped skill body, classed and with its anchors named (`--json`) |
53
54
  | `aitk secrets scan` | Report credential-shaped values in the tree the package ships, keyed on issued values rather than on words (`--json`) |
54
55
  | `aitk deps audit` | Report published advisories against the resolved dependency set, refusing rather than reporting clean when the index is unreachable (`--json`) |
55
56
  | `aitk labels audit` | Report the labels a changed set earns from the pull request label map and the paths no row reaches (`--json`) |
@@ -24,6 +24,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
24
24
  - [Output shape](output-shape.md): Two framed shapes every command renders into, how JSON and --names modes keep stdout clean, and the exit discipline that lets piped output drain
25
25
  - [Overview](overview.md): What this folder covers, the invocation rules every command inherits, and where domain behavior is documented instead
26
26
  - [Records](records.md): Validating the session records under .claude/ and the standards corpus, the per-kind checks, the refusal reasons, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
27
+ - [Restated instructions](restated.md): Counting the instructions the always-loaded file shares with the seed and the shipped skill bodies, how a match is decided, the three classes, which surface a later edit starts from, and why the sweep reports rather than gates
27
28
  - [Routing report](routing.md): Reading per CLAUDE.md section how many bullets name a path, what counts as naming one, when a rule counts as covering it, the two refusals, and why the verb reports rather than gates
28
29
  - [Sandbox](sandbox.md): Scenario routing, the expectation scoring surface, and the coverage census over scenarios and skills
29
30
  - [Scripting](scripting.md): The runtime catalogs that replace hardcoded names, what each carries, and a headless invocation per domain
@@ -0,0 +1,78 @@
1
+ ---
2
+ title: Restated instructions
3
+ description: Counting the instructions the always-loaded file shares with the seed and the shipped skill bodies, how a match is decided, the three classes, which surface a later edit starts from, and why the sweep reports rather than gates
4
+ ---
5
+
6
+ # Restated instructions
7
+
8
+ `aitk gov restated` reports every instruction the always-loaded file states that a second surface states too. It answers a question nothing else here could: one rule was found written in three places, and it was found by accident when a shipped skill deleted a file the rule said to keep.
9
+
10
+ ```bash
11
+ aitk gov restated
12
+ aitk gov restated --json
13
+ aitk gov restated --root ../my-app
14
+ ```
15
+
16
+ | Option | Behavior |
17
+ | --------------- | ---------------------------------------------------------- |
18
+ | `--root <path>` | Tree to read, defaulting to the current directory |
19
+ | `--json` | Add a machine-readable record on stdout, keeping the frame |
20
+
21
+ Under `--json` the record holds stdout alone and the frame still renders on stderr, refusals included, which is the split `output-shape.md` fixes for every mode.
22
+
23
+ ## The corpus it reads
24
+
25
+ Three surfaces, and they are not read symmetrically. Every top-level bullet in `CLAUDE.md` is a subject, and the other two are searched:
26
+
27
+ - `tooling/claude/seeds/CLAUDE.md`, read as bullets, since the seed carries the same shape as the file it is authored from
28
+ - `claude/skills/*/SKILL.md`, read as every prose line and bullet, since the motivating case was stated in a body as a paragraph rather than a list item
29
+
30
+ Frontmatter, headings, tables, and fenced blocks are read past. A heading names a section instead of stating a rule, a fenced block is an example whose words belong to the prose around it, and a body's `description` restates that skill's own purpose, so sweeping it would match every subject naming its domain.
31
+
32
+ A rule stated in two skill bodies and never in `CLAUDE.md` is outside this reading. The bullets there are the subjects, and a subject is what the search runs from.
33
+
34
+ ## How a match is decided
35
+
36
+ Matching is recall-first and keyed on shared distinctive tokens rather than on a phrase two surfaces spell the same way. The case this exists for was one rule written three different ways, so a near-exact matcher would ship a cheap report blind to the defect it was built for.
37
+
38
+ A token is distinctive when it appears in at most 20 of the corpus's statements, which is under one percent of them. `.claude/plans/` sits at 14 and is the anchor the motivating case turns on, while `file` sits at 371 and would match most of the tree.
39
+
40
+ A backticked token counts double. An author marking a span as code named an identifier rather than describing one, so `.claude/plans/archive/` says more about what a statement governs than any two prose words do. Two statements are one rule when their shared anchors reach a weight of 3.
41
+
42
+ Every record names the anchors its match rested on, so a reader can weigh a finding instead of taking it.
43
+
44
+ ## The three classes
45
+
46
+ - **Mirror.** Both files sit on a declared path pair whose duplication is deliberate. `CLAUDE.md` and the seed are the one pair, since the seed is authored from it and `claude-seed-sync` exists to reconcile the two. Excluding by pair rather than by content is the point: the duplication is a location fact this repository already records, and a content test would rediscover it on every run.
47
+ - **Repetition.** Two surfaces state one rule and neither is declared a copy of the other.
48
+ - **Contradiction.** The prohibition falls on one surface alone, on a match strong enough to read that as a disagreement. This is a polarity reading rather than a judgment about meaning, so weigh each against the surfaces it names.
49
+
50
+ The contradiction floor sits above the match floor deliberately. A thin match says two statements touch the same subject, which is not enough to claim one forbids what the other prescribes, so a weak pair reports as a repetition and the loudest class is reserved for a pair sharing real identity.
51
+
52
+ Two further rules decide where a prohibition counts, and both came out of false positives rather than from reasoning ahead of the corpus.
53
+
54
+ Polarity is read off the clause the anchors landed in, and off the densest such clause rather than every one carrying an anchor. A statement states one rule across several clauses, so a union answers true whenever any clause anywhere carries a marker, which is the whole statement again under another name.
55
+
56
+ The marker also has to open its clause, because an instruction leads with its verb. `Never delete a task file` prohibits where `a fallback never fires` reports, and no test reading the marker anywhere in the clause tells those apart. What that costs is a prohibition written mid-clause, which now reads as description and lands the pair in the repetition class, so both surfaces still reach the report and only the label is weaker.
57
+
58
+ A mirror that disagrees stays a finding. The exclusion reaches a repetition alone, because the two files on a declared pair are meant to agree.
59
+
60
+ ## Which surface is authoritative
61
+
62
+ Each restatement names where a later edit starts.
63
+
64
+ - `claude-md` for a seed match, since the always-loaded file is authored first and the seed carries it to a target
65
+ - `skill-body` where the subject names that skill, which is the content-ownership rule that behavior triggered only when editing one domain belongs to that domain's skill
66
+ - `unknown` everywhere else, which is a first-class answer rather than a gap
67
+
68
+ The ownership table assigns a cross-domain rule and a domain-triggered one, and reaches nothing stated in a skill body the always-loaded file never names. Guessing there would put a reader on a surface nobody decided.
69
+
70
+ ## Exit codes
71
+
72
+ Exit codes are `0` when no instruction is restated outside a declared mirror, `1` for a refusal, and `2` for at least one restatement outside one. Mirrors move no exit code.
73
+
74
+ Nothing wires this into `bun run check` or into a hook. A restatement is legitimate more often than not, and gating a measure whose ordinary result is a finding is what teaches contributors to route around the stage. `aitk gov test-order` and `aitk labels audit` are the siblings.
75
+
76
+ Both refusals are absences rather than breaks. A target holds neither the seed nor a shipped skills tree, so `aitk audits run` reads `no-instructions` and `no-surfaces` as a corpus that is not there rather than a verb that failed.
77
+
78
+ An exit code says nothing about a call made from a session, since a shell profile may wrap the binary in a function taking its status from a later command. Read the record's `counts` rather than the exit when a skill consumes this.
@@ -12,6 +12,8 @@ paths:
12
12
  ## Before editing
13
13
 
14
14
  - Read the skill's sibling `REQUIREMENT.md` when one exists. If the change closes no gap it states, change the requirement first or drop the change.
15
+ - Confirm `create-skill`'s two creation-time questions are answered before a new `SKILL.md` lands, whether drafted by hand, by another skill, or by `create-skill` itself. Carry the third question into the sibling `REQUIREMENT.md`'s `Must not` section as a review criterion rather than a gate.
16
+ - Report it rather than proceeding silently when `create-skill` does not resolve. It ships with the plugin and this rule ships with the CLI, so a project that installed governance alone does not have it.
15
17
 
16
18
  ## After editing
17
19
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.8.0",
4
+ "version": "3.9.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -1,5 +1,6 @@
1
1
  import type { ReachRefusal } from '@/claude/skills-reach'
2
2
  import type { AuditRefusal } from '@/deps/audit'
3
+ import type { RestatedRefusal } from '@/gov/restated'
3
4
  import type { LabelAuditRefusal } from '@/labels/audit'
4
5
  import type { ValidateRefusal as RecordRefusal } from '@/records/validate'
5
6
  import type { ScanRefusal } from '@/secrets/scan'
@@ -356,6 +357,33 @@ function labelCoverageCounts(
356
357
  return allOf({ uncovered: lengthOf(root.uncovered) })
357
358
  }
358
359
 
360
+ /**
361
+ * Reads the two classes that are findings, leaving the declared mirrors out.
362
+ *
363
+ * A mirror is an authoring root and its consumed copy, which duplicate on
364
+ * purpose, so folding them in would report a corpus getting worse every time a
365
+ * seed is kept in step with the file it is authored from. Same reasoning the
366
+ * label audit drops its declined rows on.
367
+ *
368
+ * The reach count is left out for a different reason. It counts how many
369
+ * surfaces an instruction reached rather than whether anything is wrong, and it
370
+ * moves in lockstep with the two retained here, so a baseline carrying it would
371
+ * report one movement twice.
372
+ */
373
+ function restatedCounts(record: unknown): Record<string, number> | undefined {
374
+ const counts = asObject(asObject(record)?.counts)
375
+ if (counts === undefined) return undefined
376
+
377
+ return allOf({
378
+ contradictions:
379
+ typeof counts.contradictions === 'number'
380
+ ? counts.contradictions
381
+ : undefined,
382
+ repetitions:
383
+ typeof counts.repetitions === 'number' ? counts.repetitions : undefined,
384
+ })
385
+ }
386
+
359
387
  function findingsOnly(record: unknown): Record<string, number> | undefined {
360
388
  const root = asObject(record)
361
389
  if (root === undefined) return undefined
@@ -548,6 +576,27 @@ export const AUDITS: readonly AuditSpec[] = [
548
576
  absentReasons: ['no-map'] satisfies LabelAuditRefusal[],
549
577
  counts: labelCoverageCounts,
550
578
  },
579
+ {
580
+ id: 'restated',
581
+ label: 'Restated instructions',
582
+ argv: ['gov', 'restated', '--json'],
583
+ // Reports rather than gates, on the split this file already draws. Whether
584
+ // a rule stated on two surfaces should be stated on one is a judgment the
585
+ // person owning the surface takes, and most restatements here are correct,
586
+ // so a push failing on one would fail on the ordinary case.
587
+ gatingExits: [],
588
+ corpus: 'tracked',
589
+ // Both reasons the sweep refuses for, and each is an absence rather than a
590
+ // break. A target holds neither the seed nor a shipped skills tree, so
591
+ // without the allowance every project installing this CLI reports the verb
592
+ // unmeasured on every run and never changes, which is the permanent signal
593
+ // the per-machine allowance exists against. Same shape as the reach verb.
594
+ absentReasons: [
595
+ 'no-instructions',
596
+ 'no-surfaces',
597
+ ] satisfies RestatedRefusal[],
598
+ counts: restatedCounts,
599
+ },
551
600
  {
552
601
  id: 'deps',
553
602
  label: 'Dependency advisories',
package/src/cli.ts CHANGED
@@ -85,6 +85,7 @@ function showHelp(): void {
85
85
  `${GREY}│${NC} aitk sandbox git:commit`,
86
86
  `${GREY}│${NC} aitk gov install react`,
87
87
  `${GREY}│${NC} aitk gov sync ../my-app`,
88
+ `${GREY}│${NC} aitk gov restated --json`,
88
89
  `${GREY}│${NC} aitk standards markdown`,
89
90
  `${GREY}│${NC} aitk snippets install base ../my-app`,
90
91
  `${GREY}│${NC} aitk snippets sync ../my-app`,
@@ -14,6 +14,15 @@ import {
14
14
  mergeExtraRules,
15
15
  resolveRules,
16
16
  } from '@/gov/stacks'
17
+ import {
18
+ INSTRUCTIONS_REL,
19
+ type RestatedEntry,
20
+ type RestatedRefusal,
21
+ type RestatedReport,
22
+ readRestated,
23
+ SEED_REL,
24
+ SHIPPED_SKILLS_REL,
25
+ } from '@/gov/restated'
17
26
  import {
18
27
  readSuperseded,
19
28
  SUPERSEDED_MARKER,
@@ -68,6 +77,17 @@ interface SupersededOptions {
68
77
  readonly json?: boolean
69
78
  }
70
79
 
80
+ interface RestatedOptions {
81
+ readonly root?: string
82
+ readonly json?: boolean
83
+ }
84
+
85
+ /** What a reader does about each way the sweep produced no reading. */
86
+ const RESTATED_REFUSALS: Record<RestatedRefusal, string> = {
87
+ 'no-instructions': `No ${INSTRUCTIONS_REL} here, or it carries no bullet, so there is no instruction corpus to sweep.`,
88
+ 'no-surfaces': `Neither ${SEED_REL} nor ${SHIPPED_SKILLS_REL}/ is here, so no second surface exists to match against.`,
89
+ }
90
+
71
91
  export function register(program: Command): void {
72
92
  const gov = program
73
93
  .command('gov')
@@ -245,6 +265,146 @@ export function register(program: Command): void {
245
265
  process.exitCode = await runSuperseded(superseded, replacement, opts)
246
266
  },
247
267
  )
268
+
269
+ gov
270
+ .command('restated')
271
+ .description(
272
+ 'Report every instruction the always-loaded file states that a second surface states too',
273
+ )
274
+ .helpOption('-h, --help', 'Show this help message')
275
+ .option('--root <path>', 'Tree to read, defaulting to the cwd')
276
+ .option('--json', 'Add a machine-readable record on stdout')
277
+ .addHelpText(
278
+ 'after',
279
+ [
280
+ '',
281
+ `Matches every bullet in ${INSTRUCTIONS_REL} against ${SEED_REL}`,
282
+ `and every ${SHIPPED_SKILLS_REL}/*/SKILL.md body. Matching is recall-first,`,
283
+ 'keyed on distinctive tokens two statements share rather than on a phrase',
284
+ 'they spell the same way, because the case this exists for was one rule',
285
+ 'written three different ways.',
286
+ '',
287
+ 'What it separates:',
288
+ ' mirror a declared authoring-to-consumed pair, where repeating is the design',
289
+ ' repetition two surfaces state one rule and neither is declared a copy',
290
+ ' contradiction the prohibition falls on one surface alone, on a strong match',
291
+ '',
292
+ 'The contradiction class is a polarity reading rather than a judgment',
293
+ 'about meaning, so weigh each against the surfaces it names.',
294
+ '',
295
+ 'What it does not measure:',
296
+ ' a rule stated in two skill bodies and never in the always-loaded file,',
297
+ ' since the bullets there are the subjects and the bodies are searched',
298
+ '',
299
+ 'Exit codes:',
300
+ ' 0 no instruction is restated outside a declared mirror',
301
+ ' 1 refused, with the reason on stderr or in the JSON record',
302
+ ' 2 at least one instruction is restated outside a declared mirror',
303
+ '',
304
+ 'Examples:',
305
+ ' aitk gov restated',
306
+ ' aitk gov restated --json',
307
+ '',
308
+ ].join('\n'),
309
+ )
310
+ .action((opts: RestatedOptions) => {
311
+ process.exitCode = runRestated(opts)
312
+ })
313
+ }
314
+
315
+ /**
316
+ * Reports and never gates, matching the two sweeps above. A restatement is
317
+ * legitimate more often than not, so failing a push on one would fail on the
318
+ * ordinary case and teach contributors to route around the stage.
319
+ */
320
+ function runRestated(opts: RestatedOptions): number {
321
+ const root = resolve(opts.root ?? process.cwd())
322
+ const report = readRestated(root)
323
+ const emitJson = opts.json ?? false
324
+
325
+ if (report.kind === 'unreadable') {
326
+ intro('aitk gov restated')
327
+ logStep('Refused')
328
+ logWarn(RESTATED_REFUSALS[report.reason])
329
+ outro()
330
+
331
+ if (emitJson) {
332
+ process.stdout.write(
333
+ `${JSON.stringify({
334
+ root,
335
+ reason: report.reason,
336
+ message: RESTATED_REFUSALS[report.reason],
337
+ })}\n`,
338
+ )
339
+ }
340
+
341
+ return 1
342
+ }
343
+
344
+ reportRestated(report, root)
345
+
346
+ if (emitJson) {
347
+ process.stdout.write(`${JSON.stringify({ root, ...report })}\n`)
348
+ }
349
+
350
+ const findings = report.counts.contradictions + report.counts.repetitions
351
+ return findings > 0 ? 2 : 0
352
+ }
353
+
354
+ function describeEntry(entry: RestatedEntry): string[] {
355
+ const lines = [`${entry.subject.file}:${entry.subject.line}`]
356
+
357
+ for (const surface of entry.surfaces) {
358
+ lines.push(
359
+ ` [${surface.restatement}] ${surface.file}:${surface.line} via ${surface.anchors.join(', ')}`,
360
+ )
361
+ }
362
+
363
+ return lines
364
+ }
365
+
366
+ function reportRestated(
367
+ report: Extract<RestatedReport, { kind: 'measured' }>,
368
+ root: string,
369
+ ): void {
370
+ intro('aitk gov restated')
371
+
372
+ // A count of what matched reads as a verdict on the repository unless the run
373
+ // also says how wide the corpus behind it was.
374
+ logStep('Corpus')
375
+ logInfo(
376
+ `${report.corpus.instructions} instruction(s) against ${report.corpus.candidates} statement(s) from the seed and ${report.corpus.bodies} shipped body/bodies in ${root}`,
377
+ )
378
+ logInfo(
379
+ `matched on ${report.matcher.anchors} weighted anchor(s), dropping any token in more than ${report.matcher.common} statements`,
380
+ )
381
+
382
+ // Named rather than counted. A polarity split is the only class claiming a
383
+ // defect, and a reader weighing one has to reach both surfaces.
384
+ logStep(
385
+ report.counts.contradictions === 0 ? 'No contradiction' : 'Contradictions',
386
+ )
387
+ if (report.counts.contradictions === 0) {
388
+ logInfo('no restatement puts a prohibition on one surface alone')
389
+ } else {
390
+ for (const entry of report.restatements) {
391
+ const carries = entry.surfaces.some(
392
+ (surface) => surface.restatement === 'contradiction',
393
+ )
394
+ if (!carries) continue
395
+ for (const line of describeEntry(entry)) logWarn(line)
396
+ }
397
+ }
398
+
399
+ logStep('Restated')
400
+ logInfo(
401
+ `${report.counts.repetitions} repetition(s) outside a declared mirror, and ${report.counts.mirrors} on one`,
402
+ )
403
+ logInfo(
404
+ `${report.counts.threeSurface} instruction(s) reach three surfaces or more`,
405
+ )
406
+
407
+ outro()
248
408
  }
249
409
 
250
410
  /**
@@ -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 @@ 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