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.
Files changed (111) hide show
  1. package/README.md +27 -14
  2. package/package.json +1 -1
  3. package/src/commands/init.js +39 -28
  4. package/src/commands/sync.js +2 -2
  5. package/src/lib/asset-plan.js +127 -0
  6. package/src/lib/copy-template.js +101 -141
  7. package/src/lib/host-assets.js +231 -0
  8. package/src/lib/plan-applier.js +329 -0
  9. package/src/lib/platforms.js +15 -76
  10. package/src/lib/skill-registry.js +503 -0
  11. package/template/.claude/agents/cowork-check.md +10 -1
  12. package/template/.claude/agents/cowork-implement.md +11 -2
  13. package/template/.claude/hooks/inject-workflow-state.py +51 -254
  14. package/template/.codex/agents/cowork-check.toml +12 -3
  15. package/template/.codex/agents/cowork-implement.toml +11 -2
  16. package/template/.codex/agents/default.toml +3 -2
  17. package/template/.codex/agents/explorer.toml +3 -2
  18. package/template/.codex/agents/worker.toml +2 -1
  19. package/template/.codex/hooks/inject-workflow-state.py +46 -265
  20. package/template/.cowork-flow/.version +1 -1
  21. package/template/.cowork-flow/scripts/application/__init__.py +1 -0
  22. package/template/.cowork-flow/scripts/application/batch_execution.py +743 -0
  23. package/template/.cowork-flow/scripts/application/runtime_context_service.py +365 -0
  24. package/template/.cowork-flow/scripts/application/task_archive.py +220 -0
  25. package/template/.cowork-flow/scripts/application/task_context.py +765 -0
  26. package/template/.cowork-flow/scripts/application/task_creation.py +201 -0
  27. package/template/.cowork-flow/scripts/application/task_lifecycle.py +532 -0
  28. package/template/.cowork-flow/scripts/application/task_tree.py +179 -0
  29. package/template/.cowork-flow/scripts/commands/doctor.py +78 -57
  30. package/template/.cowork-flow/scripts/commands/party_mode_v2.py +671 -425
  31. package/template/.cowork-flow/scripts/commands/subagent.py +38 -26
  32. package/template/.cowork-flow/scripts/commands/task.py +535 -1953
  33. package/template/.cowork-flow/scripts/commands/task_archive_commands.py +195 -0
  34. package/template/.cowork-flow/scripts/commands/task_context_commands.py +240 -0
  35. package/template/.cowork-flow/scripts/commands/task_create_command.py +164 -0
  36. package/template/.cowork-flow/scripts/commands/task_navigation.py +407 -0
  37. package/template/.cowork-flow/scripts/commands/task_parser.py +364 -0
  38. package/template/.cowork-flow/scripts/commands/task_support.py +76 -0
  39. package/template/.cowork-flow/scripts/commands/task_tree_commands.py +160 -0
  40. package/template/.cowork-flow/scripts/common/core/execution_context.py +161 -85
  41. package/template/.cowork-flow/scripts/common/core/host_manifest.py +393 -0
  42. package/template/.cowork-flow/scripts/common/core/skill_registry.py +568 -0
  43. package/template/.cowork-flow/scripts/common/gates/coding_standards.py +58 -7
  44. package/template/.cowork-flow/scripts/common/gates/gates.py +323 -46
  45. package/template/.cowork-flow/scripts/common/gates/models.py +196 -0
  46. package/template/.cowork-flow/scripts/common/gates/registry.py +79 -0
  47. package/template/.cowork-flow/scripts/common/gates/tdd_evidence.py +13 -13
  48. package/template/.cowork-flow/scripts/common/gates/validate_coding_standards.py +104 -62
  49. package/template/.cowork-flow/scripts/common/gates/validate_implementation.py +39 -22
  50. package/template/.cowork-flow/scripts/common/gates/validate_rules.py +223 -40
  51. package/template/.cowork-flow/scripts/common/git/git_context.py +216 -182
  52. package/template/.cowork-flow/scripts/common/host/__init__.py +1 -0
  53. package/template/.cowork-flow/scripts/common/host/workflow_state_hook.py +303 -0
  54. package/template/.cowork-flow/scripts/common/storage/__init__.py +1 -0
  55. package/template/.cowork-flow/scripts/common/storage/operation_log.py +72 -0
  56. package/template/.cowork-flow/scripts/common/storage/state_store.py +279 -0
  57. package/template/.cowork-flow/scripts/common/storage/unit_of_work.py +214 -0
  58. package/template/.cowork-flow/scripts/common/task/active_task.py +93 -135
  59. package/template/.cowork-flow/scripts/common/task/batch_mode.py +33 -120
  60. package/template/.cowork-flow/scripts/common/task/decision_review.py +85 -0
  61. package/template/.cowork-flow/scripts/common/task/readiness.py +9 -15
  62. package/template/.cowork-flow/scripts/common/task/task_repository.py +156 -0
  63. package/template/.cowork-flow/scripts/run.py +24 -0
  64. package/template/.cowork-flow/spec/contracts/decision-anchor.md +8 -2
  65. package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +2 -4
  66. package/template/.cowork-flow/spec/contracts/workflow-state-templates.md +1 -1
  67. package/template/.cowork-flow/spec/protocols/decision-review.md +28 -0
  68. package/template/.cowork-flow/spec/protocols/review.md +34 -0
  69. package/template/.cowork-flow/spec/protocols/spec-maintenance.md +16 -0
  70. package/template/.cowork-flow/spec/protocols/tdd.md +30 -0
  71. package/template/.cowork-flow/spec/references/definition-of-done.md +4 -4
  72. package/template/.cowork-flow/spec/references/orchestration-patterns.md +1 -1
  73. package/template/.cowork-flow/spec/runtime/contract-registry.json +25 -0
  74. package/template/.cowork-flow/spec/runtime/host-assets.json +214 -0
  75. package/template/.cowork-flow/spec/runtime/index.md +8 -0
  76. package/template/.cowork-flow/spec/runtime/rules.json +61 -10
  77. package/template/.cowork-flow/spec/runtime/skill-registry.json +397 -0
  78. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +173 -0
  79. package/template/.cowork-flow/spec/schemas/rules.schema.json +10 -1
  80. package/template/.cowork-flow/spec/schemas/skill-registry.schema.json +202 -0
  81. package/template/.cowork-flow/workflow.md +18 -13
  82. package/template/.opencode/agents/cowork-check.md +11 -0
  83. package/template/.opencode/agents/cowork-implement.md +16 -1
  84. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  85. package/template/.zcode/hooks/inject-context.js +3 -85
  86. package/template/.zcode/hooks/inject-context.selfcheck.mjs +6 -6
  87. package/template/.zcode/scaffold/.cowork-flow/spec/contracts/decision-anchor.md +8 -2
  88. package/template/.zcode/scaffold/.cowork-flow/spec/contracts/subagent-dispatch.md +2 -4
  89. package/template/.zcode/scaffold/.cowork-flow/spec/runtime/rules.json +61 -10
  90. package/template/.zcode/scaffold/.cowork-flow/spec/schemas/rules.schema.json +10 -1
  91. package/template/.zcode/scaffold/.cowork-flow/workflow.md +18 -13
  92. package/template/.zcode/scaffold/AGENTS.md +18 -8
  93. package/template/AGENTS.md +1 -1
  94. package/template/skills/batch-mode/SKILL.md +140 -76
  95. package/template/skills/brainstorming/SKILL.md +2 -2
  96. package/template/skills/cowork-flow/SKILL.md +42 -0
  97. package/template/skills/party-mode/SHARED-BOUNDARIES.md +1 -1
  98. package/template/skills/party-mode/SKILL.md +48 -119
  99. package/template/.cowork-flow/scripts/common/gates/spec_validator.py +0 -53
  100. package/template/.cowork-flow/scripts/common/gates/validate_jsonl.py +0 -32
  101. package/template/skills/before-dev/SKILL.md +0 -41
  102. package/template/skills/check/SKILL.md +0 -53
  103. package/template/skills/check/simplification-guide.md +0 -47
  104. package/template/skills/continue/SKILL.md +0 -29
  105. package/template/skills/doubt-review/SKILL.md +0 -118
  106. package/template/skills/finish-work/SKILL.md +0 -32
  107. package/template/skills/party-mode-v2/SKILL.md +0 -75
  108. package/template/skills/start/SKILL.md +0 -78
  109. package/template/skills/tdd/SKILL.md +0 -74
  110. package/template/skills/update-spec/SKILL.md +0 -39
  111. 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
- lines = content.splitlines()
101
- for index, line in enumerate(lines):
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=index + 1,
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):