@erclx/aitk 2.2.0 → 3.1.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": "2.2.0",
4
+ "version": "3.1.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -35,7 +35,7 @@ Read these on both paths, skipping any that do not exist:
35
35
  - `${CLAUDE_SKILL_DIR}/../../standards/markdown.md`: word, punctuation, and formatting constraints
36
36
  - The `write-human` skill: tone and sentence construction constraints
37
37
 
38
- 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.
38
+ 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.
39
39
 
40
40
  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.
41
41
 
@@ -43,6 +43,6 @@ The batching fails a fifth way. A folder holding thirty unread items put as thir
43
43
 
44
44
  - Filing a dump and writing the items, which is `claude-intake` and owns every other write into the folder
45
45
  - Promoting an answered item onto the board, which is `claude-tasks` and runs after the answers land
46
- - The item format, the answer contract, and retrieval, which `standards/intake.md` owns and this skill cites
46
+ - The item format, the answer contract, and retrieval, which the toolkit's `standards/intake.md` owns and this skill cites
47
47
  - The comparable answer slots in groundwork and feature plans, which carry their own contracts and are a separate measurement
48
48
  - 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.
@@ -15,7 +15,7 @@ A body written around a named tool fails a third way. A session on a surface car
15
15
 
16
16
  The opposite failure is the one that ships silently. A session that takes a preference decision itself produces work the operator did not ask for and never learns a choice was made, because nothing in the output says a fork was passed.
17
17
 
18
- `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.
18
+ `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.
19
19
 
20
20
  ## Must
21
21
 
@@ -40,6 +40,6 @@ The opposite failure is the one that ships silently. A session that takes a pref
40
40
  ## Out of scope
41
41
 
42
42
  - Making the ordinary judgment call, which is a pick plus a one-sentence tradeoff and needs no surface
43
- - The chat-side pick with no repository behind it, which `snippets/decision-help.md` covers and reaches a different reader
43
+ - The chat-side pick with no repository behind it, which the toolkit's `snippets/decision-help.md` covers and reaches a different reader
44
44
  - Writing the decision into a plan, task, or architecture record, which each owning standard governs and this skill only routes to
45
45
  - 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.
@@ -11,7 +11,7 @@ Read these files in parallel:
11
11
 
12
12
  - `${CLAUDE_SKILL_DIR}/references/branch.md`: branch format, valid types, and constraints
13
13
  - `${CLAUDE_SKILL_DIR}/references/pr.md`: structure, rules, and banned phrases
14
- - `${CLAUDE_SKILL_DIR}/references/labels.md`: label map format, matching, and the missing-label warning. Skip when the project has no `.claude/pr-labels.toml`.
14
+ - `${CLAUDE_SKILL_DIR}/references/labels.md`: label map format, matching, and the missing-label warning. Skip when the project has no `.claude/aitk/pr-labels.toml`.
15
15
  - `${CLAUDE_SKILL_DIR}/../../standards/markdown.md`: banned words, punctuation, and formatting for all generated text
16
16
  - The `write-human` skill: voice, rhythm, and sentence construction for all generated text
17
17
  - `${CLAUDE_SKILL_DIR}/../../standards/versioning.md`: phase label vs semver discipline
@@ -84,7 +84,7 @@ A detached HEAD gives `git branch --show-current` an empty result, which would r
84
84
 
85
85
  ### Labels
86
86
 
87
- Read `.claude/pr-labels.toml` from the project root. A project that has not declared a map gets no labels and no warning, since a label set this skill supplied would be a guess about that project's surfaces.
87
+ Read `.claude/aitk/pr-labels.toml` from the project root. A project that has not declared a map gets no labels and no warning, since a label set this skill supplied would be a guess about that project's surfaces.
88
88
 
89
89
  When the file resolves, match it against the name-only diff per `${CLAUDE_SKILL_DIR}/references/labels.md` and write the comma-separated result into `pr_labels` below. Leave `pr_labels` empty when no map resolves or no prefix matches, which skips the labelling command rather than running it against nothing.
90
90
 
@@ -17,7 +17,7 @@ Does not govern:
17
17
 
18
18
  ## Map format
19
19
 
20
- The map lives at `.claude/pr-labels.toml` in the project root. Each key under `[domains]` is a label name and its value is the list of path prefixes that earn it.
20
+ The map lives at `.claude/aitk/pr-labels.toml` in the project root. Each key under `[domains]` is a label name and its value is the list of path prefixes that earn it.
21
21
 
22
22
  ```toml
23
23
  [domains]
@@ -111,7 +111,7 @@ grep -l '^## Indexes' CLAUDE.md 2>/dev/null
111
111
 
112
112
  If the section is present, skip the seed silently.
113
113
 
114
- 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.
114
+ 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.
115
115
 
116
116
  ```markdown
117
117
  - When a folder has an `index.md`, check it before reading individual files in that folder.
@@ -147,5 +147,7 @@ Replace bracketed values with the values from this run. Drop the "Folders bootst
147
147
 
148
148
  ## Reference
149
149
 
150
- - `.claude/context/indexes.md`: system rationale, frontmatter contract, when to adopt
151
- - `docs/agents/indexes.md`: `aitk indexes regen` flags, exit codes, JSON shape
150
+ Both pages sit in the toolkit and install nowhere, so a target reads them there rather than in its own tree.
151
+
152
+ - The toolkit's `.claude/context/indexes.md`: system rationale, frontmatter contract, when to adopt
153
+ - The toolkit's `docs/agents/indexes.md`: `aitk indexes regen` flags, exit codes, JSON shape
@@ -5,7 +5,7 @@ description: Fetches a YouTube video transcript with metadata frontmatter into t
5
5
 
6
6
  # YouTube transcripts
7
7
 
8
- 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.
8
+ 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.
9
9
 
10
10
  ## Guards
11
11
 
@@ -22,11 +22,11 @@ aitk audits list --json
22
22
 
23
23
  ## What it runs
24
24
 
25
- 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.
25
+ 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.
26
26
 
27
27
  The verbs walk separate trees and share no state, so they run together. Measured on the authoring machine at twelve verbs, a run finished in 0.8 seconds of wall clock against 4.4 seconds of processor, which is under every other stage in `bun run check`. `aitk deps audit` is the one that changes that reading, since it reaches a network rather than a tree and its latency is the index's rather than this machine's.
28
28
 
29
- 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.
29
+ 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.
30
30
 
31
31
  Each is invoked as the CLI the caller is running rather than as a global `aitk`. A globally installed binary resolves to the main checkout no matter which worktree is executing, so the aggregate would measure a tree the branch never touched and report a pass over it.
32
32
 
@@ -46,6 +46,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
46
46
  | `aitk markdown audit` | Fail any markdown path on a banned character, word, or spelling, and report the structural checkpoints |
47
47
  | `aitk claude skills audit` | Report both skill corpora against the mechanical rules in `standards/skill.md` |
48
48
  | `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`) |
49
+ | `aitk claude skills reach` | Report the shipped bodies citing a toolkit path no target project receives, exiting 2 on an unqualified one |
49
50
  | `aitk gov test-order` | Report where an implementation reached history ahead of the test covering it (`--json`) |
50
51
  | `aitk secrets scan` | Report credential-shaped values in the tree the package ships, keyed on issued values rather than on words (`--json`) |
51
52
  | `aitk deps audit` | Report published advisories against the resolved dependency set, refusing rather than reporting clean when the index is unreachable (`--json`) |
@@ -58,25 +59,25 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
58
59
 
59
60
  Each domain exposes a consistent shape where applicable: `list`, `install`, `sync`, `create`.
60
61
 
61
- | Domain | Subcommands |
62
- | ----------- | ------------------------------------------------------------------------------------------- |
63
- | `tooling` | `list`, `sync`, `ref`, `create`, `verify`, `inject`, `prune-gitignore` |
64
- | `snippets` | `list`, `install`, `sync`, `create` |
65
- | `standards` | `list`, `<name>` |
66
- | `gov` | `list`, `install`, `sync`, `build`, `regen`, `test-order` |
67
- | `claude` | `init`, `sync`, `seeds list`, `skills list`, `skills audit`, `skills drift`, `setup [dest]` |
68
- | `wiki` | `init` |
69
- | `design` | `render` |
70
- | `slides` | `render`, `list` |
71
- | `tasks` | `archive`, `validate` |
72
- | `intake` | `list`, `answer` |
73
- | `teach` | `list`, `open`, `resource`, `glossary` |
74
- | `comments` | `scan` |
75
- | `context` | `audit` |
76
- | `markdown` | `audit` |
77
- | `secrets` | `scan` |
78
- | `deps` | `audit` |
79
- | `audits` | `run`, `list` |
62
+ | Domain | Subcommands |
63
+ | ----------- | ----------------------------------------------------------------------------------------------------------- |
64
+ | `tooling` | `list`, `sync`, `ref`, `create`, `verify`, `inject`, `prune-gitignore` |
65
+ | `snippets` | `list`, `install`, `sync`, `create` |
66
+ | `standards` | `list`, `<name>` |
67
+ | `gov` | `list`, `install`, `sync`, `build`, `regen`, `test-order` |
68
+ | `claude` | `init`, `sync`, `seeds list`, `skills list`, `skills audit`, `skills drift`, `skills reach`, `setup [dest]` |
69
+ | `wiki` | `init` |
70
+ | `design` | `render` |
71
+ | `slides` | `render`, `list` |
72
+ | `tasks` | `archive`, `validate` |
73
+ | `intake` | `list`, `answer` |
74
+ | `teach` | `list`, `open`, `resource`, `glossary` |
75
+ | `comments` | `scan` |
76
+ | `context` | `audit` |
77
+ | `markdown` | `audit` |
78
+ | `secrets` | `scan` |
79
+ | `deps` | `audit` |
80
+ | `audits` | `run`, `list` |
80
81
 
81
82
  Common patterns:
82
83
 
@@ -26,6 +26,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
26
26
  - [Scripting](scripting.md): The runtime catalogs that replace hardcoded names, what each carries, and a headless invocation per domain
27
27
  - [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
28
28
  - [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
29
+ - [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
29
30
  - [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
30
31
  - [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
31
32
  - [Teach](teach.md): Listing learning workspaces and the ordinal a new one takes, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, the refusal reasons, and why every write here runs through a verb
@@ -94,11 +94,13 @@ when the project authored it, or `drifted` when no stamp covers it.
94
94
 
95
95
  Use `--json` for the machine-readable report and `--exit-code` to fail a CI job. Orphaned
96
96
  files are excluded from that exit code, since a project-authored rule never
97
- converges. Attribution reads `.claude/aitk.json`, which every install and sync
98
- writes.
97
+ converges. Attribution reads `.claude/aitk/config.json`, which every install and
98
+ sync writes.
99
99
 
100
- A target installed before stamping shipped has no such file, and the report
101
- falls back to the toolkit's own git history. Installed content matching any
100
+ A target installed before stamping shipped has no such file, and neither does one
101
+ stamped before the file moved into `.claude/aitk/`, which is read as absent
102
+ rather than migrated. Both fall back to the toolkit's own git history.
103
+ Installed content matching any
102
104
  version that history ever published proves the file is untouched, so it reports
103
105
  `stale` naming the commit it came from, and content matching no published
104
106
  version stays `drifted`. A toolkit reached outside a git clone, which is what a
@@ -0,0 +1,57 @@
1
+ ---
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
4
+ ---
5
+
6
+ # Citation reach
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.
9
+
10
+ ```bash
11
+ aitk claude skills reach
12
+ aitk claude skills reach --json
13
+ ```
14
+
15
+ | Option | Behavior |
16
+ | -------- | ---------------------------------------------------------- |
17
+ | `--json` | Add a machine-readable record on stdout, keeping the frame |
18
+
19
+ ## The defect it reads for
20
+
21
+ 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.
22
+
23
+ ## What counts as a citation
24
+
25
+ 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.
26
+
27
+ - A placeholder such as `.claude/context/<domain>.md`, which names a shape rather than a file
28
+ - A path resolved through `${CLAUDE_SKILL_DIR}`, which is self-contained wherever the plugin loads
29
+ - A sibling named relatively, such as `references/labels.md`, which matches no authoring root and travels with the body
30
+
31
+ 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.
32
+
33
+ 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.
34
+
35
+ ## The ownership key
36
+
37
+ 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.
38
+
39
+ `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
+
41
+ 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
+
43
+ ## The qualifier
44
+
45
+ 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.
46
+
47
+ ```markdown
48
+ Read `.claude/context/indexes.md` from the toolkit if context on the system is needed.
49
+ ```
50
+
51
+ 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.
52
+
53
+ ## Exit codes
54
+
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.
56
+
57
+ 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.
@@ -124,7 +124,7 @@ The report opens by naming the binary running it. The installed version reads ag
124
124
 
125
125
  A `stale` file still matches what the toolkit installed, so the update is mechanical. A `customized` file carries local edits, so taking the upstream version is a decision and `aitk:claude-seed-sync` is the tool for it. A `stranded` file sits where an older toolkit installed it and the toolkit has since moved, which is what `aitk:migration-standards` handles.
126
126
 
127
- That attribution comes from `.claude/aitk.json`, a stamp every install and sync writes. Snippets and governance record a hash per installed file. Tooling records the stack chain it resolved instead, since its install runs no per-file walk to attribute.
127
+ That attribution comes from `.claude/aitk/config.json`, a stamp every install and sync writes. Snippets and governance record a hash per installed file. Tooling records the stack chain it resolved instead, since its install runs no per-file walk to attribute.
128
128
 
129
129
  Each domain holds its own toolkit commit, so syncing governance today does not move the revision snippets measures against, and each domain reports the upstream commits touching its own source path. Running any sync stamps that domain, and the report names the ones still unstamped.
130
130
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "2.2.0",
4
+ "version": "3.1.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -1,3 +1,4 @@
1
+ import type { ReachRefusal } from '@/claude/skills-reach'
1
2
  import type { AuditRefusal } from '@/deps/audit'
2
3
  import type { ValidateRefusal as RecordRefusal } from '@/records/validate'
3
4
  import type { ScanRefusal } from '@/secrets/scan'
@@ -286,6 +287,21 @@ function skillCounts(record: unknown): Record<string, number> | undefined {
286
287
  )
287
288
  }
288
289
 
290
+ /**
291
+ * Reads the unqualified citations alone, leaving the qualified ones out.
292
+ *
293
+ * A qualified citation is a repair that already landed, so folding the two
294
+ * together would report a corpus getting worse every time one is fixed. The
295
+ * key is still read rather than assumed present, since a record carrying
296
+ * neither array is a shape that moved rather than a catalog with nothing in it.
297
+ */
298
+ function reachCounts(record: unknown): Record<string, number> | undefined {
299
+ const root = asObject(record)
300
+ if (root === undefined || !Array.isArray(root.qualified)) return undefined
301
+
302
+ return allOf({ unqualifiedCitations: lengthOf(root.unqualified) })
303
+ }
304
+
289
305
  function boardCounts(record: unknown): Record<string, number> | undefined {
290
306
  const root = asObject(record)
291
307
  if (root === undefined) return undefined
@@ -403,6 +419,26 @@ export const AUDITS: readonly AuditSpec[] = [
403
419
  corpus: 'tracked',
404
420
  counts: skillCounts,
405
421
  },
422
+ {
423
+ id: 'skills-reach',
424
+ label: 'Shipped citation reach',
425
+ argv: ['claude', 'skills', 'reach', '--json'],
426
+ // Reports rather than gates, on the split this file already draws. A body
427
+ // naming a toolkit path is sometimes correct, since the instruction may be
428
+ // meant for a session in this repository, so the verdict is a judgment and
429
+ // a push failing on one teaches a contributor to route around the stage.
430
+ gatingExits: [],
431
+ corpus: 'tracked',
432
+ // The one reason this verb refuses for, and it is an absence rather than a
433
+ // break. A tracked corpus normally allows nothing, since a tree that ships
434
+ // to targets and cannot be found is a broken checkout, and this is the
435
+ // second exception on the same test the secret scan takes: no target holds
436
+ // `claude/skills/`, so without the allowance every project installing this
437
+ // CLI reports the verb unmeasured on every run and never changes, which is
438
+ // the permanent signal the per-machine allowance exists against.
439
+ absentReasons: ['no-skills'] satisfies ReachRefusal[],
440
+ counts: reachCounts,
441
+ },
406
442
  {
407
443
  id: 'tasks',
408
444
  label: 'Task board',
@@ -0,0 +1,193 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
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')
10
+
11
+ /**
12
+ * The authoring roots this repository owns and no install channel delivers.
13
+ *
14
+ * Every entry is a folder a target never holds under that spelling. Standards
15
+ * install nowhere and are reached through the plugin corpus, rules install
16
+ * under `.claude/rules/`, snippets under `.claude/snippets/`, and the rest are
17
+ * this repository's own source, docs, and catalogs.
18
+ *
19
+ * `src/`, `scripts/`, and bare `docs/` are deliberately absent. A body naming
20
+ * one of those is describing the reader's own tree, so listing them would
21
+ * report a correct citation on every run and bury the defect this measures.
22
+ * `docs/agents/` is the exception, being the CLI contract pages that exist
23
+ * here alone.
24
+ */
25
+ const AUTHORING_ROOTS = [
26
+ '.claude/context/',
27
+ 'claude/',
28
+ 'docs/agents/',
29
+ 'governance/',
30
+ 'internal/',
31
+ 'snippets/',
32
+ 'standards/',
33
+ 'tooling/',
34
+ 'wiki/',
35
+ ] as const
36
+
37
+ /**
38
+ * What marks a citation as deliberately naming this repository's own copy.
39
+ *
40
+ * The word rather than a notation, matching the three bodies that already
41
+ * spell it and the repair the plan settled on. A parser-visible syntax was the
42
+ * alternative and it invents a spelling for a handful of lines while leaving
43
+ * the shipped precedent unreadable.
44
+ */
45
+ const QUALIFIER = /toolkit/i
46
+
47
+ /** A backticked token, which is how every body spells a path it cites. */
48
+ const TOKEN = /`([^`\s]+)`/g
49
+
50
+ /**
51
+ * A path a reader could open, which is the only kind worth measuring.
52
+ *
53
+ * Requires an extension and a separator, and admits no `<`, `$`, or `*`. A
54
+ * body writes `.claude/context/<domain>.md` to name a shape rather than a
55
+ * file, and `${CLAUDE_SKILL_DIR}/../../standards/markdown.md` to resolve
56
+ * against the plugin root, which is self-contained by construction.
57
+ */
58
+ const CONCRETE = /^[.A-Za-z0-9_][A-Za-z0-9._/-]*\.[a-z]{1,4}$/
59
+
60
+ export interface Citation {
61
+ readonly file: string
62
+ /** One-based, matching the `file:line` form a reader clicks. */
63
+ readonly line: number
64
+ readonly path: string
65
+ readonly qualified: boolean
66
+ }
67
+
68
+ /** Why a scan produced no corpus, which is never the same as a clean one. */
69
+ export type ReachRefusal = 'no-skills'
70
+
71
+ export type ReachReport =
72
+ | {
73
+ readonly kind: 'measured'
74
+ /** Files opened, so a report can state what the verdict covers. */
75
+ readonly bodies: number
76
+ readonly qualified: readonly Citation[]
77
+ readonly unqualified: readonly Citation[]
78
+ }
79
+ | { readonly kind: 'refused'; readonly reason: ReachRefusal }
80
+
81
+ export function isQualified(line: string): boolean {
82
+ return QUALIFIER.test(line)
83
+ }
84
+
85
+ /**
86
+ * Every path a seed lands on in a target, spelled the way a body would cite it.
87
+ *
88
+ * Read off the seed tree rather than listed, so a seed added to any stack
89
+ * clears its own citations without this module being edited. Dotfiles are in
90
+ * scope because the whole seeded context corpus sits under `.claude/`.
91
+ */
92
+ export function readReceivedPaths(root: string): Set<string> {
93
+ const toolingRoot = join(root, 'tooling')
94
+ if (!existsSync(toolingRoot)) return new Set()
95
+
96
+ const received = new Set<string>()
97
+ for (const path of new Bun.Glob('*/seeds/**/*').scanSync({
98
+ cwd: toolingRoot,
99
+ onlyFiles: true,
100
+ dot: true,
101
+ })) {
102
+ const posix = path.replaceAll('\\', '/')
103
+ received.add(posix.replace(/^[^/]+\/seeds\//, ''))
104
+ }
105
+ return received
106
+ }
107
+
108
+ /**
109
+ * Whether a cited path is this repository's own rather than the reader's.
110
+ *
111
+ * A seeded path is disowned twice over: under its own name, and under the
112
+ * folder spelling it takes once a target splits the entry. A domain that
113
+ * outgrows one file becomes `<domain>/`, which is still the entry the seed
114
+ * delivered, so reporting the split form would fail a target for growing.
115
+ */
116
+ export function isToolkitOwned(path: string, received: Set<string>): boolean {
117
+ if (received.has(path)) return false
118
+
119
+ for (const seeded of received) {
120
+ const stem = seeded.replace(/\.md$/, '')
121
+ if (stem !== seeded && path.startsWith(`${stem}/`)) return false
122
+ }
123
+
124
+ return AUTHORING_ROOTS.some((prefix) => path.startsWith(prefix))
125
+ }
126
+
127
+ /**
128
+ * Every toolkit-owned path one shipped file cites, with the line's verdict.
129
+ *
130
+ * Existence is not checked here. A body may name a path this repository once
131
+ * held, and separating the shape test from the disk read is what lets the
132
+ * shape be tested without a tree on disk.
133
+ */
134
+ export function citationsIn(
135
+ file: string,
136
+ text: string,
137
+ received: Set<string>,
138
+ ): Citation[] {
139
+ const citations: Citation[] = []
140
+
141
+ for (const [index, line] of text.split('\n').entries()) {
142
+ const qualified = isQualified(line)
143
+
144
+ for (const match of line.matchAll(TOKEN)) {
145
+ const path = match[1]
146
+ if (!CONCRETE.test(path) || !path.includes('/')) continue
147
+ if (!isToolkitOwned(path, received)) continue
148
+
149
+ citations.push({ file, line: index + 1, path, qualified })
150
+ }
151
+ }
152
+
153
+ return citations
154
+ }
155
+
156
+ /**
157
+ * Reads every shipped body for a path its reader cannot open.
158
+ *
159
+ * A citation of a path this repository does not hold is dropped rather than
160
+ * reported. The measure asks whether a claim true here is false in a target,
161
+ * and a path true in neither is a different defect that `aitk context audit`
162
+ * already reports against its own corpus.
163
+ */
164
+ export function scanReach(root: string): ReachReport {
165
+ const skillsRoot = join(root, SHIPPED_SKILLS)
166
+ if (!existsSync(skillsRoot)) return { kind: 'refused', reason: 'no-skills' }
167
+
168
+ const received = readReceivedPaths(root)
169
+ const files = [
170
+ ...new Bun.Glob('**/*.md').scanSync({ cwd: skillsRoot, onlyFiles: true }),
171
+ ].sort()
172
+
173
+ const qualified: Citation[] = []
174
+ const unqualified: Citation[] = []
175
+
176
+ for (const file of files) {
177
+ const posix = file.replaceAll('\\', '/')
178
+ const text = readFileSync(join(skillsRoot, file), 'utf8')
179
+
180
+ for (const citation of citationsIn(
181
+ `${SHIPPED_SKILLS.replaceAll('\\', '/')}/${posix}`,
182
+ text,
183
+ received,
184
+ )) {
185
+ if (!existsSync(join(root, citation.path))) continue
186
+
187
+ if (citation.qualified) qualified.push(citation)
188
+ else unqualified.push(citation)
189
+ }
190
+ }
191
+
192
+ return { kind: 'measured', bodies: files.length, qualified, unqualified }
193
+ }
@@ -23,6 +23,11 @@ import {
23
23
  } from '@/claude/skills-audit'
24
24
  import { type DriftReport, readDrift } from '@/claude/skills-drift'
25
25
  import { listSkills } from '@/claude/skills-list'
26
+ import {
27
+ type ReachRefusal,
28
+ type ReachReport,
29
+ scanReach,
30
+ } from '@/claude/skills-reach'
26
31
  import {
27
32
  planSettings,
28
33
  readSettings,
@@ -70,6 +75,10 @@ interface SkillsDriftOptions {
70
75
  readonly json?: boolean
71
76
  }
72
77
 
78
+ interface SkillsReachOptions {
79
+ readonly json?: boolean
80
+ }
81
+
73
82
  const SEEDED_FILES: readonly string[] = [
74
83
  'ARCHITECTURE.md',
75
84
  'REQUIREMENTS.md',
@@ -160,15 +169,15 @@ export function register(program: Command): void {
160
169
 
161
170
  const skills = claude
162
171
  .command('skills')
163
- .description('Plugin skill catalog (list, audit, drift)')
164
- .argument('[subcommand]', "One of 'list', 'audit', or 'drift'")
172
+ .description('Plugin skill catalog (list, audit, drift, reach)')
173
+ .argument('[subcommand]', "One of 'list', 'audit', 'drift', or 'reach'")
165
174
  .helpOption('-h, --help', 'Show this help message')
166
175
  .action((subcommand: string | undefined) => {
167
176
  intro('aitk claude')
168
177
  logError(
169
178
  subcommand === undefined
170
- ? "Missing subcommand. Use 'list', 'audit', or 'drift'."
171
- : `Unknown subcommand: ${subcommand}. Use 'list', 'audit', or 'drift'.`,
179
+ ? "Missing subcommand. Use 'list', 'audit', 'drift', or 'reach'."
180
+ : `Unknown subcommand: ${subcommand}. Use 'list', 'audit', 'drift', or 'reach'.`,
172
181
  )
173
182
  outro()
174
183
  process.exitCode = 1
@@ -260,6 +269,42 @@ export function register(program: Command): void {
260
269
  .action(async (ref: string, opts: SkillsDriftOptions) => {
261
270
  process.exitCode = await runSkillsDrift(ref, opts)
262
271
  })
272
+
273
+ skills
274
+ .command('reach')
275
+ .description('Report shipped bodies citing a path no target receives')
276
+ .argument('[path]', 'Repository root, defaulting to the current directory')
277
+ .helpOption('-h, --help', 'Show this help message')
278
+ .option('--json', 'Add a machine-readable record on stdout')
279
+ .addHelpText(
280
+ 'after',
281
+ [
282
+ '',
283
+ 'Scope:',
284
+ ' Every markdown file under claude/skills/, which is the tree that',
285
+ ' installs into a target. A cited path counts when it sits under an',
286
+ ' authoring root no install channel delivers and this repository',
287
+ ' holds it. A path under src/, scripts/, or bare docs/ names the',
288
+ " reader's own tree and is not measured.",
289
+ '',
290
+ 'Exit codes:',
291
+ ' 0 every citation names the toolkit as the owner',
292
+ ' 1 refused, with the reason on stderr',
293
+ ' 2 at least one citation is unqualified',
294
+ '',
295
+ 'Reports rather than gates. A toolkit-scoped instruction is sometimes',
296
+ 'meant for a session in this repository, so the verdict is a reading',
297
+ 'and the repair is to name the owner in the sentence.',
298
+ '',
299
+ 'Examples:',
300
+ ' aitk claude skills reach',
301
+ ' aitk claude skills reach --json',
302
+ '',
303
+ ].join('\n'),
304
+ )
305
+ .action((path: string | undefined, opts: SkillsReachOptions) => {
306
+ process.exitCode = runSkillsReach(path, opts)
307
+ })
263
308
  }
264
309
 
265
310
  function succeed(message: string): number {
@@ -567,6 +612,80 @@ function reportSkew(skew: SkewReport): void {
567
612
  else logInfo(describeSkew(skew))
568
613
  }
569
614
 
615
+ /** What a reader does about the one way the corpus fails to build. */
616
+ const REACH_REFUSALS: Record<ReachRefusal, string> = {
617
+ 'no-skills':
618
+ 'No claude/skills/ here, so this tree ships no plugin body to measure.',
619
+ }
620
+
621
+ /**
622
+ * Measures the cwd rather than the toolkit root, matching the audit and drift
623
+ * verbs, so a linked worktree reads its own branch instead of `main`.
624
+ */
625
+ function runSkillsReach(
626
+ path: string | undefined,
627
+ opts: SkillsReachOptions,
628
+ ): number {
629
+ const root = resolve(path ?? process.cwd())
630
+ const report = scanReach(root)
631
+
632
+ if (report.kind === 'refused') {
633
+ frameError(REACH_REFUSALS[report.reason])
634
+ if (opts.json) {
635
+ process.stdout.write(
636
+ `${JSON.stringify({
637
+ root,
638
+ reason: report.reason,
639
+ message: REACH_REFUSALS[report.reason],
640
+ })}\n`,
641
+ )
642
+ }
643
+ return 1
644
+ }
645
+
646
+ intro('aitk claude skills reach')
647
+ reportReach(report)
648
+ outro()
649
+
650
+ if (opts.json) {
651
+ process.stdout.write(
652
+ `${JSON.stringify({
653
+ root,
654
+ bodies: report.bodies,
655
+ qualified: report.qualified,
656
+ unqualified: report.unqualified,
657
+ })}\n`,
658
+ )
659
+ }
660
+
661
+ return report.unqualified.length === 0 ? 0 : 2
662
+ }
663
+
664
+ /**
665
+ * States the corpus on every run, including the clean one. A count of what
666
+ * failed reads as a verdict on the catalog unless the run also says how many
667
+ * bodies it opened and how many citations it already accepted.
668
+ */
669
+ function reportReach(report: Extract<ReachReport, { kind: 'measured' }>): void {
670
+ logStep('Corpus')
671
+ logInfo(
672
+ `${plural(report.bodies, 'shipped file')} read, ${plural(report.qualified.length, 'citation')} already naming the toolkit as owner`,
673
+ )
674
+
675
+ logStep('Unqualified citations')
676
+ if (report.unqualified.length === 0) {
677
+ logInfo('Every toolkit-owned path a shipped body cites names its owner.')
678
+ return
679
+ }
680
+
681
+ logWarn(plural(report.unqualified.length, 'citation'))
682
+ pipeOutput(
683
+ report.unqualified
684
+ .map((citation) => `${citation.file}:${citation.line} ${citation.path}`)
685
+ .join('\n'),
686
+ )
687
+ }
688
+
570
689
  /**
571
690
  * States the bound on every run, including the run that names nothing. A report
572
691
  * listing only what moved reads as a verdict on what a session holds, and the
package/src/sync/stamp.ts CHANGED
@@ -53,7 +53,7 @@ export interface Stamp {
53
53
  }
54
54
 
55
55
  export function stampPath(target: string): string {
56
- return join(target, '.claude', 'aitk.json')
56
+ return join(target, '.claude', 'aitk', 'config.json')
57
57
  }
58
58
 
59
59
  export function hashContent(content: Buffer | string): string {