@erclx/aitk 3.0.0 → 3.2.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-design-extract/SKILL.md +1 -1
- package/claude/skills/claude-intake-answer/REQUIREMENT.md +1 -1
- package/claude/skills/decision-escalate/REQUIREMENT.md +2 -2
- package/claude/skills/git-pr/SKILL.md +16 -2
- package/claude/skills/git-pr/references/labels.md +26 -2
- package/claude/skills/setup-indexes/SKILL.md +5 -3
- package/claude/skills/youtube-transcripts/SKILL.md +1 -1
- package/docs/agents/audits.md +2 -2
- package/docs/agents/commands.md +22 -19
- package/docs/agents/index.md +2 -0
- package/docs/agents/install-and-sync.md +73 -5
- package/docs/agents/label-coverage.md +73 -0
- package/docs/agents/skills-reach.md +57 -0
- package/docs/target-projects.md +14 -2
- package/package.json +1 -1
- package/src/audits/catalog.ts +85 -0
- package/src/claude/skills-reach.ts +193 -0
- package/src/cli.ts +4 -0
- package/src/commands/claude.ts +123 -4
- package/src/commands/labels.ts +166 -0
- package/src/commands/sync.ts +13 -0
- package/src/git-files.ts +74 -0
- package/src/labels/audit.ts +82 -0
- package/src/labels/coverage.ts +79 -0
- package/src/labels/map.ts +101 -0
- package/src/sync/check.ts +167 -4
package/src/audits/catalog.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { ReachRefusal } from '@/claude/skills-reach'
|
|
1
2
|
import type { AuditRefusal } from '@/deps/audit'
|
|
3
|
+
import type { LabelAuditRefusal } from '@/labels/audit'
|
|
2
4
|
import type { ValidateRefusal as RecordRefusal } from '@/records/validate'
|
|
3
5
|
import type { ScanRefusal } from '@/secrets/scan'
|
|
4
6
|
import type { ValidateRefusal as BoardRefusal } from '@/tasks/validate'
|
|
@@ -286,6 +288,21 @@ function skillCounts(record: unknown): Record<string, number> | undefined {
|
|
|
286
288
|
)
|
|
287
289
|
}
|
|
288
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Reads the unqualified citations alone, leaving the qualified ones out.
|
|
293
|
+
*
|
|
294
|
+
* A qualified citation is a repair that already landed, so folding the two
|
|
295
|
+
* together would report a corpus getting worse every time one is fixed. The
|
|
296
|
+
* key is still read rather than assumed present, since a record carrying
|
|
297
|
+
* neither array is a shape that moved rather than a catalog with nothing in it.
|
|
298
|
+
*/
|
|
299
|
+
function reachCounts(record: unknown): Record<string, number> | undefined {
|
|
300
|
+
const root = asObject(record)
|
|
301
|
+
if (root === undefined || !Array.isArray(root.qualified)) return undefined
|
|
302
|
+
|
|
303
|
+
return allOf({ unqualifiedCitations: lengthOf(root.unqualified) })
|
|
304
|
+
}
|
|
305
|
+
|
|
289
306
|
function boardCounts(record: unknown): Record<string, number> | undefined {
|
|
290
307
|
const root = asObject(record)
|
|
291
308
|
if (root === undefined) return undefined
|
|
@@ -318,6 +335,27 @@ function advisoryCounts(record: unknown): Record<string, number> | undefined {
|
|
|
318
335
|
return counts
|
|
319
336
|
}
|
|
320
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Reads the uncovered paths alone, which is the half that is a finding.
|
|
340
|
+
*
|
|
341
|
+
* The declined paths are deliberately not counted. They are whichever declined
|
|
342
|
+
* rows this branch happened to touch rather than a measure of the map, so a
|
|
343
|
+
* clean trunk reads zero while the map declares eight, and the number would
|
|
344
|
+
* describe the branch rather than the decision.
|
|
345
|
+
*
|
|
346
|
+
* Retaining it also broke the verdict. `classify` reads a clean run as quiet
|
|
347
|
+
* only when every count is zero, so a branch touching one declined path exited
|
|
348
|
+
* 0 and still reported as carrying findings.
|
|
349
|
+
*/
|
|
350
|
+
function labelCoverageCounts(
|
|
351
|
+
record: unknown,
|
|
352
|
+
): Record<string, number> | undefined {
|
|
353
|
+
const root = asObject(record)
|
|
354
|
+
if (root === undefined) return undefined
|
|
355
|
+
|
|
356
|
+
return allOf({ uncovered: lengthOf(root.uncovered) })
|
|
357
|
+
}
|
|
358
|
+
|
|
321
359
|
function findingsOnly(record: unknown): Record<string, number> | undefined {
|
|
322
360
|
const root = asObject(record)
|
|
323
361
|
if (root === undefined) return undefined
|
|
@@ -403,6 +441,26 @@ export const AUDITS: readonly AuditSpec[] = [
|
|
|
403
441
|
corpus: 'tracked',
|
|
404
442
|
counts: skillCounts,
|
|
405
443
|
},
|
|
444
|
+
{
|
|
445
|
+
id: 'skills-reach',
|
|
446
|
+
label: 'Shipped citation reach',
|
|
447
|
+
argv: ['claude', 'skills', 'reach', '--json'],
|
|
448
|
+
// Reports rather than gates, on the split this file already draws. A body
|
|
449
|
+
// naming a toolkit path is sometimes correct, since the instruction may be
|
|
450
|
+
// meant for a session in this repository, so the verdict is a judgment and
|
|
451
|
+
// a push failing on one teaches a contributor to route around the stage.
|
|
452
|
+
gatingExits: [],
|
|
453
|
+
corpus: 'tracked',
|
|
454
|
+
// The one reason this verb refuses for, and it is an absence rather than a
|
|
455
|
+
// break. A tracked corpus normally allows nothing, since a tree that ships
|
|
456
|
+
// to targets and cannot be found is a broken checkout, and this is the
|
|
457
|
+
// second exception on the same test the secret scan takes: no target holds
|
|
458
|
+
// `claude/skills/`, so without the allowance every project installing this
|
|
459
|
+
// CLI reports the verb unmeasured on every run and never changes, which is
|
|
460
|
+
// the permanent signal the per-machine allowance exists against.
|
|
461
|
+
absentReasons: ['no-skills'] satisfies ReachRefusal[],
|
|
462
|
+
counts: reachCounts,
|
|
463
|
+
},
|
|
406
464
|
{
|
|
407
465
|
id: 'tasks',
|
|
408
466
|
label: 'Task board',
|
|
@@ -463,6 +521,33 @@ export const AUDITS: readonly AuditSpec[] = [
|
|
|
463
521
|
] satisfies ScanRefusal[],
|
|
464
522
|
counts: findingsOnly,
|
|
465
523
|
},
|
|
524
|
+
{
|
|
525
|
+
id: 'labels',
|
|
526
|
+
label: 'Pull request label coverage',
|
|
527
|
+
argv: ['labels', 'audit', '--json'],
|
|
528
|
+
// Reports rather than gates, on the split this file already draws. Whether
|
|
529
|
+
// an uncovered surface deserves a label is a judgment, and a push failing
|
|
530
|
+
// on one would ask a contributor to answer a question only the person who
|
|
531
|
+
// owns the surface can.
|
|
532
|
+
gatingExits: [],
|
|
533
|
+
// The map is committed, so every clone reads the same rows and a delta is
|
|
534
|
+
// shared. The changed set the rows are read against is the branch's, which
|
|
535
|
+
// is what makes a clean trunk report zero rather than nothing.
|
|
536
|
+
corpus: 'tracked',
|
|
537
|
+
// Only `uncovered` is retained, for the reason its extractor states.
|
|
538
|
+
//
|
|
539
|
+
// The one reason that means this project declares no surfaces to cover,
|
|
540
|
+
// which is the recorded decision that a project without a map is labelled
|
|
541
|
+
// silently. A tracked corpus normally allows nothing absent, and this is
|
|
542
|
+
// the second exception beside the secret scan rather than a default.
|
|
543
|
+
//
|
|
544
|
+
// The other three are deliberately left out. A map that will not parse, a
|
|
545
|
+
// map with no usable row, and a range git could not answer are each a
|
|
546
|
+
// corpus that exists and went unread, so calling any of them an absence
|
|
547
|
+
// would report a pass over a branch nobody measured.
|
|
548
|
+
absentReasons: ['no-map'] satisfies LabelAuditRefusal[],
|
|
549
|
+
counts: labelCoverageCounts,
|
|
550
|
+
},
|
|
466
551
|
{
|
|
467
552
|
id: 'deps',
|
|
468
553
|
label: 'Dependency advisories',
|
|
@@ -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
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { register as sessions } from '@/commands/sessions'
|
|
|
28
28
|
import { register as audits } from '@/commands/audits'
|
|
29
29
|
import { register as secrets } from '@/commands/secrets'
|
|
30
30
|
import { register as deps } from '@/commands/deps'
|
|
31
|
+
import { register as labels } from '@/commands/labels'
|
|
31
32
|
import { register as upgrade } from '@/commands/upgrade'
|
|
32
33
|
import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
|
|
33
34
|
import { palette } from '@/ui'
|
|
@@ -67,6 +68,7 @@ function showHelp(): void {
|
|
|
67
68
|
`${GREY}│${NC} sessions [cmd] ${GREY}# Resolve live sessions to worktree and branch (list)${NC}`,
|
|
68
69
|
`${GREY}│${NC} secrets [cmd] ${GREY}# Read the shipped tree for credential-shaped values (scan)${NC}`,
|
|
69
70
|
`${GREY}│${NC} deps [cmd] ${GREY}# Read the resolved dependency set for advisories (audit)${NC}`,
|
|
71
|
+
`${GREY}│${NC} labels [cmd] ${GREY}# Read a changed set against the pull request label map (audit)${NC}`,
|
|
70
72
|
`${GREY}│${NC} audits [cmd] ${GREY}# Run every health check as one set (run, list)${NC}`,
|
|
71
73
|
`${GREY}│${NC} upgrade ${GREY}# Reinstall the CLI globally with the manager that installed it${NC}`,
|
|
72
74
|
`${GREY}│${NC}`,
|
|
@@ -110,6 +112,7 @@ function showHelp(): void {
|
|
|
110
112
|
`${GREY}│${NC} aitk sessions list --json`,
|
|
111
113
|
`${GREY}│${NC} aitk secrets scan --json`,
|
|
112
114
|
`${GREY}│${NC} aitk deps audit --json`,
|
|
115
|
+
`${GREY}│${NC} aitk labels audit --json`,
|
|
113
116
|
`${GREY}│${NC} aitk audits run --json`,
|
|
114
117
|
`${GREY}│${NC} aitk upgrade --json`,
|
|
115
118
|
`${GREY}└${NC}`,
|
|
@@ -156,6 +159,7 @@ records(program)
|
|
|
156
159
|
sessions(program)
|
|
157
160
|
secrets(program)
|
|
158
161
|
deps(program)
|
|
162
|
+
labels(program)
|
|
159
163
|
audits(program)
|
|
160
164
|
upgrade(program)
|
|
161
165
|
|
package/src/commands/claude.ts
CHANGED
|
@@ -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 '
|
|
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 '
|
|
171
|
-
: `Unknown subcommand: ${subcommand}. Use 'list', 'audit', or '
|
|
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
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import type { Command } from 'commander'
|
|
3
|
+
import { type LabelAuditRefusal, auditLabels } from '@/labels/audit'
|
|
4
|
+
import { MAP_REL } from '@/labels/map'
|
|
5
|
+
import { intro, logInfo, logStep, logWarn, outro, plural } from '@/ui'
|
|
6
|
+
|
|
7
|
+
interface AuditOptions {
|
|
8
|
+
readonly base?: string
|
|
9
|
+
readonly root?: string
|
|
10
|
+
readonly json?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** What a reader does about each way the audit produced no reading. */
|
|
14
|
+
const REFUSALS: Record<LabelAuditRefusal, string> = {
|
|
15
|
+
// An answer rather than a fault. A project declaring no map is labelled
|
|
16
|
+
// silently by design, so a refusal reading as a break would make the map
|
|
17
|
+
// mandatory for every target.
|
|
18
|
+
'no-map': `No ${MAP_REL} here, so this project labels nothing and declares no surfaces to cover.`,
|
|
19
|
+
'unreadable-map': `${MAP_REL} is not valid TOML, so no row could be read.`,
|
|
20
|
+
'no-domains': `${MAP_REL} carries no usable row under [domains], so every path would read as uncovered.`,
|
|
21
|
+
'no-base': 'No base resolves against the trunk. Fetch origin or pass --base.',
|
|
22
|
+
'bad-base':
|
|
23
|
+
'The ref passed to --base resolves to no commit here. Pass one this tree carries.',
|
|
24
|
+
'unreadable-changes':
|
|
25
|
+
'git could not list what this branch changed, so the set is unknown.',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function register(program: Command): void {
|
|
29
|
+
const labels = program
|
|
30
|
+
.command('labels')
|
|
31
|
+
.description('Resolve a changed set against the pull request label map')
|
|
32
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
33
|
+
|
|
34
|
+
labels
|
|
35
|
+
.command('audit')
|
|
36
|
+
.description(
|
|
37
|
+
'Report the labels a changed set earns and the paths no row reaches',
|
|
38
|
+
)
|
|
39
|
+
.argument(
|
|
40
|
+
'[paths...]',
|
|
41
|
+
'Changed set to read, defaulting to the branch range',
|
|
42
|
+
)
|
|
43
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
44
|
+
.option('--base <ref>', 'Far side of the range, defaulting to the trunk')
|
|
45
|
+
.option('--root <path>', 'Repository to read, defaulting to the cwd')
|
|
46
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
47
|
+
.addHelpText(
|
|
48
|
+
'after',
|
|
49
|
+
[
|
|
50
|
+
'',
|
|
51
|
+
`Reads ${MAP_REL} and matches it prefix-anchored, which is the rule the`,
|
|
52
|
+
"map's own census was measured against. It reports and never gates,",
|
|
53
|
+
'because whether an uncovered surface deserves a label is a judgment.',
|
|
54
|
+
'',
|
|
55
|
+
'What it separates:',
|
|
56
|
+
' uncovered a surface no row reaches, which is a gap wanting a row',
|
|
57
|
+
' declined a path a [declined] row names, which is a decision already taken',
|
|
58
|
+
'',
|
|
59
|
+
'What it does not measure:',
|
|
60
|
+
' a prefix reaching no path, which is the map going stale from the other',
|
|
61
|
+
' side and a second measure rather than this one',
|
|
62
|
+
'',
|
|
63
|
+
'Exit codes:',
|
|
64
|
+
' 0 every changed path is labelled or declined',
|
|
65
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
66
|
+
' 2 at least one changed path is reached by no row',
|
|
67
|
+
'',
|
|
68
|
+
'Examples:',
|
|
69
|
+
' aitk labels audit',
|
|
70
|
+
' aitk labels audit --json',
|
|
71
|
+
' aitk labels audit --base origin/main',
|
|
72
|
+
' aitk labels audit src/cli.ts docs/index.md --json',
|
|
73
|
+
'',
|
|
74
|
+
].join('\n'),
|
|
75
|
+
)
|
|
76
|
+
.action(async (paths: string[], opts: AuditOptions) => {
|
|
77
|
+
process.exitCode = await runAudit(paths, opts)
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function runAudit(paths: string[], opts: AuditOptions): Promise<number> {
|
|
82
|
+
const root = resolve(opts.root ?? process.cwd())
|
|
83
|
+
const emitJson = opts.json ?? false
|
|
84
|
+
|
|
85
|
+
const report = await auditLabels(root, {
|
|
86
|
+
base: opts.base,
|
|
87
|
+
...(paths.length > 0 && { paths }),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
intro('aitk labels audit')
|
|
91
|
+
|
|
92
|
+
// The frame renders on stderr in both modes and the record goes to stdout
|
|
93
|
+
// alone, so an operator reading the terminal sees the refusal rather than a
|
|
94
|
+
// command that appeared to do nothing.
|
|
95
|
+
if (report.kind === 'refused') {
|
|
96
|
+
logStep('Refused')
|
|
97
|
+
logWarn(REFUSALS[report.reason])
|
|
98
|
+
outro()
|
|
99
|
+
|
|
100
|
+
if (emitJson) {
|
|
101
|
+
process.stdout.write(
|
|
102
|
+
`${JSON.stringify({
|
|
103
|
+
root,
|
|
104
|
+
reason: report.reason,
|
|
105
|
+
message: REFUSALS[report.reason],
|
|
106
|
+
})}\n`,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
return 1
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const { coverage } = report
|
|
113
|
+
|
|
114
|
+
logStep('Scope')
|
|
115
|
+
logInfo(
|
|
116
|
+
report.base === undefined
|
|
117
|
+
? `${plural(report.changed.length, 'path')} supplied by the caller`
|
|
118
|
+
: `${plural(report.changed.length, 'path')} changed since ${report.base.slice(0, 8)}`,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
logStep('Labels')
|
|
122
|
+
logInfo(
|
|
123
|
+
coverage.labels.length === 0
|
|
124
|
+
? 'this set earns no label'
|
|
125
|
+
: coverage.labels.join(', '),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
// Named rather than counted into the uncovered line. A path somebody decided
|
|
129
|
+
// against wants nothing done, and folding it in would ask for a row that was
|
|
130
|
+
// already refused.
|
|
131
|
+
logStep('Declined')
|
|
132
|
+
if (coverage.declined.length === 0) {
|
|
133
|
+
logInfo('no changed path is deliberately unlabelled')
|
|
134
|
+
} else {
|
|
135
|
+
for (const entry of coverage.declined) {
|
|
136
|
+
logInfo(`${entry.path}: ${entry.reason}`)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
logStep(coverage.uncovered.length === 0 ? 'Covered' : 'Uncovered')
|
|
141
|
+
if (coverage.uncovered.length === 0) {
|
|
142
|
+
logInfo('every changed path is reached by a row')
|
|
143
|
+
} else {
|
|
144
|
+
logWarn(
|
|
145
|
+
`${plural(coverage.uncovered.length, 'path')} reached by no row. Give each a prefix on the row that owns its subject, or a [declined] row with the reason it earns none.`,
|
|
146
|
+
)
|
|
147
|
+
for (const path of coverage.uncovered) logWarn(path)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
outro()
|
|
151
|
+
|
|
152
|
+
if (emitJson) {
|
|
153
|
+
process.stdout.write(
|
|
154
|
+
`${JSON.stringify({
|
|
155
|
+
root,
|
|
156
|
+
...(report.base !== undefined && { base: report.base }),
|
|
157
|
+
changed: report.changed,
|
|
158
|
+
labels: coverage.labels,
|
|
159
|
+
declined: coverage.declined,
|
|
160
|
+
uncovered: coverage.uncovered,
|
|
161
|
+
})}\n`,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return coverage.uncovered.length === 0 ? 0 : 2
|
|
166
|
+
}
|
package/src/commands/sync.ts
CHANGED
|
@@ -162,6 +162,19 @@ function renderCheck(report: CheckReport): void {
|
|
|
162
162
|
for (const name of report.newSkills) logInfo(name)
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
// Warned where `newSkills` is noted, because a new skill loads live and needs
|
|
166
|
+
// nothing run while a rule reaches the target only when someone installs it.
|
|
167
|
+
// No sync closes any of this, so the remedy names the install command.
|
|
168
|
+
if (report.newRules.length > 0) {
|
|
169
|
+
logStep('New rules, never installed')
|
|
170
|
+
for (const name of report.newRules) logWarn(name)
|
|
171
|
+
// Names the stack as the reader's to supply, since no target records one
|
|
172
|
+
// and `--add` layers onto a resolved stack rather than standing in for it.
|
|
173
|
+
logInfo(
|
|
174
|
+
'Run `aitk gov install <stack>`, naming the stack yourself since no target records it. Add `--add <rule>` to take one.',
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
165
178
|
renderUnclaimed(report)
|
|
166
179
|
renderMigrations(report)
|
|
167
180
|
|