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.
- xrefkit/__init__.py +5 -0
- xrefkit/__main__.py +5 -0
- xrefkit/catalog_cli.py +57 -0
- xrefkit/cli.py +71 -0
- xrefkit/contracts.py +297 -0
- xrefkit/ctx.py +160 -0
- xrefkit/dashboard.py +1220 -0
- xrefkit/discovery.py +85 -0
- xrefkit/gate.py +428 -0
- xrefkit/goalstate.py +555 -0
- xrefkit/hashing.py +18 -0
- xrefkit/import_skill.py +469 -0
- xrefkit/instance.py +133 -0
- xrefkit/loaders.py +77 -0
- xrefkit/mcp/__init__.py +26 -0
- xrefkit/mcp/audit.py +168 -0
- xrefkit/mcp/bootstrap.py +337 -0
- xrefkit/mcp/catalog.py +2638 -0
- xrefkit/mcp/cli.py +173 -0
- xrefkit/mcp/client_cache.py +356 -0
- xrefkit/mcp/context_registry.py +277 -0
- xrefkit/mcp/contracts.py +243 -0
- xrefkit/mcp/dist.py +234 -0
- xrefkit/mcp/ownership.py +246 -0
- xrefkit/mcp/repository.py +217 -0
- xrefkit/mcp/schemas.py +349 -0
- xrefkit/mcp/server.py +773 -0
- xrefkit/mcp/startup_contract_pack.py +154 -0
- xrefkit/mcp_tools.py +47 -0
- xrefkit/models/__init__.py +41 -0
- xrefkit/models/common.py +185 -0
- xrefkit/models/effective_bundle.py +131 -0
- xrefkit/models/local_manifest.py +217 -0
- xrefkit/models/package_manifest.py +126 -0
- xrefkit/models/run_log.py +276 -0
- xrefkit/models/server_config.py +160 -0
- xrefkit/models/skill_definition.py +131 -0
- xrefkit/operations_cli.py +670 -0
- xrefkit/ownership.py +276 -0
- xrefkit/packmeta.py +289 -0
- xrefkit/registry.py +334 -0
- xrefkit/resolver.py +252 -0
- xrefkit/resource_provider.py +187 -0
- xrefkit/resources/base/contracts.json +178 -0
- xrefkit/resources/base/current.json +6 -0
- xrefkit/resources/base/generations/7a682a5272907354/contracts.json +178 -0
- xrefkit/resources/base/generations/7a682a5272907354/model_body.md +22 -0
- xrefkit/resources/base/generations/9929294385ccb7b0/contracts.json +178 -0
- xrefkit/resources/base/generations/9929294385ccb7b0/model_body.md +22 -0
- xrefkit/resources/base/model_body.md +22 -0
- xrefkit/runlog.py +45 -0
- xrefkit/skillmeta.py +1034 -0
- xrefkit/skillrun.py +2381 -0
- xrefkit/structure_catalog.py +199 -0
- xrefkit/tools/__init__.py +119 -0
- xrefkit/tools/__main__.py +4 -0
- xrefkit/v2_cli.py +130 -0
- xrefkit/workspace.py +117 -0
- xrefkit/xref.py +1048 -0
- xrefkit-0.3.0.dist-info/METADATA +203 -0
- xrefkit-0.3.0.dist-info/RECORD +65 -0
- xrefkit-0.3.0.dist-info/WHEEL +5 -0
- xrefkit-0.3.0.dist-info/entry_points.txt +2 -0
- xrefkit-0.3.0.dist-info/licenses/LICENSE +21 -0
- 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")
|