@erclx/aitk 0.44.0 → 0.45.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.
@@ -12,6 +12,15 @@ import {
12
12
  type Seed,
13
13
  } from '@/claude/seeds'
14
14
  import { listSeeds, readSeedContents } from '@/claude/seeds-list'
15
+ import {
16
+ auditExitCode,
17
+ auditSkills,
18
+ CORPORA,
19
+ DESCRIPTION_LIMIT,
20
+ REQUIREMENT_SECTIONS,
21
+ type SkillFinding,
22
+ type SkillsAudit,
23
+ } from '@/claude/skills-audit'
15
24
  import { listSkills } from '@/claude/skills-list'
16
25
  import {
17
26
  planSettings,
@@ -24,6 +33,7 @@ import { execScript, PROJECT_ROOT } from '@/exec'
24
33
  import { isDirectory, resolveTarget } from '@/target'
25
34
  import { injectGitignore, pruneGitignore } from '@/tooling/inject'
26
35
  import {
36
+ frameError,
27
37
  intro,
28
38
  isNonInteractive,
29
39
  logAdd,
@@ -32,6 +42,8 @@ import {
32
42
  logStep,
33
43
  logWarn,
34
44
  outro,
45
+ pipeOutput,
46
+ plural,
35
47
  select,
36
48
  } from '@/ui'
37
49
 
@@ -49,12 +61,17 @@ interface SkillsListOptions {
49
61
  readonly names?: boolean
50
62
  }
51
63
 
64
+ interface SkillsAuditOptions {
65
+ readonly json?: boolean
66
+ readonly requirementsOnly?: boolean
67
+ }
68
+
52
69
  const SEEDED_FILES: readonly string[] = [
53
70
  'ARCHITECTURE.md',
54
71
  'REQUIREMENTS.md',
55
72
  'DESIGN.md',
56
73
  ]
57
- const SEEDED_DIRS: readonly string[] = ['tasks', 'wireframes']
74
+ const SEEDED_DIRS: readonly string[] = ['memory', 'tasks', 'wireframes']
58
75
  const USER_DIR = join('tooling', 'claude', 'user')
59
76
  const STATUSLINE = 'statusline-command.sh'
60
77
 
@@ -139,15 +156,15 @@ export function register(program: Command): void {
139
156
 
140
157
  const skills = claude
141
158
  .command('skills')
142
- .description('Plugin skill catalog (list)')
143
- .argument('[subcommand]', "Only 'list' is supported")
159
+ .description('Plugin skill catalog (list, audit)')
160
+ .argument('[subcommand]', "One of 'list' or 'audit'")
144
161
  .helpOption('-h, --help', 'Show this help message')
145
162
  .action((subcommand: string | undefined) => {
146
163
  intro('aitk claude')
147
164
  logError(
148
165
  subcommand === undefined
149
- ? "Missing subcommand. Use 'list'."
150
- : `Unknown subcommand: ${subcommand}. Use 'list'.`,
166
+ ? "Missing subcommand. Use 'list' or 'audit'."
167
+ : `Unknown subcommand: ${subcommand}. Use 'list' or 'audit'.`,
151
168
  )
152
169
  outro()
153
170
  process.exitCode = 1
@@ -172,6 +189,41 @@ export function register(program: Command): void {
172
189
  .action((opts: SkillsListOptions) => {
173
190
  process.exitCode = runSkillsList(opts)
174
191
  })
192
+
193
+ skills
194
+ .command('audit')
195
+ .description(
196
+ 'Report both skill corpora against the mechanical rules in standards/skill.md',
197
+ )
198
+ .argument('[path]', 'Project root, defaulting to the current directory')
199
+ .helpOption('-h, --help', 'Show this help message')
200
+ .option('--json', 'Add a machine-readable record on stdout')
201
+ .option(
202
+ '--requirements-only',
203
+ 'Run the gating requirement-presence check alone',
204
+ )
205
+ .addHelpText(
206
+ 'after',
207
+ [
208
+ '',
209
+ 'Exit codes:',
210
+ ' 0 the audit completed with every skill carrying a requirement',
211
+ ' 1 refused, with the reason on stderr',
212
+ ' 2 a skill folder carries no REQUIREMENT.md',
213
+ '',
214
+ 'Only a missing REQUIREMENT.md sets a failing exit code. Name, description,',
215
+ 'folder, and requirement-section findings are advisory.',
216
+ '',
217
+ 'Examples:',
218
+ ' aitk claude skills audit',
219
+ ' aitk claude skills audit --json',
220
+ ' aitk claude skills audit --requirements-only',
221
+ '',
222
+ ].join('\n'),
223
+ )
224
+ .action(async (path: string | undefined, opts: SkillsAuditOptions) => {
225
+ process.exitCode = await runSkillsAudit(path, opts)
226
+ })
175
227
  }
176
228
 
177
229
  function succeed(message: string): number {
@@ -413,3 +465,229 @@ function runSkillsList(opts: SkillsListOptions): number {
413
465
  outro()
414
466
  return 0
415
467
  }
468
+
469
+ /**
470
+ * Measures the tree at the cwd rather than the toolkit root the catalog reads,
471
+ * so a linked worktree audits its own branch instead of reporting on `main`. A
472
+ * target carrying `.claude/skills/` alone is in scope for the same reason.
473
+ */
474
+ async function runSkillsAudit(
475
+ path: string | undefined,
476
+ opts: SkillsAuditOptions,
477
+ ): Promise<number> {
478
+ const root = resolve(path ?? process.cwd())
479
+ const gateOnly = opts.requirementsOnly ?? false
480
+ const report = await auditSkills(root)
481
+
482
+ if (report.corpora.length === 0) {
483
+ return refuseAudit(
484
+ `No skill corpus under ${root}. Looked for ${CORPORA.join(' and ')}.`,
485
+ gateOnly,
486
+ )
487
+ }
488
+
489
+ if (gateOnly) {
490
+ reportRequirementGate(report)
491
+ } else {
492
+ intro('aitk claude skills audit')
493
+ reportScope(report)
494
+ reportRequirements(report)
495
+ reportFrontmatter(report)
496
+ reportFolder(report)
497
+ reportRequirementShape(report)
498
+ reportUnmeasured()
499
+ outro()
500
+ }
501
+
502
+ if (opts.json) {
503
+ process.stdout.write(
504
+ `${JSON.stringify({
505
+ root,
506
+ corpora: report.corpora.map((corpus) => ({
507
+ path: corpus.rel,
508
+ skills: corpus.skills,
509
+ })),
510
+ skills: report.skills,
511
+ findings: {
512
+ missingRequirement: report.missingRequirement,
513
+ nameMismatch: report.nameMismatch,
514
+ missingDescription: report.missingDescription,
515
+ longDescription: report.longDescription,
516
+ readme: report.readme,
517
+ folderName: report.folderName,
518
+ requirementSections: report.requirementSections,
519
+ },
520
+ checkpoints: {
521
+ descriptionLimit: DESCRIPTION_LIMIT,
522
+ requirementSections: REQUIREMENT_SECTIONS,
523
+ corpora: CORPORA,
524
+ },
525
+ })}\n`,
526
+ )
527
+ }
528
+
529
+ return auditExitCode(report)
530
+ }
531
+
532
+ function refuseAudit(message: string, gateOnly: boolean): number {
533
+ if (gateOnly) {
534
+ frameError(message)
535
+ return 1
536
+ }
537
+
538
+ intro('aitk claude skills audit')
539
+ logStep('Refused')
540
+ logWarn(message)
541
+ outro()
542
+ return 1
543
+ }
544
+
545
+ /**
546
+ * Prints nothing when every skill carries a requirement.
547
+ *
548
+ * `--requirements-only` is what `verify.sh` runs on every push, and that script
549
+ * pipes a stage's whole output into its own frame. A passing gate that printed
550
+ * its frame would nest one inside the other on every contributor's push.
551
+ */
552
+ function reportRequirementGate(report: SkillsAudit): void {
553
+ const missing = report.missingRequirement
554
+ if (missing.length === 0) return
555
+
556
+ intro('aitk claude skills audit')
557
+ logError(
558
+ missing.length === 1
559
+ ? '1 skill folder carries no REQUIREMENT.md'
560
+ : `${missing.length} skill folders carry no REQUIREMENT.md`,
561
+ )
562
+ pipeOutput(missing.join('\n'))
563
+ outro()
564
+ }
565
+
566
+ function reportFindings(findings: readonly SkillFinding[]): void {
567
+ pipeOutput(
568
+ findings.map((found) => `${found.rel} ${found.detail}`).join('\n'),
569
+ )
570
+ }
571
+
572
+ /**
573
+ * Names each corpus that resolved, since a corpus the tree does not carry is
574
+ * skipped silently and a count taken over one of the two reads as the whole.
575
+ */
576
+ function reportScope(report: SkillsAudit): void {
577
+ logStep('Scope')
578
+ for (const corpus of report.corpora) {
579
+ logInfo(`${corpus.rel}: ${plural(corpus.skills, 'skill')}`)
580
+ }
581
+ }
582
+
583
+ function reportRequirements(report: SkillsAudit): void {
584
+ logStep('Requirements')
585
+ logInfo('Every skill folder carries REQUIREMENT.md beside SKILL.md.')
586
+ logInfo('This is the only measure here that fails a run.')
587
+
588
+ if (report.missingRequirement.length === 0) {
589
+ logInfo('Every skill carries one.')
590
+ return
591
+ }
592
+
593
+ logWarn(`${plural(report.missingRequirement.length, 'skill')} without one`)
594
+ pipeOutput(report.missingRequirement.join('\n'))
595
+ }
596
+
597
+ function reportFrontmatter(report: SkillsAudit): void {
598
+ logStep('Frontmatter')
599
+ logInfo(
600
+ `name matches the folder, and description is present and under ${DESCRIPTION_LIMIT} characters.`,
601
+ )
602
+ logInfo('A body whose frontmatter does not parse reads as declaring neither.')
603
+
604
+ const findings =
605
+ report.nameMismatch.length +
606
+ report.missingDescription.length +
607
+ report.longDescription.length
608
+ if (findings === 0) {
609
+ logInfo('Every body declares both fields.')
610
+ return
611
+ }
612
+
613
+ if (report.nameMismatch.length > 0) {
614
+ logWarn(
615
+ `${plural(report.nameMismatch.length, 'body')} whose name does not match its folder`,
616
+ )
617
+ reportFindings(report.nameMismatch)
618
+ }
619
+
620
+ if (report.missingDescription.length > 0) {
621
+ logWarn(
622
+ `${plural(report.missingDescription.length, 'body')} without a description`,
623
+ )
624
+ pipeOutput(report.missingDescription.join('\n'))
625
+ }
626
+
627
+ if (report.longDescription.length > 0) {
628
+ logWarn(
629
+ `${plural(report.longDescription.length, 'description')} past the ${DESCRIPTION_LIMIT}-character ceiling`,
630
+ )
631
+ reportFindings(report.longDescription)
632
+ }
633
+ }
634
+
635
+ function reportFolder(report: SkillsAudit): void {
636
+ logStep('Folder')
637
+ logInfo(
638
+ 'No README.md inside a skill folder, and a folder name in kebab-case carrying no capital or underscore.',
639
+ )
640
+
641
+ if (report.readme.length === 0 && report.folderName.length === 0) {
642
+ logInfo('Every folder conforms.')
643
+ return
644
+ }
645
+
646
+ if (report.readme.length > 0) {
647
+ logWarn(`${plural(report.readme.length, 'folder')} carrying a README.md`)
648
+ pipeOutput(report.readme.join('\n'))
649
+ }
650
+
651
+ if (report.folderName.length > 0) {
652
+ logWarn(
653
+ `${plural(report.folderName.length, 'folder name')} outside kebab-case`,
654
+ )
655
+ pipeOutput(report.folderName.join('\n'))
656
+ }
657
+ }
658
+
659
+ function reportRequirementShape(report: SkillsAudit): void {
660
+ logStep('Requirement shape')
661
+ logInfo(
662
+ `Each REQUIREMENT.md declares ${REQUIREMENT_SECTIONS.join(' and ')}, matched at any heading level.`,
663
+ )
664
+ logInfo(
665
+ 'A folder carrying no requirement is reported above rather than counted twice here.',
666
+ )
667
+
668
+ if (report.requirementSections.length === 0) {
669
+ logInfo('Every requirement declares both.')
670
+ return
671
+ }
672
+
673
+ logWarn(
674
+ `${plural(report.requirementSections.length, 'requirement')} short a declared section`,
675
+ )
676
+ reportFindings(report.requirementSections)
677
+ }
678
+
679
+ /**
680
+ * Stated on every run, including the run where everything above passed. A
681
+ * report that lists only what it measured reads as a verdict on the standard
682
+ * rather than on the half of it a parser can reach.
683
+ */
684
+ function reportUnmeasured(): void {
685
+ logStep('Unmeasured')
686
+ logInfo(
687
+ 'The standard states rules no parser reads, and a pass above says nothing about them.',
688
+ )
689
+ logInfo(
690
+ 'Whether each Must traces to a stated gap, whether a gap reads as an observed failure rather than an intent, and whether a description routes.',
691
+ )
692
+ logInfo('The 5,000-word body ceiling is mechanical and still absent here.')
693
+ }
@@ -31,6 +31,7 @@ import {
31
31
  logWarn,
32
32
  outro,
33
33
  pipeOutput,
34
+ plural,
34
35
  } from '@/ui'
35
36
 
36
37
  /** Returned when an unresolved citation is found, which is the gating check. */
@@ -253,10 +254,6 @@ function reportGate(report: ScannedCitations): void {
253
254
  outro()
254
255
  }
255
256
 
256
- function plural(count: number, noun: string): string {
257
- return `${count} ${noun}${count === 1 ? '' : 's'}`
258
- }
259
-
260
257
  /**
261
258
  * Names the resolved path of every audited folder, plus the requested names
262
259
  * that resolved nowhere.
package/src/ui.ts CHANGED
@@ -59,6 +59,11 @@ export function pipeOutput(text: string): void {
59
59
  )
60
60
  }
61
61
 
62
+ /** Counts a noun for a report line, where the plural is the bare `s` form. */
63
+ export function plural(count: number, noun: string): string {
64
+ return `${count} ${noun}${count === 1 ? '' : 's'}`
65
+ }
66
+
62
67
  export function frameError(message: string): void {
63
68
  process.stderr.write(
64
69
  `${GREY}┌${NC}\n${GREY}│${NC} ${RED}✗${NC} ${message}\n${GREY}└${NC}\n`,
@@ -20,7 +20,7 @@ The claude stack installs the `.claude/` workflow directory into a project. Stat
20
20
  ├── plans/ ← execution detail for multi-step tasks, gitignored. `feature-*.md` entries swept by claude-docs.
21
21
  ├── review/ ← scratch for claude-review and claude-ui-test output, gitignored
22
22
  ├── .tmp/ ← ephemeral scratch space, gitignored
23
- └── memory/ ← session memory files, gitignored
23
+ └── memory/ ← session facts no context entry owns, gitignored. `index.md` regenerated by a hook.
24
24
  ```
25
25
 
26
26
  ## Upgrading from a single-file board
@@ -33,6 +33,16 @@ Convert by hand, once per project:
33
33
  2. Run `aitk indexes regen --no-stage --root . .claude/tasks/<any-task>.md` to build the catalog.
34
34
  3. Delete `.claude/TASKS.md`, and swap its `.gitignore` entry for `.claude/tasks/`.
35
35
 
36
+ ## Upgrading a hand-appended memory index
37
+
38
+ A project installed before the memory folder gained a generated index still holds `.claude/memory/MEMORY.md`, and its entries still carry `name` and `type` frontmatter. Nothing migrates it. `claude-memory-capture` stops appending rows once the new seed lands, so the old file freezes at whatever it held while the folder keeps growing past it.
39
+
40
+ Convert by hand, once per project:
41
+
42
+ 1. Rewrite each entry's `name` key to `title` and its `type` key to a sentence-case `category`, quoting any `description` that opens with a backtick or a colon so the frontmatter parses.
43
+ 2. Replace `MEMORY.md` with an `index.md` carrying `title` and `subtitle` frontmatter and nothing else.
44
+ 3. Run `aitk indexes regen --no-stage --root . .claude/memory/index.md` to build the catalog, and compare its entry count against the file count before deleting anything.
45
+
36
46
  ## Upgrading from a single-file diagram set
37
47
 
38
48
  A project installed before the diagram surface became a folder still holds `.claude/DIAGRAMS.md`. Unlike the board, this one migrates itself. The `claude-diagram` skill reads the flat file when `.claude/diagrams/` holds no entries, splits it by kind into the folder, and reports what it wrote. The old file stays on disk so the split can be compared against its source, and deleting it is a manual step once that check passes.
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Regenerates .claude/memory/index.md after a memory file changes.
4
+ #
5
+ # The memory folder is gitignored, so the whole-repo walk in `bun run check`
6
+ # drops it and never regenerates this index. A positional path bypasses that
7
+ # filter, which makes this hook the only trigger that reaches the folder.
8
+
9
+ input=$(cat)
10
+
11
+ tool=$(printf '%s' "$input" | jq -r '.tool_name // empty')
12
+ case "$tool" in
13
+ Write | Edit | MultiEdit) ;;
14
+ *) exit 0 ;;
15
+ esac
16
+
17
+ file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
18
+ [ -n "$file_path" ] || exit 0
19
+
20
+ case "$file_path" in
21
+ */.claude/memory/*.md) ;;
22
+ *) exit 0 ;;
23
+ esac
24
+
25
+ case "$file_path" in
26
+ */.claude/memory/index.md) exit 0 ;;
27
+ esac
28
+
29
+ # Report a missing CLI rather than exiting quietly. The path guard above already
30
+ # scopes this to a memory-file edit, so the message only fires where the stale
31
+ # index it warns about is the actual outcome.
32
+ if ! command -v aitk >/dev/null 2>&1; then
33
+ jq -nc --arg msg 'aitk is not on PATH, so .claude/memory/index.md was not regenerated and is now stale. Install the toolkit CLI or run aitk indexes regen by hand.' \
34
+ '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$msg}}'
35
+ exit 0
36
+ fi
37
+
38
+ # The walk-up boundary has to come from the path, not from the session. Shared
39
+ # scratch resolves at the main worktree root, so a session inside a linked
40
+ # worktree passes a path that sits outside its own project directory and the
41
+ # default boundary would reject it.
42
+ root="${file_path%/.claude/memory/*}"
43
+ [ -n "$root" ] || exit 0
44
+
45
+ # `--no-stage` because a hook has no business touching the index. On a project
46
+ # whose memory folder is not gitignored, the default auto-stage would silently
47
+ # add memory files to whatever commit is being assembled.
48
+ output=$(aitk indexes regen --no-stage --root "$root" "$file_path" 2>&1) && exit 0
49
+
50
+ # Regen failed, which on this folder means a memory file is missing `title` or
51
+ # `description`. Report it. Nothing else can: the folder is gitignored, so the
52
+ # whole-repo walk never reaches it and no gate stage will ever fail on a stale
53
+ # index. Staying quiet here is what makes the drift permanent.
54
+ errors=$(printf '%s\n' "$output" | grep '^ERROR: ' | head -5)
55
+ [ -n "$errors" ] || errors="$output"
56
+
57
+ msg="Memory index regen failed, so .claude/memory/index.md is now stale. Fix the frontmatter and save again. $errors"
58
+ jq -nc --arg msg "$msg" \
59
+ '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$msg}}'
60
+ exit 0
@@ -0,0 +1,8 @@
1
+ ---
2
+ title: Memory
3
+ subtitle: Session facts with no owning surface, grouped by kind. Feedback is the working set.
4
+ ---
5
+
6
+ # Memory
7
+
8
+ Session facts with no owning surface, grouped by kind. Feedback is the working set.
@@ -31,6 +31,10 @@
31
31
  {
32
32
  "type": "command",
33
33
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tasks-index.sh"
34
+ },
35
+ {
36
+ "type": "command",
37
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/memory-index.sh"
34
38
  }
35
39
  ]
36
40
  }
@@ -72,9 +72,12 @@
72
72
  ## Memory
73
73
 
74
74
  - Write all memory files to `.claude/memory/`, not `~/.claude/projects/`
75
+ - A fact about a domain goes to that domain's `.claude/context/` entry, not to memory. `claude-memory-capture` routes it there and `claude-docs` folds it in. Memory keeps only what no context entry owns.
75
76
  - Save a feedback memory only when the same mistake happens twice in the session, or when the user explicitly corrects you. First-occurrence slips are noise.
76
77
  - Keep feedback memories to 3 lines: the rule, a one-line Why, and a one-line How to apply. Capture the pattern, not the recovery narrative.
77
78
  - Before creating a new memory file, check for an existing one on the same topic. Update rather than duplicate.
79
+ - Give every entry `title`, `description`, and a sentence-case `category`. Never hand-edit `.claude/memory/index.md`. A hook regenerates it from sibling frontmatter.
80
+ - Never delete a memory entry. `claude-memory-review` moves a retired one to `.claude/.tmp/memory-archive/`.
78
81
 
79
82
  ## Scratch
80
83