@erclx/aitk
Advanced tools
| { | ||
| "name": "aitk", | ||
| "description": "Automated governance, versioning, and discovery tools for Claude Code.", | ||
| "version": "3.39.0", | ||
| "version": "3.40.0", | ||
| "author": { | ||
@@ -6,0 +6,0 @@ "name": "Eric Le", |
@@ -92,3 +92,3 @@ --- | ||
| - `standards/`, which is the authoring root and reaches a reader by resolution rather than by an install, so it carries no `.claude/` spelling | ||
| - `snippets/` and `.claude/snippets/` | ||
| - `snippets/`, which reaches a session through the `claude/snippets` symlink rather than an install, so it carries no `.claude/` spelling either | ||
| - `internal/` and `tooling/`, which hold the stack references and the seed documents a target is handed | ||
@@ -95,0 +95,0 @@ - `CLAUDE.md` at the repository root, named as a file because a path prefix reaches nothing that sits in no folder |
@@ -52,4 +52,4 @@ --- | ||
| | `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`) | | ||
| | `aitk claude skills reach` | Report the shipped bodies citing a toolkit path no target project receives, exiting 2 on an unqualified one | | ||
| | `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 | | ||
| | `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 | | ||
| | `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>`) | | ||
| | `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`) | | ||
@@ -56,0 +56,0 @@ | `aitk gov test-order` | Report where an implementation reached history ahead of the test covering it (`--json`) | |
@@ -35,3 +35,3 @@ --- | ||
| - [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 | ||
| - [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 | ||
| - [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 | ||
| - [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 | ||
@@ -38,0 +38,0 @@ - [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 |
| --- | ||
| title: Citation reach | ||
| 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 | ||
| 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 | ||
| --- | ||
@@ -8,3 +8,3 @@ | ||
| `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. | ||
| `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. | ||
@@ -14,2 +14,3 @@ ```bash | ||
| aitk claude skills reach --json | ||
| aitk claude skills reach ~/repos/my-project | ||
| ``` | ||
@@ -21,2 +22,6 @@ | ||
| ## Which corpus it reads | ||
| `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. | ||
| ## The defect it reads for | ||
@@ -44,2 +49,4 @@ | ||
| `.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. | ||
| 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. | ||
@@ -59,4 +66,4 @@ | ||
| 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. | ||
| 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. | ||
| 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. |
@@ -141,3 +141,3 @@ --- | ||
| 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. | ||
| 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. | ||
@@ -144,0 +144,0 @@ 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. |
+1
-1
| { | ||
| "name": "@erclx/aitk", | ||
| "type": "module", | ||
| "version": "3.39.0", | ||
| "version": "3.40.0", | ||
| "description": "Infrastructure and quality tooling for developer workflows", | ||
@@ -6,0 +6,0 @@ "license": "MIT", |
@@ -8,28 +8,4 @@ #!/usr/bin/env bash | ||
| mirror_dir() { | ||
| local src="$1" | ||
| local dest="$2" | ||
| local find_args=("${@:3}") | ||
| rm -rf "$dest" | ||
| while IFS= read -r file; do | ||
| local rel="${file#"$src"/}" | ||
| mkdir -p "$dest/$(dirname "$rel")" | ||
| cp "$file" "$dest/$rel" | ||
| done < <(find "$src" -type f "${find_args[@]}" | sort) | ||
| } | ||
| mirror_dir "$PROJECT_ROOT/snippets" "$PROJECT_ROOT/.claude/snippets" -name "*.md" | ||
| # `internal/` is the surface the plugin does not symlink. Mirrored on its own so | ||
| # toolkit sessions read it at a `.claude/` path like every other consumed copy. | ||
| # `internal/rules/` is excluded because it lands in `.claude/rules/` below, and | ||
| # mirroring it here too would publish each rule at a second inert path. The | ||
| # exclusion is anchored to that one folder, since an unanchored `*/rules/*` would | ||
| # also drop a later `internal/standards/rules/` and report nothing for it. | ||
| mirror_dir "$PROJECT_ROOT/internal" "$PROJECT_ROOT/.claude/internal" -name "*.md" -not -path "$PROJECT_ROOT/internal/rules/*" | ||
| # `.claude/rules/` is a subset rather than a mirror, so it resolves through the | ||
| # stack machinery instead of a fourth `mirror_dir` call. The record naming the | ||
| # subset is `internal/governance.toml`. | ||
| # stack machinery. The record naming the subset is `internal/governance.toml`. | ||
| bun "$PROJECT_ROOT/src/cli.ts" gov regen --root "$PROJECT_ROOT" |
@@ -264,3 +264,3 @@ #!/usr/bin/env bash | ||
| run_check "bash $PROJECT_ROOT/scripts/core/regen-claude-copies.sh" "Consumed-copy regen failed" | ||
| 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." | ||
| assert_no_drift ".claude/rules" "Consumed copies drifted. Run bun run check and commit .claude/rules." | ||
| log_info "Consumed copies clean" | ||
@@ -267,0 +267,0 @@ |
@@ -533,6 +533,7 @@ import type { SkillsAuditRefusal } from '@/claude/skills-audit' | ||
| // to targets and cannot be found is a broken checkout, and this is the | ||
| // second exception on the same test the secret scan takes: no target holds | ||
| // `claude/skills/`, so without the allowance every project installing this | ||
| // CLI reports the verb unmeasured on every run and never changes, which is | ||
| // the permanent signal the per-machine allowance exists against. | ||
| // second exception on the same test the secret scan takes: a project | ||
| // carrying neither `claude/skills/` nor `.claude/skills/` has adopted no | ||
| // skill convention, so without the allowance it reports the verb | ||
| // unmeasured on every run and never changes, which is the permanent signal | ||
| // the per-machine allowance exists against. | ||
| absentReasons: ['no-skills'] satisfies ReachRefusal[], | ||
@@ -551,5 +552,7 @@ counts: reachCounts, | ||
| corpus: 'tracked', | ||
| // The one reason this verb refuses for, and it is an absence for the same | ||
| // reason the reach check's is: a project carrying no `claude/skills/` has | ||
| // adopted no skill convention this audit reads. | ||
| // The one reason this run refuses for, and it is an absence for the same | ||
| // reason the reach check's is: a project carrying neither `claude/skills/` | ||
| // nor `.claude/skills/` has adopted no skill convention this audit reads. | ||
| // The two case-corpus refusals never reach here, since they need `--cases` | ||
| // and this argv passes none. | ||
| absentReasons: ['no-skills'] satisfies RankRefusal[], | ||
@@ -556,0 +559,0 @@ counts: rankCounts, |
@@ -46,2 +46,8 @@ import { existsSync } from 'node:fs' | ||
| export interface CorpusReport { | ||
| /** | ||
| * Left as `join` produced it, where `SkillsCorpus.rel` in `skills-list.ts` | ||
| * normalizes the same spelling to POSIX. This one is an existing JSON field | ||
| * a caller already reads, so the split holds until a branch reading this verb | ||
| * is the one to close it. | ||
| */ | ||
| readonly rel: string | ||
@@ -48,0 +54,0 @@ readonly skills: number |
| import { existsSync, readFileSync } from 'node:fs' | ||
| import { dirname, join } from 'node:path' | ||
| import { CORPORA } from '@/claude/skills-audit' | ||
@@ -12,3 +13,35 @@ const FRONTMATTER = /^---\n([\s\S]*?)\n---/ | ||
| export interface SkillsCorpus { | ||
| /** | ||
| * The corpus spelling in POSIX form, so a report reads the same on Windows. | ||
| * `CorpusReport.rel` in `skills-audit.ts` is the same spelling left as `join` | ||
| * produced it, so the two verbs disagree there. Normalizing it is a contract | ||
| * change on an existing JSON field and belongs to a branch reading the audit. | ||
| */ | ||
| readonly rel: string | ||
| /** The folder a listing reads, absolute against the root it was resolved at. */ | ||
| readonly dir: string | ||
| } | ||
| /** | ||
| * The skill corpus a measure reads at a given root: the shipped tree in this | ||
| * repository and a target's own `.claude/skills/` in a project that consumes | ||
| * it. `CORPORA` order settles a tree carrying both, so every reading taken | ||
| * here still comes from `claude/skills/`. | ||
| * | ||
| * Kept apart from `listSkills` deliberately. `src/counts/catalogs.ts` counts | ||
| * the shipped catalog through that function, so teaching it to read both | ||
| * corpora would move the reported total off the tree that installs and | ||
| * falsify every sentence in the corpus stating it. | ||
| */ | ||
| export function resolveSkillsCorpus(root: string): SkillsCorpus | undefined { | ||
| for (const rel of CORPORA) { | ||
| const dir = join(root, rel) | ||
| if (existsSync(dir)) return { rel: rel.replaceAll('\\', '/'), dir } | ||
| } | ||
| return undefined | ||
| } | ||
| /** | ||
| * Enumerates the plugin skill catalog, which is the corpus under `claude/` | ||
@@ -26,3 +59,10 @@ * rather than the internal skills under `.claude/`. Only the former installs | ||
| export function listSkills(root: string): SkillListing[] { | ||
| const skillsRoot = join(root, 'claude', 'skills') | ||
| return listSkillsAt(join(root, 'claude', 'skills')) | ||
| } | ||
| /** | ||
| * The same enumeration against a corpus folder the caller already resolved, | ||
| * which is what `resolveSkillsCorpus` hands a measure that reaches a target. | ||
| */ | ||
| export function listSkillsAt(skillsRoot: string): SkillListing[] { | ||
| if (!existsSync(skillsRoot)) return [] | ||
@@ -29,0 +69,0 @@ |
@@ -1,4 +0,3 @@ | ||
| import { existsSync } from 'node:fs' | ||
| import { join } from 'node:path' | ||
| import { listSkills } from '@/claude/skills-list' | ||
| import { existsSync, readFileSync } from 'node:fs' | ||
| import { listSkillsAt, resolveSkillsCorpus } from '@/claude/skills-list' | ||
@@ -17,4 +16,2 @@ /** | ||
| const SKILLS_DIR = join('claude', 'skills') | ||
| const STOP = new Set( | ||
@@ -48,7 +45,21 @@ '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( | ||
| /** Why a measure produced no reading, which is never the same as a clean one. */ | ||
| export type RankRefusal = 'no-skills' | ||
| export type RankRefusal = 'no-skills' | 'no-cases' | 'bad-cases' | ||
| /** The refusals a case corpus read produces, which the scan itself cannot raise. */ | ||
| export type CaseCorpusRefusal = Extract<RankRefusal, 'no-cases' | 'bad-cases'> | ||
| export type CaseCorpusReport = | ||
| | { readonly kind: 'cases'; readonly cases: readonly SkillCase[] } | ||
| | { | ||
| readonly kind: 'refused' | ||
| readonly reason: CaseCorpusRefusal | ||
| /** What the caller has to change, which the reason alone never says. */ | ||
| readonly detail: string | ||
| } | ||
| export type RankReport = | ||
| | { | ||
| readonly kind: 'measured' | ||
| /** The corpus spelling measured, since a root can carry either one. */ | ||
| readonly corpus: string | ||
| readonly skills: number | ||
@@ -65,2 +76,63 @@ readonly cases: number | ||
| /** | ||
| * Reads a project's own case corpus, which is JSON in the shape `SKILL_CASES` | ||
| * already holds. A target authors its own skills and its own vocabulary, so | ||
| * the toolkit corpus answers a question no other project asked. | ||
| * | ||
| * No standard stands behind the shape until a third project needs one, so | ||
| * every way the file fails is reported with what to change rather than | ||
| * measured against a spec. An empty array refuses for the reason a missing | ||
| * file does: a corpus of nothing scores 0 of 0 and reads as a clean pass. | ||
| */ | ||
| export function loadCaseCorpus(path: string): CaseCorpusReport { | ||
| if (!existsSync(path)) { | ||
| return { kind: 'refused', reason: 'no-cases', detail: path } | ||
| } | ||
| let parsed: unknown | ||
| try { | ||
| parsed = JSON.parse(readFileSync(path, 'utf8')) | ||
| } catch (error) { | ||
| const detail = error instanceof Error ? error.message : String(error) | ||
| return { kind: 'refused', reason: 'bad-cases', detail } | ||
| } | ||
| if (!Array.isArray(parsed)) { | ||
| return { | ||
| kind: 'refused', | ||
| reason: 'bad-cases', | ||
| detail: 'the file holds something other than an array of cases', | ||
| } | ||
| } | ||
| const cases: SkillCase[] = [] | ||
| for (const [index, entry] of parsed.entries()) { | ||
| const record = entry as Record<string, unknown> | null | ||
| if ( | ||
| typeof record !== 'object' || | ||
| record === null || | ||
| typeof record.prompt !== 'string' || | ||
| typeof record.expect !== 'string' | ||
| ) { | ||
| return { | ||
| kind: 'refused', | ||
| reason: 'bad-cases', | ||
| detail: `case ${index} carries no string prompt or expect`, | ||
| } | ||
| } | ||
| cases.push({ prompt: record.prompt, expect: record.expect }) | ||
| } | ||
| if (cases.length === 0) { | ||
| return { | ||
| kind: 'refused', | ||
| reason: 'bad-cases', | ||
| detail: 'the file holds no cases at all', | ||
| } | ||
| } | ||
| return { kind: 'cases', cases } | ||
| } | ||
| /** | ||
| * Every shipped skill's frontmatter description, read the way a prompt is | ||
@@ -72,3 +144,9 @@ * matched against it: whole, including the quoted trigger phrases it states. | ||
| export function loadCatalog(root: string): RankedSkill[] { | ||
| return listSkills(root) | ||
| const corpus = resolveSkillsCorpus(root) | ||
| return corpus === undefined ? [] : loadCatalogAt(corpus.dir) | ||
| } | ||
| /** The same read against a corpus folder the caller already resolved. */ | ||
| export function loadCatalogAt(skillsRoot: string): RankedSkill[] { | ||
| return listSkillsAt(skillsRoot) | ||
| .filter((skill) => skill.description !== '') | ||
@@ -221,5 +299,6 @@ .map((skill) => ({ name: skill.name, description: skill.description })) | ||
| /** | ||
| * Reads the shipped catalog off disk and scores it against the given case | ||
| * corpus. Measures the cwd's catalog rather than the toolkit root, matching | ||
| * the reach and audit verbs, so a linked worktree reads its own branch. | ||
| * Reads whichever skill corpus the root carries and scores it against the | ||
| * given cases. Measures the cwd's catalog rather than the toolkit root, | ||
| * matching the reach and audit verbs, so a linked worktree reads its own | ||
| * branch and a target reads the skills it wrote itself. | ||
| */ | ||
@@ -230,6 +309,6 @@ export function scanRank( | ||
| ): RankReport { | ||
| const skillsRoot = join(root, SKILLS_DIR) | ||
| if (!existsSync(skillsRoot)) return { kind: 'refused', reason: 'no-skills' } | ||
| const corpus = resolveSkillsCorpus(root) | ||
| if (corpus === undefined) return { kind: 'refused', reason: 'no-skills' } | ||
| const catalog = loadCatalog(root) | ||
| const catalog = loadCatalogAt(corpus.dir) | ||
| const { rank1, top3, misses, unmeasurable } = measureCases(catalog, cases) | ||
@@ -239,2 +318,3 @@ | ||
| kind: 'measured', | ||
| corpus: corpus.rel, | ||
| skills: catalog.length, | ||
@@ -241,0 +321,0 @@ cases: cases.length, |
| import { existsSync, readFileSync } from 'node:fs' | ||
| import { join } from 'node:path' | ||
| import { resolveSkillsCorpus } from '@/claude/skills-list' | ||
| /** | ||
| * The tree that installs into a target. The internal skills under `.claude/` | ||
| * never leave this repository, so a citation there is read by a session that | ||
| * already has the file and cannot be a reach defect. | ||
| */ | ||
| const SHIPPED_SKILLS = join('claude', 'skills') | ||
| /** | ||
| * The authoring roots this repository owns and no install channel delivers. | ||
@@ -74,2 +68,4 @@ * | ||
| readonly kind: 'measured' | ||
| /** The corpus spelling read, since a root can carry either one. */ | ||
| readonly corpus: string | ||
| /** Files opened, so a report can state what the verdict covers. */ | ||
@@ -87,2 +83,21 @@ readonly bodies: number | ||
| /** | ||
| * The roots that belong to the toolkit rather than to the reader, read | ||
| * against the corpus being measured. | ||
| * | ||
| * A target's `.claude/context/` is the reader's own tree. A seed put the | ||
| * entries there and the project owns them afterward, so a body under | ||
| * `.claude/skills/` citing one names a file its reader holds. Measuring it | ||
| * would report a correct citation on every run, which is exactly why `src/` | ||
| * and `scripts/` are absent from the list above. In this repository the seed | ||
| * tree settles the same question through `readReceivedPaths`, which a target | ||
| * carrying no `tooling/` folder cannot answer at all, so the root comes out | ||
| * by corpus instead. | ||
| */ | ||
| export function authoringRootsFor(corpus: string): readonly string[] { | ||
| if (!corpus.startsWith('.claude/')) return AUTHORING_ROOTS | ||
| return AUTHORING_ROOTS.filter((root) => !root.startsWith('.claude/')) | ||
| } | ||
| /** | ||
| * Every path a seed lands on in a target, spelled the way a body would cite it. | ||
@@ -118,3 +133,7 @@ * | ||
| */ | ||
| export function isToolkitOwned(path: string, received: Set<string>): boolean { | ||
| export function isToolkitOwned( | ||
| path: string, | ||
| received: Set<string>, | ||
| roots: readonly string[] = AUTHORING_ROOTS, | ||
| ): boolean { | ||
| if (received.has(path)) return false | ||
@@ -127,3 +146,3 @@ | ||
| return AUTHORING_ROOTS.some((prefix) => path.startsWith(prefix)) | ||
| return roots.some((prefix) => path.startsWith(prefix)) | ||
| } | ||
@@ -142,2 +161,3 @@ | ||
| received: Set<string>, | ||
| roots: readonly string[] = AUTHORING_ROOTS, | ||
| ): Citation[] { | ||
@@ -152,3 +172,3 @@ const citations: Citation[] = [] | ||
| if (!CONCRETE.test(path) || !path.includes('/')) continue | ||
| if (!isToolkitOwned(path, received)) continue | ||
| if (!isToolkitOwned(path, received, roots)) continue | ||
@@ -163,3 +183,4 @@ citations.push({ file, line: index + 1, path, qualified }) | ||
| /** | ||
| * Reads every shipped body for a path its reader cannot open. | ||
| * Reads every body in the root's skill corpus for a path its reader cannot | ||
| * open. | ||
| * | ||
@@ -170,10 +191,16 @@ * A citation of a path this repository does not hold is dropped rather than | ||
| * already reports against its own corpus. | ||
| * | ||
| * `resolveSkillsCorpus` prefers `claude/skills/`, which is the tree that | ||
| * installs into a target, so this repository's own reading is the one it | ||
| * always was. A project carrying `.claude/skills/` alone has no shipped tree | ||
| * and its own skills are the whole corpus a reader there opens. | ||
| */ | ||
| export function scanReach(root: string): ReachReport { | ||
| const skillsRoot = join(root, SHIPPED_SKILLS) | ||
| if (!existsSync(skillsRoot)) return { kind: 'refused', reason: 'no-skills' } | ||
| const corpus = resolveSkillsCorpus(root) | ||
| if (corpus === undefined) return { kind: 'refused', reason: 'no-skills' } | ||
| const received = readReceivedPaths(root) | ||
| const roots = authoringRootsFor(corpus.rel) | ||
| const files = [ | ||
| ...new Bun.Glob('**/*.md').scanSync({ cwd: skillsRoot, onlyFiles: true }), | ||
| ...new Bun.Glob('**/*.md').scanSync({ cwd: corpus.dir, onlyFiles: true }), | ||
| ].sort() | ||
@@ -186,8 +213,9 @@ | ||
| const posix = file.replaceAll('\\', '/') | ||
| const text = readFileSync(join(skillsRoot, file), 'utf8') | ||
| const text = readFileSync(join(corpus.dir, file), 'utf8') | ||
| for (const citation of citationsIn( | ||
| `${SHIPPED_SKILLS.replaceAll('\\', '/')}/${posix}`, | ||
| `${corpus.rel}/${posix}`, | ||
| text, | ||
| received, | ||
| roots, | ||
| )) { | ||
@@ -201,3 +229,9 @@ if (!existsSync(join(root, citation.path))) continue | ||
| return { kind: 'measured', bodies: files.length, qualified, unqualified } | ||
| return { | ||
| kind: 'measured', | ||
| corpus: corpus.rel, | ||
| bodies: files.length, | ||
| qualified, | ||
| unqualified, | ||
| } | ||
| } |
+86
-34
@@ -39,5 +39,7 @@ import { existsSync } from 'node:fs' | ||
| import { | ||
| loadCaseCorpus, | ||
| type RankRefusal, | ||
| type RankReport, | ||
| scanRank, | ||
| type SkillCase, | ||
| } from '@/claude/skills-rank' | ||
@@ -97,2 +99,3 @@ import { | ||
| readonly json?: boolean | ||
| readonly cases?: string | ||
| } | ||
@@ -336,4 +339,4 @@ | ||
| .command('reach') | ||
| .description('Report shipped bodies citing a path no target receives') | ||
| .argument('[path]', 'Repository root, defaulting to the current directory') | ||
| .description('Report skill bodies citing a path no target receives') | ||
| .argument('[path]', 'Project root, defaulting to the current directory') | ||
| .helpOption('-h, --help', 'Show this help message') | ||
@@ -347,6 +350,8 @@ .option('--json', 'Add a machine-readable record on stdout') | ||
| ' Every markdown file under claude/skills/, which is the tree that', | ||
| ' installs into a target. A cited path counts when it sits under an', | ||
| ' authoring root no install channel delivers and this repository', | ||
| ' installs into a target, or under .claude/skills/ in a project', | ||
| ' carrying that corpus alone. A cited path counts when it sits under', | ||
| ' an authoring root no install channel delivers and the project', | ||
| ' holds it. A path under src/, scripts/, or bare docs/ names the', | ||
| " reader's own tree and is not measured.", | ||
| " reader's own tree and is not measured, and .claude/context/ joins", | ||
| " them when the corpus read is a project's own.", | ||
| '', | ||
@@ -365,2 +370,3 @@ 'Exit codes:', | ||
| ' aitk claude skills reach --json', | ||
| ' aitk claude skills reach ~/repos/my-project', | ||
| '', | ||
@@ -375,6 +381,10 @@ ].join('\n'), | ||
| .command('rank') | ||
| .description('Score the shipped catalog against the routing case corpus') | ||
| .argument('[path]', 'Repository root, defaulting to the current directory') | ||
| .description('Score a skill catalog against a routing case corpus') | ||
| .argument('[path]', 'Project root, defaulting to the current directory') | ||
| .helpOption('-h, --help', 'Show this help message') | ||
| .option('--json', 'Add a machine-readable record on stdout') | ||
| .option( | ||
| '--cases <path>', | ||
| "A project's own case corpus as JSON, replacing the toolkit's", | ||
| ) | ||
| .addHelpText( | ||
@@ -385,9 +395,17 @@ 'after', | ||
| 'Scope:', | ||
| ' TF-IDF cosine similarity over every claude/skills/*/SKILL.md', | ||
| ' frontmatter description, scored against the hand-authored corpus', | ||
| ' at src/claude/cases/. A necessary condition rather than a report of', | ||
| ' real routing behavior: it asks whether the descriptions are', | ||
| ' separable by the words they use, and Claude Code does not route', | ||
| ' this way.', | ||
| ' TF-IDF cosine similarity over every SKILL.md frontmatter', | ||
| ' description under claude/skills/, or under .claude/skills/ in a', | ||
| ' project carrying that corpus alone, scored against the', | ||
| ' hand-authored corpus at src/claude/cases/. A necessary condition', | ||
| ' rather than a report of real routing behavior: it asks whether the', | ||
| ' descriptions are separable by the words they use, and Claude Code', | ||
| ' does not route this way.', | ||
| '', | ||
| 'The case corpus:', | ||
| ' --cases takes a JSON array of { "prompt", "expect" } objects, the', | ||
| ' shape src/claude/cases/ already holds, where expect is a skill', | ||
| " folder name. A project's own skills need its own prompts, so the", | ||
| ' toolkit corpus is not a default anything else can measure against.', | ||
| ' No standard stands behind the file until a third project needs one.', | ||
| '', | ||
| 'Exit codes:', | ||
@@ -404,2 +422,3 @@ ' 0 the catalog was read, whether or not a case missed rank one', | ||
| ' aitk claude skills rank --json', | ||
| ' aitk claude skills rank ~/repos/my-project --cases cases.json', | ||
| '', | ||
@@ -805,3 +824,3 @@ ].join('\n'), | ||
| 'no-skills': | ||
| 'No claude/skills/ here, so this tree ships no plugin body to measure.', | ||
| 'Neither claude/skills/ nor .claude/skills/ here, so this project carries no skill body to measure.', | ||
| } | ||
@@ -811,3 +830,5 @@ | ||
| * Measures the cwd rather than the toolkit root, matching the audit and drift | ||
| * verbs, so a linked worktree reads its own branch instead of `main`. | ||
| * verbs, so a linked worktree reads its own branch instead of `main`, and a | ||
| * target carrying `.claude/skills/` alone is in scope the way the audit | ||
| * already has it. | ||
| */ | ||
@@ -843,2 +864,3 @@ function runSkillsReach( | ||
| root, | ||
| corpus: report.corpus, | ||
| bodies: report.bodies, | ||
@@ -862,3 +884,3 @@ qualified: report.qualified, | ||
| logInfo( | ||
| `${plural(report.bodies, 'shipped file')} read, ${plural(report.qualified.length, 'citation')} already naming the toolkit as owner`, | ||
| `${report.corpus}: ${plural(report.bodies, 'file')} read, ${plural(report.qualified.length, 'citation')} already naming the toolkit as owner`, | ||
| ) | ||
@@ -880,6 +902,9 @@ | ||
| /** What a reader does about the one way the measure fails to build. */ | ||
| /** What a reader does about each way the measure fails to build. */ | ||
| const RANK_REFUSALS: Record<RankRefusal, string> = { | ||
| 'no-skills': | ||
| 'No claude/skills/ here, so this tree ships no plugin body to measure.', | ||
| 'Neither claude/skills/ nor .claude/skills/ here, so this project carries no skill body to measure.', | ||
| 'no-cases': 'No case corpus at the path given to --cases.', | ||
| 'bad-cases': | ||
| 'The case corpus is not a JSON array of { "prompt", "expect" } objects.', | ||
| } | ||
@@ -889,5 +914,9 @@ | ||
| * Measures the cwd rather than the toolkit root, matching the reach and audit | ||
| * verbs, so a linked worktree reads its own branch instead of `main`. The | ||
| * case corpus is the toolkit's own, since a target project ships no cases of | ||
| * its own for a catalog it did not author. | ||
| * verbs, so a linked worktree reads its own branch instead of `main`, and a | ||
| * target carrying `.claude/skills/` alone is in scope. | ||
| * | ||
| * The toolkit's own cases are the default and answer for this catalog alone. | ||
| * A project measuring its own skills supplies its own prompts through | ||
| * `--cases`, since a corpus written against skills it did not author scores | ||
| * vocabulary it never uses. | ||
| */ | ||
@@ -899,18 +928,18 @@ function runSkillsRank( | ||
| const root = resolve(path ?? process.cwd()) | ||
| const report = scanRank(root, SKILL_CASES) | ||
| if (report.kind === 'refused') { | ||
| frameError(RANK_REFUSALS[report.reason]) | ||
| if (opts.json) { | ||
| process.stdout.write( | ||
| `${JSON.stringify({ | ||
| root, | ||
| reason: report.reason, | ||
| message: RANK_REFUSALS[report.reason], | ||
| })}\n`, | ||
| ) | ||
| let cases: readonly SkillCase[] = SKILL_CASES | ||
| if (opts.cases !== undefined) { | ||
| const corpus = loadCaseCorpus(resolve(opts.cases)) | ||
| if (corpus.kind === 'refused') { | ||
| return refuseRank(root, corpus.reason, corpus.detail, opts) | ||
| } | ||
| return 1 | ||
| cases = corpus.cases | ||
| } | ||
| const report = scanRank(root, cases) | ||
| if (report.kind === 'refused') { | ||
| return refuseRank(root, report.reason, '', opts) | ||
| } | ||
| intro('aitk claude skills rank') | ||
@@ -924,2 +953,3 @@ reportRank(report) | ||
| root, | ||
| corpus: report.corpus, | ||
| skills: report.skills, | ||
@@ -939,2 +969,24 @@ cases: report.cases, | ||
| /** | ||
| * Carries the detail beside the reason, since three refusals share one verb | ||
| * and only one of them names a path the caller can correct without it. | ||
| */ | ||
| function refuseRank( | ||
| root: string, | ||
| reason: RankRefusal, | ||
| detail: string, | ||
| opts: SkillsRankOptions, | ||
| ): number { | ||
| const message = RANK_REFUSALS[reason] | ||
| frameError(detail === '' ? message : `${message} ${detail}`) | ||
| if (opts.json) { | ||
| process.stdout.write( | ||
| `${JSON.stringify({ root, reason, message, detail })}\n`, | ||
| ) | ||
| } | ||
| return 1 | ||
| } | ||
| /** | ||
| * States the corpus and both counts on every run, including a clean one. A | ||
@@ -947,3 +999,3 @@ * miss list alone reads as a verdict on the catalog unless the run also says | ||
| logInfo( | ||
| `${plural(report.skills, 'skill')} scored against ${plural(report.cases, 'case')}`, | ||
| `${report.corpus}: ${plural(report.skills, 'skill')} scored against ${plural(report.cases, 'case')}`, | ||
| ) | ||
@@ -950,0 +1002,0 @@ |
@@ -777,5 +777,5 @@ import { existsSync } from 'node:fs' | ||
| /** | ||
| * Silent on success so the consumed-copy stage that calls it stays as quiet as | ||
| * the three `mirror_dir` lines it sits beside. The installed set is readable on | ||
| * disk, so printing it would only add noise to every `bun run check`. | ||
| * Silent on success so the consumed-copy stage that calls it stays quiet. It is | ||
| * the only work that stage does now. The installed set is readable on disk, so | ||
| * printing it would only add noise to every `bun run check`. | ||
| */ | ||
@@ -782,0 +782,0 @@ async function runRegen(opts: RegenOptions): Promise<number> { |
@@ -94,3 +94,3 @@ --- | ||
| - 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 | ||
| - `EVAL.md` holds prompts and a judging rubric a person runs by hand, so a skill carrying one needs no runner beside it | ||
| - `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 | ||
| - `SKILL.md` must start with YAML frontmatter between `---` delimiters | ||
@@ -97,0 +97,0 @@ - No `README.md` inside the skill folder |
@@ -36,4 +36,3 @@ --- | ||
| - Chrome extension: type `>slug` in a supported chat UI (claude.ai, gemini.google.com) to insert the snippet text inline | ||
| - Claude Code terminal: prefix the install path with `@` (e.g. `@.claude/snippets/claude/feature`) | ||
| - 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` | ||
| - 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 | ||
@@ -40,0 +39,0 @@ ## Use patterns |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
2893015
0.29%34549
0.57%