@erclx/aitk
Advanced tools
| --- | ||
| 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 | ||
| --- | ||
| # Citation reach | ||
| `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. | ||
| ```bash | ||
| aitk claude skills reach | ||
| aitk claude skills reach --json | ||
| ``` | ||
| | Option | Behavior | | ||
| | -------- | ---------------------------------------------------------- | | ||
| | `--json` | Add a machine-readable record on stdout, keeping the frame | | ||
| ## The defect it reads for | ||
| 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. | ||
| ## What counts as a citation | ||
| A backticked token carrying a separator and an extension, which is how every body spells a path it means a reader to open. Three forms are skipped by construction. | ||
| - A placeholder such as `.claude/context/<domain>.md`, which names a shape rather than a file | ||
| - A path resolved through `${CLAUDE_SKILL_DIR}`, which is self-contained wherever the plugin loads | ||
| - A sibling named relatively, such as `references/labels.md`, which matches no authoring root and travels with the body | ||
| The same sibling named from the repository root as `claude/skills/<name>/references/<file>.md` is reported rather than skipped. The plugin loads from a cache rather than from the project tree, so that spelling resolves for nobody and the report is correct. | ||
| A path the toolkit does not hold is dropped rather than reported. The measure asks whether a claim true here is false in a target, and a path true in neither is a different defect that `aitk context audit` already reports against its own corpus. | ||
| ## The ownership key | ||
| A cited path counts when it sits under an authoring root no install channel delivers. Standards install nowhere and are read through the plugin corpus, snippets land under `.claude/snippets/`, governance rules under `.claude/rules/`, and the rest is this repository's own source, catalogs, and contract pages. | ||
| `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. | ||
| 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. | ||
| ## The qualifier | ||
| A correct citation and a defective one are the same string, and the sentence around it is the difference. A citation counts as decided when its line names the toolkit as the owner, matching the bodies that already spell it that way. The repair for a finding is to say whose copy the path is, never to delete the citation, since the paths name real documents a reader wants. | ||
| ```markdown | ||
| Read `.claude/context/indexes.md` from the toolkit if context on the system is needed. | ||
| ``` | ||
| A line mentioning the toolkit for an unrelated reason exempts a citation on it. That is the accepted cost of a word over a notation every future body would have to learn. | ||
| ## Exit codes | ||
| 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. | ||
| 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. |
| import { existsSync, readFileSync } from 'node:fs' | ||
| import { join } from 'node:path' | ||
| /** | ||
| * 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. | ||
| * | ||
| * Every entry is a folder a target never holds under that spelling. Standards | ||
| * install nowhere and are reached through the plugin corpus, rules install | ||
| * under `.claude/rules/`, snippets under `.claude/snippets/`, and the rest are | ||
| * this repository's own source, docs, and catalogs. | ||
| * | ||
| * `src/`, `scripts/`, and bare `docs/` are deliberately absent. A body naming | ||
| * one of those is describing the reader's own tree, so listing them would | ||
| * report a correct citation on every run and bury the defect this measures. | ||
| * `docs/agents/` is the exception, being the CLI contract pages that exist | ||
| * here alone. | ||
| */ | ||
| const AUTHORING_ROOTS = [ | ||
| '.claude/context/', | ||
| 'claude/', | ||
| 'docs/agents/', | ||
| 'governance/', | ||
| 'internal/', | ||
| 'snippets/', | ||
| 'standards/', | ||
| 'tooling/', | ||
| 'wiki/', | ||
| ] as const | ||
| /** | ||
| * What marks a citation as deliberately naming this repository's own copy. | ||
| * | ||
| * The word rather than a notation, matching the three bodies that already | ||
| * spell it and the repair the plan settled on. A parser-visible syntax was the | ||
| * alternative and it invents a spelling for a handful of lines while leaving | ||
| * the shipped precedent unreadable. | ||
| */ | ||
| const QUALIFIER = /toolkit/i | ||
| /** A backticked token, which is how every body spells a path it cites. */ | ||
| const TOKEN = /`([^`\s]+)`/g | ||
| /** | ||
| * A path a reader could open, which is the only kind worth measuring. | ||
| * | ||
| * Requires an extension and a separator, and admits no `<`, `$`, or `*`. A | ||
| * body writes `.claude/context/<domain>.md` to name a shape rather than a | ||
| * file, and `${CLAUDE_SKILL_DIR}/../../standards/markdown.md` to resolve | ||
| * against the plugin root, which is self-contained by construction. | ||
| */ | ||
| const CONCRETE = /^[.A-Za-z0-9_][A-Za-z0-9._/-]*\.[a-z]{1,4}$/ | ||
| export interface Citation { | ||
| readonly file: string | ||
| /** One-based, matching the `file:line` form a reader clicks. */ | ||
| readonly line: number | ||
| readonly path: string | ||
| readonly qualified: boolean | ||
| } | ||
| /** Why a scan produced no corpus, which is never the same as a clean one. */ | ||
| export type ReachRefusal = 'no-skills' | ||
| export type ReachReport = | ||
| | { | ||
| readonly kind: 'measured' | ||
| /** Files opened, so a report can state what the verdict covers. */ | ||
| readonly bodies: number | ||
| readonly qualified: readonly Citation[] | ||
| readonly unqualified: readonly Citation[] | ||
| } | ||
| | { readonly kind: 'refused'; readonly reason: ReachRefusal } | ||
| export function isQualified(line: string): boolean { | ||
| return QUALIFIER.test(line) | ||
| } | ||
| /** | ||
| * Every path a seed lands on in a target, spelled the way a body would cite it. | ||
| * | ||
| * Read off the seed tree rather than listed, so a seed added to any stack | ||
| * clears its own citations without this module being edited. Dotfiles are in | ||
| * scope because the whole seeded context corpus sits under `.claude/`. | ||
| */ | ||
| export function readReceivedPaths(root: string): Set<string> { | ||
| const toolingRoot = join(root, 'tooling') | ||
| if (!existsSync(toolingRoot)) return new Set() | ||
| const received = new Set<string>() | ||
| for (const path of new Bun.Glob('*/seeds/**/*').scanSync({ | ||
| cwd: toolingRoot, | ||
| onlyFiles: true, | ||
| dot: true, | ||
| })) { | ||
| const posix = path.replaceAll('\\', '/') | ||
| received.add(posix.replace(/^[^/]+\/seeds\//, '')) | ||
| } | ||
| return received | ||
| } | ||
| /** | ||
| * Whether a cited path is this repository's own rather than the reader's. | ||
| * | ||
| * A seeded path is disowned twice over: under its own name, and under the | ||
| * folder spelling it takes once a target 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 target for growing. | ||
| */ | ||
| export function isToolkitOwned(path: string, received: Set<string>): boolean { | ||
| if (received.has(path)) return false | ||
| for (const seeded of received) { | ||
| const stem = seeded.replace(/\.md$/, '') | ||
| if (stem !== seeded && path.startsWith(`${stem}/`)) return false | ||
| } | ||
| return AUTHORING_ROOTS.some((prefix) => path.startsWith(prefix)) | ||
| } | ||
| /** | ||
| * Every toolkit-owned path one shipped file cites, with the line's verdict. | ||
| * | ||
| * Existence is not checked here. A body may name a path this repository once | ||
| * held, and separating the shape test from the disk read is what lets the | ||
| * shape be tested without a tree on disk. | ||
| */ | ||
| export function citationsIn( | ||
| file: string, | ||
| text: string, | ||
| received: Set<string>, | ||
| ): Citation[] { | ||
| const citations: Citation[] = [] | ||
| for (const [index, line] of text.split('\n').entries()) { | ||
| const qualified = isQualified(line) | ||
| for (const match of line.matchAll(TOKEN)) { | ||
| const path = match[1] | ||
| if (!CONCRETE.test(path) || !path.includes('/')) continue | ||
| if (!isToolkitOwned(path, received)) continue | ||
| citations.push({ file, line: index + 1, path, qualified }) | ||
| } | ||
| } | ||
| return citations | ||
| } | ||
| /** | ||
| * Reads every shipped body for a path its reader cannot open. | ||
| * | ||
| * A citation of a path this repository does not hold is dropped rather than | ||
| * reported. The measure asks whether a claim true here is false in a target, | ||
| * and a path true in neither is a different defect that `aitk context audit` | ||
| * already reports against its own corpus. | ||
| */ | ||
| export function scanReach(root: string): ReachReport { | ||
| const skillsRoot = join(root, SHIPPED_SKILLS) | ||
| if (!existsSync(skillsRoot)) return { kind: 'refused', reason: 'no-skills' } | ||
| const received = readReceivedPaths(root) | ||
| const files = [ | ||
| ...new Bun.Glob('**/*.md').scanSync({ cwd: skillsRoot, onlyFiles: true }), | ||
| ].sort() | ||
| const qualified: Citation[] = [] | ||
| const unqualified: Citation[] = [] | ||
| for (const file of files) { | ||
| const posix = file.replaceAll('\\', '/') | ||
| const text = readFileSync(join(skillsRoot, file), 'utf8') | ||
| for (const citation of citationsIn( | ||
| `${SHIPPED_SKILLS.replaceAll('\\', '/')}/${posix}`, | ||
| text, | ||
| received, | ||
| )) { | ||
| if (!existsSync(join(root, citation.path))) continue | ||
| if (citation.qualified) qualified.push(citation) | ||
| else unqualified.push(citation) | ||
| } | ||
| } | ||
| return { kind: 'measured', bodies: files.length, qualified, unqualified } | ||
| } |
| { | ||
| "name": "aitk", | ||
| "description": "Automated governance, versioning, and discovery tools for Claude Code.", | ||
| "version": "3.0.0", | ||
| "version": "3.1.0", | ||
| "author": { | ||
@@ -6,0 +6,0 @@ "name": "Eric Le", |
@@ -38,3 +38,3 @@ --- | ||
| On the source path, also read the UI surfaces matched in Step 1 plus `docs/agents/output-shape.md` and `docs/index.md` for output shape or framing rules already documented. | ||
| On the source path, also read the UI surfaces matched in Step 1 plus any `docs/agents/output-shape.md` and `docs/index.md` the project itself carries, for output shape or framing rules already documented. Those two are the toolkit's own spelling, so a project keeping its framing rules elsewhere is read there instead. | ||
@@ -41,0 +41,0 @@ On the greenfield path, also read `.claude/ARCHITECTURE.md` for platform, tech stack, and surface type. Do not scan `src/`, stylesheets, or UI modules. Step 1 already established they hold nothing. |
@@ -46,4 +46,4 @@ --- | ||
| - Promoting an answered item onto the board, which is `claude-tasks` and runs after the answers land | ||
| - The item format, the answer contract, and retrieval, which `standards/intake.md` owns and this skill cites | ||
| - The item format, the answer contract, and retrieval, which the toolkit's `standards/intake.md` owns and this skill cites | ||
| - The comparable answer slots in groundwork and feature plans, which carry their own contracts and are a separate measurement | ||
| - Deciding when to fire. The skill is user-invoked through `disable-model-invocation`, so answering is the operator's call rather than a description match. |
@@ -18,3 +18,3 @@ --- | ||
| `CLAUDE.md` states both branches of the rule, sending an ordinary judgment call to a pick with the tradeoff in one sentence and a preference-deciding call to the operator. The first branch has `snippets/decision-help.md` behind it and the second had no surface at all. | ||
| `CLAUDE.md` states both branches of the rule, sending an ordinary judgment call to a pick with the tradeoff in one sentence and a preference-deciding call to the operator. The first branch has the toolkit's `snippets/decision-help.md` behind it, which installs into a project as `.claude/snippets/decision-help.md`, and the second had no surface at all. | ||
@@ -44,4 +44,4 @@ ## Must | ||
| - Making the ordinary judgment call, which is a pick plus a one-sentence tradeoff and needs no surface | ||
| - The chat-side pick with no repository behind it, which `snippets/decision-help.md` covers and reaches a different reader | ||
| - The chat-side pick with no repository behind it, which the toolkit's `snippets/decision-help.md` covers and reaches a different reader | ||
| - Writing the decision into a plan, task, or architecture record, which each owning standard governs and this skill only routes to | ||
| - Deciding when to fire. The skill is user-invoked through `disable-model-invocation`, so escalating is the operator's call rather than a description match. |
@@ -114,3 +114,3 @@ --- | ||
| If `CLAUDE.md` exists but has no `## Indexes` section, offer to install the canonical convention block. The text below is the source of truth and is mirrored in `tooling/claude/seeds/CLAUDE.md`. Paste it verbatim. Do not rewrite, paraphrase, condense, or add punctuation. | ||
| If `CLAUDE.md` exists but has no `## Indexes` section, offer to install the canonical convention block. The text below is the source of truth and is mirrored in the toolkit's `tooling/claude/seeds/CLAUDE.md`. Paste it verbatim. Do not rewrite, paraphrase, condense, or add punctuation. | ||
@@ -151,3 +151,5 @@ ```markdown | ||
| - `.claude/context/indexes.md`: system rationale, frontmatter contract, when to adopt | ||
| - `docs/agents/indexes.md`: `aitk indexes regen` flags, exit codes, JSON shape | ||
| Both pages sit in the toolkit and install nowhere, so a target reads them there rather than in its own tree. | ||
| - The toolkit's `.claude/context/indexes.md`: system rationale, frontmatter contract, when to adopt | ||
| - The toolkit's `docs/agents/indexes.md`: `aitk indexes regen` flags, exit codes, JSON shape |
@@ -8,3 +8,3 @@ --- | ||
| Turn a pasted YouTube URL into a markdown file with YAML frontmatter and a cleaned prose body. The `aitk transcripts` command owns the fetch, VTT cleanup, and frontmatter. Do not reimplement that logic. See `.claude/context/transcripts.md` for the output format and field list. | ||
| Turn a pasted YouTube URL into a markdown file with YAML frontmatter and a cleaned prose body. The `aitk transcripts` command owns the fetch, VTT cleanup, and frontmatter. Do not reimplement that logic. The output format and field list live in the toolkit's `.claude/context/transcripts.md`, which a target does not receive. | ||
@@ -11,0 +11,0 @@ ## Guards |
@@ -25,7 +25,7 @@ --- | ||
| Fourteen verbs, listed by `aitk audits list`. Each runs once in its fullest form, and the aggregate reads that verb's own record rather than imposing a shared envelope on it. Every one of those records already has consumers naming its keys, so a common shape would be a breaking change bought for tidiness. | ||
| Fifteen verbs, listed by `aitk audits list`. Each runs once in its fullest form, and the aggregate reads that verb's own record rather than imposing a shared envelope on it. Every one of those records already has consumers naming its keys, so a common shape would be a breaking change bought for tidiness. | ||
| The verbs walk separate trees and share no state, so they run together. Measured on the authoring machine at twelve verbs, a run finished in 0.8 seconds of wall clock against 4.4 seconds of processor, which is under every other stage in `bun run check`. `aitk deps audit` is the one that changes that reading, since it reaches a network rather than a tree and its latency is the index's rather than this machine's. | ||
| Twelve of the fourteen read a tree on this disk. The two added by `state-scoped-risk.md` read committed state rather than an arriving change, which is the gap every review surface here leaves by construction. | ||
| Thirteen of the fifteen read a tree on this disk. The two added by `state-scoped-risk.md` read committed state rather than an arriving change, which is the gap every review surface here leaves by construction. | ||
@@ -32,0 +32,0 @@ Each is invoked as the CLI the caller is running rather than as a global `aitk`. A globally installed binary resolves to the main checkout no matter which worktree is executing, so the aggregate would measure a tree the branch never touched and report a pass over it. |
+20
-19
@@ -49,2 +49,3 @@ --- | ||
| | `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 gov test-order` | Report where an implementation reached history ahead of the test covering it (`--json`) | | ||
@@ -62,21 +63,21 @@ | `aitk secrets scan` | Report credential-shaped values in the tree the package ships, keyed on issued values rather than on words (`--json`) | | ||
| | Domain | Subcommands | | ||
| | ----------- | ------------------------------------------------------------------------------------------- | | ||
| | `tooling` | `list`, `sync`, `ref`, `create`, `verify`, `inject`, `prune-gitignore` | | ||
| | `snippets` | `list`, `install`, `sync`, `create` | | ||
| | `standards` | `list`, `<name>` | | ||
| | `gov` | `list`, `install`, `sync`, `build`, `regen`, `test-order` | | ||
| | `claude` | `init`, `sync`, `seeds list`, `skills list`, `skills audit`, `skills drift`, `setup [dest]` | | ||
| | `wiki` | `init` | | ||
| | `design` | `render` | | ||
| | `slides` | `render`, `list` | | ||
| | `tasks` | `archive`, `validate` | | ||
| | `intake` | `list`, `answer` | | ||
| | `teach` | `list`, `open`, `resource`, `glossary` | | ||
| | `comments` | `scan` | | ||
| | `context` | `audit` | | ||
| | `markdown` | `audit` | | ||
| | `secrets` | `scan` | | ||
| | `deps` | `audit` | | ||
| | `audits` | `run`, `list` | | ||
| | Domain | Subcommands | | ||
| | ----------- | ----------------------------------------------------------------------------------------------------------- | | ||
| | `tooling` | `list`, `sync`, `ref`, `create`, `verify`, `inject`, `prune-gitignore` | | ||
| | `snippets` | `list`, `install`, `sync`, `create` | | ||
| | `standards` | `list`, `<name>` | | ||
| | `gov` | `list`, `install`, `sync`, `build`, `regen`, `test-order` | | ||
| | `claude` | `init`, `sync`, `seeds list`, `skills list`, `skills audit`, `skills drift`, `skills reach`, `setup [dest]` | | ||
| | `wiki` | `init` | | ||
| | `design` | `render` | | ||
| | `slides` | `render`, `list` | | ||
| | `tasks` | `archive`, `validate` | | ||
| | `intake` | `list`, `answer` | | ||
| | `teach` | `list`, `open`, `resource`, `glossary` | | ||
| | `comments` | `scan` | | ||
| | `context` | `audit` | | ||
| | `markdown` | `audit` | | ||
| | `secrets` | `scan` | | ||
| | `deps` | `audit` | | ||
| | `audits` | `run`, `list` | | ||
@@ -83,0 +84,0 @@ Common patterns: |
@@ -29,2 +29,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 | ||
| - [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 | ||
@@ -31,0 +32,0 @@ - [Tasks](tasks.md): Selecting a shipped task by stem or pull request, recording a number and closing an outcome, the refusal reasons, the board and backlog checks validate runs, and why the board root defaults to the main worktree |
+1
-1
| { | ||
| "name": "@erclx/aitk", | ||
| "type": "module", | ||
| "version": "3.0.0", | ||
| "version": "3.1.0", | ||
| "description": "Infrastructure and quality tooling for developer workflows", | ||
@@ -6,0 +6,0 @@ "license": "MIT", |
@@ -0,1 +1,2 @@ | ||
| import type { ReachRefusal } from '@/claude/skills-reach' | ||
| import type { AuditRefusal } from '@/deps/audit' | ||
@@ -289,2 +290,17 @@ import type { ValidateRefusal as RecordRefusal } from '@/records/validate' | ||
| /** | ||
| * Reads the unqualified citations alone, leaving the qualified ones out. | ||
| * | ||
| * A qualified citation is a repair that already landed, so folding the two | ||
| * together would report a corpus getting worse every time one is fixed. The | ||
| * key is still read rather than assumed present, since a record carrying | ||
| * neither array is a shape that moved rather than a catalog with nothing in it. | ||
| */ | ||
| function reachCounts(record: unknown): Record<string, number> | undefined { | ||
| const root = asObject(record) | ||
| if (root === undefined || !Array.isArray(root.qualified)) return undefined | ||
| return allOf({ unqualifiedCitations: lengthOf(root.unqualified) }) | ||
| } | ||
| function boardCounts(record: unknown): Record<string, number> | undefined { | ||
@@ -408,2 +424,22 @@ const root = asObject(record) | ||
| { | ||
| id: 'skills-reach', | ||
| label: 'Shipped citation reach', | ||
| argv: ['claude', 'skills', 'reach', '--json'], | ||
| // Reports rather than gates, on the split this file already draws. A body | ||
| // naming a toolkit path is sometimes correct, since the instruction may be | ||
| // meant for a session in this repository, so the verdict is a judgment and | ||
| // a push failing on one teaches a contributor to route around the stage. | ||
| gatingExits: [], | ||
| corpus: 'tracked', | ||
| // The one reason this verb refuses for, and it is an absence rather than a | ||
| // break. A tracked corpus normally allows nothing, since a tree that ships | ||
| // 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. | ||
| absentReasons: ['no-skills'] satisfies ReachRefusal[], | ||
| counts: reachCounts, | ||
| }, | ||
| { | ||
| id: 'tasks', | ||
@@ -410,0 +446,0 @@ label: 'Task board', |
+123
-4
@@ -27,2 +27,7 @@ import { existsSync } from 'node:fs' | ||
| import { | ||
| type ReachRefusal, | ||
| type ReachReport, | ||
| scanReach, | ||
| } from '@/claude/skills-reach' | ||
| import { | ||
| planSettings, | ||
@@ -74,2 +79,6 @@ readSettings, | ||
| interface SkillsReachOptions { | ||
| readonly json?: boolean | ||
| } | ||
| const SEEDED_FILES: readonly string[] = [ | ||
@@ -165,4 +174,4 @@ 'ARCHITECTURE.md', | ||
| .command('skills') | ||
| .description('Plugin skill catalog (list, audit, drift)') | ||
| .argument('[subcommand]', "One of 'list', 'audit', or 'drift'") | ||
| .description('Plugin skill catalog (list, audit, drift, reach)') | ||
| .argument('[subcommand]', "One of 'list', 'audit', 'drift', or 'reach'") | ||
| .helpOption('-h, --help', 'Show this help message') | ||
@@ -173,4 +182,4 @@ .action((subcommand: string | undefined) => { | ||
| subcommand === undefined | ||
| ? "Missing subcommand. Use 'list', 'audit', or 'drift'." | ||
| : `Unknown subcommand: ${subcommand}. Use 'list', 'audit', or 'drift'.`, | ||
| ? "Missing subcommand. Use 'list', 'audit', 'drift', or 'reach'." | ||
| : `Unknown subcommand: ${subcommand}. Use 'list', 'audit', 'drift', or 'reach'.`, | ||
| ) | ||
@@ -267,2 +276,38 @@ outro() | ||
| }) | ||
| skills | ||
| .command('reach') | ||
| .description('Report shipped bodies citing a path no target receives') | ||
| .argument('[path]', 'Repository root, defaulting to the current directory') | ||
| .helpOption('-h, --help', 'Show this help message') | ||
| .option('--json', 'Add a machine-readable record on stdout') | ||
| .addHelpText( | ||
| 'after', | ||
| [ | ||
| '', | ||
| 'Scope:', | ||
| ' 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', | ||
| ' holds it. A path under src/, scripts/, or bare docs/ names the', | ||
| " reader's own tree and is not measured.", | ||
| '', | ||
| 'Exit codes:', | ||
| ' 0 every citation names the toolkit as the owner', | ||
| ' 1 refused, with the reason on stderr', | ||
| ' 2 at least one citation is unqualified', | ||
| '', | ||
| 'Reports rather than gates. A toolkit-scoped instruction is sometimes', | ||
| 'meant for a session in this repository, so the verdict is a reading', | ||
| 'and the repair is to name the owner in the sentence.', | ||
| '', | ||
| 'Examples:', | ||
| ' aitk claude skills reach', | ||
| ' aitk claude skills reach --json', | ||
| '', | ||
| ].join('\n'), | ||
| ) | ||
| .action((path: string | undefined, opts: SkillsReachOptions) => { | ||
| process.exitCode = runSkillsReach(path, opts) | ||
| }) | ||
| } | ||
@@ -575,3 +620,77 @@ | ||
| /** What a reader does about the one way the corpus fails to build. */ | ||
| const REACH_REFUSALS: Record<ReachRefusal, string> = { | ||
| 'no-skills': | ||
| 'No claude/skills/ here, so this tree ships no plugin body to measure.', | ||
| } | ||
| /** | ||
| * 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`. | ||
| */ | ||
| function runSkillsReach( | ||
| path: string | undefined, | ||
| opts: SkillsReachOptions, | ||
| ): number { | ||
| const root = resolve(path ?? process.cwd()) | ||
| const report = scanReach(root) | ||
| if (report.kind === 'refused') { | ||
| frameError(REACH_REFUSALS[report.reason]) | ||
| if (opts.json) { | ||
| process.stdout.write( | ||
| `${JSON.stringify({ | ||
| root, | ||
| reason: report.reason, | ||
| message: REACH_REFUSALS[report.reason], | ||
| })}\n`, | ||
| ) | ||
| } | ||
| return 1 | ||
| } | ||
| intro('aitk claude skills reach') | ||
| reportReach(report) | ||
| outro() | ||
| if (opts.json) { | ||
| process.stdout.write( | ||
| `${JSON.stringify({ | ||
| root, | ||
| bodies: report.bodies, | ||
| qualified: report.qualified, | ||
| unqualified: report.unqualified, | ||
| })}\n`, | ||
| ) | ||
| } | ||
| return report.unqualified.length === 0 ? 0 : 2 | ||
| } | ||
| /** | ||
| * States the corpus on every run, including the clean one. A count of what | ||
| * failed reads as a verdict on the catalog unless the run also says how many | ||
| * bodies it opened and how many citations it already accepted. | ||
| */ | ||
| function reportReach(report: Extract<ReachReport, { kind: 'measured' }>): void { | ||
| logStep('Corpus') | ||
| logInfo( | ||
| `${plural(report.bodies, 'shipped file')} read, ${plural(report.qualified.length, 'citation')} already naming the toolkit as owner`, | ||
| ) | ||
| logStep('Unqualified citations') | ||
| if (report.unqualified.length === 0) { | ||
| logInfo('Every toolkit-owned path a shipped body cites names its owner.') | ||
| return | ||
| } | ||
| logWarn(plural(report.unqualified.length, 'citation')) | ||
| pipeOutput( | ||
| report.unqualified | ||
| .map((citation) => `${citation.file}:${citation.line} ${citation.path}`) | ||
| .join('\n'), | ||
| ) | ||
| } | ||
| /** | ||
| * States the bound on every run, including the run that names nothing. A report | ||
@@ -578,0 +697,0 @@ * listing only what moved reads as a verdict on what a session holds, and the |
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
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.
2469150
0.72%546
0.37%26673
1.17%125
0.81%