@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.
Files changed (59) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/claude-autoship/SKILL.md +5 -5
  3. package/claude/skills/claude-docs/SKILL.md +15 -10
  4. package/claude/skills/claude-memory-review/SKILL.md +7 -7
  5. package/claude/skills/claude-memory-review/references/receipt-format.md +1 -1
  6. package/claude/skills/claude-orchestrate/SKILL.md +2 -2
  7. package/claude/skills/claude-pr-review/SKILL.md +25 -7
  8. package/claude/skills/claude-review/SKILL.md +5 -3
  9. package/claude/skills/claude-screencast/SKILL.md +9 -4
  10. package/claude/skills/claude-tasks/SKILL.md +2 -2
  11. package/claude/skills/create-rule/REQUIREMENT.md +2 -1
  12. package/claude/skills/create-rule/SKILL.md +8 -8
  13. package/claude/skills/create-snippet/REQUIREMENT.md +3 -0
  14. package/claude/skills/create-snippet/SKILL.md +2 -2
  15. package/claude/skills/git-pr/references/pr.md +3 -0
  16. package/claude/skills/git-ship/SKILL.md +1 -1
  17. package/claude/skills/git-split/references/pr.md +3 -0
  18. package/claude/skills/restate/REQUIREMENT.md +41 -0
  19. package/claude/skills/restate/SKILL.md +39 -0
  20. package/claude/skills/toolkit-feedback/SKILL.md +2 -2
  21. package/claude/skills/write-human/REQUIREMENT.md +1 -1
  22. package/claude/skills/write-human/SKILL.md +1 -1
  23. package/docs/agents/capture.md +3 -1
  24. package/docs/agents/commands.md +25 -21
  25. package/docs/agents/demo.md +82 -0
  26. package/docs/agents/index.md +2 -0
  27. package/docs/agents/install-and-sync.md +6 -2
  28. package/docs/agents/records.md +2 -2
  29. package/docs/agents/routing.md +61 -0
  30. package/docs/agents/tasks.md +1 -1
  31. package/docs/ai-workflow.md +8 -5
  32. package/docs/operating-model.md +13 -4
  33. package/governance/rules/claude/558-plan.md +1 -2
  34. package/governance/rules/lib/300-testing-ts.md +1 -0
  35. package/package.json +3 -2
  36. package/src/claude/routing.ts +283 -0
  37. package/src/cli.ts +4 -1
  38. package/src/commands/claude.ts +130 -1
  39. package/src/commands/demo.ts +373 -0
  40. package/src/commands/feedback.ts +10 -3
  41. package/src/commands/tasks.ts +1 -1
  42. package/src/demo/beats.ts +135 -0
  43. package/src/demo/compile.ts +295 -0
  44. package/src/demo/cursors.ts +55 -0
  45. package/src/demo/drive.ts +256 -0
  46. package/src/demo/pointer.ts +178 -0
  47. package/src/demo/theme.ts +112 -0
  48. package/src/gov/adapter.ts +1 -0
  49. package/src/records/backup.ts +34 -8
  50. package/src/snippets/adapter.ts +1 -0
  51. package/src/sync/engine.ts +25 -1
  52. package/src/tasks/archive.ts +11 -4
  53. package/standards/bundled/pr.md +3 -0
  54. package/standards/plan.md +1 -1
  55. package/standards/tasks.md +4 -4
  56. package/tooling/claude/manifest.toml +1 -1
  57. package/tooling/claude/reference.md +6 -5
  58. package/tooling/claude/seeds/.claude/hooks/tasks-index.sh +4 -1
  59. 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} feedback ${GREY}# Write toolkit feedback from stdin to .claude/review/${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)
@@ -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':