easy-coding-harness 0.5.3 → 0.6.1
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 +27 -0
- package/README.md +13 -11
- package/dist/cli.js +134 -2
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/templates/claude/agents/ec-implementer.md +3 -0
- package/templates/codex/agents/ec-implementer.toml +3 -0
- package/templates/common/bundled-skills/ec-init/SKILL.md +1 -1
- package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +12 -5
- package/templates/common/skills/ec-analysis/SKILL.md +87 -51
- package/templates/common/skills/ec-brainstorming/SKILL.md +2 -1
- package/templates/common/skills/ec-git/SKILL.md +1 -1
- package/templates/common/skills/ec-implementing/SKILL.md +42 -13
- package/templates/common/skills/ec-memory/SKILL.md +46 -20
- package/templates/common/skills/ec-reviewing/SKILL.md +10 -6
- package/templates/common/skills/ec-task-close/SKILL.md +1 -1
- package/templates/common/skills/ec-task-management/SKILL.md +1 -1
- package/templates/common/skills/ec-verification/SKILL.md +25 -26
- package/templates/common/skills/ec-workflow/SKILL.md +119 -69
- package/templates/main-constraint/AGENTS.md.tpl +20 -12
- package/templates/main-constraint/CLAUDE.md.tpl +20 -12
- package/templates/qoder/agents/ec-implementer.md +3 -0
- package/templates/runtime/memory/SHORT_MEMORY_TEMPLATE.md +2 -0
- package/templates/runtime/templates/dev-spec-skeleton.md +41 -46
- package/templates/shared-hooks/easy_coding_state.py +1040 -39
- package/templates/shared-hooks/inject-workflow-state.py +1 -176
- package/templates/shared-hooks/session-start.py +2 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
import argparse
|
|
3
|
+
import hashlib
|
|
3
4
|
import json
|
|
4
5
|
import os
|
|
6
|
+
import re
|
|
5
7
|
from datetime import datetime, timezone
|
|
6
8
|
from pathlib import Path
|
|
7
9
|
import sys
|
|
@@ -25,7 +27,6 @@ MANDATORY_DEV_SPEC_HEADERS: list[str] = [
|
|
|
25
27
|
"### 需求解析",
|
|
26
28
|
"### 现状",
|
|
27
29
|
"### 冲突摘要",
|
|
28
|
-
"### 待用户决策",
|
|
29
30
|
"### 影响面分析",
|
|
30
31
|
"### 改动范围",
|
|
31
32
|
"### 修改方案",
|
|
@@ -37,19 +38,48 @@ MANDATORY_DEV_SPEC_HEADERS: list[str] = [
|
|
|
37
38
|
VALID_TRANSITIONS: dict[str, set[str]] = {
|
|
38
39
|
"idle": {"INIT"},
|
|
39
40
|
"INIT": {"ANALYSIS", "CLOSED"},
|
|
40
|
-
"ANALYSIS": {"
|
|
41
|
-
"
|
|
42
|
-
"IMPLEMENT": {"REVIEW", "ANALYSIS", "CLOSED"},
|
|
41
|
+
"ANALYSIS": {"IMPLEMENT", "CLOSED"},
|
|
42
|
+
"IMPLEMENT": {"REVIEW", "VERIFICATION", "ANALYSIS", "COMPLETE", "CLOSED"},
|
|
43
43
|
"REVIEW": {"VERIFICATION", "IMPLEMENT", "ANALYSIS", "CLOSED"},
|
|
44
|
-
"VERIFICATION": {"
|
|
45
|
-
"
|
|
46
|
-
"MEMORY_LONG": {"COMPLETE", "CLOSED"},
|
|
44
|
+
"VERIFICATION": {"MEMORY", "IMPLEMENT", "CLOSED"},
|
|
45
|
+
"MEMORY": {"COMPLETE", "CLOSED"},
|
|
47
46
|
"COMPLETE": set(),
|
|
48
47
|
"CLOSED": set(),
|
|
49
48
|
}
|
|
50
49
|
|
|
50
|
+
AUTO_TRANSITIONS = {
|
|
51
|
+
("INIT", "ANALYSIS"),
|
|
52
|
+
("MEMORY", "COMPLETE"),
|
|
53
|
+
}
|
|
54
|
+
READ_ONLY_COMPLETION_TRANSITION = ("IMPLEMENT", "COMPLETE")
|
|
55
|
+
NO_CODE_TASK_TYPES = {"analysis", "doc", "report"}
|
|
56
|
+
|
|
57
|
+
LEGACY_STAGE_MAP = {
|
|
58
|
+
"WAITING_CONFIRM": "ANALYSIS",
|
|
59
|
+
"MEMORY_SHORT": "MEMORY",
|
|
60
|
+
"MEMORY_LONG": "MEMORY",
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
DEFAULT_SHORT_TERM_MAX = 10
|
|
52
64
|
DEFAULT_SHORT_TERM_KEEP = 5
|
|
65
|
+
DEV_SPEC_PLACEHOLDER_PATTERN = re.compile(r"\[\[EC_TODO:[^\]\n]+\]\]")
|
|
66
|
+
MARKDOWN_HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
|
|
67
|
+
TABLE_HEADER_CELLS = {
|
|
68
|
+
"改动文件",
|
|
69
|
+
"改动类型",
|
|
70
|
+
"文件编码",
|
|
71
|
+
"改动核心内容",
|
|
72
|
+
"单元",
|
|
73
|
+
"说明",
|
|
74
|
+
"类型",
|
|
75
|
+
"涉及文件",
|
|
76
|
+
"依赖",
|
|
77
|
+
"测试点",
|
|
78
|
+
"级别",
|
|
79
|
+
"归属单元",
|
|
80
|
+
"方式",
|
|
81
|
+
"验证命令",
|
|
82
|
+
}
|
|
53
83
|
|
|
54
84
|
|
|
55
85
|
class StateError(Exception):
|
|
@@ -127,52 +157,248 @@ def read_memory_config(root: Path) -> dict[str, int]:
|
|
|
127
157
|
parsed = parse_positive_int(value)
|
|
128
158
|
if parsed is not None:
|
|
129
159
|
config[key] = parsed
|
|
160
|
+
if config["short_term_keep"] > config["short_term_max"]:
|
|
161
|
+
raise StateError(
|
|
162
|
+
"Invalid memory config in .easy-coding/config.yaml: "
|
|
163
|
+
"memory.short_term_keep must be less than or equal to memory.short_term_max."
|
|
164
|
+
)
|
|
130
165
|
return config
|
|
131
166
|
|
|
132
167
|
|
|
133
|
-
def
|
|
168
|
+
def short_memory_entries(root: Path) -> list[dict[str, object]]:
|
|
134
169
|
short_dir = root / ".easy-coding" / "memory" / "short"
|
|
135
170
|
if not short_dir.is_dir():
|
|
136
|
-
return
|
|
137
|
-
|
|
171
|
+
return []
|
|
172
|
+
entries: list[dict[str, object]] = []
|
|
138
173
|
for entry in short_dir.glob("*.md"):
|
|
139
174
|
try:
|
|
140
|
-
|
|
141
|
-
count += 1
|
|
175
|
+
content = entry.read_text(encoding="utf-8")
|
|
142
176
|
except OSError:
|
|
143
177
|
continue
|
|
144
|
-
|
|
178
|
+
if not is_schema_v2_short_memory(content):
|
|
179
|
+
continue
|
|
180
|
+
frontmatter = parse_short_memory_frontmatter(content)
|
|
181
|
+
resolved_entry = entry.resolve()
|
|
182
|
+
try:
|
|
183
|
+
resolved_entry.relative_to(short_dir.resolve())
|
|
184
|
+
display_entry = resolved_entry.relative_to(root.resolve()).as_posix()
|
|
185
|
+
except ValueError:
|
|
186
|
+
continue
|
|
187
|
+
prefix_text = entry.name.split("_", 1)[0]
|
|
188
|
+
try:
|
|
189
|
+
prefix = int(prefix_text)
|
|
190
|
+
except ValueError:
|
|
191
|
+
prefix = sys.maxsize
|
|
192
|
+
entries.append(
|
|
193
|
+
{
|
|
194
|
+
"path": resolved_entry,
|
|
195
|
+
"display_path": display_entry,
|
|
196
|
+
"date": frontmatter.get("date", ""),
|
|
197
|
+
"prefix": prefix,
|
|
198
|
+
"name": entry.name,
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
return sorted(
|
|
202
|
+
entries,
|
|
203
|
+
key=lambda item: (str(item["date"]), int(item["prefix"]), str(item["name"])),
|
|
204
|
+
)
|
|
145
205
|
|
|
146
206
|
|
|
147
|
-
def
|
|
207
|
+
def count_short_memories(root: Path) -> int:
|
|
208
|
+
return len(short_memory_entries(root))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def parse_short_memory_frontmatter(content: str) -> dict[str, str]:
|
|
148
212
|
lines = content.splitlines()
|
|
149
213
|
if not lines or lines[0].strip() != "---":
|
|
150
|
-
return
|
|
214
|
+
return {}
|
|
215
|
+
frontmatter: dict[str, str] = {}
|
|
151
216
|
for line in lines[1:]:
|
|
152
217
|
stripped = line.strip()
|
|
153
218
|
if stripped == "---":
|
|
154
|
-
return
|
|
155
|
-
if not stripped.startswith("
|
|
219
|
+
return frontmatter
|
|
220
|
+
if not stripped or stripped.startswith("#") or ":" not in stripped:
|
|
156
221
|
continue
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
return
|
|
222
|
+
key, value = stripped.split(":", 1)
|
|
223
|
+
frontmatter[key.strip()] = value.strip().strip("'\"")
|
|
224
|
+
return {}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def is_schema_v2_short_memory(content: str) -> bool:
|
|
228
|
+
frontmatter = parse_short_memory_frontmatter(content)
|
|
229
|
+
return parse_positive_int(frontmatter.get("memory_schema", "")) == 2
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def resolve_short_memory_path(root: Path, memory_file: str) -> Path:
|
|
233
|
+
short_dir = (root / ".easy-coding" / "memory" / "short").resolve()
|
|
234
|
+
memory_path = Path(memory_file.strip())
|
|
235
|
+
candidate = memory_path if memory_path.is_absolute() else root / memory_path
|
|
236
|
+
resolved_memory_path = candidate.resolve()
|
|
237
|
+
try:
|
|
238
|
+
resolved_memory_path.relative_to(short_dir)
|
|
239
|
+
except ValueError as error:
|
|
240
|
+
raise StateError("Short-memory file must be under .easy-coding/memory/short/.") from error
|
|
241
|
+
if resolved_memory_path.suffix != ".md":
|
|
242
|
+
raise StateError(f"Short-memory file must be Markdown: {memory_file.strip()}")
|
|
243
|
+
return resolved_memory_path
|
|
160
244
|
|
|
161
245
|
|
|
162
|
-
def
|
|
246
|
+
def validate_short_memory_file(
|
|
247
|
+
root: Path,
|
|
248
|
+
task_id: str,
|
|
249
|
+
memory_file: str,
|
|
250
|
+
expected_sha256: str | None = None,
|
|
251
|
+
) -> tuple[Path, str]:
|
|
252
|
+
resolved_memory_path = resolve_short_memory_path(root, memory_file)
|
|
253
|
+
if not resolved_memory_path.is_file():
|
|
254
|
+
raise StateError(f"Short-memory file not found: {memory_file.strip()}")
|
|
255
|
+
try:
|
|
256
|
+
content = resolved_memory_path.read_text(encoding="utf-8")
|
|
257
|
+
except OSError as error:
|
|
258
|
+
raise StateError(f"Cannot read short-memory file: {memory_file.strip()}") from error
|
|
259
|
+
frontmatter = parse_short_memory_frontmatter(content)
|
|
260
|
+
if parse_positive_int(frontmatter.get("memory_schema", "")) != 2:
|
|
261
|
+
raise StateError("Short-memory file must declare memory_schema: 2.")
|
|
262
|
+
source_task = frontmatter.get("source_task", "")
|
|
263
|
+
if source_task != task_id:
|
|
264
|
+
raise StateError(
|
|
265
|
+
f"Short-memory source_task {source_task or 'missing'} does not match current task {task_id}."
|
|
266
|
+
)
|
|
267
|
+
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
268
|
+
if expected_sha256 and digest != expected_sha256:
|
|
269
|
+
raise StateError("Short-memory file changed after its checkpoint was recorded.")
|
|
270
|
+
return resolved_memory_path, digest
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def validate_recorded_short_memory(
|
|
274
|
+
root: Path,
|
|
275
|
+
task_id: str,
|
|
276
|
+
progress: dict,
|
|
277
|
+
allow_missing_after_distill: bool = False,
|
|
278
|
+
) -> None:
|
|
279
|
+
if progress.get("legacy_short_memory_assumed") is True:
|
|
280
|
+
return
|
|
281
|
+
memory_file = progress.get("short_memory_file")
|
|
282
|
+
expected_sha256 = progress.get("short_memory_sha256")
|
|
283
|
+
if not isinstance(memory_file, str) or not memory_file.strip():
|
|
284
|
+
raise StateError("Recorded short-memory checkpoint is missing its file path.")
|
|
285
|
+
if not isinstance(expected_sha256, str) or not expected_sha256:
|
|
286
|
+
raise StateError("Recorded short-memory checkpoint is missing its content fingerprint.")
|
|
287
|
+
resolved_memory_path = resolve_short_memory_path(root, memory_file)
|
|
288
|
+
if allow_missing_after_distill and not resolved_memory_path.exists():
|
|
289
|
+
return
|
|
290
|
+
validate_short_memory_file(root, task_id, memory_file, expected_sha256)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def build_memory_instruction(
|
|
294
|
+
root: Path,
|
|
295
|
+
checkpoint_file: str | None = None,
|
|
296
|
+
legacy_checkpoint: bool = False,
|
|
297
|
+
) -> dict:
|
|
163
298
|
config = read_memory_config(root)
|
|
164
|
-
|
|
299
|
+
entries = short_memory_entries(root)
|
|
300
|
+
short_count = len(entries)
|
|
165
301
|
action = "distill" if short_count > config["short_term_max"] else "no-op"
|
|
166
302
|
trim_count = max(0, short_count - config["short_term_keep"]) if action == "distill" else 0
|
|
303
|
+
candidate_files = [str(entry["display_path"]) for entry in entries[:trim_count]]
|
|
304
|
+
kept_files = [str(entry["display_path"]) for entry in entries[trim_count:]]
|
|
305
|
+
if legacy_checkpoint:
|
|
306
|
+
checkpoint_disposition = "legacy"
|
|
307
|
+
elif checkpoint_file in candidate_files:
|
|
308
|
+
checkpoint_disposition = "candidate"
|
|
309
|
+
elif checkpoint_file in kept_files:
|
|
310
|
+
checkpoint_disposition = "kept"
|
|
311
|
+
else:
|
|
312
|
+
raise StateError("Recorded short-memory checkpoint is absent from the frozen memory set.")
|
|
167
313
|
return {
|
|
168
314
|
"short_count": short_count,
|
|
169
315
|
"short_term_max": config["short_term_max"],
|
|
170
316
|
"short_term_keep": config["short_term_keep"],
|
|
171
317
|
"action": action,
|
|
172
318
|
"trim_count": trim_count,
|
|
319
|
+
"candidate_files": candidate_files,
|
|
320
|
+
"kept_files": kept_files,
|
|
321
|
+
"checkpoint_disposition": checkpoint_disposition,
|
|
173
322
|
}
|
|
174
323
|
|
|
175
324
|
|
|
325
|
+
def validate_distillation_file_sets(root: Path, instruction: dict) -> None:
|
|
326
|
+
candidate_files = instruction.get("candidate_files")
|
|
327
|
+
kept_files = instruction.get("kept_files")
|
|
328
|
+
if not isinstance(candidate_files, list) or not all(
|
|
329
|
+
isinstance(item, str) for item in candidate_files
|
|
330
|
+
):
|
|
331
|
+
raise StateError("Memory instruction is missing its frozen candidate file set.")
|
|
332
|
+
if not isinstance(kept_files, list) or not all(isinstance(item, str) for item in kept_files):
|
|
333
|
+
raise StateError("Memory instruction is missing its frozen kept file set.")
|
|
334
|
+
for memory_file in candidate_files:
|
|
335
|
+
if resolve_short_memory_path(root, memory_file).exists():
|
|
336
|
+
raise StateError(f"Distillation candidate was not consumed: {memory_file}")
|
|
337
|
+
for memory_file in kept_files:
|
|
338
|
+
if not resolve_short_memory_path(root, memory_file).is_file():
|
|
339
|
+
raise StateError(f"Short-memory file selected for retention is missing: {memory_file}")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def normalize_legacy_stage(stage: object) -> object:
|
|
343
|
+
return LEGACY_STAGE_MAP.get(str(stage), stage)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def normalize_legacy_task(task: dict) -> bool:
|
|
347
|
+
"""Normalize pre-0.6 stage names without touching task artifacts outside task.json."""
|
|
348
|
+
legacy_status = str(task.get("status") or "")
|
|
349
|
+
changed = False
|
|
350
|
+
|
|
351
|
+
if legacy_status in LEGACY_STAGE_MAP:
|
|
352
|
+
task["status"] = LEGACY_STAGE_MAP[legacy_status]
|
|
353
|
+
changed = True
|
|
354
|
+
|
|
355
|
+
history = task.get("stage_history")
|
|
356
|
+
if isinstance(history, list):
|
|
357
|
+
normalized_history: list[dict] = []
|
|
358
|
+
for raw_entry in history:
|
|
359
|
+
if not isinstance(raw_entry, dict):
|
|
360
|
+
continue
|
|
361
|
+
entry = dict(raw_entry)
|
|
362
|
+
mapped_stage = normalize_legacy_stage(entry.get("stage"))
|
|
363
|
+
if mapped_stage != entry.get("stage"):
|
|
364
|
+
entry["stage"] = mapped_stage
|
|
365
|
+
changed = True
|
|
366
|
+
if normalized_history and normalized_history[-1].get("stage") == entry.get("stage"):
|
|
367
|
+
changed = True
|
|
368
|
+
continue
|
|
369
|
+
normalized_history.append(entry)
|
|
370
|
+
if changed:
|
|
371
|
+
task["stage_history"] = normalized_history
|
|
372
|
+
|
|
373
|
+
if legacy_status == "WAITING_CONFIRM" and not task.get("pending_transition"):
|
|
374
|
+
task["pending_transition"] = {
|
|
375
|
+
"from": "ANALYSIS",
|
|
376
|
+
"to": "IMPLEMENT",
|
|
377
|
+
"requested_at": now_iso(),
|
|
378
|
+
"requested_by": str(task.get("last_agent") or "legacy-migration"),
|
|
379
|
+
"reason": "migrated-from-WAITING_CONFIRM",
|
|
380
|
+
}
|
|
381
|
+
changed = True
|
|
382
|
+
|
|
383
|
+
if legacy_status == "MEMORY_LONG":
|
|
384
|
+
progress = task.get("memory_progress")
|
|
385
|
+
if not isinstance(progress, dict):
|
|
386
|
+
progress = {}
|
|
387
|
+
if progress.get("short_memory_written") is not True:
|
|
388
|
+
progress["short_memory_written"] = True
|
|
389
|
+
progress["legacy_short_memory_assumed"] = True
|
|
390
|
+
progress["updated_at"] = now_iso()
|
|
391
|
+
task["memory_progress"] = progress
|
|
392
|
+
changed = True
|
|
393
|
+
elif progress.get("legacy_short_memory_assumed") is not True:
|
|
394
|
+
progress["legacy_short_memory_assumed"] = True
|
|
395
|
+
progress["updated_at"] = now_iso()
|
|
396
|
+
task["memory_progress"] = progress
|
|
397
|
+
changed = True
|
|
398
|
+
|
|
399
|
+
return changed
|
|
400
|
+
|
|
401
|
+
|
|
176
402
|
def write_json(path: Path, data: dict) -> None:
|
|
177
403
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
178
404
|
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
@@ -254,6 +480,378 @@ def append_execution_record(root: Path, task_id: str, record: dict) -> None:
|
|
|
254
480
|
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
255
481
|
|
|
256
482
|
|
|
483
|
+
def is_non_empty_string(value: object) -> bool:
|
|
484
|
+
return isinstance(value, str) and bool(value.strip())
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def is_string_list(value: object, allow_empty: bool = True) -> bool:
|
|
488
|
+
return (
|
|
489
|
+
isinstance(value, list)
|
|
490
|
+
and (allow_empty or len(value) > 0)
|
|
491
|
+
and all(is_non_empty_string(item) for item in value)
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def has_acyclic_dependencies(dependencies_by_unit: dict[str, set[str]]) -> bool:
|
|
496
|
+
remaining = {unit_id: set(dependencies) for unit_id, dependencies in dependencies_by_unit.items()}
|
|
497
|
+
resolved: set[str] = set()
|
|
498
|
+
while remaining:
|
|
499
|
+
ready = {
|
|
500
|
+
unit_id for unit_id, dependencies in remaining.items() if dependencies.issubset(resolved)
|
|
501
|
+
}
|
|
502
|
+
if not ready:
|
|
503
|
+
return False
|
|
504
|
+
resolved.update(ready)
|
|
505
|
+
for unit_id in ready:
|
|
506
|
+
remaining.pop(unit_id)
|
|
507
|
+
return True
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def is_valid_execution_plan(plan: object, allow_empty_files: bool = False) -> bool:
|
|
511
|
+
if not isinstance(plan, dict):
|
|
512
|
+
return False
|
|
513
|
+
strategy = plan.get("strategy")
|
|
514
|
+
units = plan.get("units")
|
|
515
|
+
if strategy not in {"single", "sequential", "parallel"} or not isinstance(units, list):
|
|
516
|
+
return False
|
|
517
|
+
if not units or (strategy == "single" and len(units) != 1):
|
|
518
|
+
return False
|
|
519
|
+
if strategy == "parallel" and len(units) < 2:
|
|
520
|
+
return False
|
|
521
|
+
|
|
522
|
+
unit_ids: list[str] = []
|
|
523
|
+
has_empty_file_scope = False
|
|
524
|
+
for unit in units:
|
|
525
|
+
if not isinstance(unit, dict):
|
|
526
|
+
return False
|
|
527
|
+
if not all(is_non_empty_string(unit.get(field)) for field in ("id", "title", "type")):
|
|
528
|
+
return False
|
|
529
|
+
if not is_string_list(unit.get("files"), allow_empty=allow_empty_files):
|
|
530
|
+
return False
|
|
531
|
+
if not unit["files"]:
|
|
532
|
+
has_empty_file_scope = True
|
|
533
|
+
if not is_string_list(unit.get("depends_on")):
|
|
534
|
+
return False
|
|
535
|
+
for optional_list in ("rules_sections", "abstract_modules"):
|
|
536
|
+
if optional_list in unit and not is_string_list(unit.get(optional_list)):
|
|
537
|
+
return False
|
|
538
|
+
unit_ids.append(str(unit["id"]))
|
|
539
|
+
|
|
540
|
+
if has_empty_file_scope and (not allow_empty_files or strategy != "single" or len(units) != 1):
|
|
541
|
+
return False
|
|
542
|
+
|
|
543
|
+
if len(set(unit_ids)) != len(unit_ids):
|
|
544
|
+
return False
|
|
545
|
+
known_ids = set(unit_ids)
|
|
546
|
+
dependencies_by_unit: dict[str, set[str]] = {}
|
|
547
|
+
for unit in units:
|
|
548
|
+
unit_id = str(unit["id"])
|
|
549
|
+
dependencies = set(unit["depends_on"])
|
|
550
|
+
if unit_id in dependencies or not dependencies.issubset(known_ids):
|
|
551
|
+
return False
|
|
552
|
+
dependencies_by_unit[unit_id] = dependencies
|
|
553
|
+
if not has_acyclic_dependencies(dependencies_by_unit):
|
|
554
|
+
return False
|
|
555
|
+
|
|
556
|
+
if strategy == "parallel":
|
|
557
|
+
parallel_groups = plan.get("parallel_groups")
|
|
558
|
+
if not isinstance(parallel_groups, list) or not parallel_groups:
|
|
559
|
+
return False
|
|
560
|
+
grouped_ids: list[str] = []
|
|
561
|
+
group_levels: set[int] = set()
|
|
562
|
+
level_by_unit: dict[str, int] = {}
|
|
563
|
+
for group in parallel_groups:
|
|
564
|
+
level = group.get("level") if isinstance(group, dict) else None
|
|
565
|
+
if (
|
|
566
|
+
not isinstance(group, dict)
|
|
567
|
+
or type(level) is not int
|
|
568
|
+
or level < 0
|
|
569
|
+
or level in group_levels
|
|
570
|
+
or not is_string_list(group.get("units"), allow_empty=False)
|
|
571
|
+
):
|
|
572
|
+
return False
|
|
573
|
+
group_levels.add(level)
|
|
574
|
+
grouped_ids.extend(group["units"])
|
|
575
|
+
for unit_id in group["units"]:
|
|
576
|
+
level_by_unit[unit_id] = level
|
|
577
|
+
if len(grouped_ids) != len(set(grouped_ids)) or set(grouped_ids) != known_ids:
|
|
578
|
+
return False
|
|
579
|
+
for unit_id, dependencies in dependencies_by_unit.items():
|
|
580
|
+
if any(level_by_unit[dependency] >= level_by_unit[unit_id] for dependency in dependencies):
|
|
581
|
+
return False
|
|
582
|
+
|
|
583
|
+
return True
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def is_read_only_execution_plan(plan: object) -> bool:
|
|
587
|
+
return (
|
|
588
|
+
is_valid_execution_plan(plan, allow_empty_files=True)
|
|
589
|
+
and isinstance(plan, dict)
|
|
590
|
+
and plan.get("strategy") == "single"
|
|
591
|
+
and len(plan["units"]) == 1
|
|
592
|
+
and plan["units"][0].get("files") == []
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def has_valid_execution_plan(root: Path, task_id: str) -> bool:
|
|
597
|
+
path = execution_log_path(root, task_id)
|
|
598
|
+
if not path.exists():
|
|
599
|
+
return False
|
|
600
|
+
latest_plan: dict | None = None
|
|
601
|
+
try:
|
|
602
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
603
|
+
if not line.strip():
|
|
604
|
+
continue
|
|
605
|
+
try:
|
|
606
|
+
record = json.loads(line)
|
|
607
|
+
except json.JSONDecodeError:
|
|
608
|
+
return False
|
|
609
|
+
if isinstance(record, dict) and record.get("type") == "plan":
|
|
610
|
+
latest_plan = record
|
|
611
|
+
except OSError:
|
|
612
|
+
return False
|
|
613
|
+
task = load_task(root, task_id)
|
|
614
|
+
task_type = str(task.get("type") or "").strip().lower() if task else ""
|
|
615
|
+
if task_type in NO_CODE_TASK_TYPES:
|
|
616
|
+
return is_read_only_execution_plan(latest_plan)
|
|
617
|
+
return is_valid_execution_plan(latest_plan)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def validate_read_only_completion(root: Path, task_id: str) -> None:
|
|
621
|
+
task = load_task(root, task_id)
|
|
622
|
+
task_type = str(task.get("type") or "").strip().lower() if task else ""
|
|
623
|
+
reasons: list[str] = []
|
|
624
|
+
if task_type not in NO_CODE_TASK_TYPES:
|
|
625
|
+
reasons.append("task type is not doc, analysis, or report")
|
|
626
|
+
|
|
627
|
+
path = execution_log_path(root, task_id)
|
|
628
|
+
records: list[dict] = []
|
|
629
|
+
if not path.exists():
|
|
630
|
+
reasons.append("execution.jsonl is missing")
|
|
631
|
+
else:
|
|
632
|
+
try:
|
|
633
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
634
|
+
if not line.strip():
|
|
635
|
+
continue
|
|
636
|
+
record = json.loads(line)
|
|
637
|
+
if not isinstance(record, dict):
|
|
638
|
+
reasons.append("execution.jsonl contains a non-object record")
|
|
639
|
+
break
|
|
640
|
+
records.append(record)
|
|
641
|
+
except (OSError, json.JSONDecodeError):
|
|
642
|
+
reasons.append("execution.jsonl cannot be read as valid JSONL")
|
|
643
|
+
|
|
644
|
+
latest_plan_index: int | None = None
|
|
645
|
+
for index, record in enumerate(records):
|
|
646
|
+
if record.get("type") == "plan":
|
|
647
|
+
latest_plan_index = index
|
|
648
|
+
|
|
649
|
+
unit_id = ""
|
|
650
|
+
if latest_plan_index is None:
|
|
651
|
+
reasons.append("execution.jsonl has no plan record")
|
|
652
|
+
else:
|
|
653
|
+
plan = records[latest_plan_index]
|
|
654
|
+
if not is_read_only_execution_plan(plan):
|
|
655
|
+
reasons.append("latest plan record is invalid")
|
|
656
|
+
else:
|
|
657
|
+
units = plan["units"]
|
|
658
|
+
unit_id = str(units[0]["id"])
|
|
659
|
+
|
|
660
|
+
unit_records: list[dict] = []
|
|
661
|
+
if latest_plan_index is not None and unit_id:
|
|
662
|
+
for record in records[latest_plan_index + 1 :]:
|
|
663
|
+
if record.get("unit_id") == unit_id and record.get("type") in {"dispatch", "result"}:
|
|
664
|
+
unit_records.append(record)
|
|
665
|
+
latest_result = (
|
|
666
|
+
unit_records[-1]
|
|
667
|
+
if unit_records and unit_records[-1].get("type") == "result"
|
|
668
|
+
else None
|
|
669
|
+
)
|
|
670
|
+
if latest_result is None:
|
|
671
|
+
reasons.append("latest read-only unit has no result record")
|
|
672
|
+
else:
|
|
673
|
+
matching_dispatch = unit_records[-2] if len(unit_records) >= 2 else None
|
|
674
|
+
if matching_dispatch is None or matching_dispatch.get("type") != "dispatch":
|
|
675
|
+
reasons.append("latest read-only result has no matching dispatch record")
|
|
676
|
+
elif not is_non_empty_string(matching_dispatch.get("timestamp")):
|
|
677
|
+
reasons.append("latest read-only dispatch record has no timestamp")
|
|
678
|
+
if latest_result.get("changed_files") != []:
|
|
679
|
+
reasons.append("read-only result must contain changed_files:[]")
|
|
680
|
+
if not is_non_empty_string(latest_result.get("deliverable")):
|
|
681
|
+
reasons.append("read-only result must contain a non-empty deliverable")
|
|
682
|
+
if latest_result.get("issues") != []:
|
|
683
|
+
reasons.append("read-only result must contain issues:[]")
|
|
684
|
+
if latest_result.get("needs_attention") != []:
|
|
685
|
+
reasons.append("read-only result must contain needs_attention:[]")
|
|
686
|
+
|
|
687
|
+
if reasons:
|
|
688
|
+
raise StateError(
|
|
689
|
+
"Read-only IMPLEMENT cannot complete before its report is ready: " + "; ".join(reasons)
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def markdown_headings(content: str) -> list[tuple[int, int, str]]:
|
|
694
|
+
headings: list[tuple[int, int, str]] = []
|
|
695
|
+
fence_marker: str | None = None
|
|
696
|
+
for index, line in enumerate(content.splitlines()):
|
|
697
|
+
stripped = line.lstrip()
|
|
698
|
+
if stripped.startswith(("```", "~~~")):
|
|
699
|
+
marker = stripped[:3]
|
|
700
|
+
if fence_marker is None:
|
|
701
|
+
fence_marker = marker
|
|
702
|
+
elif fence_marker == marker:
|
|
703
|
+
fence_marker = None
|
|
704
|
+
continue
|
|
705
|
+
if fence_marker is not None:
|
|
706
|
+
continue
|
|
707
|
+
match = MARKDOWN_HEADING_PATTERN.match(line.strip())
|
|
708
|
+
if match:
|
|
709
|
+
headings.append((index, len(match.group(1)), match.group(2).strip()))
|
|
710
|
+
return headings
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def has_meaningful_markdown_body(content: str) -> bool:
|
|
714
|
+
for line in content.splitlines():
|
|
715
|
+
stripped = line.strip()
|
|
716
|
+
if not stripped or MARKDOWN_HEADING_PATTERN.match(stripped):
|
|
717
|
+
continue
|
|
718
|
+
if stripped.startswith(">"):
|
|
719
|
+
continue
|
|
720
|
+
if re.fullmatch(r"[-|: ]+", stripped):
|
|
721
|
+
continue
|
|
722
|
+
if stripped == "(若 single:单一实施单元,派发 1 个子代理执行)":
|
|
723
|
+
continue
|
|
724
|
+
if stripped.startswith("|") and stripped.endswith("|"):
|
|
725
|
+
cells = {cell.strip() for cell in stripped.strip("|").split("|") if cell.strip()}
|
|
726
|
+
if cells and cells.issubset(TABLE_HEADER_CELLS):
|
|
727
|
+
continue
|
|
728
|
+
plain = re.sub(r"[`*_]", "", stripped)
|
|
729
|
+
plain = re.sub(r"^[-+]\s*", "", plain).strip()
|
|
730
|
+
if re.fullmatch(r"[^::|]+[::]", plain):
|
|
731
|
+
continue
|
|
732
|
+
return True
|
|
733
|
+
return False
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def validate_mandatory_dev_spec_sections(content: str) -> tuple[list[str], list[str]]:
|
|
737
|
+
lines = content.splitlines()
|
|
738
|
+
headings = markdown_headings(content)
|
|
739
|
+
missing: list[str] = []
|
|
740
|
+
empty: list[str] = []
|
|
741
|
+
|
|
742
|
+
title_heading = next(
|
|
743
|
+
(
|
|
744
|
+
(line_index, level, title)
|
|
745
|
+
for line_index, level, title in headings
|
|
746
|
+
if level == 2 and (title == "技术方案" or title.startswith(("技术方案:", "技术方案:")))
|
|
747
|
+
),
|
|
748
|
+
None,
|
|
749
|
+
)
|
|
750
|
+
if title_heading is None:
|
|
751
|
+
missing.append("## 技术方案")
|
|
752
|
+
else:
|
|
753
|
+
title = title_heading[2]
|
|
754
|
+
title_value = title.removeprefix("技术方案").lstrip("::").strip()
|
|
755
|
+
if not title_value:
|
|
756
|
+
empty.append("## 技术方案")
|
|
757
|
+
|
|
758
|
+
for header in MANDATORY_DEV_SPEC_HEADERS[1:]:
|
|
759
|
+
expected_title = header.removeprefix("### ")
|
|
760
|
+
heading_index = next(
|
|
761
|
+
(
|
|
762
|
+
index
|
|
763
|
+
for index, (_, level, title) in enumerate(headings)
|
|
764
|
+
if level == 3 and title == expected_title
|
|
765
|
+
),
|
|
766
|
+
None,
|
|
767
|
+
)
|
|
768
|
+
if heading_index is None:
|
|
769
|
+
missing.append(header)
|
|
770
|
+
continue
|
|
771
|
+
line_index, level, _ = headings[heading_index]
|
|
772
|
+
next_line_index = len(lines)
|
|
773
|
+
for candidate_line, candidate_level, _ in headings[heading_index + 1 :]:
|
|
774
|
+
if candidate_level <= level:
|
|
775
|
+
next_line_index = candidate_line
|
|
776
|
+
break
|
|
777
|
+
body = "\n".join(lines[line_index + 1 : next_line_index])
|
|
778
|
+
if not has_meaningful_markdown_body(body):
|
|
779
|
+
empty.append(header)
|
|
780
|
+
|
|
781
|
+
return missing, empty
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def validate_analysis_readiness(root: Path, task_id: str) -> None:
|
|
785
|
+
task_dir = task_json_path(root, task_id).parent
|
|
786
|
+
task = load_task(root, task_id)
|
|
787
|
+
task_type = str(task.get("type") or "").strip().lower() if task else ""
|
|
788
|
+
is_read_only_task = task_type in NO_CODE_TASK_TYPES
|
|
789
|
+
dev_spec = task_dir / "dev-spec.md"
|
|
790
|
+
skeleton = root / ".easy-coding" / "templates" / "dev-spec-skeleton.md"
|
|
791
|
+
test_strategy = task_dir / "test-strategy.md"
|
|
792
|
+
reasons: list[str] = []
|
|
793
|
+
|
|
794
|
+
dev_spec_content = ""
|
|
795
|
+
if not dev_spec.exists():
|
|
796
|
+
reasons.append("dev-spec.md is missing")
|
|
797
|
+
else:
|
|
798
|
+
try:
|
|
799
|
+
dev_spec_content = dev_spec.read_text(encoding="utf-8")
|
|
800
|
+
if not dev_spec_content.strip():
|
|
801
|
+
reasons.append("dev-spec.md is empty")
|
|
802
|
+
except OSError:
|
|
803
|
+
reasons.append("dev-spec.md cannot be read")
|
|
804
|
+
|
|
805
|
+
if dev_spec_content:
|
|
806
|
+
missing_headers, empty_sections = validate_mandatory_dev_spec_sections(dev_spec_content)
|
|
807
|
+
if is_read_only_task:
|
|
808
|
+
empty_sections = [
|
|
809
|
+
header for header in empty_sections if header != "### 改动范围"
|
|
810
|
+
]
|
|
811
|
+
if missing_headers:
|
|
812
|
+
reasons.append(
|
|
813
|
+
"dev-spec.md is missing mandatory headers: "
|
|
814
|
+
+ ", ".join(header.lstrip("# ") for header in missing_headers)
|
|
815
|
+
)
|
|
816
|
+
if empty_sections:
|
|
817
|
+
reasons.append(
|
|
818
|
+
"dev-spec.md has empty mandatory sections: "
|
|
819
|
+
+ ", ".join(header.lstrip("# ") for header in empty_sections)
|
|
820
|
+
)
|
|
821
|
+
if "[阶段:ANALYSIS]" in dev_spec_content or "### 待用户决策" in dev_spec_content:
|
|
822
|
+
reasons.append("dev-spec.md contains forbidden analysis-only sections")
|
|
823
|
+
|
|
824
|
+
if not skeleton.exists():
|
|
825
|
+
reasons.append("dev-spec skeleton template is missing")
|
|
826
|
+
else:
|
|
827
|
+
try:
|
|
828
|
+
skeleton_content = skeleton.read_text(encoding="utf-8")
|
|
829
|
+
if not DEV_SPEC_PLACEHOLDER_PATTERN.search(skeleton_content):
|
|
830
|
+
reasons.append("dev-spec skeleton template has no EC_TODO markers")
|
|
831
|
+
if DEV_SPEC_PLACEHOLDER_PATTERN.search(dev_spec_content):
|
|
832
|
+
reasons.append("dev-spec.md contains unresolved template placeholders")
|
|
833
|
+
except OSError:
|
|
834
|
+
reasons.append("dev-spec skeleton template cannot be read")
|
|
835
|
+
|
|
836
|
+
if not has_valid_execution_plan(root, task_id):
|
|
837
|
+
reasons.append("execution.jsonl has no valid plan record")
|
|
838
|
+
if is_read_only_task:
|
|
839
|
+
if test_strategy.exists():
|
|
840
|
+
reasons.append("read-only task must not create test-strategy.md")
|
|
841
|
+
else:
|
|
842
|
+
try:
|
|
843
|
+
if not test_strategy.exists() or not test_strategy.read_text(encoding="utf-8").strip():
|
|
844
|
+
reasons.append("test-strategy.md is missing or empty")
|
|
845
|
+
except OSError:
|
|
846
|
+
reasons.append("test-strategy.md cannot be read")
|
|
847
|
+
|
|
848
|
+
if reasons:
|
|
849
|
+
raise StateError(
|
|
850
|
+
"ANALYSIS cannot advance to IMPLEMENT before analysis artifacts are ready: "
|
|
851
|
+
+ "; ".join(reasons)
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
|
|
257
855
|
def latest_handoff_record(root: Path, task_id: str) -> dict | None:
|
|
258
856
|
path = execution_log_path(root, task_id)
|
|
259
857
|
if not path.exists():
|
|
@@ -292,10 +890,24 @@ def get_pending_init_version(root: Path) -> str | None:
|
|
|
292
890
|
return None
|
|
293
891
|
|
|
294
892
|
|
|
295
|
-
def
|
|
893
|
+
def is_automatic_transition(previous: str, current: str, task_type: str = "") -> bool:
|
|
894
|
+
if (previous, current) in AUTO_TRANSITIONS:
|
|
895
|
+
return True
|
|
896
|
+
return (
|
|
897
|
+
(previous, current) == READ_ONLY_COMPLETION_TRANSITION
|
|
898
|
+
and task_type.strip().lower() in NO_CODE_TASK_TYPES
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def validate_transition(previous: str, current: str, task_type: str = "") -> str | None:
|
|
296
903
|
if previous == current:
|
|
297
904
|
return None
|
|
298
|
-
|
|
905
|
+
normalized_task_type = task_type.strip().lower()
|
|
906
|
+
allowed = set(VALID_TRANSITIONS.get(previous, set()))
|
|
907
|
+
if previous == "IMPLEMENT" and normalized_task_type in NO_CODE_TASK_TYPES:
|
|
908
|
+
allowed = {"ANALYSIS", "COMPLETE", "CLOSED"}
|
|
909
|
+
elif previous == "IMPLEMENT":
|
|
910
|
+
allowed.discard("COMPLETE")
|
|
299
911
|
if current in allowed:
|
|
300
912
|
return None
|
|
301
913
|
return (
|
|
@@ -335,6 +947,8 @@ def snapshot_state(
|
|
|
335
947
|
"session_file": display_path(root, session_path),
|
|
336
948
|
"current_task": str(task_id) if task_id else None,
|
|
337
949
|
"task": task,
|
|
950
|
+
"pending_transition": task.get("pending_transition") if task else None,
|
|
951
|
+
"memory_progress": task.get("memory_progress") if task else None,
|
|
338
952
|
"task_missing": missing,
|
|
339
953
|
"status": status,
|
|
340
954
|
"is_terminal": status in TERMINAL_STATUSES,
|
|
@@ -395,6 +1009,17 @@ def build_machine_breadcrumbs(
|
|
|
395
1009
|
last_agent = state.get("last_agent")
|
|
396
1010
|
if agent and last_agent and last_agent != agent:
|
|
397
1011
|
lines.append(f"[easy-coding:handoff-from:{last_agent}]")
|
|
1012
|
+
pending = state.get("pending_transition")
|
|
1013
|
+
if isinstance(pending, dict):
|
|
1014
|
+
source = str(pending.get("from") or stage)
|
|
1015
|
+
target = str(pending.get("to") or "")
|
|
1016
|
+
if target:
|
|
1017
|
+
lines.append(f"[easy-coding:pending-transition:{source}->{target}]")
|
|
1018
|
+
task_type = str(task.get("type") or "") if task else ""
|
|
1019
|
+
if is_automatic_transition(source, target, task_type):
|
|
1020
|
+
lines.append(f"[easy-coding:auto-transition-ready:{source}->{target}]")
|
|
1021
|
+
else:
|
|
1022
|
+
lines.append("[easy-coding:transition-confirmation-required]")
|
|
398
1023
|
|
|
399
1024
|
if is_project_init_required(root):
|
|
400
1025
|
lines.append("[easy-coding:init-required]")
|
|
@@ -644,15 +1269,11 @@ def append_stage_history(task: dict, stage: str, agent: str) -> None:
|
|
|
644
1269
|
history.append({"stage": stage, "agent": agent, "entered_at": now_iso()})
|
|
645
1270
|
|
|
646
1271
|
|
|
647
|
-
def
|
|
1272
|
+
def resolve_current_task(
|
|
648
1273
|
root: Path,
|
|
649
|
-
stage: str,
|
|
650
|
-
agent: str,
|
|
651
1274
|
task_id: str | None = None,
|
|
652
1275
|
session_file: str | Path | None = None,
|
|
653
|
-
) -> dict:
|
|
654
|
-
if stage not in VALID_TRANSITIONS:
|
|
655
|
-
raise StateError(f"Unknown stage: {stage}")
|
|
1276
|
+
) -> tuple[dict, str, dict]:
|
|
656
1277
|
session = ensure_session(root, session_file)
|
|
657
1278
|
resolved_task_id = task_id or session.get("current_task")
|
|
658
1279
|
if not resolved_task_id:
|
|
@@ -660,28 +1281,298 @@ def transition_task(
|
|
|
660
1281
|
task = load_task(root, str(resolved_task_id))
|
|
661
1282
|
if task is None:
|
|
662
1283
|
raise StateError(f"Task not found: {resolved_task_id}")
|
|
1284
|
+
return session, str(resolved_task_id), task
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
def request_transition(
|
|
1288
|
+
root: Path,
|
|
1289
|
+
stage: str,
|
|
1290
|
+
agent: str,
|
|
1291
|
+
task_id: str | None = None,
|
|
1292
|
+
session_file: str | Path | None = None,
|
|
1293
|
+
reason: str | None = None,
|
|
1294
|
+
) -> dict:
|
|
1295
|
+
if stage not in VALID_TRANSITIONS:
|
|
1296
|
+
raise StateError(f"Unknown stage: {stage}")
|
|
1297
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
1298
|
+
previous = str(task.get("status") or "idle")
|
|
1299
|
+
task_type = str(task.get("type") or "")
|
|
1300
|
+
if previous == stage:
|
|
1301
|
+
raise StateError(f"Transition target must differ from current stage: {stage}")
|
|
1302
|
+
|
|
1303
|
+
violation = validate_transition(previous, stage, task_type)
|
|
1304
|
+
if violation:
|
|
1305
|
+
raise StateError(violation)
|
|
1306
|
+
if is_automatic_transition(previous, stage, task_type):
|
|
1307
|
+
raise StateError(
|
|
1308
|
+
f"Transition {previous} -> {stage} is automatic; use auto-transition instead."
|
|
1309
|
+
)
|
|
1310
|
+
if previous == "ANALYSIS" and stage == "IMPLEMENT":
|
|
1311
|
+
validate_analysis_readiness(root, resolved_task_id)
|
|
1312
|
+
existing = task.get("pending_transition")
|
|
1313
|
+
if isinstance(existing, dict):
|
|
1314
|
+
if existing.get("from") != previous or existing.get("to") != stage:
|
|
1315
|
+
raise StateError(
|
|
1316
|
+
"A different transition is already pending. Cancel it before requesting another."
|
|
1317
|
+
)
|
|
1318
|
+
else:
|
|
1319
|
+
task["pending_transition"] = {
|
|
1320
|
+
"from": previous,
|
|
1321
|
+
"to": stage,
|
|
1322
|
+
"requested_at": now_iso(),
|
|
1323
|
+
"requested_by": agent,
|
|
1324
|
+
**({"reason": reason.strip()} if reason and reason.strip() else {}),
|
|
1325
|
+
}
|
|
1326
|
+
task["last_agent"] = agent
|
|
1327
|
+
write_task(root, resolved_task_id, task)
|
|
1328
|
+
|
|
1329
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
1330
|
+
snapshot["action"] = "request-transition"
|
|
1331
|
+
return snapshot
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
def apply_transition(
|
|
1335
|
+
root: Path,
|
|
1336
|
+
stage: str,
|
|
1337
|
+
agent: str,
|
|
1338
|
+
task_id: str | None = None,
|
|
1339
|
+
session_file: str | Path | None = None,
|
|
1340
|
+
) -> dict:
|
|
1341
|
+
if stage not in VALID_TRANSITIONS:
|
|
1342
|
+
raise StateError(f"Unknown stage: {stage}")
|
|
1343
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
663
1344
|
|
|
664
1345
|
previous = str(task.get("status") or "idle")
|
|
665
|
-
|
|
1346
|
+
task_type = str(task.get("type") or "")
|
|
1347
|
+
violation = validate_transition(previous, stage, task_type)
|
|
666
1348
|
if violation:
|
|
667
1349
|
raise StateError(violation)
|
|
1350
|
+
if previous == "ANALYSIS" and stage == "IMPLEMENT":
|
|
1351
|
+
validate_analysis_readiness(root, resolved_task_id)
|
|
1352
|
+
if previous == "MEMORY" and stage == "COMPLETE":
|
|
1353
|
+
progress = task.get("memory_progress")
|
|
1354
|
+
if not isinstance(progress, dict) or progress.get("completed") is not True:
|
|
1355
|
+
raise StateError("MEMORY cannot advance to COMPLETE before memory processing completes.")
|
|
1356
|
+
if (previous, stage) == READ_ONLY_COMPLETION_TRANSITION:
|
|
1357
|
+
validate_read_only_completion(root, resolved_task_id)
|
|
668
1358
|
if previous != stage:
|
|
669
1359
|
task["status"] = stage
|
|
670
1360
|
append_stage_history(task, stage, agent)
|
|
1361
|
+
task.pop("pending_transition", None)
|
|
1362
|
+
if stage == "MEMORY" and previous != stage:
|
|
1363
|
+
task["memory_progress"] = {}
|
|
671
1364
|
task["last_agent"] = agent
|
|
672
|
-
write_task(root,
|
|
1365
|
+
write_task(root, resolved_task_id, task)
|
|
673
1366
|
|
|
674
1367
|
if session.get("current_task") == resolved_task_id:
|
|
675
1368
|
if stage in TERMINAL_STATUSES:
|
|
676
1369
|
clear_session_pointer(session, agent)
|
|
677
1370
|
else:
|
|
678
|
-
session["last_seen_task"] =
|
|
1371
|
+
session["last_seen_task"] = resolved_task_id
|
|
679
1372
|
session["last_seen_stage"] = stage
|
|
680
1373
|
session["last_agent"] = agent
|
|
681
1374
|
write_session(root, session, session_file)
|
|
1375
|
+
return snapshot_state(root, session_file, session)
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
def auto_transition(
|
|
1379
|
+
root: Path,
|
|
1380
|
+
stage: str,
|
|
1381
|
+
agent: str,
|
|
1382
|
+
task_id: str | None = None,
|
|
1383
|
+
session_file: str | Path | None = None,
|
|
1384
|
+
) -> dict:
|
|
1385
|
+
_, _, task = resolve_current_task(root, task_id, session_file)
|
|
1386
|
+
previous = str(task.get("status") or "idle")
|
|
1387
|
+
task_type = str(task.get("type") or "")
|
|
1388
|
+
if not is_automatic_transition(previous, stage, task_type):
|
|
1389
|
+
raise StateError(f"Automatic transition is not allowed: {previous} -> {stage}.")
|
|
1390
|
+
|
|
1391
|
+
pending = task.get("pending_transition")
|
|
1392
|
+
if isinstance(pending, dict) and (
|
|
1393
|
+
pending.get("from") != previous or pending.get("to") != stage
|
|
1394
|
+
):
|
|
1395
|
+
raise StateError(
|
|
1396
|
+
"A different transition is already pending. Cancel it before automatic transition."
|
|
1397
|
+
)
|
|
1398
|
+
|
|
1399
|
+
snapshot = apply_transition(root, stage, agent, task_id, session_file)
|
|
1400
|
+
snapshot["action"] = "auto-transition"
|
|
1401
|
+
snapshot["automatic_transition"] = {"from": previous, "to": stage}
|
|
1402
|
+
return snapshot
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def confirm_transition(
|
|
1406
|
+
root: Path,
|
|
1407
|
+
agent: str,
|
|
1408
|
+
stage: str | None = None,
|
|
1409
|
+
task_id: str | None = None,
|
|
1410
|
+
session_file: str | Path | None = None,
|
|
1411
|
+
) -> dict:
|
|
1412
|
+
_, _, task = resolve_current_task(root, task_id, session_file)
|
|
1413
|
+
pending = task.get("pending_transition")
|
|
1414
|
+
if not isinstance(pending, dict):
|
|
1415
|
+
raise StateError("No transition is pending user confirmation.")
|
|
1416
|
+
previous = str(task.get("status") or "idle")
|
|
1417
|
+
task_type = str(task.get("type") or "")
|
|
1418
|
+
source = str(pending.get("from") or "")
|
|
1419
|
+
target = str(pending.get("to") or "")
|
|
1420
|
+
if source != previous:
|
|
1421
|
+
raise StateError(
|
|
1422
|
+
f"Pending transition source {source or 'missing'} does not match current stage {previous}."
|
|
1423
|
+
)
|
|
1424
|
+
if stage and stage != target:
|
|
1425
|
+
raise StateError(f"Pending transition targets {target}, not {stage}.")
|
|
1426
|
+
if is_automatic_transition(source, target, task_type):
|
|
1427
|
+
raise StateError(
|
|
1428
|
+
f"Transition {source} -> {target} is automatic; use auto-transition instead."
|
|
1429
|
+
)
|
|
1430
|
+
|
|
1431
|
+
snapshot = apply_transition(root, target, agent, task_id, session_file)
|
|
1432
|
+
snapshot["action"] = "confirm-transition"
|
|
1433
|
+
snapshot["confirmed_transition"] = {"from": source, "to": target}
|
|
1434
|
+
return snapshot
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
def cancel_transition(
|
|
1438
|
+
root: Path,
|
|
1439
|
+
agent: str,
|
|
1440
|
+
task_id: str | None = None,
|
|
1441
|
+
session_file: str | Path | None = None,
|
|
1442
|
+
) -> dict:
|
|
1443
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
1444
|
+
pending = task.pop("pending_transition", None)
|
|
1445
|
+
task["last_agent"] = agent
|
|
1446
|
+
write_task(root, resolved_task_id, task)
|
|
1447
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
1448
|
+
snapshot["action"] = "cancel-transition"
|
|
1449
|
+
snapshot["cancelled_transition"] = pending
|
|
1450
|
+
return snapshot
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
def memory_short_complete(
|
|
1454
|
+
root: Path,
|
|
1455
|
+
memory_file: str,
|
|
1456
|
+
agent: str,
|
|
1457
|
+
task_id: str | None = None,
|
|
1458
|
+
session_file: str | Path | None = None,
|
|
1459
|
+
) -> dict:
|
|
1460
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
1461
|
+
if task.get("status") != "MEMORY":
|
|
1462
|
+
raise StateError("Short-memory progress can only be recorded during MEMORY.")
|
|
1463
|
+
if not memory_file.strip():
|
|
1464
|
+
raise StateError("Short-memory file is required.")
|
|
1465
|
+
resolved_memory_path, digest = validate_short_memory_file(
|
|
1466
|
+
root, resolved_task_id, memory_file.strip()
|
|
1467
|
+
)
|
|
1468
|
+
progress = task.get("memory_progress")
|
|
1469
|
+
if not isinstance(progress, dict):
|
|
1470
|
+
progress = {}
|
|
1471
|
+
checkpoint_file = display_path(root, resolved_memory_path)
|
|
1472
|
+
if (
|
|
1473
|
+
progress.get("short_memory_written") is True
|
|
1474
|
+
and progress.get("legacy_short_memory_assumed") is not True
|
|
1475
|
+
):
|
|
1476
|
+
if (
|
|
1477
|
+
progress.get("short_memory_file") == checkpoint_file
|
|
1478
|
+
and progress.get("short_memory_sha256") == digest
|
|
1479
|
+
):
|
|
1480
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
1481
|
+
snapshot["action"] = "memory-short-complete"
|
|
1482
|
+
snapshot["checkpoint_unchanged"] = True
|
|
1483
|
+
return snapshot
|
|
1484
|
+
raise StateError("A different short-memory checkpoint is already recorded for this task.")
|
|
1485
|
+
progress["short_memory_written"] = True
|
|
1486
|
+
progress["short_memory_file"] = checkpoint_file
|
|
1487
|
+
progress["short_memory_sha256"] = digest
|
|
1488
|
+
progress.pop("legacy_short_memory_assumed", None)
|
|
1489
|
+
progress["updated_at"] = now_iso()
|
|
1490
|
+
task["memory_progress"] = progress
|
|
1491
|
+
task["last_agent"] = agent
|
|
1492
|
+
write_task(root, resolved_task_id, task)
|
|
1493
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
1494
|
+
snapshot["action"] = "memory-short-complete"
|
|
1495
|
+
return snapshot
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def memory_instruction(
|
|
1499
|
+
root: Path,
|
|
1500
|
+
task_id: str | None = None,
|
|
1501
|
+
session_file: str | Path | None = None,
|
|
1502
|
+
) -> dict:
|
|
1503
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
1504
|
+
if task.get("status") != "MEMORY":
|
|
1505
|
+
raise StateError("Memory instruction is only available during MEMORY.")
|
|
1506
|
+
progress = task.get("memory_progress")
|
|
1507
|
+
if not isinstance(progress, dict) or progress.get("short_memory_written") is not True:
|
|
1508
|
+
raise StateError("Write and record the short memory before requesting memory instruction.")
|
|
1509
|
+
instruction = progress.get("instruction")
|
|
1510
|
+
allow_missing_checkpoint = bool(
|
|
1511
|
+
isinstance(instruction, dict)
|
|
1512
|
+
and instruction.get("action") == "distill"
|
|
1513
|
+
and instruction.get("checkpoint_disposition") == "candidate"
|
|
1514
|
+
)
|
|
1515
|
+
validate_recorded_short_memory(
|
|
1516
|
+
root,
|
|
1517
|
+
resolved_task_id,
|
|
1518
|
+
progress,
|
|
1519
|
+
allow_missing_after_distill=allow_missing_checkpoint,
|
|
1520
|
+
)
|
|
1521
|
+
if not isinstance(instruction, dict):
|
|
1522
|
+
legacy_checkpoint = progress.get("legacy_short_memory_assumed") is True
|
|
1523
|
+
checkpoint_file = None if legacy_checkpoint else str(progress.get("short_memory_file"))
|
|
1524
|
+
instruction = build_memory_instruction(root, checkpoint_file, legacy_checkpoint)
|
|
1525
|
+
progress["instruction"] = instruction
|
|
1526
|
+
progress["updated_at"] = now_iso()
|
|
1527
|
+
task["memory_progress"] = progress
|
|
1528
|
+
write_task(root, resolved_task_id, task)
|
|
1529
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
1530
|
+
snapshot["memory"] = instruction
|
|
1531
|
+
snapshot["action"] = "memory-instruction"
|
|
1532
|
+
return snapshot
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
def memory_complete(
|
|
1536
|
+
root: Path,
|
|
1537
|
+
action: str,
|
|
1538
|
+
agent: str,
|
|
1539
|
+
task_id: str | None = None,
|
|
1540
|
+
session_file: str | Path | None = None,
|
|
1541
|
+
) -> dict:
|
|
1542
|
+
if action not in {"no-op", "distill"}:
|
|
1543
|
+
raise StateError(f"Unknown memory action: {action}")
|
|
1544
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
1545
|
+
if task.get("status") != "MEMORY":
|
|
1546
|
+
raise StateError("Memory completion can only be recorded during MEMORY.")
|
|
1547
|
+
progress = task.get("memory_progress")
|
|
1548
|
+
if not isinstance(progress, dict) or progress.get("short_memory_written") is not True:
|
|
1549
|
+
raise StateError("Short memory must be recorded before MEMORY can complete.")
|
|
1550
|
+
instruction = progress.get("instruction")
|
|
1551
|
+
if not isinstance(instruction, dict):
|
|
1552
|
+
raise StateError("Request the authoritative memory instruction before completing MEMORY.")
|
|
1553
|
+
if instruction["action"] != action:
|
|
1554
|
+
raise StateError(
|
|
1555
|
+
f"Memory action {action} does not match authoritative instruction {instruction['action']}."
|
|
1556
|
+
)
|
|
1557
|
+
validate_recorded_short_memory(
|
|
1558
|
+
root,
|
|
1559
|
+
resolved_task_id,
|
|
1560
|
+
progress,
|
|
1561
|
+
allow_missing_after_distill=(
|
|
1562
|
+
action == "distill" and instruction.get("checkpoint_disposition") == "candidate"
|
|
1563
|
+
),
|
|
1564
|
+
)
|
|
1565
|
+
if action == "distill":
|
|
1566
|
+
validate_distillation_file_sets(root, instruction)
|
|
1567
|
+
progress["long_memory_action"] = action
|
|
1568
|
+
progress["completed"] = True
|
|
1569
|
+
progress["updated_at"] = now_iso()
|
|
1570
|
+
task["memory_progress"] = progress
|
|
1571
|
+
task["last_agent"] = agent
|
|
1572
|
+
write_task(root, resolved_task_id, task)
|
|
682
1573
|
snapshot = snapshot_state(root, session_file, session)
|
|
683
|
-
|
|
684
|
-
|
|
1574
|
+
snapshot["memory"] = instruction
|
|
1575
|
+
snapshot["action"] = "memory-complete"
|
|
685
1576
|
return snapshot
|
|
686
1577
|
|
|
687
1578
|
|
|
@@ -701,6 +1592,7 @@ def close_current_task(
|
|
|
701
1592
|
if task.get("status") != "CLOSED":
|
|
702
1593
|
task["status"] = "CLOSED"
|
|
703
1594
|
append_stage_history(task, "CLOSED", agent)
|
|
1595
|
+
task.pop("pending_transition", None)
|
|
704
1596
|
task["closed_reason"] = reason
|
|
705
1597
|
task["last_agent"] = agent
|
|
706
1598
|
write_task(root, str(task_id), task)
|
|
@@ -757,7 +1649,9 @@ def record_seen_stage(
|
|
|
757
1649
|
|
|
758
1650
|
violation = None
|
|
759
1651
|
if last_seen_task == task_id and last_seen_stage:
|
|
760
|
-
|
|
1652
|
+
task = load_task(root, task_id)
|
|
1653
|
+
task_type = str(task.get("type") or "") if task else ""
|
|
1654
|
+
violation = validate_transition(str(last_seen_stage), stage, task_type)
|
|
761
1655
|
|
|
762
1656
|
if last_seen_task != task_id or last_seen_stage != stage:
|
|
763
1657
|
session["last_seen_task"] = task_id
|
|
@@ -818,11 +1712,45 @@ def main() -> int:
|
|
|
818
1712
|
claim.add_argument("--task-id", required=True)
|
|
819
1713
|
claim.add_argument("--agent", required=True)
|
|
820
1714
|
|
|
1715
|
+
request = subcommands.add_parser("request-transition", parents=[common])
|
|
1716
|
+
request.add_argument("--stage", required=True)
|
|
1717
|
+
request.add_argument("--agent", required=True)
|
|
1718
|
+
request.add_argument("--task-id")
|
|
1719
|
+
request.add_argument("--reason")
|
|
1720
|
+
|
|
1721
|
+
confirm_transition_parser = subcommands.add_parser("confirm-transition", parents=[common])
|
|
1722
|
+
confirm_transition_parser.add_argument("--stage")
|
|
1723
|
+
confirm_transition_parser.add_argument("--agent", required=True)
|
|
1724
|
+
confirm_transition_parser.add_argument("--task-id")
|
|
1725
|
+
|
|
1726
|
+
auto_transition_parser = subcommands.add_parser("auto-transition", parents=[common])
|
|
1727
|
+
auto_transition_parser.add_argument("--stage", required=True)
|
|
1728
|
+
auto_transition_parser.add_argument("--agent", required=True)
|
|
1729
|
+
auto_transition_parser.add_argument("--task-id")
|
|
1730
|
+
|
|
1731
|
+
# Compatibility alias: pre-0.6 callers still consume the pending gate instead of bypassing it.
|
|
821
1732
|
transition = subcommands.add_parser("transition", parents=[common])
|
|
822
|
-
transition.add_argument("--stage"
|
|
1733
|
+
transition.add_argument("--stage")
|
|
823
1734
|
transition.add_argument("--agent", required=True)
|
|
824
1735
|
transition.add_argument("--task-id")
|
|
825
1736
|
|
|
1737
|
+
cancel_transition_parser = subcommands.add_parser("cancel-transition", parents=[common])
|
|
1738
|
+
cancel_transition_parser.add_argument("--agent", required=True)
|
|
1739
|
+
cancel_transition_parser.add_argument("--task-id")
|
|
1740
|
+
|
|
1741
|
+
memory_short = subcommands.add_parser("memory-short-complete", parents=[common])
|
|
1742
|
+
memory_short.add_argument("--file", required=True)
|
|
1743
|
+
memory_short.add_argument("--agent", required=True)
|
|
1744
|
+
memory_short.add_argument("--task-id")
|
|
1745
|
+
|
|
1746
|
+
memory_instruction_parser = subcommands.add_parser("memory-instruction", parents=[common])
|
|
1747
|
+
memory_instruction_parser.add_argument("--task-id")
|
|
1748
|
+
|
|
1749
|
+
memory_complete_parser = subcommands.add_parser("memory-complete", parents=[common])
|
|
1750
|
+
memory_complete_parser.add_argument("--action", required=True, choices=["no-op", "distill"])
|
|
1751
|
+
memory_complete_parser.add_argument("--agent", required=True)
|
|
1752
|
+
memory_complete_parser.add_argument("--task-id")
|
|
1753
|
+
|
|
826
1754
|
close = subcommands.add_parser("close-current", parents=[common])
|
|
827
1755
|
close.add_argument("--reason", required=True)
|
|
828
1756
|
close.add_argument("--agent", required=True)
|
|
@@ -897,11 +1825,84 @@ def main() -> int:
|
|
|
897
1825
|
session_file,
|
|
898
1826
|
)
|
|
899
1827
|
)
|
|
900
|
-
elif command == "transition":
|
|
1828
|
+
elif command == "request-transition":
|
|
1829
|
+
emit(
|
|
1830
|
+
attach_status_context(
|
|
1831
|
+
root,
|
|
1832
|
+
request_transition(
|
|
1833
|
+
root,
|
|
1834
|
+
args.stage,
|
|
1835
|
+
args.agent,
|
|
1836
|
+
args.task_id,
|
|
1837
|
+
session_file,
|
|
1838
|
+
args.reason,
|
|
1839
|
+
),
|
|
1840
|
+
args.agent,
|
|
1841
|
+
session_file,
|
|
1842
|
+
)
|
|
1843
|
+
)
|
|
1844
|
+
elif command in {"confirm-transition", "transition"}:
|
|
1845
|
+
emit(
|
|
1846
|
+
attach_status_context(
|
|
1847
|
+
root,
|
|
1848
|
+
confirm_transition(root, args.agent, args.stage, args.task_id, session_file),
|
|
1849
|
+
args.agent,
|
|
1850
|
+
session_file,
|
|
1851
|
+
)
|
|
1852
|
+
)
|
|
1853
|
+
elif command == "auto-transition":
|
|
1854
|
+
emit(
|
|
1855
|
+
attach_status_context(
|
|
1856
|
+
root,
|
|
1857
|
+
auto_transition(root, args.stage, args.agent, args.task_id, session_file),
|
|
1858
|
+
args.agent,
|
|
1859
|
+
session_file,
|
|
1860
|
+
)
|
|
1861
|
+
)
|
|
1862
|
+
elif command == "cancel-transition":
|
|
1863
|
+
emit(
|
|
1864
|
+
attach_status_context(
|
|
1865
|
+
root,
|
|
1866
|
+
cancel_transition(root, args.agent, args.task_id, session_file),
|
|
1867
|
+
args.agent,
|
|
1868
|
+
session_file,
|
|
1869
|
+
)
|
|
1870
|
+
)
|
|
1871
|
+
elif command == "memory-short-complete":
|
|
1872
|
+
emit(
|
|
1873
|
+
attach_status_context(
|
|
1874
|
+
root,
|
|
1875
|
+
memory_short_complete(
|
|
1876
|
+
root,
|
|
1877
|
+
args.file,
|
|
1878
|
+
args.agent,
|
|
1879
|
+
args.task_id,
|
|
1880
|
+
session_file,
|
|
1881
|
+
),
|
|
1882
|
+
args.agent,
|
|
1883
|
+
session_file,
|
|
1884
|
+
)
|
|
1885
|
+
)
|
|
1886
|
+
elif command == "memory-instruction":
|
|
1887
|
+
emit(
|
|
1888
|
+
attach_status_context(
|
|
1889
|
+
root,
|
|
1890
|
+
memory_instruction(root, args.task_id, session_file),
|
|
1891
|
+
None,
|
|
1892
|
+
session_file,
|
|
1893
|
+
)
|
|
1894
|
+
)
|
|
1895
|
+
elif command == "memory-complete":
|
|
901
1896
|
emit(
|
|
902
1897
|
attach_status_context(
|
|
903
1898
|
root,
|
|
904
|
-
|
|
1899
|
+
memory_complete(
|
|
1900
|
+
root,
|
|
1901
|
+
args.action,
|
|
1902
|
+
args.agent,
|
|
1903
|
+
args.task_id,
|
|
1904
|
+
session_file,
|
|
1905
|
+
),
|
|
905
1906
|
args.agent,
|
|
906
1907
|
session_file,
|
|
907
1908
|
)
|