@erclx/aitk 0.44.0 → 0.46.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 +15 -10
- package/claude/skills/claude-docs/SKILL.md +16 -2
- package/claude/skills/claude-memory-capture/REQUIREMENT.md +14 -4
- package/claude/skills/claude-memory-capture/SKILL.md +45 -16
- package/claude/skills/claude-memory-review/REQUIREMENT.md +8 -2
- package/claude/skills/claude-memory-review/SKILL.md +41 -25
- package/claude/skills/claude-orchestrate/SKILL.md +5 -0
- package/claude/skills/claude-seed-sync/REQUIREMENT.md +3 -1
- package/claude/skills/claude-seed-sync/SKILL.md +16 -4
- package/claude/skills/git-ship/SKILL.md +14 -11
- package/claude/skills/session-resume/SKILL.md +2 -2
- package/claude/skills/toolkit-operator/SKILL.md +18 -1
- package/docs/agents/commands.md +36 -35
- package/docs/agents/index.md +1 -0
- package/docs/agents/indexes.md +3 -1
- package/docs/agents/install-and-sync.md +46 -0
- package/docs/agents/scripting.md +4 -3
- package/docs/agents/skills-audit.md +55 -0
- package/docs/ai-workflow.md +3 -2
- package/docs/target-projects.md +5 -1
- package/package.json +1 -1
- package/scripts/core/verify.sh +8 -0
- package/src/claude/seeds.ts +7 -1
- package/src/claude/skills-audit.ts +215 -0
- package/src/claude/skills-list.ts +3 -3
- package/src/commands/claude.ts +283 -5
- package/src/commands/context.ts +1 -4
- package/src/commands/sync.ts +54 -1
- package/src/sync/check.ts +71 -0
- package/src/sync/layout.ts +139 -0
- package/src/sync/seeds-report.ts +111 -0
- package/src/ui.ts +5 -0
- package/tooling/claude/reference.md +11 -1
- package/tooling/claude/seeds/.claude/hooks/memory-index.sh +60 -0
- package/tooling/claude/seeds/.claude/memory/index.md +8 -0
- package/tooling/claude/seeds/.claude/settings.json +4 -0
- package/tooling/claude/seeds/CLAUDE.md +3 -0
|
@@ -17,9 +17,9 @@ export interface SkillListing {
|
|
|
17
17
|
* The folder name wins over the frontmatter `name` when they disagree, because
|
|
18
18
|
* Claude Code invokes a skill by its directory.
|
|
19
19
|
*
|
|
20
|
-
* `requirement` reports whether the folder carries `REQUIREMENT.md`.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* `requirement` reports whether the folder carries `REQUIREMENT.md`. Every skill
|
|
21
|
+
* is meant to carry one, so a false is a gap rather than a recorded exemption.
|
|
22
|
+
* `aitk claude skills audit` is what fails on it, across both corpora.
|
|
23
23
|
*/
|
|
24
24
|
export function listSkills(root: string): SkillListing[] {
|
|
25
25
|
const skillsRoot = join(root, 'claude', 'skills')
|
package/src/commands/claude.ts
CHANGED
|
@@ -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]', "
|
|
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
|
+
}
|
package/src/commands/context.ts
CHANGED
|
@@ -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/commands/sync.ts
CHANGED
|
@@ -95,6 +95,16 @@ async function runCheck(target: string, options: SyncOptions): Promise<number> {
|
|
|
95
95
|
function renderCheck(report: CheckReport): void {
|
|
96
96
|
intro('aitk sync --check')
|
|
97
97
|
|
|
98
|
+
if (!report.managed) {
|
|
99
|
+
logStep('Not a toolkit project')
|
|
100
|
+
logWarn('No .claude/ directory and no CLAUDE.md at the target.')
|
|
101
|
+
logInfo(
|
|
102
|
+
'Run `aitk init` to install, or /aitk:setup-init to resolve a stack.',
|
|
103
|
+
)
|
|
104
|
+
outro()
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
98
108
|
for (const domain of report.domains) {
|
|
99
109
|
logStep(domain.domain)
|
|
100
110
|
|
|
@@ -123,14 +133,33 @@ function renderCheck(report: CheckReport): void {
|
|
|
123
133
|
}
|
|
124
134
|
}
|
|
125
135
|
|
|
136
|
+
for (const entry of report.unmigrated) {
|
|
137
|
+
logStep(`${entry.domain} (not migrated)`)
|
|
138
|
+
logWarn(
|
|
139
|
+
`${entry.files} files at ${entry.rootPath}/, nothing at ${entry.installPath}/`,
|
|
140
|
+
)
|
|
141
|
+
logInfo('Run /aitk:migration-standards to relocate them.')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
renderSeeds(report)
|
|
145
|
+
|
|
146
|
+
if (report.superseded.length > 0) {
|
|
147
|
+
logStep('Superseded by a newer layout')
|
|
148
|
+
for (const entry of report.superseded) {
|
|
149
|
+
logWarn(`${entry.rel} (replaced by ${entry.replacedBy}/)`)
|
|
150
|
+
}
|
|
151
|
+
logInfo('Move the content yourself. No sync command touches these.')
|
|
152
|
+
}
|
|
153
|
+
|
|
126
154
|
if (report.newSkills.length > 0) {
|
|
127
155
|
logStep('New skills, no sync needed')
|
|
128
156
|
for (const name of report.newSkills) logInfo(name)
|
|
129
157
|
}
|
|
130
158
|
|
|
131
159
|
outro()
|
|
160
|
+
const unmigrated = report.unmigrated.map((entry) => entry.domain)
|
|
132
161
|
const uncovered = STAMP_DOMAINS.filter(
|
|
133
|
-
(domain) => !report.covers.includes(domain),
|
|
162
|
+
(domain) => !report.covers.includes(domain) && !unmigrated.includes(domain),
|
|
134
163
|
)
|
|
135
164
|
const unstamped =
|
|
136
165
|
uncovered.length === 0 ? '' : `Unstamped: ${uncovered.join(', ')}. `
|
|
@@ -139,6 +168,30 @@ function renderCheck(report: CheckReport): void {
|
|
|
139
168
|
)
|
|
140
169
|
}
|
|
141
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Seeds print their own section because no sync command applies them. A `stale`
|
|
173
|
+
* seed is safe to take whole and a `drifted` one holds edits, which is the split
|
|
174
|
+
* `claude-seed-sync` reads to decide what needs a section-level merge.
|
|
175
|
+
*/
|
|
176
|
+
function renderSeeds(report: CheckReport): void {
|
|
177
|
+
const notable = report.seeds.entries.filter(
|
|
178
|
+
(entry) => entry.state !== 'matching',
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if (notable.length === 0) return
|
|
182
|
+
|
|
183
|
+
logStep('seeds')
|
|
184
|
+
if (report.seeds.historyUnavailable) {
|
|
185
|
+
logWarn('This toolkit has no git history. Drift below is unattributed.')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const entry of notable) {
|
|
189
|
+
logWarn(`${entry.rel} (${entry.state})`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
logInfo('Run /aitk:claude-seed-sync to reconcile these section by section.')
|
|
193
|
+
}
|
|
194
|
+
|
|
142
195
|
async function runSync(target: string): Promise<number> {
|
|
143
196
|
intro('aitk sync')
|
|
144
197
|
|
package/src/sync/check.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
1
2
|
import { join } from 'node:path'
|
|
2
3
|
import { execa } from 'execa'
|
|
3
4
|
import { createGovAdapter } from '@/gov/adapter'
|
|
4
5
|
import { createSnippetsAdapter } from '@/snippets/adapter'
|
|
5
6
|
import { planSync, type ScanEntry, type SyncAdapter } from '@/sync/engine'
|
|
7
|
+
import {
|
|
8
|
+
collectSuperseded,
|
|
9
|
+
detectUnmigrated,
|
|
10
|
+
type SupersededEntry,
|
|
11
|
+
type UnmigratedDomain,
|
|
12
|
+
} from '@/sync/layout'
|
|
13
|
+
import { buildSeedsReport, type SeedsReport } from '@/sync/seeds-report'
|
|
6
14
|
import {
|
|
7
15
|
readStamp,
|
|
8
16
|
STAMP_DOMAINS,
|
|
@@ -68,7 +76,16 @@ export interface UpstreamCommit {
|
|
|
68
76
|
|
|
69
77
|
export interface CheckReport {
|
|
70
78
|
readonly covers: readonly StampDomain[]
|
|
79
|
+
/** False when the target is not a toolkit project, so every section stays empty. */
|
|
80
|
+
readonly managed: boolean
|
|
71
81
|
readonly domains: readonly DomainReport[]
|
|
82
|
+
/**
|
|
83
|
+
* Reported beside the domains rather than as one of them, because seeds carry
|
|
84
|
+
* no stamp and produce no change. See `@/sync/seeds-report`.
|
|
85
|
+
*/
|
|
86
|
+
readonly seeds: SeedsReport
|
|
87
|
+
readonly superseded: readonly SupersededEntry[]
|
|
88
|
+
readonly unmigrated: readonly UnmigratedDomain[]
|
|
72
89
|
readonly newSkills: readonly string[]
|
|
73
90
|
}
|
|
74
91
|
|
|
@@ -78,6 +95,33 @@ export function installedStampDomains(target: string): StampDomain[] {
|
|
|
78
95
|
)
|
|
79
96
|
}
|
|
80
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Whether the target is a toolkit-managed project at all. Seeds are enumerated
|
|
100
|
+
* from the source rather than from what a target installed, so without this gate
|
|
101
|
+
* a directory the toolkit has never touched reports every seed as `missing` and
|
|
102
|
+
* routes to a skill that reconciles section by section. `installedStampDomains`
|
|
103
|
+
* gates the three scanned domains the same way, which is why they stay quiet on
|
|
104
|
+
* the same directory.
|
|
105
|
+
*
|
|
106
|
+
* An unmigrated domain counts as a marker in its own right. `detectUnmigrated`
|
|
107
|
+
* fires only on root files whose basename the toolkit ships, so it firing proves
|
|
108
|
+
* the toolkit installed here before the layout moved under `.claude/`. Reading
|
|
109
|
+
* only the markers would report such a target as unmanaged while the same report
|
|
110
|
+
* carried its unmigrated domain, and a consumer reading the JSON would route to
|
|
111
|
+
* the relocation while the rendered half routed to install.
|
|
112
|
+
*/
|
|
113
|
+
export function isManagedTarget(
|
|
114
|
+
target: string,
|
|
115
|
+
unmigrated: readonly UnmigratedDomain[],
|
|
116
|
+
): boolean {
|
|
117
|
+
if (unmigrated.length > 0) return true
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
isDirectory(join(target, '.claude')) ||
|
|
121
|
+
existsSync(join(target, 'CLAUDE.md'))
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
81
125
|
export function countStates(entries: readonly ScanEntry[]): StateCounts {
|
|
82
126
|
return {
|
|
83
127
|
matching: count(entries, 'matching'),
|
|
@@ -93,8 +137,16 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
|
|
|
93
137
|
* Whether the target has diverged from the toolkit in a way a sync could close.
|
|
94
138
|
* Orphaned files are excluded: a project-authored rule never converges, and
|
|
95
139
|
* counting it would leave `--exit-code` failing forever with no remedy.
|
|
140
|
+
*
|
|
141
|
+
* An unmigrated domain counts, because running the relocation closes it. A
|
|
142
|
+
* superseded artifact does not, for the same reason orphaned files do not: only
|
|
143
|
+
* the user can move content they wrote, so failing a job on it leaves the job
|
|
144
|
+
* red with no mechanical remedy. Seeds are excluded on the same grounds, since
|
|
145
|
+
* every seed a project edits would otherwise fail the check forever.
|
|
96
146
|
*/
|
|
97
147
|
export function hasDrift(report: CheckReport): boolean {
|
|
148
|
+
if (report.unmigrated.length > 0) return true
|
|
149
|
+
|
|
98
150
|
return report.domains.some(
|
|
99
151
|
(domain) =>
|
|
100
152
|
domain.counts.stale +
|
|
@@ -126,9 +178,28 @@ export async function buildCheckReport(
|
|
|
126
178
|
.map((domain) => domain.commit)
|
|
127
179
|
.filter((commit): commit is string => commit !== undefined)
|
|
128
180
|
|
|
181
|
+
const unmigrated = detectUnmigrated(toolkitRoot, target)
|
|
182
|
+
const managed = isManagedTarget(target, unmigrated)
|
|
183
|
+
|
|
184
|
+
if (!managed) {
|
|
185
|
+
return {
|
|
186
|
+
covers: [],
|
|
187
|
+
managed,
|
|
188
|
+
domains: [],
|
|
189
|
+
seeds: { entries: [], historyUnavailable: false },
|
|
190
|
+
superseded: [],
|
|
191
|
+
unmigrated: [],
|
|
192
|
+
newSkills: [],
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
129
196
|
return {
|
|
130
197
|
covers: stamp?.covers ?? [],
|
|
198
|
+
managed,
|
|
131
199
|
domains,
|
|
200
|
+
seeds: buildSeedsReport(toolkitRoot, target),
|
|
201
|
+
superseded: collectSuperseded(target),
|
|
202
|
+
unmigrated,
|
|
132
203
|
newSkills: await readNewSkills(toolkitRoot, anchors),
|
|
133
204
|
}
|
|
134
205
|
}
|