cowork-flow 0.0.47 → 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.
- package/CHANGELOG.md +41 -0
- package/README.md +102 -23
- package/package.json +8 -3
- package/src/cli.js +6 -0
- package/src/commands/init.js +0 -83
- package/src/commands/source-refresh.js +40 -0
- package/src/commands/sync.js +6 -0
- package/src/commands/update.js +59 -6
- package/src/lib/copy-template.js +200 -50
- package/src/lib/host-assets.js +367 -64
- package/src/lib/plan-applier.js +47 -0
- package/template/.claude/hooks/inject-workflow-state.py +7 -0
- package/template/.codex/hooks/inject-workflow-state.py +7 -0
- package/template/.codex/hooks.json +1 -1
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/config.yaml +0 -11
- package/template/.cowork-flow/scripts/adapters/cli/init_developer.py +0 -1
- package/template/.cowork-flow/scripts/adapters/cli/subagent.py +7 -4
- package/template/.cowork-flow/scripts/adapters/cli/task.py +8 -484
- package/template/.cowork-flow/scripts/adapters/cli/task_archive_commands.py +15 -134
- package/template/.cowork-flow/scripts/adapters/cli/task_context_commands.py +7 -1
- package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +388 -0
- package/template/.cowork-flow/scripts/adapters/cli/task_navigation.py +163 -2
- package/template/.cowork-flow/scripts/adapters/git/git_context.py +16 -193
- package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +170 -84
- package/template/.cowork-flow/scripts/infra/config.py +0 -17
- package/template/.cowork-flow/scripts/infra/developer_profile.py +0 -95
- package/template/.cowork-flow/scripts/infra/paths.py +0 -79
- package/template/.cowork-flow/scripts/infra/skill_manifest.py +23 -3
- package/template/.cowork-flow/scripts/infra/storage/operation_log.py +40 -0
- package/template/.cowork-flow/scripts/infra/storage/state_store.py +356 -1
- package/template/.cowork-flow/scripts/kernel/workflow_route.py +15 -15
- package/template/.cowork-flow/scripts/run.py +0 -5
- package/template/.cowork-flow/scripts/services/context_discovery.py +156 -0
- package/template/.cowork-flow/scripts/services/context_jsonl.py +119 -0
- package/template/.cowork-flow/scripts/services/context_paths.py +186 -0
- package/template/.cowork-flow/scripts/services/lifecycle_checks.py +186 -69
- package/template/.cowork-flow/scripts/services/lifecycle_policy.py +128 -0
- package/template/.cowork-flow/scripts/services/plan_binding.py +146 -0
- package/template/.cowork-flow/scripts/services/readiness.py +3 -170
- package/template/.cowork-flow/scripts/services/task_archive.py +115 -24
- package/template/.cowork-flow/scripts/services/task_context.py +103 -365
- package/template/.cowork-flow/scripts/services/task_creation.py +40 -5
- package/template/.cowork-flow/scripts/services/task_lifecycle.py +155 -18
- package/template/.cowork-flow/scripts/services/task_routing.py +80 -67
- package/template/.cowork-flow/scripts/services/task_utils.py +0 -146
- package/template/.cowork-flow/scripts/services/workflow_runtime.py +1 -1
- package/template/.cowork-flow/spec/contracts/capabilities.md +23 -0
- package/template/.cowork-flow/spec/contracts/decision-anchor.md +2 -2
- package/template/.cowork-flow/spec/contracts/error-output-as-data.md +8 -0
- package/template/.cowork-flow/spec/contracts/index.md +1 -0
- package/template/.cowork-flow/spec/contracts/party-mode-v2-board.md +20 -0
- package/template/.cowork-flow/spec/contracts/plan-binding.md +50 -0
- package/template/.cowork-flow/spec/contracts/skill-owned-actions.md +31 -7
- package/template/.cowork-flow/spec/guides/index.md +3 -1
- package/template/.cowork-flow/spec/index.md +5 -4
- package/template/.cowork-flow/spec/references/definition-of-done.md +2 -3
- package/template/.cowork-flow/spec/references/orchestration-patterns.md +1 -1
- package/template/.cowork-flow/spec/runtime/contract-registry.json +14 -0
- package/template/.cowork-flow/spec/runtime/host-assets.json +73 -3
- package/template/.cowork-flow/spec/runtime/index.md +1 -1
- package/template/.cowork-flow/spec/schemas/host-assets.schema.json +150 -8
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/skills/batch-execution/SKILL.md +37 -3
- package/template/skills/batch-execution/scripts/batch_action.py +4 -0
- package/template/skills/batch-execution/scripts/batch_execution.py +120 -499
- package/template/skills/batch-execution/scripts/batch_recovery.py +94 -0
- package/template/skills/batch-execution/scripts/batch_state_machine.py +201 -0
- package/template/skills/batch-execution/scripts/batch_verification.py +326 -0
- package/template/skills/brainstorming/SKILL.md +8 -7
- package/template/skills/cowork-flow/SKILL.md +3 -0
- package/template/skills/party-mode/scripts/party_action_contract.py +113 -0
- package/template/skills/party-mode/scripts/party_actions.py +95 -0
- package/template/skills/party-mode/scripts/party_board_store.py +148 -0
- package/template/skills/party-mode/scripts/party_mode_v2.py +345 -317
- package/template/skills/party-mode/scripts/party_reports.py +177 -0
- package/template/skills/party-mode/scripts/party_state_machine.py +189 -0
- package/template/skills/runtime-health/SKILL.md +14 -2
- package/template/skills/runtime-health/scripts/doctor.py +278 -41
- package/template/skills/task-planning/SKILL.md +105 -61
- package/template/skills/task-review/SKILL.md +4 -4
- package/template/skills/task-review/scripts/review_check.py +277 -20
- package/template/skills/test-first/SKILL.md +18 -3
- package/template/skills/test-first/manifest.json +8 -4
- package/template/.cowork-flow/changes/archive/.gitkeep +0 -1
- package/template/.cowork-flow/scripts/adapters/cli/add_session.py +0 -465
- package/template/.cowork-flow/scripts/adapters/cli/change.py +0 -412
- package/template/.cowork-flow/workspace/index.md +0 -102
- package/template/.zcode/scaffold/AGENTS.md +0 -145
- package/template/.zcode/scaffold/CLAUDE.md +0 -5
|
@@ -17,6 +17,28 @@ except ModuleNotFoundError: # pragma: no cover - Python < 3.11
|
|
|
17
17
|
MANIFEST_PATH = Path(".cowork-flow/spec/runtime/host-assets.json")
|
|
18
18
|
SCHEMA_PATH = Path(".cowork-flow/spec/schemas/host-assets.schema.json")
|
|
19
19
|
|
|
20
|
+
REQUIRED_HOST_NEUTRAL_CAPABILITIES = (
|
|
21
|
+
"task_action",
|
|
22
|
+
"subagent_dispatch",
|
|
23
|
+
"file_write",
|
|
24
|
+
"party_board_action",
|
|
25
|
+
)
|
|
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"))
|
|
41
|
+
|
|
20
42
|
|
|
21
43
|
class HostManifestError(RuntimeError):
|
|
22
44
|
"""Raised when the Host Asset Manifest is unavailable or invalid."""
|
|
@@ -29,6 +51,12 @@ class CommandTarget:
|
|
|
29
51
|
target: str
|
|
30
52
|
|
|
31
53
|
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class CapabilityDeclaration:
|
|
56
|
+
status: str
|
|
57
|
+
fallback: str | None
|
|
58
|
+
|
|
59
|
+
|
|
32
60
|
@dataclass(frozen=True)
|
|
33
61
|
class HostPlatform:
|
|
34
62
|
id: str
|
|
@@ -57,6 +85,8 @@ class SyncPolicy:
|
|
|
57
85
|
class HostManifest:
|
|
58
86
|
schema_version: int
|
|
59
87
|
capability_values: tuple[str, ...]
|
|
88
|
+
required_host_capabilities: tuple[str, ...]
|
|
89
|
+
capability_matrix: dict[str, dict[str, CapabilityDeclaration]]
|
|
60
90
|
platforms: tuple[HostPlatform, ...]
|
|
61
91
|
excluded_prefixes: tuple[str, ...]
|
|
62
92
|
sync_policy: SyncPolicy
|
|
@@ -80,6 +110,18 @@ class HostManifest:
|
|
|
80
110
|
return platform.id
|
|
81
111
|
raise HostManifestError(f"unknown host platform alias: {alias}")
|
|
82
112
|
|
|
113
|
+
def host_capability(
|
|
114
|
+
self,
|
|
115
|
+
host_id: str,
|
|
116
|
+
capability: str,
|
|
117
|
+
) -> CapabilityDeclaration:
|
|
118
|
+
try:
|
|
119
|
+
return self.capability_matrix[host_id][capability]
|
|
120
|
+
except KeyError as error:
|
|
121
|
+
raise HostManifestError(
|
|
122
|
+
f"unknown host-neutral capability: {host_id}:{capability}"
|
|
123
|
+
) from error
|
|
124
|
+
|
|
83
125
|
|
|
84
126
|
def load_host_manifest(template_root: Path) -> HostManifest:
|
|
85
127
|
path = Path(template_root) / MANIFEST_PATH
|
|
@@ -281,125 +323,131 @@ def _parse_scalar(value: str) -> object:
|
|
|
281
323
|
|
|
282
324
|
|
|
283
325
|
def _build_manifest(raw: dict[str, Any]) -> HostManifest:
|
|
326
|
+
_reject_unknown_fields(raw, MANIFEST_KEYS, "host asset manifest")
|
|
284
327
|
schema_version = raw.get("schemaVersion")
|
|
285
328
|
if schema_version != 1:
|
|
286
329
|
raise HostManifestError(
|
|
287
330
|
f"unsupported host asset schemaVersion: {schema_version}"
|
|
288
331
|
)
|
|
289
|
-
capability_values = _string_tuple(
|
|
290
|
-
|
|
291
|
-
"
|
|
292
|
-
|
|
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}")
|
|
293
336
|
platforms_raw = raw.get("platforms")
|
|
294
337
|
if not isinstance(platforms_raw, list) or not platforms_raw:
|
|
295
338
|
raise HostManifestError("platforms must be a non-empty array")
|
|
296
|
-
platforms = tuple(
|
|
297
|
-
_build_platform(item)
|
|
298
|
-
for item in platforms_raw
|
|
299
|
-
if isinstance(item, dict)
|
|
300
|
-
)
|
|
339
|
+
platforms = tuple(_build_platform(item, set(capability_values)) for item in platforms_raw if isinstance(item, dict))
|
|
301
340
|
if len(platforms) != len(platforms_raw):
|
|
302
341
|
raise HostManifestError("every platform must be an object")
|
|
342
|
+
_validate_platform_identity(platforms)
|
|
343
|
+
required_host_capabilities, capability_matrix = _build_capability_matrix(
|
|
344
|
+
raw.get("capabilityMatrix"), capability_values, tuple(platform.id for platform in platforms)
|
|
345
|
+
)
|
|
303
346
|
sync_raw = raw.get("syncPolicy")
|
|
304
347
|
if not isinstance(sync_raw, dict):
|
|
305
348
|
raise HostManifestError("syncPolicy must be an object")
|
|
349
|
+
_reject_unknown_fields(sync_raw, SYNC_POLICY_KEYS, "syncPolicy")
|
|
306
350
|
return HostManifest(
|
|
307
351
|
schema_version=schema_version,
|
|
308
352
|
capability_values=capability_values,
|
|
353
|
+
required_host_capabilities=required_host_capabilities,
|
|
354
|
+
capability_matrix=capability_matrix,
|
|
309
355
|
platforms=platforms,
|
|
310
|
-
excluded_prefixes=_string_tuple(
|
|
311
|
-
raw.get("excludedPrefixes"),
|
|
312
|
-
"excludedPrefixes",
|
|
313
|
-
),
|
|
356
|
+
excluded_prefixes=_string_tuple(raw.get("excludedPrefixes"), "excludedPrefixes"),
|
|
314
357
|
sync_policy=SyncPolicy(
|
|
315
|
-
protected_files=_string_tuple(
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
),
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
"syncPolicy.protectedPrefixes",
|
|
322
|
-
),
|
|
323
|
-
safe_files=_string_tuple(
|
|
324
|
-
sync_raw.get("safeFiles"),
|
|
325
|
-
"syncPolicy.safeFiles",
|
|
326
|
-
),
|
|
327
|
-
safe_prefixes=_string_tuple(
|
|
328
|
-
sync_raw.get("safePrefixes"),
|
|
329
|
-
"syncPolicy.safePrefixes",
|
|
330
|
-
),
|
|
331
|
-
managed_block_files=_string_tuple(
|
|
332
|
-
sync_raw.get("managedBlockFiles"),
|
|
333
|
-
"syncPolicy.managedBlockFiles",
|
|
334
|
-
),
|
|
335
|
-
obsolete_files=_string_tuple(
|
|
336
|
-
sync_raw.get("obsoleteFiles"),
|
|
337
|
-
"syncPolicy.obsoleteFiles",
|
|
338
|
-
),
|
|
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"),
|
|
339
364
|
),
|
|
340
365
|
)
|
|
341
366
|
|
|
342
|
-
|
|
343
|
-
|
|
367
|
+
def _build_capability_matrix(
|
|
368
|
+
raw: object,
|
|
369
|
+
allowed: tuple[str, ...],
|
|
370
|
+
platform_ids: tuple[str, ...],
|
|
371
|
+
) -> tuple[tuple[str, ...], dict[str, dict[str, CapabilityDeclaration]]]:
|
|
372
|
+
if not isinstance(raw, dict):
|
|
373
|
+
raise HostManifestError("capabilityMatrix must be an object")
|
|
374
|
+
_reject_unknown_fields(raw, frozenset(("required", "hosts")), "capabilityMatrix")
|
|
375
|
+
required = _string_tuple(raw.get("required"), "capabilityMatrix.required")
|
|
376
|
+
if required != REQUIRED_HOST_NEUTRAL_CAPABILITIES:
|
|
377
|
+
expected = ", ".join(REQUIRED_HOST_NEUTRAL_CAPABILITIES)
|
|
378
|
+
raise HostManifestError(f"capabilityMatrix.required must be: {expected}")
|
|
379
|
+
hosts_raw = raw.get("hosts")
|
|
380
|
+
if not isinstance(hosts_raw, dict) or not hosts_raw:
|
|
381
|
+
raise HostManifestError("capabilityMatrix.hosts must be an object")
|
|
382
|
+
required_hosts = set(REQUIRED_CAPABILITY_MATRIX_HOSTS).union(platform_ids)
|
|
383
|
+
for host_id in sorted(required_hosts):
|
|
384
|
+
if host_id not in hosts_raw:
|
|
385
|
+
raise HostManifestError(f"capability matrix missing host: {host_id}")
|
|
386
|
+
allowed_values = set(allowed)
|
|
387
|
+
matrix: dict[str, dict[str, CapabilityDeclaration]] = {}
|
|
388
|
+
for host_id, capabilities_raw in hosts_raw.items():
|
|
389
|
+
if not isinstance(host_id, str) or not host_id.strip():
|
|
390
|
+
raise HostManifestError("capabilityMatrix host ids must be strings")
|
|
391
|
+
if not isinstance(capabilities_raw, dict):
|
|
392
|
+
raise HostManifestError(f"capabilityMatrix host {host_id} must be an object")
|
|
393
|
+
unknown = sorted(set(capabilities_raw) - set(required))
|
|
394
|
+
if unknown:
|
|
395
|
+
raise HostManifestError(f"unknown host-neutral capability {host_id}:{unknown[0]}")
|
|
396
|
+
matrix[host_id] = {}
|
|
397
|
+
for capability in required:
|
|
398
|
+
declaration = capabilities_raw.get(capability)
|
|
399
|
+
if not isinstance(declaration, dict):
|
|
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}")
|
|
402
|
+
status = declaration.get("status")
|
|
403
|
+
if not isinstance(status, str) or status not in allowed_values:
|
|
404
|
+
raise HostManifestError(f"illegal host-neutral capability {host_id}:{capability}={status}")
|
|
405
|
+
fallback_raw = declaration.get("fallback")
|
|
406
|
+
fallback: str | None = None
|
|
407
|
+
if fallback_raw is not None:
|
|
408
|
+
if not isinstance(fallback_raw, str) or not fallback_raw.strip():
|
|
409
|
+
raise HostManifestError(f"capability fallback must be a non-empty string: {host_id}:{capability}")
|
|
410
|
+
fallback = fallback_raw.strip()
|
|
411
|
+
if status == "unsupported" and fallback is None:
|
|
412
|
+
raise HostManifestError(f"unsupported capability requires fallback: {host_id}:{capability}")
|
|
413
|
+
matrix[host_id][capability] = CapabilityDeclaration(status=status, fallback=fallback)
|
|
414
|
+
return required, matrix
|
|
415
|
+
|
|
416
|
+
def _build_platform(raw: dict[str, Any], allowed: set[str]) -> HostPlatform:
|
|
417
|
+
_reject_unknown_fields(raw, PLATFORM_KEYS, "platform")
|
|
344
418
|
platform_id = _required_string(raw, "id")
|
|
345
419
|
capabilities = raw.get("capabilities")
|
|
346
420
|
if not isinstance(capabilities, dict) or not capabilities:
|
|
347
|
-
raise HostManifestError(
|
|
348
|
-
f"platform {platform_id} capabilities must be an object"
|
|
349
|
-
)
|
|
421
|
+
raise HostManifestError(f"platform {platform_id} capabilities must be an object")
|
|
350
422
|
normalized_capabilities: dict[str, str] = {}
|
|
351
423
|
for name, value in capabilities.items():
|
|
352
|
-
if not isinstance(name, str) or not
|
|
353
|
-
raise HostManifestError(
|
|
354
|
-
|
|
355
|
-
)
|
|
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}")
|
|
356
428
|
normalized_capabilities[name] = value
|
|
357
429
|
command_targets_raw = raw.get("commandTargets")
|
|
358
430
|
if not isinstance(command_targets_raw, list):
|
|
359
|
-
raise HostManifestError(
|
|
360
|
-
|
|
361
|
-
)
|
|
362
|
-
command_targets = tuple(
|
|
363
|
-
CommandTarget(
|
|
364
|
-
config=_required_string(item, "config"),
|
|
365
|
-
format=_required_string(item, "format"),
|
|
366
|
-
target=_required_string(item, "target"),
|
|
367
|
-
)
|
|
368
|
-
for item in command_targets_raw
|
|
369
|
-
if isinstance(item, dict)
|
|
370
|
-
)
|
|
371
|
-
if len(command_targets) != len(command_targets_raw):
|
|
372
|
-
raise HostManifestError(
|
|
373
|
-
f"platform {platform_id} commandTargets must be objects"
|
|
374
|
-
)
|
|
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)
|
|
375
433
|
skill_target = raw.get("skillTarget")
|
|
376
|
-
if skill_target is not None
|
|
377
|
-
|
|
378
|
-
f"platform {platform_id} skillTarget must be a string or null"
|
|
379
|
-
)
|
|
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()
|
|
380
438
|
return HostPlatform(
|
|
381
439
|
id=platform_id,
|
|
382
440
|
display_name=_required_string(raw, "displayName"),
|
|
383
441
|
aliases=_string_tuple(raw.get("aliases"), f"{platform_id}.aliases"),
|
|
384
|
-
detect_any=_string_tuple(
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
),
|
|
388
|
-
asset_prefixes=_string_tuple(
|
|
389
|
-
raw.get("assetPrefixes"),
|
|
390
|
-
f"{platform_id}.assetPrefixes",
|
|
391
|
-
),
|
|
392
|
-
asset_files=_string_tuple(
|
|
393
|
-
raw.get("assetFiles"),
|
|
394
|
-
f"{platform_id}.assetFiles",
|
|
395
|
-
),
|
|
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"),
|
|
396
445
|
skill_target=skill_target,
|
|
397
446
|
adapter_path=_required_string(raw, "adapterPath"),
|
|
398
447
|
capabilities=normalized_capabilities,
|
|
399
448
|
command_targets=command_targets,
|
|
400
449
|
)
|
|
401
450
|
|
|
402
|
-
|
|
403
451
|
def _required_string(raw: dict[str, Any], key: str) -> str:
|
|
404
452
|
value = raw.get(key)
|
|
405
453
|
if not isinstance(value, str) or not value.strip():
|
|
@@ -407,14 +455,52 @@ def _required_string(raw: dict[str, Any], key: str) -> str:
|
|
|
407
455
|
return value.strip()
|
|
408
456
|
|
|
409
457
|
|
|
410
|
-
def _string_tuple(value: object, label: str) -> tuple[str, ...]:
|
|
458
|
+
def _string_tuple(value: object, label: str, *, unique: bool = False) -> tuple[str, ...]:
|
|
411
459
|
if not isinstance(value, list):
|
|
412
460
|
raise HostManifestError(f"{label} must be an array")
|
|
413
461
|
result: list[str] = []
|
|
462
|
+
seen: set[str] = set()
|
|
414
463
|
for item in value:
|
|
415
464
|
if not isinstance(item, str) or not item.strip():
|
|
416
|
-
raise HostManifestError(
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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)
|
|
420
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]}")
|
|
@@ -13,8 +13,6 @@ from infra.paths import DIR_WORKFLOW, get_repo_root
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
# Defaults
|
|
16
|
-
DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal"
|
|
17
|
-
DEFAULT_MAX_JOURNAL_LINES = 2000
|
|
18
16
|
DEFAULT_CODEX_DISPATCH_MODE = "sub-agent"
|
|
19
17
|
DEFAULT_PARTY_MODE_V2_MIN_AGENTS = 3
|
|
20
18
|
DEFAULT_PARTY_MODE_V2_MAX_AGENTS = 5
|
|
@@ -108,21 +106,6 @@ def _load_config(repo_root: Path | None = None) -> dict:
|
|
|
108
106
|
return {}
|
|
109
107
|
|
|
110
108
|
|
|
111
|
-
def get_session_commit_message(repo_root: Path | None = None) -> str:
|
|
112
|
-
"""Get the commit message for auto-committing session records."""
|
|
113
|
-
config = _load_config(repo_root)
|
|
114
|
-
return config.get("session_commit_message", DEFAULT_SESSION_COMMIT_MESSAGE)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def get_max_journal_lines(repo_root: Path | None = None) -> int:
|
|
118
|
-
"""Get the maximum lines per journal file."""
|
|
119
|
-
config = _load_config(repo_root)
|
|
120
|
-
value = config.get("max_journal_lines", DEFAULT_MAX_JOURNAL_LINES)
|
|
121
|
-
try:
|
|
122
|
-
return int(value)
|
|
123
|
-
except (ValueError, TypeError):
|
|
124
|
-
return DEFAULT_MAX_JOURNAL_LINES
|
|
125
|
-
|
|
126
109
|
|
|
127
110
|
def get_hooks(event: str, repo_root: Path | None = None) -> list[str]:
|
|
128
111
|
"""Get hook commands for a lifecycle event.
|
|
@@ -16,10 +16,8 @@ from pathlib import Path
|
|
|
16
16
|
|
|
17
17
|
from infra.paths import (
|
|
18
18
|
DIR_WORKFLOW,
|
|
19
|
-
DIR_WORKSPACE,
|
|
20
19
|
DIR_TASKS,
|
|
21
20
|
FILE_DEVELOPER,
|
|
22
|
-
FILE_JOURNAL_PREFIX,
|
|
23
21
|
get_repo_root,
|
|
24
22
|
get_developer,
|
|
25
23
|
check_developer,
|
|
@@ -30,75 +28,12 @@ from infra.paths import (
|
|
|
30
28
|
# Developer Initialization
|
|
31
29
|
# =============================================================================
|
|
32
30
|
|
|
33
|
-
INITIAL_JOURNAL_NUMBER = 1
|
|
34
|
-
INDEX_FILE_NAME = "index.md"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def _initial_journal_content(name: str, today: str) -> str:
|
|
38
|
-
"""Build the initial journal file content."""
|
|
39
|
-
return f"""# Development Journal - {name} (Part 1)
|
|
40
|
-
|
|
41
|
-
> AI development session journal
|
|
42
|
-
> Start date: {today}
|
|
43
|
-
|
|
44
|
-
---
|
|
45
|
-
|
|
46
|
-
"""
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def _initial_index_content(name: str) -> str:
|
|
50
|
-
"""Build the initial workspace index content."""
|
|
51
|
-
return f"""# Workspace Index - {name}
|
|
52
|
-
|
|
53
|
-
> Tracks AI development session records.
|
|
54
|
-
|
|
55
|
-
---
|
|
56
|
-
|
|
57
|
-
## Current Status
|
|
58
|
-
|
|
59
|
-
<!-- @@@auto:current-status -->
|
|
60
|
-
- **Current file**: `journal-1.md`
|
|
61
|
-
- **Total Sessions**: 0
|
|
62
|
-
- **Last Active**: -
|
|
63
|
-
<!-- @@@/auto:current-status -->
|
|
64
|
-
|
|
65
|
-
---
|
|
66
|
-
|
|
67
|
-
## Active Documents
|
|
68
|
-
|
|
69
|
-
<!-- @@@auto:active-documents -->
|
|
70
|
-
| File | Lines | Status |
|
|
71
|
-
|------|------|------|
|
|
72
|
-
| `journal-1.md` | ~0 | Current |
|
|
73
|
-
<!-- @@@/auto:active-documents -->
|
|
74
|
-
|
|
75
|
-
---
|
|
76
|
-
|
|
77
|
-
## Session History
|
|
78
|
-
|
|
79
|
-
<!-- @@@auto:session-history -->
|
|
80
|
-
| # | Date | Title | Commit |
|
|
81
|
-
|---|------|------|------|
|
|
82
|
-
<!-- @@@/auto:session-history -->
|
|
83
|
-
|
|
84
|
-
---
|
|
85
|
-
|
|
86
|
-
## Notes
|
|
87
|
-
|
|
88
|
-
- Sessions are appended to the journal file
|
|
89
|
-
- A new journal file is created automatically after the current file exceeds 2000 lines
|
|
90
|
-
- Use `add_session.py` to record sessions
|
|
91
|
-
- New records use English text; existing records can remain as they are
|
|
92
|
-
"""
|
|
93
|
-
|
|
94
31
|
|
|
95
32
|
def init_developer(name: str, repo_root: Path | None = None) -> bool:
|
|
96
33
|
"""Initialize developer.
|
|
97
34
|
|
|
98
35
|
Creates:
|
|
99
36
|
- .cowork-flow/.developer file with developer info
|
|
100
|
-
- .cowork-flow/workspace/<name>/ directory structure
|
|
101
|
-
- Initial journal file and index.md
|
|
102
37
|
|
|
103
38
|
Args:
|
|
104
39
|
name: Developer name.
|
|
@@ -115,7 +50,6 @@ def init_developer(name: str, repo_root: Path | None = None) -> bool:
|
|
|
115
50
|
repo_root = get_repo_root()
|
|
116
51
|
|
|
117
52
|
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
|
|
118
|
-
workspace_dir = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / name
|
|
119
53
|
|
|
120
54
|
# Create .developer file
|
|
121
55
|
initialized_at = datetime.now().isoformat()
|
|
@@ -128,37 +62,9 @@ def init_developer(name: str, repo_root: Path | None = None) -> bool:
|
|
|
128
62
|
print(f"Error: failed to create .developer file: {e}", file=sys.stderr)
|
|
129
63
|
return False
|
|
130
64
|
|
|
131
|
-
# Create workspace directory structure
|
|
132
|
-
try:
|
|
133
|
-
workspace_dir.mkdir(parents=True, exist_ok=True)
|
|
134
|
-
except (OSError, IOError) as e:
|
|
135
|
-
print(f"Error: failed to create workspace directory: {e}", file=sys.stderr)
|
|
136
|
-
return False
|
|
137
|
-
|
|
138
|
-
# Create initial journal file
|
|
139
|
-
journal_file = workspace_dir / f"{FILE_JOURNAL_PREFIX}{INITIAL_JOURNAL_NUMBER}.md"
|
|
140
|
-
if not journal_file.exists():
|
|
141
|
-
today = datetime.now().strftime("%Y-%m-%d")
|
|
142
|
-
journal_content = _initial_journal_content(name, today)
|
|
143
|
-
try:
|
|
144
|
-
journal_file.write_text(journal_content, encoding="utf-8")
|
|
145
|
-
except (OSError, IOError) as e:
|
|
146
|
-
print(f"Error: failed to create journal file: {e}", file=sys.stderr)
|
|
147
|
-
return False
|
|
148
|
-
|
|
149
|
-
# Create index.md with markers for auto-update
|
|
150
|
-
index_file = workspace_dir / INDEX_FILE_NAME
|
|
151
|
-
if not index_file.exists():
|
|
152
|
-
index_content = _initial_index_content(name)
|
|
153
|
-
try:
|
|
154
|
-
index_file.write_text(index_content, encoding="utf-8")
|
|
155
|
-
except (OSError, IOError) as e:
|
|
156
|
-
print(f"Error: failed to create index.md: {e}", file=sys.stderr)
|
|
157
|
-
return False
|
|
158
65
|
|
|
159
66
|
print(f"Developer initialized: {name}")
|
|
160
67
|
print(f" .developer file: {dev_file}")
|
|
161
|
-
print(f" Workspace directory: {workspace_dir}")
|
|
162
68
|
|
|
163
69
|
return True
|
|
164
70
|
|
|
@@ -193,7 +99,6 @@ def show_developer_info(repo_root: Path | None = None) -> None:
|
|
|
193
99
|
print("Developer: not initialized")
|
|
194
100
|
else:
|
|
195
101
|
print(f"Developer: {developer}")
|
|
196
|
-
print(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/")
|
|
197
102
|
print(f"Tasks directory: {DIR_WORKFLOW}/{DIR_TASKS}/")
|
|
198
103
|
|
|
199
104
|
|
|
@@ -5,9 +5,7 @@ Common path utilities for Cowork Flow workflow.
|
|
|
5
5
|
Provides:
|
|
6
6
|
get_repo_root - Get repository root directory
|
|
7
7
|
get_developer - Get developer name
|
|
8
|
-
get_workspace_dir - Get developer workspace directory
|
|
9
8
|
get_tasks_dir - Get tasks directory
|
|
10
|
-
get_active_journal_file - Get current journal file
|
|
11
9
|
"""
|
|
12
10
|
|
|
13
11
|
from __future__ import annotations
|
|
@@ -24,8 +22,6 @@ from pathlib import Path
|
|
|
24
22
|
# Directory names
|
|
25
23
|
DIR_WORKFLOW = ".cowork-flow"
|
|
26
24
|
DIR_AGENTS = ".agents"
|
|
27
|
-
DIR_CHANGES = "changes"
|
|
28
|
-
DIR_WORKSPACE = "workspace"
|
|
29
25
|
DIR_TASKS = "tasks"
|
|
30
26
|
DIR_ARCHIVE = "archive"
|
|
31
27
|
DIR_SPEC = "spec"
|
|
@@ -34,7 +30,6 @@ DIR_SCRIPTS = "scripts"
|
|
|
34
30
|
# File names
|
|
35
31
|
FILE_DEVELOPER = ".developer"
|
|
36
32
|
FILE_TASK_JSON = "task.json"
|
|
37
|
-
FILE_JOURNAL_PREFIX = "journal-"
|
|
38
33
|
TASK_DATE_PREFIX_PATTERN = re.compile(r"^\d{2}-\d{2}-")
|
|
39
34
|
|
|
40
35
|
|
|
@@ -129,78 +124,6 @@ def get_tasks_dir(repo_root: Path | None = None) -> Path:
|
|
|
129
124
|
return repo_root / DIR_WORKFLOW / DIR_TASKS
|
|
130
125
|
|
|
131
126
|
|
|
132
|
-
# =============================================================================
|
|
133
|
-
# Workspace Directory
|
|
134
|
-
# =============================================================================
|
|
135
|
-
|
|
136
|
-
def get_workspace_dir(repo_root: Path | None = None) -> Path | None:
|
|
137
|
-
"""Get developer workspace directory.
|
|
138
|
-
|
|
139
|
-
Args:
|
|
140
|
-
repo_root: Repository root path. Defaults to auto-detected.
|
|
141
|
-
|
|
142
|
-
Returns:
|
|
143
|
-
Path to workspace directory or None if developer not set.
|
|
144
|
-
"""
|
|
145
|
-
if repo_root is None:
|
|
146
|
-
repo_root = get_repo_root()
|
|
147
|
-
|
|
148
|
-
developer = get_developer(repo_root)
|
|
149
|
-
if developer:
|
|
150
|
-
return repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer
|
|
151
|
-
return None
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
# =============================================================================
|
|
155
|
-
# Journal File
|
|
156
|
-
# =============================================================================
|
|
157
|
-
|
|
158
|
-
def get_active_journal_file(repo_root: Path | None = None) -> Path | None:
|
|
159
|
-
"""Get the current active journal file.
|
|
160
|
-
|
|
161
|
-
Args:
|
|
162
|
-
repo_root: Repository root path. Defaults to auto-detected.
|
|
163
|
-
|
|
164
|
-
Returns:
|
|
165
|
-
Path to active journal file or None if not found.
|
|
166
|
-
"""
|
|
167
|
-
if repo_root is None:
|
|
168
|
-
repo_root = get_repo_root()
|
|
169
|
-
|
|
170
|
-
workspace_dir = get_workspace_dir(repo_root)
|
|
171
|
-
if workspace_dir is None or not workspace_dir.is_dir():
|
|
172
|
-
return None
|
|
173
|
-
|
|
174
|
-
latest: Path | None = None
|
|
175
|
-
highest = 0
|
|
176
|
-
|
|
177
|
-
for f in workspace_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"):
|
|
178
|
-
if not f.is_file():
|
|
179
|
-
continue
|
|
180
|
-
|
|
181
|
-
# Extract number from filename
|
|
182
|
-
name = f.stem # e.g., "journal-1"
|
|
183
|
-
match = re.search(r"(\d+)$", name)
|
|
184
|
-
if match:
|
|
185
|
-
num = int(match.group(1))
|
|
186
|
-
if num > highest:
|
|
187
|
-
highest = num
|
|
188
|
-
latest = f
|
|
189
|
-
|
|
190
|
-
return latest
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
def count_lines(file_path: Path) -> int:
|
|
194
|
-
"""Count lines in a file.
|
|
195
|
-
|
|
196
|
-
Args:
|
|
197
|
-
file_path: Path to file.
|
|
198
|
-
|
|
199
|
-
Returns:
|
|
200
|
-
Number of lines, or 0 if file doesn't exist.
|
|
201
|
-
"""
|
|
202
|
-
if not file_path.is_file():
|
|
203
|
-
return 0
|
|
204
127
|
|
|
205
128
|
try:
|
|
206
129
|
with file_path.open("r", encoding="utf-8") as f:
|
|
@@ -238,5 +161,3 @@ if __name__ == "__main__":
|
|
|
238
161
|
print(f"Repository root: {repo}")
|
|
239
162
|
print(f"Developer: {get_developer(repo)}")
|
|
240
163
|
print(f"Tasks dir: {get_tasks_dir(repo)}")
|
|
241
|
-
print(f"Workspace dir: {get_workspace_dir(repo)}")
|
|
242
|
-
print(f"Journal file: {get_active_journal_file(repo)}")
|
|
@@ -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
|
|
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
|
|
387
|
+
(item.name, item.aliases, item.script)
|
|
368
388
|
for item in manifest.commands
|
|
369
389
|
),
|
|
370
390
|
)
|