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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "4.16.0",
3
+ "version": "4.17.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "4.16.0"
3
+ version = "4.17.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -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 build_skills_manifest(emitted: dict[str, list[str]]) -> dict:
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
- "skill_md_exists": 20, "frontmatter_present": 15, "required_fields": 15,
18
- "name_format": 10, "allowed_tools_list": 5, "has_h1": 10, "has_h2": 5,
19
- "line_count": 5, "agent_attribution": 10, "output_section": 5,
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 validate_skill(skill_dir: Path) -> SkillResult:
86
- """Validate a single skill directory against ArkaOS v2 standards."""
87
- parts = skill_dir.resolve().parts
88
- try:
89
- dept = parts[parts.index("departments") + 1]
90
- except (ValueError, IndexError):
91
- dept = "unknown"
92
- result = SkillResult(name=f"{dept}/{skill_dir.name}")
93
- skill_md = skill_dir / "SKILL.md"
94
-
95
- if not skill_md.is_file():
96
- result.deduct(WEIGHTS["skill_md_exists"], "missing SKILL.md")
97
- result.finalize()
98
- return result
99
-
100
- content = skill_md.read_text(encoding="utf-8")
101
- line_count = content.count("\n") + 1
102
-
103
- # Frontmatter checks
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", "allowed_tools_list"):
107
- result.deduct(WEIGHTS[key], f"no {key.replace('_', ' ')} (no frontmatter)")
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
- missing = [f for f in ("name", "description", "allowed-tools") if f not in fm]
110
- if missing:
111
- result.deduct(WEIGHTS["required_fields"], f"missing fields: {', '.join(missing)}")
112
- name_val = fm.get("name", "")
113
- if isinstance(name_val, str) and not re.match(r"^[a-z][\w-]*/[\w-]+$", name_val):
114
- result.deduct(WEIGHTS["name_format"], f"name '{name_val}' does not match dept/slug format")
115
- tools = fm.get("allowed-tools")
116
- if tools is not None and not isinstance(tools, list):
117
- result.deduct(WEIGHTS["allowed_tools_list"], "allowed-tools is not a list")
118
-
119
- # Content checks (strip frontmatter)
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(WEIGHTS["line_count"], f"line count {line_count} outside 30-200 range")
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(WEIGHTS["line_count"] // 2, f"line count {line_count} outside ideal 60-120 range")
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(WEIGHTS["agent_attribution"], "missing agent attribution (> **Agent:** line)")
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(f"Summary: {len(results)} skills validated, {passed} passed, {warnings} warnings, {failures} failures")
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 main() -> int:
178
- """Entry point. Returns exit code."""
228
+ def _build_parser() -> argparse.ArgumentParser:
179
229
  parser = argparse.ArgumentParser(
180
- description="ArkaOS v2 Skill Validator — validate SKILL.md files against project standards.",
181
- epilog="Exit codes: 0 = all passed, 1 = warnings only, 2 = failures found.",
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("path", type=Path, help="Skill directory or parent to scan recursively.")
184
- parser.add_argument("--json", action="store_true", dest="json_output", help="Output as JSON.")
185
- parser.add_argument("--summary", action="store_true", help="Print only summary totals.")
186
- args = parser.parse_args()
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
- skill_dirs = [target]
195
- elif target.is_dir():
196
- skill_dirs = discover_skills(target)
197
- else:
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
- print("No SKILL.md files found.", file=sys.stderr)
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]