@erclx/aitk 3.22.0 → 3.23.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.22.0",
4
+ "version": "3.23.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -26,7 +26,7 @@ Nineteen verbs, listed by `aitk audits list`. Each runs once in its fullest form
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
- Sixteen of the nineteen read a tree on this disk, the one added by `restated.md` and the one added by `census.md` among them, since the first reads three such trees against each other and the second counts every file in one. 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
+ Sixteen of the nineteen read a tree on this disk, the one added by `restated.md` and the one added by `census.md` among them, since the first reads four such trees against each other and the second counts every file in one. 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
 
@@ -9,58 +9,58 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
9
9
 
10
10
  ## Project-level
11
11
 
12
- | Command | Purpose |
13
- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
14
- | `aitk init [path]` | Bootstrap a project with selected toolkit domains |
15
- | `aitk sync [path]` | Sync all installed domains in a target project |
16
- | `aitk sync --check` | Report toolkit drift and the installed version against the newest published (`--json`, `--exit-code`) |
17
- | `aitk sandbox [cat:cmd]` | Run sandbox scenarios (interactive or routed), toolkit-only like the tree it reads |
18
- | `aitk sandbox reset` | Reset sandbox to baseline |
19
- | `aitk sandbox clean` | Wipe the sandbox |
20
- | `aitk sandbox check` | Score a provisioned sandbox against a scenario expectation (`--json` for the verdict) |
21
- | `aitk sandbox coverage` | Report which scenarios declare expectations (`--json`, `--strict`, `--skills`) |
22
- | `aitk indexes regen` | Regenerate `index.md` files from sibling frontmatter |
23
- | `aitk docs [topic]` | Emit toolkit reference docs (`list`, or a topic by name) |
24
- | `aitk design render` | Render `.claude/DESIGN.md` tokens to HTML and CSS |
25
- | `aitk slides render` | Render a `.claude/SLIDES.md` source into a PowerPoint deck |
26
- | `aitk slides list` | List the available slide layouts (`--json` for the catalog) |
27
- | `aitk feedback` | Write toolkit feedback from stdin to `.claude/review/feedback/`, or open a GitHub issue with `--github` |
28
- | `aitk transcripts <url>` | Fetch a YouTube transcript with metadata frontmatter (needs `yt-dlp`) |
29
- | `aitk tasks archive` | Move a shipped task off the board, clear its ordering row, and regenerate the index |
30
- | `aitk tasks pull-request` | Record a pull request number on the task a branch closes, by stem or `--plan` (`--json`) |
31
- | `aitk tasks outcome` | Mark outcomes `[x]` on a task by position, repeating `--close` (`--json`) |
32
- | `aitk tasks validate` | Report board rows whose plan, task file, group, file set, or blocker does not hold (`--json`) |
33
- | `aitk intake list` | Report intake folder counts, or one folder's items, keeping what is unread with `--unread` (`--json`) |
34
- | `aitk intake answer` | Write selections into one cluster's answer slots, repeating `--set <item>=<answer>` (`--json`) |
35
- | `aitk teach list` | Report learning workspaces and the ordinal a new one takes, or what one workspace holds (`--json`) |
36
- | `aitk teach open` | Open a workspace at the next ordinal with its mission, resources, and glossary files (`--json`) |
37
- | `aitk teach resource` | Record sources and leads in a workspace, repeating `--read` or `--lead` as `<title>=<url>` (`--json`) |
38
- | `aitk teach glossary` | Add terms to a workspace glossary alphabetically, repeating `--term <term>=<definition>` (`--json`) |
39
- | `aitk records validate` | Report a session record or a standard against the standard governing it, per kind (`--json`) |
40
- | `aitk records migrate` | Rewrite the records a validate finding names a recoverable transform for (`--write`, `--json`) |
41
- | `aitk records size` | Report what each record folder holds and how much of it is recent, heaviest first (`--json`) |
42
- | `aitk records push` | Commit the nine backed record folders and push them to a private records remote (`--json`) |
43
- | `aitk records pull` | Fetch the records remote and write it back, refusing rather than discarding unpushed records (`--json`) |
44
- | `aitk sessions list` | Resolve live sessions to the worktree and branch each holds, filtered by `--branch` (`--json`) |
45
- | `aitk comments scan` | Measure comment density by language and comment kind, with a trend recomputed from git |
46
- | `aitk context audit` | Report required sections, length, cited paths, reference form, catalog tables, provenance, superseded-decision narration, and index drift |
47
- | `aitk markdown audit` | Fail any markdown path on a banned character, word, or spelling, and report the structural checkpoints |
48
- | `aitk claude skills audit` | Report both skill corpora against the mechanical rules in `standards/skill.md` |
49
- | `aitk claude skills drift` | Name the shipped skill bodies rewritten between a given ref and `HEAD`, and the installed version against the newest published (`--json`) |
50
- | `aitk claude skills reach` | Report the shipped bodies citing a toolkit path no target project receives, exiting 2 on an unqualified one |
51
- | `aitk claude skills rank` | Score the shipped catalog's descriptions against a hand-authored case corpus by TF-IDF cosine similarity, reporting rank-one and top-three |
52
- | `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`) |
53
- | `aitk gov test-order` | Report where an implementation reached history ahead of the test covering it (`--json`) |
54
- | `aitk gov superseded` | Report where the tree still asserts a value a changed convention no longer produces, keyed on the value (`--json`) |
55
- | `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`) |
56
- | `aitk secrets scan` | Report credential-shaped values in the tree the package ships, keyed on issued values rather than on words (`--json`) |
57
- | `aitk deps audit` | Report published advisories against the resolved dependency set, refusing rather than reporting clean when the index is unreachable (`--json`) |
58
- | `aitk labels audit` | Report the labels a changed set earns from the pull request label map and the paths no row reaches (`--json`) |
59
- | `aitk census [path]` | Report tracked file count, a breakdown by extension, and a line total that skips whatever reads as binary (`--json`) |
60
- | `aitk audits run` | Run every audit as one set, report per check under one verdict, and compare each count to the recorded baseline (`--json`, `--record`) |
61
- | `aitk audits list` | List every audit the set runs, with the corpus each reads and whether it gates (`--json`) |
62
- | `aitk capture [source]` | Render HTML capture sources to PNG, toolkit-only and absent from an installed package |
63
- | `aitk upgrade` | Reinstall the CLI globally with the package manager the install path names (`--json`) |
12
+ | Command | Purpose |
13
+ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
14
+ | `aitk init [path]` | Bootstrap a project with selected toolkit domains |
15
+ | `aitk sync [path]` | Sync all installed domains in a target project |
16
+ | `aitk sync --check` | Report toolkit drift and the installed version against the newest published (`--json`, `--exit-code`) |
17
+ | `aitk sandbox [cat:cmd]` | Run sandbox scenarios (interactive or routed), toolkit-only like the tree it reads |
18
+ | `aitk sandbox reset` | Reset sandbox to baseline |
19
+ | `aitk sandbox clean` | Wipe the sandbox |
20
+ | `aitk sandbox check` | Score a provisioned sandbox against a scenario expectation (`--json` for the verdict) |
21
+ | `aitk sandbox coverage` | Report which scenarios declare expectations (`--json`, `--strict`, `--skills`) |
22
+ | `aitk indexes regen` | Regenerate `index.md` files from sibling frontmatter |
23
+ | `aitk docs [topic]` | Emit toolkit reference docs (`list`, or a topic by name) |
24
+ | `aitk design render` | Render `.claude/DESIGN.md` tokens to HTML and CSS |
25
+ | `aitk slides render` | Render a `.claude/SLIDES.md` source into a PowerPoint deck |
26
+ | `aitk slides list` | List the available slide layouts (`--json` for the catalog) |
27
+ | `aitk feedback` | Write toolkit feedback from stdin to `.claude/review/feedback/`, or open a GitHub issue with `--github` |
28
+ | `aitk transcripts <url>` | Fetch a YouTube transcript with metadata frontmatter (needs `yt-dlp`) |
29
+ | `aitk tasks archive` | Move a shipped task off the board, clear its ordering row, and regenerate the index |
30
+ | `aitk tasks pull-request` | Record a pull request number on the task a branch closes, by stem or `--plan` (`--json`) |
31
+ | `aitk tasks outcome` | Mark outcomes `[x]` on a task by position, repeating `--close` (`--json`) |
32
+ | `aitk tasks validate` | Report board rows whose plan, task file, group, file set, or blocker does not hold (`--json`) |
33
+ | `aitk intake list` | Report intake folder counts, or one folder's items, keeping what is unread with `--unread` (`--json`) |
34
+ | `aitk intake answer` | Write selections into one cluster's answer slots, repeating `--set <item>=<answer>` (`--json`) |
35
+ | `aitk teach list` | Report learning workspaces and the ordinal a new one takes, or what one workspace holds (`--json`) |
36
+ | `aitk teach open` | Open a workspace at the next ordinal with its mission, resources, and glossary files (`--json`) |
37
+ | `aitk teach resource` | Record sources and leads in a workspace, repeating `--read` or `--lead` as `<title>=<url>` (`--json`) |
38
+ | `aitk teach glossary` | Add terms to a workspace glossary alphabetically, repeating `--term <term>=<definition>` (`--json`) |
39
+ | `aitk records validate` | Report a session record or a standard against the standard governing it, per kind (`--json`) |
40
+ | `aitk records migrate` | Rewrite the records a validate finding names a recoverable transform for (`--write`, `--json`) |
41
+ | `aitk records size` | Report what each record folder holds and how much of it is recent, heaviest first (`--json`) |
42
+ | `aitk records push` | Commit the nine backed record folders and push them to a private records remote (`--json`) |
43
+ | `aitk records pull` | Fetch the records remote and write it back, refusing rather than discarding unpushed records (`--json`) |
44
+ | `aitk sessions list` | Resolve live sessions to the worktree and branch each holds, filtered by `--branch` (`--json`) |
45
+ | `aitk comments scan` | Measure comment density by language and comment kind, with a trend recomputed from git |
46
+ | `aitk context audit` | Report required sections, length, cited paths, reference form, catalog tables, provenance, superseded-decision narration, and index drift |
47
+ | `aitk markdown audit` | Fail any markdown path on a banned character, word, or spelling, and report the structural checkpoints |
48
+ | `aitk claude skills audit` | Report both skill corpora against the mechanical rules in `standards/skill.md` |
49
+ | `aitk claude skills drift` | Name the shipped skill bodies rewritten between a given ref and `HEAD`, and the installed version against the newest published (`--json`) |
50
+ | `aitk claude skills reach` | Report the shipped bodies citing a toolkit path no target project receives, exiting 2 on an unqualified one |
51
+ | `aitk claude skills rank` | Score the shipped catalog's descriptions against a hand-authored case corpus by TF-IDF cosine similarity, reporting rank-one and top-three |
52
+ | `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`) |
53
+ | `aitk gov test-order` | Report where an implementation reached history ahead of the test covering it (`--json`) |
54
+ | `aitk gov superseded` | Report where the tree still asserts a value a changed convention no longer produces, keyed on the value (`--json`) |
55
+ | `aitk gov restated` | Report every instruction the always-loaded file or a rule shares with the seed, a shipped skill body, or another rule, classed and with its anchors named (`--json`) |
56
+ | `aitk secrets scan` | Report credential-shaped values in the tree the package ships, keyed on issued values rather than on words (`--json`) |
57
+ | `aitk deps audit` | Report published advisories against the resolved dependency set, refusing rather than reporting clean when the index is unreachable (`--json`) |
58
+ | `aitk labels audit` | Report the labels a changed set earns from the pull request label map and the paths no row reaches (`--json`) |
59
+ | `aitk census [path]` | Report tracked file count, a breakdown by extension, and a line total that skips whatever reads as binary (`--json`) |
60
+ | `aitk audits run` | Run every audit as one set, report per check under one verdict, and compare each count to the recorded baseline (`--json`, `--record`) |
61
+ | `aitk audits list` | List every audit the set runs, with the corpus each reads and whether it gates (`--json`) |
62
+ | `aitk capture [source]` | Render HTML capture sources to PNG, toolkit-only and absent from an installed package |
63
+ | `aitk upgrade` | Reinstall the CLI globally with the package manager the install path names (`--json`) |
64
64
 
65
65
  `aitk demo` is the second browser command and the one that ships, since its purpose is running in a target rather than regenerating what this repository commits. It needs a browser binary the package does not carry, installed once with `bunx playwright install chromium`.
66
66
 
@@ -26,7 +26,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
26
26
  - [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
27
27
  - [Overview](overview.md): What this folder covers, the invocation rules every command inherits, and where domain behavior is documented instead
28
28
  - [Records](records.md): Validating the session records under .claude/ and the standards corpus, the per-kind checks, the refusal reasons, migrating a record a frontmatter change orphaned, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
29
- - [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
29
+ - [Restated instructions](restated.md): Counting the instructions the always-loaded file and every path-scoped rule share with the seed, the shipped skill bodies, and each other, how a match is decided, the three classes, which surface a later edit starts from, and why the sweep reports rather than gates
30
30
  - [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
31
31
  - [Sandbox](sandbox.md): Scenario routing, the expectation scoring surface, and the coverage census over scenarios and skills
32
32
  - [Scripting](scripting.md): The runtime catalogs that replace hardcoded names, what each carries, and a headless invocation per domain
@@ -1,11 +1,11 @@
1
1
  ---
2
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
3
+ description: Counting the instructions the always-loaded file and every path-scoped rule share with the seed, the shipped skill bodies, and each other, how a match is decided, the three classes, which surface a later edit starts from, and why the sweep reports rather than gates
4
4
  ---
5
5
 
6
6
  # Restated instructions
7
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.
8
+ `aitk gov restated` reports every instruction the always-loaded file or a path-scoped rule states that a further 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
9
 
10
10
  ```bash
11
11
  aitk gov restated
@@ -22,14 +22,17 @@ Under `--json` the record holds stdout alone and the frame still renders on stde
22
22
 
23
23
  ## The corpus it reads
24
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:
25
+ Four surfaces, and they are not read symmetrically. Every top-level bullet in `CLAUDE.md` and in every path-scoped rule is a subject, and every surface below is searched, rules included a second time:
26
26
 
27
27
  - `tooling/claude/seeds/CLAUDE.md`, read as bullets, since the seed carries the same shape as the file it is authored from
28
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
+ - `governance/rules/**/*.md`, the authoring root rather than the consumed copy under `.claude/rules/`, read as bullets the same way the seed is
30
+
31
+ A rule reads as both a subject and a candidate, unlike the seed and the shipped bodies. A stack ships a whole rule folder, so a bullet duplicated between two rules reaches a target exactly as a bullet duplicated between the always-loaded file and a rule does, and neither shape is visible from one side alone. A candidate sharing its subject's file is skipped, since two bullets inside one rule sharing anchors are adjacent instructions on one topic rather than the same rule shipped twice, and a pair found from one direction is not reported again from the other.
29
32
 
30
33
  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
34
 
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.
35
+ A rule stated in two skill bodies and never in `CLAUDE.md` or another rule is outside this reading. A body is searched but never read as a subject, and a subject is what the search runs from.
33
36
 
34
37
  ## How a match is decided
35
38
 
@@ -63,7 +66,7 @@ Each restatement names where a later edit starts.
63
66
 
64
67
  - `claude-md` for a seed match, since the always-loaded file is authored first and the seed carries it to a target
65
68
  - `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
69
+ - `unknown` everywhere else, a rule match included, which is a first-class answer rather than a gap
67
70
 
68
71
  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
72
 
@@ -73,6 +76,6 @@ Exit codes are `0` when no instruction is restated outside a declared mirror, `1
73
76
 
74
77
  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
78
 
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.
79
+ Both refusals are absences rather than breaks. A target holds none of the seed, a shipped skills tree, or a rules 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
80
 
78
81
  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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.22.0",
4
+ "version": "3.23.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -26,6 +26,7 @@ import {
26
26
  type RestatedRefusal,
27
27
  type RestatedReport,
28
28
  readRestated,
29
+ RULES_REL as RESTATED_RULES_REL,
29
30
  SEED_REL,
30
31
  SHIPPED_SKILLS_REL,
31
32
  } from '@/gov/restated'
@@ -97,7 +98,7 @@ interface CountsOptions {
97
98
  /** What a reader does about each way the sweep produced no reading. */
98
99
  const RESTATED_REFUSALS: Record<RestatedRefusal, string> = {
99
100
  'no-instructions': `No ${INSTRUCTIONS_REL} here, or it carries no bullet, so there is no instruction corpus to sweep.`,
100
- 'no-surfaces': `Neither ${SEED_REL} nor ${SHIPPED_SKILLS_REL}/ is here, so no second surface exists to match against.`,
101
+ 'no-surfaces': `None of ${SEED_REL}, ${SHIPPED_SKILLS_REL}/, or ${RESTATED_RULES_REL}/ is here, so no further surface exists to match against.`,
101
102
  }
102
103
 
103
104
  const COUNTS_REFUSALS: Record<CountsRefusal, string> = {
@@ -346,11 +347,12 @@ export function register(program: Command): void {
346
347
  'after',
347
348
  [
348
349
  '',
349
- `Matches every bullet in ${INSTRUCTIONS_REL} against ${SEED_REL}`,
350
- `and every ${SHIPPED_SKILLS_REL}/*/SKILL.md body. Matching is recall-first,`,
351
- 'keyed on distinctive tokens two statements share rather than on a phrase',
352
- 'they spell the same way, because the case this exists for was one rule',
353
- 'written three different ways.',
350
+ `Matches every bullet in ${INSTRUCTIONS_REL} and every rule under`,
351
+ `${RESTATED_RULES_REL}/ against ${SEED_REL}, every`,
352
+ `${SHIPPED_SKILLS_REL}/*/SKILL.md body, and every other rule. Matching is`,
353
+ 'recall-first, keyed on distinctive tokens two statements share rather',
354
+ 'than on a phrase they spell the same way, because the case this exists',
355
+ 'for was one rule written three different ways.',
354
356
  '',
355
357
  'What it separates:',
356
358
  ' mirror a declared authoring-to-consumed pair, where repeating is the design',
@@ -361,8 +363,8 @@ export function register(program: Command): void {
361
363
  'about meaning, so weigh each against the surfaces it names.',
362
364
  '',
363
365
  'What it does not measure:',
364
- ' a rule stated in two skill bodies and never in the always-loaded file,',
365
- ' since the bullets there are the subjects and the bodies are searched',
366
+ ' a rule stated in two skill bodies and never in the always-loaded file',
367
+ ' or a path-scoped rule, since a body is searched but never read as a subject',
366
368
  '',
367
369
  'Exit codes:',
368
370
  ' 0 no instruction is restated outside a declared mirror',
@@ -441,7 +443,7 @@ function reportRestated(
441
443
  // also says how wide the corpus behind it was.
442
444
  logStep('Corpus')
443
445
  logInfo(
444
- `${report.corpus.instructions} instruction(s) against ${report.corpus.candidates} statement(s) from the seed and ${report.corpus.bodies} shipped body/bodies in ${root}`,
446
+ `${report.corpus.instructions} always-loaded instruction(s) and ${report.corpus.rules} rule file(s) against ${report.corpus.candidates} statement(s) from the seed, ${report.corpus.bodies} shipped body/bodies, and every rule again, in ${root}`,
445
447
  )
446
448
  logInfo(
447
449
  `matched on ${report.matcher.anchors} weighted anchor(s), dropping any token in more than ${report.matcher.common} statements`,
@@ -10,6 +10,17 @@ export const SEED_REL = join('tooling', 'claude', 'seeds', 'CLAUDE.md')
10
10
  /** The shipped plugin bodies, which is where a rule restated in prose lands. */
11
11
  export const SHIPPED_SKILLS_REL = join('claude', 'skills')
12
12
 
13
+ /**
14
+ * Path-scoped rules, read from the authoring root rather than the consumed
15
+ * copy under `.claude/rules/`. A stack takes a whole rule folder, so a bullet
16
+ * landing anywhere under here ships to every target the stack reaches, the
17
+ * same way a bullet in the always-loaded file or the seed does. That is what
18
+ * makes a rule an instruction surface rather than a place a rule is merely
19
+ * quoted, and it is why a bullet moved here from the seed still belongs to
20
+ * the corpus this sweep reads rather than leaving it.
21
+ */
22
+ export const RULES_REL = join('governance', 'rules')
23
+
13
24
  /**
14
25
  * Path pairs whose duplication is deliberate and already recorded.
15
26
  *
@@ -168,7 +179,7 @@ export type RestatedRefusal = 'no-instructions' | 'no-surfaces'
168
179
  export type Restatement = 'mirror' | 'repetition' | 'contradiction'
169
180
 
170
181
  /** Which surface a restatement was found on, before any class is assigned. */
171
- export type SurfaceKind = 'seed' | 'skill'
182
+ export type SurfaceKind = 'seed' | 'skill' | 'rule'
172
183
 
173
184
  /**
174
185
  * Which surface a later edit starts from.
@@ -209,7 +220,7 @@ export interface RestatedCounts {
209
220
  readonly contradictions: number
210
221
  readonly repetitions: number
211
222
  readonly mirrors: number
212
- /** Subjects carried by two further surfaces, which is the title's count. */
223
+ /** Duplicate clusters reaching three statements or more, the title's count. */
213
224
  readonly threeSurface: number
214
225
  }
215
226
 
@@ -220,7 +231,9 @@ export type RestatedReport =
220
231
  readonly instructions: number
221
232
  readonly seed: number
222
233
  readonly bodies: number
223
- /** Statements the two further surfaces offered, which bounds recall. */
234
+ /** Distinct rule files a bullet was read from, counted like `bodies`. */
235
+ readonly rules: number
236
+ /** Statements the three further surfaces offered, which bounds recall. */
224
237
  readonly candidates: number
225
238
  }
226
239
  readonly matcher: {
@@ -368,6 +381,53 @@ function isMirrorPair(subject: string, surface: string): boolean {
368
381
  )
369
382
  }
370
383
 
384
+ function find(parent: Map<string, string>, node: string): string {
385
+ const above = parent.get(node)
386
+ if (above === undefined || above === node) return node
387
+ const root = find(parent, above)
388
+ parent.set(node, root)
389
+ return root
390
+ }
391
+
392
+ function union(parent: Map<string, string>, a: string, b: string): void {
393
+ if (!parent.has(a)) parent.set(a, a)
394
+ if (!parent.has(b)) parent.set(b, b)
395
+ const rootA = find(parent, a)
396
+ const rootB = find(parent, b)
397
+ if (rootA !== rootB) parent.set(rootA, rootB)
398
+ }
399
+
400
+ /**
401
+ * How many duplicate clusters reach three member statements or more.
402
+ *
403
+ * A rule sitting on both sides lets a cluster of mutually restating
404
+ * statements fragment across several `RestatedEntry` records, one per member
405
+ * that happens to lead the subject loop, so counting an entry whose own
406
+ * `surfaces.length >= 2` counts the same cluster more than once. Union over
407
+ * every reported pair instead: two statements in one component are one
408
+ * duplicated instruction wherever it was found from, and the entries a
409
+ * reader sees are one view into that same union, never a second source for
410
+ * its total.
411
+ */
412
+ function countThreeSurfaceClusters(pairs: ReadonlySet<string>): number {
413
+ const parent = new Map<string, string>()
414
+
415
+ for (const key of pairs) {
416
+ const [left, right] = key.split('|')
417
+ union(parent, left, right)
418
+ }
419
+
420
+ const sizes = new Map<string, number>()
421
+ for (const node of parent.keys()) {
422
+ const root = find(parent, node)
423
+ sizes.set(root, (sizes.get(root) ?? 0) + 1)
424
+ }
425
+
426
+ let clusters = 0
427
+ for (const size of sizes.values()) if (size >= 3) clusters += 1
428
+ return clusters
429
+ }
430
+
371
431
  /**
372
432
  * Bullets at the top level of a markdown file, which is the unit an instruction
373
433
  * takes in the always-loaded file and in the seed.
@@ -398,6 +458,26 @@ function readBullets(root: string, relative: string): Statement[] {
398
458
  return statements
399
459
  }
400
460
 
461
+ /**
462
+ * Every top-level bullet across every rule file, keyed to the file it came
463
+ * from rather than folded into one corpus. A rule reads as bullets under the
464
+ * same convention the always-loaded file and the seed use, so `readBullets`
465
+ * is reused per file rather than rebuilt for prose, and frontmatter is read
466
+ * past for the same reason it is: no line there starts with `- `.
467
+ */
468
+ function readRuleFiles(root: string, rulesRoot: string): Statement[] {
469
+ const files = [
470
+ ...new Bun.Glob('**/*.md').scanSync({ cwd: rulesRoot, onlyFiles: true }),
471
+ ].sort()
472
+
473
+ return files.flatMap((file) =>
474
+ readBullets(
475
+ root,
476
+ `${RULES_REL.replaceAll('\\', '/')}/${file.replaceAll('\\', '/')}`,
477
+ ),
478
+ )
479
+ }
480
+
401
481
  /**
402
482
  * Every prose line and bullet in a shipped body.
403
483
  *
@@ -550,7 +630,8 @@ function classify(
550
630
  }
551
631
 
552
632
  /**
553
- * Every instruction in the always-loaded file that a second surface also states.
633
+ * Every instruction the always-loaded file or a path-scoped rule states that a
634
+ * further surface also states.
554
635
  *
555
636
  * Matching is recall-first, keyed on distinctive tokens two statements share
556
637
  * rather than on a phrase they spell the same way. The motivating case was one
@@ -558,6 +639,13 @@ function classify(
558
639
  * defect the sweep exists for, and a recall-first reading can be narrowed from
559
640
  * real output where the reverse cannot.
560
641
  *
642
+ * The always-loaded file and every rule each open the search as a subject, and
643
+ * the seed, the shipped bodies, and every rule again close it as a candidate.
644
+ * A rule sits on both sides because a stack ships a whole rule folder, so a
645
+ * bullet duplicated between two rules reaches a target exactly as a bullet
646
+ * duplicated between the always-loaded file and a rule does, and neither shape
647
+ * is visible from one side alone.
648
+ *
561
649
  * It reports and never gates. A restatement is legitimate more often than not,
562
650
  * so a push failing on one would fail on the ordinary case.
563
651
  */
@@ -574,14 +662,31 @@ export function readRestated(root: string): RestatedReport {
574
662
 
575
663
  const skillsRoot = join(root, SHIPPED_SKILLS_REL)
576
664
  const bodies = existsSync(skillsRoot) ? readBodyLines(root, skillsRoot) : []
577
-
578
665
  const bodyFiles = new Set(bodies.map((candidate) => candidate.file)).size
579
- if (seed.length === 0 && bodies.length === 0) {
666
+
667
+ const rulesRoot = join(root, RULES_REL)
668
+ const ruleStatements = existsSync(rulesRoot)
669
+ ? readRuleFiles(root, rulesRoot)
670
+ : []
671
+ const rules: Candidate[] = ruleStatements.map((statement) => ({
672
+ ...statement,
673
+ kind: 'rule' as const,
674
+ }))
675
+ const ruleFiles = new Set(ruleStatements.map((statement) => statement.file))
676
+ .size
677
+
678
+ if (seed.length === 0 && bodies.length === 0 && rules.length === 0) {
580
679
  return { kind: 'unreadable', reason: 'no-surfaces' }
581
680
  }
582
681
 
583
- const subjects = instructions.map(index)
584
- const candidates = [...seed, ...bodies].map(index)
682
+ // A rule states a directive directly, the same as the always-loaded file, so
683
+ // it joins the subjects a match is searched from rather than sitting only on
684
+ // the candidate side. Without this, an instruction that moved out of the
685
+ // always-loaded file and into two rules has no subject left carrying it, and
686
+ // the pair the seed move produced would stay invisible to this sweep the
687
+ // same way it did before this corpus widened.
688
+ const subjects = [...instructions, ...rules].map(index)
689
+ const candidates = [...seed, ...bodies, ...rules].map(index)
585
690
  const frequency = documentFrequency([subjects, candidates])
586
691
 
587
692
  const distinctive = (analysis: Analysis): Set<string> =>
@@ -604,13 +709,37 @@ export function readRestated(root: string): RestatedReport {
604
709
  let contradictions = 0
605
710
  let repetitions = 0
606
711
  let mirrors = 0
607
- let threeSurface = 0
712
+
713
+ // A rule bullet sits on both sides now, so the pair it forms with another
714
+ // rule bullet would otherwise surface twice: once with each end read as the
715
+ // subject. Recording the pair the first time it is found and skipping it
716
+ // the second keeps one entry per duplicate regardless of which side a
717
+ // reader lands on, the way a seed-versus-body pair never could collide,
718
+ // since only a rule occupies both roles. The same skip is what fragments a
719
+ // cluster of mutually restating rules across several entries, which is why
720
+ // `countThreeSurfaceClusters` reads this set again below rather than the
721
+ // per-entry `surfaces.length` the loop produces.
722
+ const reportedPairs = new Set<string>()
723
+ const pairKey = (a: Statement, b: Statement): string => {
724
+ const left = `${a.file}:${a.line}`
725
+ const right = `${b.file}:${b.line}`
726
+ return left < right ? `${left}|${right}` : `${right}|${left}`
727
+ }
608
728
 
609
729
  for (const subject of subjects) {
610
730
  const rare = distinctive(subject.analysis)
611
731
  const surfaces: Surface[] = []
612
732
 
613
733
  for (const candidate of rareCandidates) {
734
+ // A second surface is a second file. Two bullets sharing anchors inside
735
+ // one rule are adjacent instructions on one topic rather than the same
736
+ // rule shipped twice, and only a rule can reach this branch at all,
737
+ // since it is the one kind read as both a subject and a candidate.
738
+ if (candidate.statement.file === subject.statement.file) continue
739
+
740
+ const key = pairKey(subject.statement, candidate.statement)
741
+ if (reportedPairs.has(key)) continue
742
+
614
743
  const shared = [...candidate.rare]
615
744
  .filter((token) => rare.has(token))
616
745
  .sort()
@@ -626,6 +755,7 @@ export function readRestated(root: string): RestatedReport {
626
755
  )
627
756
 
628
757
  if (weight < ANCHOR_FLOOR) continue
758
+ reportedPairs.add(key)
629
759
 
630
760
  const { restatement, reason } = classify(
631
761
  subject.statement,
@@ -659,22 +789,18 @@ export function readRestated(root: string): RestatedReport {
659
789
  else repetitions += 1
660
790
  }
661
791
 
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
792
  restatements.push({ subject: subject.statement, surfaces })
670
793
  }
671
794
 
795
+ const threeSurface = countThreeSurfaceClusters(reportedPairs)
796
+
672
797
  return {
673
798
  kind: 'measured',
674
799
  corpus: {
675
800
  instructions: instructions.length,
676
801
  seed: seed.length,
677
802
  bodies: bodyFiles,
803
+ rules: ruleFiles,
678
804
  candidates: candidates.length,
679
805
  },
680
806
  matcher: {