@erclx/aitk 3.7.0 → 3.9.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/create-rule/REQUIREMENT.md +2 -1
- package/claude/skills/create-rule/SKILL.md +8 -8
- package/claude/skills/create-skill/REQUIREMENT.md +5 -1
- package/claude/skills/create-skill/SKILL.md +10 -4
- package/claude/skills/create-snippet/REQUIREMENT.md +3 -0
- package/claude/skills/create-snippet/SKILL.md +2 -2
- package/claude/skills/restate/REQUIREMENT.md +41 -0
- package/claude/skills/restate/SKILL.md +39 -0
- package/claude/skills/write-human/REQUIREMENT.md +1 -1
- package/claude/skills/write-human/SKILL.md +1 -1
- package/docs/agents/audits.md +2 -2
- package/docs/agents/commands.md +23 -21
- package/docs/agents/index.md +2 -0
- package/docs/agents/install-and-sync.md +6 -2
- package/docs/agents/restated.md +78 -0
- package/docs/agents/routing.md +61 -0
- package/docs/ai-workflow.md +1 -0
- package/governance/rules/claude/570-skill.md +2 -0
- package/package.json +1 -1
- package/src/audits/catalog.ts +49 -0
- package/src/claude/routing.ts +283 -0
- package/src/cli.ts +1 -0
- package/src/commands/claude.ts +130 -1
- package/src/commands/gov.ts +160 -0
- package/src/gov/adapter.ts +1 -0
- package/src/gov/restated.ts +688 -0
- package/src/snippets/adapter.ts +1 -0
- package/src/sync/engine.ts +25 -1
- package/standards/skill.md +1 -0
package/src/audits/catalog.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReachRefusal } from '@/claude/skills-reach'
|
|
2
2
|
import type { AuditRefusal } from '@/deps/audit'
|
|
3
|
+
import type { RestatedRefusal } from '@/gov/restated'
|
|
3
4
|
import type { LabelAuditRefusal } from '@/labels/audit'
|
|
4
5
|
import type { ValidateRefusal as RecordRefusal } from '@/records/validate'
|
|
5
6
|
import type { ScanRefusal } from '@/secrets/scan'
|
|
@@ -356,6 +357,33 @@ function labelCoverageCounts(
|
|
|
356
357
|
return allOf({ uncovered: lengthOf(root.uncovered) })
|
|
357
358
|
}
|
|
358
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Reads the two classes that are findings, leaving the declared mirrors out.
|
|
362
|
+
*
|
|
363
|
+
* A mirror is an authoring root and its consumed copy, which duplicate on
|
|
364
|
+
* purpose, so folding them in would report a corpus getting worse every time a
|
|
365
|
+
* seed is kept in step with the file it is authored from. Same reasoning the
|
|
366
|
+
* label audit drops its declined rows on.
|
|
367
|
+
*
|
|
368
|
+
* The reach count is left out for a different reason. It counts how many
|
|
369
|
+
* surfaces an instruction reached rather than whether anything is wrong, and it
|
|
370
|
+
* moves in lockstep with the two retained here, so a baseline carrying it would
|
|
371
|
+
* report one movement twice.
|
|
372
|
+
*/
|
|
373
|
+
function restatedCounts(record: unknown): Record<string, number> | undefined {
|
|
374
|
+
const counts = asObject(asObject(record)?.counts)
|
|
375
|
+
if (counts === undefined) return undefined
|
|
376
|
+
|
|
377
|
+
return allOf({
|
|
378
|
+
contradictions:
|
|
379
|
+
typeof counts.contradictions === 'number'
|
|
380
|
+
? counts.contradictions
|
|
381
|
+
: undefined,
|
|
382
|
+
repetitions:
|
|
383
|
+
typeof counts.repetitions === 'number' ? counts.repetitions : undefined,
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
|
|
359
387
|
function findingsOnly(record: unknown): Record<string, number> | undefined {
|
|
360
388
|
const root = asObject(record)
|
|
361
389
|
if (root === undefined) return undefined
|
|
@@ -548,6 +576,27 @@ export const AUDITS: readonly AuditSpec[] = [
|
|
|
548
576
|
absentReasons: ['no-map'] satisfies LabelAuditRefusal[],
|
|
549
577
|
counts: labelCoverageCounts,
|
|
550
578
|
},
|
|
579
|
+
{
|
|
580
|
+
id: 'restated',
|
|
581
|
+
label: 'Restated instructions',
|
|
582
|
+
argv: ['gov', 'restated', '--json'],
|
|
583
|
+
// Reports rather than gates, on the split this file already draws. Whether
|
|
584
|
+
// a rule stated on two surfaces should be stated on one is a judgment the
|
|
585
|
+
// person owning the surface takes, and most restatements here are correct,
|
|
586
|
+
// so a push failing on one would fail on the ordinary case.
|
|
587
|
+
gatingExits: [],
|
|
588
|
+
corpus: 'tracked',
|
|
589
|
+
// Both reasons the sweep refuses for, and each is an absence rather than a
|
|
590
|
+
// break. A target holds neither the seed nor a shipped skills tree, so
|
|
591
|
+
// without the allowance every project installing this CLI reports the verb
|
|
592
|
+
// unmeasured on every run and never changes, which is the permanent signal
|
|
593
|
+
// the per-machine allowance exists against. Same shape as the reach verb.
|
|
594
|
+
absentReasons: [
|
|
595
|
+
'no-instructions',
|
|
596
|
+
'no-surfaces',
|
|
597
|
+
] satisfies RestatedRefusal[],
|
|
598
|
+
counts: restatedCounts,
|
|
599
|
+
},
|
|
551
600
|
{
|
|
552
601
|
id: 'deps',
|
|
553
602
|
label: 'Dependency advisories',
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const CLAUDE_MD = 'CLAUDE.md'
|
|
5
|
+
const RULES_DIR = join('.claude', 'rules')
|
|
6
|
+
|
|
7
|
+
/** A backticked token, which is how the always-loaded file spells a path. */
|
|
8
|
+
const TOKEN = /`([^`\s]+)`/g
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A path a reader could open. Admits no `$`, `*`, or `=`, so an assignment
|
|
12
|
+
* (`AITK_NON_INTERACTIVE=1`) and a variable are both excluded.
|
|
13
|
+
*/
|
|
14
|
+
const PATH_TOKEN = /^[.A-Za-z0-9_][A-Za-z0-9._/-]*$/
|
|
15
|
+
|
|
16
|
+
/** A placeholder segment, which a shape carries and a real path does not. */
|
|
17
|
+
const SHAPE = /<[^>]*>/
|
|
18
|
+
|
|
19
|
+
/** An alphabetic extension, which separates `cspell.json` from `3.6.0`. */
|
|
20
|
+
const EXTENSION = /\.[a-z]{2,5}$/
|
|
21
|
+
|
|
22
|
+
/** Extensions a folder is probed with, so a glob narrowed by type still hits. */
|
|
23
|
+
const PROBES = ['probe.md', 'probe.ts', 'probe.tsx', 'probe.sh', 'probe.json']
|
|
24
|
+
|
|
25
|
+
export interface RuleGlobs {
|
|
26
|
+
/** Path under `.claude/rules/`, which is how a reader cites a rule. */
|
|
27
|
+
readonly rule: string
|
|
28
|
+
readonly globs: readonly string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Section {
|
|
32
|
+
/** `Behavior` for an H2, `Behavior / Scope` for the H3 beneath it. */
|
|
33
|
+
readonly heading: string
|
|
34
|
+
/** The top-level bullet lines the heading owns, in reading order. */
|
|
35
|
+
readonly lines: readonly string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SectionReport {
|
|
39
|
+
readonly heading: string
|
|
40
|
+
readonly bullets: number
|
|
41
|
+
/** Bullets naming at least one path, which is the reading this measures. */
|
|
42
|
+
readonly pathScoped: number
|
|
43
|
+
/** Path-scoped bullets whose named path some rule glob already reaches. */
|
|
44
|
+
readonly covered: number
|
|
45
|
+
/** Distinct named paths no rule glob reaches, in reading order. */
|
|
46
|
+
readonly uncovered: readonly string[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Why a scan produced no reading, which is never the same as a clean one. */
|
|
50
|
+
export type RoutingRefusal = 'no-claude-md' | 'no-rules'
|
|
51
|
+
|
|
52
|
+
export type RoutingReport =
|
|
53
|
+
| {
|
|
54
|
+
readonly kind: 'measured'
|
|
55
|
+
readonly rules: number
|
|
56
|
+
readonly sections: readonly SectionReport[]
|
|
57
|
+
}
|
|
58
|
+
| { readonly kind: 'refused'; readonly reason: RoutingRefusal }
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Every distinct path one bullet names, in reading order.
|
|
62
|
+
*
|
|
63
|
+
* Naming a path is what this measures, not firing only on it. A bullet can
|
|
64
|
+
* name a folder and still apply every session, and one can fire on a path it
|
|
65
|
+
* never spells, so the count is a reading a person still has to judge.
|
|
66
|
+
*/
|
|
67
|
+
export function namedPaths(line: string): string[] {
|
|
68
|
+
const paths: string[] = []
|
|
69
|
+
|
|
70
|
+
for (const match of line.matchAll(TOKEN)) {
|
|
71
|
+
const token = concretePrefix(match[1])
|
|
72
|
+
if (token === undefined) continue
|
|
73
|
+
if (!PATH_TOKEN.test(token)) continue
|
|
74
|
+
if (!token.includes('/') && !EXTENSION.test(token)) continue
|
|
75
|
+
if (!paths.includes(token)) paths.push(token)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return paths
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The openable part of a token, which for a shape is the folder above the
|
|
83
|
+
* placeholder.
|
|
84
|
+
*
|
|
85
|
+
* `.claude/context/<domain>.md` names `.claude/context/` and nothing narrower,
|
|
86
|
+
* so dropping the whole token would report the section that carries it as
|
|
87
|
+
* naming no path at all. A token whose placeholder sits in the first segment
|
|
88
|
+
* has no openable prefix and is dropped.
|
|
89
|
+
*/
|
|
90
|
+
function concretePrefix(token: string): string | undefined {
|
|
91
|
+
if (!SHAPE.test(token)) return token
|
|
92
|
+
|
|
93
|
+
const prefix = token.slice(0, token.indexOf('<'))
|
|
94
|
+
return prefix.endsWith('/') ? prefix : undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Splits the file into the sections a reader sees, counting top-level bullets.
|
|
99
|
+
*
|
|
100
|
+
* A nested bullet belongs to the one above it rather than to the section, and
|
|
101
|
+
* a fenced block holds example text rather than instruction, so neither is
|
|
102
|
+
* counted. A heading carrying no bullet is dropped, since the report answers
|
|
103
|
+
* how many bullets are path-scoped and a section with none answers nothing.
|
|
104
|
+
*/
|
|
105
|
+
export function splitSections(text: string): Section[] {
|
|
106
|
+
const sections: Section[] = []
|
|
107
|
+
let parent = ''
|
|
108
|
+
let heading: string | undefined
|
|
109
|
+
let lines: string[] = []
|
|
110
|
+
let fenced = false
|
|
111
|
+
|
|
112
|
+
const flush = (): void => {
|
|
113
|
+
if (heading !== undefined && lines.length > 0)
|
|
114
|
+
sections.push({ heading, lines })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const open = (next: string): void => {
|
|
118
|
+
flush()
|
|
119
|
+
heading = next
|
|
120
|
+
lines = []
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const line of text.split('\n')) {
|
|
124
|
+
if (line.startsWith('```')) {
|
|
125
|
+
fenced = !fenced
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
if (fenced) continue
|
|
129
|
+
|
|
130
|
+
const h2 = line.match(/^## (.+)$/)
|
|
131
|
+
if (h2) {
|
|
132
|
+
parent = h2[1]
|
|
133
|
+
open(parent)
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const h3 = line.match(/^### (.+)$/)
|
|
138
|
+
if (h3) {
|
|
139
|
+
open(parent === '' ? h3[1] : `${parent} / ${h3[1]}`)
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (line.startsWith('- ')) lines.push(line)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
flush()
|
|
147
|
+
return sections
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Every path-scoped rule the tree installs, with the globs it declares.
|
|
152
|
+
*
|
|
153
|
+
* An always-on rule declares no `paths` and applies at the same priority as
|
|
154
|
+
* the always-loaded file, so it covers no path in particular and is skipped.
|
|
155
|
+
*/
|
|
156
|
+
export function readRuleGlobs(root: string): RuleGlobs[] {
|
|
157
|
+
const rulesRoot = join(root, RULES_DIR)
|
|
158
|
+
if (!existsSync(rulesRoot)) return []
|
|
159
|
+
|
|
160
|
+
const rules: RuleGlobs[] = []
|
|
161
|
+
const files = [
|
|
162
|
+
...new Bun.Glob('**/*.md').scanSync({ cwd: rulesRoot, onlyFiles: true }),
|
|
163
|
+
].sort()
|
|
164
|
+
|
|
165
|
+
for (const file of files) {
|
|
166
|
+
const text = readFileSync(join(rulesRoot, file), 'utf8')
|
|
167
|
+
const globs = [...frontmatter(text).matchAll(/^\s*-\s*'([^']+)'\s*$/gm)]
|
|
168
|
+
.map((match) => match[1])
|
|
169
|
+
.filter((glob) => glob.includes('*') || glob.includes('.'))
|
|
170
|
+
|
|
171
|
+
if (globs.length === 0) continue
|
|
172
|
+
rules.push({ rule: file.replaceAll('\\', '/'), globs })
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return rules
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The block between the opening and closing `---`, or nothing for a rule
|
|
180
|
+
* carrying no frontmatter.
|
|
181
|
+
*
|
|
182
|
+
* Bounding the read is what keeps a body bullet out of the glob list. A rule
|
|
183
|
+
* quoting a path in prose would otherwise register it as a scope the rule
|
|
184
|
+
* never declared, and the resulting coverage would be wrong with nothing
|
|
185
|
+
* reporting it.
|
|
186
|
+
*/
|
|
187
|
+
function frontmatter(text: string): string {
|
|
188
|
+
if (!text.startsWith('---\n')) return ''
|
|
189
|
+
|
|
190
|
+
const end = text.indexOf('\n---', 3)
|
|
191
|
+
return end === -1 ? '' : text.slice(4, end)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Whether a glob scopes itself to a location rather than to a file type.
|
|
196
|
+
*
|
|
197
|
+
* A glob opening `**` reaches every folder in the tree, so it answers that a
|
|
198
|
+
* file type is governed and never that a named path is. Counting one would
|
|
199
|
+
* report every markdown path covered by `501-markdown` and leave the column
|
|
200
|
+
* saying nothing a reader could act on.
|
|
201
|
+
*/
|
|
202
|
+
function isAnchored(glob: string): boolean {
|
|
203
|
+
return !glob.startsWith('**/')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The first rule scoping itself to a named path, or undefined for none.
|
|
208
|
+
*
|
|
209
|
+
* A folder is probed with a handful of extensions rather than matched as a
|
|
210
|
+
* literal, because a glob narrowed by file type reaches under the folder
|
|
211
|
+
* without ever matching the folder's own name.
|
|
212
|
+
*/
|
|
213
|
+
export function coveringRule(
|
|
214
|
+
path: string,
|
|
215
|
+
rules: readonly RuleGlobs[],
|
|
216
|
+
): string | undefined {
|
|
217
|
+
const bare = path.replace(/\/+$/, '')
|
|
218
|
+
const candidates = [bare, ...PROBES.map((probe) => `${bare}/${probe}`)]
|
|
219
|
+
|
|
220
|
+
for (const { rule, globs } of rules) {
|
|
221
|
+
for (const glob of globs) {
|
|
222
|
+
if (!isAnchored(glob)) continue
|
|
223
|
+
const matcher = new Bun.Glob(glob)
|
|
224
|
+
if (candidates.some((candidate) => matcher.match(candidate))) return rule
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return undefined
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Reads the always-loaded file against the rules installed beside it.
|
|
233
|
+
*
|
|
234
|
+
* Measures the tree it is pointed at rather than the toolkit root, so a linked
|
|
235
|
+
* worktree reads its own branch and a target reads its own file.
|
|
236
|
+
*/
|
|
237
|
+
export function scanRouting(root: string): RoutingReport {
|
|
238
|
+
const file = join(root, CLAUDE_MD)
|
|
239
|
+
if (!existsSync(file)) return { kind: 'refused', reason: 'no-claude-md' }
|
|
240
|
+
|
|
241
|
+
const rules = readRuleGlobs(root)
|
|
242
|
+
if (rules.length === 0) return { kind: 'refused', reason: 'no-rules' }
|
|
243
|
+
|
|
244
|
+
const sections = splitSections(readFileSync(file, 'utf8')).map((section) =>
|
|
245
|
+
classify(section, rules),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
return { kind: 'measured', rules: rules.length, sections }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Counts one section's bullets against the rules installed beside the file. */
|
|
252
|
+
function classify(
|
|
253
|
+
section: Section,
|
|
254
|
+
rules: readonly RuleGlobs[],
|
|
255
|
+
): SectionReport {
|
|
256
|
+
let pathScoped = 0
|
|
257
|
+
let covered = 0
|
|
258
|
+
const uncovered: string[] = []
|
|
259
|
+
|
|
260
|
+
for (const line of section.lines) {
|
|
261
|
+
const paths = namedPaths(line)
|
|
262
|
+
if (paths.length === 0) continue
|
|
263
|
+
pathScoped += 1
|
|
264
|
+
|
|
265
|
+
const reached = paths.filter(
|
|
266
|
+
(path) => coveringRule(path, rules) !== undefined,
|
|
267
|
+
)
|
|
268
|
+
if (reached.length > 0) covered += 1
|
|
269
|
+
|
|
270
|
+
for (const path of paths) {
|
|
271
|
+
if (reached.includes(path) || uncovered.includes(path)) continue
|
|
272
|
+
uncovered.push(path)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
heading: section.heading,
|
|
278
|
+
bullets: section.lines.length,
|
|
279
|
+
pathScoped,
|
|
280
|
+
covered,
|
|
281
|
+
uncovered,
|
|
282
|
+
}
|
|
283
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -85,6 +85,7 @@ function showHelp(): void {
|
|
|
85
85
|
`${GREY}│${NC} aitk sandbox git:commit`,
|
|
86
86
|
`${GREY}│${NC} aitk gov install react`,
|
|
87
87
|
`${GREY}│${NC} aitk gov sync ../my-app`,
|
|
88
|
+
`${GREY}│${NC} aitk gov restated --json`,
|
|
88
89
|
`${GREY}│${NC} aitk standards markdown`,
|
|
89
90
|
`${GREY}│${NC} aitk snippets install base ../my-app`,
|
|
90
91
|
`${GREY}│${NC} aitk snippets sync ../my-app`,
|
package/src/commands/claude.ts
CHANGED
|
@@ -21,6 +21,11 @@ import {
|
|
|
21
21
|
type SkillFinding,
|
|
22
22
|
type SkillsAudit,
|
|
23
23
|
} from '@/claude/skills-audit'
|
|
24
|
+
import {
|
|
25
|
+
type RoutingRefusal,
|
|
26
|
+
type RoutingReport,
|
|
27
|
+
scanRouting,
|
|
28
|
+
} from '@/claude/routing'
|
|
24
29
|
import { type DriftReport, readDrift } from '@/claude/skills-drift'
|
|
25
30
|
import { listSkills } from '@/claude/skills-list'
|
|
26
31
|
import {
|
|
@@ -79,6 +84,10 @@ interface SkillsReachOptions {
|
|
|
79
84
|
readonly json?: boolean
|
|
80
85
|
}
|
|
81
86
|
|
|
87
|
+
interface RoutingOptions {
|
|
88
|
+
readonly json?: boolean
|
|
89
|
+
}
|
|
90
|
+
|
|
82
91
|
const SEEDED_FILES: readonly string[] = [
|
|
83
92
|
'ARCHITECTURE.md',
|
|
84
93
|
'REQUIREMENTS.md',
|
|
@@ -91,7 +100,7 @@ const STATUSLINE = 'statusline-command.sh'
|
|
|
91
100
|
export function register(program: Command): void {
|
|
92
101
|
const claude = program
|
|
93
102
|
.command('claude')
|
|
94
|
-
.description('Claude workflow (init, seeds, sync, setup)')
|
|
103
|
+
.description('Claude workflow (init, seeds, sync, setup, routing)')
|
|
95
104
|
.helpOption('-h, --help', 'Show this help message')
|
|
96
105
|
.addHelpText(
|
|
97
106
|
'after',
|
|
@@ -167,6 +176,42 @@ export function register(program: Command): void {
|
|
|
167
176
|
process.exitCode = await runSeedsList(opts)
|
|
168
177
|
})
|
|
169
178
|
|
|
179
|
+
claude
|
|
180
|
+
.command('routing')
|
|
181
|
+
.description('Report per CLAUDE.md section how many bullets name a path')
|
|
182
|
+
.argument('[path]', 'Repository root, defaulting to the current directory')
|
|
183
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
184
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
185
|
+
.addHelpText(
|
|
186
|
+
'after',
|
|
187
|
+
[
|
|
188
|
+
'',
|
|
189
|
+
'Scope:',
|
|
190
|
+
' Every H2 and H3 in CLAUDE.md that owns at least one top-level',
|
|
191
|
+
' bullet, counted against the path-scoped rules under .claude/rules/.',
|
|
192
|
+
' A bullet is path-scoped here when it names a path, which is not the',
|
|
193
|
+
" same as firing only on one. A rule's glob covers a named folder",
|
|
194
|
+
' only when the glob is anchored to a location rather than to a file',
|
|
195
|
+
' type, so **/*.md covers README.md and no folder at all.',
|
|
196
|
+
'',
|
|
197
|
+
'Exit codes:',
|
|
198
|
+
' 0 the file was read',
|
|
199
|
+
' 1 refused, with the reason on stderr',
|
|
200
|
+
'',
|
|
201
|
+
'Reports rather than gates. Whether a bullet belongs in a rule is the',
|
|
202
|
+
'judgment 592-claude-md states, and naming a path is evidence for it',
|
|
203
|
+
'rather than the answer.',
|
|
204
|
+
'',
|
|
205
|
+
'Examples:',
|
|
206
|
+
' aitk claude routing',
|
|
207
|
+
' aitk claude routing --json',
|
|
208
|
+
'',
|
|
209
|
+
].join('\n'),
|
|
210
|
+
)
|
|
211
|
+
.action((path: string | undefined, opts: RoutingOptions) => {
|
|
212
|
+
process.exitCode = runRouting(path, opts)
|
|
213
|
+
})
|
|
214
|
+
|
|
170
215
|
const skills = claude
|
|
171
216
|
.command('skills')
|
|
172
217
|
.description('Plugin skill catalog (list, audit, drift, reach)')
|
|
@@ -612,6 +657,90 @@ function reportSkew(skew: SkewReport): void {
|
|
|
612
657
|
else logInfo(describeSkew(skew))
|
|
613
658
|
}
|
|
614
659
|
|
|
660
|
+
/** What a reader does about each way the reading cannot be taken. */
|
|
661
|
+
const ROUTING_REFUSALS: Record<RoutingRefusal, string> = {
|
|
662
|
+
'no-claude-md': 'No CLAUDE.md here, so this tree has no always-loaded file.',
|
|
663
|
+
'no-rules':
|
|
664
|
+
'No path-scoped rules under .claude/rules/, so nothing covers a path yet.',
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Measures the cwd rather than the toolkit root, matching the reach and drift
|
|
669
|
+
* verbs, so a linked worktree reads its own branch instead of `main`.
|
|
670
|
+
*/
|
|
671
|
+
function runRouting(path: string | undefined, opts: RoutingOptions): number {
|
|
672
|
+
const root = resolve(path ?? process.cwd())
|
|
673
|
+
const report = scanRouting(root)
|
|
674
|
+
|
|
675
|
+
if (report.kind === 'refused') {
|
|
676
|
+
frameError(ROUTING_REFUSALS[report.reason])
|
|
677
|
+
if (opts.json) {
|
|
678
|
+
process.stdout.write(
|
|
679
|
+
`${JSON.stringify({
|
|
680
|
+
root,
|
|
681
|
+
reason: report.reason,
|
|
682
|
+
message: ROUTING_REFUSALS[report.reason],
|
|
683
|
+
})}\n`,
|
|
684
|
+
)
|
|
685
|
+
}
|
|
686
|
+
return 1
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
intro('aitk claude routing')
|
|
690
|
+
reportRouting(report)
|
|
691
|
+
outro()
|
|
692
|
+
|
|
693
|
+
if (opts.json) {
|
|
694
|
+
process.stdout.write(
|
|
695
|
+
`${JSON.stringify({
|
|
696
|
+
root,
|
|
697
|
+
rules: report.rules,
|
|
698
|
+
sections: report.sections,
|
|
699
|
+
})}\n`,
|
|
700
|
+
)
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return 0
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* States the corpus on every run, so a section naming no path reads as
|
|
708
|
+
* measured rather than as skipped. A reader deciding what to cut needs the
|
|
709
|
+
* sections that stay as much as the ones that move.
|
|
710
|
+
*/
|
|
711
|
+
function reportRouting(
|
|
712
|
+
report: Extract<RoutingReport, { kind: 'measured' }>,
|
|
713
|
+
): void {
|
|
714
|
+
const bullets = report.sections.reduce(
|
|
715
|
+
(total, section) => total + section.bullets,
|
|
716
|
+
0,
|
|
717
|
+
)
|
|
718
|
+
const pathScoped = report.sections.reduce(
|
|
719
|
+
(total, section) => total + section.pathScoped,
|
|
720
|
+
0,
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
logStep('Corpus')
|
|
724
|
+
logInfo(
|
|
725
|
+
`${plural(report.sections.length, 'section')} carrying ${plural(bullets, 'bullet')}, read against ${plural(report.rules, 'path-scoped rule')}`,
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
logStep('Sections')
|
|
729
|
+
logInfo(`${pathScoped} of ${bullets} bullets name a path`)
|
|
730
|
+
pipeOutput(
|
|
731
|
+
report.sections
|
|
732
|
+
.map(
|
|
733
|
+
(section) =>
|
|
734
|
+
`${section.pathScoped}/${section.bullets} path-scoped, ${section.covered} covered ${section.heading}${
|
|
735
|
+
section.uncovered.length === 0
|
|
736
|
+
? ''
|
|
737
|
+
: ` [uncovered: ${section.uncovered.join(', ')}]`
|
|
738
|
+
}`,
|
|
739
|
+
)
|
|
740
|
+
.join('\n'),
|
|
741
|
+
)
|
|
742
|
+
}
|
|
743
|
+
|
|
615
744
|
/** What a reader does about the one way the corpus fails to build. */
|
|
616
745
|
const REACH_REFUSALS: Record<ReachRefusal, string> = {
|
|
617
746
|
'no-skills':
|
package/src/commands/gov.ts
CHANGED
|
@@ -14,6 +14,15 @@ import {
|
|
|
14
14
|
mergeExtraRules,
|
|
15
15
|
resolveRules,
|
|
16
16
|
} from '@/gov/stacks'
|
|
17
|
+
import {
|
|
18
|
+
INSTRUCTIONS_REL,
|
|
19
|
+
type RestatedEntry,
|
|
20
|
+
type RestatedRefusal,
|
|
21
|
+
type RestatedReport,
|
|
22
|
+
readRestated,
|
|
23
|
+
SEED_REL,
|
|
24
|
+
SHIPPED_SKILLS_REL,
|
|
25
|
+
} from '@/gov/restated'
|
|
17
26
|
import {
|
|
18
27
|
readSuperseded,
|
|
19
28
|
SUPERSEDED_MARKER,
|
|
@@ -68,6 +77,17 @@ interface SupersededOptions {
|
|
|
68
77
|
readonly json?: boolean
|
|
69
78
|
}
|
|
70
79
|
|
|
80
|
+
interface RestatedOptions {
|
|
81
|
+
readonly root?: string
|
|
82
|
+
readonly json?: boolean
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** What a reader does about each way the sweep produced no reading. */
|
|
86
|
+
const RESTATED_REFUSALS: Record<RestatedRefusal, string> = {
|
|
87
|
+
'no-instructions': `No ${INSTRUCTIONS_REL} here, or it carries no bullet, so there is no instruction corpus to sweep.`,
|
|
88
|
+
'no-surfaces': `Neither ${SEED_REL} nor ${SHIPPED_SKILLS_REL}/ is here, so no second surface exists to match against.`,
|
|
89
|
+
}
|
|
90
|
+
|
|
71
91
|
export function register(program: Command): void {
|
|
72
92
|
const gov = program
|
|
73
93
|
.command('gov')
|
|
@@ -245,6 +265,146 @@ export function register(program: Command): void {
|
|
|
245
265
|
process.exitCode = await runSuperseded(superseded, replacement, opts)
|
|
246
266
|
},
|
|
247
267
|
)
|
|
268
|
+
|
|
269
|
+
gov
|
|
270
|
+
.command('restated')
|
|
271
|
+
.description(
|
|
272
|
+
'Report every instruction the always-loaded file states that a second surface states too',
|
|
273
|
+
)
|
|
274
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
275
|
+
.option('--root <path>', 'Tree to read, defaulting to the cwd')
|
|
276
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
277
|
+
.addHelpText(
|
|
278
|
+
'after',
|
|
279
|
+
[
|
|
280
|
+
'',
|
|
281
|
+
`Matches every bullet in ${INSTRUCTIONS_REL} against ${SEED_REL}`,
|
|
282
|
+
`and every ${SHIPPED_SKILLS_REL}/*/SKILL.md body. Matching is recall-first,`,
|
|
283
|
+
'keyed on distinctive tokens two statements share rather than on a phrase',
|
|
284
|
+
'they spell the same way, because the case this exists for was one rule',
|
|
285
|
+
'written three different ways.',
|
|
286
|
+
'',
|
|
287
|
+
'What it separates:',
|
|
288
|
+
' mirror a declared authoring-to-consumed pair, where repeating is the design',
|
|
289
|
+
' repetition two surfaces state one rule and neither is declared a copy',
|
|
290
|
+
' contradiction the prohibition falls on one surface alone, on a strong match',
|
|
291
|
+
'',
|
|
292
|
+
'The contradiction class is a polarity reading rather than a judgment',
|
|
293
|
+
'about meaning, so weigh each against the surfaces it names.',
|
|
294
|
+
'',
|
|
295
|
+
'What it does not measure:',
|
|
296
|
+
' a rule stated in two skill bodies and never in the always-loaded file,',
|
|
297
|
+
' since the bullets there are the subjects and the bodies are searched',
|
|
298
|
+
'',
|
|
299
|
+
'Exit codes:',
|
|
300
|
+
' 0 no instruction is restated outside a declared mirror',
|
|
301
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
302
|
+
' 2 at least one instruction is restated outside a declared mirror',
|
|
303
|
+
'',
|
|
304
|
+
'Examples:',
|
|
305
|
+
' aitk gov restated',
|
|
306
|
+
' aitk gov restated --json',
|
|
307
|
+
'',
|
|
308
|
+
].join('\n'),
|
|
309
|
+
)
|
|
310
|
+
.action((opts: RestatedOptions) => {
|
|
311
|
+
process.exitCode = runRestated(opts)
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Reports and never gates, matching the two sweeps above. A restatement is
|
|
317
|
+
* legitimate more often than not, so failing a push on one would fail on the
|
|
318
|
+
* ordinary case and teach contributors to route around the stage.
|
|
319
|
+
*/
|
|
320
|
+
function runRestated(opts: RestatedOptions): number {
|
|
321
|
+
const root = resolve(opts.root ?? process.cwd())
|
|
322
|
+
const report = readRestated(root)
|
|
323
|
+
const emitJson = opts.json ?? false
|
|
324
|
+
|
|
325
|
+
if (report.kind === 'unreadable') {
|
|
326
|
+
intro('aitk gov restated')
|
|
327
|
+
logStep('Refused')
|
|
328
|
+
logWarn(RESTATED_REFUSALS[report.reason])
|
|
329
|
+
outro()
|
|
330
|
+
|
|
331
|
+
if (emitJson) {
|
|
332
|
+
process.stdout.write(
|
|
333
|
+
`${JSON.stringify({
|
|
334
|
+
root,
|
|
335
|
+
reason: report.reason,
|
|
336
|
+
message: RESTATED_REFUSALS[report.reason],
|
|
337
|
+
})}\n`,
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return 1
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
reportRestated(report, root)
|
|
345
|
+
|
|
346
|
+
if (emitJson) {
|
|
347
|
+
process.stdout.write(`${JSON.stringify({ root, ...report })}\n`)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const findings = report.counts.contradictions + report.counts.repetitions
|
|
351
|
+
return findings > 0 ? 2 : 0
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function describeEntry(entry: RestatedEntry): string[] {
|
|
355
|
+
const lines = [`${entry.subject.file}:${entry.subject.line}`]
|
|
356
|
+
|
|
357
|
+
for (const surface of entry.surfaces) {
|
|
358
|
+
lines.push(
|
|
359
|
+
` [${surface.restatement}] ${surface.file}:${surface.line} via ${surface.anchors.join(', ')}`,
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return lines
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function reportRestated(
|
|
367
|
+
report: Extract<RestatedReport, { kind: 'measured' }>,
|
|
368
|
+
root: string,
|
|
369
|
+
): void {
|
|
370
|
+
intro('aitk gov restated')
|
|
371
|
+
|
|
372
|
+
// A count of what matched reads as a verdict on the repository unless the run
|
|
373
|
+
// also says how wide the corpus behind it was.
|
|
374
|
+
logStep('Corpus')
|
|
375
|
+
logInfo(
|
|
376
|
+
`${report.corpus.instructions} instruction(s) against ${report.corpus.candidates} statement(s) from the seed and ${report.corpus.bodies} shipped body/bodies in ${root}`,
|
|
377
|
+
)
|
|
378
|
+
logInfo(
|
|
379
|
+
`matched on ${report.matcher.anchors} weighted anchor(s), dropping any token in more than ${report.matcher.common} statements`,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
// Named rather than counted. A polarity split is the only class claiming a
|
|
383
|
+
// defect, and a reader weighing one has to reach both surfaces.
|
|
384
|
+
logStep(
|
|
385
|
+
report.counts.contradictions === 0 ? 'No contradiction' : 'Contradictions',
|
|
386
|
+
)
|
|
387
|
+
if (report.counts.contradictions === 0) {
|
|
388
|
+
logInfo('no restatement puts a prohibition on one surface alone')
|
|
389
|
+
} else {
|
|
390
|
+
for (const entry of report.restatements) {
|
|
391
|
+
const carries = entry.surfaces.some(
|
|
392
|
+
(surface) => surface.restatement === 'contradiction',
|
|
393
|
+
)
|
|
394
|
+
if (!carries) continue
|
|
395
|
+
for (const line of describeEntry(entry)) logWarn(line)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
logStep('Restated')
|
|
400
|
+
logInfo(
|
|
401
|
+
`${report.counts.repetitions} repetition(s) outside a declared mirror, and ${report.counts.mirrors} on one`,
|
|
402
|
+
)
|
|
403
|
+
logInfo(
|
|
404
|
+
`${report.counts.threeSurface} instruction(s) reach three surfaces or more`,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
outro()
|
|
248
408
|
}
|
|
249
409
|
|
|
250
410
|
/**
|