@erclx/aitk 3.39.0 → 3.40.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.39.0",
4
+ "version": "3.40.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -89,7 +89,7 @@ Behavior paths carry two spellings, the one a surface authors at and the one it
89
89
  - `claude/skills/` and `.claude/skills/`
90
90
  - `governance/rules/` and `.claude/rules/`
91
91
  - `standards/`, which is the authoring root and reaches a reader by resolution rather than by an install, so it carries no `.claude/` spelling
92
- - `snippets/` and `.claude/snippets/`
92
+ - `snippets/`, which reaches a session through the `claude/snippets` symlink rather than an install, so it carries no `.claude/` spelling either
93
93
  - `internal/` and `tooling/`, which hold the stack references and the seed documents a target is handed
94
94
  - `CLAUDE.md` at the repository root, named as a file because a path prefix reaches nothing that sits in no folder
95
95
 
@@ -49,8 +49,8 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
49
49
  | `aitk claude skills audit` | Report both skill corpora against the mechanical rules in `standards/skill.md` |
50
50
  | `aitk standards audit` | Report the corpus against the `## Success criterion` gate, failing only on a standard new to the branch (`--json`, `--arrivals-only`) |
51
51
  | `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`) |
52
- | `aitk claude skills reach` | Report the shipped bodies citing a toolkit path no target project receives, exiting 2 on an unqualified one |
53
- | `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 skills reach` | Report the bodies in either skill corpus citing a toolkit path no target project receives, exiting 2 on an unqualified one |
53
+ | `aitk claude skills rank` | Score either skill corpus's descriptions against a case corpus by TF-IDF cosine similarity, reporting rank-one and top-three (`--cases <path>`) |
54
54
  | `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`) |
55
55
  | `aitk gov test-order` | Report where an implementation reached history ahead of the test covering it (`--json`) |
56
56
  | `aitk gov superseded` | Report where the tree still asserts a value a changed convention no longer produces, keyed on the value (`--json`) |
@@ -32,7 +32,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
32
32
  - [Scripting](scripting.md): The runtime catalogs that replace hardcoded names, what each carries, and a headless invocation per domain
33
33
  - [Sessions](sessions.md): Resolving live peer sessions to the worktree and branch each holds, the liveness confidence field, the unresolved reasons, and what the read depends on
34
34
  - [Skill audit](skills-audit.md): Measuring both skill corpora against standards/skill.md, the checks it reads, the requirement gate that is the only failing one, and the drift verb that names bodies rewritten since a ref
35
- - [Citation reach](skills-reach.md): Reporting the shipped skill bodies that cite a path no target project receives, the ownership key that decides what counts, the one-word qualifier that marks a citation as decided, and why the verb reports instead of gating
35
+ - [Citation reach](skills-reach.md): Reporting the skill bodies that cite a path no target project receives, which corpus the verb reads, the ownership key that decides what counts, the one-word qualifier that marks a citation as decided, and why the verb reports instead of gating
36
36
  - [Standard success criteria](standards-audit.md): Reading the corpus against the Success criterion gate, why the check scopes to arrival rather than the whole corpus, and the exit codes it sets
37
37
  - [State-scoped risk](state-scoped-risk.md): Reading committed state rather than an arriving change, the shipped-tree corpus the secret scan reads, what it keys on and how a deliberate value is exempted, the advisory check and its network failure mode, and why one gates while the other reports
38
38
  - [Superseded values](superseded.md): Reading where the tree still asserts a value a changed convention no longer produces, why the sweep keys on the value rather than the file, the exemption marker, the blind spot it cannot reach, and why it reports rather than gates
@@ -1,21 +1,26 @@
1
1
  ---
2
2
  title: Citation reach
3
- description: Reporting the shipped skill bodies that cite a path no target project receives, the ownership key that decides what counts, the one-word qualifier that marks a citation as decided, and why the verb reports instead of gating
3
+ description: Reporting the skill bodies that cite a path no target project receives, which corpus the verb reads, the ownership key that decides what counts, the one-word qualifier that marks a citation as decided, and why the verb reports instead of gating
4
4
  ---
5
5
 
6
6
  # Citation reach
7
7
 
8
- `aitk claude skills reach [path]` reports every shipped skill body citing a path that exists in the toolkit and reaches no target project. It reads and reports. Repairing what it finds is separate work.
8
+ `aitk claude skills reach [path]` reports every skill body citing a path that exists in the project and reaches no reader elsewhere. It reads and reports. Repairing what it finds is separate work.
9
9
 
10
10
  ```bash
11
11
  aitk claude skills reach
12
12
  aitk claude skills reach --json
13
+ aitk claude skills reach ~/repos/my-project
13
14
  ```
14
15
 
15
16
  | Option | Behavior |
16
17
  | -------- | ---------------------------------------------------------- |
17
18
  | `--json` | Add a machine-readable record on stdout, keeping the frame |
18
19
 
20
+ ## Which corpus it reads
21
+
22
+ `claude/skills/` when the project holds it, and `.claude/skills/` otherwise. The shipped tree wins where both are present, so a toolkit reads what it ships and a project holding only its own skills is measured rather than refused. Every report names the corpus it read on its `Corpus` line and carries it as `corpus` in the `--json` record.
23
+
19
24
  ## The defect it reads for
20
25
 
21
26
  A plugin skill installs into a project and the toolkit's own tree is not there. A body naming `.claude/context/transcripts.md` resolves for a session running in this repository and sends every other reader to nothing, and no stage asked the question until this one. The shape is wider than one folder: a seed naming a standard with no route and a machine-readable field naming a toolkit-only path are the same claim, true here and false in a target.
@@ -38,6 +43,8 @@ A cited path counts when it sits under an authoring root no install channel deli
38
43
 
39
44
  `src/`, `scripts/`, and bare `docs/` are deliberately outside the list. A body naming one of those is describing the reader's own tree, so listing them reports a correct citation on every run and buries the finding under the pass.
40
45
 
46
+ `.claude/context/` joins them when the corpus read is a project's own. A seed put those entries there and the project owns them afterward, so a body under `.claude/skills/` naming one points at a file its reader holds. The seed disowning below cannot answer that in a project, since it reads a `tooling/` tree only the toolkit carries.
47
+
41
48
  A path a seed installs is disowned twice, under its own name and under the folder spelling it takes once a project splits the entry. A domain that outgrows one file becomes `<domain>/`, which is still the entry the seed delivered, so reporting the split form would fail a project for growing.
42
49
 
43
50
  ## The qualifier
@@ -52,6 +59,6 @@ A line mentioning the toolkit for an unrelated reason exempts a citation on it.
52
59
 
53
60
  ## Exit codes
54
61
 
55
- Exit codes are `0` when every citation names its owner, `1` for a refusal, and `2` when at least one is unqualified. The refusal is a tree carrying no `claude/skills/`, which ships no plugin body to measure, and it reports the reason rather than a clean count over nothing.
62
+ Exit codes are `0` when every citation names its owner, `1` for a refusal, and `2` when at least one is unqualified. The refusal is a tree carrying neither `claude/skills/` nor `.claude/skills/`, which holds no skill body to measure, and it reports the reason rather than a clean count over nothing.
56
63
 
57
64
  The verb reports rather than gates. A toolkit-scoped instruction is sometimes meant for a session in this repository, so failing a push on one would make the check something to route around. `aitk audits run` registers it with no gating exit for the same reason, and carries `unqualifiedCitations` as its retained count.
@@ -138,7 +138,7 @@ For features on a mature stack, chain the post-plan pipeline in one session. App
138
138
 
139
139
  Review findings split by origin before severity is read. One the branch inherited stops the chain, and one the run itself caused is repaired in place at any severity, bounded at a single pass. Origin is causation rather than authorship, so staleness the run induced in a file it never opened counts as its own and the plan's file list bounds what it builds rather than what it may repair.
140
140
 
141
- Review is skipped when the diff is prose that only informs: every changed file matches `*.md` or `*.txt`, and none sits under a behavior path. Behavior paths cover skills, rules, standards, snippets, and `tooling/` in both the authoring and the installed spelling, plus root `CLAUDE.md`, so the list matches whether a repository authors those surfaces or consumed them from the toolkit.
141
+ Review is skipped when the diff is prose that only informs: every changed file matches `*.md` or `*.txt`, and none sits under a behavior path. Behavior paths cover skills and rules in both the authoring and the installed spelling, so the list matches whether a repository authors those surfaces or consumed them from the toolkit. Standards, snippets, `internal/`, and `tooling/` carry the authoring spelling alone, since none of the four reaches a session through a `.claude/` copy, and root `CLAUDE.md` is named as a file because a path prefix reaches nothing sitting in no folder.
142
142
 
143
143
  Markdown under one states what an agent does, so a branch touching it reaches review while `docs/` and `wiki/` still skip and stay gated by `docs-sync`, `claude-standards-audit`, and pre-push hooks.
144
144
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.39.0",
4
+ "version": "3.40.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -5,30 +5,6 @@ set -o pipefail
5
5
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
6
  PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
7
7
 
8
- mirror_dir() {
9
- local src="$1"
10
- local dest="$2"
11
- local find_args=("${@:3}")
12
-
13
- rm -rf "$dest"
14
- while IFS= read -r file; do
15
- local rel="${file#"$src"/}"
16
- mkdir -p "$dest/$(dirname "$rel")"
17
- cp "$file" "$dest/$rel"
18
- done < <(find "$src" -type f "${find_args[@]}" | sort)
19
- }
20
-
21
- mirror_dir "$PROJECT_ROOT/snippets" "$PROJECT_ROOT/.claude/snippets" -name "*.md"
22
-
23
- # `internal/` is the surface the plugin does not symlink. Mirrored on its own so
24
- # toolkit sessions read it at a `.claude/` path like every other consumed copy.
25
- # `internal/rules/` is excluded because it lands in `.claude/rules/` below, and
26
- # mirroring it here too would publish each rule at a second inert path. The
27
- # exclusion is anchored to that one folder, since an unanchored `*/rules/*` would
28
- # also drop a later `internal/standards/rules/` and report nothing for it.
29
- mirror_dir "$PROJECT_ROOT/internal" "$PROJECT_ROOT/.claude/internal" -name "*.md" -not -path "$PROJECT_ROOT/internal/rules/*"
30
-
31
8
  # `.claude/rules/` is a subset rather than a mirror, so it resolves through the
32
- # stack machinery instead of a fourth `mirror_dir` call. The record naming the
33
- # subset is `internal/governance.toml`.
9
+ # stack machinery. The record naming the subset is `internal/governance.toml`.
34
10
  bun "$PROJECT_ROOT/src/cli.ts" gov regen --root "$PROJECT_ROOT"
@@ -261,7 +261,7 @@ main() {
261
261
 
262
262
  log_step "Consumed copies"
263
263
  run_check "bash $PROJECT_ROOT/scripts/core/regen-claude-copies.sh" "Consumed-copy regen failed"
264
- assert_no_drift ".claude/snippets .claude/internal .claude/rules" "Consumed copies drifted. Run bun run check and commit .claude/snippets, .claude/internal, and .claude/rules."
264
+ assert_no_drift ".claude/rules" "Consumed copies drifted. Run bun run check and commit .claude/rules."
265
265
  log_info "Consumed copies clean"
266
266
 
267
267
  # Only the HTML is asserted. The PNG beside it is a chromium render whose bytes
@@ -530,10 +530,11 @@ export const AUDITS: readonly AuditSpec[] = [
530
530
  // The one reason this verb refuses for, and it is an absence rather than a
531
531
  // break. A tracked corpus normally allows nothing, since a tree that ships
532
532
  // to targets and cannot be found is a broken checkout, and this is the
533
- // second exception on the same test the secret scan takes: no target holds
534
- // `claude/skills/`, so without the allowance every project installing this
535
- // CLI reports the verb unmeasured on every run and never changes, which is
536
- // the permanent signal the per-machine allowance exists against.
533
+ // second exception on the same test the secret scan takes: a project
534
+ // carrying neither `claude/skills/` nor `.claude/skills/` has adopted no
535
+ // skill convention, so without the allowance it reports the verb
536
+ // unmeasured on every run and never changes, which is the permanent signal
537
+ // the per-machine allowance exists against.
537
538
  absentReasons: ['no-skills'] satisfies ReachRefusal[],
538
539
  counts: reachCounts,
539
540
  },
@@ -547,9 +548,11 @@ export const AUDITS: readonly AuditSpec[] = [
547
548
  // moved rank would teach a contributor to route around the stage.
548
549
  gatingExits: [],
549
550
  corpus: 'tracked',
550
- // The one reason this verb refuses for, and it is an absence for the same
551
- // reason the reach check's is: a project carrying no `claude/skills/` has
552
- // adopted no skill convention this audit reads.
551
+ // The one reason this run refuses for, and it is an absence for the same
552
+ // reason the reach check's is: a project carrying neither `claude/skills/`
553
+ // nor `.claude/skills/` has adopted no skill convention this audit reads.
554
+ // The two case-corpus refusals never reach here, since they need `--cases`
555
+ // and this argv passes none.
553
556
  absentReasons: ['no-skills'] satisfies RankRefusal[],
554
557
  counts: rankCounts,
555
558
  },
@@ -43,6 +43,12 @@ export interface SkillFinding {
43
43
  }
44
44
 
45
45
  export interface CorpusReport {
46
+ /**
47
+ * Left as `join` produced it, where `SkillsCorpus.rel` in `skills-list.ts`
48
+ * normalizes the same spelling to POSIX. This one is an existing JSON field
49
+ * a caller already reads, so the split holds until a branch reading this verb
50
+ * is the one to close it.
51
+ */
46
52
  readonly rel: string
47
53
  readonly skills: number
48
54
  }
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
+ import { CORPORA } from '@/claude/skills-audit'
3
4
 
4
5
  const FRONTMATTER = /^---\n([\s\S]*?)\n---/
5
6
 
@@ -9,6 +10,38 @@ export interface SkillListing {
9
10
  readonly requirement: boolean
10
11
  }
11
12
 
13
+ export interface SkillsCorpus {
14
+ /**
15
+ * The corpus spelling in POSIX form, so a report reads the same on Windows.
16
+ * `CorpusReport.rel` in `skills-audit.ts` is the same spelling left as `join`
17
+ * produced it, so the two verbs disagree there. Normalizing it is a contract
18
+ * change on an existing JSON field and belongs to a branch reading the audit.
19
+ */
20
+ readonly rel: string
21
+ /** The folder a listing reads, absolute against the root it was resolved at. */
22
+ readonly dir: string
23
+ }
24
+
25
+ /**
26
+ * The skill corpus a measure reads at a given root: the shipped tree in this
27
+ * repository and a target's own `.claude/skills/` in a project that consumes
28
+ * it. `CORPORA` order settles a tree carrying both, so every reading taken
29
+ * here still comes from `claude/skills/`.
30
+ *
31
+ * Kept apart from `listSkills` deliberately. `src/counts/catalogs.ts` counts
32
+ * the shipped catalog through that function, so teaching it to read both
33
+ * corpora would move the reported total off the tree that installs and
34
+ * falsify every sentence in the corpus stating it.
35
+ */
36
+ export function resolveSkillsCorpus(root: string): SkillsCorpus | undefined {
37
+ for (const rel of CORPORA) {
38
+ const dir = join(root, rel)
39
+ if (existsSync(dir)) return { rel: rel.replaceAll('\\', '/'), dir }
40
+ }
41
+
42
+ return undefined
43
+ }
44
+
12
45
  /**
13
46
  * Enumerates the plugin skill catalog, which is the corpus under `claude/`
14
47
  * rather than the internal skills under `.claude/`. Only the former installs
@@ -22,7 +55,14 @@ export interface SkillListing {
22
55
  * `aitk claude skills audit` is what fails on it, across both corpora.
23
56
  */
24
57
  export function listSkills(root: string): SkillListing[] {
25
- const skillsRoot = join(root, 'claude', 'skills')
58
+ return listSkillsAt(join(root, 'claude', 'skills'))
59
+ }
60
+
61
+ /**
62
+ * The same enumeration against a corpus folder the caller already resolved,
63
+ * which is what `resolveSkillsCorpus` hands a measure that reaches a target.
64
+ */
65
+ export function listSkillsAt(skillsRoot: string): SkillListing[] {
26
66
  if (!existsSync(skillsRoot)) return []
27
67
 
28
68
  const paths = [
@@ -1,6 +1,5 @@
1
- import { existsSync } from 'node:fs'
2
- import { join } from 'node:path'
3
- import { listSkills } from '@/claude/skills-list'
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { listSkillsAt, resolveSkillsCorpus } from '@/claude/skills-list'
4
3
 
5
4
  /**
6
5
  * Whether a prompt reaches the right skill, measured by TF-IDF cosine
@@ -14,8 +13,6 @@ import { listSkills } from '@/claude/skills-list'
14
13
  * tracks on a cadence.
15
14
  */
16
15
 
17
- const SKILLS_DIR = join('claude', 'skills')
18
-
19
16
  const STOP = new Set(
20
17
  'a about after again all also and any are as at be before but by can do does for from has have help how in into is it its just make not of on or our so that the then there these this to use used uses using want was what when where which who why with you your run'.split(
21
18
  ' ',
@@ -44,11 +41,25 @@ export interface Miss {
44
41
  }
45
42
 
46
43
  /** Why a measure produced no reading, which is never the same as a clean one. */
47
- export type RankRefusal = 'no-skills'
44
+ export type RankRefusal = 'no-skills' | 'no-cases' | 'bad-cases'
45
+
46
+ /** The refusals a case corpus read produces, which the scan itself cannot raise. */
47
+ export type CaseCorpusRefusal = Extract<RankRefusal, 'no-cases' | 'bad-cases'>
48
+
49
+ export type CaseCorpusReport =
50
+ | { readonly kind: 'cases'; readonly cases: readonly SkillCase[] }
51
+ | {
52
+ readonly kind: 'refused'
53
+ readonly reason: CaseCorpusRefusal
54
+ /** What the caller has to change, which the reason alone never says. */
55
+ readonly detail: string
56
+ }
48
57
 
49
58
  export type RankReport =
50
59
  | {
51
60
  readonly kind: 'measured'
61
+ /** The corpus spelling measured, since a root can carry either one. */
62
+ readonly corpus: string
52
63
  readonly skills: number
53
64
  readonly cases: number
54
65
  readonly rank1: number
@@ -59,6 +70,67 @@ export type RankReport =
59
70
  }
60
71
  | { readonly kind: 'refused'; readonly reason: RankRefusal }
61
72
 
73
+ /**
74
+ * Reads a project's own case corpus, which is JSON in the shape `SKILL_CASES`
75
+ * already holds. A target authors its own skills and its own vocabulary, so
76
+ * the toolkit corpus answers a question no other project asked.
77
+ *
78
+ * No standard stands behind the shape until a third project needs one, so
79
+ * every way the file fails is reported with what to change rather than
80
+ * measured against a spec. An empty array refuses for the reason a missing
81
+ * file does: a corpus of nothing scores 0 of 0 and reads as a clean pass.
82
+ */
83
+ export function loadCaseCorpus(path: string): CaseCorpusReport {
84
+ if (!existsSync(path)) {
85
+ return { kind: 'refused', reason: 'no-cases', detail: path }
86
+ }
87
+
88
+ let parsed: unknown
89
+ try {
90
+ parsed = JSON.parse(readFileSync(path, 'utf8'))
91
+ } catch (error) {
92
+ const detail = error instanceof Error ? error.message : String(error)
93
+ return { kind: 'refused', reason: 'bad-cases', detail }
94
+ }
95
+
96
+ if (!Array.isArray(parsed)) {
97
+ return {
98
+ kind: 'refused',
99
+ reason: 'bad-cases',
100
+ detail: 'the file holds something other than an array of cases',
101
+ }
102
+ }
103
+
104
+ const cases: SkillCase[] = []
105
+ for (const [index, entry] of parsed.entries()) {
106
+ const record = entry as Record<string, unknown> | null
107
+ if (
108
+ typeof record !== 'object' ||
109
+ record === null ||
110
+ typeof record.prompt !== 'string' ||
111
+ typeof record.expect !== 'string'
112
+ ) {
113
+ return {
114
+ kind: 'refused',
115
+ reason: 'bad-cases',
116
+ detail: `case ${index} carries no string prompt or expect`,
117
+ }
118
+ }
119
+
120
+ cases.push({ prompt: record.prompt, expect: record.expect })
121
+ }
122
+
123
+ if (cases.length === 0) {
124
+ return {
125
+ kind: 'refused',
126
+ reason: 'bad-cases',
127
+ detail: 'the file holds no cases at all',
128
+ }
129
+ }
130
+
131
+ return { kind: 'cases', cases }
132
+ }
133
+
62
134
  /**
63
135
  * Every shipped skill's frontmatter description, read the way a prompt is
64
136
  * matched against it: whole, including the quoted trigger phrases it states.
@@ -66,7 +138,13 @@ export type RankReport =
66
138
  * and never wins a rank, so it is dropped rather than scored on nothing.
67
139
  */
68
140
  export function loadCatalog(root: string): RankedSkill[] {
69
- return listSkills(root)
141
+ const corpus = resolveSkillsCorpus(root)
142
+ return corpus === undefined ? [] : loadCatalogAt(corpus.dir)
143
+ }
144
+
145
+ /** The same read against a corpus folder the caller already resolved. */
146
+ export function loadCatalogAt(skillsRoot: string): RankedSkill[] {
147
+ return listSkillsAt(skillsRoot)
70
148
  .filter((skill) => skill.description !== '')
71
149
  .map((skill) => ({ name: skill.name, description: skill.description }))
72
150
  }
@@ -214,22 +292,24 @@ export function measureCases(
214
292
  }
215
293
 
216
294
  /**
217
- * Reads the shipped catalog off disk and scores it against the given case
218
- * corpus. Measures the cwd's catalog rather than the toolkit root, matching
219
- * the reach and audit verbs, so a linked worktree reads its own branch.
295
+ * Reads whichever skill corpus the root carries and scores it against the
296
+ * given cases. Measures the cwd's catalog rather than the toolkit root,
297
+ * matching the reach and audit verbs, so a linked worktree reads its own
298
+ * branch and a target reads the skills it wrote itself.
220
299
  */
221
300
  export function scanRank(
222
301
  root: string,
223
302
  cases: readonly SkillCase[],
224
303
  ): RankReport {
225
- const skillsRoot = join(root, SKILLS_DIR)
226
- if (!existsSync(skillsRoot)) return { kind: 'refused', reason: 'no-skills' }
304
+ const corpus = resolveSkillsCorpus(root)
305
+ if (corpus === undefined) return { kind: 'refused', reason: 'no-skills' }
227
306
 
228
- const catalog = loadCatalog(root)
307
+ const catalog = loadCatalogAt(corpus.dir)
229
308
  const { rank1, top3, misses, unmeasurable } = measureCases(catalog, cases)
230
309
 
231
310
  return {
232
311
  kind: 'measured',
312
+ corpus: corpus.rel,
233
313
  skills: catalog.length,
234
314
  cases: cases.length,
235
315
  rank1,
@@ -1,12 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
-
4
- /**
5
- * The tree that installs into a target. The internal skills under `.claude/`
6
- * never leave this repository, so a citation there is read by a session that
7
- * already has the file and cannot be a reach defect.
8
- */
9
- const SHIPPED_SKILLS = join('claude', 'skills')
3
+ import { resolveSkillsCorpus } from '@/claude/skills-list'
10
4
 
11
5
  /**
12
6
  * The authoring roots this repository owns and no install channel delivers.
@@ -71,6 +65,8 @@ export type ReachRefusal = 'no-skills'
71
65
  export type ReachReport =
72
66
  | {
73
67
  readonly kind: 'measured'
68
+ /** The corpus spelling read, since a root can carry either one. */
69
+ readonly corpus: string
74
70
  /** Files opened, so a report can state what the verdict covers. */
75
71
  readonly bodies: number
76
72
  readonly qualified: readonly Citation[]
@@ -82,6 +78,25 @@ export function isQualified(line: string): boolean {
82
78
  return QUALIFIER.test(line)
83
79
  }
84
80
 
81
+ /**
82
+ * The roots that belong to the toolkit rather than to the reader, read
83
+ * against the corpus being measured.
84
+ *
85
+ * A target's `.claude/context/` is the reader's own tree. A seed put the
86
+ * entries there and the project owns them afterward, so a body under
87
+ * `.claude/skills/` citing one names a file its reader holds. Measuring it
88
+ * would report a correct citation on every run, which is exactly why `src/`
89
+ * and `scripts/` are absent from the list above. In this repository the seed
90
+ * tree settles the same question through `readReceivedPaths`, which a target
91
+ * carrying no `tooling/` folder cannot answer at all, so the root comes out
92
+ * by corpus instead.
93
+ */
94
+ export function authoringRootsFor(corpus: string): readonly string[] {
95
+ if (!corpus.startsWith('.claude/')) return AUTHORING_ROOTS
96
+
97
+ return AUTHORING_ROOTS.filter((root) => !root.startsWith('.claude/'))
98
+ }
99
+
85
100
  /**
86
101
  * Every path a seed lands on in a target, spelled the way a body would cite it.
87
102
  *
@@ -113,7 +128,11 @@ export function readReceivedPaths(root: string): Set<string> {
113
128
  * outgrows one file becomes `<domain>/`, which is still the entry the seed
114
129
  * delivered, so reporting the split form would fail a target for growing.
115
130
  */
116
- export function isToolkitOwned(path: string, received: Set<string>): boolean {
131
+ export function isToolkitOwned(
132
+ path: string,
133
+ received: Set<string>,
134
+ roots: readonly string[] = AUTHORING_ROOTS,
135
+ ): boolean {
117
136
  if (received.has(path)) return false
118
137
 
119
138
  for (const seeded of received) {
@@ -121,7 +140,7 @@ export function isToolkitOwned(path: string, received: Set<string>): boolean {
121
140
  if (stem !== seeded && path.startsWith(`${stem}/`)) return false
122
141
  }
123
142
 
124
- return AUTHORING_ROOTS.some((prefix) => path.startsWith(prefix))
143
+ return roots.some((prefix) => path.startsWith(prefix))
125
144
  }
126
145
 
127
146
  /**
@@ -135,6 +154,7 @@ export function citationsIn(
135
154
  file: string,
136
155
  text: string,
137
156
  received: Set<string>,
157
+ roots: readonly string[] = AUTHORING_ROOTS,
138
158
  ): Citation[] {
139
159
  const citations: Citation[] = []
140
160
 
@@ -144,7 +164,7 @@ export function citationsIn(
144
164
  for (const match of line.matchAll(TOKEN)) {
145
165
  const path = match[1]
146
166
  if (!CONCRETE.test(path) || !path.includes('/')) continue
147
- if (!isToolkitOwned(path, received)) continue
167
+ if (!isToolkitOwned(path, received, roots)) continue
148
168
 
149
169
  citations.push({ file, line: index + 1, path, qualified })
150
170
  }
@@ -154,20 +174,27 @@ export function citationsIn(
154
174
  }
155
175
 
156
176
  /**
157
- * Reads every shipped body for a path its reader cannot open.
177
+ * Reads every body in the root's skill corpus for a path its reader cannot
178
+ * open.
158
179
  *
159
180
  * A citation of a path this repository does not hold is dropped rather than
160
181
  * reported. The measure asks whether a claim true here is false in a target,
161
182
  * and a path true in neither is a different defect that `aitk context audit`
162
183
  * already reports against its own corpus.
184
+ *
185
+ * `resolveSkillsCorpus` prefers `claude/skills/`, which is the tree that
186
+ * installs into a target, so this repository's own reading is the one it
187
+ * always was. A project carrying `.claude/skills/` alone has no shipped tree
188
+ * and its own skills are the whole corpus a reader there opens.
163
189
  */
164
190
  export function scanReach(root: string): ReachReport {
165
- const skillsRoot = join(root, SHIPPED_SKILLS)
166
- if (!existsSync(skillsRoot)) return { kind: 'refused', reason: 'no-skills' }
191
+ const corpus = resolveSkillsCorpus(root)
192
+ if (corpus === undefined) return { kind: 'refused', reason: 'no-skills' }
167
193
 
168
194
  const received = readReceivedPaths(root)
195
+ const roots = authoringRootsFor(corpus.rel)
169
196
  const files = [
170
- ...new Bun.Glob('**/*.md').scanSync({ cwd: skillsRoot, onlyFiles: true }),
197
+ ...new Bun.Glob('**/*.md').scanSync({ cwd: corpus.dir, onlyFiles: true }),
171
198
  ].sort()
172
199
 
173
200
  const qualified: Citation[] = []
@@ -175,12 +202,13 @@ export function scanReach(root: string): ReachReport {
175
202
 
176
203
  for (const file of files) {
177
204
  const posix = file.replaceAll('\\', '/')
178
- const text = readFileSync(join(skillsRoot, file), 'utf8')
205
+ const text = readFileSync(join(corpus.dir, file), 'utf8')
179
206
 
180
207
  for (const citation of citationsIn(
181
- `${SHIPPED_SKILLS.replaceAll('\\', '/')}/${posix}`,
208
+ `${corpus.rel}/${posix}`,
182
209
  text,
183
210
  received,
211
+ roots,
184
212
  )) {
185
213
  if (!existsSync(join(root, citation.path))) continue
186
214
 
@@ -189,5 +217,11 @@ export function scanReach(root: string): ReachReport {
189
217
  }
190
218
  }
191
219
 
192
- return { kind: 'measured', bodies: files.length, qualified, unqualified }
220
+ return {
221
+ kind: 'measured',
222
+ corpus: corpus.rel,
223
+ bodies: files.length,
224
+ qualified,
225
+ unqualified,
226
+ }
193
227
  }
@@ -36,9 +36,11 @@ import {
36
36
  } from '@/claude/skills-reach'
37
37
  import { SKILL_CASES } from '@/claude/cases/all'
38
38
  import {
39
+ loadCaseCorpus,
39
40
  type RankRefusal,
40
41
  type RankReport,
41
42
  scanRank,
43
+ type SkillCase,
42
44
  } from '@/claude/skills-rank'
43
45
  import {
44
46
  planSettings,
@@ -93,6 +95,7 @@ interface SkillsReachOptions {
93
95
 
94
96
  interface SkillsRankOptions {
95
97
  readonly json?: boolean
98
+ readonly cases?: string
96
99
  }
97
100
 
98
101
  interface RoutingOptions {
@@ -331,8 +334,8 @@ export function register(program: Command): void {
331
334
 
332
335
  skills
333
336
  .command('reach')
334
- .description('Report shipped bodies citing a path no target receives')
335
- .argument('[path]', 'Repository root, defaulting to the current directory')
337
+ .description('Report skill bodies citing a path no target receives')
338
+ .argument('[path]', 'Project root, defaulting to the current directory')
336
339
  .helpOption('-h, --help', 'Show this help message')
337
340
  .option('--json', 'Add a machine-readable record on stdout')
338
341
  .addHelpText(
@@ -341,10 +344,12 @@ export function register(program: Command): void {
341
344
  '',
342
345
  'Scope:',
343
346
  ' Every markdown file under claude/skills/, which is the tree that',
344
- ' installs into a target. A cited path counts when it sits under an',
345
- ' authoring root no install channel delivers and this repository',
347
+ ' installs into a target, or under .claude/skills/ in a project',
348
+ ' carrying that corpus alone. A cited path counts when it sits under',
349
+ ' an authoring root no install channel delivers and the project',
346
350
  ' holds it. A path under src/, scripts/, or bare docs/ names the',
347
- " reader's own tree and is not measured.",
351
+ " reader's own tree and is not measured, and .claude/context/ joins",
352
+ " them when the corpus read is a project's own.",
348
353
  '',
349
354
  'Exit codes:',
350
355
  ' 0 every citation names the toolkit as the owner',
@@ -358,6 +363,7 @@ export function register(program: Command): void {
358
363
  'Examples:',
359
364
  ' aitk claude skills reach',
360
365
  ' aitk claude skills reach --json',
366
+ ' aitk claude skills reach ~/repos/my-project',
361
367
  '',
362
368
  ].join('\n'),
363
369
  )
@@ -367,21 +373,33 @@ export function register(program: Command): void {
367
373
 
368
374
  skills
369
375
  .command('rank')
370
- .description('Score the shipped catalog against the routing case corpus')
371
- .argument('[path]', 'Repository root, defaulting to the current directory')
376
+ .description('Score a skill catalog against a routing case corpus')
377
+ .argument('[path]', 'Project root, defaulting to the current directory')
372
378
  .helpOption('-h, --help', 'Show this help message')
373
379
  .option('--json', 'Add a machine-readable record on stdout')
380
+ .option(
381
+ '--cases <path>',
382
+ "A project's own case corpus as JSON, replacing the toolkit's",
383
+ )
374
384
  .addHelpText(
375
385
  'after',
376
386
  [
377
387
  '',
378
388
  'Scope:',
379
- ' TF-IDF cosine similarity over every claude/skills/*/SKILL.md',
380
- ' frontmatter description, scored against the hand-authored corpus',
381
- ' at src/claude/cases/. A necessary condition rather than a report of',
382
- ' real routing behavior: it asks whether the descriptions are',
383
- ' separable by the words they use, and Claude Code does not route',
384
- ' this way.',
389
+ ' TF-IDF cosine similarity over every SKILL.md frontmatter',
390
+ ' description under claude/skills/, or under .claude/skills/ in a',
391
+ ' project carrying that corpus alone, scored against the',
392
+ ' hand-authored corpus at src/claude/cases/. A necessary condition',
393
+ ' rather than a report of real routing behavior: it asks whether the',
394
+ ' descriptions are separable by the words they use, and Claude Code',
395
+ ' does not route this way.',
396
+ '',
397
+ 'The case corpus:',
398
+ ' --cases takes a JSON array of { "prompt", "expect" } objects, the',
399
+ ' shape src/claude/cases/ already holds, where expect is a skill',
400
+ " folder name. A project's own skills need its own prompts, so the",
401
+ ' toolkit corpus is not a default anything else can measure against.',
402
+ ' No standard stands behind the file until a third project needs one.',
385
403
  '',
386
404
  'Exit codes:',
387
405
  ' 0 the catalog was read, whether or not a case missed rank one',
@@ -394,6 +412,7 @@ export function register(program: Command): void {
394
412
  'Examples:',
395
413
  ' aitk claude skills rank',
396
414
  ' aitk claude skills rank --json',
415
+ ' aitk claude skills rank ~/repos/my-project --cases cases.json',
397
416
  '',
398
417
  ].join('\n'),
399
418
  )
@@ -794,12 +813,14 @@ function reportRouting(
794
813
  /** What a reader does about the one way the corpus fails to build. */
795
814
  const REACH_REFUSALS: Record<ReachRefusal, string> = {
796
815
  'no-skills':
797
- 'No claude/skills/ here, so this tree ships no plugin body to measure.',
816
+ 'Neither claude/skills/ nor .claude/skills/ here, so this project carries no skill body to measure.',
798
817
  }
799
818
 
800
819
  /**
801
820
  * Measures the cwd rather than the toolkit root, matching the audit and drift
802
- * verbs, so a linked worktree reads its own branch instead of `main`.
821
+ * verbs, so a linked worktree reads its own branch instead of `main`, and a
822
+ * target carrying `.claude/skills/` alone is in scope the way the audit
823
+ * already has it.
803
824
  */
804
825
  function runSkillsReach(
805
826
  path: string | undefined,
@@ -830,6 +851,7 @@ function runSkillsReach(
830
851
  process.stdout.write(
831
852
  `${JSON.stringify({
832
853
  root,
854
+ corpus: report.corpus,
833
855
  bodies: report.bodies,
834
856
  qualified: report.qualified,
835
857
  unqualified: report.unqualified,
@@ -848,7 +870,7 @@ function runSkillsReach(
848
870
  function reportReach(report: Extract<ReachReport, { kind: 'measured' }>): void {
849
871
  logStep('Corpus')
850
872
  logInfo(
851
- `${plural(report.bodies, 'shipped file')} read, ${plural(report.qualified.length, 'citation')} already naming the toolkit as owner`,
873
+ `${report.corpus}: ${plural(report.bodies, 'file')} read, ${plural(report.qualified.length, 'citation')} already naming the toolkit as owner`,
852
874
  )
853
875
 
854
876
  logStep('Unqualified citations')
@@ -865,37 +887,44 @@ function reportReach(report: Extract<ReachReport, { kind: 'measured' }>): void {
865
887
  )
866
888
  }
867
889
 
868
- /** What a reader does about the one way the measure fails to build. */
890
+ /** What a reader does about each way the measure fails to build. */
869
891
  const RANK_REFUSALS: Record<RankRefusal, string> = {
870
892
  'no-skills':
871
- 'No claude/skills/ here, so this tree ships no plugin body to measure.',
893
+ 'Neither claude/skills/ nor .claude/skills/ here, so this project carries no skill body to measure.',
894
+ 'no-cases': 'No case corpus at the path given to --cases.',
895
+ 'bad-cases':
896
+ 'The case corpus is not a JSON array of { "prompt", "expect" } objects.',
872
897
  }
873
898
 
874
899
  /**
875
900
  * Measures the cwd rather than the toolkit root, matching the reach and audit
876
- * verbs, so a linked worktree reads its own branch instead of `main`. The
877
- * case corpus is the toolkit's own, since a target project ships no cases of
878
- * its own for a catalog it did not author.
901
+ * verbs, so a linked worktree reads its own branch instead of `main`, and a
902
+ * target carrying `.claude/skills/` alone is in scope.
903
+ *
904
+ * The toolkit's own cases are the default and answer for this catalog alone.
905
+ * A project measuring its own skills supplies its own prompts through
906
+ * `--cases`, since a corpus written against skills it did not author scores
907
+ * vocabulary it never uses.
879
908
  */
880
909
  function runSkillsRank(
881
910
  path: string | undefined,
882
911
  opts: SkillsRankOptions,
883
912
  ): number {
884
913
  const root = resolve(path ?? process.cwd())
885
- const report = scanRank(root, SKILL_CASES)
886
914
 
887
- if (report.kind === 'refused') {
888
- frameError(RANK_REFUSALS[report.reason])
889
- if (opts.json) {
890
- process.stdout.write(
891
- `${JSON.stringify({
892
- root,
893
- reason: report.reason,
894
- message: RANK_REFUSALS[report.reason],
895
- })}\n`,
896
- )
915
+ let cases: readonly SkillCase[] = SKILL_CASES
916
+ if (opts.cases !== undefined) {
917
+ const corpus = loadCaseCorpus(resolve(opts.cases))
918
+ if (corpus.kind === 'refused') {
919
+ return refuseRank(root, corpus.reason, corpus.detail, opts)
897
920
  }
898
- return 1
921
+ cases = corpus.cases
922
+ }
923
+
924
+ const report = scanRank(root, cases)
925
+
926
+ if (report.kind === 'refused') {
927
+ return refuseRank(root, report.reason, '', opts)
899
928
  }
900
929
 
901
930
  intro('aitk claude skills rank')
@@ -906,6 +935,7 @@ function runSkillsRank(
906
935
  process.stdout.write(
907
936
  `${JSON.stringify({
908
937
  root,
938
+ corpus: report.corpus,
909
939
  skills: report.skills,
910
940
  cases: report.cases,
911
941
  rank1: report.rank1,
@@ -919,6 +949,28 @@ function runSkillsRank(
919
949
  return 0
920
950
  }
921
951
 
952
+ /**
953
+ * Carries the detail beside the reason, since three refusals share one verb
954
+ * and only one of them names a path the caller can correct without it.
955
+ */
956
+ function refuseRank(
957
+ root: string,
958
+ reason: RankRefusal,
959
+ detail: string,
960
+ opts: SkillsRankOptions,
961
+ ): number {
962
+ const message = RANK_REFUSALS[reason]
963
+ frameError(detail === '' ? message : `${message} ${detail}`)
964
+
965
+ if (opts.json) {
966
+ process.stdout.write(
967
+ `${JSON.stringify({ root, reason, message, detail })}\n`,
968
+ )
969
+ }
970
+
971
+ return 1
972
+ }
973
+
922
974
  /**
923
975
  * States the corpus and both counts on every run, including a clean one. A
924
976
  * miss list alone reads as a verdict on the catalog unless the run also says
@@ -927,7 +979,7 @@ function runSkillsRank(
927
979
  function reportRank(report: Extract<RankReport, { kind: 'measured' }>): void {
928
980
  logStep('Corpus')
929
981
  logInfo(
930
- `${plural(report.skills, 'skill')} scored against ${plural(report.cases, 'case')}`,
982
+ `${report.corpus}: ${plural(report.skills, 'skill')} scored against ${plural(report.cases, 'case')}`,
931
983
  )
932
984
 
933
985
  logStep('Score')
@@ -774,9 +774,9 @@ function runList(opts: ListOptions): number {
774
774
  }
775
775
 
776
776
  /**
777
- * Silent on success so the consumed-copy stage that calls it stays as quiet as
778
- * the three `mirror_dir` lines it sits beside. The installed set is readable on
779
- * disk, so printing it would only add noise to every `bun run check`.
777
+ * Silent on success so the consumed-copy stage that calls it stays quiet. It is
778
+ * the only work that stage does now. The installed set is readable on disk, so
779
+ * printing it would only add noise to every `bun run check`.
780
780
  */
781
781
  async function runRegen(opts: RegenOptions): Promise<number> {
782
782
  const result = await regenConsumedRules(resolve(opts.root ?? PROJECT_ROOT))
@@ -91,7 +91,7 @@ allowed-tools: <tools required>
91
91
 
92
92
  - Skill is a folder named in kebab-case containing `SKILL.md` (required), `REQUIREMENT.md` (required), `EVAL.md` (optional), `scripts/` (optional), `references/` (optional), `assets/` (optional)
93
93
  - Name a file sitting directly in the skill folder in capitals and a bundled folder in lowercase, so the parts a reader opens are distinct from the ones a skill loads
94
- - `EVAL.md` holds prompts and a judging rubric a person runs by hand, so a skill carrying one needs no runner beside it
94
+ - `EVAL.md` holds prompts and a judging rubric a person runs by hand, which is the path open to everyone. `claude plugin eval` is the automated one and it is gated in early access, refusing before it parses a target, so write the hand-run rubric and know that the runner exists rather than that it does not
95
95
  - `SKILL.md` must start with YAML frontmatter between `---` delimiters
96
96
  - No `README.md` inside the skill folder
97
97
  - No spaces, capitals, or underscores in folder or skill name
@@ -33,8 +33,7 @@ Overlapping a skill that does the same job is not disqualifying on its own. A sn
33
33
  ## Invocation channels
34
34
 
35
35
  - Chrome extension: type `>slug` in a supported chat UI (claude.ai, gemini.google.com) to insert the snippet text inline
36
- - Claude Code terminal: prefix the install path with `@` (e.g. `@.claude/snippets/claude/feature`)
37
- - Snippets install preserving the source folder structure. A snippet at `claude/edit.md` installs as `.claude/snippets/claude/edit.md` and is invoked as `@.claude/snippets/claude/edit`
36
+ - Claude Code terminal: prefix the plugin path with `@` (e.g. `@claude/snippets/claude/feature-recap`). `claude/snippets` is a symlink to the authoring root, so the folder a snippet is written in is the folder it is invoked from
38
37
 
39
38
  ## Use patterns
40
39