@erclx/aitk 0.43.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.
Files changed (35) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/claude-autoship/SKILL.md +15 -10
  3. package/claude/skills/claude-docs/SKILL.md +16 -2
  4. package/claude/skills/claude-memory-capture/REQUIREMENT.md +14 -4
  5. package/claude/skills/claude-memory-capture/SKILL.md +45 -16
  6. package/claude/skills/claude-memory-review/REQUIREMENT.md +8 -2
  7. package/claude/skills/claude-memory-review/SKILL.md +41 -25
  8. package/claude/skills/claude-orchestrate/SKILL.md +5 -0
  9. package/claude/skills/git-ship/SKILL.md +14 -11
  10. package/claude/skills/session-resume/SKILL.md +2 -2
  11. package/docs/agents/commands.md +36 -34
  12. package/docs/agents/index.md +2 -1
  13. package/docs/agents/indexes.md +3 -1
  14. package/docs/agents/scripting.md +4 -3
  15. package/docs/agents/skills-audit.md +55 -0
  16. package/docs/agents/tasks.md +33 -1
  17. package/docs/ai-workflow.md +5 -2
  18. package/package.json +1 -1
  19. package/scripts/core/verify.sh +8 -0
  20. package/snippets/claude/orchestrator-sweep.md +2 -0
  21. package/src/claude/seeds.ts +1 -0
  22. package/src/claude/skills-audit.ts +215 -0
  23. package/src/claude/skills-list.ts +3 -3
  24. package/src/commands/claude.ts +283 -5
  25. package/src/commands/context.ts +1 -4
  26. package/src/commands/tasks.ts +113 -0
  27. package/src/tasks/archive.ts +5 -3
  28. package/src/tasks/validate.ts +401 -0
  29. package/src/ui.ts +5 -0
  30. package/standards/tasks.md +5 -0
  31. package/tooling/claude/reference.md +11 -1
  32. package/tooling/claude/seeds/.claude/hooks/memory-index.sh +60 -0
  33. package/tooling/claude/seeds/.claude/memory/index.md +8 -0
  34. package/tooling/claude/seeds/.claude/settings.json +4 -0
  35. package/tooling/claude/seeds/CLAUDE.md +3 -0
@@ -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.
@@ -2,6 +2,11 @@ import { relative } from 'node:path'
2
2
  import { $ } from 'bun'
3
3
  import type { Command } from 'commander'
4
4
  import { type ArchiveOutcome, archiveTask } from '@/tasks/archive'
5
+ import {
6
+ type Finding,
7
+ type ValidateOutcome,
8
+ validateBoard,
9
+ } from '@/tasks/validate'
5
10
  import {
6
11
  intro,
7
12
  logAdd,
@@ -9,16 +14,25 @@ import {
9
14
  logInfo,
10
15
  logRemove,
11
16
  logStep,
17
+ logWarn,
12
18
  outro,
13
19
  pipeOutput,
14
20
  } from '@/ui'
15
21
 
22
+ /** Returned when the board carries a finding, which is the gating result. */
23
+ const EXIT_FINDINGS = 2
24
+
16
25
  interface ArchiveCommandOptions {
17
26
  readonly json?: boolean
18
27
  readonly pullRequest?: string
19
28
  readonly root?: string
20
29
  }
21
30
 
31
+ interface ValidateCommandOptions {
32
+ readonly json?: boolean
33
+ readonly root?: string
34
+ }
35
+
22
36
  /**
23
37
  * The board is shared scratch at the main worktree root, and `git worktree
24
38
  * list` puts that root first. A pull inside a linked worktree fires the same
@@ -71,6 +85,105 @@ export function register(program: Command): void {
71
85
  .action(async (task: string | undefined, opts: ArchiveCommandOptions) => {
72
86
  process.exitCode = await runArchive(task, opts)
73
87
  })
88
+
89
+ tasks
90
+ .command('validate')
91
+ .description(
92
+ 'Report what each board row claims against what the tree holds',
93
+ )
94
+ .helpOption('-h, --help', 'Show this help message')
95
+ .option('--json', 'Add a machine-readable record on stdout')
96
+ .option('--root <path>', 'Board root, defaulting to the main worktree')
97
+ .addHelpText(
98
+ 'after',
99
+ [
100
+ '',
101
+ 'Checks:',
102
+ ' every Run now row points at a plan file that resolves',
103
+ ' every row maps to a task file and every task file to a row',
104
+ ' no task carries more than one row',
105
+ ' no two Run now rows touch the same file',
106
+ '',
107
+ 'Exit codes:',
108
+ ' 0 every check passed',
109
+ ' 1 refused, with the reason on stderr or in the JSON record',
110
+ ' 2 the board carries at least one finding',
111
+ '',
112
+ 'It reports and never writes. A row is a claim about readiness, so a',
113
+ 'session fixes the row the report names.',
114
+ '',
115
+ 'Examples:',
116
+ ' aitk tasks validate',
117
+ ' aitk tasks validate --json',
118
+ '',
119
+ ].join('\n'),
120
+ )
121
+ .action(async (opts: ValidateCommandOptions) => {
122
+ process.exitCode = await runValidate(opts)
123
+ })
124
+ }
125
+
126
+ async function runValidate(opts: ValidateCommandOptions): Promise<number> {
127
+ const root = opts.root ?? (await mainWorktreeRoot())
128
+ const outcome = await validateBoard(root)
129
+
130
+ return reportValidation(outcome, opts.json ?? false, root)
131
+ }
132
+
133
+ function reportValidation(
134
+ outcome: ValidateOutcome,
135
+ emitJson: boolean,
136
+ root: string,
137
+ ): number {
138
+ if (!outcome.ok) {
139
+ if (emitJson) {
140
+ process.stdout.write(
141
+ `${JSON.stringify({ ok: false, reason: outcome.reason, message: outcome.message })}\n`,
142
+ )
143
+ return 1
144
+ }
145
+
146
+ intro('aitk tasks validate')
147
+ logStep('Refused')
148
+ logError(outcome.message)
149
+ outro()
150
+ return 1
151
+ }
152
+
153
+ if (!emitJson) {
154
+ intro('aitk tasks validate')
155
+ logStep('Board')
156
+ logInfo(
157
+ `${outcome.rows} row(s) across the readiness groups, ${outcome.tasks} task file(s)`,
158
+ )
159
+
160
+ logStep(outcome.findings.length === 0 ? 'Clean' : 'Findings')
161
+ if (outcome.findings.length === 0) {
162
+ logInfo('every row resolves, maps one to one, and touches its own files')
163
+ } else {
164
+ for (const finding of outcome.findings) logWarn(describe(finding))
165
+ }
166
+ outro()
167
+ }
168
+
169
+ if (emitJson) {
170
+ process.stdout.write(
171
+ `${JSON.stringify({
172
+ ok: true,
173
+ root,
174
+ rows: outcome.rows,
175
+ tasks: outcome.tasks,
176
+ findings: outcome.findings,
177
+ })}\n`,
178
+ )
179
+ }
180
+
181
+ return outcome.findings.length > 0 ? EXIT_FINDINGS : 0
182
+ }
183
+
184
+ function describe(finding: Finding): string {
185
+ const scope = finding.group ? `${finding.group}: ` : ''
186
+ return `${scope}${finding.subject} ${finding.message}`
74
187
  }
75
188
 
76
189
  async function runArchive(
@@ -8,10 +8,12 @@ const ARCHIVE_DIR = join('.claude', '.tmp', 'task-archive')
8
8
  const PLANS_DIR = join('.claude', 'plans')
9
9
 
10
10
  /**
11
- * Generated and hand-maintained siblings that sit on the board without being
12
- * tasks. The post-merge hook skips the same two names.
11
+ * Siblings that sit on the board without being tasks: the generated index, the
12
+ * hand-maintained ordering, and the pre-compaction session map. `validate`
13
+ * reads the same list, so neither verb can count a sibling as a task the other
14
+ * does not.
13
15
  */
14
- const RESERVED_STEMS = ['index', 'priority'] as const
16
+ export const RESERVED_STEMS = ['index', 'priority', 'session'] as const
15
17
 
16
18
  export const ARCHIVE_REFUSALS = [
17
19
  'no-board',