arkaos 4.16.0 → 4.17.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/VERSION +1 -1
- package/config/skills-provenance.yaml +338 -0
- package/core/governance/harness_scanner.py +776 -0
- package/core/governance/harness_scanner_cli.py +131 -0
- package/core/skills/__init__.py +21 -0
- package/core/skills/provenance.py +195 -0
- package/installer/cli.js +24 -0
- package/installer/doctor.js +46 -0
- package/knowledge/skills-manifest.json +945 -237
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/marketplace_gen.py +57 -1
- package/scripts/skill_validator.py +133 -62
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -50,6 +50,12 @@ if str(REPO_ROOT / "scripts") not in sys.path:
|
|
|
50
50
|
|
|
51
51
|
from marketplace_export import _convert # noqa: E402
|
|
52
52
|
|
|
53
|
+
from core.skills.provenance import ( # noqa: E402
|
|
54
|
+
FIRST_PARTY,
|
|
55
|
+
SkillProvenance,
|
|
56
|
+
parse_provenance,
|
|
57
|
+
)
|
|
58
|
+
|
|
53
59
|
CURATED_YAML = REPO_ROOT / "config" / "skills-curated.yaml"
|
|
54
60
|
DEPARTMENTS_DIR = REPO_ROOT / "departments"
|
|
55
61
|
PLUGINS_DIR = REPO_ROOT / "plugins"
|
|
@@ -233,7 +239,50 @@ def build_marketplace(emitted: dict[str, list[str]]) -> dict:
|
|
|
233
239
|
return manifest
|
|
234
240
|
|
|
235
241
|
|
|
236
|
-
def
|
|
242
|
+
def skill_provenance(dept: str, slug: str) -> SkillProvenance:
|
|
243
|
+
"""Provenance from the skill's own frontmatter, path-tagged on error.
|
|
244
|
+
|
|
245
|
+
A bare pydantic traceback across 260 skills is a needle hunt on the
|
|
246
|
+
release-critical path (step 1b) — name the file that broke.
|
|
247
|
+
"""
|
|
248
|
+
skill_md = DEPARTMENTS_DIR / dept / "skills" / slug / "SKILL.md"
|
|
249
|
+
try:
|
|
250
|
+
return parse_provenance(skill_md.read_text(encoding="utf-8"))
|
|
251
|
+
except ValueError as exc:
|
|
252
|
+
raise ValueError(
|
|
253
|
+
f"{skill_md.relative_to(REPO_ROOT)}: bad provenance — {exc}"
|
|
254
|
+
) from exc
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _provenance_entry(prov: SkillProvenance) -> dict:
|
|
258
|
+
"""Manifest shape: the full licence trail, not just the origin."""
|
|
259
|
+
entry = {"origin": prov.origin}
|
|
260
|
+
if not prov.is_first_party:
|
|
261
|
+
entry["source"] = prov.source
|
|
262
|
+
entry["license"] = prov.license
|
|
263
|
+
return entry
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _merge_provenance(slug: str, entry: dict, prov: SkillProvenance) -> None:
|
|
267
|
+
"""One manifest row can front N department copies of a slug.
|
|
268
|
+
|
|
269
|
+
A collision where the copies disagree on lineage is unrepresentable
|
|
270
|
+
in one row — refuse loudly rather than pick a winner.
|
|
271
|
+
"""
|
|
272
|
+
incoming = _provenance_entry(prov)
|
|
273
|
+
current = entry["provenance"]
|
|
274
|
+
if current["origin"] == FIRST_PARTY:
|
|
275
|
+
entry["provenance"] = incoming
|
|
276
|
+
return
|
|
277
|
+
if incoming["origin"] != FIRST_PARTY and incoming != current:
|
|
278
|
+
raise ValueError(
|
|
279
|
+
f"slug {slug!r} collides across departments with conflicting "
|
|
280
|
+
f"provenance: {current} vs {incoming}"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _skill_rows(emitted: dict[str, list[str]]) -> dict[str, dict]:
|
|
285
|
+
"""slug -> {depts, curated, plugins, collision, provenance}."""
|
|
237
286
|
curated = load_curated()
|
|
238
287
|
subskills = dept_subskills()
|
|
239
288
|
collisions = collision_slugs(subskills)
|
|
@@ -243,8 +292,10 @@ def build_skills_manifest(emitted: dict[str, list[str]]) -> dict:
|
|
|
243
292
|
entry = skills.setdefault(slug, {
|
|
244
293
|
"depts": [], "curated": False, "plugins": [],
|
|
245
294
|
"collision": slug in collisions,
|
|
295
|
+
"provenance": {"origin": FIRST_PARTY},
|
|
246
296
|
})
|
|
247
297
|
entry["depts"].append(dept)
|
|
298
|
+
_merge_provenance(slug, entry, skill_provenance(dept, slug))
|
|
248
299
|
if slug in set(curated.get(dept, [])):
|
|
249
300
|
entry["curated"] = True
|
|
250
301
|
elif dept in emitted and slug in emitted[dept]:
|
|
@@ -252,6 +303,11 @@ def build_skills_manifest(emitted: dict[str, list[str]]) -> dict:
|
|
|
252
303
|
for entry in skills.values():
|
|
253
304
|
entry["depts"].sort()
|
|
254
305
|
entry["plugins"].sort()
|
|
306
|
+
return skills
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def build_skills_manifest(emitted: dict[str, list[str]]) -> dict:
|
|
310
|
+
skills = _skill_rows(emitted)
|
|
255
311
|
return {
|
|
256
312
|
"_meta": {
|
|
257
313
|
"generator": "scripts/marketplace_gen.py",
|
|
@@ -13,11 +13,21 @@ import sys
|
|
|
13
13
|
from dataclasses import dataclass, field
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
17
|
+
if str(REPO_ROOT) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(REPO_ROOT))
|
|
19
|
+
|
|
20
|
+
from core.skills.provenance import provenance_issues # noqa: E402
|
|
21
|
+
|
|
22
|
+
# Penalty budget: sums to exactly 100. A missing SKILL.md is fatal
|
|
23
|
+
# (score 0) rather than a weighted deduction — the old 20-point hit
|
|
24
|
+
# left a skill with no file at all scoring 80/GOOD.
|
|
16
25
|
WEIGHTS = {
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
26
|
+
"frontmatter_present": 15, "required_fields": 15, "name_format": 10,
|
|
27
|
+
"allowed_tools_list": 5, "has_h1": 10, "has_h2": 5, "line_count": 5,
|
|
28
|
+
"agent_attribution": 10, "output_section": 5, "provenance": 20,
|
|
20
29
|
}
|
|
30
|
+
assert sum(WEIGHTS.values()) == 100, "penalty budget must sum to 100"
|
|
21
31
|
|
|
22
32
|
|
|
23
33
|
@dataclass
|
|
@@ -82,58 +92,96 @@ def parse_frontmatter(content: str) -> dict[str, str | list[str]] | None:
|
|
|
82
92
|
return data
|
|
83
93
|
|
|
84
94
|
|
|
85
|
-
def
|
|
86
|
-
"""
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if not
|
|
96
|
-
|
|
97
|
-
result.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
95
|
+
def _check_fields(result: SkillResult, fm: dict) -> None:
|
|
96
|
+
"""Required fields, name format, tool list shape."""
|
|
97
|
+
missing = [
|
|
98
|
+
f for f in ("name", "description", "allowed-tools") if f not in fm
|
|
99
|
+
]
|
|
100
|
+
if missing:
|
|
101
|
+
result.deduct(
|
|
102
|
+
WEIGHTS["required_fields"], f"missing fields: {', '.join(missing)}"
|
|
103
|
+
)
|
|
104
|
+
name_val = fm.get("name", "")
|
|
105
|
+
if isinstance(name_val, str) and not re.match(
|
|
106
|
+
r"^[a-z][\w-]*/[\w-]+$", name_val):
|
|
107
|
+
result.deduct(
|
|
108
|
+
WEIGHTS["name_format"],
|
|
109
|
+
f"name '{name_val}' does not match dept/slug format",
|
|
110
|
+
)
|
|
111
|
+
tools = fm.get("allowed-tools")
|
|
112
|
+
if tools is not None and not isinstance(tools, list):
|
|
113
|
+
result.deduct(
|
|
114
|
+
WEIGHTS["allowed_tools_list"], "allowed-tools is not a list"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _check_frontmatter(result: SkillResult, content: str) -> None:
|
|
119
|
+
"""Frontmatter shape, required fields, and provenance."""
|
|
104
120
|
fm = parse_frontmatter(content)
|
|
105
121
|
if fm is None:
|
|
106
|
-
for key in ("frontmatter_present", "required_fields", "name_format",
|
|
107
|
-
|
|
122
|
+
for key in ("frontmatter_present", "required_fields", "name_format",
|
|
123
|
+
"allowed_tools_list"):
|
|
124
|
+
result.deduct(
|
|
125
|
+
WEIGHTS[key], f"no {key.replace('_', ' ')} (no frontmatter)"
|
|
126
|
+
)
|
|
108
127
|
else:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
128
|
+
_check_fields(result, fm)
|
|
129
|
+
# One deduction however many ways the block is wrong — a laundered
|
|
130
|
+
# origin is one defect, not a compounding tally.
|
|
131
|
+
issues = provenance_issues(content)
|
|
132
|
+
if issues:
|
|
133
|
+
result.deduct(
|
|
134
|
+
WEIGHTS["provenance"], f"provenance: {'; '.join(issues)}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _check_body(result: SkillResult, content: str) -> None:
|
|
139
|
+
"""Headings, length, agent attribution, output contract."""
|
|
120
140
|
body = re.sub(r"^---.*?---\s*", "", content, count=1, flags=re.DOTALL)
|
|
141
|
+
line_count = content.count("\n") + 1
|
|
121
142
|
if not re.search(r"^# ", body, re.MULTILINE):
|
|
122
143
|
result.deduct(WEIGHTS["has_h1"], "no H1 heading found")
|
|
123
144
|
if not re.search(r"^## ", body, re.MULTILINE):
|
|
124
145
|
result.deduct(WEIGHTS["has_h2"], "no H2 heading found")
|
|
125
|
-
|
|
126
|
-
# Line count: error if outside 30-200, soft warn if outside 60-120
|
|
146
|
+
# Error outside 30-200 lines, soft warn outside the ideal 60-120.
|
|
127
147
|
if line_count < 30 or line_count > 200:
|
|
128
|
-
result.deduct(
|
|
148
|
+
result.deduct(
|
|
149
|
+
WEIGHTS["line_count"],
|
|
150
|
+
f"line count {line_count} outside 30-200 range",
|
|
151
|
+
)
|
|
129
152
|
elif line_count < 60 or line_count > 120:
|
|
130
|
-
result.deduct(
|
|
131
|
-
|
|
153
|
+
result.deduct(
|
|
154
|
+
WEIGHTS["line_count"] // 2,
|
|
155
|
+
f"line count {line_count} outside ideal 60-120 range",
|
|
156
|
+
)
|
|
132
157
|
if not re.search(r"^>\s*\*\*Agent:", body, re.MULTILINE):
|
|
133
|
-
result.deduct(
|
|
158
|
+
result.deduct(
|
|
159
|
+
WEIGHTS["agent_attribution"],
|
|
160
|
+
"missing agent attribution (> **Agent:** line)",
|
|
161
|
+
)
|
|
134
162
|
if not re.search(r"^##\s+Output", body, re.MULTILINE):
|
|
135
163
|
result.deduct(WEIGHTS["output_section"], "missing ## Output section")
|
|
136
164
|
|
|
165
|
+
|
|
166
|
+
def _department_of(skill_dir: Path) -> str:
|
|
167
|
+
parts = skill_dir.resolve().parts
|
|
168
|
+
try:
|
|
169
|
+
return parts[parts.index("departments") + 1]
|
|
170
|
+
except (ValueError, IndexError):
|
|
171
|
+
return "unknown"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def validate_skill(skill_dir: Path) -> SkillResult:
|
|
175
|
+
"""Validate a single skill directory against ArkaOS v2 standards."""
|
|
176
|
+
result = SkillResult(name=f"{_department_of(skill_dir)}/{skill_dir.name}")
|
|
177
|
+
skill_md = skill_dir / "SKILL.md"
|
|
178
|
+
if not skill_md.is_file():
|
|
179
|
+
result.deduct(100, "missing SKILL.md")
|
|
180
|
+
result.finalize()
|
|
181
|
+
return result
|
|
182
|
+
content = skill_md.read_text(encoding="utf-8")
|
|
183
|
+
_check_frontmatter(result, content)
|
|
184
|
+
_check_body(result, content)
|
|
137
185
|
result.finalize()
|
|
138
186
|
return result
|
|
139
187
|
|
|
@@ -161,7 +209,10 @@ def print_text(results: list[SkillResult], summary_only: bool = False) -> None:
|
|
|
161
209
|
print(f"{icon} {r.name} \u2014 {r.score}/100 {r.level}{suffix}")
|
|
162
210
|
print()
|
|
163
211
|
passed, warnings, failures = _counts(results)
|
|
164
|
-
print(
|
|
212
|
+
print(
|
|
213
|
+
f"Summary: {len(results)} skills validated, {passed} passed, "
|
|
214
|
+
f"{warnings} warnings, {failures} failures"
|
|
215
|
+
)
|
|
165
216
|
|
|
166
217
|
|
|
167
218
|
def print_json(results: list[SkillResult]) -> None:
|
|
@@ -174,32 +225,52 @@ def print_json(results: list[SkillResult]) -> None:
|
|
|
174
225
|
print(json.dumps(output, indent=2))
|
|
175
226
|
|
|
176
227
|
|
|
177
|
-
def
|
|
178
|
-
"""Entry point. Returns exit code."""
|
|
228
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
179
229
|
parser = argparse.ArgumentParser(
|
|
180
|
-
description=
|
|
181
|
-
|
|
230
|
+
description=(
|
|
231
|
+
"ArkaOS v2 Skill Validator — validate SKILL.md files "
|
|
232
|
+
"against project standards."
|
|
233
|
+
),
|
|
234
|
+
epilog=(
|
|
235
|
+
"Exit codes: 0 = all passed, 1 = warnings only, "
|
|
236
|
+
"2 = failures found."
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
parser.add_argument(
|
|
240
|
+
"path", type=Path,
|
|
241
|
+
help="Skill directory or parent to scan recursively.",
|
|
242
|
+
)
|
|
243
|
+
parser.add_argument(
|
|
244
|
+
"--json", action="store_true", dest="json_output",
|
|
245
|
+
help="Output as JSON.",
|
|
182
246
|
)
|
|
183
|
-
parser.add_argument(
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
target: Path = args.path.resolve()
|
|
247
|
+
parser.add_argument(
|
|
248
|
+
"--summary", action="store_true", help="Print only summary totals.",
|
|
249
|
+
)
|
|
250
|
+
return parser
|
|
188
251
|
|
|
189
|
-
if not target.exists():
|
|
190
|
-
print(f"Error: path does not exist: {target}", file=sys.stderr)
|
|
191
|
-
return 2
|
|
192
252
|
|
|
253
|
+
def _resolve_targets(target: Path) -> list[Path]:
|
|
254
|
+
"""Skill dirs under `target`. Raises ValueError with the reason."""
|
|
255
|
+
if not target.exists():
|
|
256
|
+
raise ValueError(f"path does not exist: {target}")
|
|
193
257
|
if (target / "SKILL.md").is_file():
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
print(f"Error: {target} is not a directory", file=sys.stderr)
|
|
199
|
-
return 2
|
|
200
|
-
|
|
258
|
+
return [target]
|
|
259
|
+
if not target.is_dir():
|
|
260
|
+
raise ValueError(f"{target} is not a directory")
|
|
261
|
+
skill_dirs = discover_skills(target)
|
|
201
262
|
if not skill_dirs:
|
|
202
|
-
|
|
263
|
+
raise ValueError("no SKILL.md files found")
|
|
264
|
+
return skill_dirs
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def main() -> int:
|
|
268
|
+
"""Entry point. Returns exit code."""
|
|
269
|
+
args = _build_parser().parse_args()
|
|
270
|
+
try:
|
|
271
|
+
skill_dirs = _resolve_targets(args.path.resolve())
|
|
272
|
+
except ValueError as exc:
|
|
273
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
203
274
|
return 2
|
|
204
275
|
|
|
205
276
|
results = [validate_skill(d) for d in skill_dirs]
|