@erclx/aitk 3.38.1 → 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.38.1",
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
 
@@ -35,6 +35,8 @@ Every one of them therefore has to ship inside the skill, the four covering the
35
35
 
36
36
  The dispatch's return leg is unplaced for the same reason. A reply reaches this session as a message and settles a finding, corrects which session holds which branch, or names a gate a worker's edits pass through, and reading it is the whole of what happens to it. Both sessions then end, so the half that belonged on the pull request never reaches it and the half that belonged on the board is rediscovered by whoever needs it next.
37
37
 
38
+ Cost never enters the decision to widen a wave. The rule weighing how many tracks to open measures collision and review attention, and both describe what a worker writes rather than what the loop spends. Claude Code delivers a message from another session as a fresh turn carrying the whole accumulated context, and a recurring poll bills that same window on its own interval, so a wave's spend tracks this session's context rather than the work coming back. The one setting that bounds it reads as available to whoever finds it, and the two values that would bound it break the handback the loop runs on.
39
+
38
40
  The session also records nothing of what it learns. Both other callers of memory capture are ship-chain skills and this one never ships, so the session taking every operator correction is the session with no moment that writes one down. Hanging that moment on the merge sweep answers it and bills the operator a capture pass per batch of merges while nothing is being built, which is a cost paid on the days shipping is fastest.
39
41
 
40
42
  ## Must
@@ -43,6 +45,7 @@ The session also records nothing of what it learns. Both other callers of memory
43
45
  - Report the state of play so the human knows what to launch, what to review, and what to merge
44
46
  - Verify a plan against the tree before handing it over, counting the sites it claims and opening the files it describes
45
47
  - List a candidate's file set against every track in flight, and serialize when the sets are not disjoint
48
+ - State what a wave costs in inbound turns beside the collision rule, and name the control that bounds it together with the reason it stays unset, since a lever recorded without its conclusion reads as available to set
46
49
  - Place every finding a merged pull request produced against the surface that owns it
47
50
  - Place what the return leg carries by what it changes, sending an answer that settles a finding onto the pull request and one that corrects this session's model of the world to the task owning that surface, since neither becomes a record by being read
48
51
  - Keep one planned, non-conflicting task in reserve beyond what is running
@@ -200,6 +200,21 @@ properly and stop when you cannot. An operator can also cap this session's
200
200
  workers by saying so, and a spoken cap binds for that session rather than
201
201
  standing as a number in a file.
202
202
 
203
+ Inbound turns are the third input to that judgment. Claude Code delivers a
204
+ message from another session as a new turn whenever this one sits idle, and the
205
+ turn carries the whole accumulated context rather than the few lines the worker
206
+ sent, so one handback from a wide wave costs more than the same handback from a
207
+ narrow one. A recurring review poll bills that window again on every interval it
208
+ fires. Weigh the spend before widening, since it lands on this session's context
209
+ and never on the worker's.
210
+
211
+ `crossSessionInbound` is the control, on an `accept`, `hold`, `refuse` ladder,
212
+ and it is recorded here as deliberately not pulled. `hold` and `refuse` are the
213
+ two values that bound the cost, and both break the handback this loop runs on,
214
+ since a held message reaches nobody until a later `accept` applies and a refused
215
+ one is dropped outright. `accept` bounds nothing. Read the ladder before turning
216
+ concurrency up rather than after, and leave it unset.
217
+
203
218
  Serialize any track that touches a shared wiring seam with another in flight, and
204
219
  serialize one whose sets are disjoint when a stated reason still puts it behind
205
220
  another, since two tracks interact in ways no file-set comparison reads. One
@@ -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`) |
@@ -37,7 +37,7 @@ The set is closed rather than derived, so widening it is a deliberate change to
37
37
 
38
38
  A sentence has to carry an assertion verb (`loads`, `ships`, `carries`, `holds`, `counts`, `totals`, `documents`, `declares`, `installs`, `lists`, `contains`, `comprises`, `authors`), the quantifier `all`, or an article (`the`, `a`, `an`) immediately ahead of the number, which is immediately ahead of the catalog noun, with one optional qualifying word between the number and the noun (`sixty-one shipped skills`). The number reads either as digits or as a spelled-out cardinal through ninety-nine, since this corpus states a catalog size in words as often as in digits.
39
39
 
40
- Nothing stands between the trigger and the number, which is the rule an author writing a new count has to know. It separates `the toolkit authors 69 rules` and `took all 69 rules`, both read, from `a domain of 55 skills` and `denominator of sixty-one shipped skills`, neither of them read. A figure meant as a past state takes a date in the same sentence instead, which reads the sentence past whatever its shape.
40
+ Nothing stands between the trigger and the number, which is the rule an author writing a new count has to know. It separates `the toolkit authors 70 rules` and `took all 70 rules`, both read, from `a domain of 55 skills` and `denominator of sixty-one shipped skills`, neither of them read. A figure meant as a past state takes a date in the same sentence instead, which reads the sentence past whatever its shape.
41
41
 
42
42
  ## How the trigger set grew
43
43
 
@@ -45,7 +45,7 @@ The verb gate is not the first design tried. A bare number next to a catalog nou
45
45
 
46
46
  The article half was added after a first review of the shipped design found a live miss: `.claude/context/development/gates.md` stated a stale audit total while the tree held 20, and the verb gate never reached it, since that sentence puts its verb after the noun rather than ahead of the number. Widening the trigger set to admit an article ahead of the number closed that gap.
47
47
 
48
- The quantifier `all` and the verb `authors` joined on the same evidence, two more live misses failing only the trigger test. Allowing words to stand between the trigger and the number was the other candidate, measured and declined: at widths of one, two, and three it reached 77, 104, and 132 sentences against a baseline of 65, missed `took all 69 rules` at every width, and reached `the toolkit authors 69 rules` only at a width admitting the indirect-noun shape below. The two words reach both at 75 sentences and no false positive.
48
+ The quantifier `all` and the verb `authors` joined on the same evidence, two more live misses failing only the trigger test. Allowing words to stand between the trigger and the number was the other candidate, measured and declined: at widths of one, two, and three it reached 77, 104, and 132 sentences against a baseline of 65, missed `took all 70 rules` at every width, and reached `the toolkit authors 70 rules` only at a width admitting the indirect-noun shape below. The two words reach both at 75 sentences and no false positive.
49
49
 
50
50
  ## The plausibility filter
51
51
 
@@ -59,7 +59,7 @@ The article gate is looser than the verb list and carries a real cost. Re-runnin
59
59
 
60
60
  A delta phrased as a transition (`from fourteen to fifteen`), a fraction (`thirteen of sixteen`), and a total reached through an indirect noun (`denominator of sixty-one shipped skills`) are all catalog-size claims this corpus carries, and none matches the trigger shape this reads. Each stays a known gap. The false-positive rate that gated closing them has a measurement behind it now, and what it showed is that the widening these three need is the one that costs a false positive rather than the one that does not.
61
61
 
62
- A second figure in a sentence whose first figure already matched is a fourth gap and a structural one, since one match is taken per catalog per sentence. `authors 69 rules under governance/rules/ and consumes 54 into .claude/rules/` is read for its 69 alone, which is correct here because the two figures name different populations, and a sentence stating one catalog twice would go unread the same way.
62
+ A second figure in a sentence whose first figure already matched is a fourth gap and a structural one, since one match is taken per catalog per sentence. `authors 70 rules under governance/rules/ and consumes 55 into .claude/rules/` is read for its 70 alone, which is correct here because the two figures name different populations, and a sentence stating one catalog twice would go unread the same way.
63
63
 
64
64
  A calendar date (`2026-08-21`) or a backticked commit reference in the same sentence reads the whole sentence past, since that is how this corpus already marks a figure as a historical record rather than a live claim. `.claude/ARCHITECTURE.md` and the context entries carry a figure this way deliberately, and every one of them stays correct forever.
65
65
 
@@ -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
 
@@ -154,6 +154,14 @@ above it, and stop adding once you can no longer review every output properly.
154
154
  Serialize a track sharing a wiring seam with another, and serialize one whose
155
155
  sets are disjoint when a stated reason still puts it behind another.
156
156
 
157
+ Inbound turns cost the controlling session as well, so weigh the spend before
158
+ widening. A message from one of your other sessions arrives as a new turn
159
+ carrying the whole accumulated context, and a recurring poll bills that window
160
+ again on its own interval, which means a wide wave spends against the
161
+ orchestrator rather than against any worker. `crossSessionInbound` is the one
162
+ control over that and stays unset here, since `hold` and `refuse` both break the
163
+ worker handback and `accept` bounds nothing.
164
+
157
165
  Unit checks run freely in many worktrees at once.
158
166
  A dev server, an end-to-end run, and a screenshot run alongside each other on a
159
167
  web stack, since every worktree derives its own port. Singleton resources (one
@@ -0,0 +1,14 @@
1
+ ---
2
+ description: State the autoContinueAtUsageLimit inversion and the autoCompactWindow cap for .claude/settings.json and its seeded copy
3
+ paths:
4
+ - '.claude/settings.json'
5
+ - 'tooling/claude/seeds/.claude/settings.json'
6
+ ---
7
+
8
+ # Settings standards
9
+
10
+ ## Session budget settings
11
+
12
+ - Never set `autoContinueAtUsageLimit` in this file. Its scope is user or managed, and a repository file setting it while no user, `--settings`, or managed value does makes Claude Code read the setting as off, so writing `true` here turns the behavior off for every operator carrying no value of their own.
13
+ - Treat `autoCompactWindow` as capped at the model's own context window. A value above that cap is inert on a session running a smaller window and bites only on one running the larger, so name the window a recorded value is meant for.
14
+ - Record why a session budget setting is left unset in the project's own development notes rather than in this file. JSON carries no pointer, so a session reading the settings file alone concludes the setting is unconfigured and sets it again.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.38.1",
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