xrefkit 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. xrefkit/__init__.py +5 -0
  2. xrefkit/__main__.py +5 -0
  3. xrefkit/catalog_cli.py +57 -0
  4. xrefkit/cli.py +71 -0
  5. xrefkit/contracts.py +297 -0
  6. xrefkit/ctx.py +160 -0
  7. xrefkit/dashboard.py +1220 -0
  8. xrefkit/discovery.py +85 -0
  9. xrefkit/gate.py +428 -0
  10. xrefkit/goalstate.py +555 -0
  11. xrefkit/hashing.py +18 -0
  12. xrefkit/import_skill.py +469 -0
  13. xrefkit/instance.py +133 -0
  14. xrefkit/loaders.py +77 -0
  15. xrefkit/mcp/__init__.py +26 -0
  16. xrefkit/mcp/audit.py +168 -0
  17. xrefkit/mcp/bootstrap.py +337 -0
  18. xrefkit/mcp/catalog.py +2638 -0
  19. xrefkit/mcp/cli.py +173 -0
  20. xrefkit/mcp/client_cache.py +356 -0
  21. xrefkit/mcp/context_registry.py +277 -0
  22. xrefkit/mcp/contracts.py +243 -0
  23. xrefkit/mcp/dist.py +234 -0
  24. xrefkit/mcp/ownership.py +246 -0
  25. xrefkit/mcp/repository.py +217 -0
  26. xrefkit/mcp/schemas.py +349 -0
  27. xrefkit/mcp/server.py +773 -0
  28. xrefkit/mcp/startup_contract_pack.py +154 -0
  29. xrefkit/mcp_tools.py +47 -0
  30. xrefkit/models/__init__.py +41 -0
  31. xrefkit/models/common.py +185 -0
  32. xrefkit/models/effective_bundle.py +131 -0
  33. xrefkit/models/local_manifest.py +217 -0
  34. xrefkit/models/package_manifest.py +126 -0
  35. xrefkit/models/run_log.py +276 -0
  36. xrefkit/models/server_config.py +160 -0
  37. xrefkit/models/skill_definition.py +131 -0
  38. xrefkit/operations_cli.py +670 -0
  39. xrefkit/ownership.py +276 -0
  40. xrefkit/packmeta.py +289 -0
  41. xrefkit/registry.py +334 -0
  42. xrefkit/resolver.py +252 -0
  43. xrefkit/resource_provider.py +187 -0
  44. xrefkit/resources/base/contracts.json +178 -0
  45. xrefkit/resources/base/current.json +6 -0
  46. xrefkit/resources/base/generations/7a682a5272907354/contracts.json +178 -0
  47. xrefkit/resources/base/generations/7a682a5272907354/model_body.md +22 -0
  48. xrefkit/resources/base/generations/9929294385ccb7b0/contracts.json +178 -0
  49. xrefkit/resources/base/generations/9929294385ccb7b0/model_body.md +22 -0
  50. xrefkit/resources/base/model_body.md +22 -0
  51. xrefkit/runlog.py +45 -0
  52. xrefkit/skillmeta.py +1034 -0
  53. xrefkit/skillrun.py +2381 -0
  54. xrefkit/structure_catalog.py +199 -0
  55. xrefkit/tools/__init__.py +119 -0
  56. xrefkit/tools/__main__.py +4 -0
  57. xrefkit/v2_cli.py +130 -0
  58. xrefkit/workspace.py +117 -0
  59. xrefkit/xref.py +1048 -0
  60. xrefkit-0.3.0.dist-info/METADATA +203 -0
  61. xrefkit-0.3.0.dist-info/RECORD +65 -0
  62. xrefkit-0.3.0.dist-info/WHEEL +5 -0
  63. xrefkit-0.3.0.dist-info/entry_points.txt +2 -0
  64. xrefkit-0.3.0.dist-info/licenses/LICENSE +21 -0
  65. xrefkit-0.3.0.dist-info/top_level.txt +1 -0
xrefkit/skillrun.py ADDED
@@ -0,0 +1,2381 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import functools
5
+ import os
6
+ import re
7
+ import tempfile
8
+ import time
9
+ import uuid
10
+ from dataclasses import dataclass
11
+ from datetime import date
12
+ from pathlib import Path
13
+
14
+ if os.name == "nt":
15
+ import msvcrt
16
+ else: # pragma: no cover - exercised on POSIX only
17
+ import fcntl
18
+
19
+ from xrefkit.skillmeta import (
20
+ REQUIRED_OS_CONTRACT,
21
+ VALID_CAPABILITY_LAYERING_POLICIES,
22
+ VALID_WORKFLOW_PROTOCOL_POLICIES,
23
+ TRIAL_DEFAULT_EXECUTION_MODE,
24
+ TRIAL_DEFAULT_GUARD_POLICY,
25
+ _parse_key_value_list,
26
+ _parse_meta_lines,
27
+ _resolve_maturity,
28
+ resolve_os_contract,
29
+ validate_skill_meta,
30
+ )
31
+
32
+
33
+ @dataclass
34
+ class SkillRunResult:
35
+ ok: bool
36
+ skill_id: str | None
37
+ skill_doc: str | None
38
+ run_log: str | None
39
+ errors: list[str]
40
+ assigned_roles: dict[str, str] | None = None
41
+ work_items: list[dict[str, str]] | None = None
42
+ artifacts: list[dict[str, str]] | None = None
43
+ concerns: list[dict[str, str]] | None = None
44
+ closure_checks: dict[str, str] | None = None
45
+ handoff_sources: list[dict[str, str]] | None = None
46
+ domain_knowledge: dict[str, object] | None = None
47
+ run_id: str | None = None
48
+
49
+ def to_dict(self) -> dict[str, object]:
50
+ return {
51
+ "ok": self.ok,
52
+ "skill_id": self.skill_id,
53
+ "skill_doc": self.skill_doc,
54
+ "run_log": self.run_log,
55
+ "errors": self.errors,
56
+ "assigned_roles": self.assigned_roles or {},
57
+ "work_items": self.work_items or [],
58
+ "artifacts": self.artifacts or [],
59
+ "concerns": self.concerns or [],
60
+ "closure_checks": self.closure_checks or {},
61
+ "handoff_sources": self.handoff_sources or [],
62
+ "domain_knowledge": self.domain_knowledge or {},
63
+ "run_id": self.run_id,
64
+ }
65
+
66
+
67
+ class _LogFileLock:
68
+ def __init__(self, path: Path, timeout_seconds: float = 5.0) -> None:
69
+ self.path = path
70
+ self.timeout_seconds = timeout_seconds
71
+ self.handle = None
72
+
73
+ def __enter__(self) -> "_LogFileLock":
74
+ self.path.parent.mkdir(parents=True, exist_ok=True)
75
+ self.handle = self.path.open("a+b")
76
+ if self.handle.tell() == 0:
77
+ self.handle.write(b"0")
78
+ self.handle.flush()
79
+ deadline = time.monotonic() + self.timeout_seconds
80
+ while True:
81
+ try:
82
+ self.handle.seek(0)
83
+ if os.name == "nt":
84
+ msvcrt.locking(self.handle.fileno(), msvcrt.LK_NBLCK, 1)
85
+ else: # pragma: no cover - exercised on POSIX only
86
+ fcntl.flock(self.handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
87
+ return self
88
+ except (OSError, BlockingIOError):
89
+ if time.monotonic() >= deadline:
90
+ self.handle.close()
91
+ self.handle = None
92
+ raise TimeoutError(f"timed out acquiring Skill log lock: {self.path}")
93
+ time.sleep(0.02)
94
+
95
+ def __exit__(self, exc_type, exc, tb) -> None:
96
+ if self.handle is None:
97
+ return
98
+ try:
99
+ self.handle.seek(0)
100
+ if os.name == "nt":
101
+ msvcrt.locking(self.handle.fileno(), msvcrt.LK_UNLCK, 1)
102
+ else: # pragma: no cover - exercised on POSIX only
103
+ fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
104
+ finally:
105
+ self.handle.close()
106
+ self.handle = None
107
+
108
+
109
+ def _atomic_write_text(path: Path, text: str) -> None:
110
+ path.parent.mkdir(parents=True, exist_ok=True)
111
+ fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
112
+ try:
113
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
114
+ handle.write(text)
115
+ handle.flush()
116
+ os.fsync(handle.fileno())
117
+ os.replace(temporary, path)
118
+ except BaseException:
119
+ try:
120
+ os.unlink(temporary)
121
+ except FileNotFoundError:
122
+ pass
123
+ raise
124
+
125
+
126
+ def _locked_log_update(func):
127
+ @functools.wraps(func)
128
+ def wrapper(args, *extra, **kwargs):
129
+ log_path = Path(args.log).resolve()
130
+ with _LogFileLock(log_path.with_name(f".{log_path.name}.lock")):
131
+ return func(args, *extra, **kwargs)
132
+
133
+ return wrapper
134
+
135
+
136
+ VALID_PHASES = {"startup", "planning", "execution", "check", "quality", "closure", "handoff"}
137
+ VALID_PHASE_STATUSES = {"pending", "in_progress", "done", "blocked", "unknown", "escalated"}
138
+ VALID_WORKITEM_STATUSES = {"pending", "in_progress", "done", "blocked", "unknown", "escalated"}
139
+ VALID_ARTIFACT_STATUSES = {"pending", "in_progress", "done", "blocked", "unknown", "escalated"}
140
+ VALID_ARTIFACT_KINDS = {"output", "evidence", "check", "judgment", "source", "handoff"}
141
+ VALID_CONCERN_KINDS = {"unknown", "risk", "judgment"}
142
+ VALID_CONCERN_STATUSES = {"open", "resolved", "escalated"}
143
+ VALID_JUDGMENT_TYPES = {"trivial", "non_trivial"}
144
+ # model_tier values that make the quality gate mandatory at closure.
145
+ QUALITY_REQUIRED_TIERS = {"standard", "heavy"}
146
+ PHASE_LABELS = {
147
+ "startup": "Startup",
148
+ "planning": "Planning",
149
+ "execution": "Execution",
150
+ "check": "Check",
151
+ "quality": "Quality",
152
+ "closure": "Closure",
153
+ "handoff": "Handoff",
154
+ }
155
+ PHASE_SECTIONS = {
156
+ "execution": "Execution Role",
157
+ "check": "Check Role",
158
+ "quality": "Quality Gate",
159
+ "closure": "Closure Gate",
160
+ "handoff": "Handoff",
161
+ }
162
+ REQUIRED_CLOSE_SECTIONS = ("Execution Role", "Check Role", "Handoff")
163
+ ACCEPTED_CLOSE_STATUSES = {"done", "escalated"}
164
+ PHASE_REQUIRED_ROLES = {
165
+ "execution": "executor",
166
+ "check": "checker",
167
+ "quality": "quality_reviewer",
168
+ "handoff": "handoff_owner",
169
+ }
170
+ CLIENT_HIDDEN_DOMAIN_CATALOG_KEYS = {"path", "file", "content_path", "local_path", "source_path"}
171
+
172
+
173
+ WORKLIST_ROWS = [
174
+ ("Startup", "Confirm task, scope, active Skill, inputs, and loaded-context boundary."),
175
+ ("Planning", "Create concrete work items, assumptions, target outputs, and handoff boundary."),
176
+ ("Execution", "Execute the Skill procedure inside the declared capability and flow boundary."),
177
+ ("Check", "Run the separate check role against evidence, output quality, unknowns, and handoff readiness."),
178
+ ("Closure", "Apply the closure gate and keep pass, fail, unknown, and escalation states explicit."),
179
+ ("Handoff", "Record outputs, unresolved items, next owner, and human decision points."),
180
+ ]
181
+ WORKITEM_RE = re.compile(
182
+ r"^- \[(?P<checkbox>[ x!])\] (?P<item_id>[A-Za-z0-9_.-]+) "
183
+ r"status=`(?P<status>[^`]+)` role=`(?P<role>[^`]+)`: (?P<text>.*)$"
184
+ )
185
+ ARTIFACT_RE = re.compile(
186
+ r"^- \[(?P<checkbox>[ x!])\] (?P<artifact_id>[A-Za-z0-9_.-]+) "
187
+ r"kind=`(?P<kind>[^`]+)` status=`(?P<status>[^`]+)` "
188
+ r"role=`(?P<role>[^`]+)` target=`(?P<target>[^`]+)` item=`(?P<item_id>[^`]+)`: (?P<note>.*)$"
189
+ )
190
+ CONCERN_RE = re.compile(
191
+ r"^- \[(?P<checkbox>[ x!])\] (?P<concern_id>[A-Za-z0-9_.-]+) "
192
+ r"kind=`(?P<kind>[^`]+)` status=`(?P<status>[^`]+)` "
193
+ r"judgment=`(?P<judgment>[^`]+)` role=`(?P<role>[^`]+)` "
194
+ r"target=`(?P<target>[^`]+)`: (?P<text>.*)$"
195
+ )
196
+
197
+
198
+ def _safe_slug(value: str) -> str:
199
+ chars = [ch.lower() if ch.isalnum() else "_" for ch in value]
200
+ slug = "".join(chars).strip("_")
201
+ while "__" in slug:
202
+ slug = slug.replace("__", "_")
203
+ return slug or "skill"
204
+
205
+
206
+ def _read_task(args) -> tuple[str | None, list[str]]:
207
+ if args.task and args.task_file:
208
+ return None, ["use either --task or --task-file, not both"]
209
+ if args.task_file:
210
+ path = Path(args.task_file)
211
+ if not path.exists():
212
+ return None, [f"task file not found: {path}"]
213
+ return path.read_text(encoding="utf-8").strip(), []
214
+ if args.task:
215
+ return str(args.task).strip(), []
216
+ return None, ["missing --task or --task-file"]
217
+
218
+
219
+ def _parse_semicolon_fields(value: str) -> dict[str, str]:
220
+ fields: dict[str, str] = {}
221
+ for raw_field in value.split(";"):
222
+ field = raw_field.strip()
223
+ if not field:
224
+ continue
225
+ if "=" in field:
226
+ key, raw_value = field.split("=", 1)
227
+ fields[key.strip()] = raw_value.strip().strip("`")
228
+ else:
229
+ fields[field] = "true"
230
+ if "name" in fields and "slot" not in fields:
231
+ fields["slot"] = fields["name"]
232
+ return fields
233
+
234
+
235
+ def _parse_bool(value: object) -> bool:
236
+ if isinstance(value, bool):
237
+ return value
238
+ return str(value or "").strip().lower() in {"1", "true", "yes", "required"}
239
+
240
+
241
+ def _parse_knowledge_input_requirements(parsed: dict[str, object]) -> list[dict[str, object]]:
242
+ value = parsed.get("knowledge_inputs")
243
+ if not isinstance(value, list):
244
+ return []
245
+ requirements: list[dict[str, object]] = []
246
+ for item in value:
247
+ if isinstance(item, str):
248
+ fields = _parse_semicolon_fields(item)
249
+ name = fields.get("slot") or fields.get("name")
250
+ if not name:
251
+ continue
252
+ requirements.append(
253
+ {
254
+ "name": name,
255
+ "required": _parse_bool(fields.get("required")),
256
+ "accepts": [
257
+ part.strip()
258
+ for part in str(fields.get("accepts") or "").split(",")
259
+ if part.strip()
260
+ ],
261
+ "purpose": fields.get("purpose") or "",
262
+ }
263
+ )
264
+ return requirements
265
+
266
+
267
+ def _load_domain_knowledge_catalog(path_value: str | None) -> tuple[list[dict[str, object]], list[str]]:
268
+ if not path_value:
269
+ return [], []
270
+ path = Path(path_value)
271
+ if not path.exists():
272
+ return [], [f"domain knowledge catalog not found: {path}"]
273
+ try:
274
+ payload = json.loads(path.read_text(encoding="utf-8"))
275
+ except json.JSONDecodeError as exc:
276
+ return [], [f"domain knowledge catalog is not valid JSON: {exc}"]
277
+ raw_entries = payload.get("entries") if isinstance(payload, dict) else payload
278
+ if not isinstance(raw_entries, list):
279
+ return [], ["domain knowledge catalog must be a JSON object with entries[] or a JSON array"]
280
+
281
+ entries: list[dict[str, object]] = []
282
+ errors: list[str] = []
283
+ seen: set[str] = set()
284
+ for index, raw_entry in enumerate(raw_entries, start=1):
285
+ if not isinstance(raw_entry, dict):
286
+ errors.append(f"domain knowledge entry {index} must be an object")
287
+ continue
288
+ xid = str(raw_entry.get("xid") or "").strip()
289
+ if not xid:
290
+ errors.append(f"domain knowledge entry {index} is missing xid")
291
+ continue
292
+ if xid in seen:
293
+ errors.append(f"duplicate domain knowledge xid in catalog: {xid}")
294
+ continue
295
+ hidden_keys = sorted(CLIENT_HIDDEN_DOMAIN_CATALOG_KEYS.intersection(raw_entry))
296
+ if hidden_keys:
297
+ errors.append(
298
+ f"domain knowledge entry {xid} exposes local-path-like keys: {', '.join(hidden_keys)}"
299
+ )
300
+ continue
301
+ seen.add(xid)
302
+ entries.append(
303
+ {
304
+ "xid": xid,
305
+ "kind": str(raw_entry.get("kind") or "").strip() or "unspecified",
306
+ "title": str(raw_entry.get("title") or "").strip() or xid,
307
+ "summary": str(raw_entry.get("summary") or "").strip(),
308
+ "domain": str(raw_entry.get("domain") or "").strip(),
309
+ "tags": raw_entry.get("tags") if isinstance(raw_entry.get("tags"), list) else [],
310
+ "content_hash": str(raw_entry.get("content_hash") or "").strip(),
311
+ "version": str(raw_entry.get("version") or "").strip(),
312
+ "last_verified": str(raw_entry.get("last_verified") or "").strip(),
313
+ "validity_conditions": str(raw_entry.get("validity_conditions") or "").strip(),
314
+ }
315
+ )
316
+ return entries, errors
317
+
318
+
319
+ def _parse_selected_knowledge_inputs(values: list[str]) -> tuple[dict[str, list[str]], list[str]]:
320
+ selected: dict[str, list[str]] = {}
321
+ errors: list[str] = []
322
+ for value in values:
323
+ if "=" not in value:
324
+ errors.append(f"knowledge input must be name=XID[,XID]: {value}")
325
+ continue
326
+ name, raw_xids = value.split("=", 1)
327
+ name = name.strip()
328
+ xids = [part.strip() for part in raw_xids.split(",") if part.strip()]
329
+ if not name:
330
+ errors.append(f"knowledge input name is empty: {value}")
331
+ continue
332
+ if not xids:
333
+ errors.append(f"knowledge input has no XIDs: {value}")
334
+ continue
335
+ selected.setdefault(name, []).extend(xids)
336
+ return selected, errors
337
+
338
+
339
+ def _prepare_domain_knowledge_context(
340
+ *,
341
+ parsed_meta: dict[str, object],
342
+ catalog_path: str | None,
343
+ selected_values: list[str],
344
+ ) -> tuple[dict[str, object], list[str]]:
345
+ entries, catalog_errors = _load_domain_knowledge_catalog(catalog_path)
346
+ selected, selected_errors = _parse_selected_knowledge_inputs(selected_values)
347
+ errors = catalog_errors + selected_errors
348
+ if selected_values and not catalog_path:
349
+ errors.append("--knowledge-input requires --domain-knowledge-catalog")
350
+
351
+ available_xids = {str(entry["xid"]) for entry in entries}
352
+ for input_name, xids in selected.items():
353
+ for xid in xids:
354
+ if xid not in available_xids:
355
+ errors.append(f"knowledge input {input_name} references XID not in catalog: {xid}")
356
+
357
+ requirements = _parse_knowledge_input_requirements(parsed_meta)
358
+ for requirement in requirements:
359
+ name = str(requirement["name"])
360
+ if requirement.get("required") and not selected.get(name):
361
+ errors.append(f"required knowledge input is not selected: {name}")
362
+
363
+ return {
364
+ "available": entries,
365
+ "selected": selected,
366
+ "requirements": requirements,
367
+ }, errors
368
+
369
+
370
+ def _default_log_path(root: Path, skill_id: str) -> Path:
371
+ base = root / "work" / "sessions"
372
+ filename = f"{date.today().isoformat()}_skill_run_{_safe_slug(skill_id)}.md"
373
+ candidate = base / filename
374
+ if not candidate.exists():
375
+ return candidate
376
+
377
+ stem = candidate.stem
378
+ suffix = candidate.suffix
379
+ index = 2
380
+ while True:
381
+ numbered = base / f"{stem}_{index}{suffix}"
382
+ if not numbered.exists():
383
+ return numbered
384
+ index += 1
385
+
386
+
387
+ def _render_log(
388
+ *,
389
+ run_id: str,
390
+ skill_id: str,
391
+ maturity: str,
392
+ meta_path: Path,
393
+ skill_doc: Path,
394
+ execution_mode: str,
395
+ guard_policy: str,
396
+ capability_layering: str,
397
+ workflow_protocol: str,
398
+ capability: str,
399
+ tuning: str,
400
+ role_responsibilities: dict[str, str],
401
+ capability_refs: list[str],
402
+ assigned_roles: dict[str, str],
403
+ task: str,
404
+ os_contract: dict[str, str],
405
+ handoff_sources: list[dict[str, str]],
406
+ model_tier: str | None,
407
+ domain_knowledge: dict[str, object],
408
+ ) -> str:
409
+ tier_label = model_tier or "unset"
410
+ quality_policy = "required" if model_tier in QUALITY_REQUIRED_TIERS else "optional"
411
+ contract_lines = "\n".join(f"- {key}: `{value}`" for key, value in os_contract.items())
412
+ capability_ref_lines = "\n".join(
413
+ f"- `{ref}`" for ref in capability_refs
414
+ ) or "- none declared"
415
+ protocol_role_responsibilities = {
416
+ "quality_reviewer": "protocol-owned output-content acceptance when the quality gate is required",
417
+ "handoff_owner": "protocol-owned explicit handoff progression",
418
+ }
419
+ role_responsibility_lines = "\n".join(
420
+ f"- {role}: `{role_responsibilities.get(role) or protocol_role_responsibilities.get(role, 'not declared')}`"
421
+ for role in ("executor", "quality_reviewer", "handoff_owner")
422
+ )
423
+ worklist_lines = "\n".join(
424
+ f"- [ ] {name}: {description}" for name, description in WORKLIST_ROWS
425
+ )
426
+ handoff_source_lines = "\n".join(
427
+ f"- source_log: `{source['source_log']}` skill_id=`{source['skill_id']}` closure=`{source['closure']}` handoff=`{source['handoff']}`"
428
+ for source in handoff_sources
429
+ ) or "- none"
430
+ available_domain_entries = domain_knowledge.get("available", [])
431
+ if isinstance(available_domain_entries, list) and available_domain_entries:
432
+ available_domain_lines = "\n".join(
433
+ "\n".join(
434
+ [
435
+ f"- xid: `{entry.get('xid')}`",
436
+ f" kind: `{entry.get('kind')}`",
437
+ f" domain: `{entry.get('domain') or '-'}`",
438
+ f" title: `{entry.get('title')}`",
439
+ f" summary: {entry.get('summary') or '-'}",
440
+ f" content_hash: `{entry.get('content_hash') or entry.get('version') or 'unknown'}`",
441
+ f" last_verified: `{entry.get('last_verified') or 'unknown'}`",
442
+ f" validity_conditions: {entry.get('validity_conditions') or 'unknown'}",
443
+ ]
444
+ )
445
+ for entry in available_domain_entries
446
+ if isinstance(entry, dict)
447
+ )
448
+ else:
449
+ available_domain_lines = "- none supplied"
450
+ selected_domain_inputs = domain_knowledge.get("selected", {})
451
+ if isinstance(selected_domain_inputs, dict) and selected_domain_inputs:
452
+ selected_domain_lines = "\n".join(
453
+ "\n".join([f"- {name}:"] + [f" - `{xid}`" for xid in xids])
454
+ for name, xids in selected_domain_inputs.items()
455
+ if isinstance(xids, list)
456
+ )
457
+ else:
458
+ selected_domain_lines = "- none selected"
459
+ domain_requirements = domain_knowledge.get("requirements", [])
460
+ if isinstance(domain_requirements, list) and domain_requirements:
461
+ requirement_lines = "\n".join(
462
+ "\n".join(
463
+ [
464
+ f"- name: `{requirement.get('name')}`",
465
+ f" required: `{str(requirement.get('required')).lower()}`",
466
+ f" accepts: `{', '.join(requirement.get('accepts') or []) if isinstance(requirement.get('accepts'), list) else '-'}`",
467
+ f" purpose: `{requirement.get('purpose') or '-'}`",
468
+ ]
469
+ )
470
+ for requirement in domain_requirements
471
+ if isinstance(requirement, dict)
472
+ )
473
+ else:
474
+ requirement_lines = "- none declared"
475
+ return f"""# Skill Run Log
476
+
477
+ - run_id: `{run_id}`
478
+ - mcp_session_id: `-`
479
+ - repository_fingerprint: `-`
480
+ - date: `{date.today().isoformat()}`
481
+ - skill_id: `{skill_id}`
482
+ - maturity: `{maturity}`
483
+ - meta: `{meta_path.as_posix()}`
484
+ - skill_doc: `{skill_doc.as_posix()}`
485
+ - task: {task}
486
+
487
+ ## Skill Load Gate
488
+
489
+ - status: `opened_by_xrefkit_skill_run`
490
+ - rule: do not open or execute the Skill procedure until this runtime envelope exists
491
+
492
+ ## Runtime Role Assignment
493
+
494
+ - guard_policy: `{guard_policy}`
495
+ - capability_layering: `{capability_layering}`
496
+ - workflow_protocol: `{workflow_protocol}`
497
+ - capability: `{capability}`
498
+ - tuning: `{tuning}`
499
+ - execution_mode: `{execution_mode}`
500
+ - model_tier: `{tier_label}`
501
+ - executor: `{assigned_roles["executor"]}`
502
+ - checker: `{assigned_roles["checker"]}`
503
+ - quality_reviewer: `{assigned_roles["quality_reviewer"]}`
504
+ - handoff_owner: `{assigned_roles["handoff_owner"]}`
505
+ - separation_rule: `execution, check, and quality must be advanced by different runtime roles from the executor`
506
+ - executor_context: `{assigned_roles["executor_context"]}`
507
+ - checker_context: `{assigned_roles["checker_context"]}`
508
+ - quality_reviewer_context: `{assigned_roles["quality_reviewer_context"]}`
509
+
510
+ ## Role Responsibilities
511
+
512
+ {role_responsibility_lines}
513
+
514
+ ## Workflow Protocol
515
+
516
+ - workflow_protocol: `{workflow_protocol}`
517
+ - checker: `protocol-owned deterministic workflow-progression verification via xrefkit skill verify`
518
+ - rule: checker responsibility is assigned by the runtime workflow protocol, not repeated in Skill meta
519
+
520
+ ## OS Contract
521
+
522
+ {contract_lines}
523
+
524
+ ## Capability Layering
525
+
526
+ - capability_layering: `{capability_layering}`
527
+ - capability: `{capability}`
528
+ - tuning: `{tuning}`
529
+ - rule: execute the Skill inside the declared capability / tuning / responsibility boundary; capability definitions are control definitions, not evidence
530
+ - capability_refs:
531
+ {capability_ref_lines}
532
+
533
+ ## Startup Inputs
534
+
535
+ - rule: when work starts from a prior handoff, the receiving startup must name the handoff source log and verify that its closure gate already passed
536
+ {handoff_source_lines}
537
+
538
+ ## Domain Knowledge Inputs
539
+
540
+ - rule: available and selected brownfield domain knowledge is recorded by XID only; load full bodies through XID resolution, not local paths
541
+ - requirements:
542
+ {requirement_lines}
543
+
544
+ ### Available Domain Knowledge
545
+
546
+ {available_domain_lines}
547
+
548
+ ### Selected Knowledge Inputs
549
+
550
+ {selected_domain_lines}
551
+
552
+ ### Used Knowledge Refs
553
+
554
+ - rule: record actually consulted domain knowledge XIDs as runtime artifacts or evidence before handoff
555
+ - none recorded yet
556
+
557
+ ## MCP Correlation
558
+
559
+ - status: `pending`
560
+ - rule: bind this Skill Run to one MCP session with `xrefkit skill correlate` after `bind_skill_run` returns
561
+
562
+ ## Skill Routing Trace
563
+
564
+ - status: `partial`
565
+ - event: {json.dumps({"event": "skill.selected", "selected_skill": skill_id, "selection_mode": "direct_meta", "candidate_source": "selected_only", "candidates": [skill_id], "reason": "selected meta supplied by caller after semantic routing"}, ensure_ascii=False, separators=(",", ":"))}
566
+
567
+ ## Knowledge Search Trace
568
+
569
+ - status: `pending`
570
+ - rule: record search queries, hits, misses, and fallback decisions with `xrefkit skill knowledge --action search`
571
+
572
+ ## Loaded Knowledge Inputs
573
+
574
+ - status: `pending`
575
+ - rule: record each XID body actually loaded into model context with `xrefkit skill knowledge --action load`
576
+
577
+ ## Knowledge Application Trace
578
+
579
+ - status: `pending`
580
+ - rule: link each applied XID to a judgment or artifact with `xrefkit skill knowledge --action apply`
581
+
582
+ ## Human Feedback
583
+
584
+ - status: `pending`
585
+ - rule: record human acceptance, correction, or rejection with `xrefkit skill feedback --kind human`
586
+
587
+ ## Outcome Feedback
588
+
589
+ - status: `pending`
590
+ - rule: record downstream outcome evidence with `xrefkit skill feedback --kind outcome`
591
+
592
+ ## Worklist
593
+
594
+ {worklist_lines}
595
+
596
+ ## Concrete Work Items
597
+
598
+ - status: `pending`
599
+ - rule: task-specific work items must be added with `xrefkit skill workitem` and closed as `done` or `escalated`
600
+
601
+ ## Runtime Artifacts
602
+
603
+ - status: `pending`
604
+ - rule: outputs, evidence, checks, judgments, sources, and handoff links must be added with `xrefkit skill artifact`
605
+
606
+ ## Execution Role
607
+
608
+ - status: `pending`
609
+ - responsibility: perform the Skill procedure inside the declared flow, capability, and guard boundary
610
+
611
+ ## Check Role
612
+
613
+ - status: `pending`
614
+ - responsibility: deterministically verify workflow-progression records (worklist, work items, artifact recording and linkage, concerns, role separation) with `xrefkit skill verify`; output quality is the quality gate's responsibility, not this one
615
+
616
+ ## Quality Gate
617
+
618
+ - status: `pending`
619
+ - model_tier: `{tier_label}`
620
+ - policy: `{quality_policy}`
621
+ - rule: declare acceptance check items as `check`-kind artifacts at planning; an independent quality reviewer sets each to `done` (pass) or `blocked` (fail) with `xrefkit skill artifact`; domain reviews run as separate review Skills orchestrated by the main session and linked here. Required when model_tier is `standard` or `heavy`; optional otherwise
622
+
623
+ ## Unknowns And Risks
624
+
625
+ - status: `pending`
626
+ - rule: unknowns, missing evidence, risks, and unsupported assumptions must remain explicit and must be resolved, escalated, or linked before closure
627
+
628
+ ## Closure Gate
629
+
630
+ - status: `pending`
631
+ - rule: close only after execution, check, log, unknown/risk, and handoff rows are complete or explicitly escalated
632
+
633
+ ## Handoff
634
+
635
+ - status: `pending`
636
+ - rule: record outputs, unresolved items, next owner, and human decision points
637
+
638
+ ## Token Usage
639
+
640
+ - status: `pending`
641
+ - input: `-`
642
+ - output: `-`
643
+ - total: `-`
644
+ - rule: record tokens consumed by this skill run with `xrefkit skill tokens` (informational; does not gate closure)
645
+ """
646
+
647
+
648
+ def _replace_line(text: str, old: str, new: str) -> tuple[str, bool]:
649
+ if old not in text:
650
+ return text, False
651
+ return text.replace(old, new, 1), True
652
+
653
+
654
+ def _append_phase_event(text: str, *, phase: str, status: str, role: str | None, note: str | None) -> str:
655
+ if "## Phase Events\n" not in text:
656
+ text = text.rstrip() + "\n\n## Phase Events\n\n"
657
+ note_text = note or ""
658
+ event = f"- {date.today().isoformat()} `{phase}` -> `{status}`"
659
+ if role:
660
+ event += f" role=`{role}`"
661
+ if note_text:
662
+ event += f": {note_text}"
663
+ return text.rstrip() + "\n" + event + "\n"
664
+
665
+
666
+ def _section_status(text: str, section: str) -> str | None:
667
+ marker = f"## {section}\n"
668
+ start = text.find(marker)
669
+ if start == -1:
670
+ return None
671
+
672
+ next_section = text.find("\n## ", start + len(marker))
673
+ body = text[start:] if next_section == -1 else text[start:next_section]
674
+ prefix = "- status: `"
675
+ status_start = body.find(prefix)
676
+ if status_start == -1:
677
+ return None
678
+ status_start += len(prefix)
679
+ status_end = body.find("`", status_start)
680
+ if status_end == -1:
681
+ return None
682
+ return body[status_start:status_end]
683
+
684
+
685
+ def _section_body(text: str, section: str) -> tuple[str | None, int, int]:
686
+ marker = f"## {section}\n"
687
+ start = text.find(marker)
688
+ if start == -1:
689
+ return None, -1, -1
690
+ next_section = text.find("\n## ", start + len(marker))
691
+ end = len(text) if next_section == -1 else next_section
692
+ return text[start:end], start, end
693
+
694
+
695
+ def _log_field(text: str, name: str) -> str | None:
696
+ match = re.search(rf"^- {re.escape(name)}: `([^`]*)`", text, re.MULTILINE)
697
+ if match is None:
698
+ return None
699
+ value = match.group(1).strip()
700
+ return None if value in {"", "-"} else value
701
+
702
+
703
+ def _append_observation_event(text: str, *, section: str, event: dict[str, object]) -> str:
704
+ body, start, end = _section_body(text, section)
705
+ if body is None:
706
+ body = f"## {section}\n\n- status: `pending`\n"
707
+ start = len(text.rstrip()) + 2
708
+ end = start
709
+ text = text.rstrip() + "\n\n" + body
710
+ body, start, end = _section_body(text, section)
711
+ assert body is not None
712
+ serialized = json.dumps(event, ensure_ascii=False, separators=(",", ":"))
713
+ updated = re.sub(r"^- status: `[^`]+`", "- status: `recorded`", body, count=1, flags=re.MULTILINE)
714
+ updated = updated.rstrip() + f"\n- event: {serialized}\n"
715
+ return text[:start] + updated + text[end:]
716
+
717
+
718
+ def _observation_events(text: str) -> list[dict[str, object]]:
719
+ events: list[dict[str, object]] = []
720
+ for match in re.finditer(r"^- event: (?P<event>\{.*\})$", text, re.MULTILINE):
721
+ try:
722
+ event = json.loads(match.group("event"))
723
+ except json.JSONDecodeError:
724
+ continue
725
+ if isinstance(event, dict) and isinstance(event.get("event"), str):
726
+ events.append(event)
727
+ return events
728
+
729
+
730
+ def _replace_log_field(text: str, name: str, value: str) -> tuple[str, bool]:
731
+ pattern = re.compile(rf"^- {re.escape(name)}: `[^`]*`", re.MULTILINE)
732
+ return pattern.subn(f"- {name}: `{value}`", text, count=1)[0], bool(pattern.search(text))
733
+
734
+
735
+ def _valid_log_token(value: str) -> bool:
736
+ return bool(re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", value))
737
+
738
+
739
+ def _log_skill_id(text: str) -> str | None:
740
+ prefix = "- skill_id: `"
741
+ start = text.find(prefix)
742
+ if start == -1:
743
+ return None
744
+ start += len(prefix)
745
+ end = text.find("`", start)
746
+ if end == -1:
747
+ return None
748
+ return text[start:end]
749
+
750
+
751
+ def _log_model_tier(text: str) -> str | None:
752
+ prefix = "- model_tier: `"
753
+ start = text.find(prefix)
754
+ if start == -1:
755
+ return None
756
+ start += len(prefix)
757
+ end = text.find("`", start)
758
+ if end == -1:
759
+ return None
760
+ value = text[start:end]
761
+ return None if value in {"", "unset"} else value
762
+
763
+
764
+ def _set_phase_status(text: str, *, phase: str, status: str) -> str:
765
+ label = PHASE_LABELS[phase]
766
+ checkbox = "x" if status == "done" else "!"
767
+ new_worklist = f"- [{checkbox}] {label}:"
768
+ for marker in (" ", "x", "!"):
769
+ text, changed = _replace_line(text, f"- [{marker}] {label}:", new_worklist)
770
+ if changed:
771
+ break
772
+
773
+ section = PHASE_SECTIONS.get(phase)
774
+ if section:
775
+ new_status = f"## {section}\n\n- status: `{status}`"
776
+ for existing in VALID_PHASE_STATUSES:
777
+ old_status = f"## {section}\n\n- status: `{existing}`"
778
+ text, changed = _replace_line(text, old_status, new_status)
779
+ if changed:
780
+ break
781
+ return text
782
+
783
+
784
+ def _workitem_checkbox(status: str) -> str:
785
+ if status == "done":
786
+ return "x"
787
+ if status == "pending":
788
+ return " "
789
+ return "!"
790
+
791
+
792
+ def _status_checkbox(status: str) -> str:
793
+ if status == "done":
794
+ return "x"
795
+ if status == "pending":
796
+ return " "
797
+ return "!"
798
+
799
+
800
+ def _concern_checkbox(status: str) -> str:
801
+ if status == "resolved":
802
+ return "x"
803
+ if status == "open":
804
+ return " "
805
+ return "!"
806
+
807
+
808
+ def _parse_work_items(text: str) -> list[dict[str, str]]:
809
+ body, _, _ = _section_body(text, "Concrete Work Items")
810
+ if body is None:
811
+ return []
812
+
813
+ items: list[dict[str, str]] = []
814
+ for line in body.splitlines():
815
+ match = WORKITEM_RE.match(line)
816
+ if not match:
817
+ continue
818
+ items.append(
819
+ {
820
+ "item_id": match.group("item_id"),
821
+ "status": match.group("status"),
822
+ "role": match.group("role"),
823
+ "text": match.group("text"),
824
+ }
825
+ )
826
+ return items
827
+
828
+
829
+ def _render_workitem_line(*, item_id: str, status: str, role: str, text: str) -> str:
830
+ return f"- [{_workitem_checkbox(status)}] {item_id} status=`{status}` role=`{role}`: {text}"
831
+
832
+
833
+ def _overall_workitem_status(items: list[dict[str, str]]) -> str:
834
+ if not items:
835
+ return "pending"
836
+ if all(item["status"] in ACCEPTED_CLOSE_STATUSES for item in items):
837
+ if any(item["status"] == "escalated" for item in items):
838
+ return "escalated"
839
+ return "done"
840
+ if any(item["status"] in {"blocked", "unknown", "escalated"} for item in items):
841
+ return "blocked"
842
+ return "in_progress"
843
+
844
+
845
+ def _replace_concrete_work_items_section(text: str, items: list[dict[str, str]]) -> str:
846
+ body, start, end = _section_body(text, "Concrete Work Items")
847
+ if body is None:
848
+ insert_at = text.find("\n## Execution Role")
849
+ if insert_at == -1:
850
+ insert_at = len(text)
851
+ section = "\n\n## Concrete Work Items\n\n- status: `pending`\n- rule: task-specific work items must be added with `xrefkit skill workitem` and closed as `done` or `escalated`\n"
852
+ text = text[:insert_at] + section + text[insert_at:]
853
+ body, start, end = _section_body(text, "Concrete Work Items")
854
+ if body is None:
855
+ return text
856
+
857
+ status = _overall_workitem_status(items)
858
+ lines = [
859
+ "## Concrete Work Items",
860
+ "",
861
+ f"- status: `{status}`",
862
+ "- rule: task-specific work items must be added with `xrefkit skill workitem` and closed as `done` or `escalated`",
863
+ ]
864
+ lines.extend(_render_workitem_line(**item) for item in items)
865
+ new_body = "\n".join(lines) + "\n"
866
+ return text[:start] + new_body + text[end:]
867
+
868
+
869
+ @_locked_log_update
870
+ def update_work_item(args) -> SkillRunResult:
871
+ log_path = Path(args.log).resolve()
872
+ if not log_path.exists():
873
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=None, errors=[f"log not found: {log_path}"])
874
+
875
+ item_id = str(args.item).strip()
876
+ status = str(args.status).lower()
877
+ role = str(args.role).strip()
878
+ item_text = str(args.text or "").strip()
879
+ if not item_id:
880
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=["missing --item"])
881
+ if status not in VALID_WORKITEM_STATUSES:
882
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=[f"invalid work item status: {status}"])
883
+ if not role:
884
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=["missing --role"])
885
+
886
+ text = log_path.read_text(encoding="utf-8")
887
+ if "## Skill Load Gate\n\n- status: `opened_by_xrefkit_skill_run`" not in text:
888
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=["skill run log is missing an opened Skill Load Gate"])
889
+
890
+ items = _parse_work_items(text)
891
+ existing = next((item for item in items if item["item_id"] == item_id), None)
892
+ if existing:
893
+ existing["status"] = status
894
+ existing["role"] = role
895
+ if item_text:
896
+ existing["text"] = item_text
897
+ else:
898
+ if not item_text:
899
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=["new work item requires --text"])
900
+ items.append({"item_id": item_id, "status": status, "role": role, "text": item_text})
901
+
902
+ text = _replace_concrete_work_items_section(text, items)
903
+ text = _append_phase_event(text, phase=f"workitem:{item_id}", status=status, role=role, note=item_text or None)
904
+ _atomic_write_text(log_path, text)
905
+ return SkillRunResult(ok=True, skill_id=None, skill_doc=None, run_log=str(log_path), errors=[], work_items=items)
906
+
907
+
908
+ def _parse_artifacts(text: str) -> list[dict[str, str]]:
909
+ body, _, _ = _section_body(text, "Runtime Artifacts")
910
+ if body is None:
911
+ return []
912
+
913
+ artifacts: list[dict[str, str]] = []
914
+ for line in body.splitlines():
915
+ match = ARTIFACT_RE.match(line)
916
+ if not match:
917
+ continue
918
+ artifacts.append(
919
+ {
920
+ "artifact_id": match.group("artifact_id"),
921
+ "kind": match.group("kind"),
922
+ "status": match.group("status"),
923
+ "role": match.group("role"),
924
+ "target": match.group("target"),
925
+ "item_id": match.group("item_id"),
926
+ "note": match.group("note"),
927
+ }
928
+ )
929
+ return artifacts
930
+
931
+
932
+ def _render_artifact_line(
933
+ *,
934
+ artifact_id: str,
935
+ kind: str,
936
+ status: str,
937
+ role: str,
938
+ target: str,
939
+ item_id: str,
940
+ note: str,
941
+ ) -> str:
942
+ return (
943
+ f"- [{_status_checkbox(status)}] {artifact_id} kind=`{kind}` status=`{status}` "
944
+ f"role=`{role}` target=`{target}` item=`{item_id}`: {note}"
945
+ )
946
+
947
+
948
+ def _overall_artifact_status(artifacts: list[dict[str, str]]) -> str:
949
+ if not artifacts:
950
+ return "pending"
951
+ if all(artifact["status"] in ACCEPTED_CLOSE_STATUSES for artifact in artifacts):
952
+ if any(artifact["status"] == "escalated" for artifact in artifacts):
953
+ return "escalated"
954
+ return "done"
955
+ if any(artifact["status"] in {"blocked", "unknown", "escalated"} for artifact in artifacts):
956
+ return "blocked"
957
+ return "in_progress"
958
+
959
+
960
+ def _replace_runtime_artifacts_section(text: str, artifacts: list[dict[str, str]]) -> str:
961
+ body, start, end = _section_body(text, "Runtime Artifacts")
962
+ if body is None:
963
+ insert_at = text.find("\n## Execution Role")
964
+ if insert_at == -1:
965
+ insert_at = len(text)
966
+ section = "\n\n## Runtime Artifacts\n\n- status: `pending`\n- rule: outputs, evidence, checks, judgments, sources, and handoff links must be added with `xrefkit skill artifact`\n"
967
+ text = text[:insert_at] + section + text[insert_at:]
968
+ body, start, end = _section_body(text, "Runtime Artifacts")
969
+ if body is None:
970
+ return text
971
+
972
+ status = _overall_artifact_status(artifacts)
973
+ lines = [
974
+ "## Runtime Artifacts",
975
+ "",
976
+ f"- status: `{status}`",
977
+ "- rule: outputs, evidence, checks, judgments, sources, and handoff links must be added with `xrefkit skill artifact`",
978
+ ]
979
+ lines.extend(_render_artifact_line(**artifact) for artifact in artifacts)
980
+ new_body = "\n".join(lines) + "\n"
981
+ return text[:start] + new_body + text[end:]
982
+
983
+
984
+ @_locked_log_update
985
+ def update_artifact(args) -> SkillRunResult:
986
+ log_path = Path(args.log).resolve()
987
+ if not log_path.exists():
988
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=None, errors=[f"log not found: {log_path}"])
989
+
990
+ artifact_id = str(args.artifact).strip()
991
+ kind = str(args.kind).lower()
992
+ status = str(args.status).lower()
993
+ role = str(args.role).strip()
994
+ target = str(args.target or "").strip()
995
+ item_id = str(args.item or "").strip()
996
+ note = str(args.note or "").strip()
997
+ if not artifact_id:
998
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=["missing --artifact"])
999
+ if kind not in VALID_ARTIFACT_KINDS:
1000
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=[f"invalid artifact kind: {kind}"])
1001
+ if status not in VALID_ARTIFACT_STATUSES:
1002
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=[f"invalid artifact status: {status}"])
1003
+ if not role:
1004
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=["missing --role"])
1005
+
1006
+ text = log_path.read_text(encoding="utf-8")
1007
+ if "## Skill Load Gate\n\n- status: `opened_by_xrefkit_skill_run`" not in text:
1008
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=["skill run log is missing an opened Skill Load Gate"])
1009
+
1010
+ artifacts = _parse_artifacts(text)
1011
+ existing = next((artifact for artifact in artifacts if artifact["artifact_id"] == artifact_id), None)
1012
+ if existing:
1013
+ existing["kind"] = kind
1014
+ existing["status"] = status
1015
+ existing["role"] = role
1016
+ if target:
1017
+ existing["target"] = target
1018
+ if item_id:
1019
+ existing["item_id"] = item_id
1020
+ if note:
1021
+ existing["note"] = note
1022
+ else:
1023
+ if not target:
1024
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=["new artifact requires --target"])
1025
+ artifacts.append(
1026
+ {
1027
+ "artifact_id": artifact_id,
1028
+ "kind": kind,
1029
+ "status": status,
1030
+ "role": role,
1031
+ "target": target,
1032
+ "item_id": item_id or "-",
1033
+ "note": note or "-",
1034
+ }
1035
+ )
1036
+
1037
+ text = _replace_runtime_artifacts_section(text, artifacts)
1038
+ text = _append_phase_event(text, phase=f"artifact:{artifact_id}", status=status, role=role, note=note or target)
1039
+ _atomic_write_text(log_path, text)
1040
+ return SkillRunResult(ok=True, skill_id=None, skill_doc=None, run_log=str(log_path), errors=[], artifacts=artifacts)
1041
+
1042
+
1043
+ def _parse_concerns(text: str) -> list[dict[str, str]]:
1044
+ body, _, _ = _section_body(text, "Unknowns And Risks")
1045
+ if body is None:
1046
+ return []
1047
+
1048
+ concerns: list[dict[str, str]] = []
1049
+ for line in body.splitlines():
1050
+ match = CONCERN_RE.match(line)
1051
+ if not match:
1052
+ continue
1053
+ concerns.append(
1054
+ {
1055
+ "concern_id": match.group("concern_id"),
1056
+ "kind": match.group("kind"),
1057
+ "status": match.group("status"),
1058
+ "judgment": match.group("judgment"),
1059
+ "role": match.group("role"),
1060
+ "target": match.group("target"),
1061
+ "text": match.group("text"),
1062
+ }
1063
+ )
1064
+ return concerns
1065
+
1066
+
1067
+ def _render_concern_line(
1068
+ *,
1069
+ concern_id: str,
1070
+ kind: str,
1071
+ status: str,
1072
+ judgment: str,
1073
+ role: str,
1074
+ target: str,
1075
+ text: str,
1076
+ ) -> str:
1077
+ return (
1078
+ f"- [{_concern_checkbox(status)}] {concern_id} kind=`{kind}` status=`{status}` "
1079
+ f"judgment=`{judgment}` role=`{role}` target=`{target}`: {text}"
1080
+ )
1081
+
1082
+
1083
+ def _overall_concern_status(concerns: list[dict[str, str]]) -> str:
1084
+ if not concerns:
1085
+ return "pending"
1086
+ if all(concern["status"] in {"resolved", "escalated"} for concern in concerns):
1087
+ if any(concern["status"] == "escalated" for concern in concerns):
1088
+ return "escalated"
1089
+ return "done"
1090
+ return "blocked"
1091
+
1092
+
1093
+ def _replace_unknowns_and_risks_section(text: str, concerns: list[dict[str, str]]) -> str:
1094
+ body, start, end = _section_body(text, "Unknowns And Risks")
1095
+ if body is None:
1096
+ insert_at = text.find("\n## Closure Gate")
1097
+ if insert_at == -1:
1098
+ insert_at = len(text)
1099
+ section = "\n\n## Unknowns And Risks\n\n- status: `pending`\n- rule: unknowns, missing evidence, risks, and unsupported assumptions must remain explicit and must be resolved, escalated, or linked before closure\n"
1100
+ text = text[:insert_at] + section + text[insert_at:]
1101
+ body, start, end = _section_body(text, "Unknowns And Risks")
1102
+ if body is None:
1103
+ return text
1104
+
1105
+ lines = [
1106
+ "## Unknowns And Risks",
1107
+ "",
1108
+ f"- status: `{_overall_concern_status(concerns)}`",
1109
+ "- rule: unknowns, missing evidence, risks, and unsupported assumptions must remain explicit and must be resolved, escalated, or linked before closure",
1110
+ ]
1111
+ lines.extend(_render_concern_line(**concern) for concern in concerns)
1112
+ new_body = "\n".join(lines) + "\n"
1113
+ return text[:start] + new_body + text[end:]
1114
+
1115
+
1116
+ @_locked_log_update
1117
+ def update_concern(args) -> SkillRunResult:
1118
+ log_path = Path(args.log).resolve()
1119
+ if not log_path.exists():
1120
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=None, errors=[f"log not found: {log_path}"])
1121
+
1122
+ concern_id = str(args.concern).strip()
1123
+ kind = str(args.kind).lower()
1124
+ status = str(args.status).lower()
1125
+ judgment = str(args.judgment or "trivial").lower()
1126
+ role = str(args.role).strip()
1127
+ target = str(args.target or "").strip()
1128
+ concern_text = str(args.text or "").strip()
1129
+ if not concern_id:
1130
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=["missing --concern"])
1131
+ if kind not in VALID_CONCERN_KINDS:
1132
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=[f"invalid concern kind: {kind}"])
1133
+ if status not in VALID_CONCERN_STATUSES:
1134
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=[f"invalid concern status: {status}"])
1135
+ if judgment not in VALID_JUDGMENT_TYPES:
1136
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=[f"invalid judgment type: {judgment}"])
1137
+ if kind != "judgment" and judgment == "non_trivial":
1138
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=["non_trivial judgment marker is only valid for kind=judgment"])
1139
+ if not role:
1140
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=["missing --role"])
1141
+
1142
+ text = log_path.read_text(encoding="utf-8")
1143
+ if "## Skill Load Gate\n\n- status: `opened_by_xrefkit_skill_run`" not in text:
1144
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=["skill run log is missing an opened Skill Load Gate"])
1145
+
1146
+ concerns = _parse_concerns(text)
1147
+ existing = next((concern for concern in concerns if concern["concern_id"] == concern_id), None)
1148
+ if existing:
1149
+ existing["kind"] = kind
1150
+ existing["status"] = status
1151
+ existing["judgment"] = judgment
1152
+ existing["role"] = role
1153
+ if target:
1154
+ existing["target"] = target
1155
+ if concern_text:
1156
+ existing["text"] = concern_text
1157
+ else:
1158
+ if not concern_text:
1159
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=["new concern requires --text"])
1160
+ concerns.append(
1161
+ {
1162
+ "concern_id": concern_id,
1163
+ "kind": kind,
1164
+ "status": status,
1165
+ "judgment": judgment,
1166
+ "role": role,
1167
+ "target": target or "-",
1168
+ "text": concern_text,
1169
+ }
1170
+ )
1171
+
1172
+ text = _replace_unknowns_and_risks_section(text, concerns)
1173
+ text = _append_phase_event(text, phase=f"concern:{concern_id}", status=status, role=role, note=concern_text or None)
1174
+ _atomic_write_text(log_path, text)
1175
+ return SkillRunResult(ok=True, skill_id=None, skill_doc=None, run_log=str(log_path), errors=[], concerns=concerns)
1176
+
1177
+
1178
+ def _assigned_role(text: str, role_name: str) -> str | None:
1179
+ prefix = f"- {role_name}: `"
1180
+ start = text.find(prefix)
1181
+ if start == -1:
1182
+ return None
1183
+ start += len(prefix)
1184
+ end = text.find("`", start)
1185
+ if end == -1:
1186
+ return None
1187
+ return text[start:end]
1188
+
1189
+
1190
+ def _phase_has_role_event(text: str, *, phase: str, role: str) -> bool:
1191
+ marker = f"`{phase}` -> `"
1192
+ role_marker = f"role=`{role}`"
1193
+ return any(marker in line and role_marker in line for line in text.splitlines())
1194
+
1195
+
1196
+ def _validate_phase_role(text: str, *, phase: str, role: str | None) -> list[str]:
1197
+ required_role_name = PHASE_REQUIRED_ROLES.get(phase)
1198
+ if not required_role_name:
1199
+ return []
1200
+ if not role:
1201
+ return [f"{phase} phase requires --role {required_role_name}"]
1202
+
1203
+ assigned = _assigned_role(text, required_role_name)
1204
+ if not assigned:
1205
+ return [f"runtime role assignment is missing {required_role_name}"]
1206
+ if role != assigned:
1207
+ return [f"{phase} phase requires role {assigned}; got {role}"]
1208
+ return []
1209
+
1210
+
1211
+ def _assign_runtime_roles(*, skill_id: str, execution_mode: str, model_tier: str | None) -> dict[str, str]:
1212
+ # execution_mode governs the executor side only. The check phase is
1213
+ # workflow-progression verification, which `xrefkit skill verify` performs
1214
+ # deterministically; deterministic code is context-independent by
1215
+ # construction, so no checker subagent is assigned.
1216
+ if execution_mode == "subagent_required":
1217
+ executor_context = "isolated_subagent_required"
1218
+ elif execution_mode == "subagent_preferred":
1219
+ executor_context = "subagent_preferred"
1220
+ else:
1221
+ executor_context = "current_context_allowed"
1222
+ checker_context = "deterministic_xrefkit_verification"
1223
+
1224
+ # The quality phase is the quality axis: output acceptance and domain
1225
+ # review, exercised by an independent quality reviewer separate from the
1226
+ # executor. It runs in an independent subagent when the quality gate is
1227
+ # mandatory for this tier; otherwise it is optional.
1228
+ if model_tier in QUALITY_REQUIRED_TIERS:
1229
+ quality_reviewer_context = "independent_quality_subagent_required"
1230
+ else:
1231
+ quality_reviewer_context = "optional_for_this_tier"
1232
+
1233
+ return {
1234
+ "executor": f"{skill_id}:executor",
1235
+ "checker": f"{skill_id}:checker",
1236
+ "quality_reviewer": f"{skill_id}:quality_reviewer",
1237
+ "handoff_owner": f"{skill_id}:handoff_owner",
1238
+ "executor_context": executor_context,
1239
+ "checker_context": checker_context,
1240
+ "quality_reviewer_context": quality_reviewer_context,
1241
+ }
1242
+
1243
+
1244
+ @_locked_log_update
1245
+ def update_skill_phase(args) -> SkillRunResult:
1246
+ log_path = Path(args.log).resolve()
1247
+ if not log_path.exists():
1248
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=None, errors=[f"log not found: {log_path}"])
1249
+
1250
+ phase = str(args.phase).lower()
1251
+ status = str(args.status).lower()
1252
+ if phase not in VALID_PHASES:
1253
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=[f"invalid phase: {phase}"])
1254
+ if status not in VALID_PHASE_STATUSES:
1255
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=[f"invalid status: {status}"])
1256
+
1257
+ text = log_path.read_text(encoding="utf-8")
1258
+ role_errors = _validate_phase_role(text, phase=phase, role=args.role)
1259
+ if role_errors:
1260
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=role_errors)
1261
+
1262
+ text = _set_phase_status(text, phase=phase, status=status)
1263
+ text = _append_phase_event(text, phase=phase, status=status, role=args.role, note=args.note)
1264
+ _atomic_write_text(log_path, text)
1265
+
1266
+ return SkillRunResult(ok=True, skill_id=None, skill_doc=None, run_log=str(log_path), errors=[])
1267
+
1268
+
1269
+ def _has_work_judgment_reference(concerns: list[dict[str, str]], artifacts: list[dict[str, str]]) -> bool:
1270
+ refs = [concern["target"] for concern in concerns]
1271
+ refs.extend(artifact["target"] for artifact in artifacts)
1272
+ return any("work/judgments/" in ref.replace("\\", "/") for ref in refs)
1273
+
1274
+
1275
+ def _evaluate_closure_linkage(
1276
+ *, concerns: list[dict[str, str]], artifacts: list[dict[str, str]]
1277
+ ) -> tuple[list[str], dict[str, str]]:
1278
+ errors: list[str] = []
1279
+
1280
+ open_unknowns = [
1281
+ concern["concern_id"]
1282
+ for concern in concerns
1283
+ if concern["kind"] == "unknown" and concern["status"] != "resolved"
1284
+ ]
1285
+ if open_unknowns:
1286
+ errors.append(f"unresolved unknowns block closure: {', '.join(open_unknowns)}")
1287
+ unknown_check = "failed"
1288
+ else:
1289
+ unknown_check = "passed"
1290
+
1291
+ open_risks = [
1292
+ concern["concern_id"]
1293
+ for concern in concerns
1294
+ if concern["kind"] == "risk" and concern["status"] not in {"resolved", "escalated"}
1295
+ ]
1296
+ escalated_risks = [
1297
+ concern["concern_id"]
1298
+ for concern in concerns
1299
+ if concern["kind"] == "risk" and concern["status"] == "escalated"
1300
+ ]
1301
+ if open_risks:
1302
+ errors.append(f"unresolved risks block closure unless escalated: {', '.join(open_risks)}")
1303
+ risk_check = "failed"
1304
+ else:
1305
+ risk_check = "passed"
1306
+
1307
+ open_judgments = [
1308
+ concern["concern_id"]
1309
+ for concern in concerns
1310
+ if concern["kind"] == "judgment" and concern["status"] not in {"resolved", "escalated"}
1311
+ ]
1312
+ non_trivial_judgments = [
1313
+ concern["concern_id"]
1314
+ for concern in concerns
1315
+ if concern["kind"] == "judgment" and concern["judgment"] == "non_trivial"
1316
+ ]
1317
+ has_judgment_artifact = any(
1318
+ artifact["kind"] == "judgment" and artifact["status"] in ACCEPTED_CLOSE_STATUSES
1319
+ for artifact in artifacts
1320
+ )
1321
+ has_work_judgment_ref = _has_work_judgment_reference(concerns, artifacts)
1322
+ if non_trivial_judgments and not (has_judgment_artifact or has_work_judgment_ref):
1323
+ errors.append(
1324
+ "non-trivial judgments require a judgment artifact or work/judgments/ reference before closure: "
1325
+ + ", ".join(non_trivial_judgments)
1326
+ )
1327
+ judgment_check = "failed"
1328
+ elif open_judgments:
1329
+ errors.append(f"unresolved judgments block closure: {', '.join(open_judgments)}")
1330
+ judgment_check = "failed"
1331
+ else:
1332
+ judgment_check = "passed"
1333
+
1334
+ return errors, {
1335
+ "unknown": unknown_check,
1336
+ "risk": risk_check,
1337
+ "judgment": judgment_check,
1338
+ "open_unknowns": ",".join(open_unknowns) or "-",
1339
+ "open_risks": ",".join(open_risks) or "-",
1340
+ "escalated_risks": ",".join(escalated_risks) or "-",
1341
+ "open_judgments": ",".join(open_judgments) or "-",
1342
+ "non_trivial_judgments": ",".join(non_trivial_judgments) or "-",
1343
+ "judgment_reference": "present" if has_judgment_artifact or has_work_judgment_ref else "not_required",
1344
+ }
1345
+
1346
+
1347
+ def _replace_closure_checks(text: str, checks: dict[str, str]) -> str:
1348
+ body, start, end = _section_body(text, "Closure Gate")
1349
+ if body is None:
1350
+ return text
1351
+
1352
+ lines = body.rstrip().splitlines()
1353
+ filtered: list[str] = []
1354
+ skip = False
1355
+ for line in lines:
1356
+ if line == "### Closure Checks":
1357
+ skip = True
1358
+ continue
1359
+ if skip and line.startswith("### "):
1360
+ skip = False
1361
+ if skip:
1362
+ continue
1363
+ filtered.append(line)
1364
+
1365
+ filtered.extend(
1366
+ [
1367
+ "",
1368
+ "### Closure Checks",
1369
+ "",
1370
+ f"- unknown: `{checks['unknown']}` open=`{checks['open_unknowns']}`",
1371
+ f"- risk: `{checks['risk']}` open=`{checks['open_risks']}` escalated=`{checks['escalated_risks']}`",
1372
+ f"- judgment: `{checks['judgment']}` open=`{checks['open_judgments']}` non_trivial=`{checks['non_trivial_judgments']}` reference=`{checks['judgment_reference']}`",
1373
+ ]
1374
+ )
1375
+ new_body = "\n".join(filtered) + "\n"
1376
+ return text[:start] + new_body + text[end:]
1377
+
1378
+
1379
+ def _validate_handoff_sources(root: Path, source_logs: list[str]) -> tuple[list[dict[str, str]], list[str]]:
1380
+ validated: list[dict[str, str]] = []
1381
+ errors: list[str] = []
1382
+ for raw_source in source_logs:
1383
+ source_path = Path(raw_source)
1384
+ if not source_path.is_absolute():
1385
+ source_path = root / source_path
1386
+ source_path = source_path.resolve()
1387
+ if not source_path.exists():
1388
+ errors.append(f"handoff source log not found: {source_path}")
1389
+ continue
1390
+ text = source_path.read_text(encoding="utf-8")
1391
+ if "## Skill Load Gate\n\n- status: `opened_by_xrefkit_skill_run`" not in text:
1392
+ errors.append(f"handoff source log was not opened by xrefkit skill run: {source_path}")
1393
+ continue
1394
+ closure_status = _section_status(text, "Closure Gate")
1395
+ if closure_status not in ACCEPTED_CLOSE_STATUSES:
1396
+ errors.append(
1397
+ f"handoff source log must have Closure Gate done or escalated before startup: {source_path} current={closure_status or 'missing'}"
1398
+ )
1399
+ continue
1400
+ handoff_status = _section_status(text, "Handoff")
1401
+ if handoff_status not in ACCEPTED_CLOSE_STATUSES:
1402
+ errors.append(
1403
+ f"handoff source log must have Handoff done or escalated before startup: {source_path} current={handoff_status or 'missing'}"
1404
+ )
1405
+ continue
1406
+ validated.append(
1407
+ {
1408
+ "source_log": source_path.relative_to(root).as_posix() if source_path.is_relative_to(root) else str(source_path),
1409
+ "skill_id": _log_skill_id(text) or "-",
1410
+ "closure": closure_status,
1411
+ "handoff": handoff_status,
1412
+ }
1413
+ )
1414
+ return validated, errors
1415
+
1416
+
1417
+ def _progression_record_errors(
1418
+ text: str,
1419
+ ) -> tuple[list[str], list[dict[str, str]], list[dict[str, str]], list[dict[str, str]], dict[str, str]]:
1420
+ """Deterministic work-item / artifact / concern checks shared by verify and close."""
1421
+ errors: list[str] = []
1422
+
1423
+ work_items = _parse_work_items(text)
1424
+ if not work_items:
1425
+ errors.append("at least one concrete work item is required before closure")
1426
+ for item in work_items:
1427
+ if item["status"] not in ACCEPTED_CLOSE_STATUSES:
1428
+ errors.append(
1429
+ f"work item {item['item_id']} must be done or escalated before closure; current={item['status']}"
1430
+ )
1431
+
1432
+ artifacts = _parse_artifacts(text)
1433
+ artifact_kinds = {artifact["kind"] for artifact in artifacts}
1434
+ if "output" not in artifact_kinds:
1435
+ errors.append("at least one output artifact is required before closure")
1436
+ if "evidence" not in artifact_kinds:
1437
+ errors.append("at least one evidence artifact is required before closure")
1438
+ for artifact in artifacts:
1439
+ if artifact["status"] not in ACCEPTED_CLOSE_STATUSES:
1440
+ errors.append(
1441
+ f"artifact {artifact['artifact_id']} must be done or escalated before closure; current={artifact['status']}"
1442
+ )
1443
+
1444
+ concerns = _parse_concerns(text)
1445
+ linkage_errors, closure_checks = _evaluate_closure_linkage(concerns=concerns, artifacts=artifacts)
1446
+ errors.extend(linkage_errors)
1447
+
1448
+ return errors, work_items, artifacts, concerns, closure_checks
1449
+
1450
+
1451
+ @_locked_log_update
1452
+ def verify_progression_run(args) -> SkillRunResult:
1453
+ """Deterministically verify workflow progression and advance the check phase.
1454
+
1455
+ This replaces the LLM checker subagent for the check phase. The check is
1456
+ record-level only (worklist completion, work items, artifact recording and
1457
+ linkage, concern resolution, role separation); it does not open artifact
1458
+ targets, judge content, or assess output quality. Quality is a separate
1459
+ axis owned by review-oriented Skills.
1460
+ """
1461
+ log_path = Path(args.log).resolve()
1462
+ if not log_path.exists():
1463
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=None, errors=[f"log not found: {log_path}"])
1464
+
1465
+ text = log_path.read_text(encoding="utf-8")
1466
+ if "## Skill Load Gate\n\n- status: `opened_by_xrefkit_skill_run`" not in text:
1467
+ return SkillRunResult(
1468
+ ok=False,
1469
+ skill_id=None,
1470
+ skill_doc=None,
1471
+ run_log=str(log_path),
1472
+ errors=["skill run log is missing an opened Skill Load Gate"],
1473
+ )
1474
+
1475
+ errors: list[str] = []
1476
+ executor = _assigned_role(text, "executor")
1477
+ checker = _assigned_role(text, "checker")
1478
+ if not executor or not checker:
1479
+ errors.append("runtime role assignment is incomplete")
1480
+ elif executor == checker:
1481
+ errors.append("executor and checker roles must be different")
1482
+ elif not _phase_has_role_event(text, phase="execution", role=executor):
1483
+ errors.append(f"execution phase must be advanced by executor role {executor}")
1484
+
1485
+ record_errors, work_items, artifacts, concerns, closure_checks = _progression_record_errors(text)
1486
+ errors.extend(record_errors)
1487
+
1488
+ new_status = "blocked" if errors else "done"
1489
+ role = checker or "xrefkit:progression_checker"
1490
+ note = args.note or ("progression record verified" if not errors else "progression record incomplete")
1491
+ text = _set_phase_status(text, phase="check", status=new_status)
1492
+ text = _append_phase_event(text, phase="check", status=new_status, role=role, note=note)
1493
+ _atomic_write_text(log_path, text)
1494
+
1495
+ return SkillRunResult(
1496
+ ok=not errors,
1497
+ skill_id=_log_skill_id(text),
1498
+ skill_doc=None,
1499
+ run_log=str(log_path),
1500
+ errors=errors,
1501
+ work_items=work_items,
1502
+ artifacts=artifacts,
1503
+ concerns=concerns,
1504
+ closure_checks=closure_checks,
1505
+ )
1506
+
1507
+
1508
+ @_locked_log_update
1509
+ def close_skill_run(args) -> SkillRunResult:
1510
+ log_path = Path(args.log).resolve()
1511
+ if not log_path.exists():
1512
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=None, errors=[f"log not found: {log_path}"])
1513
+
1514
+ text = log_path.read_text(encoding="utf-8")
1515
+ if "## Skill Load Gate\n\n- status: `opened_by_xrefkit_skill_run`" not in text:
1516
+ return SkillRunResult(
1517
+ ok=False,
1518
+ skill_id=None,
1519
+ skill_doc=None,
1520
+ run_log=str(log_path),
1521
+ errors=["skill run log is missing an opened Skill Load Gate"],
1522
+ )
1523
+
1524
+ errors: list[str] = []
1525
+ statuses: dict[str, str | None] = {}
1526
+ for section in REQUIRED_CLOSE_SECTIONS:
1527
+ status = _section_status(text, section)
1528
+ statuses[section] = status
1529
+ if status not in ACCEPTED_CLOSE_STATUSES:
1530
+ errors.append(f"{section} must be done or escalated before closure; current={status or 'missing'}")
1531
+
1532
+ executor = _assigned_role(text, "executor")
1533
+ checker = _assigned_role(text, "checker")
1534
+ handoff_owner = _assigned_role(text, "handoff_owner")
1535
+ if not executor or not checker or not handoff_owner:
1536
+ errors.append("runtime role assignment is incomplete")
1537
+ elif executor == checker:
1538
+ errors.append("executor and checker roles must be different")
1539
+ else:
1540
+ if not _phase_has_role_event(text, phase="execution", role=executor):
1541
+ errors.append(f"execution phase must be advanced by executor role {executor}")
1542
+ if not _phase_has_role_event(text, phase="check", role=checker):
1543
+ errors.append(f"check phase must be advanced by checker role {checker}")
1544
+ if not _phase_has_role_event(text, phase="handoff", role=handoff_owner):
1545
+ errors.append(f"handoff phase must be advanced by handoff_owner role {handoff_owner}")
1546
+
1547
+ record_errors, work_items, artifacts, concerns, closure_checks = _progression_record_errors(text)
1548
+ errors.extend(record_errors)
1549
+
1550
+ # Tier-conditional quality gate. For standard/heavy tiers the quality axis
1551
+ # is mandatory: the quality phase must be advanced and at least one
1552
+ # acceptance `check`-kind artifact must exist. Light/untiered skills may
1553
+ # close without a quality gate. Artifact statuses themselves are already
1554
+ # enforced by _progression_record_errors.
1555
+ model_tier = _log_model_tier(text)
1556
+ quality_status = _section_status(text, "Quality Gate")
1557
+ statuses["Quality Gate"] = quality_status
1558
+ if model_tier in QUALITY_REQUIRED_TIERS:
1559
+ quality_reviewer = _assigned_role(text, "quality_reviewer")
1560
+ if quality_status not in ACCEPTED_CLOSE_STATUSES:
1561
+ errors.append(
1562
+ f"Quality Gate must be done or escalated before closure for model_tier {model_tier}; current={quality_status or 'missing'}"
1563
+ )
1564
+ if not any(artifact["kind"] == "check" for artifact in artifacts):
1565
+ errors.append(
1566
+ f"at least one acceptance check artifact is required before closure for model_tier {model_tier}"
1567
+ )
1568
+ if quality_reviewer and executor and quality_reviewer == executor:
1569
+ errors.append("executor and quality_reviewer roles must be different")
1570
+ if quality_reviewer and not _phase_has_role_event(text, phase="quality", role=quality_reviewer):
1571
+ errors.append(f"quality phase must be advanced by quality_reviewer role {quality_reviewer}")
1572
+
1573
+ if errors:
1574
+ return SkillRunResult(
1575
+ ok=False,
1576
+ skill_id=None,
1577
+ skill_doc=None,
1578
+ run_log=str(log_path),
1579
+ errors=errors,
1580
+ concerns=concerns,
1581
+ closure_checks=closure_checks,
1582
+ )
1583
+
1584
+ has_escalation = (
1585
+ "escalated" in statuses.values()
1586
+ or any(item["status"] == "escalated" for item in work_items)
1587
+ or any(artifact["status"] == "escalated" for artifact in artifacts)
1588
+ or any(concern["status"] == "escalated" for concern in concerns)
1589
+ )
1590
+ close_status = "escalated" if has_escalation else "done"
1591
+ text = _set_phase_status(text, phase="closure", status=close_status)
1592
+ text = _replace_closure_checks(text, closure_checks)
1593
+ text = _append_phase_event(text, phase="closure", status=close_status, role="closure_gate", note=args.note)
1594
+ _atomic_write_text(log_path, text)
1595
+ return SkillRunResult(
1596
+ ok=True,
1597
+ skill_id=None,
1598
+ skill_doc=None,
1599
+ run_log=str(log_path),
1600
+ errors=[],
1601
+ concerns=concerns,
1602
+ closure_checks=closure_checks,
1603
+ )
1604
+
1605
+
1606
+ def _parse_token_usage(text: str) -> dict[str, str] | None:
1607
+ body, _, _ = _section_body(text, "Token Usage")
1608
+ if body is None:
1609
+ return None
1610
+
1611
+ def field(key: str) -> str | None:
1612
+ match = re.search(rf"^- {key}: `([^`]*)`", body, re.MULTILINE)
1613
+ return match.group(1) if match else None
1614
+
1615
+ return {
1616
+ "status": field("status") or "",
1617
+ "input": field("input") or "",
1618
+ "output": field("output") or "",
1619
+ "total": field("total") or "",
1620
+ }
1621
+
1622
+
1623
+ def _render_token_usage_section(
1624
+ *, input_value: str, output_value: str, total_value: str, note: str | None
1625
+ ) -> str:
1626
+ lines = [
1627
+ "## Token Usage",
1628
+ "",
1629
+ "- status: `recorded`",
1630
+ f"- input: `{input_value}`",
1631
+ f"- output: `{output_value}`",
1632
+ f"- total: `{total_value}`",
1633
+ "- rule: record tokens consumed by this skill run with `xrefkit skill tokens` (informational; does not gate closure)",
1634
+ ]
1635
+ if note:
1636
+ lines.append(f"- note: {note}")
1637
+ return "\n".join(lines) + "\n"
1638
+
1639
+
1640
+ def _replace_token_usage_section(text: str, new_body: str) -> str:
1641
+ body, start, end = _section_body(text, "Token Usage")
1642
+ if body is None:
1643
+ # Older logs created before this section existed: insert it just before
1644
+ # the Phase Events log, or append it at the end.
1645
+ block = "\n" + new_body.rstrip() + "\n"
1646
+ insert_at = text.find("\n## Phase Events")
1647
+ if insert_at == -1:
1648
+ return text.rstrip() + "\n\n" + new_body.rstrip() + "\n"
1649
+ return text[:insert_at] + "\n" + block + text[insert_at:]
1650
+ return text[:start] + new_body + text[end:]
1651
+
1652
+
1653
+ def _validate_observation_log(log_path: Path) -> tuple[str | None, SkillRunResult | None]:
1654
+ if not log_path.exists():
1655
+ return None, SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=None, errors=[f"log not found: {log_path}"])
1656
+ try:
1657
+ text = log_path.read_text(encoding="utf-8")
1658
+ except (OSError, UnicodeError) as exc:
1659
+ return None, SkillRunResult(
1660
+ ok=False,
1661
+ skill_id=None,
1662
+ skill_doc=None,
1663
+ run_log=str(log_path),
1664
+ errors=[f"could not read skill run log: {exc}"],
1665
+ )
1666
+ if "## Skill Load Gate\n\n- status: `opened_by_xrefkit_skill_run`" not in text:
1667
+ return None, SkillRunResult(
1668
+ ok=False,
1669
+ skill_id=None,
1670
+ skill_doc=None,
1671
+ run_log=str(log_path),
1672
+ errors=["skill run log is missing an opened Skill Load Gate"],
1673
+ )
1674
+ if _log_field(text, "run_id") is None:
1675
+ return None, SkillRunResult(
1676
+ ok=False,
1677
+ skill_id=_log_skill_id(text),
1678
+ skill_doc=None,
1679
+ run_log=str(log_path),
1680
+ errors=["skill run log is missing run_id; create a new run or migrate the log before recording observations"],
1681
+ )
1682
+ return text, None
1683
+
1684
+
1685
+ @_locked_log_update
1686
+ def correlate_skill_run(args) -> SkillRunResult:
1687
+ log_path = Path(args.log).resolve()
1688
+ text, error = _validate_observation_log(log_path)
1689
+ if error is not None:
1690
+ return error
1691
+ assert text is not None
1692
+ run_id = _log_field(text, "run_id")
1693
+ expected_run_id = str(getattr(args, "run_id", "") or "").strip()
1694
+ if expected_run_id and expected_run_id != run_id:
1695
+ return SkillRunResult(
1696
+ ok=False,
1697
+ skill_id=_log_skill_id(text),
1698
+ skill_doc=None,
1699
+ run_log=str(log_path),
1700
+ errors=[f"run_id mismatch: log={run_id} provided={expected_run_id}"],
1701
+ run_id=run_id,
1702
+ )
1703
+ mcp_session_id = str(args.mcp_session_id).strip()
1704
+ repository_fingerprint = str(args.repository_fingerprint).strip()
1705
+ if not mcp_session_id or not repository_fingerprint:
1706
+ return SkillRunResult(
1707
+ ok=False,
1708
+ skill_id=_log_skill_id(text),
1709
+ skill_doc=None,
1710
+ run_log=str(log_path),
1711
+ errors=["mcp_session_id and repository_fingerprint are required"],
1712
+ run_id=run_id,
1713
+ )
1714
+ if not _valid_log_token(mcp_session_id) or not _valid_log_token(repository_fingerprint):
1715
+ return SkillRunResult(
1716
+ ok=False,
1717
+ skill_id=_log_skill_id(text),
1718
+ skill_doc=None,
1719
+ run_log=str(log_path),
1720
+ errors=["mcp_session_id and repository_fingerprint must be safe identifier tokens"],
1721
+ run_id=run_id,
1722
+ )
1723
+ existing_session_id = _log_field(text, "mcp_session_id")
1724
+ existing_fingerprint = _log_field(text, "repository_fingerprint")
1725
+ if existing_session_id is not None or existing_fingerprint is not None:
1726
+ if (existing_session_id, existing_fingerprint) != (mcp_session_id, repository_fingerprint):
1727
+ return SkillRunResult(
1728
+ ok=False,
1729
+ skill_id=_log_skill_id(text),
1730
+ skill_doc=None,
1731
+ run_log=str(log_path),
1732
+ errors=["Skill Run is already correlated to a different MCP session or repository fingerprint"],
1733
+ run_id=run_id,
1734
+ )
1735
+ return SkillRunResult(
1736
+ ok=True,
1737
+ skill_id=_log_skill_id(text),
1738
+ skill_doc=None,
1739
+ run_log=str(log_path),
1740
+ errors=[],
1741
+ run_id=run_id,
1742
+ )
1743
+ text, _ = _replace_log_field(text, "mcp_session_id", mcp_session_id)
1744
+ text, _ = _replace_log_field(text, "repository_fingerprint", repository_fingerprint)
1745
+ text = _append_observation_event(
1746
+ text,
1747
+ section="MCP Correlation",
1748
+ event={
1749
+ "event": "mcp.bound",
1750
+ "run_id": run_id,
1751
+ "mcp_session_id": mcp_session_id,
1752
+ "repository_fingerprint": repository_fingerprint,
1753
+ },
1754
+ )
1755
+ _atomic_write_text(log_path, text)
1756
+ return SkillRunResult(
1757
+ ok=True,
1758
+ skill_id=_log_skill_id(text),
1759
+ skill_doc=None,
1760
+ run_log=str(log_path),
1761
+ errors=[],
1762
+ run_id=run_id,
1763
+ )
1764
+
1765
+
1766
+ @_locked_log_update
1767
+ def update_skill_routing(args) -> SkillRunResult:
1768
+ log_path = Path(args.log).resolve()
1769
+ text, error = _validate_observation_log(log_path)
1770
+ if error is not None:
1771
+ return error
1772
+ assert text is not None
1773
+ selected_skill = str(args.selected_skill).strip()
1774
+ candidates = [str(value).strip() for value in args.candidate if str(value).strip()]
1775
+ reason = str(args.reason).strip()
1776
+ if not selected_skill or not candidates or not reason:
1777
+ return SkillRunResult(
1778
+ ok=False,
1779
+ skill_id=_log_skill_id(text),
1780
+ skill_doc=None,
1781
+ run_log=str(log_path),
1782
+ errors=["selected_skill, at least one candidate, and reason are required"],
1783
+ run_id=_log_field(text, "run_id"),
1784
+ )
1785
+ if selected_skill not in candidates:
1786
+ return SkillRunResult(
1787
+ ok=False,
1788
+ skill_id=_log_skill_id(text),
1789
+ skill_doc=None,
1790
+ run_log=str(log_path),
1791
+ errors=["selected_skill must be present in candidates"],
1792
+ run_id=_log_field(text, "run_id"),
1793
+ )
1794
+ if selected_skill != _log_skill_id(text):
1795
+ return SkillRunResult(
1796
+ ok=False,
1797
+ skill_id=_log_skill_id(text),
1798
+ skill_doc=None,
1799
+ run_log=str(log_path),
1800
+ errors=["selected_skill must match the Skill Run skill_id; use a new run or explicit handoff for a different Skill"],
1801
+ run_id=_log_field(text, "run_id"),
1802
+ )
1803
+ text = _append_observation_event(
1804
+ text,
1805
+ section="Skill Routing Trace",
1806
+ event={
1807
+ "event": "skill.routed",
1808
+ "selected_skill": selected_skill,
1809
+ "selection_mode": str(args.selection_mode),
1810
+ "candidate_source": "recorded",
1811
+ "candidates": candidates,
1812
+ "reason": reason,
1813
+ },
1814
+ )
1815
+ _atomic_write_text(log_path, text)
1816
+ return SkillRunResult(
1817
+ ok=True,
1818
+ skill_id=_log_skill_id(text),
1819
+ skill_doc=None,
1820
+ run_log=str(log_path),
1821
+ errors=[],
1822
+ run_id=_log_field(text, "run_id"),
1823
+ )
1824
+
1825
+
1826
+ @_locked_log_update
1827
+ def update_knowledge_observation(args) -> SkillRunResult:
1828
+ log_path = Path(args.log).resolve()
1829
+ text, error = _validate_observation_log(log_path)
1830
+ if error is not None:
1831
+ return error
1832
+ assert text is not None
1833
+ action = str(args.action)
1834
+ note = str(args.note or "").strip() or None
1835
+ source = str(args.source or "client").strip()
1836
+ if action == "search":
1837
+ query = str(args.query or "").strip()
1838
+ result_xids = [str(value).strip() for value in args.xid if str(value).strip()]
1839
+ if not query:
1840
+ errors = ["--query is required for action=search"]
1841
+ else:
1842
+ errors = []
1843
+ if args.status == "hit" and not result_xids:
1844
+ errors.append("action=search status=hit requires at least one --xid")
1845
+ if args.status == "miss" and result_xids:
1846
+ errors.append("action=search status=miss must not include --xid")
1847
+ section = "Knowledge Search Trace"
1848
+ event: dict[str, object] = {
1849
+ "event": "knowledge.search",
1850
+ "query": query,
1851
+ "status": str(args.status),
1852
+ "result_xids": result_xids,
1853
+ "source": source,
1854
+ }
1855
+ elif action == "load":
1856
+ xids = [str(value).strip() for value in args.xid if str(value).strip()]
1857
+ content_hash = str(args.content_hash or "").strip()
1858
+ errors = []
1859
+ if len(xids) != 1:
1860
+ errors.append("action=load requires exactly one --xid")
1861
+ if not content_hash:
1862
+ errors.append("--content-hash is required for action=load")
1863
+ section = "Loaded Knowledge Inputs"
1864
+ event = {
1865
+ "event": "knowledge.loaded",
1866
+ "xid": xids[0] if len(xids) == 1 else "",
1867
+ "content_hash": content_hash,
1868
+ "source": source,
1869
+ }
1870
+ else:
1871
+ xids = [str(value).strip() for value in args.xid if str(value).strip()]
1872
+ target = str(args.target or "").strip()
1873
+ content_hash = str(args.content_hash or "").strip()
1874
+ errors = []
1875
+ if len(xids) != 1:
1876
+ errors.append("action=apply requires exactly one --xid")
1877
+ if not target:
1878
+ errors.append("--target is required for action=apply")
1879
+ if not content_hash:
1880
+ errors.append("--content-hash is required for action=apply")
1881
+ artifacts = _parse_artifacts(text)
1882
+ concerns = _parse_concerns(text)
1883
+ valid_targets = {artifact["artifact_id"] for artifact in artifacts} | {
1884
+ concern["concern_id"] for concern in concerns
1885
+ }
1886
+ if target and target not in valid_targets:
1887
+ errors.append("action=apply target must identify an existing artifact or concern")
1888
+ loaded_pairs = {
1889
+ (str(event.get("xid")), str(event.get("content_hash")))
1890
+ for event in _observation_events(text)
1891
+ if event.get("event") == "knowledge.loaded"
1892
+ }
1893
+ if len(xids) == 1 and content_hash and (xids[0], content_hash) not in loaded_pairs:
1894
+ errors.append("action=apply requires a prior knowledge.loaded event with the same XID and content hash")
1895
+ section = "Knowledge Application Trace"
1896
+ event = {
1897
+ "event": "knowledge.applied",
1898
+ "xid": xids[0] if len(xids) == 1 else "",
1899
+ "target": target,
1900
+ "content_hash": content_hash,
1901
+ "decisive": bool(args.decisive),
1902
+ "source": source,
1903
+ }
1904
+ if errors:
1905
+ return SkillRunResult(
1906
+ ok=False,
1907
+ skill_id=_log_skill_id(text),
1908
+ skill_doc=None,
1909
+ run_log=str(log_path),
1910
+ errors=errors,
1911
+ run_id=_log_field(text, "run_id"),
1912
+ )
1913
+ if note:
1914
+ event["note"] = note
1915
+ text = _append_observation_event(text, section=section, event=event)
1916
+ _atomic_write_text(log_path, text)
1917
+ return SkillRunResult(
1918
+ ok=True,
1919
+ skill_id=_log_skill_id(text),
1920
+ skill_doc=None,
1921
+ run_log=str(log_path),
1922
+ errors=[],
1923
+ run_id=_log_field(text, "run_id"),
1924
+ )
1925
+
1926
+
1927
+ @_locked_log_update
1928
+ def update_feedback_observation(args) -> SkillRunResult:
1929
+ log_path = Path(args.log).resolve()
1930
+ text, error = _validate_observation_log(log_path)
1931
+ if error is not None:
1932
+ return error
1933
+ assert text is not None
1934
+ note = str(args.note or "").strip()
1935
+ if not note:
1936
+ return SkillRunResult(
1937
+ ok=False,
1938
+ skill_id=_log_skill_id(text),
1939
+ skill_doc=None,
1940
+ run_log=str(log_path),
1941
+ errors=["--note is required for feedback observations"],
1942
+ run_id=_log_field(text, "run_id"),
1943
+ )
1944
+ allowed_statuses = {
1945
+ "human": {"accepted", "corrected", "rejected", "unknown"},
1946
+ "outcome": {"successful", "failed", "mixed", "unknown"},
1947
+ }
1948
+ if args.status not in allowed_statuses[args.kind]:
1949
+ return SkillRunResult(
1950
+ ok=False,
1951
+ skill_id=_log_skill_id(text),
1952
+ skill_doc=None,
1953
+ run_log=str(log_path),
1954
+ errors=[f"invalid {args.kind} feedback status: {args.status}"],
1955
+ run_id=_log_field(text, "run_id"),
1956
+ )
1957
+ section = "Human Feedback" if args.kind == "human" else "Outcome Feedback"
1958
+ event = {
1959
+ "event": "human.feedback" if args.kind == "human" else "outcome.feedback",
1960
+ "status": str(args.status),
1961
+ "target": str(args.target or "").strip() or None,
1962
+ "note": note,
1963
+ }
1964
+ text = _append_observation_event(text, section=section, event=event)
1965
+ _atomic_write_text(log_path, text)
1966
+ return SkillRunResult(
1967
+ ok=True,
1968
+ skill_id=_log_skill_id(text),
1969
+ skill_doc=None,
1970
+ run_log=str(log_path),
1971
+ errors=[],
1972
+ run_id=_log_field(text, "run_id"),
1973
+ )
1974
+
1975
+
1976
+ @_locked_log_update
1977
+ def update_token_usage(args) -> SkillRunResult:
1978
+ log_path = Path(args.log).resolve()
1979
+ if not log_path.exists():
1980
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=None, errors=[f"log not found: {log_path}"])
1981
+
1982
+ def _coerce(name: str, value) -> tuple[int | None, str | None]:
1983
+ if value is None:
1984
+ return None, None
1985
+ try:
1986
+ number = int(value)
1987
+ except (TypeError, ValueError):
1988
+ return None, f"{name} must be an integer: {value}"
1989
+ if number < 0:
1990
+ return None, f"{name} must be >= 0: {value}"
1991
+ return number, None
1992
+
1993
+ input_tokens, e_in = _coerce("input", args.input)
1994
+ output_tokens, e_out = _coerce("output", args.output)
1995
+ total_arg, e_total = _coerce("total", args.total)
1996
+ coerce_errors = [error for error in (e_in, e_out, e_total) if error]
1997
+ if coerce_errors:
1998
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=coerce_errors)
1999
+ if input_tokens is None and output_tokens is None and total_arg is None:
2000
+ return SkillRunResult(
2001
+ ok=False, skill_id=None, skill_doc=None, run_log=str(log_path),
2002
+ errors=["provide at least one of --input, --output, or --total"],
2003
+ )
2004
+
2005
+ text = log_path.read_text(encoding="utf-8")
2006
+ if "## Skill Load Gate\n\n- status: `opened_by_xrefkit_skill_run`" not in text:
2007
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=str(log_path), errors=["skill run log is missing an opened Skill Load Gate"])
2008
+
2009
+ total_tokens = total_arg if total_arg is not None else (input_tokens or 0) + (output_tokens or 0)
2010
+ note = str(args.note or "").strip() or None
2011
+ new_body = _render_token_usage_section(
2012
+ input_value=str(input_tokens) if input_tokens is not None else "-",
2013
+ output_value=str(output_tokens) if output_tokens is not None else "-",
2014
+ total_value=str(total_tokens),
2015
+ note=note,
2016
+ )
2017
+ text = _replace_token_usage_section(text, new_body)
2018
+ text = _append_phase_event(
2019
+ text, phase="tokens", status="recorded", role=None,
2020
+ note=note or f"input={input_tokens if input_tokens is not None else '-'} output={output_tokens if output_tokens is not None else '-'} total={total_tokens}",
2021
+ )
2022
+ _atomic_write_text(log_path, text)
2023
+ return SkillRunResult(ok=True, skill_id=_log_skill_id(text), skill_doc=None, run_log=str(log_path), errors=[])
2024
+
2025
+
2026
+ def run_skill(args) -> SkillRunResult:
2027
+ root = Path(args.root).resolve()
2028
+ meta_path = (root / args.meta).resolve()
2029
+ task, task_errors = _read_task(args)
2030
+ if task_errors:
2031
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=None, errors=task_errors)
2032
+ handoff_source_logs = [str(value).strip() for value in getattr(args, "handoff_source_log", []) if str(value).strip()]
2033
+
2034
+ if not meta_path.exists():
2035
+ return SkillRunResult(ok=False, skill_id=None, skill_doc=None, run_log=None, errors=[f"meta not found: {meta_path}"])
2036
+
2037
+ parsed = _parse_meta_lines(meta_path.read_text(encoding="utf-8"))
2038
+ domain_knowledge, domain_knowledge_errors = _prepare_domain_knowledge_context(
2039
+ parsed_meta=parsed,
2040
+ catalog_path=getattr(args, "domain_knowledge_catalog", None),
2041
+ selected_values=getattr(args, "knowledge_input", []),
2042
+ )
2043
+ if domain_knowledge_errors:
2044
+ return SkillRunResult(
2045
+ ok=False,
2046
+ skill_id=str(parsed.get("skill_id")) if parsed.get("skill_id") else None,
2047
+ skill_doc=None,
2048
+ run_log=None,
2049
+ errors=domain_knowledge_errors,
2050
+ domain_knowledge=domain_knowledge,
2051
+ )
2052
+ maturity, _ = _resolve_maturity(parsed)
2053
+ maturity = maturity or "stable"
2054
+
2055
+ if maturity == "draft":
2056
+ return SkillRunResult(
2057
+ ok=False,
2058
+ skill_id=str(parsed.get("skill_id")) if parsed.get("skill_id") else None,
2059
+ skill_doc=None,
2060
+ run_log=None,
2061
+ errors=[
2062
+ "draft skills are not load-ready; promote the skill to trial with provisional runtime fields before xrefkit skill run"
2063
+ ],
2064
+ )
2065
+ if maturity == "deprecated":
2066
+ return SkillRunResult(
2067
+ ok=False,
2068
+ skill_id=str(parsed.get("skill_id")) if parsed.get("skill_id") else None,
2069
+ skill_doc=None,
2070
+ run_log=None,
2071
+ errors=["deprecated skills cannot be opened with xrefkit skill run"],
2072
+ )
2073
+
2074
+ validation_level = "trial" if maturity == "trial" else "stable"
2075
+ validation = validate_skill_meta(meta_path, check_level=validation_level)
2076
+ if maturity == "trial":
2077
+ allowed_trial_errors = {"trial-or-higher skills must include at least one observation_refs entry"}
2078
+ blocking_errors = [error for error in validation.errors if error not in allowed_trial_errors]
2079
+ else:
2080
+ blocking_errors = list(validation.errors)
2081
+
2082
+ if blocking_errors:
2083
+ return SkillRunResult(
2084
+ ok=False,
2085
+ skill_id=validation.skill_id,
2086
+ skill_doc=None,
2087
+ run_log=None,
2088
+ errors=blocking_errors,
2089
+ )
2090
+
2091
+ skill_id = str(parsed.get("skill_id"))
2092
+ raw_execution_mode = parsed.get("execution_mode")
2093
+ execution_mode = str(raw_execution_mode) if raw_execution_mode else TRIAL_DEFAULT_EXECUTION_MODE
2094
+ if execution_mode not in {"local_default", "subagent_preferred", "subagent_required"}:
2095
+ return SkillRunResult(
2096
+ ok=False,
2097
+ skill_id=skill_id,
2098
+ skill_doc=None,
2099
+ run_log=None,
2100
+ errors=[f"invalid execution_mode for skill run: {execution_mode}"],
2101
+ )
2102
+ raw_guard_policy = parsed.get("guard_policy")
2103
+ guard_policy = str(raw_guard_policy) if raw_guard_policy else TRIAL_DEFAULT_GUARD_POLICY
2104
+ raw_capability_layering = parsed.get("capability_layering")
2105
+ capability_layering = str(raw_capability_layering) if raw_capability_layering else "required"
2106
+ if capability_layering not in VALID_CAPABILITY_LAYERING_POLICIES:
2107
+ return SkillRunResult(
2108
+ ok=False,
2109
+ skill_id=skill_id,
2110
+ skill_doc=None,
2111
+ run_log=None,
2112
+ errors=[f"invalid capability_layering for skill run: {capability_layering}"],
2113
+ )
2114
+ raw_workflow_protocol = parsed.get("workflow_protocol")
2115
+ workflow_protocol = str(raw_workflow_protocol) if raw_workflow_protocol else "required"
2116
+ if workflow_protocol not in VALID_WORKFLOW_PROTOCOL_POLICIES:
2117
+ return SkillRunResult(
2118
+ ok=False,
2119
+ skill_id=skill_id,
2120
+ skill_doc=None,
2121
+ run_log=None,
2122
+ errors=[f"invalid workflow_protocol for skill run: {workflow_protocol}"],
2123
+ )
2124
+ raw_capability_refs = parsed.get("capability_refs", [])
2125
+ capability_refs = [str(ref) for ref in raw_capability_refs] if isinstance(raw_capability_refs, list) else []
2126
+ capability = str(parsed.get("capability") or "not declared")
2127
+ tuning = str(parsed.get("tuning") or "not declared")
2128
+ role_responsibilities = _parse_key_value_list(parsed.get("role_responsibilities"))
2129
+ raw_skill_doc = str(parsed.get("skill_doc"))
2130
+ skill_doc_path = (meta_path.parent / raw_skill_doc).resolve()
2131
+ if not skill_doc_path.exists():
2132
+ return SkillRunResult(
2133
+ ok=False,
2134
+ skill_id=skill_id,
2135
+ skill_doc=str(skill_doc_path),
2136
+ run_log=None,
2137
+ errors=[f"skill_doc not found: {skill_doc_path}"],
2138
+ )
2139
+ os_contract = resolve_os_contract(parsed.get("os_contract"))
2140
+ if maturity == "trial":
2141
+ merged_os_contract = dict(REQUIRED_OS_CONTRACT)
2142
+ merged_os_contract.update(os_contract)
2143
+ os_contract = merged_os_contract
2144
+ validated_handoff_sources, handoff_source_errors = _validate_handoff_sources(root, handoff_source_logs)
2145
+ if handoff_source_errors:
2146
+ return SkillRunResult(
2147
+ ok=False,
2148
+ skill_id=skill_id,
2149
+ skill_doc=None,
2150
+ run_log=None,
2151
+ errors=handoff_source_errors,
2152
+ handoff_sources=validated_handoff_sources,
2153
+ )
2154
+ raw_model_tier = parsed.get("model_tier")
2155
+ model_tier = str(raw_model_tier) if raw_model_tier else None
2156
+ assigned_roles = _assign_runtime_roles(
2157
+ skill_id=skill_id, execution_mode=execution_mode, model_tier=model_tier
2158
+ )
2159
+
2160
+ out_path = Path(args.out) if args.out else _default_log_path(root, skill_id)
2161
+ if not out_path.is_absolute():
2162
+ out_path = root / out_path
2163
+ out_path.parent.mkdir(parents=True, exist_ok=True)
2164
+
2165
+ raw_run_id = str(getattr(args, "run_id", None) or uuid.uuid4())
2166
+ try:
2167
+ run_id = str(uuid.UUID(raw_run_id))
2168
+ except ValueError:
2169
+ return SkillRunResult(
2170
+ ok=False,
2171
+ skill_id=skill_id,
2172
+ skill_doc=None,
2173
+ run_log=None,
2174
+ errors=[f"run_id must be a UUID: {raw_run_id}"],
2175
+ )
2176
+ sessions_dir = root / "work" / "sessions"
2177
+ if sessions_dir.exists():
2178
+ for existing_log in sessions_dir.rglob("*.md"):
2179
+ try:
2180
+ existing_text = existing_log.read_text(encoding="utf-8")
2181
+ except (OSError, UnicodeError):
2182
+ continue
2183
+ if _log_field(existing_text, "run_id") == run_id:
2184
+ return SkillRunResult(
2185
+ ok=False,
2186
+ skill_id=skill_id,
2187
+ skill_doc=None,
2188
+ run_log=None,
2189
+ errors=[f"run_id is already used by an existing Skill Run: {existing_log}"],
2190
+ )
2191
+ log = _render_log(
2192
+ run_id=run_id,
2193
+ skill_id=skill_id,
2194
+ maturity=maturity,
2195
+ meta_path=meta_path.relative_to(root),
2196
+ skill_doc=skill_doc_path.relative_to(root),
2197
+ execution_mode=execution_mode,
2198
+ guard_policy=guard_policy,
2199
+ capability_layering=capability_layering,
2200
+ workflow_protocol=workflow_protocol,
2201
+ capability=capability,
2202
+ tuning=tuning,
2203
+ role_responsibilities=role_responsibilities,
2204
+ capability_refs=capability_refs,
2205
+ assigned_roles=assigned_roles,
2206
+ task=str(task),
2207
+ os_contract=os_contract,
2208
+ handoff_sources=validated_handoff_sources,
2209
+ model_tier=model_tier,
2210
+ domain_knowledge=domain_knowledge,
2211
+ )
2212
+ with _LogFileLock(out_path.with_name(f".{out_path.name}.lock")):
2213
+ _atomic_write_text(out_path, log)
2214
+
2215
+ return SkillRunResult(
2216
+ ok=True,
2217
+ skill_id=skill_id,
2218
+ skill_doc=str(skill_doc_path),
2219
+ run_log=str(out_path),
2220
+ errors=[],
2221
+ assigned_roles=assigned_roles,
2222
+ handoff_sources=validated_handoff_sources,
2223
+ domain_knowledge=domain_knowledge,
2224
+ run_id=run_id,
2225
+ )
2226
+
2227
+
2228
+ def cmd_skill_run(args) -> int:
2229
+ result = run_skill(args)
2230
+ if args.json:
2231
+ print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2))
2232
+ elif result.ok:
2233
+ print(f"ok: {result.run_log}")
2234
+ print(f" run_id: {result.run_id}")
2235
+ print(f" skill_id: {result.skill_id}")
2236
+ print(f" skill_doc: {result.skill_doc}")
2237
+ for key, value in (result.assigned_roles or {}).items():
2238
+ print(f" {key}: {value}")
2239
+ print(" next: open the Skill procedure from skill_doc and keep updating run_log with skill phase")
2240
+ else:
2241
+ print("fail: skill run")
2242
+ if result.skill_id:
2243
+ print(f" skill_id: {result.skill_id}")
2244
+ for error in result.errors:
2245
+ print(f" error: {error}")
2246
+ return 0 if result.ok else 1
2247
+
2248
+
2249
+ def cmd_skill_phase(args) -> int:
2250
+ result = update_skill_phase(args)
2251
+ if args.json:
2252
+ print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2))
2253
+ elif result.ok:
2254
+ print(f"ok: {result.run_log}")
2255
+ print(f" phase: {args.phase}")
2256
+ print(f" status: {args.status}")
2257
+ else:
2258
+ print("fail: skill phase")
2259
+ for error in result.errors:
2260
+ print(f" error: {error}")
2261
+ return 0 if result.ok else 1
2262
+
2263
+
2264
+ def cmd_skill_workitem(args) -> int:
2265
+ result = update_work_item(args)
2266
+ if args.json:
2267
+ print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2))
2268
+ elif result.ok:
2269
+ print(f"ok: {result.run_log}")
2270
+ print(f" item: {args.item}")
2271
+ print(f" status: {args.status}")
2272
+ else:
2273
+ print("fail: skill workitem")
2274
+ for error in result.errors:
2275
+ print(f" error: {error}")
2276
+ return 0 if result.ok else 1
2277
+
2278
+
2279
+ def cmd_skill_artifact(args) -> int:
2280
+ result = update_artifact(args)
2281
+ if args.json:
2282
+ print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2))
2283
+ elif result.ok:
2284
+ print(f"ok: {result.run_log}")
2285
+ print(f" artifact: {args.artifact}")
2286
+ print(f" kind: {args.kind}")
2287
+ print(f" status: {args.status}")
2288
+ else:
2289
+ print("fail: skill artifact")
2290
+ for error in result.errors:
2291
+ print(f" error: {error}")
2292
+ return 0 if result.ok else 1
2293
+
2294
+
2295
+ def cmd_skill_concern(args) -> int:
2296
+ result = update_concern(args)
2297
+ if args.json:
2298
+ print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2))
2299
+ elif result.ok:
2300
+ print(f"ok: {result.run_log}")
2301
+ print(f" concern: {args.concern}")
2302
+ print(f" kind: {args.kind}")
2303
+ print(f" status: {args.status}")
2304
+ else:
2305
+ print("fail: skill concern")
2306
+ for error in result.errors:
2307
+ print(f" error: {error}")
2308
+ return 0 if result.ok else 1
2309
+
2310
+
2311
+ def cmd_skill_close(args) -> int:
2312
+ result = close_skill_run(args)
2313
+ if args.json:
2314
+ print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2))
2315
+ elif result.ok:
2316
+ print(f"ok: {result.run_log}")
2317
+ print(" closure: accepted")
2318
+ else:
2319
+ print("fail: skill close")
2320
+ for error in result.errors:
2321
+ print(f" error: {error}")
2322
+ return 0 if result.ok else 1
2323
+
2324
+
2325
+ def cmd_skill_tokens(args) -> int:
2326
+ result = update_token_usage(args)
2327
+ if args.json:
2328
+ print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2))
2329
+ elif result.ok:
2330
+ print(f"ok: {result.run_log}")
2331
+ print(" token usage: recorded")
2332
+ else:
2333
+ print("fail: skill tokens")
2334
+ for error in result.errors:
2335
+ print(f" error: {error}")
2336
+ return 0 if result.ok else 1
2337
+
2338
+
2339
+ def cmd_skill_verify(args) -> int:
2340
+ result = verify_progression_run(args)
2341
+ if args.json:
2342
+ print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2))
2343
+ elif result.ok:
2344
+ print(f"ok: {result.run_log}")
2345
+ print(" check: progression verified")
2346
+ else:
2347
+ print("fail: skill verify")
2348
+ for error in result.errors:
2349
+ print(f" error: {error}")
2350
+ return 0 if result.ok else 1
2351
+
2352
+
2353
+ def _cmd_observation(args, updater, label: str) -> int:
2354
+ result = updater(args)
2355
+ if args.json:
2356
+ print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2))
2357
+ elif result.ok:
2358
+ print(f"ok: {result.run_log}")
2359
+ print(f" run_id: {result.run_id}")
2360
+ print(f" observation: {label}")
2361
+ else:
2362
+ print(f"fail: skill {label}")
2363
+ for error in result.errors:
2364
+ print(f" error: {error}")
2365
+ return 0 if result.ok else 1
2366
+
2367
+
2368
+ def cmd_skill_correlate(args) -> int:
2369
+ return _cmd_observation(args, correlate_skill_run, "correlate")
2370
+
2371
+
2372
+ def cmd_skill_routing(args) -> int:
2373
+ return _cmd_observation(args, update_skill_routing, "routing")
2374
+
2375
+
2376
+ def cmd_skill_knowledge(args) -> int:
2377
+ return _cmd_observation(args, update_knowledge_observation, "knowledge")
2378
+
2379
+
2380
+ def cmd_skill_feedback(args) -> int:
2381
+ return _cmd_observation(args, update_feedback_observation, "feedback")