skill-automation-package 0.2.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/LICENSE +21 -0
- package/README.md +441 -0
- package/assets/.claude/skills/project-skill-router/SKILL.md +32 -0
- package/assets/.claude/skills/project-skill-router/skill.json +50 -0
- package/assets/.claude/tests/test_skill_agent.py +508 -0
- package/assets/.claude/tools/skill_agent.py +2501 -0
- package/bin/skill-automation-package.js +402 -0
- package/package.json +42 -0
- package/scripts/install.py +208 -0
- package/scripts/package_layout.py +108 -0
- package/templates/agents_block.md +20 -0
- package/templates/claude_block.md +11 -0
|
@@ -0,0 +1,2501 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from datetime import UTC
|
|
15
|
+
except ImportError:
|
|
16
|
+
from datetime import timezone
|
|
17
|
+
|
|
18
|
+
UTC = timezone.utc
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
STOPWORDS = {
|
|
22
|
+
"a",
|
|
23
|
+
"an",
|
|
24
|
+
"and",
|
|
25
|
+
"are",
|
|
26
|
+
"as",
|
|
27
|
+
"at",
|
|
28
|
+
"be",
|
|
29
|
+
"by",
|
|
30
|
+
"for",
|
|
31
|
+
"from",
|
|
32
|
+
"how",
|
|
33
|
+
"in",
|
|
34
|
+
"into",
|
|
35
|
+
"is",
|
|
36
|
+
"it",
|
|
37
|
+
"of",
|
|
38
|
+
"on",
|
|
39
|
+
"or",
|
|
40
|
+
"that",
|
|
41
|
+
"the",
|
|
42
|
+
"this",
|
|
43
|
+
"to",
|
|
44
|
+
"use",
|
|
45
|
+
"when",
|
|
46
|
+
"with",
|
|
47
|
+
"within",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
ACTION_WORDS = {
|
|
51
|
+
"add",
|
|
52
|
+
"build",
|
|
53
|
+
"create",
|
|
54
|
+
"debug",
|
|
55
|
+
"draft",
|
|
56
|
+
"edit",
|
|
57
|
+
"find",
|
|
58
|
+
"fix",
|
|
59
|
+
"generate",
|
|
60
|
+
"handle",
|
|
61
|
+
"implement",
|
|
62
|
+
"improve",
|
|
63
|
+
"inspect",
|
|
64
|
+
"investigate",
|
|
65
|
+
"make",
|
|
66
|
+
"refactor",
|
|
67
|
+
"review",
|
|
68
|
+
"route",
|
|
69
|
+
"scaffold",
|
|
70
|
+
"search",
|
|
71
|
+
"summarize",
|
|
72
|
+
"support",
|
|
73
|
+
"test",
|
|
74
|
+
"update",
|
|
75
|
+
"verify",
|
|
76
|
+
"write",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
LOW_SIGNAL_TOKENS = STOPWORDS | ACTION_WORDS | {
|
|
80
|
+
"agent",
|
|
81
|
+
"agents",
|
|
82
|
+
"app",
|
|
83
|
+
"code",
|
|
84
|
+
"feature",
|
|
85
|
+
"file",
|
|
86
|
+
"files",
|
|
87
|
+
"issue",
|
|
88
|
+
"issues",
|
|
89
|
+
"problem",
|
|
90
|
+
"problems",
|
|
91
|
+
"project",
|
|
92
|
+
"repo",
|
|
93
|
+
"repository",
|
|
94
|
+
"request",
|
|
95
|
+
"screen",
|
|
96
|
+
"task",
|
|
97
|
+
"workflow",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
DEFAULT_CATEGORY = "workflow"
|
|
101
|
+
|
|
102
|
+
CATEGORY_RULES: dict[str, dict[str, Any]] = {
|
|
103
|
+
"ios": {
|
|
104
|
+
"keywords": [
|
|
105
|
+
"ios",
|
|
106
|
+
"ipad",
|
|
107
|
+
"iphone",
|
|
108
|
+
"swift",
|
|
109
|
+
"swiftui",
|
|
110
|
+
"xcode",
|
|
111
|
+
"cloudkit",
|
|
112
|
+
"firebase",
|
|
113
|
+
"ocr",
|
|
114
|
+
"vision",
|
|
115
|
+
"modelcontext",
|
|
116
|
+
"swiftdata",
|
|
117
|
+
],
|
|
118
|
+
"steps": [
|
|
119
|
+
"Inspect the touched Swift, SwiftUI, and service files before changing behavior.",
|
|
120
|
+
"Preserve the layer boundaries and platform rules already documented in CLAUDE.md.",
|
|
121
|
+
"Implement the smallest safe change for the requested iOS workflow and keep state updates explicit.",
|
|
122
|
+
"Run the narrowest relevant build or test command, or record the exact reason verification could not run.",
|
|
123
|
+
],
|
|
124
|
+
"validation": [
|
|
125
|
+
"Run the smallest relevant `xcodebuild` build or test for the touched code.",
|
|
126
|
+
"Re-check `CLAUDE.md` rules if the change touches `Domain/`, `Presentation/`, or Firebase integration.",
|
|
127
|
+
"Confirm UI-state and async behavior on the affected screen when presentation code changes.",
|
|
128
|
+
],
|
|
129
|
+
"examples": [
|
|
130
|
+
"Debug OCR extraction regressions in the iOS app without breaking the domain boundaries.",
|
|
131
|
+
"Investigate CloudKit or Firebase-related app behavior and verify the affected workflow.",
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
"frontend": {
|
|
135
|
+
"keywords": [
|
|
136
|
+
"frontend",
|
|
137
|
+
"react",
|
|
138
|
+
"tsx",
|
|
139
|
+
"tailwind",
|
|
140
|
+
"vite",
|
|
141
|
+
"css",
|
|
142
|
+
"ui",
|
|
143
|
+
"ux",
|
|
144
|
+
"component",
|
|
145
|
+
"page",
|
|
146
|
+
"browser",
|
|
147
|
+
"playwright",
|
|
148
|
+
"vitest",
|
|
149
|
+
],
|
|
150
|
+
"steps": [
|
|
151
|
+
"Inspect the current page, component, and styling patterns before changing UI behavior.",
|
|
152
|
+
"Keep the existing design language and interaction model consistent unless the task explicitly changes them.",
|
|
153
|
+
"Implement the narrowest component, route, or styling changes needed for the request.",
|
|
154
|
+
"Run the smallest relevant frontend test or build step and capture any remaining manual QA gap.",
|
|
155
|
+
],
|
|
156
|
+
"validation": [
|
|
157
|
+
"Run the relevant unit, integration, or build command for the touched frontend area.",
|
|
158
|
+
"Check the affected UI states, loading paths, and empty states if the change is user-facing.",
|
|
159
|
+
"Verify that class names, route wiring, and shared utilities still align after the edit.",
|
|
160
|
+
],
|
|
161
|
+
"examples": [
|
|
162
|
+
"Refine a React page or component while preserving the existing design system.",
|
|
163
|
+
"Fix a frontend interaction regression and verify the result with targeted tests or build checks.",
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
"docs": {
|
|
167
|
+
"keywords": [
|
|
168
|
+
"doc",
|
|
169
|
+
"docs",
|
|
170
|
+
"documentation",
|
|
171
|
+
"markdown",
|
|
172
|
+
"readme",
|
|
173
|
+
"policy",
|
|
174
|
+
"spec",
|
|
175
|
+
"srs",
|
|
176
|
+
"draft",
|
|
177
|
+
"summary",
|
|
178
|
+
"writeup",
|
|
179
|
+
"compliance",
|
|
180
|
+
"privacy",
|
|
181
|
+
],
|
|
182
|
+
"steps": [
|
|
183
|
+
"Gather the smallest set of source artifacts needed to write the document accurately.",
|
|
184
|
+
"Extract decisions, dates, and constraints directly from source material before drafting new text.",
|
|
185
|
+
"Write the document in a structure that optimizes quick scanning and future reuse.",
|
|
186
|
+
"Proofread headings, links, dates, and factual claims against the source files before finishing.",
|
|
187
|
+
],
|
|
188
|
+
"validation": [
|
|
189
|
+
"Re-check names, dates, and references against the source documents after drafting.",
|
|
190
|
+
"Ensure headings and section ordering match the intended audience and file conventions.",
|
|
191
|
+
"Remove unsupported claims and keep the output grounded in the cited project artifacts.",
|
|
192
|
+
],
|
|
193
|
+
"examples": [
|
|
194
|
+
"Draft a project summary, policy, or implementation note from current repository documents.",
|
|
195
|
+
"Update Markdown documentation so it matches the latest code or planning state.",
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
"testing": {
|
|
199
|
+
"keywords": [
|
|
200
|
+
"test",
|
|
201
|
+
"tests",
|
|
202
|
+
"unittest",
|
|
203
|
+
"integration",
|
|
204
|
+
"regression",
|
|
205
|
+
"qa",
|
|
206
|
+
"assertion",
|
|
207
|
+
"coverage",
|
|
208
|
+
"verify",
|
|
209
|
+
"verification",
|
|
210
|
+
"failing",
|
|
211
|
+
"failure",
|
|
212
|
+
],
|
|
213
|
+
"steps": [
|
|
214
|
+
"Identify the exact behavior that should fail or pass before changing the test surface.",
|
|
215
|
+
"Locate the smallest existing test scope that already covers the affected workflow.",
|
|
216
|
+
"Add or adjust targeted tests so they prove the intended behavior rather than implementation trivia.",
|
|
217
|
+
"Run the relevant test command and summarize what still remains unverified.",
|
|
218
|
+
],
|
|
219
|
+
"validation": [
|
|
220
|
+
"Run the narrowest test target that exercises the changed behavior.",
|
|
221
|
+
"Check that the new assertions would fail without the intended fix.",
|
|
222
|
+
"Record any intentionally skipped test coverage with the reason and remaining risk.",
|
|
223
|
+
],
|
|
224
|
+
"examples": [
|
|
225
|
+
"Add or adjust targeted regression tests for a known failing workflow.",
|
|
226
|
+
"Narrow down a failing test surface and leave behind stable coverage for the fix.",
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
"github": {
|
|
230
|
+
"keywords": [
|
|
231
|
+
"github",
|
|
232
|
+
"pull request",
|
|
233
|
+
"pr",
|
|
234
|
+
"review",
|
|
235
|
+
"reviewer",
|
|
236
|
+
"comment",
|
|
237
|
+
"issue",
|
|
238
|
+
"actions",
|
|
239
|
+
"workflow run",
|
|
240
|
+
"checks",
|
|
241
|
+
"ci",
|
|
242
|
+
],
|
|
243
|
+
"steps": [
|
|
244
|
+
"Resolve the exact repository, PR, issue, or branch context before acting.",
|
|
245
|
+
"Inspect the smallest amount of review, diff, or CI context needed for the request.",
|
|
246
|
+
"Apply the requested GitHub-side change or related code change without broadening scope.",
|
|
247
|
+
"Re-check status, comments, or requested-change state so the outcome is explicit.",
|
|
248
|
+
],
|
|
249
|
+
"validation": [
|
|
250
|
+
"Verify the target PR, issue, or branch before writing comments or applying labels.",
|
|
251
|
+
"Re-check unresolved review or CI state after making the requested change.",
|
|
252
|
+
"Summarize what changed, what remains open, and what evidence supports the conclusion.",
|
|
253
|
+
],
|
|
254
|
+
"examples": [
|
|
255
|
+
"Address actionable pull request feedback and summarize what still remains.",
|
|
256
|
+
"Inspect a GitHub PR or issue, handle the requested task, and verify the updated state.",
|
|
257
|
+
],
|
|
258
|
+
},
|
|
259
|
+
"backend": {
|
|
260
|
+
"keywords": [
|
|
261
|
+
"api",
|
|
262
|
+
"backend",
|
|
263
|
+
"server",
|
|
264
|
+
"endpoint",
|
|
265
|
+
"database",
|
|
266
|
+
"db",
|
|
267
|
+
"migration",
|
|
268
|
+
"schema",
|
|
269
|
+
"service",
|
|
270
|
+
"auth",
|
|
271
|
+
"query",
|
|
272
|
+
"worker",
|
|
273
|
+
],
|
|
274
|
+
"steps": [
|
|
275
|
+
"Inspect the current contract, schema, and call sites before changing backend behavior.",
|
|
276
|
+
"Keep API boundaries, data shape, and error handling explicit while implementing the change.",
|
|
277
|
+
"Update the narrowest service or persistence layer needed for the request.",
|
|
278
|
+
"Run the relevant tests or contract checks and record any follow-up risk.",
|
|
279
|
+
],
|
|
280
|
+
"validation": [
|
|
281
|
+
"Run the smallest relevant backend test or validation command for the touched code path.",
|
|
282
|
+
"Check contract shape, migrations, and error handling if the request changes data flow.",
|
|
283
|
+
"Verify dependent call sites still match the updated interface after the edit.",
|
|
284
|
+
],
|
|
285
|
+
"examples": [
|
|
286
|
+
"Implement or debug an API or database workflow while preserving contract compatibility.",
|
|
287
|
+
"Investigate a backend service issue and verify the fix with targeted checks.",
|
|
288
|
+
],
|
|
289
|
+
},
|
|
290
|
+
"workflow": {
|
|
291
|
+
"keywords": [
|
|
292
|
+
"workflow",
|
|
293
|
+
"automation",
|
|
294
|
+
"agent",
|
|
295
|
+
"reuse",
|
|
296
|
+
"search",
|
|
297
|
+
"route",
|
|
298
|
+
"scaffold",
|
|
299
|
+
"template",
|
|
300
|
+
"orchestration",
|
|
301
|
+
],
|
|
302
|
+
"steps": [
|
|
303
|
+
"Clarify the repeatable workflow, its trigger conditions, and the intended reusable outcome.",
|
|
304
|
+
"Search for existing local skills, scripts, references, or assets before creating new ones.",
|
|
305
|
+
"Capture the reusable steps and the minimum metadata needed for future discovery.",
|
|
306
|
+
"Validate that another agent could find and reuse the resulting workflow with minimal context.",
|
|
307
|
+
],
|
|
308
|
+
"validation": [
|
|
309
|
+
"Check that the workflow is discoverable by name, tags, or trigger phrases.",
|
|
310
|
+
"Confirm the documented steps are short, procedural, and actually reusable.",
|
|
311
|
+
"Refresh the skill registry after any change so future agents can resolve the skill immediately.",
|
|
312
|
+
],
|
|
313
|
+
"examples": [
|
|
314
|
+
"Create or refine a repeatable local workflow that other agents can search and reuse.",
|
|
315
|
+
"Search for an existing reusable process before inventing a new one.",
|
|
316
|
+
],
|
|
317
|
+
},
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
TITLE_CASE_OVERRIDES = {
|
|
321
|
+
"api": "API",
|
|
322
|
+
"ci": "CI",
|
|
323
|
+
"claude": "Claude",
|
|
324
|
+
"cloudkit": "CloudKit",
|
|
325
|
+
"firebase": "Firebase",
|
|
326
|
+
"github": "GitHub",
|
|
327
|
+
"ios": "iOS",
|
|
328
|
+
"ipad": "iPad",
|
|
329
|
+
"iphone": "iPhone",
|
|
330
|
+
"ocr": "OCR",
|
|
331
|
+
"pr": "PR",
|
|
332
|
+
"swiftui": "SwiftUI",
|
|
333
|
+
"ui": "UI",
|
|
334
|
+
"ux": "UX",
|
|
335
|
+
"xcode": "Xcode",
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
PROTECTED_SKILLS = {"project-skill-router", "omc-reference"}
|
|
339
|
+
ARCHIVE_DIRNAME = "_archived"
|
|
340
|
+
USAGE_FILENAME = "usage.json"
|
|
341
|
+
USAGE_HISTORY_LIMIT = 12
|
|
342
|
+
ACTIVE_RECENT_DAYS = 14
|
|
343
|
+
PRUNE_NEVER_REUSED_DAYS = 21
|
|
344
|
+
PRUNE_SINGLE_REUSE_DAYS = 45
|
|
345
|
+
PRUNE_LOW_REUSE_DAYS = 90
|
|
346
|
+
DEFAULT_MANAGEMENT_MODE = "patch"
|
|
347
|
+
REFRESH_STALE_DAYS = 45
|
|
348
|
+
REFRESH_LOW_SCORE_THRESHOLD = 10.0
|
|
349
|
+
REFRESH_SCORE_HISTORY_LIMIT = 8
|
|
350
|
+
REFRESH_TAG_LIMIT = 8
|
|
351
|
+
REFRESH_TRIGGER_LIMIT = 6
|
|
352
|
+
REFRESH_EXAMPLE_LIMIT = 6
|
|
353
|
+
SUBTASK_ROUTING_COMMAND = 'python3 .claude/tools/skill_agent.py auto "<sub-task>" --json'
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@dataclass(slots=True)
|
|
357
|
+
class SkillRecord:
|
|
358
|
+
name: str
|
|
359
|
+
path: str
|
|
360
|
+
description: str
|
|
361
|
+
category: str
|
|
362
|
+
tags: list[str]
|
|
363
|
+
triggers: list[str]
|
|
364
|
+
summary: str
|
|
365
|
+
steps: list[str]
|
|
366
|
+
related_skills: list[str]
|
|
367
|
+
validation: list[str]
|
|
368
|
+
examples: list[str]
|
|
369
|
+
title: str
|
|
370
|
+
management_mode: str
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@dataclass(slots=True)
|
|
374
|
+
class SkillBlueprint:
|
|
375
|
+
name: str
|
|
376
|
+
title: str
|
|
377
|
+
description: str
|
|
378
|
+
category: str
|
|
379
|
+
summary: str
|
|
380
|
+
tags: list[str]
|
|
381
|
+
triggers: list[str]
|
|
382
|
+
steps: list[str]
|
|
383
|
+
related_skills: list[str]
|
|
384
|
+
validation: list[str]
|
|
385
|
+
examples: list[str]
|
|
386
|
+
source_task: str
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@dataclass(slots=True)
|
|
390
|
+
class SkillRefreshPlan:
|
|
391
|
+
name: str
|
|
392
|
+
path: str
|
|
393
|
+
status: str
|
|
394
|
+
reason: str
|
|
395
|
+
management_mode: str
|
|
396
|
+
changes: dict[str, Any]
|
|
397
|
+
recent_tasks: list[str]
|
|
398
|
+
reuse_count: int
|
|
399
|
+
avg_score: float | None
|
|
400
|
+
updated_days: int
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@dataclass(slots=True, frozen=True)
|
|
404
|
+
class CliArgument:
|
|
405
|
+
flags: tuple[str, ...]
|
|
406
|
+
kwargs: dict[str, Any]
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@dataclass(slots=True, frozen=True)
|
|
410
|
+
class CliCommand:
|
|
411
|
+
name: str
|
|
412
|
+
help: str
|
|
413
|
+
handler: Callable[[argparse.Namespace], int]
|
|
414
|
+
arguments: tuple[CliArgument, ...] = ()
|
|
415
|
+
configure: Callable[[argparse.ArgumentParser], None] | None = None
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def main() -> int:
|
|
419
|
+
parser = build_parser()
|
|
420
|
+
args = parser.parse_args()
|
|
421
|
+
return args.func(args)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
425
|
+
parser = argparse.ArgumentParser(
|
|
426
|
+
description="Search, rank, scaffold, and index repo-local Claude skills."
|
|
427
|
+
)
|
|
428
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
429
|
+
|
|
430
|
+
for command in build_command_specs():
|
|
431
|
+
command_parser = subparsers.add_parser(command.name, help=command.help)
|
|
432
|
+
add_shared_location_args(command_parser)
|
|
433
|
+
if command.configure:
|
|
434
|
+
command.configure(command_parser)
|
|
435
|
+
add_cli_arguments(command_parser, command.arguments)
|
|
436
|
+
command_parser.set_defaults(func=command.handler)
|
|
437
|
+
|
|
438
|
+
return parser
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def add_cli_arguments(
|
|
442
|
+
parser: argparse.ArgumentParser,
|
|
443
|
+
arguments: tuple[CliArgument, ...],
|
|
444
|
+
) -> None:
|
|
445
|
+
for argument in arguments:
|
|
446
|
+
parser.add_argument(*argument.flags, **argument.kwargs)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def add_shared_location_args(parser: argparse.ArgumentParser) -> None:
|
|
450
|
+
parser.add_argument(
|
|
451
|
+
"--repo-root",
|
|
452
|
+
type=Path,
|
|
453
|
+
help="Repository root. Defaults to auto-detecting from the current working directory.",
|
|
454
|
+
)
|
|
455
|
+
parser.add_argument(
|
|
456
|
+
"--skills-dir",
|
|
457
|
+
type=Path,
|
|
458
|
+
help="Skills directory. Defaults to <repo-root>/.claude/skills.",
|
|
459
|
+
)
|
|
460
|
+
parser.add_argument(
|
|
461
|
+
"--registry-path",
|
|
462
|
+
type=Path,
|
|
463
|
+
help="Registry path. Defaults to <skills-dir>/registry.json.",
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def add_create_arguments(parser: argparse.ArgumentParser) -> None:
|
|
468
|
+
parser.add_argument("name", help="Skill name. Will be normalized to hyphen-case.")
|
|
469
|
+
parser.add_argument("--summary", required=True, help="What the skill does.")
|
|
470
|
+
parser.add_argument(
|
|
471
|
+
"--when",
|
|
472
|
+
required=True,
|
|
473
|
+
help="When the agent should use the skill.",
|
|
474
|
+
)
|
|
475
|
+
parser.add_argument(
|
|
476
|
+
"--category",
|
|
477
|
+
default=DEFAULT_CATEGORY,
|
|
478
|
+
help=f"Skill category. Defaults to {DEFAULT_CATEGORY!r}.",
|
|
479
|
+
)
|
|
480
|
+
parser.add_argument(
|
|
481
|
+
"--tag",
|
|
482
|
+
action="append",
|
|
483
|
+
default=[],
|
|
484
|
+
help="Tag to index for search. Repeat to add more.",
|
|
485
|
+
)
|
|
486
|
+
parser.add_argument(
|
|
487
|
+
"--trigger",
|
|
488
|
+
action="append",
|
|
489
|
+
default=[],
|
|
490
|
+
help="Trigger phrase that should lead an agent to this skill.",
|
|
491
|
+
)
|
|
492
|
+
parser.add_argument(
|
|
493
|
+
"--step",
|
|
494
|
+
action="append",
|
|
495
|
+
default=[],
|
|
496
|
+
help="Workflow step to include in the skill body. Repeat to add more.",
|
|
497
|
+
)
|
|
498
|
+
parser.add_argument(
|
|
499
|
+
"--related",
|
|
500
|
+
action="append",
|
|
501
|
+
default=[],
|
|
502
|
+
help="Related skill name. Repeat to add more.",
|
|
503
|
+
)
|
|
504
|
+
parser.add_argument(
|
|
505
|
+
"--force", action="store_true", help="Overwrite an existing skill directory."
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
510
|
+
records, _, _ = load_records_from_args(args)
|
|
511
|
+
if args.json:
|
|
512
|
+
print(json.dumps([asdict(record) for record in records], ensure_ascii=False, indent=2))
|
|
513
|
+
return 0
|
|
514
|
+
|
|
515
|
+
for record in records:
|
|
516
|
+
print(f"{record.name}\t{record.category}\t{record.path}")
|
|
517
|
+
return 0
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def cmd_refresh(args: argparse.Namespace) -> int:
|
|
521
|
+
records, skills_dir, registry_path = load_records_from_args(args)
|
|
522
|
+
write_registry(records, registry_path)
|
|
523
|
+
relative_registry = safe_relative_path(registry_path, skills_dir.parent)
|
|
524
|
+
print(f"Refreshed {len(records)} skills into {relative_registry}")
|
|
525
|
+
return 0
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def cmd_search(args: argparse.Namespace) -> int:
|
|
529
|
+
records, _, _ = load_records_from_args(args)
|
|
530
|
+
matches = search_records(records, args.query, limit=args.top)
|
|
531
|
+
if args.json:
|
|
532
|
+
payload = [
|
|
533
|
+
{"score": score, "reason": reason, **asdict(record)}
|
|
534
|
+
for score, reason, record in matches
|
|
535
|
+
]
|
|
536
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
537
|
+
return 0
|
|
538
|
+
|
|
539
|
+
if not matches:
|
|
540
|
+
print("No matching skills found.")
|
|
541
|
+
return 0
|
|
542
|
+
|
|
543
|
+
for score, reason, record in matches:
|
|
544
|
+
print(f"[{score:.1f}] {record.name} ({record.category})")
|
|
545
|
+
print(f" path: {record.path}")
|
|
546
|
+
print(f" reason: {reason}")
|
|
547
|
+
print(f" summary: {record.summary}")
|
|
548
|
+
return 0
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def cmd_suggest(args: argparse.Namespace) -> int:
|
|
552
|
+
records, _, _ = load_records_from_args(args)
|
|
553
|
+
matches = search_records(records, args.task, limit=args.top)
|
|
554
|
+
if args.json:
|
|
555
|
+
payload = {
|
|
556
|
+
"task": args.task,
|
|
557
|
+
"matches": [
|
|
558
|
+
{"score": score, "reason": reason, **asdict(record)}
|
|
559
|
+
for score, reason, record in matches
|
|
560
|
+
],
|
|
561
|
+
}
|
|
562
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
563
|
+
return 0
|
|
564
|
+
|
|
565
|
+
print(f"Task: {args.task}")
|
|
566
|
+
if not matches:
|
|
567
|
+
print("No reusable skill found. Consider creating a new one.")
|
|
568
|
+
return 0
|
|
569
|
+
|
|
570
|
+
for index, (score, reason, record) in enumerate(matches, start=1):
|
|
571
|
+
print(f"{index}. {record.name} [{score:.1f}]")
|
|
572
|
+
print(f" path: {record.path}")
|
|
573
|
+
print(f" reason: {reason}")
|
|
574
|
+
print(f" use: {record.description}")
|
|
575
|
+
best_score = matches[0][0]
|
|
576
|
+
if best_score < 4.0:
|
|
577
|
+
print("Recommendation: no strong match; scaffold a new reusable skill.")
|
|
578
|
+
return 0
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def cmd_resolve(args: argparse.Namespace) -> int:
|
|
582
|
+
records, _, _ = load_records_from_args(args)
|
|
583
|
+
matches = search_records(records, args.task, limit=1)
|
|
584
|
+
if not matches:
|
|
585
|
+
print("")
|
|
586
|
+
return 1
|
|
587
|
+
score, _, record = matches[0]
|
|
588
|
+
if score < args.min_score:
|
|
589
|
+
print("")
|
|
590
|
+
return 1
|
|
591
|
+
print(record.path)
|
|
592
|
+
return 0
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def cmd_create(args: argparse.Namespace) -> int:
|
|
596
|
+
existing_records, skills_dir, registry_path = load_records_from_args(args)
|
|
597
|
+
blueprint = build_manual_blueprint(
|
|
598
|
+
raw_name=args.name,
|
|
599
|
+
summary=args.summary,
|
|
600
|
+
when=args.when,
|
|
601
|
+
category=args.category,
|
|
602
|
+
tags=args.tag,
|
|
603
|
+
triggers=args.trigger,
|
|
604
|
+
steps=args.step,
|
|
605
|
+
related_skills=args.related,
|
|
606
|
+
existing_records=existing_records,
|
|
607
|
+
)
|
|
608
|
+
record = create_skill(skills_dir=skills_dir, blueprint=blueprint, force=args.force)
|
|
609
|
+
records = discover_skills(skills_dir)
|
|
610
|
+
write_registry(records, registry_path)
|
|
611
|
+
record_skill_event(
|
|
612
|
+
usage_path=resolve_usage_path(skills_dir),
|
|
613
|
+
record=record,
|
|
614
|
+
action="manual-create",
|
|
615
|
+
task=blueprint.source_task,
|
|
616
|
+
)
|
|
617
|
+
print(f"Created {record.name} at {record.path}")
|
|
618
|
+
return 0
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def cmd_bootstrap(args: argparse.Namespace) -> int:
|
|
622
|
+
existing_records, skills_dir, registry_path = load_records_from_args(args)
|
|
623
|
+
blueprint = build_bootstrap_blueprint(
|
|
624
|
+
task=args.task,
|
|
625
|
+
raw_name=args.name,
|
|
626
|
+
category=args.category,
|
|
627
|
+
extra_tags=args.tag,
|
|
628
|
+
existing_records=existing_records,
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
if args.dry_run:
|
|
632
|
+
emit_blueprint_preview(blueprint, as_json=args.json)
|
|
633
|
+
return 0
|
|
634
|
+
|
|
635
|
+
record = create_skill(skills_dir=skills_dir, blueprint=blueprint, force=args.force)
|
|
636
|
+
records = discover_skills(skills_dir)
|
|
637
|
+
write_registry(records, registry_path)
|
|
638
|
+
record_skill_event(
|
|
639
|
+
usage_path=resolve_usage_path(skills_dir),
|
|
640
|
+
record=record,
|
|
641
|
+
action="manual-bootstrap",
|
|
642
|
+
task=blueprint.source_task,
|
|
643
|
+
)
|
|
644
|
+
if args.json:
|
|
645
|
+
payload = blueprint_payload(blueprint)
|
|
646
|
+
payload["path"] = record.path
|
|
647
|
+
payload["created"] = True
|
|
648
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
649
|
+
return 0
|
|
650
|
+
print(f"Bootstrapped {record.name} at {record.path}")
|
|
651
|
+
return 0
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def cmd_auto(args: argparse.Namespace) -> int:
|
|
655
|
+
records, skills_dir, registry_path = load_records_from_args(args)
|
|
656
|
+
matches = search_records(records, args.task, limit=3)
|
|
657
|
+
preferred_match = choose_auto_match(matches, args.task)
|
|
658
|
+
usage_path = resolve_usage_path(skills_dir)
|
|
659
|
+
|
|
660
|
+
if preferred_match and preferred_match[0] >= args.min_score:
|
|
661
|
+
score, reason, record = preferred_match
|
|
662
|
+
record_skill_event(
|
|
663
|
+
usage_path=usage_path,
|
|
664
|
+
record=record,
|
|
665
|
+
action="auto-reuse",
|
|
666
|
+
task=args.task,
|
|
667
|
+
score=score,
|
|
668
|
+
)
|
|
669
|
+
skill_update = None
|
|
670
|
+
if not args.skip_update:
|
|
671
|
+
skill_update = maybe_auto_update_skill(
|
|
672
|
+
record=record,
|
|
673
|
+
skills_dir=skills_dir,
|
|
674
|
+
registry_path=registry_path,
|
|
675
|
+
usage_path=usage_path,
|
|
676
|
+
task=args.task,
|
|
677
|
+
)
|
|
678
|
+
payload = {
|
|
679
|
+
"action": "reuse",
|
|
680
|
+
"task": clean_text(args.task),
|
|
681
|
+
"match": match_payload(score, reason, record),
|
|
682
|
+
}
|
|
683
|
+
if skill_update:
|
|
684
|
+
payload["skill_update"] = skill_update
|
|
685
|
+
emit_auto_result(payload, as_json=args.json)
|
|
686
|
+
return 0
|
|
687
|
+
|
|
688
|
+
blueprint = build_bootstrap_blueprint(
|
|
689
|
+
task=args.task,
|
|
690
|
+
raw_name=None,
|
|
691
|
+
category=args.category,
|
|
692
|
+
extra_tags=args.tag,
|
|
693
|
+
existing_records=records,
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
if args.dry_run:
|
|
697
|
+
payload = {
|
|
698
|
+
"action": "preview-create",
|
|
699
|
+
"task": clean_text(args.task),
|
|
700
|
+
"min_score": args.min_score,
|
|
701
|
+
"best_match": match_payload(*preferred_match) if preferred_match else None,
|
|
702
|
+
"blueprint": blueprint_payload(blueprint),
|
|
703
|
+
}
|
|
704
|
+
emit_auto_result(payload, as_json=args.json)
|
|
705
|
+
return 0
|
|
706
|
+
|
|
707
|
+
record = create_skill(skills_dir=skills_dir, blueprint=blueprint, force=args.force)
|
|
708
|
+
updated_records = discover_skills(skills_dir)
|
|
709
|
+
write_registry(updated_records, registry_path)
|
|
710
|
+
record_skill_event(
|
|
711
|
+
usage_path=usage_path,
|
|
712
|
+
record=record,
|
|
713
|
+
action="auto-created",
|
|
714
|
+
task=blueprint.source_task,
|
|
715
|
+
)
|
|
716
|
+
payload = {
|
|
717
|
+
"action": "created",
|
|
718
|
+
"task": clean_text(args.task),
|
|
719
|
+
"min_score": args.min_score,
|
|
720
|
+
"best_match": match_payload(*preferred_match) if preferred_match else None,
|
|
721
|
+
"created_skill": asdict(record),
|
|
722
|
+
"blueprint": blueprint_payload(blueprint),
|
|
723
|
+
}
|
|
724
|
+
emit_auto_result(payload, as_json=args.json)
|
|
725
|
+
return 0
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def cmd_usage(args: argparse.Namespace) -> int:
|
|
729
|
+
records, skills_dir, _ = load_records_from_args(args)
|
|
730
|
+
summaries = build_skill_usage_summaries(records, resolve_usage_path(skills_dir))
|
|
731
|
+
if args.status != "all":
|
|
732
|
+
summaries = [item for item in summaries if item["status"] == args.status]
|
|
733
|
+
|
|
734
|
+
if args.json:
|
|
735
|
+
print(json.dumps(summaries, ensure_ascii=False, indent=2))
|
|
736
|
+
return 0
|
|
737
|
+
|
|
738
|
+
if not summaries:
|
|
739
|
+
print("No skill usage data available.")
|
|
740
|
+
return 0
|
|
741
|
+
|
|
742
|
+
for item in summaries:
|
|
743
|
+
print(
|
|
744
|
+
f"[{item['status']}] {item['name']} "
|
|
745
|
+
f"reuse={item['reuse_count']} create={item['create_count']} "
|
|
746
|
+
f"last={item['last_activity_days']}d age={item['age_days']}d"
|
|
747
|
+
)
|
|
748
|
+
print(f" path: {item['path']}")
|
|
749
|
+
print(f" reason: {item['reason']}")
|
|
750
|
+
return 0
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def cmd_review(args: argparse.Namespace) -> int:
|
|
754
|
+
records, skills_dir, _ = load_records_from_args(args)
|
|
755
|
+
plans = build_skill_refresh_plans(records, resolve_usage_path(skills_dir))
|
|
756
|
+
if args.status != "all":
|
|
757
|
+
plans = [plan for plan in plans if plan.status == args.status]
|
|
758
|
+
|
|
759
|
+
payload = [refresh_plan_payload(plan) for plan in plans]
|
|
760
|
+
if args.json:
|
|
761
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
762
|
+
return 0
|
|
763
|
+
|
|
764
|
+
if not payload:
|
|
765
|
+
print("No skill refresh candidates.")
|
|
766
|
+
return 0
|
|
767
|
+
|
|
768
|
+
for item in payload:
|
|
769
|
+
print(f"[{item['status']}] {item['name']} ({item['management_mode']})")
|
|
770
|
+
print(f" path: {item['path']}")
|
|
771
|
+
print(f" reason: {item['reason']}")
|
|
772
|
+
if item["updated_fields"]:
|
|
773
|
+
print(f" fields: {', '.join(item['updated_fields'])}")
|
|
774
|
+
return 0
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def cmd_update(args: argparse.Namespace) -> int:
|
|
778
|
+
records, skills_dir, registry_path = load_records_from_args(args)
|
|
779
|
+
usage_path = resolve_usage_path(skills_dir)
|
|
780
|
+
plans = build_skill_refresh_plans(records, usage_path)
|
|
781
|
+
if args.name:
|
|
782
|
+
normalized_name = normalize_name(args.name)
|
|
783
|
+
plans = [plan for plan in plans if plan.name == normalized_name]
|
|
784
|
+
if not plans:
|
|
785
|
+
raise SystemExit(f"No skill named {normalized_name} was found.")
|
|
786
|
+
else:
|
|
787
|
+
plans = [plan for plan in plans if plan.status == "candidate"]
|
|
788
|
+
|
|
789
|
+
payload = [refresh_plan_payload(plan) for plan in plans]
|
|
790
|
+
if not args.apply:
|
|
791
|
+
if args.json:
|
|
792
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
793
|
+
return 0
|
|
794
|
+
if not payload:
|
|
795
|
+
print("No skill refresh candidates.")
|
|
796
|
+
return 0
|
|
797
|
+
for item in payload:
|
|
798
|
+
print(f"[{item['status']}] {item['name']} ({item['management_mode']})")
|
|
799
|
+
print(f" path: {item['path']}")
|
|
800
|
+
print(f" reason: {item['reason']}")
|
|
801
|
+
if item["updated_fields"]:
|
|
802
|
+
print(f" fields: {', '.join(item['updated_fields'])}")
|
|
803
|
+
print("Next: rerun with `--apply` to persist these metadata updates.")
|
|
804
|
+
return 0
|
|
805
|
+
|
|
806
|
+
if not plans:
|
|
807
|
+
if args.json:
|
|
808
|
+
print(json.dumps([], ensure_ascii=False, indent=2))
|
|
809
|
+
else:
|
|
810
|
+
print("No skill refresh candidates.")
|
|
811
|
+
return 0
|
|
812
|
+
|
|
813
|
+
records_by_name = {record.name: record for record in records}
|
|
814
|
+
updated: list[dict[str, Any]] = []
|
|
815
|
+
for plan in plans:
|
|
816
|
+
if plan.status != "candidate":
|
|
817
|
+
continue
|
|
818
|
+
record = records_by_name.get(plan.name)
|
|
819
|
+
if not record:
|
|
820
|
+
continue
|
|
821
|
+
updated.append(
|
|
822
|
+
apply_skill_update_plan(
|
|
823
|
+
record=record,
|
|
824
|
+
plan=plan,
|
|
825
|
+
usage_path=usage_path,
|
|
826
|
+
task=plan.recent_tasks[0] if plan.recent_tasks else f"refresh {plan.name}",
|
|
827
|
+
action="manual-update",
|
|
828
|
+
)
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
updated_records = discover_skills(skills_dir)
|
|
832
|
+
write_registry(updated_records, registry_path)
|
|
833
|
+
|
|
834
|
+
if args.json:
|
|
835
|
+
print(json.dumps(updated, ensure_ascii=False, indent=2))
|
|
836
|
+
return 0
|
|
837
|
+
|
|
838
|
+
if not updated:
|
|
839
|
+
print("No skill refresh candidates.")
|
|
840
|
+
return 0
|
|
841
|
+
|
|
842
|
+
for item in updated:
|
|
843
|
+
print(f"[updated] {item['name']} ({item['management_mode']})")
|
|
844
|
+
print(f" path: {item['path']}")
|
|
845
|
+
print(f" fields: {', '.join(item['updated_fields'])}")
|
|
846
|
+
print(f" reason: {item['reason']}")
|
|
847
|
+
return 0
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def cmd_prune(args: argparse.Namespace) -> int:
|
|
851
|
+
records, skills_dir, registry_path = load_records_from_args(args)
|
|
852
|
+
usage_path = resolve_usage_path(skills_dir)
|
|
853
|
+
summaries = build_skill_usage_summaries(records, usage_path)
|
|
854
|
+
candidates = [item for item in summaries if item["status"] == "candidate"]
|
|
855
|
+
|
|
856
|
+
if args.json and not args.apply:
|
|
857
|
+
print(json.dumps(candidates, ensure_ascii=False, indent=2))
|
|
858
|
+
return 0
|
|
859
|
+
|
|
860
|
+
if not candidates:
|
|
861
|
+
if args.json:
|
|
862
|
+
print(json.dumps([], ensure_ascii=False, indent=2))
|
|
863
|
+
else:
|
|
864
|
+
print("No prune candidates.")
|
|
865
|
+
return 0
|
|
866
|
+
|
|
867
|
+
if not args.apply:
|
|
868
|
+
for item in candidates:
|
|
869
|
+
print(f"[candidate] {item['name']}")
|
|
870
|
+
print(f" path: {item['path']}")
|
|
871
|
+
print(f" reason: {item['reason']}")
|
|
872
|
+
print("Next: rerun with `--apply` to archive these skills.")
|
|
873
|
+
return 0
|
|
874
|
+
|
|
875
|
+
archived = archive_skill_candidates(candidates, skills_dir, usage_path)
|
|
876
|
+
updated_records = discover_skills(skills_dir)
|
|
877
|
+
write_registry(updated_records, registry_path)
|
|
878
|
+
|
|
879
|
+
if args.json:
|
|
880
|
+
print(json.dumps(archived, ensure_ascii=False, indent=2))
|
|
881
|
+
return 0
|
|
882
|
+
|
|
883
|
+
for item in archived:
|
|
884
|
+
print(f"[archived] {item['name']}")
|
|
885
|
+
print(f" from: {item['from_path']}")
|
|
886
|
+
print(f" to: {item['archived_path']}")
|
|
887
|
+
print(f" reason: {item['reason']}")
|
|
888
|
+
return 0
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def build_command_specs() -> list[CliCommand]:
|
|
892
|
+
return [
|
|
893
|
+
CliCommand(
|
|
894
|
+
name="list",
|
|
895
|
+
help="List discovered skills.",
|
|
896
|
+
handler=cmd_list,
|
|
897
|
+
arguments=(cli_argument("--json", action="store_true", help="Emit JSON."),),
|
|
898
|
+
),
|
|
899
|
+
CliCommand(
|
|
900
|
+
name="refresh",
|
|
901
|
+
help="Scan skills and rebuild the registry file.",
|
|
902
|
+
handler=cmd_refresh,
|
|
903
|
+
),
|
|
904
|
+
CliCommand(
|
|
905
|
+
name="search",
|
|
906
|
+
help="Search skills by task, trigger, category, or tags.",
|
|
907
|
+
handler=cmd_search,
|
|
908
|
+
arguments=(
|
|
909
|
+
cli_argument("query", help="Search query."),
|
|
910
|
+
cli_argument("--top", type=int, default=5, help="Result limit."),
|
|
911
|
+
cli_argument("--json", action="store_true", help="Emit JSON."),
|
|
912
|
+
),
|
|
913
|
+
),
|
|
914
|
+
CliCommand(
|
|
915
|
+
name="suggest",
|
|
916
|
+
help="Recommend the best skill matches for a task.",
|
|
917
|
+
handler=cmd_suggest,
|
|
918
|
+
arguments=(
|
|
919
|
+
cli_argument("task", help="Natural-language task description."),
|
|
920
|
+
cli_argument("--top", type=int, default=3, help="Result limit."),
|
|
921
|
+
cli_argument("--json", action="store_true", help="Emit JSON."),
|
|
922
|
+
),
|
|
923
|
+
),
|
|
924
|
+
CliCommand(
|
|
925
|
+
name="resolve",
|
|
926
|
+
help="Return the best-matching skill path for a task.",
|
|
927
|
+
handler=cmd_resolve,
|
|
928
|
+
arguments=(
|
|
929
|
+
cli_argument("task", help="Natural-language task description."),
|
|
930
|
+
cli_argument(
|
|
931
|
+
"--min-score",
|
|
932
|
+
type=float,
|
|
933
|
+
default=4.0,
|
|
934
|
+
help="Minimum score required to treat the match as reusable.",
|
|
935
|
+
),
|
|
936
|
+
),
|
|
937
|
+
),
|
|
938
|
+
CliCommand(
|
|
939
|
+
name="create",
|
|
940
|
+
help="Create a new skill with structured metadata.",
|
|
941
|
+
handler=cmd_create,
|
|
942
|
+
configure=add_create_arguments,
|
|
943
|
+
),
|
|
944
|
+
CliCommand(
|
|
945
|
+
name="bootstrap",
|
|
946
|
+
help="Create a richer new skill from a task statement using inferred metadata.",
|
|
947
|
+
handler=cmd_bootstrap,
|
|
948
|
+
arguments=(
|
|
949
|
+
cli_argument("task", help="Task statement used to scaffold a skill."),
|
|
950
|
+
cli_argument(
|
|
951
|
+
"--name",
|
|
952
|
+
help="Explicit skill name. When omitted, derive one from the task.",
|
|
953
|
+
),
|
|
954
|
+
cli_argument(
|
|
955
|
+
"--category",
|
|
956
|
+
default="auto",
|
|
957
|
+
help="Skill category. Defaults to auto inference from the task.",
|
|
958
|
+
),
|
|
959
|
+
cli_argument(
|
|
960
|
+
"--tag",
|
|
961
|
+
action="append",
|
|
962
|
+
default=[],
|
|
963
|
+
help="Extra tag. Repeat to add more.",
|
|
964
|
+
),
|
|
965
|
+
cli_argument(
|
|
966
|
+
"--dry-run",
|
|
967
|
+
action="store_true",
|
|
968
|
+
help="Preview the generated skill without writing files.",
|
|
969
|
+
),
|
|
970
|
+
cli_argument(
|
|
971
|
+
"--json",
|
|
972
|
+
action="store_true",
|
|
973
|
+
help="Emit the generated skill preview as JSON.",
|
|
974
|
+
),
|
|
975
|
+
cli_argument(
|
|
976
|
+
"--force",
|
|
977
|
+
action="store_true",
|
|
978
|
+
help="Overwrite an existing skill directory.",
|
|
979
|
+
),
|
|
980
|
+
),
|
|
981
|
+
),
|
|
982
|
+
CliCommand(
|
|
983
|
+
name="auto",
|
|
984
|
+
help="Resolve the best local skill for a task, or create one automatically when missing.",
|
|
985
|
+
handler=cmd_auto,
|
|
986
|
+
arguments=(
|
|
987
|
+
cli_argument("task", help="Task statement to resolve against local skills."),
|
|
988
|
+
cli_argument(
|
|
989
|
+
"--min-score",
|
|
990
|
+
type=float,
|
|
991
|
+
default=8.0,
|
|
992
|
+
help="Minimum score required to reuse an existing skill.",
|
|
993
|
+
),
|
|
994
|
+
cli_argument(
|
|
995
|
+
"--category",
|
|
996
|
+
default="auto",
|
|
997
|
+
help="Category hint when a new skill must be generated.",
|
|
998
|
+
),
|
|
999
|
+
cli_argument(
|
|
1000
|
+
"--tag",
|
|
1001
|
+
action="append",
|
|
1002
|
+
default=[],
|
|
1003
|
+
help="Extra tag to include if a new skill is generated.",
|
|
1004
|
+
),
|
|
1005
|
+
cli_argument(
|
|
1006
|
+
"--dry-run",
|
|
1007
|
+
action="store_true",
|
|
1008
|
+
help="Do not write files when no reusable skill exists; return a creation preview instead.",
|
|
1009
|
+
),
|
|
1010
|
+
cli_argument(
|
|
1011
|
+
"--skip-update",
|
|
1012
|
+
action="store_true",
|
|
1013
|
+
help="Skip automatic metadata refresh after reusing an existing skill.",
|
|
1014
|
+
),
|
|
1015
|
+
cli_argument("--json", action="store_true", help="Emit JSON."),
|
|
1016
|
+
cli_argument(
|
|
1017
|
+
"--force",
|
|
1018
|
+
action="store_true",
|
|
1019
|
+
help="Overwrite an existing generated skill if needed.",
|
|
1020
|
+
),
|
|
1021
|
+
),
|
|
1022
|
+
),
|
|
1023
|
+
CliCommand(
|
|
1024
|
+
name="usage",
|
|
1025
|
+
help="Show skill reuse frequency, freshness, and cleanup candidates.",
|
|
1026
|
+
handler=cmd_usage,
|
|
1027
|
+
arguments=(
|
|
1028
|
+
cli_argument("--json", action="store_true", help="Emit JSON."),
|
|
1029
|
+
cli_argument(
|
|
1030
|
+
"--status",
|
|
1031
|
+
default="all",
|
|
1032
|
+
choices=["all", "active", "stale", "candidate", "protected"],
|
|
1033
|
+
help="Filter by computed usage status.",
|
|
1034
|
+
),
|
|
1035
|
+
),
|
|
1036
|
+
),
|
|
1037
|
+
CliCommand(
|
|
1038
|
+
name="review",
|
|
1039
|
+
help="Review existing skills for safe metadata refresh candidates.",
|
|
1040
|
+
handler=cmd_review,
|
|
1041
|
+
arguments=(
|
|
1042
|
+
cli_argument("--json", action="store_true", help="Emit JSON."),
|
|
1043
|
+
cli_argument(
|
|
1044
|
+
"--status",
|
|
1045
|
+
default="candidate",
|
|
1046
|
+
choices=["all", "candidate", "healthy", "protected", "locked"],
|
|
1047
|
+
help="Filter by computed refresh status.",
|
|
1048
|
+
),
|
|
1049
|
+
),
|
|
1050
|
+
),
|
|
1051
|
+
CliCommand(
|
|
1052
|
+
name="update",
|
|
1053
|
+
help="Preview or apply safe metadata refreshes for existing skills.",
|
|
1054
|
+
handler=cmd_update,
|
|
1055
|
+
arguments=(
|
|
1056
|
+
cli_argument(
|
|
1057
|
+
"name",
|
|
1058
|
+
nargs="?",
|
|
1059
|
+
help="Specific skill name. When omitted, operate on all refresh candidates.",
|
|
1060
|
+
),
|
|
1061
|
+
cli_argument("--json", action="store_true", help="Emit JSON."),
|
|
1062
|
+
cli_argument(
|
|
1063
|
+
"--apply",
|
|
1064
|
+
action="store_true",
|
|
1065
|
+
help="Apply the planned metadata updates instead of only previewing them.",
|
|
1066
|
+
),
|
|
1067
|
+
),
|
|
1068
|
+
),
|
|
1069
|
+
CliCommand(
|
|
1070
|
+
name="prune",
|
|
1071
|
+
help="Archive low-value skills that have seen little or no reuse.",
|
|
1072
|
+
handler=cmd_prune,
|
|
1073
|
+
arguments=(
|
|
1074
|
+
cli_argument("--json", action="store_true", help="Emit JSON."),
|
|
1075
|
+
cli_argument(
|
|
1076
|
+
"--apply",
|
|
1077
|
+
action="store_true",
|
|
1078
|
+
help="Archive the current candidates instead of only previewing them.",
|
|
1079
|
+
),
|
|
1080
|
+
),
|
|
1081
|
+
),
|
|
1082
|
+
]
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def cli_argument(*flags: str, **kwargs: Any) -> CliArgument:
|
|
1086
|
+
return CliArgument(flags=tuple(flags), kwargs=kwargs)
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def load_records_from_args(
|
|
1090
|
+
args: argparse.Namespace,
|
|
1091
|
+
) -> tuple[list[SkillRecord], Path, Path]:
|
|
1092
|
+
repo_root = resolve_repo_root(args.repo_root)
|
|
1093
|
+
skills_dir = (args.skills_dir or repo_root / ".claude" / "skills").resolve()
|
|
1094
|
+
registry_path = (args.registry_path or skills_dir / "registry.json").resolve()
|
|
1095
|
+
records = discover_skills(skills_dir)
|
|
1096
|
+
return records, skills_dir, registry_path
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def resolve_repo_root(explicit_root: Path | None) -> Path:
|
|
1100
|
+
if explicit_root:
|
|
1101
|
+
return explicit_root.resolve()
|
|
1102
|
+
|
|
1103
|
+
current = Path.cwd().resolve()
|
|
1104
|
+
for candidate in [current, *current.parents]:
|
|
1105
|
+
if (candidate / ".claude").exists():
|
|
1106
|
+
return candidate
|
|
1107
|
+
return current
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def discover_skills(skills_dir: Path) -> list[SkillRecord]:
|
|
1111
|
+
records: list[SkillRecord] = []
|
|
1112
|
+
if not skills_dir.exists():
|
|
1113
|
+
return records
|
|
1114
|
+
|
|
1115
|
+
for skill_file in sorted(skills_dir.rglob("SKILL.md")):
|
|
1116
|
+
if ARCHIVE_DIRNAME in skill_file.parts:
|
|
1117
|
+
continue
|
|
1118
|
+
record = parse_skill(skill_file)
|
|
1119
|
+
if record:
|
|
1120
|
+
records.append(record)
|
|
1121
|
+
return sorted(records, key=lambda item: (item.category, item.name))
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def parse_skill(skill_file: Path) -> SkillRecord | None:
|
|
1125
|
+
text = skill_file.read_text(encoding="utf-8")
|
|
1126
|
+
frontmatter, body = parse_frontmatter(text)
|
|
1127
|
+
name = normalize_name(str(frontmatter.get("name") or skill_file.parent.name))
|
|
1128
|
+
description = clean_text(str(frontmatter.get("description") or ""))
|
|
1129
|
+
title = extract_title(body) or name.replace("-", " ").title()
|
|
1130
|
+
|
|
1131
|
+
companion_metadata = load_companion_metadata(skill_file.parent / "skill.json")
|
|
1132
|
+
summary = clean_text(str(companion_metadata.get("summary") or description or title))
|
|
1133
|
+
category = normalize_category(
|
|
1134
|
+
str(companion_metadata.get("category") or DEFAULT_CATEGORY),
|
|
1135
|
+
summary,
|
|
1136
|
+
)
|
|
1137
|
+
tags = unique_strings(companion_metadata.get("tags", []))
|
|
1138
|
+
triggers = unique_strings(companion_metadata.get("triggers", []))
|
|
1139
|
+
steps = unique_strings(companion_metadata.get("steps", []))
|
|
1140
|
+
related_skills = unique_strings(companion_metadata.get("related_skills", []))
|
|
1141
|
+
validation = unique_strings(companion_metadata.get("validation", []))
|
|
1142
|
+
examples = unique_strings(companion_metadata.get("examples", []))
|
|
1143
|
+
management_mode = normalize_management_mode(
|
|
1144
|
+
companion_metadata.get("management_mode", DEFAULT_MANAGEMENT_MODE)
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
if not description:
|
|
1148
|
+
description = summary
|
|
1149
|
+
|
|
1150
|
+
return SkillRecord(
|
|
1151
|
+
name=name,
|
|
1152
|
+
path=str(skill_file.parent),
|
|
1153
|
+
description=description,
|
|
1154
|
+
category=category,
|
|
1155
|
+
tags=tags,
|
|
1156
|
+
triggers=triggers,
|
|
1157
|
+
summary=summary,
|
|
1158
|
+
steps=steps,
|
|
1159
|
+
related_skills=related_skills,
|
|
1160
|
+
validation=validation,
|
|
1161
|
+
examples=examples,
|
|
1162
|
+
title=title,
|
|
1163
|
+
management_mode=management_mode,
|
|
1164
|
+
)
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
|
1168
|
+
if not text.startswith("---"):
|
|
1169
|
+
return {}, text
|
|
1170
|
+
|
|
1171
|
+
lines = text.splitlines()
|
|
1172
|
+
if not lines or lines[0].strip() != "---":
|
|
1173
|
+
return {}, text
|
|
1174
|
+
|
|
1175
|
+
closing_index = None
|
|
1176
|
+
for index in range(1, len(lines)):
|
|
1177
|
+
if lines[index].strip() == "---":
|
|
1178
|
+
closing_index = index
|
|
1179
|
+
break
|
|
1180
|
+
if closing_index is None:
|
|
1181
|
+
return {}, text
|
|
1182
|
+
|
|
1183
|
+
frontmatter: dict[str, Any] = {}
|
|
1184
|
+
for line in lines[1:closing_index]:
|
|
1185
|
+
if ":" not in line:
|
|
1186
|
+
continue
|
|
1187
|
+
key, raw_value = line.split(":", 1)
|
|
1188
|
+
frontmatter[key.strip()] = parse_scalar(raw_value.strip())
|
|
1189
|
+
|
|
1190
|
+
body = "\n".join(lines[closing_index + 1 :]).lstrip()
|
|
1191
|
+
return frontmatter, body
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
def parse_scalar(raw_value: str) -> Any:
|
|
1195
|
+
if raw_value in {"true", "false"}:
|
|
1196
|
+
return raw_value == "true"
|
|
1197
|
+
if raw_value.startswith(("'", '"')) and raw_value.endswith(("'", '"')):
|
|
1198
|
+
return raw_value[1:-1]
|
|
1199
|
+
return raw_value
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def load_companion_metadata(path: Path) -> dict[str, Any]:
|
|
1203
|
+
if not path.exists():
|
|
1204
|
+
return {}
|
|
1205
|
+
try:
|
|
1206
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
1207
|
+
except json.JSONDecodeError:
|
|
1208
|
+
return {}
|
|
1209
|
+
if not isinstance(payload, dict):
|
|
1210
|
+
return {}
|
|
1211
|
+
return payload
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def normalize_management_mode(value: Any) -> str:
|
|
1215
|
+
if not isinstance(value, str):
|
|
1216
|
+
return DEFAULT_MANAGEMENT_MODE
|
|
1217
|
+
normalized = clean_text(value).lower()
|
|
1218
|
+
if normalized in {"locked", "managed", "patch"}:
|
|
1219
|
+
return normalized
|
|
1220
|
+
return DEFAULT_MANAGEMENT_MODE
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def extract_title(body: str) -> str:
|
|
1224
|
+
for line in body.splitlines():
|
|
1225
|
+
stripped = line.strip()
|
|
1226
|
+
if stripped.startswith("# "):
|
|
1227
|
+
return stripped[2:].strip()
|
|
1228
|
+
return ""
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def unique_strings(values: Any) -> list[str]:
|
|
1232
|
+
result: list[str] = []
|
|
1233
|
+
seen: set[str] = set()
|
|
1234
|
+
if not isinstance(values, list):
|
|
1235
|
+
return result
|
|
1236
|
+
for value in values:
|
|
1237
|
+
if not isinstance(value, str):
|
|
1238
|
+
continue
|
|
1239
|
+
item = clean_text(value)
|
|
1240
|
+
if not item or item in seen:
|
|
1241
|
+
continue
|
|
1242
|
+
result.append(item)
|
|
1243
|
+
seen.add(item)
|
|
1244
|
+
return result
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
def search_records(
|
|
1248
|
+
records: list[SkillRecord],
|
|
1249
|
+
query: str,
|
|
1250
|
+
*,
|
|
1251
|
+
limit: int,
|
|
1252
|
+
) -> list[tuple[float, str, SkillRecord]]:
|
|
1253
|
+
query_text = clean_text(query).lower()
|
|
1254
|
+
query_tokens = tokenize(query_text)
|
|
1255
|
+
if not query_tokens:
|
|
1256
|
+
return []
|
|
1257
|
+
|
|
1258
|
+
matches: list[tuple[float, str, SkillRecord]] = []
|
|
1259
|
+
for record in records:
|
|
1260
|
+
score, reasons = score_record(record, query_text, query_tokens)
|
|
1261
|
+
if score <= 0:
|
|
1262
|
+
continue
|
|
1263
|
+
matches.append((score, ", ".join(reasons[:3]), record))
|
|
1264
|
+
|
|
1265
|
+
matches.sort(key=lambda item: (-item[0], item[2].name))
|
|
1266
|
+
return matches[:limit]
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def score_record(
|
|
1270
|
+
record: SkillRecord,
|
|
1271
|
+
query_text: str,
|
|
1272
|
+
query_tokens: set[str],
|
|
1273
|
+
) -> tuple[float, list[str]]:
|
|
1274
|
+
score = 0.0
|
|
1275
|
+
reasons: list[str] = []
|
|
1276
|
+
|
|
1277
|
+
name_tokens = tokenize(record.name.replace("-", " "))
|
|
1278
|
+
category_tokens = tokenize(record.category.replace("-", " "))
|
|
1279
|
+
title_tokens = tokenize(record.title)
|
|
1280
|
+
description_tokens = tokenize(record.description)
|
|
1281
|
+
summary_tokens = tokenize(record.summary)
|
|
1282
|
+
tag_tokens = {token for tag in record.tags for token in tokenize(tag)}
|
|
1283
|
+
trigger_tokens = {token for phrase in record.triggers for token in tokenize(phrase)}
|
|
1284
|
+
step_tokens = {token for step in record.steps for token in tokenize(step)}
|
|
1285
|
+
related_tokens = {token for name in record.related_skills for token in tokenize(name)}
|
|
1286
|
+
validation_tokens = {
|
|
1287
|
+
token for check in record.validation for token in tokenize(check)
|
|
1288
|
+
}
|
|
1289
|
+
example_tokens = {token for example in record.examples for token in tokenize(example)}
|
|
1290
|
+
|
|
1291
|
+
if query_text in record.name:
|
|
1292
|
+
score += 6.0
|
|
1293
|
+
reasons.append("full skill-name match")
|
|
1294
|
+
if query_text in record.description.lower():
|
|
1295
|
+
score += 5.0
|
|
1296
|
+
reasons.append("description phrase match")
|
|
1297
|
+
if query_text in record.summary.lower():
|
|
1298
|
+
score += 4.5
|
|
1299
|
+
reasons.append("summary phrase match")
|
|
1300
|
+
|
|
1301
|
+
intersections = [
|
|
1302
|
+
("name", name_tokens, 4.0),
|
|
1303
|
+
("category", category_tokens, 3.0),
|
|
1304
|
+
("title", title_tokens, 2.5),
|
|
1305
|
+
("tags", tag_tokens, 2.5),
|
|
1306
|
+
("triggers", trigger_tokens, 2.5),
|
|
1307
|
+
("description", description_tokens, 1.75),
|
|
1308
|
+
("summary", summary_tokens, 1.75),
|
|
1309
|
+
("steps", step_tokens, 1.0),
|
|
1310
|
+
("validation", validation_tokens, 1.0),
|
|
1311
|
+
("examples", example_tokens, 1.0),
|
|
1312
|
+
("related", related_tokens, 1.0),
|
|
1313
|
+
]
|
|
1314
|
+
|
|
1315
|
+
for label, tokens, weight in intersections:
|
|
1316
|
+
overlap = query_tokens & tokens
|
|
1317
|
+
if not overlap:
|
|
1318
|
+
continue
|
|
1319
|
+
score += weight * len(overlap)
|
|
1320
|
+
reasons.append(f"{label} overlap: {', '.join(sorted(overlap)[:3])}")
|
|
1321
|
+
|
|
1322
|
+
if record.category in query_text:
|
|
1323
|
+
score += 1.5
|
|
1324
|
+
reasons.append("category phrase match")
|
|
1325
|
+
|
|
1326
|
+
return score, reasons
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def tokenize(text: str) -> set[str]:
|
|
1330
|
+
tokens = re.findall(r"[a-zA-Z0-9][a-zA-Z0-9_-]{1,}", text.lower())
|
|
1331
|
+
return {token for token in tokens if token not in STOPWORDS}
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
def ordered_tokens(text: str) -> list[str]:
|
|
1335
|
+
tokens = re.findall(r"[a-zA-Z0-9][a-zA-Z0-9_-]{1,}", text.lower())
|
|
1336
|
+
ordered: list[str] = []
|
|
1337
|
+
seen: set[str] = set()
|
|
1338
|
+
for token in tokens:
|
|
1339
|
+
if token in seen or token in STOPWORDS:
|
|
1340
|
+
continue
|
|
1341
|
+
ordered.append(token)
|
|
1342
|
+
seen.add(token)
|
|
1343
|
+
return ordered
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def build_manual_blueprint(
|
|
1347
|
+
*,
|
|
1348
|
+
raw_name: str,
|
|
1349
|
+
summary: str,
|
|
1350
|
+
when: str,
|
|
1351
|
+
category: str,
|
|
1352
|
+
tags: list[str],
|
|
1353
|
+
triggers: list[str],
|
|
1354
|
+
steps: list[str],
|
|
1355
|
+
related_skills: list[str],
|
|
1356
|
+
existing_records: list[SkillRecord],
|
|
1357
|
+
) -> SkillBlueprint:
|
|
1358
|
+
normalized_summary = sentence_case(summary)
|
|
1359
|
+
source_task = clean_text(f"{summary} {when}")
|
|
1360
|
+
normalized_category = normalize_category(category, source_task)
|
|
1361
|
+
focus = derive_focus_phrase(source_task)
|
|
1362
|
+
inferred_tags = infer_tags(source_task, normalized_category, tags)
|
|
1363
|
+
inferred_triggers = unique_strings(triggers) or infer_trigger_phrases(
|
|
1364
|
+
source_task, normalized_category
|
|
1365
|
+
)
|
|
1366
|
+
inferred_steps = ensure_nested_skill_routing_step(
|
|
1367
|
+
unique_strings(steps) or infer_steps(normalized_category, normalized_summary)
|
|
1368
|
+
)
|
|
1369
|
+
inferred_validation = infer_validation(normalized_category)
|
|
1370
|
+
inferred_examples = infer_examples(
|
|
1371
|
+
normalized_category, normalized_summary, focus, inferred_triggers
|
|
1372
|
+
)
|
|
1373
|
+
inferred_related = (
|
|
1374
|
+
unique_strings(related_skills)
|
|
1375
|
+
or suggest_related_skills(
|
|
1376
|
+
existing_records, source_task, exclude_name=normalize_name(raw_name)
|
|
1377
|
+
)
|
|
1378
|
+
)
|
|
1379
|
+
|
|
1380
|
+
description = ensure_sentence(
|
|
1381
|
+
f"{normalized_summary.rstrip('.')} . Use when {clean_text(when).rstrip('.')}"
|
|
1382
|
+
).replace(" .", ".")
|
|
1383
|
+
name = normalize_name(raw_name)
|
|
1384
|
+
return SkillBlueprint(
|
|
1385
|
+
name=name,
|
|
1386
|
+
title=build_title(name),
|
|
1387
|
+
description=description,
|
|
1388
|
+
category=normalized_category,
|
|
1389
|
+
summary=normalized_summary,
|
|
1390
|
+
tags=inferred_tags,
|
|
1391
|
+
triggers=inferred_triggers,
|
|
1392
|
+
steps=inferred_steps,
|
|
1393
|
+
related_skills=inferred_related,
|
|
1394
|
+
validation=inferred_validation,
|
|
1395
|
+
examples=inferred_examples,
|
|
1396
|
+
source_task=source_task,
|
|
1397
|
+
)
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
def build_bootstrap_blueprint(
|
|
1401
|
+
*,
|
|
1402
|
+
task: str,
|
|
1403
|
+
raw_name: str | None,
|
|
1404
|
+
category: str,
|
|
1405
|
+
extra_tags: list[str],
|
|
1406
|
+
existing_records: list[SkillRecord],
|
|
1407
|
+
) -> SkillBlueprint:
|
|
1408
|
+
source_task = clean_text(task)
|
|
1409
|
+
normalized_category = normalize_category(category, source_task)
|
|
1410
|
+
name = normalize_name(raw_name or derive_name_from_task(source_task))
|
|
1411
|
+
summary = derive_summary_from_task(source_task)
|
|
1412
|
+
when = build_when_clause(source_task)
|
|
1413
|
+
focus = derive_focus_phrase(source_task)
|
|
1414
|
+
inferred_triggers = infer_trigger_phrases(source_task, normalized_category)
|
|
1415
|
+
inferred_steps = ensure_nested_skill_routing_step(
|
|
1416
|
+
infer_steps(normalized_category, source_task)
|
|
1417
|
+
)
|
|
1418
|
+
inferred_validation = infer_validation(normalized_category)
|
|
1419
|
+
inferred_examples = infer_examples(
|
|
1420
|
+
normalized_category, summary, focus, inferred_triggers
|
|
1421
|
+
)
|
|
1422
|
+
inferred_related = suggest_related_skills(
|
|
1423
|
+
existing_records, source_task, exclude_name=name
|
|
1424
|
+
)
|
|
1425
|
+
description = ensure_sentence(f"{summary.rstrip('.')} . Use when {when}").replace(" .", ".")
|
|
1426
|
+
|
|
1427
|
+
return SkillBlueprint(
|
|
1428
|
+
name=name,
|
|
1429
|
+
title=build_title(name),
|
|
1430
|
+
description=description,
|
|
1431
|
+
category=normalized_category,
|
|
1432
|
+
summary=summary,
|
|
1433
|
+
tags=infer_tags(source_task, normalized_category, extra_tags),
|
|
1434
|
+
triggers=inferred_triggers,
|
|
1435
|
+
steps=inferred_steps,
|
|
1436
|
+
related_skills=inferred_related,
|
|
1437
|
+
validation=inferred_validation,
|
|
1438
|
+
examples=inferred_examples,
|
|
1439
|
+
source_task=source_task,
|
|
1440
|
+
)
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
def normalize_category(category: str, task_text: str) -> str:
|
|
1444
|
+
normalized = normalize_name(category)
|
|
1445
|
+
if not normalized or normalized == "auto":
|
|
1446
|
+
return infer_category(task_text)
|
|
1447
|
+
return normalized
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def infer_category(task_text: str) -> str:
|
|
1451
|
+
lowered = task_text.lower()
|
|
1452
|
+
tokens = tokenize(task_text)
|
|
1453
|
+
best_category = DEFAULT_CATEGORY
|
|
1454
|
+
best_score = 0
|
|
1455
|
+
|
|
1456
|
+
for category, rule in CATEGORY_RULES.items():
|
|
1457
|
+
score = 0
|
|
1458
|
+
for keyword in rule["keywords"]:
|
|
1459
|
+
normalized_keyword = keyword.lower()
|
|
1460
|
+
if " " in normalized_keyword and normalized_keyword in lowered:
|
|
1461
|
+
score += 3
|
|
1462
|
+
continue
|
|
1463
|
+
keyword_tokens = tokenize(normalized_keyword)
|
|
1464
|
+
overlap = tokens & keyword_tokens
|
|
1465
|
+
if overlap:
|
|
1466
|
+
score += len(overlap)
|
|
1467
|
+
if score > best_score:
|
|
1468
|
+
best_category = category
|
|
1469
|
+
best_score = score
|
|
1470
|
+
|
|
1471
|
+
return best_category
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
def infer_tags(task_text: str, category: str, extra_tags: list[str]) -> list[str]:
|
|
1475
|
+
tags: list[str] = []
|
|
1476
|
+
tags.append(category)
|
|
1477
|
+
for token in ordered_tokens(task_text):
|
|
1478
|
+
if token in LOW_SIGNAL_TOKENS:
|
|
1479
|
+
continue
|
|
1480
|
+
tags.append(token)
|
|
1481
|
+
if len(tags) >= 5:
|
|
1482
|
+
break
|
|
1483
|
+
tags.extend(extra_tags)
|
|
1484
|
+
return unique_strings(tags)
|
|
1485
|
+
|
|
1486
|
+
|
|
1487
|
+
def infer_trigger_phrases(task_text: str, category: str) -> list[str]:
|
|
1488
|
+
cleaned = clean_text(task_text).rstrip(".")
|
|
1489
|
+
focus = derive_focus_phrase(cleaned)
|
|
1490
|
+
action = detect_action_word(cleaned)
|
|
1491
|
+
triggers = [cleaned]
|
|
1492
|
+
|
|
1493
|
+
if focus and focus != cleaned:
|
|
1494
|
+
triggers.append(focus)
|
|
1495
|
+
|
|
1496
|
+
if action and focus:
|
|
1497
|
+
if action in {"debug", "fix", "investigate", "inspect"}:
|
|
1498
|
+
triggers.append(f"{focus} issue")
|
|
1499
|
+
triggers.append(f"{focus} regression")
|
|
1500
|
+
elif action in {"create", "build", "implement", "draft", "write", "add"}:
|
|
1501
|
+
triggers.append(f"{action} {focus}")
|
|
1502
|
+
triggers.append(f"{focus} workflow")
|
|
1503
|
+
else:
|
|
1504
|
+
triggers.append(f"{focus} workflow")
|
|
1505
|
+
|
|
1506
|
+
if category != DEFAULT_CATEGORY and focus and category not in focus.split():
|
|
1507
|
+
triggers.append(f"{category} {focus}")
|
|
1508
|
+
|
|
1509
|
+
return unique_strings(triggers[:5])
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
def infer_steps(category: str, task_text: str) -> list[str]:
|
|
1513
|
+
focus = derive_focus_phrase(task_text) or clean_text(task_text)
|
|
1514
|
+
steps = [build_task_intake_step(focus)]
|
|
1515
|
+
steps.extend(CATEGORY_RULES.get(category, CATEGORY_RULES[DEFAULT_CATEGORY])["steps"])
|
|
1516
|
+
return unique_strings(steps)
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
def ensure_nested_skill_routing_step(steps: list[str]) -> list[str]:
|
|
1520
|
+
return unique_strings([*steps, build_nested_skill_routing_step()])
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
def build_nested_skill_routing_step() -> str:
|
|
1524
|
+
return (
|
|
1525
|
+
f"If any workflow step expands into a repeatable, non-trivial subtask, run "
|
|
1526
|
+
f"`{SUBTASK_ROUTING_COMMAND}`, follow the reused or generated sub-skill, and then "
|
|
1527
|
+
"return to the current workflow."
|
|
1528
|
+
)
|
|
1529
|
+
|
|
1530
|
+
|
|
1531
|
+
def infer_validation(category: str) -> list[str]:
|
|
1532
|
+
return unique_strings(CATEGORY_RULES.get(category, CATEGORY_RULES[DEFAULT_CATEGORY])["validation"])
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
def infer_examples(
|
|
1536
|
+
category: str,
|
|
1537
|
+
summary: str,
|
|
1538
|
+
focus: str,
|
|
1539
|
+
triggers: list[str],
|
|
1540
|
+
) -> list[str]:
|
|
1541
|
+
examples = [ensure_sentence(summary)]
|
|
1542
|
+
examples.extend(CATEGORY_RULES.get(category, CATEGORY_RULES[DEFAULT_CATEGORY])["examples"])
|
|
1543
|
+
if focus:
|
|
1544
|
+
examples.append(
|
|
1545
|
+
ensure_sentence(f"Handle repeat work around {focus} with a reusable playbook")
|
|
1546
|
+
)
|
|
1547
|
+
if triggers:
|
|
1548
|
+
examples.append(ensure_sentence(f"Use this skill when asked to {triggers[0]}"))
|
|
1549
|
+
return unique_strings(examples[:4])
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
def build_task_intake_step(focus: str) -> str:
|
|
1553
|
+
if focus:
|
|
1554
|
+
return f"Clarify the desired outcome, failure mode, and constraints for {focus} before editing anything."
|
|
1555
|
+
return "Clarify the desired outcome, failure mode, and constraints before editing anything."
|
|
1556
|
+
|
|
1557
|
+
|
|
1558
|
+
def build_when_clause(task_text: str) -> str:
|
|
1559
|
+
cleaned = clean_text(task_text).rstrip(".")
|
|
1560
|
+
return (
|
|
1561
|
+
f"the user asks to {cleaned}, when that workflow is failing, "
|
|
1562
|
+
"or when similar work is likely to recur"
|
|
1563
|
+
)
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
def derive_summary_from_task(task_text: str) -> str:
|
|
1567
|
+
cleaned = sentence_case(task_text.rstrip("."))
|
|
1568
|
+
if cleaned.lower().startswith("to "):
|
|
1569
|
+
cleaned = f"Handle {cleaned[3:]}"
|
|
1570
|
+
return ensure_sentence(cleaned)
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
def derive_focus_phrase(task_text: str) -> str:
|
|
1574
|
+
tokens = [token for token in ordered_tokens(task_text) if token not in LOW_SIGNAL_TOKENS]
|
|
1575
|
+
if len(tokens) < 2:
|
|
1576
|
+
tokens = [token for token in ordered_tokens(task_text) if token not in STOPWORDS][:3]
|
|
1577
|
+
return " ".join(tokens[:4]).strip()
|
|
1578
|
+
|
|
1579
|
+
|
|
1580
|
+
def detect_action_word(task_text: str) -> str:
|
|
1581
|
+
for token in ordered_tokens(task_text):
|
|
1582
|
+
if token in ACTION_WORDS:
|
|
1583
|
+
return token
|
|
1584
|
+
return ""
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
def suggest_related_skills(
|
|
1588
|
+
records: list[SkillRecord],
|
|
1589
|
+
query: str,
|
|
1590
|
+
*,
|
|
1591
|
+
exclude_name: str,
|
|
1592
|
+
limit: int = 3,
|
|
1593
|
+
min_score: float = 4.0,
|
|
1594
|
+
) -> list[str]:
|
|
1595
|
+
matches = search_records(records, query, limit=max(limit * 2, 6))
|
|
1596
|
+
related: list[str] = []
|
|
1597
|
+
for score, _, record in matches:
|
|
1598
|
+
if score < min_score or record.name == exclude_name:
|
|
1599
|
+
continue
|
|
1600
|
+
related.append(record.name)
|
|
1601
|
+
if len(related) >= limit:
|
|
1602
|
+
break
|
|
1603
|
+
return unique_strings(related)
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
def create_skill(
|
|
1607
|
+
*,
|
|
1608
|
+
skills_dir: Path,
|
|
1609
|
+
blueprint: SkillBlueprint,
|
|
1610
|
+
force: bool,
|
|
1611
|
+
) -> SkillRecord:
|
|
1612
|
+
if not blueprint.name:
|
|
1613
|
+
raise SystemExit("Skill name cannot be empty after normalization.")
|
|
1614
|
+
|
|
1615
|
+
skill_dir = skills_dir / blueprint.name
|
|
1616
|
+
if skill_dir.exists() and not force:
|
|
1617
|
+
raise SystemExit(f"{skill_dir} already exists. Use --force to overwrite it.")
|
|
1618
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
1619
|
+
|
|
1620
|
+
metadata = {
|
|
1621
|
+
"category": blueprint.category,
|
|
1622
|
+
"summary": blueprint.summary,
|
|
1623
|
+
"tags": blueprint.tags,
|
|
1624
|
+
"triggers": blueprint.triggers,
|
|
1625
|
+
"steps": blueprint.steps,
|
|
1626
|
+
"related_skills": blueprint.related_skills,
|
|
1627
|
+
"validation": blueprint.validation,
|
|
1628
|
+
"examples": blueprint.examples,
|
|
1629
|
+
"source_task": blueprint.source_task,
|
|
1630
|
+
"management_mode": DEFAULT_MANAGEMENT_MODE,
|
|
1631
|
+
"created_at": utc_now(),
|
|
1632
|
+
"updated_at": utc_now(),
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
skill_md = build_skill_markdown(blueprint)
|
|
1636
|
+
(skill_dir / "SKILL.md").write_text(skill_md, encoding="utf-8")
|
|
1637
|
+
(skill_dir / "skill.json").write_text(
|
|
1638
|
+
json.dumps(metadata, ensure_ascii=False, indent=2) + "\n",
|
|
1639
|
+
encoding="utf-8",
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
return SkillRecord(
|
|
1643
|
+
name=blueprint.name,
|
|
1644
|
+
path=str(skill_dir),
|
|
1645
|
+
description=blueprint.description,
|
|
1646
|
+
category=blueprint.category,
|
|
1647
|
+
tags=blueprint.tags,
|
|
1648
|
+
triggers=blueprint.triggers,
|
|
1649
|
+
summary=blueprint.summary,
|
|
1650
|
+
steps=blueprint.steps,
|
|
1651
|
+
related_skills=blueprint.related_skills,
|
|
1652
|
+
validation=blueprint.validation,
|
|
1653
|
+
examples=blueprint.examples,
|
|
1654
|
+
title=blueprint.title,
|
|
1655
|
+
management_mode=DEFAULT_MANAGEMENT_MODE,
|
|
1656
|
+
)
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
def build_skill_markdown(blueprint: SkillBlueprint) -> str:
|
|
1660
|
+
lines = [
|
|
1661
|
+
"---",
|
|
1662
|
+
f"name: {blueprint.name}",
|
|
1663
|
+
f"description: {blueprint.description}",
|
|
1664
|
+
"---",
|
|
1665
|
+
"",
|
|
1666
|
+
f"# {blueprint.title}",
|
|
1667
|
+
"",
|
|
1668
|
+
"## Goal",
|
|
1669
|
+
"",
|
|
1670
|
+
ensure_sentence(blueprint.summary),
|
|
1671
|
+
"",
|
|
1672
|
+
"## Workflow",
|
|
1673
|
+
"",
|
|
1674
|
+
]
|
|
1675
|
+
|
|
1676
|
+
for index, step in enumerate(blueprint.steps, start=1):
|
|
1677
|
+
lines.append(f"{index}. {ensure_sentence(step)}")
|
|
1678
|
+
|
|
1679
|
+
if blueprint.validation:
|
|
1680
|
+
lines.extend(["", "## Validation", ""])
|
|
1681
|
+
for item in blueprint.validation:
|
|
1682
|
+
lines.append(f"- {ensure_sentence(item)}")
|
|
1683
|
+
|
|
1684
|
+
if blueprint.examples:
|
|
1685
|
+
lines.extend(["", "## Example Requests", ""])
|
|
1686
|
+
for example in blueprint.examples:
|
|
1687
|
+
lines.append(f"- {ensure_sentence(example)}")
|
|
1688
|
+
|
|
1689
|
+
if blueprint.triggers:
|
|
1690
|
+
lines.extend(["", "## Trigger Phrases", ""])
|
|
1691
|
+
for trigger in blueprint.triggers:
|
|
1692
|
+
lines.append(f"- {ensure_sentence(trigger)}")
|
|
1693
|
+
|
|
1694
|
+
lines.extend(["", "## Reuse Notes", ""])
|
|
1695
|
+
lines.append(f"- Category: `{blueprint.category}`")
|
|
1696
|
+
if blueprint.tags:
|
|
1697
|
+
lines.append(f"- Tags: {', '.join(f'`{tag}`' for tag in blueprint.tags)}")
|
|
1698
|
+
lines.append(
|
|
1699
|
+
"- Start future task routing with `python3 .claude/tools/skill_agent.py auto \"<task>\" --json`."
|
|
1700
|
+
)
|
|
1701
|
+
lines.append(
|
|
1702
|
+
f"- When a workflow step turns into its own reusable subtask, reroute it through "
|
|
1703
|
+
f"`{SUBTASK_ROUTING_COMMAND}` before inventing an ad-hoc mini-flow."
|
|
1704
|
+
)
|
|
1705
|
+
lines.append(
|
|
1706
|
+
"- Search for this skill with `python3 .claude/tools/skill_agent.py search \"<task>\"`."
|
|
1707
|
+
)
|
|
1708
|
+
lines.append(
|
|
1709
|
+
"- Review reuse health with `python3 .claude/tools/skill_agent.py usage`, inspect refresh candidates with `python3 .claude/tools/skill_agent.py review`, and archive stale skills with `python3 .claude/tools/skill_agent.py prune --apply`."
|
|
1710
|
+
)
|
|
1711
|
+
lines.append(
|
|
1712
|
+
"- Preview a generated skill with `python3 .claude/tools/skill_agent.py bootstrap \"<task>\" --dry-run --json`."
|
|
1713
|
+
)
|
|
1714
|
+
if blueprint.related_skills:
|
|
1715
|
+
lines.append(
|
|
1716
|
+
f"- Related skills: {', '.join(f'`{name}`' for name in blueprint.related_skills)}"
|
|
1717
|
+
)
|
|
1718
|
+
|
|
1719
|
+
return "\n".join(lines) + "\n"
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
def emit_blueprint_preview(blueprint: SkillBlueprint, *, as_json: bool) -> None:
|
|
1723
|
+
payload = blueprint_payload(blueprint)
|
|
1724
|
+
if as_json:
|
|
1725
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
1726
|
+
return
|
|
1727
|
+
|
|
1728
|
+
print(f"Name: {blueprint.name}")
|
|
1729
|
+
print(f"Category: {blueprint.category}")
|
|
1730
|
+
print(f"Tags: {', '.join(blueprint.tags) or '(none)'}")
|
|
1731
|
+
print(f"Related: {', '.join(blueprint.related_skills) or '(none)'}")
|
|
1732
|
+
print("")
|
|
1733
|
+
print(payload["markdown"])
|
|
1734
|
+
|
|
1735
|
+
|
|
1736
|
+
def blueprint_payload(blueprint: SkillBlueprint) -> dict[str, Any]:
|
|
1737
|
+
payload = asdict(blueprint)
|
|
1738
|
+
payload["markdown"] = build_skill_markdown(blueprint)
|
|
1739
|
+
return payload
|
|
1740
|
+
|
|
1741
|
+
|
|
1742
|
+
def match_payload(score: float, reason: str, record: SkillRecord) -> dict[str, Any]:
|
|
1743
|
+
payload = asdict(record)
|
|
1744
|
+
payload["score"] = score
|
|
1745
|
+
payload["reason"] = reason
|
|
1746
|
+
return payload
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
def choose_auto_match(
|
|
1750
|
+
matches: list[tuple[float, str, SkillRecord]],
|
|
1751
|
+
task: str,
|
|
1752
|
+
) -> tuple[float, str, SkillRecord] | None:
|
|
1753
|
+
for match in matches:
|
|
1754
|
+
if should_skip_auto_reuse(match[2], task):
|
|
1755
|
+
continue
|
|
1756
|
+
return match
|
|
1757
|
+
return None
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
def should_skip_auto_reuse(record: SkillRecord, task: str) -> bool:
|
|
1761
|
+
if record.name != "project-skill-router":
|
|
1762
|
+
return False
|
|
1763
|
+
return not is_skill_management_task(task)
|
|
1764
|
+
|
|
1765
|
+
|
|
1766
|
+
def is_skill_management_task(task: str) -> bool:
|
|
1767
|
+
tokens = tokenize(task)
|
|
1768
|
+
return bool(
|
|
1769
|
+
tokens
|
|
1770
|
+
& {
|
|
1771
|
+
"automation",
|
|
1772
|
+
"bootstrap",
|
|
1773
|
+
"local",
|
|
1774
|
+
"registry",
|
|
1775
|
+
"reuse",
|
|
1776
|
+
"router",
|
|
1777
|
+
"scaffold",
|
|
1778
|
+
"skill",
|
|
1779
|
+
"skills",
|
|
1780
|
+
}
|
|
1781
|
+
)
|
|
1782
|
+
|
|
1783
|
+
|
|
1784
|
+
def emit_auto_result(payload: dict[str, Any], *, as_json: bool) -> None:
|
|
1785
|
+
if as_json:
|
|
1786
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
1787
|
+
return
|
|
1788
|
+
|
|
1789
|
+
action = payload["action"]
|
|
1790
|
+
print(f"Action: {action}")
|
|
1791
|
+
print(f"Task: {payload['task']}")
|
|
1792
|
+
|
|
1793
|
+
if payload.get("match"):
|
|
1794
|
+
match = payload["match"]
|
|
1795
|
+
print(f"Skill: {match['name']}")
|
|
1796
|
+
print(f"Path: {match['path']}")
|
|
1797
|
+
print(f"Reason: {match['reason']}")
|
|
1798
|
+
if payload.get("skill_update"):
|
|
1799
|
+
update = payload["skill_update"]
|
|
1800
|
+
print(
|
|
1801
|
+
"Updated fields: "
|
|
1802
|
+
+ ", ".join(update["updated_fields"])
|
|
1803
|
+
)
|
|
1804
|
+
print("Next: open the skill and follow its workflow.")
|
|
1805
|
+
return
|
|
1806
|
+
|
|
1807
|
+
if payload.get("created_skill"):
|
|
1808
|
+
created = payload["created_skill"]
|
|
1809
|
+
print(f"Skill: {created['name']}")
|
|
1810
|
+
print(f"Path: {created['path']}")
|
|
1811
|
+
print("Reason: no existing skill cleared the reuse threshold, so a new skill was generated.")
|
|
1812
|
+
print("Next: open the generated SKILL.md and use it immediately.")
|
|
1813
|
+
return
|
|
1814
|
+
|
|
1815
|
+
if payload.get("blueprint"):
|
|
1816
|
+
blueprint = payload["blueprint"]
|
|
1817
|
+
print(f"Skill: {blueprint['name']}")
|
|
1818
|
+
print(f"Category: {blueprint['category']}")
|
|
1819
|
+
print("Reason: no reusable skill cleared the threshold; previewing a generated skill instead.")
|
|
1820
|
+
print("Next: rerun without --dry-run to persist the generated skill.")
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
def resolve_usage_path(skills_dir: Path) -> Path:
|
|
1824
|
+
return skills_dir / USAGE_FILENAME
|
|
1825
|
+
|
|
1826
|
+
|
|
1827
|
+
def load_usage_store(usage_path: Path) -> dict[str, Any]:
|
|
1828
|
+
if not usage_path.exists():
|
|
1829
|
+
return {"skills": {}}
|
|
1830
|
+
try:
|
|
1831
|
+
payload = json.loads(usage_path.read_text(encoding="utf-8"))
|
|
1832
|
+
except json.JSONDecodeError:
|
|
1833
|
+
return {"skills": {}}
|
|
1834
|
+
if not isinstance(payload, dict):
|
|
1835
|
+
return {"skills": {}}
|
|
1836
|
+
skills = payload.get("skills")
|
|
1837
|
+
if not isinstance(skills, dict):
|
|
1838
|
+
payload["skills"] = {}
|
|
1839
|
+
return payload
|
|
1840
|
+
|
|
1841
|
+
|
|
1842
|
+
def write_usage_store(usage_path: Path, payload: dict[str, Any]) -> None:
|
|
1843
|
+
usage_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1844
|
+
payload["updated_at"] = utc_now()
|
|
1845
|
+
usage_path.write_text(
|
|
1846
|
+
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
|
1847
|
+
encoding="utf-8",
|
|
1848
|
+
)
|
|
1849
|
+
|
|
1850
|
+
|
|
1851
|
+
def record_skill_event(
|
|
1852
|
+
*,
|
|
1853
|
+
usage_path: Path,
|
|
1854
|
+
record: SkillRecord,
|
|
1855
|
+
action: str,
|
|
1856
|
+
task: str,
|
|
1857
|
+
score: float | None = None,
|
|
1858
|
+
) -> None:
|
|
1859
|
+
now = utc_now()
|
|
1860
|
+
payload = load_usage_store(usage_path)
|
|
1861
|
+
skills = payload.setdefault("skills", {})
|
|
1862
|
+
entry = skills.setdefault(record.name, {})
|
|
1863
|
+
|
|
1864
|
+
entry["name"] = record.name
|
|
1865
|
+
entry["category"] = record.category
|
|
1866
|
+
entry["path"] = record.path
|
|
1867
|
+
entry["first_seen_at"] = str(entry.get("first_seen_at") or now)
|
|
1868
|
+
entry["last_activity_at"] = now
|
|
1869
|
+
entry["last_action"] = action
|
|
1870
|
+
entry["last_task"] = clean_text(task)
|
|
1871
|
+
entry["reuse_count"] = int(entry.get("reuse_count", 0))
|
|
1872
|
+
entry["create_count"] = int(entry.get("create_count", 0))
|
|
1873
|
+
entry["auto_hits"] = int(entry.get("auto_hits", 0))
|
|
1874
|
+
entry["update_count"] = int(entry.get("update_count", 0))
|
|
1875
|
+
|
|
1876
|
+
if action in {"auto-reuse", "auto-created"}:
|
|
1877
|
+
entry["auto_hits"] += 1
|
|
1878
|
+
if "reuse" in action:
|
|
1879
|
+
entry["reuse_count"] += 1
|
|
1880
|
+
entry["last_reused_at"] = now
|
|
1881
|
+
if "create" in action or "bootstrap" in action:
|
|
1882
|
+
entry["create_count"] += 1
|
|
1883
|
+
entry["last_created_at"] = now
|
|
1884
|
+
if "update" in action:
|
|
1885
|
+
entry["update_count"] += 1
|
|
1886
|
+
entry["last_updated_at"] = now
|
|
1887
|
+
if score is not None:
|
|
1888
|
+
entry["last_score"] = round(score, 2)
|
|
1889
|
+
score_history = entry.get("score_history", [])
|
|
1890
|
+
if not isinstance(score_history, list):
|
|
1891
|
+
score_history = []
|
|
1892
|
+
score_history.append({"at": now, "score": round(score, 2)})
|
|
1893
|
+
entry["score_history"] = score_history[-REFRESH_SCORE_HISTORY_LIMIT:]
|
|
1894
|
+
|
|
1895
|
+
history = entry.get("history", [])
|
|
1896
|
+
if not isinstance(history, list):
|
|
1897
|
+
history = []
|
|
1898
|
+
history.append(
|
|
1899
|
+
{
|
|
1900
|
+
"at": now,
|
|
1901
|
+
"action": action,
|
|
1902
|
+
"task": clean_text(task),
|
|
1903
|
+
}
|
|
1904
|
+
)
|
|
1905
|
+
entry["history"] = history[-USAGE_HISTORY_LIMIT:]
|
|
1906
|
+
|
|
1907
|
+
write_usage_store(usage_path, payload)
|
|
1908
|
+
|
|
1909
|
+
|
|
1910
|
+
def maybe_auto_update_skill(
|
|
1911
|
+
*,
|
|
1912
|
+
record: SkillRecord,
|
|
1913
|
+
skills_dir: Path,
|
|
1914
|
+
registry_path: Path,
|
|
1915
|
+
usage_path: Path,
|
|
1916
|
+
task: str,
|
|
1917
|
+
) -> dict[str, Any] | None:
|
|
1918
|
+
usage_store = load_usage_store(usage_path)
|
|
1919
|
+
entry = usage_store.get("skills", {}).get(record.name, {})
|
|
1920
|
+
plan = build_skill_refresh_plan(record, entry)
|
|
1921
|
+
if plan.status != "candidate":
|
|
1922
|
+
return None
|
|
1923
|
+
|
|
1924
|
+
updated = apply_skill_update_plan(
|
|
1925
|
+
record=record,
|
|
1926
|
+
plan=plan,
|
|
1927
|
+
usage_path=usage_path,
|
|
1928
|
+
task=task,
|
|
1929
|
+
action="auto-update",
|
|
1930
|
+
)
|
|
1931
|
+
updated_records = discover_skills(skills_dir)
|
|
1932
|
+
write_registry(updated_records, registry_path)
|
|
1933
|
+
return updated
|
|
1934
|
+
|
|
1935
|
+
|
|
1936
|
+
def build_skill_refresh_plans(
|
|
1937
|
+
records: list[SkillRecord],
|
|
1938
|
+
usage_path: Path,
|
|
1939
|
+
) -> list[SkillRefreshPlan]:
|
|
1940
|
+
usage_store = load_usage_store(usage_path)
|
|
1941
|
+
skills = usage_store.get("skills", {})
|
|
1942
|
+
plans = [build_skill_refresh_plan(record, skills.get(record.name, {})) for record in records]
|
|
1943
|
+
plans.sort(
|
|
1944
|
+
key=lambda plan: (
|
|
1945
|
+
refresh_status_rank(plan.status),
|
|
1946
|
+
plan.updated_days * -1,
|
|
1947
|
+
plan.name,
|
|
1948
|
+
)
|
|
1949
|
+
)
|
|
1950
|
+
return plans
|
|
1951
|
+
|
|
1952
|
+
|
|
1953
|
+
def build_skill_refresh_plan(
|
|
1954
|
+
record: SkillRecord,
|
|
1955
|
+
entry: dict[str, Any],
|
|
1956
|
+
) -> SkillRefreshPlan:
|
|
1957
|
+
metadata = load_companion_metadata(Path(record.path) / "skill.json")
|
|
1958
|
+
management_mode = normalize_management_mode(
|
|
1959
|
+
metadata.get("management_mode", record.management_mode)
|
|
1960
|
+
)
|
|
1961
|
+
recent_tasks = recent_reuse_tasks(entry)
|
|
1962
|
+
reuse_count = int(entry.get("reuse_count", 0))
|
|
1963
|
+
avg_score = average_recent_score(entry)
|
|
1964
|
+
updated_at = (
|
|
1965
|
+
parse_datetime(metadata.get("updated_at"))
|
|
1966
|
+
or parse_datetime(metadata.get("created_at"))
|
|
1967
|
+
or filesystem_timestamp(Path(record.path) / "skill.json")
|
|
1968
|
+
or filesystem_timestamp(Path(record.path) / "SKILL.md")
|
|
1969
|
+
or datetime.now(UTC)
|
|
1970
|
+
)
|
|
1971
|
+
updated_days = max((datetime.now(UTC) - updated_at).days, 0)
|
|
1972
|
+
|
|
1973
|
+
if record.name in PROTECTED_SKILLS:
|
|
1974
|
+
return SkillRefreshPlan(
|
|
1975
|
+
name=record.name,
|
|
1976
|
+
path=record.path,
|
|
1977
|
+
status="protected",
|
|
1978
|
+
reason="Core routing or reference skill.",
|
|
1979
|
+
management_mode=management_mode,
|
|
1980
|
+
changes={},
|
|
1981
|
+
recent_tasks=recent_tasks,
|
|
1982
|
+
reuse_count=reuse_count,
|
|
1983
|
+
avg_score=avg_score,
|
|
1984
|
+
updated_days=updated_days,
|
|
1985
|
+
)
|
|
1986
|
+
|
|
1987
|
+
if management_mode == "locked":
|
|
1988
|
+
return SkillRefreshPlan(
|
|
1989
|
+
name=record.name,
|
|
1990
|
+
path=record.path,
|
|
1991
|
+
status="locked",
|
|
1992
|
+
reason="Locked skills require explicit manual edits.",
|
|
1993
|
+
management_mode=management_mode,
|
|
1994
|
+
changes={},
|
|
1995
|
+
recent_tasks=recent_tasks,
|
|
1996
|
+
reuse_count=reuse_count,
|
|
1997
|
+
avg_score=avg_score,
|
|
1998
|
+
updated_days=updated_days,
|
|
1999
|
+
)
|
|
2000
|
+
|
|
2001
|
+
reasons: list[str] = []
|
|
2002
|
+
if reuse_count >= 1 and len(record.triggers) < 2 and recent_tasks:
|
|
2003
|
+
reasons.append("Trigger coverage is too thin for a reused skill.")
|
|
2004
|
+
if reuse_count >= 1 and len(record.examples) < 2 and recent_tasks:
|
|
2005
|
+
reasons.append("Example requests are too thin for a reused skill.")
|
|
2006
|
+
if reuse_count >= 2 and avg_score is not None and avg_score < REFRESH_LOW_SCORE_THRESHOLD:
|
|
2007
|
+
reasons.append("Recent reuse scores are low enough that metadata should be tightened.")
|
|
2008
|
+
if reuse_count >= 2 and updated_days >= REFRESH_STALE_DAYS and recent_tasks:
|
|
2009
|
+
reasons.append(f"Metadata has not been refreshed for {REFRESH_STALE_DAYS}+ days.")
|
|
2010
|
+
|
|
2011
|
+
known_tokens = known_record_tokens(record)
|
|
2012
|
+
novel_tokens = [
|
|
2013
|
+
token
|
|
2014
|
+
for token in ordered_tokens(" ".join(recent_tasks))
|
|
2015
|
+
if token not in known_tokens and token not in LOW_SIGNAL_TOKENS
|
|
2016
|
+
]
|
|
2017
|
+
if reuse_count >= 2 and len(novel_tokens) >= 2:
|
|
2018
|
+
reasons.append("Recent reuse requests contain new terms not represented in the skill metadata.")
|
|
2019
|
+
|
|
2020
|
+
changes = build_refresh_metadata_changes(
|
|
2021
|
+
record=record,
|
|
2022
|
+
metadata=metadata,
|
|
2023
|
+
recent_tasks=recent_tasks,
|
|
2024
|
+
management_mode=management_mode,
|
|
2025
|
+
)
|
|
2026
|
+
actionable_fields = [
|
|
2027
|
+
field for field in changes if field not in {"updated_at", "management_mode"}
|
|
2028
|
+
]
|
|
2029
|
+
if not reasons or not actionable_fields:
|
|
2030
|
+
return SkillRefreshPlan(
|
|
2031
|
+
name=record.name,
|
|
2032
|
+
path=record.path,
|
|
2033
|
+
status="healthy",
|
|
2034
|
+
reason="No safe metadata refresh is needed right now.",
|
|
2035
|
+
management_mode=management_mode,
|
|
2036
|
+
changes={},
|
|
2037
|
+
recent_tasks=recent_tasks,
|
|
2038
|
+
reuse_count=reuse_count,
|
|
2039
|
+
avg_score=avg_score,
|
|
2040
|
+
updated_days=updated_days,
|
|
2041
|
+
)
|
|
2042
|
+
|
|
2043
|
+
return SkillRefreshPlan(
|
|
2044
|
+
name=record.name,
|
|
2045
|
+
path=record.path,
|
|
2046
|
+
status="candidate",
|
|
2047
|
+
reason=" ".join(reasons[:3]),
|
|
2048
|
+
management_mode=management_mode,
|
|
2049
|
+
changes=changes,
|
|
2050
|
+
recent_tasks=recent_tasks,
|
|
2051
|
+
reuse_count=reuse_count,
|
|
2052
|
+
avg_score=avg_score,
|
|
2053
|
+
updated_days=updated_days,
|
|
2054
|
+
)
|
|
2055
|
+
|
|
2056
|
+
|
|
2057
|
+
def recent_reuse_tasks(entry: dict[str, Any], *, limit: int = 4) -> list[str]:
|
|
2058
|
+
history = entry.get("history", [])
|
|
2059
|
+
if not isinstance(history, list):
|
|
2060
|
+
history = []
|
|
2061
|
+
tasks: list[str] = []
|
|
2062
|
+
seen: set[str] = set()
|
|
2063
|
+
for item in reversed(history):
|
|
2064
|
+
if not isinstance(item, dict):
|
|
2065
|
+
continue
|
|
2066
|
+
action = str(item.get("action") or "")
|
|
2067
|
+
task = clean_text(str(item.get("task") or ""))
|
|
2068
|
+
if "reuse" not in action or not task or task in seen:
|
|
2069
|
+
continue
|
|
2070
|
+
tasks.append(task)
|
|
2071
|
+
seen.add(task)
|
|
2072
|
+
if len(tasks) >= limit:
|
|
2073
|
+
break
|
|
2074
|
+
fallback = clean_text(str(entry.get("last_task") or ""))
|
|
2075
|
+
if fallback and fallback not in seen and len(tasks) < limit:
|
|
2076
|
+
tasks.append(fallback)
|
|
2077
|
+
return tasks
|
|
2078
|
+
|
|
2079
|
+
|
|
2080
|
+
def average_recent_score(entry: dict[str, Any], *, limit: int = 5) -> float | None:
|
|
2081
|
+
score_history = entry.get("score_history", [])
|
|
2082
|
+
if not isinstance(score_history, list):
|
|
2083
|
+
score_history = []
|
|
2084
|
+
values: list[float] = []
|
|
2085
|
+
for item in score_history[-limit:]:
|
|
2086
|
+
if not isinstance(item, dict):
|
|
2087
|
+
continue
|
|
2088
|
+
score = item.get("score")
|
|
2089
|
+
if isinstance(score, (int, float)):
|
|
2090
|
+
values.append(float(score))
|
|
2091
|
+
if not values:
|
|
2092
|
+
return None
|
|
2093
|
+
return round(sum(values) / len(values), 2)
|
|
2094
|
+
|
|
2095
|
+
|
|
2096
|
+
def known_record_tokens(record: SkillRecord) -> set[str]:
|
|
2097
|
+
token_fields = [
|
|
2098
|
+
record.name.replace("-", " "),
|
|
2099
|
+
record.category,
|
|
2100
|
+
record.description,
|
|
2101
|
+
record.summary,
|
|
2102
|
+
" ".join(record.tags),
|
|
2103
|
+
" ".join(record.triggers),
|
|
2104
|
+
" ".join(record.examples),
|
|
2105
|
+
]
|
|
2106
|
+
tokens: set[str] = set()
|
|
2107
|
+
for field in token_fields:
|
|
2108
|
+
tokens |= tokenize(field)
|
|
2109
|
+
return tokens
|
|
2110
|
+
|
|
2111
|
+
|
|
2112
|
+
def build_refresh_metadata_changes(
|
|
2113
|
+
*,
|
|
2114
|
+
record: SkillRecord,
|
|
2115
|
+
metadata: dict[str, Any],
|
|
2116
|
+
recent_tasks: list[str],
|
|
2117
|
+
management_mode: str,
|
|
2118
|
+
) -> dict[str, Any]:
|
|
2119
|
+
changes: dict[str, Any] = {}
|
|
2120
|
+
existing_tags = unique_strings(metadata.get("tags", record.tags))
|
|
2121
|
+
existing_triggers = unique_strings(metadata.get("triggers", record.triggers))
|
|
2122
|
+
existing_examples = unique_strings(metadata.get("examples", record.examples))
|
|
2123
|
+
|
|
2124
|
+
joined_tasks = " ".join(recent_tasks)
|
|
2125
|
+
merged_tags = merge_limited_strings(
|
|
2126
|
+
existing_tags,
|
|
2127
|
+
infer_tags(joined_tasks, record.category, []),
|
|
2128
|
+
limit=REFRESH_TAG_LIMIT,
|
|
2129
|
+
)
|
|
2130
|
+
merged_triggers = merge_limited_strings(
|
|
2131
|
+
existing_triggers,
|
|
2132
|
+
[
|
|
2133
|
+
trigger
|
|
2134
|
+
for task in recent_tasks
|
|
2135
|
+
for trigger in infer_trigger_phrases(task, record.category)
|
|
2136
|
+
],
|
|
2137
|
+
limit=REFRESH_TRIGGER_LIMIT,
|
|
2138
|
+
)
|
|
2139
|
+
merged_examples = merge_limited_strings(
|
|
2140
|
+
existing_examples,
|
|
2141
|
+
[ensure_sentence(sentence_case(task)) for task in recent_tasks],
|
|
2142
|
+
limit=REFRESH_EXAMPLE_LIMIT,
|
|
2143
|
+
)
|
|
2144
|
+
|
|
2145
|
+
if merged_tags != existing_tags:
|
|
2146
|
+
changes["tags"] = merged_tags
|
|
2147
|
+
if merged_triggers != existing_triggers:
|
|
2148
|
+
changes["triggers"] = merged_triggers
|
|
2149
|
+
if merged_examples != existing_examples:
|
|
2150
|
+
changes["examples"] = merged_examples
|
|
2151
|
+
if normalize_management_mode(metadata.get("management_mode")) != management_mode:
|
|
2152
|
+
changes["management_mode"] = management_mode
|
|
2153
|
+
if changes:
|
|
2154
|
+
changes["updated_at"] = utc_now()
|
|
2155
|
+
return changes
|
|
2156
|
+
|
|
2157
|
+
|
|
2158
|
+
def merge_limited_strings(existing: list[str], additions: list[str], *, limit: int) -> list[str]:
|
|
2159
|
+
merged = list(existing)
|
|
2160
|
+
seen = set(existing)
|
|
2161
|
+
for item in additions:
|
|
2162
|
+
cleaned = clean_text(item)
|
|
2163
|
+
if not cleaned or cleaned in seen:
|
|
2164
|
+
continue
|
|
2165
|
+
merged.append(cleaned)
|
|
2166
|
+
seen.add(cleaned)
|
|
2167
|
+
if len(merged) >= limit:
|
|
2168
|
+
break
|
|
2169
|
+
return merged
|
|
2170
|
+
|
|
2171
|
+
|
|
2172
|
+
def refresh_status_rank(status: str) -> int:
|
|
2173
|
+
order = {
|
|
2174
|
+
"candidate": 0,
|
|
2175
|
+
"healthy": 1,
|
|
2176
|
+
"locked": 2,
|
|
2177
|
+
"protected": 3,
|
|
2178
|
+
}
|
|
2179
|
+
return order.get(status, 99)
|
|
2180
|
+
|
|
2181
|
+
|
|
2182
|
+
def refresh_plan_payload(plan: SkillRefreshPlan) -> dict[str, Any]:
|
|
2183
|
+
return {
|
|
2184
|
+
"name": plan.name,
|
|
2185
|
+
"path": plan.path,
|
|
2186
|
+
"status": plan.status,
|
|
2187
|
+
"reason": plan.reason,
|
|
2188
|
+
"management_mode": plan.management_mode,
|
|
2189
|
+
"updated_fields": sorted(plan.changes.keys()),
|
|
2190
|
+
"recent_tasks": plan.recent_tasks,
|
|
2191
|
+
"reuse_count": plan.reuse_count,
|
|
2192
|
+
"avg_score": plan.avg_score,
|
|
2193
|
+
"updated_days": plan.updated_days,
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
|
|
2197
|
+
def apply_skill_update_plan(
|
|
2198
|
+
*,
|
|
2199
|
+
record: SkillRecord,
|
|
2200
|
+
plan: SkillRefreshPlan,
|
|
2201
|
+
usage_path: Path,
|
|
2202
|
+
task: str,
|
|
2203
|
+
action: str,
|
|
2204
|
+
) -> dict[str, Any]:
|
|
2205
|
+
skill_dir = Path(record.path)
|
|
2206
|
+
metadata_path = skill_dir / "skill.json"
|
|
2207
|
+
metadata = load_companion_metadata(metadata_path)
|
|
2208
|
+
metadata.update(plan.changes)
|
|
2209
|
+
metadata_path.write_text(
|
|
2210
|
+
json.dumps(metadata, ensure_ascii=False, indent=2) + "\n",
|
|
2211
|
+
encoding="utf-8",
|
|
2212
|
+
)
|
|
2213
|
+
|
|
2214
|
+
if plan.management_mode == "managed":
|
|
2215
|
+
blueprint = build_blueprint_from_record(record, metadata)
|
|
2216
|
+
(skill_dir / "SKILL.md").write_text(
|
|
2217
|
+
build_skill_markdown(blueprint),
|
|
2218
|
+
encoding="utf-8",
|
|
2219
|
+
)
|
|
2220
|
+
|
|
2221
|
+
updated_record = parse_skill(skill_dir / "SKILL.md") or record
|
|
2222
|
+
record_skill_event(
|
|
2223
|
+
usage_path=usage_path,
|
|
2224
|
+
record=updated_record,
|
|
2225
|
+
action=action,
|
|
2226
|
+
task=task,
|
|
2227
|
+
)
|
|
2228
|
+
return {
|
|
2229
|
+
"name": updated_record.name,
|
|
2230
|
+
"path": updated_record.path,
|
|
2231
|
+
"management_mode": plan.management_mode,
|
|
2232
|
+
"updated_fields": sorted(plan.changes.keys()),
|
|
2233
|
+
"reason": plan.reason,
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
|
|
2237
|
+
def build_blueprint_from_record(
|
|
2238
|
+
record: SkillRecord,
|
|
2239
|
+
metadata: dict[str, Any],
|
|
2240
|
+
) -> SkillBlueprint:
|
|
2241
|
+
return SkillBlueprint(
|
|
2242
|
+
name=record.name,
|
|
2243
|
+
title=record.title,
|
|
2244
|
+
description=record.description,
|
|
2245
|
+
category=normalize_category(str(metadata.get("category") or record.category), record.summary),
|
|
2246
|
+
summary=clean_text(str(metadata.get("summary") or record.summary)),
|
|
2247
|
+
tags=unique_strings(metadata.get("tags", record.tags)),
|
|
2248
|
+
triggers=unique_strings(metadata.get("triggers", record.triggers)),
|
|
2249
|
+
steps=ensure_nested_skill_routing_step(
|
|
2250
|
+
unique_strings(metadata.get("steps", record.steps))
|
|
2251
|
+
),
|
|
2252
|
+
related_skills=unique_strings(metadata.get("related_skills", record.related_skills)),
|
|
2253
|
+
validation=unique_strings(metadata.get("validation", record.validation)),
|
|
2254
|
+
examples=unique_strings(metadata.get("examples", record.examples)),
|
|
2255
|
+
source_task=clean_text(str(metadata.get("source_task") or "")),
|
|
2256
|
+
)
|
|
2257
|
+
|
|
2258
|
+
|
|
2259
|
+
def build_skill_usage_summaries(
|
|
2260
|
+
records: list[SkillRecord],
|
|
2261
|
+
usage_path: Path,
|
|
2262
|
+
) -> list[dict[str, Any]]:
|
|
2263
|
+
usage_store = load_usage_store(usage_path)
|
|
2264
|
+
skills = usage_store.get("skills", {})
|
|
2265
|
+
summaries = []
|
|
2266
|
+
|
|
2267
|
+
for record in records:
|
|
2268
|
+
entry = skills.get(record.name, {})
|
|
2269
|
+
summaries.append(summarize_skill_usage(record, entry))
|
|
2270
|
+
|
|
2271
|
+
summaries.sort(
|
|
2272
|
+
key=lambda item: (
|
|
2273
|
+
usage_status_rank(item["status"]),
|
|
2274
|
+
-item["reuse_count"],
|
|
2275
|
+
-item["age_days"],
|
|
2276
|
+
item["name"],
|
|
2277
|
+
)
|
|
2278
|
+
)
|
|
2279
|
+
return summaries
|
|
2280
|
+
|
|
2281
|
+
|
|
2282
|
+
def summarize_skill_usage(record: SkillRecord, entry: dict[str, Any]) -> dict[str, Any]:
|
|
2283
|
+
now = datetime.now(UTC)
|
|
2284
|
+
created_at = (
|
|
2285
|
+
parse_datetime(entry.get("first_seen_at"))
|
|
2286
|
+
or parse_datetime(load_skill_metadata_timestamp(record, "created_at"))
|
|
2287
|
+
or parse_datetime(load_skill_metadata_timestamp(record, "updated_at"))
|
|
2288
|
+
or filesystem_timestamp(Path(record.path) / "SKILL.md")
|
|
2289
|
+
or now
|
|
2290
|
+
)
|
|
2291
|
+
last_activity = (
|
|
2292
|
+
parse_datetime(entry.get("last_activity_at"))
|
|
2293
|
+
or parse_datetime(entry.get("last_reused_at"))
|
|
2294
|
+
or parse_datetime(entry.get("last_created_at"))
|
|
2295
|
+
or created_at
|
|
2296
|
+
)
|
|
2297
|
+
|
|
2298
|
+
reuse_count = int(entry.get("reuse_count", 0))
|
|
2299
|
+
create_count = int(entry.get("create_count", 0))
|
|
2300
|
+
age_days = max((now - created_at).days, 0)
|
|
2301
|
+
last_activity_days = max((now - last_activity).days, 0)
|
|
2302
|
+
status, reason = classify_skill_usage(
|
|
2303
|
+
record.name,
|
|
2304
|
+
reuse_count=reuse_count,
|
|
2305
|
+
create_count=create_count,
|
|
2306
|
+
age_days=age_days,
|
|
2307
|
+
last_activity_days=last_activity_days,
|
|
2308
|
+
)
|
|
2309
|
+
|
|
2310
|
+
return {
|
|
2311
|
+
"name": record.name,
|
|
2312
|
+
"path": record.path,
|
|
2313
|
+
"category": record.category,
|
|
2314
|
+
"management_mode": record.management_mode,
|
|
2315
|
+
"reuse_count": reuse_count,
|
|
2316
|
+
"create_count": create_count,
|
|
2317
|
+
"auto_hits": int(entry.get("auto_hits", 0)),
|
|
2318
|
+
"update_count": int(entry.get("update_count", 0)),
|
|
2319
|
+
"age_days": age_days,
|
|
2320
|
+
"last_activity_days": last_activity_days,
|
|
2321
|
+
"status": status,
|
|
2322
|
+
"reason": reason,
|
|
2323
|
+
"last_task": entry.get("last_task"),
|
|
2324
|
+
"last_action": entry.get("last_action"),
|
|
2325
|
+
"first_seen_at": format_datetime(created_at),
|
|
2326
|
+
"last_activity_at": format_datetime(last_activity),
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
|
|
2330
|
+
def classify_skill_usage(
|
|
2331
|
+
skill_name: str,
|
|
2332
|
+
*,
|
|
2333
|
+
reuse_count: int,
|
|
2334
|
+
create_count: int,
|
|
2335
|
+
age_days: int,
|
|
2336
|
+
last_activity_days: int,
|
|
2337
|
+
) -> tuple[str, str]:
|
|
2338
|
+
if skill_name in PROTECTED_SKILLS:
|
|
2339
|
+
return "protected", "Core routing or reference skill."
|
|
2340
|
+
if reuse_count == 0 and create_count == 0 and age_days >= PRUNE_NEVER_REUSED_DAYS:
|
|
2341
|
+
return "candidate", f"Never used and older than {PRUNE_NEVER_REUSED_DAYS} days."
|
|
2342
|
+
if reuse_count == 0 and create_count >= 1 and last_activity_days >= PRUNE_NEVER_REUSED_DAYS:
|
|
2343
|
+
return "candidate", f"Created but never reused after {PRUNE_NEVER_REUSED_DAYS} days."
|
|
2344
|
+
if reuse_count <= 1 and last_activity_days >= PRUNE_SINGLE_REUSE_DAYS:
|
|
2345
|
+
return "candidate", f"Reused once or less and idle for {PRUNE_SINGLE_REUSE_DAYS}+ days."
|
|
2346
|
+
if reuse_count <= 2 and last_activity_days >= PRUNE_LOW_REUSE_DAYS:
|
|
2347
|
+
return "candidate", f"Low reuse and idle for {PRUNE_LOW_REUSE_DAYS}+ days."
|
|
2348
|
+
if reuse_count >= 3 or last_activity_days <= ACTIVE_RECENT_DAYS:
|
|
2349
|
+
return "active", "Recently active or reused often enough to keep."
|
|
2350
|
+
return "stale", "Low recent activity, but not yet old enough to archive."
|
|
2351
|
+
|
|
2352
|
+
|
|
2353
|
+
def usage_status_rank(status: str) -> int:
|
|
2354
|
+
order = {
|
|
2355
|
+
"candidate": 0,
|
|
2356
|
+
"stale": 1,
|
|
2357
|
+
"active": 2,
|
|
2358
|
+
"protected": 3,
|
|
2359
|
+
}
|
|
2360
|
+
return order.get(status, 99)
|
|
2361
|
+
|
|
2362
|
+
|
|
2363
|
+
def archive_skill_candidates(
|
|
2364
|
+
candidates: list[dict[str, Any]],
|
|
2365
|
+
skills_dir: Path,
|
|
2366
|
+
usage_path: Path,
|
|
2367
|
+
) -> list[dict[str, Any]]:
|
|
2368
|
+
archive_dir = skills_dir / ARCHIVE_DIRNAME
|
|
2369
|
+
archive_dir.mkdir(parents=True, exist_ok=True)
|
|
2370
|
+
payload = load_usage_store(usage_path)
|
|
2371
|
+
skills = payload.setdefault("skills", {})
|
|
2372
|
+
archived: list[dict[str, Any]] = []
|
|
2373
|
+
|
|
2374
|
+
for item in candidates:
|
|
2375
|
+
source = Path(item["path"])
|
|
2376
|
+
destination = unique_archive_path(archive_dir, item["name"])
|
|
2377
|
+
shutil.move(str(source), str(destination))
|
|
2378
|
+
entry = skills.setdefault(item["name"], {})
|
|
2379
|
+
entry["archived_at"] = utc_now()
|
|
2380
|
+
entry["archived_path"] = str(destination)
|
|
2381
|
+
entry["archive_reason"] = item["reason"]
|
|
2382
|
+
archived.append(
|
|
2383
|
+
{
|
|
2384
|
+
"name": item["name"],
|
|
2385
|
+
"from_path": str(source),
|
|
2386
|
+
"archived_path": str(destination),
|
|
2387
|
+
"reason": item["reason"],
|
|
2388
|
+
}
|
|
2389
|
+
)
|
|
2390
|
+
|
|
2391
|
+
write_usage_store(usage_path, payload)
|
|
2392
|
+
return archived
|
|
2393
|
+
|
|
2394
|
+
|
|
2395
|
+
def unique_archive_path(archive_dir: Path, name: str) -> Path:
|
|
2396
|
+
candidate = archive_dir / name
|
|
2397
|
+
if not candidate.exists():
|
|
2398
|
+
return candidate
|
|
2399
|
+
timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S")
|
|
2400
|
+
return archive_dir / f"{name}-{timestamp}"
|
|
2401
|
+
|
|
2402
|
+
|
|
2403
|
+
def load_skill_metadata_timestamp(record: SkillRecord, key: str) -> str:
|
|
2404
|
+
metadata = load_companion_metadata(Path(record.path) / "skill.json")
|
|
2405
|
+
value = metadata.get(key)
|
|
2406
|
+
return str(value) if isinstance(value, str) else ""
|
|
2407
|
+
|
|
2408
|
+
|
|
2409
|
+
def parse_datetime(value: Any) -> datetime | None:
|
|
2410
|
+
if not isinstance(value, str) or not value:
|
|
2411
|
+
return None
|
|
2412
|
+
try:
|
|
2413
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
2414
|
+
except ValueError:
|
|
2415
|
+
return None
|
|
2416
|
+
if parsed.tzinfo is None:
|
|
2417
|
+
return parsed.replace(tzinfo=UTC)
|
|
2418
|
+
return parsed.astimezone(UTC)
|
|
2419
|
+
|
|
2420
|
+
|
|
2421
|
+
def filesystem_timestamp(path: Path) -> datetime | None:
|
|
2422
|
+
if not path.exists():
|
|
2423
|
+
return None
|
|
2424
|
+
return datetime.fromtimestamp(path.stat().st_mtime, UTC)
|
|
2425
|
+
|
|
2426
|
+
|
|
2427
|
+
def format_datetime(value: datetime) -> str:
|
|
2428
|
+
return value.replace(microsecond=0).isoformat()
|
|
2429
|
+
|
|
2430
|
+
|
|
2431
|
+
def write_registry(records: list[SkillRecord], registry_path: Path) -> None:
|
|
2432
|
+
registry_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2433
|
+
payload = {
|
|
2434
|
+
"generated_at": utc_now(),
|
|
2435
|
+
"skills": [asdict(record) for record in records],
|
|
2436
|
+
}
|
|
2437
|
+
registry_path.write_text(
|
|
2438
|
+
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
|
2439
|
+
encoding="utf-8",
|
|
2440
|
+
)
|
|
2441
|
+
|
|
2442
|
+
|
|
2443
|
+
def normalize_name(raw_name: str) -> str:
|
|
2444
|
+
lowered = raw_name.lower()
|
|
2445
|
+
normalized = re.sub(r"[^a-z0-9]+", "-", lowered)
|
|
2446
|
+
normalized = re.sub(r"-{2,}", "-", normalized).strip("-")
|
|
2447
|
+
return normalized[:63]
|
|
2448
|
+
|
|
2449
|
+
|
|
2450
|
+
def build_title(name: str) -> str:
|
|
2451
|
+
parts = [part for part in name.split("-") if part]
|
|
2452
|
+
rendered = [TITLE_CASE_OVERRIDES.get(part, part.capitalize()) for part in parts]
|
|
2453
|
+
return " ".join(rendered)
|
|
2454
|
+
|
|
2455
|
+
|
|
2456
|
+
def derive_name_from_task(task: str) -> str:
|
|
2457
|
+
preferred = [token for token in ordered_tokens(task) if token not in LOW_SIGNAL_TOKENS]
|
|
2458
|
+
if len(preferred) < 2:
|
|
2459
|
+
preferred = [token for token in ordered_tokens(task) if token not in STOPWORDS]
|
|
2460
|
+
normalized = normalize_name("-".join(preferred[:5]))
|
|
2461
|
+
if normalized:
|
|
2462
|
+
return normalized
|
|
2463
|
+
return "new-skill"
|
|
2464
|
+
|
|
2465
|
+
|
|
2466
|
+
def clean_text(value: str) -> str:
|
|
2467
|
+
return re.sub(r"\s+", " ", value or "").strip()
|
|
2468
|
+
|
|
2469
|
+
|
|
2470
|
+
def sentence_case(value: str) -> str:
|
|
2471
|
+
text = clean_text(value)
|
|
2472
|
+
if not text:
|
|
2473
|
+
return ""
|
|
2474
|
+
return text[0].upper() + text[1:]
|
|
2475
|
+
|
|
2476
|
+
|
|
2477
|
+
def ensure_sentence(value: str) -> str:
|
|
2478
|
+
text = clean_text(value)
|
|
2479
|
+
if not text:
|
|
2480
|
+
return ""
|
|
2481
|
+
if text[-1] not in ".!?":
|
|
2482
|
+
return f"{text}."
|
|
2483
|
+
return text
|
|
2484
|
+
|
|
2485
|
+
|
|
2486
|
+
def utc_now() -> str:
|
|
2487
|
+
return datetime.now(UTC).replace(microsecond=0).isoformat()
|
|
2488
|
+
|
|
2489
|
+
|
|
2490
|
+
def safe_relative_path(path: Path, base: Path) -> str:
|
|
2491
|
+
try:
|
|
2492
|
+
return str(path.relative_to(base))
|
|
2493
|
+
except ValueError:
|
|
2494
|
+
return str(path)
|
|
2495
|
+
|
|
2496
|
+
|
|
2497
|
+
if __name__ == "__main__":
|
|
2498
|
+
try:
|
|
2499
|
+
raise SystemExit(main())
|
|
2500
|
+
except BrokenPipeError:
|
|
2501
|
+
raise SystemExit(1)
|