@erclx/aitk 3.6.0 → 3.8.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 +5 -5
- package/claude/skills/claude-docs/SKILL.md +15 -10
- package/claude/skills/claude-memory-review/SKILL.md +7 -7
- package/claude/skills/claude-memory-review/references/receipt-format.md +1 -1
- package/claude/skills/claude-orchestrate/SKILL.md +2 -2
- package/claude/skills/claude-pr-review/SKILL.md +25 -7
- package/claude/skills/claude-review/SKILL.md +5 -3
- package/claude/skills/claude-screencast/SKILL.md +9 -4
- package/claude/skills/claude-tasks/SKILL.md +2 -2
- package/claude/skills/create-rule/REQUIREMENT.md +2 -1
- package/claude/skills/create-rule/SKILL.md +8 -8
- package/claude/skills/create-snippet/REQUIREMENT.md +3 -0
- package/claude/skills/create-snippet/SKILL.md +2 -2
- package/claude/skills/git-pr/references/pr.md +3 -0
- package/claude/skills/git-ship/SKILL.md +1 -1
- package/claude/skills/git-split/references/pr.md +3 -0
- package/claude/skills/restate/REQUIREMENT.md +41 -0
- package/claude/skills/restate/SKILL.md +39 -0
- package/claude/skills/toolkit-feedback/SKILL.md +2 -2
- package/claude/skills/write-human/REQUIREMENT.md +1 -1
- package/claude/skills/write-human/SKILL.md +1 -1
- package/docs/agents/capture.md +3 -1
- package/docs/agents/commands.md +25 -21
- package/docs/agents/demo.md +82 -0
- package/docs/agents/index.md +2 -0
- package/docs/agents/install-and-sync.md +6 -2
- package/docs/agents/records.md +2 -2
- package/docs/agents/routing.md +61 -0
- package/docs/agents/tasks.md +1 -1
- package/docs/ai-workflow.md +8 -5
- package/docs/operating-model.md +13 -4
- package/governance/rules/claude/558-plan.md +1 -2
- package/governance/rules/lib/300-testing-ts.md +1 -0
- package/package.json +3 -2
- package/src/claude/routing.ts +283 -0
- package/src/cli.ts +4 -1
- package/src/commands/claude.ts +130 -1
- package/src/commands/demo.ts +373 -0
- package/src/commands/feedback.ts +10 -3
- package/src/commands/tasks.ts +1 -1
- package/src/demo/beats.ts +135 -0
- package/src/demo/compile.ts +295 -0
- package/src/demo/cursors.ts +55 -0
- package/src/demo/drive.ts +256 -0
- package/src/demo/pointer.ts +178 -0
- package/src/demo/theme.ts +112 -0
- package/src/gov/adapter.ts +1 -0
- package/src/records/backup.ts +34 -8
- package/src/snippets/adapter.ts +1 -0
- package/src/sync/engine.ts +25 -1
- package/src/tasks/archive.ts +11 -4
- package/standards/bundled/pr.md +3 -0
- package/standards/plan.md +1 -1
- package/standards/tasks.md +4 -4
- package/tooling/claude/manifest.toml +1 -1
- package/tooling/claude/reference.md +6 -5
- package/tooling/claude/seeds/.claude/hooks/tasks-index.sh +4 -1
- package/tooling/claude/seeds/CLAUDE.md +1 -1
|
@@ -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
|
@@ -15,6 +15,7 @@ import { register as docs } from '@/commands/docs'
|
|
|
15
15
|
import { register as design } from '@/commands/design'
|
|
16
16
|
import { register as slides } from '@/commands/slides'
|
|
17
17
|
import { register as capture } from '@/commands/capture'
|
|
18
|
+
import { register as demo } from '@/commands/demo'
|
|
18
19
|
import { register as feedback } from '@/commands/feedback'
|
|
19
20
|
import { register as transcripts } from '@/commands/transcripts'
|
|
20
21
|
import { register as tasks } from '@/commands/tasks'
|
|
@@ -56,7 +57,8 @@ function showHelp(): void {
|
|
|
56
57
|
`${GREY}│${NC} design [cmd] ${GREY}# Design system commands (render)${NC}`,
|
|
57
58
|
`${GREY}│${NC} slides [cmd] ${GREY}# Slide deck commands (render, list)${NC}`,
|
|
58
59
|
`${GREY}│${NC} capture [source] ${GREY}# Render HTML capture sources to PNG${NC}`,
|
|
59
|
-
`${GREY}│${NC}
|
|
60
|
+
`${GREY}│${NC} demo [cmd] ${GREY}# Record a running app (compile, run)${NC}`,
|
|
61
|
+
`${GREY}│${NC} feedback ${GREY}# Write toolkit feedback from stdin to .claude/review/feedback/${NC}`,
|
|
60
62
|
`${GREY}│${NC} transcripts <url> ${GREY}# Fetch a YouTube transcript with metadata frontmatter${NC}`,
|
|
61
63
|
`${GREY}│${NC} tasks [cmd] ${GREY}# Task board commands (archive)${NC}`,
|
|
62
64
|
`${GREY}│${NC} intake [cmd] ${GREY}# Intake folders under .claude/intake/ (list, answer)${NC}`,
|
|
@@ -147,6 +149,7 @@ docs(program)
|
|
|
147
149
|
design(program)
|
|
148
150
|
slides(program)
|
|
149
151
|
capture(program)
|
|
152
|
+
demo(program)
|
|
150
153
|
feedback(program)
|
|
151
154
|
transcripts(program)
|
|
152
155
|
tasks(program)
|
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':
|