cowork-flow 0.0.37 → 0.0.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -14
- package/package.json +1 -1
- package/src/commands/init.js +39 -28
- package/src/commands/sync.js +2 -2
- package/src/lib/asset-plan.js +127 -0
- package/src/lib/copy-template.js +101 -141
- package/src/lib/host-assets.js +231 -0
- package/src/lib/plan-applier.js +329 -0
- package/src/lib/platforms.js +15 -76
- package/src/lib/skill-registry.js +503 -0
- package/template/.claude/agents/cowork-check.md +10 -1
- package/template/.claude/agents/cowork-implement.md +11 -2
- package/template/.claude/hooks/inject-workflow-state.py +51 -254
- package/template/.codex/agents/cowork-check.toml +12 -3
- package/template/.codex/agents/cowork-implement.toml +11 -2
- package/template/.codex/agents/default.toml +3 -2
- package/template/.codex/agents/explorer.toml +3 -2
- package/template/.codex/agents/worker.toml +2 -1
- package/template/.codex/hooks/inject-workflow-state.py +46 -265
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/application/__init__.py +1 -0
- package/template/.cowork-flow/scripts/application/batch_execution.py +743 -0
- package/template/.cowork-flow/scripts/application/runtime_context_service.py +365 -0
- package/template/.cowork-flow/scripts/application/task_archive.py +220 -0
- package/template/.cowork-flow/scripts/application/task_context.py +765 -0
- package/template/.cowork-flow/scripts/application/task_creation.py +201 -0
- package/template/.cowork-flow/scripts/application/task_lifecycle.py +532 -0
- package/template/.cowork-flow/scripts/application/task_tree.py +179 -0
- package/template/.cowork-flow/scripts/commands/doctor.py +78 -57
- package/template/.cowork-flow/scripts/commands/party_mode_v2.py +671 -425
- package/template/.cowork-flow/scripts/commands/subagent.py +38 -26
- package/template/.cowork-flow/scripts/commands/task.py +535 -1953
- package/template/.cowork-flow/scripts/commands/task_archive_commands.py +195 -0
- package/template/.cowork-flow/scripts/commands/task_context_commands.py +240 -0
- package/template/.cowork-flow/scripts/commands/task_create_command.py +164 -0
- package/template/.cowork-flow/scripts/commands/task_navigation.py +407 -0
- package/template/.cowork-flow/scripts/commands/task_parser.py +364 -0
- package/template/.cowork-flow/scripts/commands/task_support.py +76 -0
- package/template/.cowork-flow/scripts/commands/task_tree_commands.py +160 -0
- package/template/.cowork-flow/scripts/common/core/execution_context.py +161 -85
- package/template/.cowork-flow/scripts/common/core/host_manifest.py +393 -0
- package/template/.cowork-flow/scripts/common/core/skill_registry.py +568 -0
- package/template/.cowork-flow/scripts/common/gates/coding_standards.py +58 -7
- package/template/.cowork-flow/scripts/common/gates/gates.py +323 -46
- package/template/.cowork-flow/scripts/common/gates/models.py +196 -0
- package/template/.cowork-flow/scripts/common/gates/registry.py +79 -0
- package/template/.cowork-flow/scripts/common/gates/tdd_evidence.py +13 -13
- package/template/.cowork-flow/scripts/common/gates/validate_coding_standards.py +104 -62
- package/template/.cowork-flow/scripts/common/gates/validate_implementation.py +39 -22
- package/template/.cowork-flow/scripts/common/gates/validate_rules.py +223 -40
- package/template/.cowork-flow/scripts/common/git/git_context.py +216 -182
- package/template/.cowork-flow/scripts/common/host/__init__.py +1 -0
- package/template/.cowork-flow/scripts/common/host/workflow_state_hook.py +303 -0
- package/template/.cowork-flow/scripts/common/storage/__init__.py +1 -0
- package/template/.cowork-flow/scripts/common/storage/operation_log.py +72 -0
- package/template/.cowork-flow/scripts/common/storage/state_store.py +279 -0
- package/template/.cowork-flow/scripts/common/storage/unit_of_work.py +214 -0
- package/template/.cowork-flow/scripts/common/task/active_task.py +93 -135
- package/template/.cowork-flow/scripts/common/task/batch_mode.py +33 -120
- package/template/.cowork-flow/scripts/common/task/decision_review.py +85 -0
- package/template/.cowork-flow/scripts/common/task/readiness.py +9 -15
- package/template/.cowork-flow/scripts/common/task/task_repository.py +156 -0
- package/template/.cowork-flow/scripts/run.py +24 -0
- package/template/.cowork-flow/spec/contracts/decision-anchor.md +8 -2
- package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +2 -4
- package/template/.cowork-flow/spec/contracts/workflow-state-templates.md +1 -1
- package/template/.cowork-flow/spec/protocols/decision-review.md +28 -0
- package/template/.cowork-flow/spec/protocols/review.md +34 -0
- package/template/.cowork-flow/spec/protocols/spec-maintenance.md +16 -0
- package/template/.cowork-flow/spec/protocols/tdd.md +30 -0
- package/template/.cowork-flow/spec/references/definition-of-done.md +4 -4
- package/template/.cowork-flow/spec/references/orchestration-patterns.md +1 -1
- package/template/.cowork-flow/spec/runtime/contract-registry.json +25 -0
- package/template/.cowork-flow/spec/runtime/host-assets.json +214 -0
- package/template/.cowork-flow/spec/runtime/index.md +8 -0
- package/template/.cowork-flow/spec/runtime/rules.json +61 -10
- package/template/.cowork-flow/spec/runtime/skill-registry.json +397 -0
- package/template/.cowork-flow/spec/schemas/host-assets.schema.json +173 -0
- package/template/.cowork-flow/spec/schemas/rules.schema.json +10 -1
- package/template/.cowork-flow/spec/schemas/skill-registry.schema.json +202 -0
- package/template/.cowork-flow/workflow.md +18 -13
- package/template/.opencode/agents/cowork-check.md +11 -0
- package/template/.opencode/agents/cowork-implement.md +16 -1
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/.zcode/hooks/inject-context.js +3 -85
- package/template/.zcode/hooks/inject-context.selfcheck.mjs +6 -6
- package/template/.zcode/scaffold/.cowork-flow/spec/contracts/decision-anchor.md +8 -2
- package/template/.zcode/scaffold/.cowork-flow/spec/contracts/subagent-dispatch.md +2 -4
- package/template/.zcode/scaffold/.cowork-flow/spec/runtime/rules.json +61 -10
- package/template/.zcode/scaffold/.cowork-flow/spec/schemas/rules.schema.json +10 -1
- package/template/.zcode/scaffold/.cowork-flow/workflow.md +18 -13
- package/template/.zcode/scaffold/AGENTS.md +18 -8
- package/template/AGENTS.md +1 -1
- package/template/skills/batch-mode/SKILL.md +140 -76
- package/template/skills/brainstorming/SKILL.md +2 -2
- package/template/skills/cowork-flow/SKILL.md +42 -0
- package/template/skills/party-mode/SHARED-BOUNDARIES.md +1 -1
- package/template/skills/party-mode/SKILL.md +48 -119
- package/template/.cowork-flow/scripts/common/gates/spec_validator.py +0 -53
- package/template/.cowork-flow/scripts/common/gates/validate_jsonl.py +0 -32
- package/template/skills/before-dev/SKILL.md +0 -41
- package/template/skills/check/SKILL.md +0 -53
- package/template/skills/check/simplification-guide.md +0 -47
- package/template/skills/continue/SKILL.md +0 -29
- package/template/skills/doubt-review/SKILL.md +0 -118
- package/template/skills/finish-work/SKILL.md +0 -32
- package/template/skills/party-mode-v2/SKILL.md +0 -75
- package/template/skills/start/SKILL.md +0 -78
- package/template/skills/tdd/SKILL.md +0 -74
- package/template/skills/update-spec/SKILL.md +0 -39
- package/template/skills/using-cowork-flow/SKILL.md +0 -80
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from fnmatch import fnmatchcase
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
REGISTRY_PATH = Path(".cowork-flow/spec/runtime/skill-registry.json")
|
|
12
|
+
KINDS = {"phase", "protocol", "domain", "mode", "runtime"}
|
|
13
|
+
VISIBILITIES = {"public", "internal"}
|
|
14
|
+
ENTRY_STATUSES = {"active", "disabled"}
|
|
15
|
+
WORKFLOW_STATUSES = {
|
|
16
|
+
"no_task",
|
|
17
|
+
"planning",
|
|
18
|
+
"in_progress",
|
|
19
|
+
"review",
|
|
20
|
+
"completed",
|
|
21
|
+
"delegated_subtask",
|
|
22
|
+
}
|
|
23
|
+
ENFORCEMENTS = {"advisory", "mandatory", "runtime"}
|
|
24
|
+
ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
25
|
+
INTENT_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
26
|
+
REQUIRED_FIELDS = {
|
|
27
|
+
"id",
|
|
28
|
+
"displayName",
|
|
29
|
+
"aliases",
|
|
30
|
+
"kind",
|
|
31
|
+
"visibility",
|
|
32
|
+
"status",
|
|
33
|
+
"statuses",
|
|
34
|
+
"intents",
|
|
35
|
+
"enforcement",
|
|
36
|
+
"runtimeGate",
|
|
37
|
+
"runtimeCommand",
|
|
38
|
+
"evidenceArtifact",
|
|
39
|
+
"source",
|
|
40
|
+
"managedPaths",
|
|
41
|
+
}
|
|
42
|
+
ALLOWED_FIELDS = REQUIRED_FIELDS | {"readWhen"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SkillRegistryError(ValueError):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class SkillEntry:
|
|
51
|
+
id: str
|
|
52
|
+
display_name: str
|
|
53
|
+
aliases: tuple[str, ...]
|
|
54
|
+
kind: str
|
|
55
|
+
visibility: str
|
|
56
|
+
status: str
|
|
57
|
+
statuses: tuple[str, ...]
|
|
58
|
+
intents: tuple[str, ...]
|
|
59
|
+
enforcement: str
|
|
60
|
+
runtime_gate: str | None
|
|
61
|
+
runtime_command: str | None
|
|
62
|
+
evidence_artifact: str | None
|
|
63
|
+
source: str
|
|
64
|
+
managed_paths: tuple[str, ...]
|
|
65
|
+
read_when_dev_types: tuple[str, ...]
|
|
66
|
+
read_when_path_patterns: tuple[str, ...]
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict[str, Any]:
|
|
69
|
+
return {
|
|
70
|
+
"id": self.id,
|
|
71
|
+
"displayName": self.display_name,
|
|
72
|
+
"aliases": list(self.aliases),
|
|
73
|
+
"kind": self.kind,
|
|
74
|
+
"visibility": self.visibility,
|
|
75
|
+
"status": self.status,
|
|
76
|
+
"statuses": list(self.statuses),
|
|
77
|
+
"intents": list(self.intents),
|
|
78
|
+
"enforcement": self.enforcement,
|
|
79
|
+
"runtimeGate": self.runtime_gate,
|
|
80
|
+
"runtimeCommand": self.runtime_command,
|
|
81
|
+
"evidenceArtifact": self.evidence_artifact,
|
|
82
|
+
"source": self.source,
|
|
83
|
+
"managedPaths": list(self.managed_paths),
|
|
84
|
+
"readWhen": {
|
|
85
|
+
"devTypes": list(self.read_when_dev_types),
|
|
86
|
+
"pathPatterns": list(self.read_when_path_patterns),
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class SkillRegistry:
|
|
93
|
+
schema_version: int
|
|
94
|
+
entries: tuple[SkillEntry, ...]
|
|
95
|
+
_by_id: dict[str, SkillEntry] = field(init=False, repr=False)
|
|
96
|
+
_aliases: dict[str, str] = field(init=False, repr=False)
|
|
97
|
+
|
|
98
|
+
def __post_init__(self) -> None:
|
|
99
|
+
object.__setattr__(self, "_by_id", {entry.id: entry for entry in self.entries})
|
|
100
|
+
object.__setattr__(
|
|
101
|
+
self,
|
|
102
|
+
"_aliases",
|
|
103
|
+
{
|
|
104
|
+
alias: entry.id
|
|
105
|
+
for entry in self.entries
|
|
106
|
+
for alias in entry.aliases
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def public_entries(self) -> tuple[SkillEntry, ...]:
|
|
112
|
+
return tuple(
|
|
113
|
+
entry
|
|
114
|
+
for entry in self.entries
|
|
115
|
+
if entry.visibility == "public" and entry.status == "active"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def public_skill_ids(self) -> tuple[str, ...]:
|
|
120
|
+
return tuple(entry.id for entry in self.public_entries)
|
|
121
|
+
|
|
122
|
+
def entry(self, id_or_alias: str) -> SkillEntry:
|
|
123
|
+
entry_id = (
|
|
124
|
+
id_or_alias
|
|
125
|
+
if id_or_alias in self._by_id
|
|
126
|
+
else self._aliases.get(id_or_alias)
|
|
127
|
+
)
|
|
128
|
+
if entry_id is None:
|
|
129
|
+
raise SkillRegistryError(f"unknown Skill Registry entry: {id_or_alias}")
|
|
130
|
+
return self._by_id[entry_id]
|
|
131
|
+
|
|
132
|
+
def domain_entries_for(
|
|
133
|
+
self,
|
|
134
|
+
*,
|
|
135
|
+
dev_type: str | None,
|
|
136
|
+
paths: tuple[str, ...] | list[str],
|
|
137
|
+
) -> tuple[SkillEntry, ...]:
|
|
138
|
+
normalized_dev_type = (dev_type or "").strip()
|
|
139
|
+
normalized_paths = tuple(
|
|
140
|
+
str(path).replace("\\", "/").removeprefix("./")
|
|
141
|
+
for path in paths
|
|
142
|
+
)
|
|
143
|
+
return tuple(
|
|
144
|
+
entry
|
|
145
|
+
for entry in self.entries
|
|
146
|
+
if entry.kind == "domain"
|
|
147
|
+
and entry.status == "active"
|
|
148
|
+
and (
|
|
149
|
+
normalized_dev_type in entry.read_when_dev_types
|
|
150
|
+
or any(
|
|
151
|
+
fnmatchcase(path, pattern)
|
|
152
|
+
for path in normalized_paths
|
|
153
|
+
for pattern in entry.read_when_path_patterns
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def to_dict(self) -> dict[str, Any]:
|
|
159
|
+
return {
|
|
160
|
+
"schemaVersion": self.schema_version,
|
|
161
|
+
"entries": [entry.to_dict() for entry in self.entries],
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def load_skill_registry(
|
|
166
|
+
template_root: Path,
|
|
167
|
+
*,
|
|
168
|
+
validate_sources: bool = True,
|
|
169
|
+
) -> SkillRegistry:
|
|
170
|
+
path = Path(template_root) / REGISTRY_PATH
|
|
171
|
+
try:
|
|
172
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
173
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
174
|
+
raise SkillRegistryError(
|
|
175
|
+
f"unable to read Skill Registry {path}: {exc}"
|
|
176
|
+
) from exc
|
|
177
|
+
return create_skill_registry(
|
|
178
|
+
raw,
|
|
179
|
+
template_root,
|
|
180
|
+
validate_sources=validate_sources,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def create_skill_registry(
|
|
185
|
+
raw: dict[str, Any],
|
|
186
|
+
template_root: Path,
|
|
187
|
+
*,
|
|
188
|
+
validate_sources: bool = True,
|
|
189
|
+
) -> SkillRegistry:
|
|
190
|
+
_validate_root(raw)
|
|
191
|
+
entries = _normalize_entries(raw["entries"], Path(template_root), validate_sources)
|
|
192
|
+
by_id = _validate_unique_tokens(entries)
|
|
193
|
+
_validate_entry_semantics(entries, by_id)
|
|
194
|
+
_validate_managed_paths(entries)
|
|
195
|
+
return SkillRegistry(schema_version=1, entries=entries)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _normalize_entries(
|
|
199
|
+
raw_entries: list[Any],
|
|
200
|
+
template_root: Path,
|
|
201
|
+
validate_sources: bool,
|
|
202
|
+
) -> tuple[SkillEntry, ...]:
|
|
203
|
+
return tuple(
|
|
204
|
+
sorted(
|
|
205
|
+
(
|
|
206
|
+
_normalize_entry(
|
|
207
|
+
entry,
|
|
208
|
+
template_root,
|
|
209
|
+
validate_sources=validate_sources,
|
|
210
|
+
)
|
|
211
|
+
for entry in raw_entries
|
|
212
|
+
),
|
|
213
|
+
key=lambda entry: entry.id,
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _validate_unique_tokens(entries: tuple[SkillEntry, ...]) -> dict[str, SkillEntry]:
|
|
219
|
+
by_id: dict[str, SkillEntry] = {}
|
|
220
|
+
tokens: set[str] = set()
|
|
221
|
+
for entry in entries:
|
|
222
|
+
for token in (entry.id, *entry.aliases):
|
|
223
|
+
if token in tokens:
|
|
224
|
+
raise SkillRegistryError(f"duplicate skill id or alias: {token}")
|
|
225
|
+
tokens.add(token)
|
|
226
|
+
if token == entry.id:
|
|
227
|
+
by_id[token] = entry
|
|
228
|
+
return by_id
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _validate_entry_semantics(
|
|
232
|
+
entries: tuple[SkillEntry, ...],
|
|
233
|
+
by_id: dict[str, SkillEntry],
|
|
234
|
+
) -> None:
|
|
235
|
+
public_intents: set[str] = set()
|
|
236
|
+
for entry in entries:
|
|
237
|
+
_validate_runtime_gate(entry, by_id)
|
|
238
|
+
_validate_runtime_enforcement(entry)
|
|
239
|
+
_validate_public_intents(entry, public_intents)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _validate_runtime_gate(
|
|
243
|
+
entry: SkillEntry,
|
|
244
|
+
by_id: dict[str, SkillEntry],
|
|
245
|
+
) -> None:
|
|
246
|
+
if entry.enforcement == "mandatory" and entry.runtime_gate is None:
|
|
247
|
+
raise SkillRegistryError(f"mandatory entry {entry.id} requires runtimeGate")
|
|
248
|
+
if entry.runtime_gate is None:
|
|
249
|
+
return
|
|
250
|
+
gate = by_id.get(entry.runtime_gate)
|
|
251
|
+
if gate is None or gate.kind != "runtime":
|
|
252
|
+
raise SkillRegistryError(
|
|
253
|
+
f"runtimeGate {entry.runtime_gate} must reference a runtime entry"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _validate_runtime_enforcement(entry: SkillEntry) -> None:
|
|
258
|
+
if entry.kind == "runtime":
|
|
259
|
+
if entry.visibility != "internal" or entry.enforcement != "runtime":
|
|
260
|
+
raise SkillRegistryError(
|
|
261
|
+
f"runtime entry {entry.id} must be internal with runtime enforcement"
|
|
262
|
+
)
|
|
263
|
+
elif entry.enforcement == "runtime":
|
|
264
|
+
raise SkillRegistryError(
|
|
265
|
+
f"non-runtime entry {entry.id} cannot use runtime enforcement"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _validate_public_intents(
|
|
270
|
+
entry: SkillEntry,
|
|
271
|
+
public_intents: set[str],
|
|
272
|
+
) -> None:
|
|
273
|
+
if entry.visibility != "public" or entry.status != "active":
|
|
274
|
+
return
|
|
275
|
+
for intent in entry.intents:
|
|
276
|
+
if intent in public_intents:
|
|
277
|
+
raise SkillRegistryError(f"duplicate public intent: {intent}")
|
|
278
|
+
public_intents.add(intent)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _validate_root(raw: Any) -> None:
|
|
282
|
+
if not isinstance(raw, dict):
|
|
283
|
+
raise SkillRegistryError("Skill Registry must be an object")
|
|
284
|
+
for key in raw:
|
|
285
|
+
if key not in {"schemaVersion", "entries"}:
|
|
286
|
+
raise SkillRegistryError(f"unexpected Skill Registry field: {key}")
|
|
287
|
+
if raw.get("schemaVersion") != 1:
|
|
288
|
+
raise SkillRegistryError(
|
|
289
|
+
"unsupported Skill Registry schemaVersion: "
|
|
290
|
+
f"{raw.get('schemaVersion')}"
|
|
291
|
+
)
|
|
292
|
+
entries = raw.get("entries")
|
|
293
|
+
if not isinstance(entries, list) or not entries:
|
|
294
|
+
raise SkillRegistryError(
|
|
295
|
+
"Skill Registry entries must be a non-empty array"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _normalize_entry(
|
|
300
|
+
raw: Any,
|
|
301
|
+
template_root: Path,
|
|
302
|
+
*,
|
|
303
|
+
validate_sources: bool,
|
|
304
|
+
) -> SkillEntry:
|
|
305
|
+
entry_id = _validate_entry_shape(raw)
|
|
306
|
+
display_name = _display_name(raw["displayName"], entry_id)
|
|
307
|
+
kind = _enum(raw["kind"], KINDS, entry_id, "kind")
|
|
308
|
+
runtime_fields = _normalize_runtime_fields(raw, entry_id)
|
|
309
|
+
source = _normalize_source(
|
|
310
|
+
raw["source"],
|
|
311
|
+
entry_id,
|
|
312
|
+
template_root,
|
|
313
|
+
validate_sources=validate_sources,
|
|
314
|
+
)
|
|
315
|
+
read_when_dev_types, read_when_path_patterns = _normalize_read_when(
|
|
316
|
+
raw.get("readWhen"),
|
|
317
|
+
entry_id,
|
|
318
|
+
kind,
|
|
319
|
+
)
|
|
320
|
+
return SkillEntry(
|
|
321
|
+
id=entry_id,
|
|
322
|
+
display_name=display_name,
|
|
323
|
+
aliases=_aliases(raw["aliases"], entry_id),
|
|
324
|
+
kind=kind,
|
|
325
|
+
visibility=_enum(raw["visibility"], VISIBILITIES, entry_id, "visibility"),
|
|
326
|
+
status=_enum(raw["status"], ENTRY_STATUSES, entry_id, "status"),
|
|
327
|
+
statuses=_workflow_statuses(raw["statuses"], entry_id),
|
|
328
|
+
intents=_intents(raw["intents"], entry_id),
|
|
329
|
+
enforcement=runtime_fields["enforcement"],
|
|
330
|
+
runtime_gate=runtime_fields["runtime_gate"],
|
|
331
|
+
runtime_command=runtime_fields["runtime_command"],
|
|
332
|
+
evidence_artifact=runtime_fields["evidence_artifact"],
|
|
333
|
+
source=source,
|
|
334
|
+
managed_paths=_managed_paths(raw["managedPaths"], entry_id),
|
|
335
|
+
read_when_dev_types=read_when_dev_types,
|
|
336
|
+
read_when_path_patterns=read_when_path_patterns,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _validate_entry_shape(raw: Any) -> str:
|
|
341
|
+
if not isinstance(raw, dict):
|
|
342
|
+
raise SkillRegistryError("Skill Registry entry must be an object")
|
|
343
|
+
entry_id = raw.get("id") if isinstance(raw.get("id"), str) else "<unknown>"
|
|
344
|
+
for name in REQUIRED_FIELDS:
|
|
345
|
+
if name not in raw:
|
|
346
|
+
raise SkillRegistryError(
|
|
347
|
+
f"entry {entry_id} missing required field: {name}"
|
|
348
|
+
)
|
|
349
|
+
for name in raw:
|
|
350
|
+
if name not in ALLOWED_FIELDS:
|
|
351
|
+
raise SkillRegistryError(f"unexpected field for {entry_id}: {name}")
|
|
352
|
+
if not isinstance(raw["id"], str) or not ID_PATTERN.fullmatch(raw["id"]):
|
|
353
|
+
raise SkillRegistryError(f"invalid id: {raw['id']}")
|
|
354
|
+
return raw["id"]
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _display_name(value: Any, entry_id: str) -> str:
|
|
358
|
+
if not isinstance(value, str) or not value.strip():
|
|
359
|
+
raise SkillRegistryError(
|
|
360
|
+
f"displayName for {entry_id} must be a non-empty string"
|
|
361
|
+
)
|
|
362
|
+
return value.strip()
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _aliases(value: Any, entry_id: str) -> tuple[str, ...]:
|
|
366
|
+
aliases = _string_tuple(value, entry_id, "aliases")
|
|
367
|
+
for alias in aliases:
|
|
368
|
+
if not ID_PATTERN.fullmatch(alias):
|
|
369
|
+
raise SkillRegistryError(
|
|
370
|
+
f"invalid alias for {entry_id}: {alias}"
|
|
371
|
+
)
|
|
372
|
+
return tuple(sorted(set(aliases)))
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _workflow_statuses(value: Any, entry_id: str) -> tuple[str, ...]:
|
|
376
|
+
statuses = _string_tuple(value, entry_id, "statuses")
|
|
377
|
+
for workflow_status in statuses:
|
|
378
|
+
if workflow_status not in WORKFLOW_STATUSES:
|
|
379
|
+
raise SkillRegistryError(
|
|
380
|
+
f"invalid workflow status for {entry_id}: {workflow_status}"
|
|
381
|
+
)
|
|
382
|
+
return tuple(sorted(set(statuses)))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _intents(value: Any, entry_id: str) -> tuple[str, ...]:
|
|
386
|
+
intents = _string_tuple(value, entry_id, "intents")
|
|
387
|
+
for intent in intents:
|
|
388
|
+
if not INTENT_PATTERN.fullmatch(intent):
|
|
389
|
+
raise SkillRegistryError(
|
|
390
|
+
f"invalid intent for {entry_id}: {intent}"
|
|
391
|
+
)
|
|
392
|
+
return tuple(sorted(set(intents)))
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _normalize_runtime_fields(raw: dict[str, Any], entry_id: str) -> dict[str, str | None]:
|
|
396
|
+
enforcement = _enum(raw["enforcement"], ENFORCEMENTS, entry_id, "enforcement")
|
|
397
|
+
runtime_gate = _nullable_string(
|
|
398
|
+
raw["runtimeGate"],
|
|
399
|
+
entry_id,
|
|
400
|
+
"runtimeGate",
|
|
401
|
+
)
|
|
402
|
+
if runtime_gate is not None and not ID_PATTERN.fullmatch(runtime_gate):
|
|
403
|
+
raise SkillRegistryError(
|
|
404
|
+
f"invalid runtimeGate for {entry_id}: {runtime_gate}"
|
|
405
|
+
)
|
|
406
|
+
runtime_command = _nullable_string(
|
|
407
|
+
raw["runtimeCommand"],
|
|
408
|
+
entry_id,
|
|
409
|
+
"runtimeCommand",
|
|
410
|
+
)
|
|
411
|
+
evidence_artifact = _nullable_string(
|
|
412
|
+
raw["evidenceArtifact"],
|
|
413
|
+
entry_id,
|
|
414
|
+
"evidenceArtifact",
|
|
415
|
+
)
|
|
416
|
+
return {
|
|
417
|
+
"enforcement": enforcement,
|
|
418
|
+
"runtime_gate": runtime_gate,
|
|
419
|
+
"runtime_command": runtime_command,
|
|
420
|
+
"evidence_artifact": evidence_artifact,
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _normalize_source(
|
|
425
|
+
value: Any,
|
|
426
|
+
entry_id: str,
|
|
427
|
+
template_root: Path,
|
|
428
|
+
*,
|
|
429
|
+
validate_sources: bool,
|
|
430
|
+
) -> str:
|
|
431
|
+
source = _relative_path(value, entry_id, "source")
|
|
432
|
+
if validate_sources and not (template_root / Path(source)).exists():
|
|
433
|
+
raise SkillRegistryError(
|
|
434
|
+
f"source does not exist for {entry_id}: {source}"
|
|
435
|
+
)
|
|
436
|
+
return source
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _managed_paths(value: Any, entry_id: str) -> tuple[str, ...]:
|
|
440
|
+
return tuple(
|
|
441
|
+
sorted(
|
|
442
|
+
set(
|
|
443
|
+
_managed_path(path, entry_id)
|
|
444
|
+
for path in _string_tuple(value, entry_id, "managedPaths")
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _enum(value: Any, allowed: set[str], entry_id: str, field_name: str) -> str:
|
|
451
|
+
if not isinstance(value, str) or value not in allowed:
|
|
452
|
+
raise SkillRegistryError(
|
|
453
|
+
f"invalid {field_name} for {entry_id}: {value}"
|
|
454
|
+
)
|
|
455
|
+
return value
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _string_tuple(value: Any, entry_id: str, field_name: str) -> tuple[str, ...]:
|
|
459
|
+
if not isinstance(value, list):
|
|
460
|
+
raise SkillRegistryError(f"{field_name} for {entry_id} must be an array")
|
|
461
|
+
normalized: list[str] = []
|
|
462
|
+
for item in value:
|
|
463
|
+
if not isinstance(item, str) or not item.strip():
|
|
464
|
+
raise SkillRegistryError(
|
|
465
|
+
f"{field_name} for {entry_id} "
|
|
466
|
+
"must contain non-empty strings"
|
|
467
|
+
)
|
|
468
|
+
normalized.append(item.strip())
|
|
469
|
+
return tuple(normalized)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _nullable_string(value: Any, entry_id: str, field_name: str) -> str | None:
|
|
473
|
+
if value is None:
|
|
474
|
+
return None
|
|
475
|
+
if not isinstance(value, str) or not value.strip():
|
|
476
|
+
raise SkillRegistryError(
|
|
477
|
+
f"{field_name} for {entry_id} "
|
|
478
|
+
"must be null or a non-empty string"
|
|
479
|
+
)
|
|
480
|
+
return value.strip()
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _normalize_read_when(
|
|
484
|
+
value: Any,
|
|
485
|
+
entry_id: str,
|
|
486
|
+
kind: str,
|
|
487
|
+
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
|
488
|
+
if value is None:
|
|
489
|
+
return (), ()
|
|
490
|
+
if kind != "domain" or not isinstance(value, dict):
|
|
491
|
+
raise SkillRegistryError(
|
|
492
|
+
f"readWhen for {entry_id} is only valid for domain entries"
|
|
493
|
+
)
|
|
494
|
+
unexpected = set(value) - {"devTypes", "pathPatterns"}
|
|
495
|
+
if unexpected:
|
|
496
|
+
raise SkillRegistryError(
|
|
497
|
+
f"unexpected readWhen field for {entry_id}: {sorted(unexpected)[0]}"
|
|
498
|
+
)
|
|
499
|
+
dev_types = tuple(sorted(set(_string_tuple(
|
|
500
|
+
value.get("devTypes", []),
|
|
501
|
+
entry_id,
|
|
502
|
+
"readWhen.devTypes",
|
|
503
|
+
))))
|
|
504
|
+
patterns = tuple(sorted(set(_string_tuple(
|
|
505
|
+
value.get("pathPatterns", []),
|
|
506
|
+
entry_id,
|
|
507
|
+
"readWhen.pathPatterns",
|
|
508
|
+
))))
|
|
509
|
+
if not dev_types and not patterns:
|
|
510
|
+
raise SkillRegistryError(
|
|
511
|
+
f"readWhen for {entry_id} requires devTypes or pathPatterns"
|
|
512
|
+
)
|
|
513
|
+
for pattern in patterns:
|
|
514
|
+
normalized = pattern.replace("\\", "/")
|
|
515
|
+
if normalized.startswith(("/", "../")) or "/../" in normalized:
|
|
516
|
+
raise SkillRegistryError(
|
|
517
|
+
f"invalid readWhen.pathPatterns for {entry_id}: {pattern}"
|
|
518
|
+
)
|
|
519
|
+
return dev_types, patterns
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _relative_path(value: Any, entry_id: str, field_name: str) -> str:
|
|
523
|
+
if not isinstance(value, str) or not value.strip():
|
|
524
|
+
raise SkillRegistryError(
|
|
525
|
+
f"{field_name} for {entry_id} must be a non-empty relative path"
|
|
526
|
+
)
|
|
527
|
+
normalized = value.strip().replace("\\", "/")
|
|
528
|
+
if normalized.startswith("./"):
|
|
529
|
+
normalized = normalized[2:]
|
|
530
|
+
parts = normalized.split("/")
|
|
531
|
+
if (
|
|
532
|
+
normalized.startswith("/")
|
|
533
|
+
or normalized.startswith("//")
|
|
534
|
+
or re.match(r"^[A-Za-z]:", normalized)
|
|
535
|
+
or ".." in parts
|
|
536
|
+
or any(character in normalized for character in "*?[]")
|
|
537
|
+
):
|
|
538
|
+
raise SkillRegistryError(
|
|
539
|
+
f"invalid {field_name} for {entry_id}: {value}"
|
|
540
|
+
)
|
|
541
|
+
return normalized
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _managed_path(value: str, entry_id: str) -> str:
|
|
545
|
+
normalized = _relative_path(value, entry_id, "managedPaths")
|
|
546
|
+
if not normalized.endswith("/"):
|
|
547
|
+
raise SkillRegistryError(
|
|
548
|
+
f"managedPaths for {entry_id} must end with /: {value}"
|
|
549
|
+
)
|
|
550
|
+
return normalized
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _validate_managed_paths(entries: tuple[SkillEntry, ...]) -> None:
|
|
554
|
+
owners: list[tuple[str, str]] = []
|
|
555
|
+
for entry in entries:
|
|
556
|
+
for path in entry.managed_paths:
|
|
557
|
+
for existing_entry, existing_path in owners:
|
|
558
|
+
if (
|
|
559
|
+
path == existing_path
|
|
560
|
+
or path.startswith(existing_path)
|
|
561
|
+
or existing_path.startswith(path)
|
|
562
|
+
):
|
|
563
|
+
raise SkillRegistryError(
|
|
564
|
+
"managed path overlap: "
|
|
565
|
+
f"{existing_entry}:{existing_path} and "
|
|
566
|
+
f"{entry.id}:{path}"
|
|
567
|
+
)
|
|
568
|
+
owners.append((entry.id, path))
|
|
@@ -97,12 +97,8 @@ def _validate_python_text_io(rel_path: str, content: str) -> list[dict]:
|
|
|
97
97
|
|
|
98
98
|
def _validate_node_text_io(rel_path: str, content: str) -> list[dict]:
|
|
99
99
|
violations: list[dict] = []
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if not NODE_TEXT_IO_PATTERN.search(line):
|
|
103
|
-
continue
|
|
104
|
-
|
|
105
|
-
segment = "\n".join(lines[index : min(index + 5, len(lines))]).lower()
|
|
100
|
+
for match in NODE_TEXT_IO_PATTERN.finditer(content):
|
|
101
|
+
segment = _node_call_segment(content, match.end() - 1).lower()
|
|
106
102
|
if "utf8" in segment or "utf-8" in segment:
|
|
107
103
|
continue
|
|
108
104
|
|
|
@@ -112,13 +108,68 @@ def _validate_node_text_io(rel_path: str, content: str) -> list[dict]:
|
|
|
112
108
|
rel_path,
|
|
113
109
|
"Coding standards: Node file IO must specify utf8 encoding",
|
|
114
110
|
"Pass 'utf8' or { encoding: 'utf8' } to readFile/writeFile calls.",
|
|
115
|
-
line=
|
|
111
|
+
line=content.count("\n", 0, match.start()) + 1,
|
|
116
112
|
)
|
|
117
113
|
)
|
|
118
114
|
|
|
119
115
|
return violations
|
|
120
116
|
|
|
121
117
|
|
|
118
|
+
def _node_call_segment(content: str, open_paren_index: int) -> str:
|
|
119
|
+
depth = 0
|
|
120
|
+
quote: str | None = None
|
|
121
|
+
escaped = False
|
|
122
|
+
line_comment = False
|
|
123
|
+
block_comment = False
|
|
124
|
+
index = open_paren_index
|
|
125
|
+
|
|
126
|
+
while index < len(content):
|
|
127
|
+
char = content[index]
|
|
128
|
+
next_char = content[index + 1] if index + 1 < len(content) else ""
|
|
129
|
+
|
|
130
|
+
if line_comment:
|
|
131
|
+
if char == "\n":
|
|
132
|
+
line_comment = False
|
|
133
|
+
index += 1
|
|
134
|
+
continue
|
|
135
|
+
if block_comment:
|
|
136
|
+
if char == "*" and next_char == "/":
|
|
137
|
+
block_comment = False
|
|
138
|
+
index += 2
|
|
139
|
+
else:
|
|
140
|
+
index += 1
|
|
141
|
+
continue
|
|
142
|
+
if quote is not None:
|
|
143
|
+
if escaped:
|
|
144
|
+
escaped = False
|
|
145
|
+
elif char == "\\":
|
|
146
|
+
escaped = True
|
|
147
|
+
elif char == quote:
|
|
148
|
+
quote = None
|
|
149
|
+
index += 1
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
if char in {"'", '"', "`"}:
|
|
153
|
+
quote = char
|
|
154
|
+
elif char == "/" and next_char == "/":
|
|
155
|
+
line_comment = True
|
|
156
|
+
index += 2
|
|
157
|
+
continue
|
|
158
|
+
elif char == "/" and next_char == "*":
|
|
159
|
+
block_comment = True
|
|
160
|
+
index += 2
|
|
161
|
+
continue
|
|
162
|
+
elif char == "(":
|
|
163
|
+
depth += 1
|
|
164
|
+
elif char == ")":
|
|
165
|
+
depth -= 1
|
|
166
|
+
if depth == 0:
|
|
167
|
+
return content[open_paren_index : index + 1]
|
|
168
|
+
index += 1
|
|
169
|
+
|
|
170
|
+
return content[open_paren_index:]
|
|
171
|
+
|
|
172
|
+
|
|
122
173
|
def _validate_powershell_text_io(rel_path: str, content: str) -> list[dict]:
|
|
123
174
|
violations: list[dict] = []
|
|
124
175
|
for line_number, raw_line in enumerate(content.splitlines(), start=1):
|