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
|
@@ -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)
|
|
@@ -3,16 +3,21 @@
|
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
|
+
import errno
|
|
6
7
|
import json
|
|
8
|
+
import math
|
|
7
9
|
import os
|
|
8
10
|
import time
|
|
9
11
|
from contextlib import contextmanager
|
|
10
12
|
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime, timezone
|
|
11
14
|
from pathlib import Path
|
|
12
15
|
from typing import Iterator
|
|
13
16
|
|
|
14
17
|
|
|
15
18
|
STATE_METADATA_KEY = "_state"
|
|
19
|
+
LOCK_METADATA_SCHEMA_VERSION = 1
|
|
20
|
+
DEFAULT_STALE_LOCK_SECONDS = 30 * 60
|
|
16
21
|
ATOMIC_REPLACE_ATTEMPTS = 5
|
|
17
22
|
ATOMIC_REPLACE_RETRY_SECONDS = 0.01
|
|
18
23
|
|
|
@@ -36,6 +41,46 @@ class StateSnapshot:
|
|
|
36
41
|
exists: bool
|
|
37
42
|
|
|
38
43
|
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class LockInfo:
|
|
46
|
+
"""Read-only facts about a StateStore lock file."""
|
|
47
|
+
|
|
48
|
+
lock_path: Path
|
|
49
|
+
target: Path
|
|
50
|
+
exists: bool
|
|
51
|
+
pid: int | None
|
|
52
|
+
created_at: str | None
|
|
53
|
+
age_seconds: float | None
|
|
54
|
+
owner_availability: str
|
|
55
|
+
status: str
|
|
56
|
+
detail: str
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def owner_available(self) -> bool | None:
|
|
60
|
+
if self.owner_availability == "available":
|
|
61
|
+
return True
|
|
62
|
+
if self.owner_availability == "missing":
|
|
63
|
+
return False
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> dict[str, object]:
|
|
67
|
+
data: dict[str, object] = {
|
|
68
|
+
"lockPath": str(self.lock_path),
|
|
69
|
+
"target": str(self.target),
|
|
70
|
+
"exists": self.exists,
|
|
71
|
+
"ownerAvailability": self.owner_availability,
|
|
72
|
+
"status": self.status,
|
|
73
|
+
"detail": self.detail,
|
|
74
|
+
}
|
|
75
|
+
if self.pid is not None:
|
|
76
|
+
data["pid"] = self.pid
|
|
77
|
+
if self.created_at is not None:
|
|
78
|
+
data["createdAt"] = self.created_at
|
|
79
|
+
if self.age_seconds is not None:
|
|
80
|
+
data["ageSeconds"] = self.age_seconds
|
|
81
|
+
return data
|
|
82
|
+
|
|
83
|
+
|
|
39
84
|
class StateStore:
|
|
40
85
|
"""Read and compare-and-swap JSON state documents."""
|
|
41
86
|
|
|
@@ -117,6 +162,113 @@ class StateStore:
|
|
|
117
162
|
) from error
|
|
118
163
|
return True
|
|
119
164
|
|
|
165
|
+
def inspect_lock(
|
|
166
|
+
self,
|
|
167
|
+
path: str | Path,
|
|
168
|
+
*,
|
|
169
|
+
stale_after_seconds: float = DEFAULT_STALE_LOCK_SECONDS,
|
|
170
|
+
) -> LockInfo:
|
|
171
|
+
target = Path(path)
|
|
172
|
+
return self.inspect_lock_path(
|
|
173
|
+
self.lock_path_for(target),
|
|
174
|
+
target=target,
|
|
175
|
+
stale_after_seconds=stale_after_seconds,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def inspect_lock_path(
|
|
179
|
+
self,
|
|
180
|
+
lock_path: str | Path,
|
|
181
|
+
*,
|
|
182
|
+
target: str | Path | None = None,
|
|
183
|
+
stale_after_seconds: float = DEFAULT_STALE_LOCK_SECONDS,
|
|
184
|
+
) -> LockInfo:
|
|
185
|
+
stale_after_seconds = self._validated_stale_threshold(
|
|
186
|
+
stale_after_seconds
|
|
187
|
+
)
|
|
188
|
+
lock = Path(lock_path)
|
|
189
|
+
target_path = (
|
|
190
|
+
Path(target)
|
|
191
|
+
if target is not None
|
|
192
|
+
else self.target_from_lock_path(lock)
|
|
193
|
+
)
|
|
194
|
+
try:
|
|
195
|
+
raw_text = lock.read_text(encoding="utf-8")
|
|
196
|
+
except FileNotFoundError:
|
|
197
|
+
return LockInfo(
|
|
198
|
+
lock_path=lock,
|
|
199
|
+
target=target_path,
|
|
200
|
+
exists=False,
|
|
201
|
+
pid=None,
|
|
202
|
+
created_at=None,
|
|
203
|
+
age_seconds=None,
|
|
204
|
+
owner_availability="unknown",
|
|
205
|
+
status="absent",
|
|
206
|
+
detail="state lock is absent",
|
|
207
|
+
)
|
|
208
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
209
|
+
return LockInfo(
|
|
210
|
+
lock_path=lock,
|
|
211
|
+
target=target_path,
|
|
212
|
+
exists=True,
|
|
213
|
+
pid=None,
|
|
214
|
+
created_at=None,
|
|
215
|
+
age_seconds=None,
|
|
216
|
+
owner_availability="unknown",
|
|
217
|
+
status="unknown",
|
|
218
|
+
detail=f"state lock metadata could not be read: {error}",
|
|
219
|
+
)
|
|
220
|
+
return self._lock_info_from_text(
|
|
221
|
+
lock,
|
|
222
|
+
target_path,
|
|
223
|
+
raw_text,
|
|
224
|
+
stale_after_seconds=stale_after_seconds,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def remove_stale_lock(
|
|
228
|
+
self,
|
|
229
|
+
path: str | Path,
|
|
230
|
+
*,
|
|
231
|
+
stale_after_seconds: float = DEFAULT_STALE_LOCK_SECONDS,
|
|
232
|
+
) -> bool:
|
|
233
|
+
stale_after_seconds = self._validated_stale_threshold(
|
|
234
|
+
stale_after_seconds
|
|
235
|
+
)
|
|
236
|
+
target = Path(path)
|
|
237
|
+
lock_path = self.lock_path_for(target)
|
|
238
|
+
try:
|
|
239
|
+
original = lock_path.read_text(encoding="utf-8")
|
|
240
|
+
except FileNotFoundError:
|
|
241
|
+
return False
|
|
242
|
+
except (OSError, UnicodeDecodeError):
|
|
243
|
+
return False
|
|
244
|
+
info = self._lock_info_from_text(
|
|
245
|
+
lock_path,
|
|
246
|
+
target,
|
|
247
|
+
original,
|
|
248
|
+
stale_after_seconds=stale_after_seconds,
|
|
249
|
+
)
|
|
250
|
+
if info.status != "recoverable":
|
|
251
|
+
return False
|
|
252
|
+
try:
|
|
253
|
+
current = lock_path.read_text(encoding="utf-8")
|
|
254
|
+
except FileNotFoundError:
|
|
255
|
+
return False
|
|
256
|
+
except (OSError, UnicodeDecodeError):
|
|
257
|
+
return False
|
|
258
|
+
if current != original:
|
|
259
|
+
return False
|
|
260
|
+
try:
|
|
261
|
+
lock_path.unlink()
|
|
262
|
+
except FileNotFoundError:
|
|
263
|
+
return False
|
|
264
|
+
except OSError as error:
|
|
265
|
+
raise StateStoreError(
|
|
266
|
+
"STATE-LOCK-002",
|
|
267
|
+
target,
|
|
268
|
+
"stale state lock could not be removed",
|
|
269
|
+
) from error
|
|
270
|
+
return True
|
|
271
|
+
|
|
120
272
|
def _load_unlocked(
|
|
121
273
|
self,
|
|
122
274
|
path: Path,
|
|
@@ -249,12 +401,13 @@ class StateStore:
|
|
|
249
401
|
@contextmanager
|
|
250
402
|
def _lock(self, path: Path) -> Iterator[None]:
|
|
251
403
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
252
|
-
lock_path =
|
|
404
|
+
lock_path = self.lock_path_for(path)
|
|
253
405
|
deadline = time.monotonic() + self.lock_timeout_seconds
|
|
254
406
|
lock_handle = None
|
|
255
407
|
while lock_handle is None:
|
|
256
408
|
try:
|
|
257
409
|
lock_handle = lock_path.open("x", encoding="utf-8")
|
|
410
|
+
self._write_lock_payload(lock_handle, path)
|
|
258
411
|
except FileExistsError as error:
|
|
259
412
|
if time.monotonic() >= deadline:
|
|
260
413
|
raise StateStoreError(
|
|
@@ -264,6 +417,15 @@ class StateStore:
|
|
|
264
417
|
) from error
|
|
265
418
|
time.sleep(self.lock_poll_seconds)
|
|
266
419
|
except OSError as error:
|
|
420
|
+
if lock_handle is not None:
|
|
421
|
+
try:
|
|
422
|
+
lock_handle.close()
|
|
423
|
+
except OSError:
|
|
424
|
+
pass
|
|
425
|
+
try:
|
|
426
|
+
lock_path.unlink(missing_ok=True)
|
|
427
|
+
except OSError:
|
|
428
|
+
pass
|
|
267
429
|
raise StateStoreError(
|
|
268
430
|
"STATE-LOCK-001",
|
|
269
431
|
path,
|
|
@@ -277,3 +439,196 @@ class StateStore:
|
|
|
277
439
|
lock_path.unlink(missing_ok=True)
|
|
278
440
|
except OSError:
|
|
279
441
|
pass
|
|
442
|
+
|
|
443
|
+
@staticmethod
|
|
444
|
+
def lock_path_for(path: str | Path) -> Path:
|
|
445
|
+
target = Path(path)
|
|
446
|
+
return target.with_name(f"{target.name}.lock")
|
|
447
|
+
|
|
448
|
+
@staticmethod
|
|
449
|
+
def target_from_lock_path(lock_path: str | Path) -> Path:
|
|
450
|
+
lock = Path(lock_path)
|
|
451
|
+
if lock.name.endswith(".lock"):
|
|
452
|
+
return lock.with_name(lock.name[:-5])
|
|
453
|
+
return lock
|
|
454
|
+
|
|
455
|
+
@staticmethod
|
|
456
|
+
def _write_lock_file(
|
|
457
|
+
lock_path: Path,
|
|
458
|
+
target: Path,
|
|
459
|
+
*,
|
|
460
|
+
pid: int | None = None,
|
|
461
|
+
created_at: str | None = None,
|
|
462
|
+
) -> None:
|
|
463
|
+
payload = StateStore._lock_payload(
|
|
464
|
+
target,
|
|
465
|
+
pid=pid,
|
|
466
|
+
created_at=created_at,
|
|
467
|
+
)
|
|
468
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
469
|
+
lock_path.write_text(
|
|
470
|
+
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
|
471
|
+
encoding="utf-8",
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
@staticmethod
|
|
475
|
+
def _write_lock_payload(stream: object, target: Path) -> None:
|
|
476
|
+
payload = StateStore._lock_payload(target)
|
|
477
|
+
stream.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
478
|
+
stream.flush()
|
|
479
|
+
|
|
480
|
+
@staticmethod
|
|
481
|
+
def _lock_payload(
|
|
482
|
+
target: Path,
|
|
483
|
+
*,
|
|
484
|
+
pid: int | None = None,
|
|
485
|
+
created_at: str | None = None,
|
|
486
|
+
) -> dict[str, object]:
|
|
487
|
+
return {
|
|
488
|
+
"schemaVersion": LOCK_METADATA_SCHEMA_VERSION,
|
|
489
|
+
"pid": os.getpid() if pid is None else pid,
|
|
490
|
+
"createdAt": created_at or StateStore._now_iso(),
|
|
491
|
+
"target": str(target),
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
@staticmethod
|
|
495
|
+
def _validated_stale_threshold(value: float) -> float:
|
|
496
|
+
threshold = float(value)
|
|
497
|
+
if not math.isfinite(threshold) or threshold < 0:
|
|
498
|
+
raise ValueError("stale_after_seconds must be a non-negative finite number")
|
|
499
|
+
return threshold
|
|
500
|
+
|
|
501
|
+
@staticmethod
|
|
502
|
+
def _now_iso() -> str:
|
|
503
|
+
return datetime.now(timezone.utc).isoformat(
|
|
504
|
+
timespec="seconds"
|
|
505
|
+
).replace("+00:00", "Z")
|
|
506
|
+
|
|
507
|
+
@staticmethod
|
|
508
|
+
def _parse_created_at(value: object) -> datetime | None:
|
|
509
|
+
if not isinstance(value, str) or not value.strip():
|
|
510
|
+
return None
|
|
511
|
+
try:
|
|
512
|
+
parsed = datetime.fromisoformat(
|
|
513
|
+
value.strip().replace("Z", "+00:00")
|
|
514
|
+
)
|
|
515
|
+
except ValueError:
|
|
516
|
+
return None
|
|
517
|
+
if parsed.tzinfo is None:
|
|
518
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
519
|
+
return parsed.astimezone(timezone.utc)
|
|
520
|
+
|
|
521
|
+
@staticmethod
|
|
522
|
+
def _pid_exists(pid: int) -> bool | None:
|
|
523
|
+
if pid <= 0:
|
|
524
|
+
return False
|
|
525
|
+
if pid == os.getpid():
|
|
526
|
+
return True
|
|
527
|
+
if os.name == "nt":
|
|
528
|
+
return StateStore._windows_pid_exists(pid)
|
|
529
|
+
try:
|
|
530
|
+
os.kill(pid, 0)
|
|
531
|
+
except ProcessLookupError:
|
|
532
|
+
return False
|
|
533
|
+
except PermissionError:
|
|
534
|
+
return True
|
|
535
|
+
except OSError as error:
|
|
536
|
+
if getattr(error, "errno", None) == errno.ESRCH:
|
|
537
|
+
return False
|
|
538
|
+
if getattr(error, "errno", None) == errno.EPERM:
|
|
539
|
+
return True
|
|
540
|
+
return None
|
|
541
|
+
return True
|
|
542
|
+
|
|
543
|
+
@staticmethod
|
|
544
|
+
def _windows_pid_exists(pid: int) -> bool | None:
|
|
545
|
+
try:
|
|
546
|
+
import ctypes
|
|
547
|
+
except ImportError:
|
|
548
|
+
return None
|
|
549
|
+
process_query_limited_information = 0x1000
|
|
550
|
+
handle = ctypes.windll.kernel32.OpenProcess(
|
|
551
|
+
process_query_limited_information,
|
|
552
|
+
False,
|
|
553
|
+
pid,
|
|
554
|
+
)
|
|
555
|
+
if handle:
|
|
556
|
+
ctypes.windll.kernel32.CloseHandle(handle)
|
|
557
|
+
return True
|
|
558
|
+
error = ctypes.windll.kernel32.GetLastError()
|
|
559
|
+
if error == 87: # ERROR_INVALID_PARAMETER: PID does not exist.
|
|
560
|
+
return False
|
|
561
|
+
if error == 5: # ERROR_ACCESS_DENIED: process exists but is protected.
|
|
562
|
+
return True
|
|
563
|
+
return None
|
|
564
|
+
|
|
565
|
+
def _lock_info_from_text(
|
|
566
|
+
self,
|
|
567
|
+
lock_path: Path,
|
|
568
|
+
target: Path,
|
|
569
|
+
raw_text: str,
|
|
570
|
+
*,
|
|
571
|
+
stale_after_seconds: float,
|
|
572
|
+
) -> LockInfo:
|
|
573
|
+
try:
|
|
574
|
+
raw = json.loads(raw_text) if raw_text.strip() else {}
|
|
575
|
+
except json.JSONDecodeError:
|
|
576
|
+
raw = {}
|
|
577
|
+
if not isinstance(raw, dict):
|
|
578
|
+
raw = {}
|
|
579
|
+
pid = raw.get("pid")
|
|
580
|
+
pid_value = pid if isinstance(pid, int) and not isinstance(pid, bool) else None
|
|
581
|
+
created_at = raw.get("createdAt")
|
|
582
|
+
created_at_value = created_at if isinstance(created_at, str) else None
|
|
583
|
+
created = self._parse_created_at(created_at_value)
|
|
584
|
+
age_seconds = None
|
|
585
|
+
if created is not None:
|
|
586
|
+
age_seconds = max(
|
|
587
|
+
0.0,
|
|
588
|
+
(datetime.now(timezone.utc) - created).total_seconds(),
|
|
589
|
+
)
|
|
590
|
+
else:
|
|
591
|
+
try:
|
|
592
|
+
age_seconds = max(0.0, time.time() - lock_path.stat().st_mtime)
|
|
593
|
+
except OSError:
|
|
594
|
+
age_seconds = None
|
|
595
|
+
target_value = raw.get("target")
|
|
596
|
+
if isinstance(target_value, str) and target_value.strip():
|
|
597
|
+
target = Path(target_value)
|
|
598
|
+
|
|
599
|
+
if pid_value is None:
|
|
600
|
+
availability = "unknown"
|
|
601
|
+
status = "unknown"
|
|
602
|
+
detail = "state lock owner pid is unknown"
|
|
603
|
+
else:
|
|
604
|
+
exists = self._pid_exists(pid_value)
|
|
605
|
+
if exists is True:
|
|
606
|
+
availability = "available"
|
|
607
|
+
status = "active"
|
|
608
|
+
detail = "state lock owner process is still available"
|
|
609
|
+
elif exists is False:
|
|
610
|
+
availability = "missing"
|
|
611
|
+
if age_seconds is not None and age_seconds >= stale_after_seconds:
|
|
612
|
+
status = "recoverable"
|
|
613
|
+
detail = "state lock owner process is missing and lock is stale"
|
|
614
|
+
else:
|
|
615
|
+
status = "stale-but-unsafe"
|
|
616
|
+
detail = (
|
|
617
|
+
"state lock owner process is missing but lock age "
|
|
618
|
+
"does not satisfy the stale threshold"
|
|
619
|
+
)
|
|
620
|
+
else:
|
|
621
|
+
availability = "unknown"
|
|
622
|
+
status = "unknown"
|
|
623
|
+
detail = "state lock owner process availability is unknown"
|
|
624
|
+
return LockInfo(
|
|
625
|
+
lock_path=lock_path,
|
|
626
|
+
target=target,
|
|
627
|
+
exists=True,
|
|
628
|
+
pid=pid_value,
|
|
629
|
+
created_at=created_at_value,
|
|
630
|
+
age_seconds=age_seconds,
|
|
631
|
+
owner_availability=availability,
|
|
632
|
+
status=status,
|
|
633
|
+
detail=detail,
|
|
634
|
+
)
|
|
@@ -38,20 +38,20 @@ INTENT_OPERATIONS = {
|
|
|
38
38
|
|
|
39
39
|
# These are state-transition facts, not Skill metadata.
|
|
40
40
|
ACTION_TRANSITIONS = {
|
|
41
|
-
"answer_questions": {"
|
|
42
|
-
"debug_failure": {"
|
|
43
|
-
"discuss_options": {"
|
|
44
|
-
"doubt_review": {"
|
|
45
|
-
"batch_execute": {"
|
|
46
|
-
"create_task": {"
|
|
47
|
-
"edit_planning_artifacts": {"
|
|
48
|
-
"start_task": {"
|
|
49
|
-
"implement_change": {"
|
|
50
|
-
"request_review": {"
|
|
51
|
-
"complete_task": {"
|
|
52
|
-
"archive_task": {"
|
|
53
|
-
"execute_delegated_work": {"
|
|
54
|
-
"repair_workflow_state": {"
|
|
41
|
+
"answer_questions": {"lifecycleCheck": None, "mutatesState": False},
|
|
42
|
+
"debug_failure": {"lifecycleCheck": None, "mutatesState": False},
|
|
43
|
+
"discuss_options": {"lifecycleCheck": None, "mutatesState": False},
|
|
44
|
+
"doubt_review": {"lifecycleCheck": None, "mutatesState": False},
|
|
45
|
+
"batch_execute": {"lifecycleCheck": "task_start", "mutatesState": True},
|
|
46
|
+
"create_task": {"lifecycleCheck": None, "mutatesState": True},
|
|
47
|
+
"edit_planning_artifacts": {"lifecycleCheck": "task_start", "mutatesState": False},
|
|
48
|
+
"start_task": {"lifecycleCheck": "task_start", "mutatesState": True},
|
|
49
|
+
"implement_change": {"lifecycleCheck": None, "mutatesState": False},
|
|
50
|
+
"request_review": {"lifecycleCheck": "task_review", "mutatesState": True},
|
|
51
|
+
"complete_task": {"lifecycleCheck": "task_complete", "mutatesState": True},
|
|
52
|
+
"archive_task": {"lifecycleCheck": "task_archive", "mutatesState": True},
|
|
53
|
+
"execute_delegated_work": {"lifecycleCheck": None, "mutatesState": False},
|
|
54
|
+
"repair_workflow_state": {"lifecycleCheck": None, "mutatesState": False},
|
|
55
55
|
}
|
|
56
56
|
RUNNABLE_ACTIONS = {
|
|
57
57
|
action_id
|
|
@@ -158,7 +158,7 @@ def _action_contract(
|
|
|
158
158
|
return {
|
|
159
159
|
"id": action_id,
|
|
160
160
|
"mutatesState": spec["mutatesState"],
|
|
161
|
-
"
|
|
161
|
+
"lifecycleCheck": spec["lifecycleCheck"],
|
|
162
162
|
"runnable": action_id in RUNNABLE_ACTIONS and not action_blockers,
|
|
163
163
|
"blockers": action_blockers,
|
|
164
164
|
}
|
|
@@ -22,15 +22,12 @@ configure_cli_encoding()
|
|
|
22
22
|
COMMAND_SCRIPTS = {
|
|
23
23
|
"resume": "adapters/cli/resume.py",
|
|
24
24
|
"task": "adapters/cli/task.py",
|
|
25
|
-
"change": "adapters/cli/change.py",
|
|
26
25
|
"get-context": "adapters/cli/get_context.py",
|
|
27
26
|
"get_context": "adapters/cli/get_context.py",
|
|
28
27
|
"get-developer": "adapters/cli/get_developer.py",
|
|
29
28
|
"get_developer": "adapters/cli/get_developer.py",
|
|
30
29
|
"init-developer": "adapters/cli/init_developer.py",
|
|
31
30
|
"init_developer": "adapters/cli/init_developer.py",
|
|
32
|
-
"add-session": "adapters/cli/add_session.py",
|
|
33
|
-
"add_session": "adapters/cli/add_session.py",
|
|
34
31
|
"subagent": "adapters/cli/subagent.py",
|
|
35
32
|
}
|
|
36
33
|
RESERVED_COMMAND_NAMES = (*COMMAND_SCRIPTS, "python", "help", "-h", "--help")
|
|
@@ -49,11 +46,9 @@ def print_usage() -> None:
|
|
|
49
46
|
Common commands:
|
|
50
47
|
resume
|
|
51
48
|
task
|
|
52
|
-
change
|
|
53
49
|
get-context
|
|
54
50
|
get-developer
|
|
55
51
|
init-developer
|
|
56
|
-
add-session
|
|
57
52
|
subagent
|
|
58
53
|
doctor
|
|
59
54
|
party-v2
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Task context discovery for specs, Skills, and installed platforms."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from infra.quality_sources import quality_source_entries
|
|
9
|
+
from infra.skill_manifest import context_entries
|
|
10
|
+
from infra.paths import (
|
|
11
|
+
DIR_AGENTS,
|
|
12
|
+
DIR_SPEC,
|
|
13
|
+
DIR_WORKFLOW,
|
|
14
|
+
get_repo_root,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_implement_base(repo_root: Path | None = None) -> list[dict]:
|
|
19
|
+
root = Path(repo_root) if repo_root is not None else get_repo_root()
|
|
20
|
+
entries = [
|
|
21
|
+
{
|
|
22
|
+
"file": "AGENTS.md",
|
|
23
|
+
"reason": "Project collaboration rules and workflow checks",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/guides/index.md",
|
|
27
|
+
"reason": "Pre-implementation thinking guides",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"file": (
|
|
31
|
+
f"{DIR_WORKFLOW}/{DIR_SPEC}/guides/"
|
|
32
|
+
"pre-implementation-checklist.md"
|
|
33
|
+
),
|
|
34
|
+
"reason": "Mandatory pre-coding checklist",
|
|
35
|
+
},
|
|
36
|
+
]
|
|
37
|
+
entries.extend(context_entries(root, context="implement"))
|
|
38
|
+
return entries
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_implement_backend() -> list[dict]:
|
|
42
|
+
return [
|
|
43
|
+
{
|
|
44
|
+
"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/backend/index.md",
|
|
45
|
+
"reason": "Backend development guide",
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_implement_frontend() -> list[dict]:
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/frontend/index.md",
|
|
54
|
+
"reason": "Frontend development guide",
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_implement_spec() -> list[dict]:
|
|
60
|
+
return [
|
|
61
|
+
{
|
|
62
|
+
"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/index.md",
|
|
63
|
+
"reason": "Spec index — read before modifying spec/",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/contracts/index.md",
|
|
67
|
+
"reason": "Contract definitions",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/schemas/index.md",
|
|
71
|
+
"reason": "Schema definitions",
|
|
72
|
+
},
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def detect_installed_platforms(repo_root: Path | None = None) -> list[str]:
|
|
77
|
+
repo_root = Path(repo_root) if repo_root is not None else get_repo_root()
|
|
78
|
+
platforms: list[str] = []
|
|
79
|
+
if (repo_root / ".codex").is_dir():
|
|
80
|
+
platforms.append("codex")
|
|
81
|
+
if (repo_root / ".opencode").is_dir():
|
|
82
|
+
platforms.append("opencode")
|
|
83
|
+
if (repo_root / ".claude").is_dir() or (repo_root / "CLAUDE.md").is_file():
|
|
84
|
+
platforms.append("claude-code")
|
|
85
|
+
return platforms
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def use_claude_skill_context(repo_root: Path | None = None) -> bool:
|
|
89
|
+
return detect_installed_platforms(repo_root) == ["claude-code"]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def skill_path(name: str, repo_root: Path | None = None) -> str:
|
|
93
|
+
if use_claude_skill_context(repo_root):
|
|
94
|
+
return f".claude/skills/{name}/SKILL.md"
|
|
95
|
+
return f"{DIR_AGENTS}/skills/{name}/SKILL.md"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_domain_skill_context(
|
|
99
|
+
repo_root: Path,
|
|
100
|
+
*,
|
|
101
|
+
dev_type: str | None = None,
|
|
102
|
+
paths: tuple[str, ...] = (),
|
|
103
|
+
) -> list[dict]:
|
|
104
|
+
return context_entries(
|
|
105
|
+
repo_root,
|
|
106
|
+
context="implement",
|
|
107
|
+
dev_type=dev_type,
|
|
108
|
+
paths=paths,
|
|
109
|
+
include_wildcard=False,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def is_skill_path(file_path: str) -> bool:
|
|
114
|
+
return (
|
|
115
|
+
file_path.startswith(f"{DIR_AGENTS}/skills/")
|
|
116
|
+
or file_path.startswith(".claude/skills/")
|
|
117
|
+
or ".skills/" in file_path
|
|
118
|
+
or "/skills/" in file_path
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def discover_spec_files(repo_root: Path, dev_type: str) -> list[str]:
|
|
123
|
+
if dev_type == "spec":
|
|
124
|
+
return [f"{DIR_WORKFLOW}/{DIR_SPEC}/index.md"]
|
|
125
|
+
|
|
126
|
+
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC / dev_type
|
|
127
|
+
if not spec_dir.is_dir():
|
|
128
|
+
return []
|
|
129
|
+
return sorted(
|
|
130
|
+
f"{DIR_WORKFLOW}/{DIR_SPEC}/{dev_type}/{path.name}"
|
|
131
|
+
for path in spec_dir.glob("*.md")
|
|
132
|
+
if path.is_file()
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def get_check_context(repo_root: Path, dev_type: str) -> list[dict]:
|
|
137
|
+
entries = context_entries(repo_root, context="check", dev_type=dev_type)
|
|
138
|
+
entries.sort(key=lambda entry: entry["file"])
|
|
139
|
+
if dev_type == "spec":
|
|
140
|
+
entries.extend(
|
|
141
|
+
{
|
|
142
|
+
"file": spec_file,
|
|
143
|
+
"reason": f"Verify {Path(spec_file).name} compliance",
|
|
144
|
+
}
|
|
145
|
+
for spec_file in discover_spec_files(repo_root, dev_type)
|
|
146
|
+
)
|
|
147
|
+
entries.extend(quality_source_entries(repo_root, dev_type))
|
|
148
|
+
return entries
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def get_debug_context(
|
|
152
|
+
dev_type: str,
|
|
153
|
+
repo_root: Path | None = None,
|
|
154
|
+
) -> list[dict]:
|
|
155
|
+
root = Path(repo_root) if repo_root is not None else get_repo_root()
|
|
156
|
+
return context_entries(root, context="debug", dev_type=dev_type)
|