cowork-flow 0.0.48 → 0.0.49

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 (43) hide show
  1. package/README.md +16 -1
  2. package/package.json +6 -3
  3. package/src/cli.js +6 -0
  4. package/src/commands/source-refresh.js +40 -0
  5. package/src/lib/copy-template.js +130 -1
  6. package/src/lib/host-assets.js +216 -71
  7. package/template/.codex/hooks.json +1 -1
  8. package/template/.cowork-flow/.version +1 -1
  9. package/template/.cowork-flow/scripts/adapters/cli/task.py +0 -2
  10. package/template/.cowork-flow/scripts/adapters/cli/task_archive_commands.py +12 -0
  11. package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +18 -133
  12. package/template/.cowork-flow/scripts/adapters/cli/task_navigation.py +82 -1
  13. package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +98 -114
  14. package/template/.cowork-flow/scripts/infra/skill_manifest.py +23 -3
  15. package/template/.cowork-flow/scripts/infra/storage/operation_log.py +40 -0
  16. package/template/.cowork-flow/scripts/infra/storage/state_store.py +356 -1
  17. package/template/.cowork-flow/scripts/services/context_discovery.py +156 -0
  18. package/template/.cowork-flow/scripts/services/context_jsonl.py +119 -0
  19. package/template/.cowork-flow/scripts/services/context_paths.py +186 -0
  20. package/template/.cowork-flow/scripts/services/lifecycle_checks.py +23 -4
  21. package/template/.cowork-flow/scripts/services/lifecycle_policy.py +128 -0
  22. package/template/.cowork-flow/scripts/services/plan_binding.py +146 -0
  23. package/template/.cowork-flow/scripts/services/task_archive.py +100 -14
  24. package/template/.cowork-flow/scripts/services/task_context.py +49 -479
  25. package/template/.cowork-flow/scripts/services/task_creation.py +31 -27
  26. package/template/.cowork-flow/scripts/services/task_lifecycle.py +155 -18
  27. package/template/.cowork-flow/spec/contracts/error-output-as-data.md +8 -0
  28. package/template/.cowork-flow/spec/runtime/index.md +1 -1
  29. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +32 -6
  30. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  31. package/template/skills/batch-execution/scripts/batch_execution.py +111 -629
  32. package/template/skills/batch-execution/scripts/batch_recovery.py +94 -0
  33. package/template/skills/batch-execution/scripts/batch_state_machine.py +201 -0
  34. package/template/skills/batch-execution/scripts/batch_verification.py +326 -0
  35. package/template/skills/cowork-flow/SKILL.md +3 -0
  36. package/template/skills/party-mode/scripts/party_actions.py +95 -0
  37. package/template/skills/party-mode/scripts/party_mode_v2.py +129 -205
  38. package/template/skills/party-mode/scripts/party_reports.py +177 -0
  39. package/template/skills/party-mode/scripts/party_state_machine.py +189 -0
  40. package/template/skills/runtime-health/scripts/doctor.py +204 -15
  41. package/template/skills/task-review/scripts/review_check.py +135 -2
  42. package/template/skills/test-first/SKILL.md +18 -3
  43. package/template/skills/test-first/manifest.json +8 -4
@@ -24,6 +24,20 @@ REQUIRED_HOST_NEUTRAL_CAPABILITIES = (
24
24
  "party_board_action",
25
25
  )
26
26
  REQUIRED_CAPABILITY_MATRIX_HOSTS = ("codex", "claude-code", "opencode", "zcode")
27
+ CAPABILITY_STATUS_VALUES = (
28
+ "native",
29
+ "shim",
30
+ "plugin",
31
+ "external",
32
+ "experimental",
33
+ "unsupported",
34
+ )
35
+ MANIFEST_KEYS = frozenset(("schemaVersion", "capabilityValues", "capabilityMatrix", "platforms", "excludedPrefixes", "syncPolicy"))
36
+ PLATFORM_KEYS = frozenset(("id", "displayName", "aliases", "detectAny", "assetPrefixes", "assetFiles", "skillTarget", "adapterPath", "capabilities", "commandTargets"))
37
+ SYNC_POLICY_KEYS = frozenset(("protectedFiles", "protectedPrefixes", "safeFiles", "safePrefixes", "managedBlockFiles", "obsoleteFiles"))
38
+ COMMAND_TARGET_KEYS = frozenset(("config", "format", "target"))
39
+ COMMAND_TARGET_FORMATS = frozenset(("json", "toml", "yaml"))
40
+ CAPABILITY_DECLARATION_KEYS = frozenset(("status", "fallback"))
27
41
 
28
42
 
29
43
  class HostManifestError(RuntimeError):
@@ -309,72 +323,47 @@ def _parse_scalar(value: str) -> object:
309
323
 
310
324
 
311
325
  def _build_manifest(raw: dict[str, Any]) -> HostManifest:
326
+ _reject_unknown_fields(raw, MANIFEST_KEYS, "host asset manifest")
312
327
  schema_version = raw.get("schemaVersion")
313
328
  if schema_version != 1:
314
329
  raise HostManifestError(
315
330
  f"unsupported host asset schemaVersion: {schema_version}"
316
331
  )
317
- capability_values = _string_tuple(
318
- raw.get("capabilityValues"),
319
- "capabilityValues",
320
- )
332
+ capability_values = _string_tuple(raw.get("capabilityValues"), "capabilityValues", unique=True)
333
+ if capability_values != CAPABILITY_STATUS_VALUES:
334
+ expected = ", ".join(CAPABILITY_STATUS_VALUES)
335
+ raise HostManifestError(f"capabilityValues must be: {expected}")
321
336
  platforms_raw = raw.get("platforms")
322
337
  if not isinstance(platforms_raw, list) or not platforms_raw:
323
338
  raise HostManifestError("platforms must be a non-empty array")
324
- platforms = tuple(
325
- _build_platform(item)
326
- for item in platforms_raw
327
- if isinstance(item, dict)
328
- )
339
+ platforms = tuple(_build_platform(item, set(capability_values)) for item in platforms_raw if isinstance(item, dict))
329
340
  if len(platforms) != len(platforms_raw):
330
341
  raise HostManifestError("every platform must be an object")
342
+ _validate_platform_identity(platforms)
331
343
  required_host_capabilities, capability_matrix = _build_capability_matrix(
332
- raw.get("capabilityMatrix"),
333
- capability_values,
334
- tuple(platform.id for platform in platforms),
344
+ raw.get("capabilityMatrix"), capability_values, tuple(platform.id for platform in platforms)
335
345
  )
336
346
  sync_raw = raw.get("syncPolicy")
337
347
  if not isinstance(sync_raw, dict):
338
348
  raise HostManifestError("syncPolicy must be an object")
349
+ _reject_unknown_fields(sync_raw, SYNC_POLICY_KEYS, "syncPolicy")
339
350
  return HostManifest(
340
351
  schema_version=schema_version,
341
352
  capability_values=capability_values,
342
353
  required_host_capabilities=required_host_capabilities,
343
354
  capability_matrix=capability_matrix,
344
355
  platforms=platforms,
345
- excluded_prefixes=_string_tuple(
346
- raw.get("excludedPrefixes"),
347
- "excludedPrefixes",
348
- ),
356
+ excluded_prefixes=_string_tuple(raw.get("excludedPrefixes"), "excludedPrefixes"),
349
357
  sync_policy=SyncPolicy(
350
- protected_files=_string_tuple(
351
- sync_raw.get("protectedFiles"),
352
- "syncPolicy.protectedFiles",
353
- ),
354
- protected_prefixes=_string_tuple(
355
- sync_raw.get("protectedPrefixes"),
356
- "syncPolicy.protectedPrefixes",
357
- ),
358
- safe_files=_string_tuple(
359
- sync_raw.get("safeFiles"),
360
- "syncPolicy.safeFiles",
361
- ),
362
- safe_prefixes=_string_tuple(
363
- sync_raw.get("safePrefixes"),
364
- "syncPolicy.safePrefixes",
365
- ),
366
- managed_block_files=_string_tuple(
367
- sync_raw.get("managedBlockFiles"),
368
- "syncPolicy.managedBlockFiles",
369
- ),
370
- obsolete_files=_string_tuple(
371
- sync_raw.get("obsoleteFiles"),
372
- "syncPolicy.obsoleteFiles",
373
- ),
358
+ protected_files=_string_tuple(sync_raw.get("protectedFiles"), "syncPolicy.protectedFiles"),
359
+ protected_prefixes=_string_tuple(sync_raw.get("protectedPrefixes"), "syncPolicy.protectedPrefixes"),
360
+ safe_files=_string_tuple(sync_raw.get("safeFiles"), "syncPolicy.safeFiles"),
361
+ safe_prefixes=_string_tuple(sync_raw.get("safePrefixes"), "syncPolicy.safePrefixes"),
362
+ managed_block_files=_string_tuple(sync_raw.get("managedBlockFiles"), "syncPolicy.managedBlockFiles"),
363
+ obsolete_files=_string_tuple(sync_raw.get("obsoleteFiles"), "syncPolicy.obsoleteFiles"),
374
364
  ),
375
365
  )
376
366
 
377
-
378
367
  def _build_capability_matrix(
379
368
  raw: object,
380
369
  allowed: tuple[str, ...],
@@ -382,6 +371,7 @@ def _build_capability_matrix(
382
371
  ) -> tuple[tuple[str, ...], dict[str, dict[str, CapabilityDeclaration]]]:
383
372
  if not isinstance(raw, dict):
384
373
  raise HostManifestError("capabilityMatrix must be an object")
374
+ _reject_unknown_fields(raw, frozenset(("required", "hosts")), "capabilityMatrix")
385
375
  required = _string_tuple(raw.get("required"), "capabilityMatrix.required")
386
376
  if required != REQUIRED_HOST_NEUTRAL_CAPABILITIES:
387
377
  expected = ", ".join(REQUIRED_HOST_NEUTRAL_CAPABILITIES)
@@ -389,119 +379,75 @@ def _build_capability_matrix(
389
379
  hosts_raw = raw.get("hosts")
390
380
  if not isinstance(hosts_raw, dict) or not hosts_raw:
391
381
  raise HostManifestError("capabilityMatrix.hosts must be an object")
392
-
393
382
  required_hosts = set(REQUIRED_CAPABILITY_MATRIX_HOSTS).union(platform_ids)
394
383
  for host_id in sorted(required_hosts):
395
384
  if host_id not in hosts_raw:
396
385
  raise HostManifestError(f"capability matrix missing host: {host_id}")
397
-
398
386
  allowed_values = set(allowed)
399
387
  matrix: dict[str, dict[str, CapabilityDeclaration]] = {}
400
388
  for host_id, capabilities_raw in hosts_raw.items():
401
389
  if not isinstance(host_id, str) or not host_id.strip():
402
390
  raise HostManifestError("capabilityMatrix host ids must be strings")
403
391
  if not isinstance(capabilities_raw, dict):
404
- raise HostManifestError(
405
- f"capabilityMatrix host {host_id} must be an object"
406
- )
392
+ raise HostManifestError(f"capabilityMatrix host {host_id} must be an object")
407
393
  unknown = sorted(set(capabilities_raw) - set(required))
408
394
  if unknown:
409
- raise HostManifestError(
410
- f"unknown host-neutral capability {host_id}:{unknown[0]}"
411
- )
395
+ raise HostManifestError(f"unknown host-neutral capability {host_id}:{unknown[0]}")
412
396
  matrix[host_id] = {}
413
397
  for capability in required:
414
398
  declaration = capabilities_raw.get(capability)
415
399
  if not isinstance(declaration, dict):
416
- raise HostManifestError(
417
- f"missing host-neutral capability {host_id}:{capability}"
418
- )
400
+ raise HostManifestError(f"missing host-neutral capability {host_id}:{capability}")
401
+ _reject_unknown_fields(declaration, CAPABILITY_DECLARATION_KEYS, f"capabilityMatrix.hosts.{host_id}.{capability}")
419
402
  status = declaration.get("status")
420
403
  if not isinstance(status, str) or status not in allowed_values:
421
- raise HostManifestError(
422
- f"illegal host-neutral capability {host_id}:{capability}={status}"
423
- )
404
+ raise HostManifestError(f"illegal host-neutral capability {host_id}:{capability}={status}")
424
405
  fallback_raw = declaration.get("fallback")
425
406
  fallback: str | None = None
426
407
  if fallback_raw is not None:
427
408
  if not isinstance(fallback_raw, str) or not fallback_raw.strip():
428
- raise HostManifestError(
429
- f"capability fallback must be a non-empty string: "
430
- f"{host_id}:{capability}"
431
- )
409
+ raise HostManifestError(f"capability fallback must be a non-empty string: {host_id}:{capability}")
432
410
  fallback = fallback_raw.strip()
433
411
  if status == "unsupported" and fallback is None:
434
- raise HostManifestError(
435
- f"unsupported capability requires fallback: "
436
- f"{host_id}:{capability}"
437
- )
438
- matrix[host_id][capability] = CapabilityDeclaration(
439
- status=status,
440
- fallback=fallback,
441
- )
412
+ raise HostManifestError(f"unsupported capability requires fallback: {host_id}:{capability}")
413
+ matrix[host_id][capability] = CapabilityDeclaration(status=status, fallback=fallback)
442
414
  return required, matrix
443
415
 
444
-
445
- def _build_platform(raw: dict[str, Any]) -> HostPlatform:
416
+ def _build_platform(raw: dict[str, Any], allowed: set[str]) -> HostPlatform:
417
+ _reject_unknown_fields(raw, PLATFORM_KEYS, "platform")
446
418
  platform_id = _required_string(raw, "id")
447
419
  capabilities = raw.get("capabilities")
448
420
  if not isinstance(capabilities, dict) or not capabilities:
449
- raise HostManifestError(
450
- f"platform {platform_id} capabilities must be an object"
451
- )
421
+ raise HostManifestError(f"platform {platform_id} capabilities must be an object")
452
422
  normalized_capabilities: dict[str, str] = {}
453
423
  for name, value in capabilities.items():
454
- if not isinstance(name, str) or not isinstance(value, str):
455
- raise HostManifestError(
456
- f"platform {platform_id} capabilities must be strings"
457
- )
424
+ if not isinstance(name, str) or not name.strip():
425
+ raise HostManifestError(f"platform {platform_id} capabilities must use string keys")
426
+ if not isinstance(value, str) or value not in allowed:
427
+ raise HostManifestError(f"platform {platform_id} illegal capability status {name}={value}")
458
428
  normalized_capabilities[name] = value
459
429
  command_targets_raw = raw.get("commandTargets")
460
430
  if not isinstance(command_targets_raw, list):
461
- raise HostManifestError(
462
- f"platform {platform_id} commandTargets must be an array"
463
- )
464
- command_targets = tuple(
465
- CommandTarget(
466
- config=_required_string(item, "config"),
467
- format=_required_string(item, "format"),
468
- target=_required_string(item, "target"),
469
- )
470
- for item in command_targets_raw
471
- if isinstance(item, dict)
472
- )
473
- if len(command_targets) != len(command_targets_raw):
474
- raise HostManifestError(
475
- f"platform {platform_id} commandTargets must be objects"
476
- )
431
+ raise HostManifestError(f"platform {platform_id} commandTargets must be an array")
432
+ command_targets = tuple(_build_command_target(item, platform_id) for item in command_targets_raw)
477
433
  skill_target = raw.get("skillTarget")
478
- if skill_target is not None and not isinstance(skill_target, str):
479
- raise HostManifestError(
480
- f"platform {platform_id} skillTarget must be a string or null"
481
- )
434
+ if skill_target is not None:
435
+ if not isinstance(skill_target, str) or not skill_target.strip():
436
+ raise HostManifestError(f"platform {platform_id} skillTarget must be a string or null")
437
+ skill_target = skill_target.strip()
482
438
  return HostPlatform(
483
439
  id=platform_id,
484
440
  display_name=_required_string(raw, "displayName"),
485
441
  aliases=_string_tuple(raw.get("aliases"), f"{platform_id}.aliases"),
486
- detect_any=_string_tuple(
487
- raw.get("detectAny"),
488
- f"{platform_id}.detectAny",
489
- ),
490
- asset_prefixes=_string_tuple(
491
- raw.get("assetPrefixes"),
492
- f"{platform_id}.assetPrefixes",
493
- ),
494
- asset_files=_string_tuple(
495
- raw.get("assetFiles"),
496
- f"{platform_id}.assetFiles",
497
- ),
442
+ detect_any=_string_tuple(raw.get("detectAny"), f"{platform_id}.detectAny"),
443
+ asset_prefixes=_string_tuple(raw.get("assetPrefixes"), f"{platform_id}.assetPrefixes"),
444
+ asset_files=_string_tuple(raw.get("assetFiles"), f"{platform_id}.assetFiles"),
498
445
  skill_target=skill_target,
499
446
  adapter_path=_required_string(raw, "adapterPath"),
500
447
  capabilities=normalized_capabilities,
501
448
  command_targets=command_targets,
502
449
  )
503
450
 
504
-
505
451
  def _required_string(raw: dict[str, Any], key: str) -> str:
506
452
  value = raw.get(key)
507
453
  if not isinstance(value, str) or not value.strip():
@@ -509,14 +455,52 @@ def _required_string(raw: dict[str, Any], key: str) -> str:
509
455
  return value.strip()
510
456
 
511
457
 
512
- def _string_tuple(value: object, label: str) -> tuple[str, ...]:
458
+ def _string_tuple(value: object, label: str, *, unique: bool = False) -> tuple[str, ...]:
513
459
  if not isinstance(value, list):
514
460
  raise HostManifestError(f"{label} must be an array")
515
461
  result: list[str] = []
462
+ seen: set[str] = set()
516
463
  for item in value:
517
464
  if not isinstance(item, str) or not item.strip():
518
- raise HostManifestError(
519
- f"{label} entries must be non-empty strings"
520
- )
521
- result.append(item.strip())
465
+ raise HostManifestError(f"{label} entries must be non-empty strings")
466
+ normalized = item.strip()
467
+ if unique and normalized in seen:
468
+ raise HostManifestError(f"{label} entries must be unique: {normalized}")
469
+ seen.add(normalized)
470
+ result.append(normalized)
522
471
  return tuple(result)
472
+
473
+
474
+ def _build_command_target(raw: object, platform_id: str) -> CommandTarget:
475
+ if not isinstance(raw, dict):
476
+ raise HostManifestError(f"platform {platform_id} commandTargets must be objects")
477
+ _reject_unknown_fields(raw, COMMAND_TARGET_KEYS, f"platform {platform_id} commandTarget")
478
+ format_value = _required_string(raw, "format")
479
+ if format_value not in COMMAND_TARGET_FORMATS:
480
+ raise HostManifestError(f"platform {platform_id} commandTarget.format must be json, toml, or yaml")
481
+ return CommandTarget(
482
+ config=_required_string(raw, "config"),
483
+ format=format_value,
484
+ target=_required_string(raw, "target"),
485
+ )
486
+
487
+
488
+ def _validate_platform_identity(platforms: tuple[HostPlatform, ...]) -> None:
489
+ ids: set[str] = set()
490
+ aliases: dict[str, str] = {}
491
+ for platform in platforms:
492
+ if platform.id in ids:
493
+ raise HostManifestError(f"duplicate platform id: {platform.id}")
494
+ ids.add(platform.id)
495
+ for alias in platform.aliases:
496
+ normalized = alias.lower()
497
+ owner = aliases.get(normalized)
498
+ if owner is not None:
499
+ raise HostManifestError(f"duplicate platform alias {alias}: {owner}, {platform.id}")
500
+ aliases[normalized] = platform.id
501
+
502
+
503
+ def _reject_unknown_fields(raw: dict[str, Any], allowed: frozenset[str], label: str) -> None:
504
+ unknown = sorted(set(raw) - allowed)
505
+ if unknown:
506
+ raise HostManifestError(f"{label} unknown field: {unknown[0]}")
@@ -312,8 +312,28 @@ def _parse_manifest(path: Path, raw: dict[str, Any]) -> SkillManifest:
312
312
  )
313
313
 
314
314
 
315
+ def _replica_precedence(repo_root: Path, manifest: SkillManifest) -> tuple[int, str]:
316
+ root = Path(repo_root).resolve()
317
+ source_template = Path(__file__).resolve().parents[3] / "skills"
318
+ ordered_roots = (
319
+ root / "template" / "skills",
320
+ source_template,
321
+ root / "skills",
322
+ root / ".agents" / "skills",
323
+ root / ".claude" / "skills",
324
+ )
325
+ manifest_path = manifest.path.resolve()
326
+ for index, candidate in enumerate(ordered_roots):
327
+ try:
328
+ manifest_path.relative_to(candidate.resolve())
329
+ except ValueError:
330
+ continue
331
+ return (index, str(manifest_path))
332
+ return (len(ordered_roots), str(manifest_path))
333
+
334
+
315
335
  def load_skill_manifests(repo_root: Path) -> tuple[SkillManifest, ...]:
316
- """Load every valid replica and reject invalid or conflicting metadata."""
336
+ """Load valid Skill metadata and prefer tracked source replicas."""
317
337
  manifests: dict[str, list[SkillManifest]] = {}
318
338
  for root in skill_roots(repo_root):
319
339
  if not root.is_dir():
@@ -331,7 +351,7 @@ def load_skill_manifests(repo_root: Path) -> tuple[SkillManifest, ...]:
331
351
  if any(_manifest_signature(replica) != canonical for replica in replicas[1:]):
332
352
  paths = ", ".join(str(replica.path) for replica in replicas)
333
353
  raise SkillManifestError(f"conflicting Skill manifest replicas: {skill}: {paths}")
334
- result.append(replicas[0])
354
+ result.append(sorted(replicas, key=lambda replica: _replica_precedence(repo_root, replica))[0])
335
355
  command_owners: dict[str, str] = {}
336
356
  for manifest in result:
337
357
  for command in manifest.commands:
@@ -364,7 +384,7 @@ def _manifest_signature(manifest: SkillManifest) -> tuple[object, ...]:
364
384
  for item in manifest.context_rules
365
385
  ),
366
386
  tuple(
367
- (item.name, item.aliases, item.script, item.script_digest)
387
+ (item.name, item.aliases, item.script)
368
388
  for item in manifest.commands
369
389
  ),
370
390
  )
@@ -70,3 +70,43 @@ class OperationLog:
70
70
  if record.get("phase") not in FINAL_PHASES:
71
71
  records.append(record)
72
72
  return tuple(records)
73
+ def pending_facts(self) -> tuple[dict, ...]:
74
+ """Return read-only facts about non-final operation records."""
75
+ if not self.operations_dir.is_dir():
76
+ return ()
77
+ facts: list[dict] = []
78
+ for path in sorted(self.operations_dir.glob("*.json")):
79
+ try:
80
+ record = self.state_store.load(path).data
81
+ except StateStoreError as error:
82
+ facts.append(
83
+ {
84
+ "path": str(path),
85
+ "phase": "unreadable",
86
+ "error": {
87
+ "code": error.code,
88
+ "path": str(error.path),
89
+ "detail": error.detail,
90
+ },
91
+ }
92
+ )
93
+ continue
94
+ phase = record.get("phase")
95
+ if phase == "committed":
96
+ continue
97
+ participants = record.get("participants")
98
+ facts.append(
99
+ {
100
+ "path": str(path),
101
+ "operation_id": str(record.get("operation_id") or path.stem),
102
+ "kind": str(record.get("kind") or "unknown"),
103
+ "phase": str(phase or "unknown"),
104
+ "participant_count": (
105
+ len(participants)
106
+ if isinstance(participants, list)
107
+ else 0
108
+ ),
109
+ "error": record.get("error"),
110
+ }
111
+ )
112
+ return tuple(facts)