@erclx/aitk 3.39.0 → 3.41.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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-autoship/SKILL.md +6 -2
- package/claude/skills/claude-orchestrate/REQUIREMENT.md +10 -0
- package/claude/skills/claude-orchestrate/SKILL.md +12 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-poll.md +15 -1
- package/claude/skills/claude-orchestrate/scripts/watch.sh +110 -0
- package/claude/skills/claude-worker/REQUIREMENT.md +54 -0
- package/claude/skills/claude-worker/SKILL.md +58 -0
- package/docs/agents/commands.md +2 -2
- package/docs/agents/index.md +1 -1
- package/docs/agents/skills-reach.md +10 -3
- package/docs/ai-workflow.md +1 -1
- package/docs/operating-model.md +40 -8
- package/package.json +1 -1
- package/scripts/core/regen-claude-copies.sh +1 -25
- package/scripts/core/verify.sh +1 -1
- package/src/audits/catalog.ts +10 -7
- package/src/claude/cases/claude-workflow.ts +5 -0
- package/src/claude/skills-audit.ts +6 -0
- package/src/claude/skills-list.ts +41 -1
- package/src/claude/skills-rank.ts +93 -13
- package/src/claude/skills-reach.ts +51 -17
- package/src/commands/claude.ts +86 -34
- package/src/commands/gov.ts +3 -3
- package/standards/skill.md +1 -1
- package/standards/snippets.md +1 -2
|
@@ -118,6 +118,11 @@ export const CLAUDE_WORKFLOW_CASES: readonly SkillCase[] = [
|
|
|
118
118
|
prompt: 'Tell me the paint and layout cost of this page right now.',
|
|
119
119
|
expect: 'claude-ux-measure',
|
|
120
120
|
},
|
|
121
|
+
{
|
|
122
|
+
prompt:
|
|
123
|
+
'I am building this branch for another session. What am I on the hook for, and what is off limits?',
|
|
124
|
+
expect: 'claude-worker',
|
|
125
|
+
},
|
|
121
126
|
{
|
|
122
127
|
prompt: 'Get me set up in a fresh Claude Code worktree for this branch.',
|
|
123
128
|
expect: 'claude-worktree',
|
|
@@ -43,6 +43,12 @@ export interface SkillFinding {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export interface CorpusReport {
|
|
46
|
+
/**
|
|
47
|
+
* Left as `join` produced it, where `SkillsCorpus.rel` in `skills-list.ts`
|
|
48
|
+
* normalizes the same spelling to POSIX. This one is an existing JSON field
|
|
49
|
+
* a caller already reads, so the split holds until a branch reading this verb
|
|
50
|
+
* is the one to close it.
|
|
51
|
+
*/
|
|
46
52
|
readonly rel: string
|
|
47
53
|
readonly skills: number
|
|
48
54
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
|
+
import { CORPORA } from '@/claude/skills-audit'
|
|
3
4
|
|
|
4
5
|
const FRONTMATTER = /^---\n([\s\S]*?)\n---/
|
|
5
6
|
|
|
@@ -9,6 +10,38 @@ export interface SkillListing {
|
|
|
9
10
|
readonly requirement: boolean
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
export interface SkillsCorpus {
|
|
14
|
+
/**
|
|
15
|
+
* The corpus spelling in POSIX form, so a report reads the same on Windows.
|
|
16
|
+
* `CorpusReport.rel` in `skills-audit.ts` is the same spelling left as `join`
|
|
17
|
+
* produced it, so the two verbs disagree there. Normalizing it is a contract
|
|
18
|
+
* change on an existing JSON field and belongs to a branch reading the audit.
|
|
19
|
+
*/
|
|
20
|
+
readonly rel: string
|
|
21
|
+
/** The folder a listing reads, absolute against the root it was resolved at. */
|
|
22
|
+
readonly dir: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The skill corpus a measure reads at a given root: the shipped tree in this
|
|
27
|
+
* repository and a target's own `.claude/skills/` in a project that consumes
|
|
28
|
+
* it. `CORPORA` order settles a tree carrying both, so every reading taken
|
|
29
|
+
* here still comes from `claude/skills/`.
|
|
30
|
+
*
|
|
31
|
+
* Kept apart from `listSkills` deliberately. `src/counts/catalogs.ts` counts
|
|
32
|
+
* the shipped catalog through that function, so teaching it to read both
|
|
33
|
+
* corpora would move the reported total off the tree that installs and
|
|
34
|
+
* falsify every sentence in the corpus stating it.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveSkillsCorpus(root: string): SkillsCorpus | undefined {
|
|
37
|
+
for (const rel of CORPORA) {
|
|
38
|
+
const dir = join(root, rel)
|
|
39
|
+
if (existsSync(dir)) return { rel: rel.replaceAll('\\', '/'), dir }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
12
45
|
/**
|
|
13
46
|
* Enumerates the plugin skill catalog, which is the corpus under `claude/`
|
|
14
47
|
* rather than the internal skills under `.claude/`. Only the former installs
|
|
@@ -22,7 +55,14 @@ export interface SkillListing {
|
|
|
22
55
|
* `aitk claude skills audit` is what fails on it, across both corpora.
|
|
23
56
|
*/
|
|
24
57
|
export function listSkills(root: string): SkillListing[] {
|
|
25
|
-
|
|
58
|
+
return listSkillsAt(join(root, 'claude', 'skills'))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The same enumeration against a corpus folder the caller already resolved,
|
|
63
|
+
* which is what `resolveSkillsCorpus` hands a measure that reaches a target.
|
|
64
|
+
*/
|
|
65
|
+
export function listSkillsAt(skillsRoot: string): SkillListing[] {
|
|
26
66
|
if (!existsSync(skillsRoot)) return []
|
|
27
67
|
|
|
28
68
|
const paths = [
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs'
|
|
2
|
-
import {
|
|
3
|
-
import { listSkills } from '@/claude/skills-list'
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { listSkillsAt, resolveSkillsCorpus } from '@/claude/skills-list'
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
5
|
* Whether a prompt reaches the right skill, measured by TF-IDF cosine
|
|
@@ -14,8 +13,6 @@ import { listSkills } from '@/claude/skills-list'
|
|
|
14
13
|
* tracks on a cadence.
|
|
15
14
|
*/
|
|
16
15
|
|
|
17
|
-
const SKILLS_DIR = join('claude', 'skills')
|
|
18
|
-
|
|
19
16
|
const STOP = new Set(
|
|
20
17
|
'a about after again all also and any are as at be before but by can do does for from has have help how in into is it its just make not of on or our so that the then there these this to use used uses using want was what when where which who why with you your run'.split(
|
|
21
18
|
' ',
|
|
@@ -44,11 +41,25 @@ export interface Miss {
|
|
|
44
41
|
}
|
|
45
42
|
|
|
46
43
|
/** Why a measure produced no reading, which is never the same as a clean one. */
|
|
47
|
-
export type RankRefusal = 'no-skills'
|
|
44
|
+
export type RankRefusal = 'no-skills' | 'no-cases' | 'bad-cases'
|
|
45
|
+
|
|
46
|
+
/** The refusals a case corpus read produces, which the scan itself cannot raise. */
|
|
47
|
+
export type CaseCorpusRefusal = Extract<RankRefusal, 'no-cases' | 'bad-cases'>
|
|
48
|
+
|
|
49
|
+
export type CaseCorpusReport =
|
|
50
|
+
| { readonly kind: 'cases'; readonly cases: readonly SkillCase[] }
|
|
51
|
+
| {
|
|
52
|
+
readonly kind: 'refused'
|
|
53
|
+
readonly reason: CaseCorpusRefusal
|
|
54
|
+
/** What the caller has to change, which the reason alone never says. */
|
|
55
|
+
readonly detail: string
|
|
56
|
+
}
|
|
48
57
|
|
|
49
58
|
export type RankReport =
|
|
50
59
|
| {
|
|
51
60
|
readonly kind: 'measured'
|
|
61
|
+
/** The corpus spelling measured, since a root can carry either one. */
|
|
62
|
+
readonly corpus: string
|
|
52
63
|
readonly skills: number
|
|
53
64
|
readonly cases: number
|
|
54
65
|
readonly rank1: number
|
|
@@ -59,6 +70,67 @@ export type RankReport =
|
|
|
59
70
|
}
|
|
60
71
|
| { readonly kind: 'refused'; readonly reason: RankRefusal }
|
|
61
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Reads a project's own case corpus, which is JSON in the shape `SKILL_CASES`
|
|
75
|
+
* already holds. A target authors its own skills and its own vocabulary, so
|
|
76
|
+
* the toolkit corpus answers a question no other project asked.
|
|
77
|
+
*
|
|
78
|
+
* No standard stands behind the shape until a third project needs one, so
|
|
79
|
+
* every way the file fails is reported with what to change rather than
|
|
80
|
+
* measured against a spec. An empty array refuses for the reason a missing
|
|
81
|
+
* file does: a corpus of nothing scores 0 of 0 and reads as a clean pass.
|
|
82
|
+
*/
|
|
83
|
+
export function loadCaseCorpus(path: string): CaseCorpusReport {
|
|
84
|
+
if (!existsSync(path)) {
|
|
85
|
+
return { kind: 'refused', reason: 'no-cases', detail: path }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let parsed: unknown
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
93
|
+
return { kind: 'refused', reason: 'bad-cases', detail }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!Array.isArray(parsed)) {
|
|
97
|
+
return {
|
|
98
|
+
kind: 'refused',
|
|
99
|
+
reason: 'bad-cases',
|
|
100
|
+
detail: 'the file holds something other than an array of cases',
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const cases: SkillCase[] = []
|
|
105
|
+
for (const [index, entry] of parsed.entries()) {
|
|
106
|
+
const record = entry as Record<string, unknown> | null
|
|
107
|
+
if (
|
|
108
|
+
typeof record !== 'object' ||
|
|
109
|
+
record === null ||
|
|
110
|
+
typeof record.prompt !== 'string' ||
|
|
111
|
+
typeof record.expect !== 'string'
|
|
112
|
+
) {
|
|
113
|
+
return {
|
|
114
|
+
kind: 'refused',
|
|
115
|
+
reason: 'bad-cases',
|
|
116
|
+
detail: `case ${index} carries no string prompt or expect`,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
cases.push({ prompt: record.prompt, expect: record.expect })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (cases.length === 0) {
|
|
124
|
+
return {
|
|
125
|
+
kind: 'refused',
|
|
126
|
+
reason: 'bad-cases',
|
|
127
|
+
detail: 'the file holds no cases at all',
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { kind: 'cases', cases }
|
|
132
|
+
}
|
|
133
|
+
|
|
62
134
|
/**
|
|
63
135
|
* Every shipped skill's frontmatter description, read the way a prompt is
|
|
64
136
|
* matched against it: whole, including the quoted trigger phrases it states.
|
|
@@ -66,7 +138,13 @@ export type RankReport =
|
|
|
66
138
|
* and never wins a rank, so it is dropped rather than scored on nothing.
|
|
67
139
|
*/
|
|
68
140
|
export function loadCatalog(root: string): RankedSkill[] {
|
|
69
|
-
|
|
141
|
+
const corpus = resolveSkillsCorpus(root)
|
|
142
|
+
return corpus === undefined ? [] : loadCatalogAt(corpus.dir)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The same read against a corpus folder the caller already resolved. */
|
|
146
|
+
export function loadCatalogAt(skillsRoot: string): RankedSkill[] {
|
|
147
|
+
return listSkillsAt(skillsRoot)
|
|
70
148
|
.filter((skill) => skill.description !== '')
|
|
71
149
|
.map((skill) => ({ name: skill.name, description: skill.description }))
|
|
72
150
|
}
|
|
@@ -214,22 +292,24 @@ export function measureCases(
|
|
|
214
292
|
}
|
|
215
293
|
|
|
216
294
|
/**
|
|
217
|
-
* Reads
|
|
218
|
-
*
|
|
219
|
-
* the reach and audit verbs, so a linked worktree reads its own
|
|
295
|
+
* Reads whichever skill corpus the root carries and scores it against the
|
|
296
|
+
* given cases. Measures the cwd's catalog rather than the toolkit root,
|
|
297
|
+
* matching the reach and audit verbs, so a linked worktree reads its own
|
|
298
|
+
* branch and a target reads the skills it wrote itself.
|
|
220
299
|
*/
|
|
221
300
|
export function scanRank(
|
|
222
301
|
root: string,
|
|
223
302
|
cases: readonly SkillCase[],
|
|
224
303
|
): RankReport {
|
|
225
|
-
const
|
|
226
|
-
if (
|
|
304
|
+
const corpus = resolveSkillsCorpus(root)
|
|
305
|
+
if (corpus === undefined) return { kind: 'refused', reason: 'no-skills' }
|
|
227
306
|
|
|
228
|
-
const catalog =
|
|
307
|
+
const catalog = loadCatalogAt(corpus.dir)
|
|
229
308
|
const { rank1, top3, misses, unmeasurable } = measureCases(catalog, cases)
|
|
230
309
|
|
|
231
310
|
return {
|
|
232
311
|
kind: 'measured',
|
|
312
|
+
corpus: corpus.rel,
|
|
233
313
|
skills: catalog.length,
|
|
234
314
|
cases: cases.length,
|
|
235
315
|
rank1,
|
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* The tree that installs into a target. The internal skills under `.claude/`
|
|
6
|
-
* never leave this repository, so a citation there is read by a session that
|
|
7
|
-
* already has the file and cannot be a reach defect.
|
|
8
|
-
*/
|
|
9
|
-
const SHIPPED_SKILLS = join('claude', 'skills')
|
|
3
|
+
import { resolveSkillsCorpus } from '@/claude/skills-list'
|
|
10
4
|
|
|
11
5
|
/**
|
|
12
6
|
* The authoring roots this repository owns and no install channel delivers.
|
|
@@ -71,6 +65,8 @@ export type ReachRefusal = 'no-skills'
|
|
|
71
65
|
export type ReachReport =
|
|
72
66
|
| {
|
|
73
67
|
readonly kind: 'measured'
|
|
68
|
+
/** The corpus spelling read, since a root can carry either one. */
|
|
69
|
+
readonly corpus: string
|
|
74
70
|
/** Files opened, so a report can state what the verdict covers. */
|
|
75
71
|
readonly bodies: number
|
|
76
72
|
readonly qualified: readonly Citation[]
|
|
@@ -82,6 +78,25 @@ export function isQualified(line: string): boolean {
|
|
|
82
78
|
return QUALIFIER.test(line)
|
|
83
79
|
}
|
|
84
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The roots that belong to the toolkit rather than to the reader, read
|
|
83
|
+
* against the corpus being measured.
|
|
84
|
+
*
|
|
85
|
+
* A target's `.claude/context/` is the reader's own tree. A seed put the
|
|
86
|
+
* entries there and the project owns them afterward, so a body under
|
|
87
|
+
* `.claude/skills/` citing one names a file its reader holds. Measuring it
|
|
88
|
+
* would report a correct citation on every run, which is exactly why `src/`
|
|
89
|
+
* and `scripts/` are absent from the list above. In this repository the seed
|
|
90
|
+
* tree settles the same question through `readReceivedPaths`, which a target
|
|
91
|
+
* carrying no `tooling/` folder cannot answer at all, so the root comes out
|
|
92
|
+
* by corpus instead.
|
|
93
|
+
*/
|
|
94
|
+
export function authoringRootsFor(corpus: string): readonly string[] {
|
|
95
|
+
if (!corpus.startsWith('.claude/')) return AUTHORING_ROOTS
|
|
96
|
+
|
|
97
|
+
return AUTHORING_ROOTS.filter((root) => !root.startsWith('.claude/'))
|
|
98
|
+
}
|
|
99
|
+
|
|
85
100
|
/**
|
|
86
101
|
* Every path a seed lands on in a target, spelled the way a body would cite it.
|
|
87
102
|
*
|
|
@@ -113,7 +128,11 @@ export function readReceivedPaths(root: string): Set<string> {
|
|
|
113
128
|
* outgrows one file becomes `<domain>/`, which is still the entry the seed
|
|
114
129
|
* delivered, so reporting the split form would fail a target for growing.
|
|
115
130
|
*/
|
|
116
|
-
export function isToolkitOwned(
|
|
131
|
+
export function isToolkitOwned(
|
|
132
|
+
path: string,
|
|
133
|
+
received: Set<string>,
|
|
134
|
+
roots: readonly string[] = AUTHORING_ROOTS,
|
|
135
|
+
): boolean {
|
|
117
136
|
if (received.has(path)) return false
|
|
118
137
|
|
|
119
138
|
for (const seeded of received) {
|
|
@@ -121,7 +140,7 @@ export function isToolkitOwned(path: string, received: Set<string>): boolean {
|
|
|
121
140
|
if (stem !== seeded && path.startsWith(`${stem}/`)) return false
|
|
122
141
|
}
|
|
123
142
|
|
|
124
|
-
return
|
|
143
|
+
return roots.some((prefix) => path.startsWith(prefix))
|
|
125
144
|
}
|
|
126
145
|
|
|
127
146
|
/**
|
|
@@ -135,6 +154,7 @@ export function citationsIn(
|
|
|
135
154
|
file: string,
|
|
136
155
|
text: string,
|
|
137
156
|
received: Set<string>,
|
|
157
|
+
roots: readonly string[] = AUTHORING_ROOTS,
|
|
138
158
|
): Citation[] {
|
|
139
159
|
const citations: Citation[] = []
|
|
140
160
|
|
|
@@ -144,7 +164,7 @@ export function citationsIn(
|
|
|
144
164
|
for (const match of line.matchAll(TOKEN)) {
|
|
145
165
|
const path = match[1]
|
|
146
166
|
if (!CONCRETE.test(path) || !path.includes('/')) continue
|
|
147
|
-
if (!isToolkitOwned(path, received)) continue
|
|
167
|
+
if (!isToolkitOwned(path, received, roots)) continue
|
|
148
168
|
|
|
149
169
|
citations.push({ file, line: index + 1, path, qualified })
|
|
150
170
|
}
|
|
@@ -154,20 +174,27 @@ export function citationsIn(
|
|
|
154
174
|
}
|
|
155
175
|
|
|
156
176
|
/**
|
|
157
|
-
* Reads every
|
|
177
|
+
* Reads every body in the root's skill corpus for a path its reader cannot
|
|
178
|
+
* open.
|
|
158
179
|
*
|
|
159
180
|
* A citation of a path this repository does not hold is dropped rather than
|
|
160
181
|
* reported. The measure asks whether a claim true here is false in a target,
|
|
161
182
|
* and a path true in neither is a different defect that `aitk context audit`
|
|
162
183
|
* already reports against its own corpus.
|
|
184
|
+
*
|
|
185
|
+
* `resolveSkillsCorpus` prefers `claude/skills/`, which is the tree that
|
|
186
|
+
* installs into a target, so this repository's own reading is the one it
|
|
187
|
+
* always was. A project carrying `.claude/skills/` alone has no shipped tree
|
|
188
|
+
* and its own skills are the whole corpus a reader there opens.
|
|
163
189
|
*/
|
|
164
190
|
export function scanReach(root: string): ReachReport {
|
|
165
|
-
const
|
|
166
|
-
if (
|
|
191
|
+
const corpus = resolveSkillsCorpus(root)
|
|
192
|
+
if (corpus === undefined) return { kind: 'refused', reason: 'no-skills' }
|
|
167
193
|
|
|
168
194
|
const received = readReceivedPaths(root)
|
|
195
|
+
const roots = authoringRootsFor(corpus.rel)
|
|
169
196
|
const files = [
|
|
170
|
-
...new Bun.Glob('**/*.md').scanSync({ cwd:
|
|
197
|
+
...new Bun.Glob('**/*.md').scanSync({ cwd: corpus.dir, onlyFiles: true }),
|
|
171
198
|
].sort()
|
|
172
199
|
|
|
173
200
|
const qualified: Citation[] = []
|
|
@@ -175,12 +202,13 @@ export function scanReach(root: string): ReachReport {
|
|
|
175
202
|
|
|
176
203
|
for (const file of files) {
|
|
177
204
|
const posix = file.replaceAll('\\', '/')
|
|
178
|
-
const text = readFileSync(join(
|
|
205
|
+
const text = readFileSync(join(corpus.dir, file), 'utf8')
|
|
179
206
|
|
|
180
207
|
for (const citation of citationsIn(
|
|
181
|
-
`${
|
|
208
|
+
`${corpus.rel}/${posix}`,
|
|
182
209
|
text,
|
|
183
210
|
received,
|
|
211
|
+
roots,
|
|
184
212
|
)) {
|
|
185
213
|
if (!existsSync(join(root, citation.path))) continue
|
|
186
214
|
|
|
@@ -189,5 +217,11 @@ export function scanReach(root: string): ReachReport {
|
|
|
189
217
|
}
|
|
190
218
|
}
|
|
191
219
|
|
|
192
|
-
return {
|
|
220
|
+
return {
|
|
221
|
+
kind: 'measured',
|
|
222
|
+
corpus: corpus.rel,
|
|
223
|
+
bodies: files.length,
|
|
224
|
+
qualified,
|
|
225
|
+
unqualified,
|
|
226
|
+
}
|
|
193
227
|
}
|
package/src/commands/claude.ts
CHANGED
|
@@ -36,9 +36,11 @@ import {
|
|
|
36
36
|
} from '@/claude/skills-reach'
|
|
37
37
|
import { SKILL_CASES } from '@/claude/cases/all'
|
|
38
38
|
import {
|
|
39
|
+
loadCaseCorpus,
|
|
39
40
|
type RankRefusal,
|
|
40
41
|
type RankReport,
|
|
41
42
|
scanRank,
|
|
43
|
+
type SkillCase,
|
|
42
44
|
} from '@/claude/skills-rank'
|
|
43
45
|
import {
|
|
44
46
|
planSettings,
|
|
@@ -93,6 +95,7 @@ interface SkillsReachOptions {
|
|
|
93
95
|
|
|
94
96
|
interface SkillsRankOptions {
|
|
95
97
|
readonly json?: boolean
|
|
98
|
+
readonly cases?: string
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
interface RoutingOptions {
|
|
@@ -331,8 +334,8 @@ export function register(program: Command): void {
|
|
|
331
334
|
|
|
332
335
|
skills
|
|
333
336
|
.command('reach')
|
|
334
|
-
.description('Report
|
|
335
|
-
.argument('[path]', '
|
|
337
|
+
.description('Report skill bodies citing a path no target receives')
|
|
338
|
+
.argument('[path]', 'Project root, defaulting to the current directory')
|
|
336
339
|
.helpOption('-h, --help', 'Show this help message')
|
|
337
340
|
.option('--json', 'Add a machine-readable record on stdout')
|
|
338
341
|
.addHelpText(
|
|
@@ -341,10 +344,12 @@ export function register(program: Command): void {
|
|
|
341
344
|
'',
|
|
342
345
|
'Scope:',
|
|
343
346
|
' Every markdown file under claude/skills/, which is the tree that',
|
|
344
|
-
' installs into a target
|
|
345
|
-
'
|
|
347
|
+
' installs into a target, or under .claude/skills/ in a project',
|
|
348
|
+
' carrying that corpus alone. A cited path counts when it sits under',
|
|
349
|
+
' an authoring root no install channel delivers and the project',
|
|
346
350
|
' holds it. A path under src/, scripts/, or bare docs/ names the',
|
|
347
|
-
" reader's own tree and is not measured.",
|
|
351
|
+
" reader's own tree and is not measured, and .claude/context/ joins",
|
|
352
|
+
" them when the corpus read is a project's own.",
|
|
348
353
|
'',
|
|
349
354
|
'Exit codes:',
|
|
350
355
|
' 0 every citation names the toolkit as the owner',
|
|
@@ -358,6 +363,7 @@ export function register(program: Command): void {
|
|
|
358
363
|
'Examples:',
|
|
359
364
|
' aitk claude skills reach',
|
|
360
365
|
' aitk claude skills reach --json',
|
|
366
|
+
' aitk claude skills reach ~/repos/my-project',
|
|
361
367
|
'',
|
|
362
368
|
].join('\n'),
|
|
363
369
|
)
|
|
@@ -367,21 +373,33 @@ export function register(program: Command): void {
|
|
|
367
373
|
|
|
368
374
|
skills
|
|
369
375
|
.command('rank')
|
|
370
|
-
.description('Score
|
|
371
|
-
.argument('[path]', '
|
|
376
|
+
.description('Score a skill catalog against a routing case corpus')
|
|
377
|
+
.argument('[path]', 'Project root, defaulting to the current directory')
|
|
372
378
|
.helpOption('-h, --help', 'Show this help message')
|
|
373
379
|
.option('--json', 'Add a machine-readable record on stdout')
|
|
380
|
+
.option(
|
|
381
|
+
'--cases <path>',
|
|
382
|
+
"A project's own case corpus as JSON, replacing the toolkit's",
|
|
383
|
+
)
|
|
374
384
|
.addHelpText(
|
|
375
385
|
'after',
|
|
376
386
|
[
|
|
377
387
|
'',
|
|
378
388
|
'Scope:',
|
|
379
|
-
' TF-IDF cosine similarity over every
|
|
380
|
-
'
|
|
381
|
-
'
|
|
382
|
-
'
|
|
383
|
-
'
|
|
384
|
-
'
|
|
389
|
+
' TF-IDF cosine similarity over every SKILL.md frontmatter',
|
|
390
|
+
' description under claude/skills/, or under .claude/skills/ in a',
|
|
391
|
+
' project carrying that corpus alone, scored against the',
|
|
392
|
+
' hand-authored corpus at src/claude/cases/. A necessary condition',
|
|
393
|
+
' rather than a report of real routing behavior: it asks whether the',
|
|
394
|
+
' descriptions are separable by the words they use, and Claude Code',
|
|
395
|
+
' does not route this way.',
|
|
396
|
+
'',
|
|
397
|
+
'The case corpus:',
|
|
398
|
+
' --cases takes a JSON array of { "prompt", "expect" } objects, the',
|
|
399
|
+
' shape src/claude/cases/ already holds, where expect is a skill',
|
|
400
|
+
" folder name. A project's own skills need its own prompts, so the",
|
|
401
|
+
' toolkit corpus is not a default anything else can measure against.',
|
|
402
|
+
' No standard stands behind the file until a third project needs one.',
|
|
385
403
|
'',
|
|
386
404
|
'Exit codes:',
|
|
387
405
|
' 0 the catalog was read, whether or not a case missed rank one',
|
|
@@ -394,6 +412,7 @@ export function register(program: Command): void {
|
|
|
394
412
|
'Examples:',
|
|
395
413
|
' aitk claude skills rank',
|
|
396
414
|
' aitk claude skills rank --json',
|
|
415
|
+
' aitk claude skills rank ~/repos/my-project --cases cases.json',
|
|
397
416
|
'',
|
|
398
417
|
].join('\n'),
|
|
399
418
|
)
|
|
@@ -794,12 +813,14 @@ function reportRouting(
|
|
|
794
813
|
/** What a reader does about the one way the corpus fails to build. */
|
|
795
814
|
const REACH_REFUSALS: Record<ReachRefusal, string> = {
|
|
796
815
|
'no-skills':
|
|
797
|
-
'
|
|
816
|
+
'Neither claude/skills/ nor .claude/skills/ here, so this project carries no skill body to measure.',
|
|
798
817
|
}
|
|
799
818
|
|
|
800
819
|
/**
|
|
801
820
|
* Measures the cwd rather than the toolkit root, matching the audit and drift
|
|
802
|
-
* verbs, so a linked worktree reads its own branch instead of `main
|
|
821
|
+
* verbs, so a linked worktree reads its own branch instead of `main`, and a
|
|
822
|
+
* target carrying `.claude/skills/` alone is in scope the way the audit
|
|
823
|
+
* already has it.
|
|
803
824
|
*/
|
|
804
825
|
function runSkillsReach(
|
|
805
826
|
path: string | undefined,
|
|
@@ -830,6 +851,7 @@ function runSkillsReach(
|
|
|
830
851
|
process.stdout.write(
|
|
831
852
|
`${JSON.stringify({
|
|
832
853
|
root,
|
|
854
|
+
corpus: report.corpus,
|
|
833
855
|
bodies: report.bodies,
|
|
834
856
|
qualified: report.qualified,
|
|
835
857
|
unqualified: report.unqualified,
|
|
@@ -848,7 +870,7 @@ function runSkillsReach(
|
|
|
848
870
|
function reportReach(report: Extract<ReachReport, { kind: 'measured' }>): void {
|
|
849
871
|
logStep('Corpus')
|
|
850
872
|
logInfo(
|
|
851
|
-
`${plural(report.bodies, '
|
|
873
|
+
`${report.corpus}: ${plural(report.bodies, 'file')} read, ${plural(report.qualified.length, 'citation')} already naming the toolkit as owner`,
|
|
852
874
|
)
|
|
853
875
|
|
|
854
876
|
logStep('Unqualified citations')
|
|
@@ -865,37 +887,44 @@ function reportReach(report: Extract<ReachReport, { kind: 'measured' }>): void {
|
|
|
865
887
|
)
|
|
866
888
|
}
|
|
867
889
|
|
|
868
|
-
/** What a reader does about
|
|
890
|
+
/** What a reader does about each way the measure fails to build. */
|
|
869
891
|
const RANK_REFUSALS: Record<RankRefusal, string> = {
|
|
870
892
|
'no-skills':
|
|
871
|
-
'
|
|
893
|
+
'Neither claude/skills/ nor .claude/skills/ here, so this project carries no skill body to measure.',
|
|
894
|
+
'no-cases': 'No case corpus at the path given to --cases.',
|
|
895
|
+
'bad-cases':
|
|
896
|
+
'The case corpus is not a JSON array of { "prompt", "expect" } objects.',
|
|
872
897
|
}
|
|
873
898
|
|
|
874
899
|
/**
|
|
875
900
|
* Measures the cwd rather than the toolkit root, matching the reach and audit
|
|
876
|
-
* verbs, so a linked worktree reads its own branch instead of `main
|
|
877
|
-
*
|
|
878
|
-
*
|
|
901
|
+
* verbs, so a linked worktree reads its own branch instead of `main`, and a
|
|
902
|
+
* target carrying `.claude/skills/` alone is in scope.
|
|
903
|
+
*
|
|
904
|
+
* The toolkit's own cases are the default and answer for this catalog alone.
|
|
905
|
+
* A project measuring its own skills supplies its own prompts through
|
|
906
|
+
* `--cases`, since a corpus written against skills it did not author scores
|
|
907
|
+
* vocabulary it never uses.
|
|
879
908
|
*/
|
|
880
909
|
function runSkillsRank(
|
|
881
910
|
path: string | undefined,
|
|
882
911
|
opts: SkillsRankOptions,
|
|
883
912
|
): number {
|
|
884
913
|
const root = resolve(path ?? process.cwd())
|
|
885
|
-
const report = scanRank(root, SKILL_CASES)
|
|
886
914
|
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
root,
|
|
893
|
-
reason: report.reason,
|
|
894
|
-
message: RANK_REFUSALS[report.reason],
|
|
895
|
-
})}\n`,
|
|
896
|
-
)
|
|
915
|
+
let cases: readonly SkillCase[] = SKILL_CASES
|
|
916
|
+
if (opts.cases !== undefined) {
|
|
917
|
+
const corpus = loadCaseCorpus(resolve(opts.cases))
|
|
918
|
+
if (corpus.kind === 'refused') {
|
|
919
|
+
return refuseRank(root, corpus.reason, corpus.detail, opts)
|
|
897
920
|
}
|
|
898
|
-
|
|
921
|
+
cases = corpus.cases
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const report = scanRank(root, cases)
|
|
925
|
+
|
|
926
|
+
if (report.kind === 'refused') {
|
|
927
|
+
return refuseRank(root, report.reason, '', opts)
|
|
899
928
|
}
|
|
900
929
|
|
|
901
930
|
intro('aitk claude skills rank')
|
|
@@ -906,6 +935,7 @@ function runSkillsRank(
|
|
|
906
935
|
process.stdout.write(
|
|
907
936
|
`${JSON.stringify({
|
|
908
937
|
root,
|
|
938
|
+
corpus: report.corpus,
|
|
909
939
|
skills: report.skills,
|
|
910
940
|
cases: report.cases,
|
|
911
941
|
rank1: report.rank1,
|
|
@@ -919,6 +949,28 @@ function runSkillsRank(
|
|
|
919
949
|
return 0
|
|
920
950
|
}
|
|
921
951
|
|
|
952
|
+
/**
|
|
953
|
+
* Carries the detail beside the reason, since three refusals share one verb
|
|
954
|
+
* and only one of them names a path the caller can correct without it.
|
|
955
|
+
*/
|
|
956
|
+
function refuseRank(
|
|
957
|
+
root: string,
|
|
958
|
+
reason: RankRefusal,
|
|
959
|
+
detail: string,
|
|
960
|
+
opts: SkillsRankOptions,
|
|
961
|
+
): number {
|
|
962
|
+
const message = RANK_REFUSALS[reason]
|
|
963
|
+
frameError(detail === '' ? message : `${message} ${detail}`)
|
|
964
|
+
|
|
965
|
+
if (opts.json) {
|
|
966
|
+
process.stdout.write(
|
|
967
|
+
`${JSON.stringify({ root, reason, message, detail })}\n`,
|
|
968
|
+
)
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
return 1
|
|
972
|
+
}
|
|
973
|
+
|
|
922
974
|
/**
|
|
923
975
|
* States the corpus and both counts on every run, including a clean one. A
|
|
924
976
|
* miss list alone reads as a verdict on the catalog unless the run also says
|
|
@@ -927,7 +979,7 @@ function runSkillsRank(
|
|
|
927
979
|
function reportRank(report: Extract<RankReport, { kind: 'measured' }>): void {
|
|
928
980
|
logStep('Corpus')
|
|
929
981
|
logInfo(
|
|
930
|
-
`${plural(report.skills, 'skill')} scored against ${plural(report.cases, 'case')}`,
|
|
982
|
+
`${report.corpus}: ${plural(report.skills, 'skill')} scored against ${plural(report.cases, 'case')}`,
|
|
931
983
|
)
|
|
932
984
|
|
|
933
985
|
logStep('Score')
|
package/src/commands/gov.ts
CHANGED
|
@@ -774,9 +774,9 @@ function runList(opts: ListOptions): number {
|
|
|
774
774
|
}
|
|
775
775
|
|
|
776
776
|
/**
|
|
777
|
-
* Silent on success so the consumed-copy stage that calls it stays
|
|
778
|
-
* the
|
|
779
|
-
*
|
|
777
|
+
* Silent on success so the consumed-copy stage that calls it stays quiet. It is
|
|
778
|
+
* the only work that stage does now. The installed set is readable on disk, so
|
|
779
|
+
* printing it would only add noise to every `bun run check`.
|
|
780
780
|
*/
|
|
781
781
|
async function runRegen(opts: RegenOptions): Promise<number> {
|
|
782
782
|
const result = await regenConsumedRules(resolve(opts.root ?? PROJECT_ROOT))
|
package/standards/skill.md
CHANGED
|
@@ -91,7 +91,7 @@ allowed-tools: <tools required>
|
|
|
91
91
|
|
|
92
92
|
- Skill is a folder named in kebab-case containing `SKILL.md` (required), `REQUIREMENT.md` (required), `EVAL.md` (optional), `scripts/` (optional), `references/` (optional), `assets/` (optional)
|
|
93
93
|
- Name a file sitting directly in the skill folder in capitals and a bundled folder in lowercase, so the parts a reader opens are distinct from the ones a skill loads
|
|
94
|
-
- `EVAL.md` holds prompts and a judging rubric a person runs by hand,
|
|
94
|
+
- `EVAL.md` holds prompts and a judging rubric a person runs by hand, which is the path open to everyone. `claude plugin eval` is the automated one and it is gated in early access, refusing before it parses a target, so write the hand-run rubric and know that the runner exists rather than that it does not
|
|
95
95
|
- `SKILL.md` must start with YAML frontmatter between `---` delimiters
|
|
96
96
|
- No `README.md` inside the skill folder
|
|
97
97
|
- No spaces, capitals, or underscores in folder or skill name
|
package/standards/snippets.md
CHANGED
|
@@ -33,8 +33,7 @@ Overlapping a skill that does the same job is not disqualifying on its own. A sn
|
|
|
33
33
|
## Invocation channels
|
|
34
34
|
|
|
35
35
|
- Chrome extension: type `>slug` in a supported chat UI (claude.ai, gemini.google.com) to insert the snippet text inline
|
|
36
|
-
- Claude Code terminal: prefix the
|
|
37
|
-
- Snippets install preserving the source folder structure. A snippet at `claude/edit.md` installs as `.claude/snippets/claude/edit.md` and is invoked as `@.claude/snippets/claude/edit`
|
|
36
|
+
- Claude Code terminal: prefix the plugin path with `@` (e.g. `@claude/snippets/claude/feature-recap`). `claude/snippets` is a symlink to the authoring root, so the folder a snippet is written in is the folder it is invoked from
|
|
38
37
|
|
|
39
38
|
## Use patterns
|
|
40
39
|
|