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
|
@@ -228,13 +228,104 @@ def worker_command_block_message(
|
|
|
228
228
|
return f"Blocked: worker mode cannot run `{command}` for assignment {assignment}. {reason}"
|
|
229
229
|
|
|
230
230
|
|
|
231
|
-
def
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
231
|
+
def _append_allowed_context(
|
|
232
|
+
lines: list[str],
|
|
233
|
+
allowed_context: object,
|
|
234
|
+
*,
|
|
235
|
+
allow_string_items: bool,
|
|
236
|
+
) -> None:
|
|
237
|
+
if not isinstance(allowed_context, list) or not allowed_context:
|
|
238
|
+
return
|
|
239
|
+
lines.extend(["", "## Allowed context"])
|
|
240
|
+
for item in allowed_context:
|
|
241
|
+
if allow_string_items and isinstance(item, str) and item.strip():
|
|
242
|
+
lines.append(f"- {item.strip()}")
|
|
243
|
+
elif isinstance(item, dict):
|
|
244
|
+
_append_file_reason_item(lines, item)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _append_file_reason_item(lines: list[str], item: dict) -> None:
|
|
248
|
+
file_value = item.get("file")
|
|
249
|
+
reason = item.get("reason")
|
|
250
|
+
if isinstance(file_value, str) and file_value.strip():
|
|
251
|
+
suffix = f" - {reason}" if isinstance(reason, str) and reason.strip() else ""
|
|
252
|
+
lines.append(f"- {file_value}{suffix}")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _append_string_list_section(
|
|
256
|
+
lines: list[str],
|
|
257
|
+
title: str,
|
|
258
|
+
values: object,
|
|
259
|
+
) -> None:
|
|
260
|
+
if not isinstance(values, list) or not values:
|
|
261
|
+
return
|
|
262
|
+
items = [value.strip() for value in values if isinstance(value, str) and value.strip()]
|
|
263
|
+
if not items:
|
|
264
|
+
return
|
|
265
|
+
lines.extend(["", title])
|
|
266
|
+
lines.extend(f"- {item}" for item in items)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _append_context_metadata(
|
|
270
|
+
lines: list[str],
|
|
271
|
+
context_data: dict[str, object],
|
|
272
|
+
) -> None:
|
|
273
|
+
metadata_fields = [
|
|
274
|
+
("runtimeContextId", "Runtime context"),
|
|
275
|
+
("runtimeContextStatus", "Runtime context status"),
|
|
276
|
+
("agentType", "Agent type"),
|
|
277
|
+
("dispatchReliability", "Dispatch reliability"),
|
|
278
|
+
]
|
|
279
|
+
runtime_context_id = (
|
|
280
|
+
context_data.get("runtimeContextId")
|
|
281
|
+
or context_data.get("runtime_context_id")
|
|
282
|
+
)
|
|
283
|
+
if isinstance(runtime_context_id, str) and runtime_context_id.strip():
|
|
284
|
+
lines.append(f"Runtime context: {runtime_context_id}")
|
|
285
|
+
for key, label in metadata_fields[1:]:
|
|
286
|
+
value = context_data.get(key)
|
|
287
|
+
if isinstance(value, str) and value.strip():
|
|
288
|
+
lines.append(f"{label}: {value}")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _append_status_file(
|
|
292
|
+
lines: list[str],
|
|
293
|
+
repo_root: Path,
|
|
294
|
+
status_file: object,
|
|
295
|
+
) -> None:
|
|
296
|
+
if not isinstance(status_file, str) or not status_file.strip():
|
|
297
|
+
return
|
|
298
|
+
status_path = repo_root / status_file
|
|
299
|
+
if not status_path.is_file():
|
|
300
|
+
return
|
|
301
|
+
try:
|
|
302
|
+
status = json.loads(status_path.read_text(encoding="utf-8"))
|
|
303
|
+
except (json.JSONDecodeError, OSError):
|
|
304
|
+
lines.extend(["", "## Current status", f"Status file unreadable: {status_file}"])
|
|
305
|
+
return
|
|
306
|
+
lines.extend(["", "## Current status", f"Status: {status.get('status', 'unknown')}"])
|
|
307
|
+
note = status.get("note")
|
|
308
|
+
if isinstance(note, str) and note.strip():
|
|
309
|
+
lines.append(f"Note: {note}")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _append_recent_events(
|
|
313
|
+
lines: list[str],
|
|
314
|
+
repo_root: Path,
|
|
315
|
+
events_file: object,
|
|
316
|
+
) -> None:
|
|
317
|
+
if not isinstance(events_file, str) or not events_file.strip():
|
|
318
|
+
return
|
|
319
|
+
events_path = repo_root / events_file
|
|
320
|
+
if not events_path.is_file():
|
|
321
|
+
return
|
|
322
|
+
events = events_path.read_text(encoding="utf-8").splitlines()[-5:]
|
|
323
|
+
if events:
|
|
324
|
+
lines.extend(["", "## Recent events"])
|
|
325
|
+
lines.extend(f"- {event}" for event in events)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _worker_resume_header(context: ExecutionContext) -> list[str]:
|
|
238
329
|
lines = [
|
|
239
330
|
"========================================",
|
|
240
331
|
"WORKER CONTEXT",
|
|
@@ -249,36 +340,28 @@ def build_worker_resume_text(
|
|
|
249
340
|
lines.append(f"Prompt file: {context.prompt_file}")
|
|
250
341
|
if context.context_file:
|
|
251
342
|
lines.append(f"Context file: {context.context_file}")
|
|
252
|
-
lines
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
343
|
+
return lines
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _append_worker_read_first(
|
|
347
|
+
lines: list[str],
|
|
348
|
+
context: ExecutionContext,
|
|
349
|
+
repo_root: Path,
|
|
350
|
+
context_data: dict[str, object],
|
|
351
|
+
) -> None:
|
|
352
|
+
lines.extend(["", "## READ FIRST", f"- Read worker brief: {context.prompt_file or '(missing prompt file)'}"])
|
|
353
|
+
_append_allowed_context(
|
|
354
|
+
lines,
|
|
355
|
+
context_data.get("allowedContext"),
|
|
356
|
+
allow_string_items=False,
|
|
258
357
|
)
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
lines.extend(["", "## Allowed context"])
|
|
263
|
-
for item in allowed_context:
|
|
264
|
-
if not isinstance(item, dict):
|
|
265
|
-
continue
|
|
266
|
-
file_value = item.get("file")
|
|
267
|
-
reason = item.get("reason")
|
|
268
|
-
if isinstance(file_value, str) and file_value.strip():
|
|
269
|
-
suffix = f" - {reason}" if isinstance(reason, str) and reason.strip() else ""
|
|
270
|
-
lines.append(f"- {file_value}{suffix}")
|
|
271
|
-
if context.task_dir and (repo_root / context.task_dir / "decision-anchor.md").is_file():
|
|
272
|
-
lines.append(f"- Read task PRD: {context.task_dir}/decision-anchor.md")
|
|
358
|
+
decision_anchor = repo_root / (context.task_dir or "") / "decision-anchor.md"
|
|
359
|
+
if context.task_dir and decision_anchor.is_file():
|
|
360
|
+
lines.append(f"- Read task decision-anchor: {context.task_dir}/decision-anchor.md")
|
|
273
361
|
lines.append("- Follow only the files, steps, and commands named in the worker brief.")
|
|
274
362
|
|
|
275
|
-
forbidden_actions = context_data.get("forbiddenActions")
|
|
276
|
-
if isinstance(forbidden_actions, list) and forbidden_actions:
|
|
277
|
-
lines.extend(["", "## FORBIDDEN ACTIONS"])
|
|
278
|
-
for action in forbidden_actions:
|
|
279
|
-
if isinstance(action, str) and action.strip():
|
|
280
|
-
lines.append(f"- {action.strip()}")
|
|
281
363
|
|
|
364
|
+
def _append_worker_rules(lines: list[str], context: ExecutionContext) -> None:
|
|
282
365
|
lines.extend(
|
|
283
366
|
[
|
|
284
367
|
"",
|
|
@@ -295,24 +378,31 @@ def build_worker_resume_text(
|
|
|
295
378
|
"========================================",
|
|
296
379
|
]
|
|
297
380
|
)
|
|
298
|
-
return "\n".join(lines)
|
|
299
381
|
|
|
300
382
|
|
|
301
|
-
def
|
|
383
|
+
def build_worker_resume_text(
|
|
302
384
|
context: ExecutionContext,
|
|
303
385
|
repo_root: Path | None = None,
|
|
304
386
|
) -> str:
|
|
305
387
|
if repo_root is None:
|
|
306
388
|
repo_root = get_repo_root()
|
|
389
|
+
|
|
390
|
+
lines = _worker_resume_header(context)
|
|
307
391
|
context_data = _load_context_file(context.context_file) if context.context_file else {}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
392
|
+
_append_worker_read_first(lines, context, repo_root, context_data)
|
|
393
|
+
_append_string_list_section(
|
|
394
|
+
lines,
|
|
395
|
+
"## FORBIDDEN ACTIONS",
|
|
396
|
+
context_data.get("forbiddenActions"),
|
|
397
|
+
)
|
|
398
|
+
_append_worker_rules(lines, context)
|
|
399
|
+
return "\n".join(lines)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _subagent_resume_header(
|
|
403
|
+
context: ExecutionContext,
|
|
404
|
+
context_data: dict[str, object],
|
|
405
|
+
) -> list[str]:
|
|
316
406
|
lines = [
|
|
317
407
|
"========================================",
|
|
318
408
|
"COWORK-FLOW SUBAGENT RESUME",
|
|
@@ -326,50 +416,13 @@ def build_subagent_resume_text(
|
|
|
326
416
|
f"Role: {context.role or 'unknown'}",
|
|
327
417
|
f"Goal: {context.goal or 'unknown'}",
|
|
328
418
|
]
|
|
329
|
-
|
|
330
|
-
lines.append(f"Runtime context: {runtime_context_id}")
|
|
331
|
-
if isinstance(runtime_context_status, str) and runtime_context_status.strip():
|
|
332
|
-
lines.append(f"Runtime context status: {runtime_context_status}")
|
|
333
|
-
if isinstance(agent_type, str) and agent_type.strip():
|
|
334
|
-
lines.append(f"Agent type: {agent_type}")
|
|
335
|
-
if isinstance(dispatch_reliability, str) and dispatch_reliability.strip():
|
|
336
|
-
lines.append(f"Dispatch reliability: {dispatch_reliability}")
|
|
419
|
+
_append_context_metadata(lines, context_data)
|
|
337
420
|
if context.context_file:
|
|
338
421
|
lines.append(f"Context file: {context.context_file}")
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
lines.append(f"- {item.strip()}")
|
|
344
|
-
elif isinstance(item, dict):
|
|
345
|
-
file_value = item.get("file")
|
|
346
|
-
reason = item.get("reason")
|
|
347
|
-
if isinstance(file_value, str) and file_value.strip():
|
|
348
|
-
suffix = f" - {reason}" if isinstance(reason, str) and reason.strip() else ""
|
|
349
|
-
lines.append(f"- {file_value}{suffix}")
|
|
350
|
-
if isinstance(forbidden_actions, list) and forbidden_actions:
|
|
351
|
-
lines.extend(["", "## Forbidden actions"])
|
|
352
|
-
for action in forbidden_actions:
|
|
353
|
-
if isinstance(action, str) and action.strip():
|
|
354
|
-
lines.append(f"- {action.strip()}")
|
|
355
|
-
if isinstance(status_file, str) and status_file.strip():
|
|
356
|
-
status_path = repo_root / status_file
|
|
357
|
-
if status_path.is_file():
|
|
358
|
-
try:
|
|
359
|
-
status = json.loads(status_path.read_text(encoding="utf-8"))
|
|
360
|
-
lines.extend(["", "## Current status", f"Status: {status.get('status', 'unknown')}"])
|
|
361
|
-
note = status.get("note")
|
|
362
|
-
if isinstance(note, str) and note.strip():
|
|
363
|
-
lines.append(f"Note: {note}")
|
|
364
|
-
except (json.JSONDecodeError, OSError):
|
|
365
|
-
lines.extend(["", "## Current status", f"Status file unreadable: {status_file}"])
|
|
366
|
-
if isinstance(events_file, str) and events_file.strip():
|
|
367
|
-
events_path = repo_root / events_file
|
|
368
|
-
if events_path.is_file():
|
|
369
|
-
events = events_path.read_text(encoding="utf-8").splitlines()[-5:]
|
|
370
|
-
if events:
|
|
371
|
-
lines.extend(["", "## Recent events"])
|
|
372
|
-
lines.extend(f"- {event}" for event in events)
|
|
422
|
+
return lines
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _append_subagent_rules(lines: list[str]) -> None:
|
|
373
426
|
lines.extend([
|
|
374
427
|
"",
|
|
375
428
|
"## RULES",
|
|
@@ -382,4 +435,27 @@ def build_subagent_resume_text(
|
|
|
382
435
|
"",
|
|
383
436
|
"========================================",
|
|
384
437
|
])
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def build_subagent_resume_text(
|
|
441
|
+
context: ExecutionContext,
|
|
442
|
+
repo_root: Path | None = None,
|
|
443
|
+
) -> str:
|
|
444
|
+
if repo_root is None:
|
|
445
|
+
repo_root = get_repo_root()
|
|
446
|
+
context_data = _load_context_file(context.context_file) if context.context_file else {}
|
|
447
|
+
lines = _subagent_resume_header(context, context_data)
|
|
448
|
+
_append_allowed_context(
|
|
449
|
+
lines,
|
|
450
|
+
context_data.get("allowedContext"),
|
|
451
|
+
allow_string_items=True,
|
|
452
|
+
)
|
|
453
|
+
_append_string_list_section(
|
|
454
|
+
lines,
|
|
455
|
+
"## Forbidden actions",
|
|
456
|
+
context_data.get("forbiddenActions"),
|
|
457
|
+
)
|
|
458
|
+
_append_status_file(lines, repo_root, context_data.get("statusFile"))
|
|
459
|
+
_append_recent_events(lines, repo_root, context_data.get("eventsFile"))
|
|
460
|
+
_append_subagent_rules(lines)
|
|
385
461
|
return "\n".join(lines)
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Host Asset Manifest loading and semantic validation."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import tomllib
|
|
13
|
+
except ModuleNotFoundError: # pragma: no cover - Python < 3.11
|
|
14
|
+
tomllib = None # type: ignore[assignment]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
MANIFEST_PATH = Path(".cowork-flow/spec/runtime/host-assets.json")
|
|
18
|
+
SCHEMA_PATH = Path(".cowork-flow/spec/schemas/host-assets.schema.json")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HostManifestError(RuntimeError):
|
|
22
|
+
"""Raised when the Host Asset Manifest is unavailable or invalid."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class CommandTarget:
|
|
27
|
+
config: str
|
|
28
|
+
format: str
|
|
29
|
+
target: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class HostPlatform:
|
|
34
|
+
id: str
|
|
35
|
+
display_name: str
|
|
36
|
+
aliases: tuple[str, ...]
|
|
37
|
+
detect_any: tuple[str, ...]
|
|
38
|
+
asset_prefixes: tuple[str, ...]
|
|
39
|
+
asset_files: tuple[str, ...]
|
|
40
|
+
skill_target: str | None
|
|
41
|
+
adapter_path: str
|
|
42
|
+
capabilities: dict[str, str]
|
|
43
|
+
command_targets: tuple[CommandTarget, ...]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class SyncPolicy:
|
|
48
|
+
protected_files: tuple[str, ...]
|
|
49
|
+
protected_prefixes: tuple[str, ...]
|
|
50
|
+
safe_files: tuple[str, ...]
|
|
51
|
+
safe_prefixes: tuple[str, ...]
|
|
52
|
+
managed_block_files: tuple[str, ...]
|
|
53
|
+
obsolete_files: tuple[str, ...]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class HostManifest:
|
|
58
|
+
schema_version: int
|
|
59
|
+
capability_values: tuple[str, ...]
|
|
60
|
+
platforms: tuple[HostPlatform, ...]
|
|
61
|
+
excluded_prefixes: tuple[str, ...]
|
|
62
|
+
sync_policy: SyncPolicy
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def platform_ids(self) -> tuple[str, ...]:
|
|
66
|
+
return tuple(platform.id for platform in self.platforms)
|
|
67
|
+
|
|
68
|
+
def platform(self, platform_id: str) -> HostPlatform:
|
|
69
|
+
for platform in self.platforms:
|
|
70
|
+
if platform.id == platform_id:
|
|
71
|
+
return platform
|
|
72
|
+
raise HostManifestError(f"unknown host platform: {platform_id}")
|
|
73
|
+
|
|
74
|
+
def resolve_alias(self, alias: str) -> str:
|
|
75
|
+
normalized = alias.strip().lower()
|
|
76
|
+
if normalized == "all":
|
|
77
|
+
raise HostManifestError("all resolves to multiple host platforms")
|
|
78
|
+
for platform in self.platforms:
|
|
79
|
+
if normalized in platform.aliases:
|
|
80
|
+
return platform.id
|
|
81
|
+
raise HostManifestError(f"unknown host platform alias: {alias}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_host_manifest(template_root: Path) -> HostManifest:
|
|
85
|
+
path = Path(template_root) / MANIFEST_PATH
|
|
86
|
+
try:
|
|
87
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
88
|
+
except FileNotFoundError as error:
|
|
89
|
+
raise HostManifestError(f"missing host asset manifest: {path}") from error
|
|
90
|
+
except json.JSONDecodeError as error:
|
|
91
|
+
raise HostManifestError(f"invalid host asset manifest JSON: {path}") from error
|
|
92
|
+
if not isinstance(raw, dict):
|
|
93
|
+
raise HostManifestError("host asset manifest must be a JSON object")
|
|
94
|
+
return _build_manifest(raw)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def validate_host_assets(template_root: Path) -> list[str]:
|
|
98
|
+
root = Path(template_root)
|
|
99
|
+
errors: list[str] = []
|
|
100
|
+
try:
|
|
101
|
+
manifest = load_host_manifest(root)
|
|
102
|
+
except HostManifestError as error:
|
|
103
|
+
return [str(error)]
|
|
104
|
+
|
|
105
|
+
if not (root / SCHEMA_PATH).is_file():
|
|
106
|
+
errors.append(f"missing host asset schema: {root / SCHEMA_PATH}")
|
|
107
|
+
|
|
108
|
+
allowed = set(manifest.capability_values)
|
|
109
|
+
aliases: dict[str, str] = {}
|
|
110
|
+
for platform in manifest.platforms:
|
|
111
|
+
for alias in platform.aliases:
|
|
112
|
+
owner = aliases.get(alias)
|
|
113
|
+
if owner is not None and owner != platform.id:
|
|
114
|
+
errors.append(
|
|
115
|
+
f"duplicate platform alias {alias}: {owner}, {platform.id}"
|
|
116
|
+
)
|
|
117
|
+
aliases[alias] = platform.id
|
|
118
|
+
_validate_platform(root, platform, allowed, errors)
|
|
119
|
+
return errors
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _validate_platform(
|
|
123
|
+
root: Path,
|
|
124
|
+
platform: HostPlatform,
|
|
125
|
+
allowed: set[str],
|
|
126
|
+
errors: list[str],
|
|
127
|
+
) -> None:
|
|
128
|
+
adapter_path = root / platform.adapter_path
|
|
129
|
+
if not adapter_path.is_file():
|
|
130
|
+
errors.append(f"missing adapter: {platform.adapter_path}")
|
|
131
|
+
return
|
|
132
|
+
try:
|
|
133
|
+
adapter = _parse_simple_yaml(adapter_path)
|
|
134
|
+
except (OSError, ValueError) as error:
|
|
135
|
+
errors.append(f"invalid adapter YAML {platform.adapter_path}: {error}")
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
if adapter.get("host") != platform.id:
|
|
139
|
+
errors.append(
|
|
140
|
+
f"adapter host mismatch {platform.adapter_path}: "
|
|
141
|
+
f"{adapter.get('host')} != {platform.id}"
|
|
142
|
+
)
|
|
143
|
+
capabilities = adapter.get("capabilities")
|
|
144
|
+
if not isinstance(capabilities, dict):
|
|
145
|
+
errors.append(f"adapter missing capabilities: {platform.adapter_path}")
|
|
146
|
+
else:
|
|
147
|
+
for name, value in capabilities.items():
|
|
148
|
+
if value not in allowed:
|
|
149
|
+
errors.append(
|
|
150
|
+
f"illegal capability {platform.id}:{name}={value}"
|
|
151
|
+
)
|
|
152
|
+
if capabilities != platform.capabilities:
|
|
153
|
+
errors.append(
|
|
154
|
+
f"capability mismatch between manifest and adapter: {platform.id}"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
for target in platform.command_targets:
|
|
158
|
+
_validate_command_target(root, platform.id, target, errors)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _validate_command_target(
|
|
162
|
+
root: Path,
|
|
163
|
+
platform_id: str,
|
|
164
|
+
command_target: CommandTarget,
|
|
165
|
+
errors: list[str],
|
|
166
|
+
) -> None:
|
|
167
|
+
config_path = root / command_target.config
|
|
168
|
+
target_path = root / command_target.target
|
|
169
|
+
if not config_path.is_file():
|
|
170
|
+
errors.append(
|
|
171
|
+
f"missing command config {platform_id}: {command_target.config}"
|
|
172
|
+
)
|
|
173
|
+
return
|
|
174
|
+
if not target_path.is_file():
|
|
175
|
+
errors.append(
|
|
176
|
+
f"missing command target {platform_id}: {command_target.target}"
|
|
177
|
+
)
|
|
178
|
+
return
|
|
179
|
+
try:
|
|
180
|
+
config = _load_structured(config_path, command_target.format)
|
|
181
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
182
|
+
errors.append(
|
|
183
|
+
f"invalid command config {platform_id}: "
|
|
184
|
+
f"{command_target.config}: {error}"
|
|
185
|
+
)
|
|
186
|
+
return
|
|
187
|
+
strings = tuple(_walk_strings(config))
|
|
188
|
+
if not any(command_target.target in value for value in strings):
|
|
189
|
+
errors.append(
|
|
190
|
+
f"command config {command_target.config} does not reference "
|
|
191
|
+
f"{command_target.target}"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _load_structured(path: Path, format_name: str) -> object:
|
|
196
|
+
text = path.read_text(encoding="utf-8")
|
|
197
|
+
if format_name == "json":
|
|
198
|
+
return json.loads(text)
|
|
199
|
+
if format_name == "toml":
|
|
200
|
+
if tomllib is None:
|
|
201
|
+
raise ValueError("TOML parser unavailable")
|
|
202
|
+
return tomllib.loads(text)
|
|
203
|
+
if format_name == "yaml":
|
|
204
|
+
return _parse_simple_yaml(path)
|
|
205
|
+
raise ValueError(f"unsupported config format: {format_name}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _walk_strings(value: object):
|
|
209
|
+
if isinstance(value, str):
|
|
210
|
+
yield value
|
|
211
|
+
elif isinstance(value, dict):
|
|
212
|
+
for item in value.values():
|
|
213
|
+
yield from _walk_strings(item)
|
|
214
|
+
elif isinstance(value, list):
|
|
215
|
+
for item in value:
|
|
216
|
+
yield from _walk_strings(item)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _parse_simple_yaml(path: Path) -> dict[str, object]:
|
|
220
|
+
root: dict[str, object] = {}
|
|
221
|
+
stack: list[tuple[int, dict[str, object]]] = [(-1, root)]
|
|
222
|
+
for line_number, raw_line in enumerate(
|
|
223
|
+
path.read_text(encoding="utf-8").splitlines(),
|
|
224
|
+
start=1,
|
|
225
|
+
):
|
|
226
|
+
if not raw_line.strip() or raw_line.lstrip().startswith("#"):
|
|
227
|
+
continue
|
|
228
|
+
indent = len(raw_line) - len(raw_line.lstrip())
|
|
229
|
+
stripped = raw_line.strip()
|
|
230
|
+
key, separator, raw_value = stripped.partition(":")
|
|
231
|
+
if not separator or not key.strip():
|
|
232
|
+
raise ValueError(f"invalid mapping at line {line_number}")
|
|
233
|
+
while stack[-1][0] >= indent:
|
|
234
|
+
stack.pop()
|
|
235
|
+
parent = stack[-1][1]
|
|
236
|
+
value = raw_value.strip()
|
|
237
|
+
if value:
|
|
238
|
+
parent[key.strip()] = _parse_scalar(value)
|
|
239
|
+
else:
|
|
240
|
+
child: dict[str, object] = {}
|
|
241
|
+
parent[key.strip()] = child
|
|
242
|
+
stack.append((indent, child))
|
|
243
|
+
return root
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _parse_scalar(value: str) -> object:
|
|
247
|
+
if value == "true":
|
|
248
|
+
return True
|
|
249
|
+
if value == "false":
|
|
250
|
+
return False
|
|
251
|
+
if value.isdecimal():
|
|
252
|
+
return int(value)
|
|
253
|
+
return value
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _build_manifest(raw: dict[str, Any]) -> HostManifest:
|
|
257
|
+
schema_version = raw.get("schemaVersion")
|
|
258
|
+
if schema_version != 1:
|
|
259
|
+
raise HostManifestError(
|
|
260
|
+
f"unsupported host asset schemaVersion: {schema_version}"
|
|
261
|
+
)
|
|
262
|
+
capability_values = _string_tuple(
|
|
263
|
+
raw.get("capabilityValues"),
|
|
264
|
+
"capabilityValues",
|
|
265
|
+
)
|
|
266
|
+
platforms_raw = raw.get("platforms")
|
|
267
|
+
if not isinstance(platforms_raw, list) or not platforms_raw:
|
|
268
|
+
raise HostManifestError("platforms must be a non-empty array")
|
|
269
|
+
platforms = tuple(
|
|
270
|
+
_build_platform(item)
|
|
271
|
+
for item in platforms_raw
|
|
272
|
+
if isinstance(item, dict)
|
|
273
|
+
)
|
|
274
|
+
if len(platforms) != len(platforms_raw):
|
|
275
|
+
raise HostManifestError("every platform must be an object")
|
|
276
|
+
sync_raw = raw.get("syncPolicy")
|
|
277
|
+
if not isinstance(sync_raw, dict):
|
|
278
|
+
raise HostManifestError("syncPolicy must be an object")
|
|
279
|
+
return HostManifest(
|
|
280
|
+
schema_version=schema_version,
|
|
281
|
+
capability_values=capability_values,
|
|
282
|
+
platforms=platforms,
|
|
283
|
+
excluded_prefixes=_string_tuple(
|
|
284
|
+
raw.get("excludedPrefixes"),
|
|
285
|
+
"excludedPrefixes",
|
|
286
|
+
),
|
|
287
|
+
sync_policy=SyncPolicy(
|
|
288
|
+
protected_files=_string_tuple(
|
|
289
|
+
sync_raw.get("protectedFiles"),
|
|
290
|
+
"syncPolicy.protectedFiles",
|
|
291
|
+
),
|
|
292
|
+
protected_prefixes=_string_tuple(
|
|
293
|
+
sync_raw.get("protectedPrefixes"),
|
|
294
|
+
"syncPolicy.protectedPrefixes",
|
|
295
|
+
),
|
|
296
|
+
safe_files=_string_tuple(
|
|
297
|
+
sync_raw.get("safeFiles"),
|
|
298
|
+
"syncPolicy.safeFiles",
|
|
299
|
+
),
|
|
300
|
+
safe_prefixes=_string_tuple(
|
|
301
|
+
sync_raw.get("safePrefixes"),
|
|
302
|
+
"syncPolicy.safePrefixes",
|
|
303
|
+
),
|
|
304
|
+
managed_block_files=_string_tuple(
|
|
305
|
+
sync_raw.get("managedBlockFiles"),
|
|
306
|
+
"syncPolicy.managedBlockFiles",
|
|
307
|
+
),
|
|
308
|
+
obsolete_files=_string_tuple(
|
|
309
|
+
sync_raw.get("obsoleteFiles"),
|
|
310
|
+
"syncPolicy.obsoleteFiles",
|
|
311
|
+
),
|
|
312
|
+
),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _build_platform(raw: dict[str, Any]) -> HostPlatform:
|
|
317
|
+
platform_id = _required_string(raw, "id")
|
|
318
|
+
capabilities = raw.get("capabilities")
|
|
319
|
+
if not isinstance(capabilities, dict) or not capabilities:
|
|
320
|
+
raise HostManifestError(
|
|
321
|
+
f"platform {platform_id} capabilities must be an object"
|
|
322
|
+
)
|
|
323
|
+
normalized_capabilities: dict[str, str] = {}
|
|
324
|
+
for name, value in capabilities.items():
|
|
325
|
+
if not isinstance(name, str) or not isinstance(value, str):
|
|
326
|
+
raise HostManifestError(
|
|
327
|
+
f"platform {platform_id} capabilities must be strings"
|
|
328
|
+
)
|
|
329
|
+
normalized_capabilities[name] = value
|
|
330
|
+
command_targets_raw = raw.get("commandTargets")
|
|
331
|
+
if not isinstance(command_targets_raw, list):
|
|
332
|
+
raise HostManifestError(
|
|
333
|
+
f"platform {platform_id} commandTargets must be an array"
|
|
334
|
+
)
|
|
335
|
+
command_targets = tuple(
|
|
336
|
+
CommandTarget(
|
|
337
|
+
config=_required_string(item, "config"),
|
|
338
|
+
format=_required_string(item, "format"),
|
|
339
|
+
target=_required_string(item, "target"),
|
|
340
|
+
)
|
|
341
|
+
for item in command_targets_raw
|
|
342
|
+
if isinstance(item, dict)
|
|
343
|
+
)
|
|
344
|
+
if len(command_targets) != len(command_targets_raw):
|
|
345
|
+
raise HostManifestError(
|
|
346
|
+
f"platform {platform_id} commandTargets must be objects"
|
|
347
|
+
)
|
|
348
|
+
skill_target = raw.get("skillTarget")
|
|
349
|
+
if skill_target is not None and not isinstance(skill_target, str):
|
|
350
|
+
raise HostManifestError(
|
|
351
|
+
f"platform {platform_id} skillTarget must be a string or null"
|
|
352
|
+
)
|
|
353
|
+
return HostPlatform(
|
|
354
|
+
id=platform_id,
|
|
355
|
+
display_name=_required_string(raw, "displayName"),
|
|
356
|
+
aliases=_string_tuple(raw.get("aliases"), f"{platform_id}.aliases"),
|
|
357
|
+
detect_any=_string_tuple(
|
|
358
|
+
raw.get("detectAny"),
|
|
359
|
+
f"{platform_id}.detectAny",
|
|
360
|
+
),
|
|
361
|
+
asset_prefixes=_string_tuple(
|
|
362
|
+
raw.get("assetPrefixes"),
|
|
363
|
+
f"{platform_id}.assetPrefixes",
|
|
364
|
+
),
|
|
365
|
+
asset_files=_string_tuple(
|
|
366
|
+
raw.get("assetFiles"),
|
|
367
|
+
f"{platform_id}.assetFiles",
|
|
368
|
+
),
|
|
369
|
+
skill_target=skill_target,
|
|
370
|
+
adapter_path=_required_string(raw, "adapterPath"),
|
|
371
|
+
capabilities=normalized_capabilities,
|
|
372
|
+
command_targets=command_targets,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _required_string(raw: dict[str, Any], key: str) -> str:
|
|
377
|
+
value = raw.get(key)
|
|
378
|
+
if not isinstance(value, str) or not value.strip():
|
|
379
|
+
raise HostManifestError(f"{key} must be a non-empty string")
|
|
380
|
+
return value.strip()
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _string_tuple(value: object, label: str) -> tuple[str, ...]:
|
|
384
|
+
if not isinstance(value, list):
|
|
385
|
+
raise HostManifestError(f"{label} must be an array")
|
|
386
|
+
result: list[str] = []
|
|
387
|
+
for item in value:
|
|
388
|
+
if not isinstance(item, str) or not item.strip():
|
|
389
|
+
raise HostManifestError(
|
|
390
|
+
f"{label} entries must be non-empty strings"
|
|
391
|
+
)
|
|
392
|
+
result.append(item.strip())
|
|
393
|
+
return tuple(result)
|