easy-coding-harness 0.5.2-beta.0 → 0.6.0
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 +18 -0
- package/README.md +8 -10
- package/dist/cli.js +156 -4
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/templates/common/bundled-skills/ec-init/SKILL.md +1 -1
- package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +8 -5
- package/templates/common/skills/ec-analysis/SKILL.md +9 -8
- package/templates/common/skills/ec-brainstorming/SKILL.md +2 -1
- package/templates/common/skills/ec-implementing/SKILL.md +5 -4
- package/templates/common/skills/ec-memory/SKILL.md +42 -20
- package/templates/common/skills/ec-reviewing/SKILL.md +6 -3
- package/templates/common/skills/ec-task-close/SKILL.md +1 -1
- package/templates/common/skills/ec-verification/SKILL.md +16 -21
- package/templates/common/skills/ec-workflow/SKILL.md +70 -62
- package/templates/main-constraint/AGENTS.md.tpl +10 -6
- package/templates/main-constraint/CLAUDE.md.tpl +10 -6
- package/templates/runtime/memory/SHORT_MEMORY_TEMPLATE.md +2 -0
- package/templates/shared-hooks/easy_coding_state.py +560 -33
- package/templates/shared-hooks/inject-workflow-state.py +1 -176
- package/templates/shared-hooks/session-start.py +2 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
import argparse
|
|
3
|
+
import hashlib
|
|
3
4
|
import json
|
|
4
5
|
import os
|
|
5
6
|
from datetime import datetime, timezone
|
|
@@ -37,17 +38,21 @@ 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
|
-
"WAITING_CONFIRM": {"IMPLEMENT", "ANALYSIS", "CLOSED"},
|
|
41
|
+
"ANALYSIS": {"IMPLEMENT", "CLOSED"},
|
|
42
42
|
"IMPLEMENT": {"REVIEW", "ANALYSIS", "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
|
+
LEGACY_STAGE_MAP = {
|
|
51
|
+
"WAITING_CONFIRM": "ANALYSIS",
|
|
52
|
+
"MEMORY_SHORT": "MEMORY",
|
|
53
|
+
"MEMORY_LONG": "MEMORY",
|
|
54
|
+
}
|
|
55
|
+
|
|
51
56
|
DEFAULT_SHORT_TERM_MAX = 10
|
|
52
57
|
DEFAULT_SHORT_TERM_KEEP = 5
|
|
53
58
|
|
|
@@ -127,52 +132,248 @@ def read_memory_config(root: Path) -> dict[str, int]:
|
|
|
127
132
|
parsed = parse_positive_int(value)
|
|
128
133
|
if parsed is not None:
|
|
129
134
|
config[key] = parsed
|
|
135
|
+
if config["short_term_keep"] > config["short_term_max"]:
|
|
136
|
+
raise StateError(
|
|
137
|
+
"Invalid memory config in .easy-coding/config.yaml: "
|
|
138
|
+
"memory.short_term_keep must be less than or equal to memory.short_term_max."
|
|
139
|
+
)
|
|
130
140
|
return config
|
|
131
141
|
|
|
132
142
|
|
|
133
|
-
def
|
|
143
|
+
def short_memory_entries(root: Path) -> list[dict[str, object]]:
|
|
134
144
|
short_dir = root / ".easy-coding" / "memory" / "short"
|
|
135
145
|
if not short_dir.is_dir():
|
|
136
|
-
return
|
|
137
|
-
|
|
146
|
+
return []
|
|
147
|
+
entries: list[dict[str, object]] = []
|
|
138
148
|
for entry in short_dir.glob("*.md"):
|
|
139
149
|
try:
|
|
140
|
-
|
|
141
|
-
count += 1
|
|
150
|
+
content = entry.read_text(encoding="utf-8")
|
|
142
151
|
except OSError:
|
|
143
152
|
continue
|
|
144
|
-
|
|
153
|
+
if not is_schema_v2_short_memory(content):
|
|
154
|
+
continue
|
|
155
|
+
frontmatter = parse_short_memory_frontmatter(content)
|
|
156
|
+
resolved_entry = entry.resolve()
|
|
157
|
+
try:
|
|
158
|
+
resolved_entry.relative_to(short_dir.resolve())
|
|
159
|
+
display_entry = resolved_entry.relative_to(root.resolve()).as_posix()
|
|
160
|
+
except ValueError:
|
|
161
|
+
continue
|
|
162
|
+
prefix_text = entry.name.split("_", 1)[0]
|
|
163
|
+
try:
|
|
164
|
+
prefix = int(prefix_text)
|
|
165
|
+
except ValueError:
|
|
166
|
+
prefix = sys.maxsize
|
|
167
|
+
entries.append(
|
|
168
|
+
{
|
|
169
|
+
"path": resolved_entry,
|
|
170
|
+
"display_path": display_entry,
|
|
171
|
+
"date": frontmatter.get("date", ""),
|
|
172
|
+
"prefix": prefix,
|
|
173
|
+
"name": entry.name,
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
return sorted(
|
|
177
|
+
entries,
|
|
178
|
+
key=lambda item: (str(item["date"]), int(item["prefix"]), str(item["name"])),
|
|
179
|
+
)
|
|
145
180
|
|
|
146
181
|
|
|
147
|
-
def
|
|
182
|
+
def count_short_memories(root: Path) -> int:
|
|
183
|
+
return len(short_memory_entries(root))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def parse_short_memory_frontmatter(content: str) -> dict[str, str]:
|
|
148
187
|
lines = content.splitlines()
|
|
149
188
|
if not lines or lines[0].strip() != "---":
|
|
150
|
-
return
|
|
189
|
+
return {}
|
|
190
|
+
frontmatter: dict[str, str] = {}
|
|
151
191
|
for line in lines[1:]:
|
|
152
192
|
stripped = line.strip()
|
|
153
193
|
if stripped == "---":
|
|
154
|
-
return
|
|
155
|
-
if not stripped.startswith("
|
|
194
|
+
return frontmatter
|
|
195
|
+
if not stripped or stripped.startswith("#") or ":" not in stripped:
|
|
156
196
|
continue
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
return
|
|
197
|
+
key, value = stripped.split(":", 1)
|
|
198
|
+
frontmatter[key.strip()] = value.strip().strip("'\"")
|
|
199
|
+
return {}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def is_schema_v2_short_memory(content: str) -> bool:
|
|
203
|
+
frontmatter = parse_short_memory_frontmatter(content)
|
|
204
|
+
return parse_positive_int(frontmatter.get("memory_schema", "")) == 2
|
|
205
|
+
|
|
160
206
|
|
|
207
|
+
def resolve_short_memory_path(root: Path, memory_file: str) -> Path:
|
|
208
|
+
short_dir = (root / ".easy-coding" / "memory" / "short").resolve()
|
|
209
|
+
memory_path = Path(memory_file.strip())
|
|
210
|
+
candidate = memory_path if memory_path.is_absolute() else root / memory_path
|
|
211
|
+
resolved_memory_path = candidate.resolve()
|
|
212
|
+
try:
|
|
213
|
+
resolved_memory_path.relative_to(short_dir)
|
|
214
|
+
except ValueError as error:
|
|
215
|
+
raise StateError("Short-memory file must be under .easy-coding/memory/short/.") from error
|
|
216
|
+
if resolved_memory_path.suffix != ".md":
|
|
217
|
+
raise StateError(f"Short-memory file must be Markdown: {memory_file.strip()}")
|
|
218
|
+
return resolved_memory_path
|
|
161
219
|
|
|
162
|
-
|
|
220
|
+
|
|
221
|
+
def validate_short_memory_file(
|
|
222
|
+
root: Path,
|
|
223
|
+
task_id: str,
|
|
224
|
+
memory_file: str,
|
|
225
|
+
expected_sha256: str | None = None,
|
|
226
|
+
) -> tuple[Path, str]:
|
|
227
|
+
resolved_memory_path = resolve_short_memory_path(root, memory_file)
|
|
228
|
+
if not resolved_memory_path.is_file():
|
|
229
|
+
raise StateError(f"Short-memory file not found: {memory_file.strip()}")
|
|
230
|
+
try:
|
|
231
|
+
content = resolved_memory_path.read_text(encoding="utf-8")
|
|
232
|
+
except OSError as error:
|
|
233
|
+
raise StateError(f"Cannot read short-memory file: {memory_file.strip()}") from error
|
|
234
|
+
frontmatter = parse_short_memory_frontmatter(content)
|
|
235
|
+
if parse_positive_int(frontmatter.get("memory_schema", "")) != 2:
|
|
236
|
+
raise StateError("Short-memory file must declare memory_schema: 2.")
|
|
237
|
+
source_task = frontmatter.get("source_task", "")
|
|
238
|
+
if source_task != task_id:
|
|
239
|
+
raise StateError(
|
|
240
|
+
f"Short-memory source_task {source_task or 'missing'} does not match current task {task_id}."
|
|
241
|
+
)
|
|
242
|
+
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
243
|
+
if expected_sha256 and digest != expected_sha256:
|
|
244
|
+
raise StateError("Short-memory file changed after its checkpoint was recorded.")
|
|
245
|
+
return resolved_memory_path, digest
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def validate_recorded_short_memory(
|
|
249
|
+
root: Path,
|
|
250
|
+
task_id: str,
|
|
251
|
+
progress: dict,
|
|
252
|
+
allow_missing_after_distill: bool = False,
|
|
253
|
+
) -> None:
|
|
254
|
+
if progress.get("legacy_short_memory_assumed") is True:
|
|
255
|
+
return
|
|
256
|
+
memory_file = progress.get("short_memory_file")
|
|
257
|
+
expected_sha256 = progress.get("short_memory_sha256")
|
|
258
|
+
if not isinstance(memory_file, str) or not memory_file.strip():
|
|
259
|
+
raise StateError("Recorded short-memory checkpoint is missing its file path.")
|
|
260
|
+
if not isinstance(expected_sha256, str) or not expected_sha256:
|
|
261
|
+
raise StateError("Recorded short-memory checkpoint is missing its content fingerprint.")
|
|
262
|
+
resolved_memory_path = resolve_short_memory_path(root, memory_file)
|
|
263
|
+
if allow_missing_after_distill and not resolved_memory_path.exists():
|
|
264
|
+
return
|
|
265
|
+
validate_short_memory_file(root, task_id, memory_file, expected_sha256)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def build_memory_instruction(
|
|
269
|
+
root: Path,
|
|
270
|
+
checkpoint_file: str | None = None,
|
|
271
|
+
legacy_checkpoint: bool = False,
|
|
272
|
+
) -> dict:
|
|
163
273
|
config = read_memory_config(root)
|
|
164
|
-
|
|
274
|
+
entries = short_memory_entries(root)
|
|
275
|
+
short_count = len(entries)
|
|
165
276
|
action = "distill" if short_count > config["short_term_max"] else "no-op"
|
|
166
277
|
trim_count = max(0, short_count - config["short_term_keep"]) if action == "distill" else 0
|
|
278
|
+
candidate_files = [str(entry["display_path"]) for entry in entries[:trim_count]]
|
|
279
|
+
kept_files = [str(entry["display_path"]) for entry in entries[trim_count:]]
|
|
280
|
+
if legacy_checkpoint:
|
|
281
|
+
checkpoint_disposition = "legacy"
|
|
282
|
+
elif checkpoint_file in candidate_files:
|
|
283
|
+
checkpoint_disposition = "candidate"
|
|
284
|
+
elif checkpoint_file in kept_files:
|
|
285
|
+
checkpoint_disposition = "kept"
|
|
286
|
+
else:
|
|
287
|
+
raise StateError("Recorded short-memory checkpoint is absent from the frozen memory set.")
|
|
167
288
|
return {
|
|
168
289
|
"short_count": short_count,
|
|
169
290
|
"short_term_max": config["short_term_max"],
|
|
170
291
|
"short_term_keep": config["short_term_keep"],
|
|
171
292
|
"action": action,
|
|
172
293
|
"trim_count": trim_count,
|
|
294
|
+
"candidate_files": candidate_files,
|
|
295
|
+
"kept_files": kept_files,
|
|
296
|
+
"checkpoint_disposition": checkpoint_disposition,
|
|
173
297
|
}
|
|
174
298
|
|
|
175
299
|
|
|
300
|
+
def validate_distillation_file_sets(root: Path, instruction: dict) -> None:
|
|
301
|
+
candidate_files = instruction.get("candidate_files")
|
|
302
|
+
kept_files = instruction.get("kept_files")
|
|
303
|
+
if not isinstance(candidate_files, list) or not all(
|
|
304
|
+
isinstance(item, str) for item in candidate_files
|
|
305
|
+
):
|
|
306
|
+
raise StateError("Memory instruction is missing its frozen candidate file set.")
|
|
307
|
+
if not isinstance(kept_files, list) or not all(isinstance(item, str) for item in kept_files):
|
|
308
|
+
raise StateError("Memory instruction is missing its frozen kept file set.")
|
|
309
|
+
for memory_file in candidate_files:
|
|
310
|
+
if resolve_short_memory_path(root, memory_file).exists():
|
|
311
|
+
raise StateError(f"Distillation candidate was not consumed: {memory_file}")
|
|
312
|
+
for memory_file in kept_files:
|
|
313
|
+
if not resolve_short_memory_path(root, memory_file).is_file():
|
|
314
|
+
raise StateError(f"Short-memory file selected for retention is missing: {memory_file}")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def normalize_legacy_stage(stage: object) -> object:
|
|
318
|
+
return LEGACY_STAGE_MAP.get(str(stage), stage)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def normalize_legacy_task(task: dict) -> bool:
|
|
322
|
+
"""Normalize pre-0.6 stage names without touching task artifacts outside task.json."""
|
|
323
|
+
legacy_status = str(task.get("status") or "")
|
|
324
|
+
changed = False
|
|
325
|
+
|
|
326
|
+
if legacy_status in LEGACY_STAGE_MAP:
|
|
327
|
+
task["status"] = LEGACY_STAGE_MAP[legacy_status]
|
|
328
|
+
changed = True
|
|
329
|
+
|
|
330
|
+
history = task.get("stage_history")
|
|
331
|
+
if isinstance(history, list):
|
|
332
|
+
normalized_history: list[dict] = []
|
|
333
|
+
for raw_entry in history:
|
|
334
|
+
if not isinstance(raw_entry, dict):
|
|
335
|
+
continue
|
|
336
|
+
entry = dict(raw_entry)
|
|
337
|
+
mapped_stage = normalize_legacy_stage(entry.get("stage"))
|
|
338
|
+
if mapped_stage != entry.get("stage"):
|
|
339
|
+
entry["stage"] = mapped_stage
|
|
340
|
+
changed = True
|
|
341
|
+
if normalized_history and normalized_history[-1].get("stage") == entry.get("stage"):
|
|
342
|
+
changed = True
|
|
343
|
+
continue
|
|
344
|
+
normalized_history.append(entry)
|
|
345
|
+
if changed:
|
|
346
|
+
task["stage_history"] = normalized_history
|
|
347
|
+
|
|
348
|
+
if legacy_status == "WAITING_CONFIRM" and not task.get("pending_transition"):
|
|
349
|
+
task["pending_transition"] = {
|
|
350
|
+
"from": "ANALYSIS",
|
|
351
|
+
"to": "IMPLEMENT",
|
|
352
|
+
"requested_at": now_iso(),
|
|
353
|
+
"requested_by": str(task.get("last_agent") or "legacy-migration"),
|
|
354
|
+
"reason": "migrated-from-WAITING_CONFIRM",
|
|
355
|
+
}
|
|
356
|
+
changed = True
|
|
357
|
+
|
|
358
|
+
if legacy_status == "MEMORY_LONG":
|
|
359
|
+
progress = task.get("memory_progress")
|
|
360
|
+
if not isinstance(progress, dict):
|
|
361
|
+
progress = {}
|
|
362
|
+
if progress.get("short_memory_written") is not True:
|
|
363
|
+
progress["short_memory_written"] = True
|
|
364
|
+
progress["legacy_short_memory_assumed"] = True
|
|
365
|
+
progress["updated_at"] = now_iso()
|
|
366
|
+
task["memory_progress"] = progress
|
|
367
|
+
changed = True
|
|
368
|
+
elif progress.get("legacy_short_memory_assumed") is not True:
|
|
369
|
+
progress["legacy_short_memory_assumed"] = True
|
|
370
|
+
progress["updated_at"] = now_iso()
|
|
371
|
+
task["memory_progress"] = progress
|
|
372
|
+
changed = True
|
|
373
|
+
|
|
374
|
+
return changed
|
|
375
|
+
|
|
376
|
+
|
|
176
377
|
def write_json(path: Path, data: dict) -> None:
|
|
177
378
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
178
379
|
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
@@ -335,6 +536,8 @@ def snapshot_state(
|
|
|
335
536
|
"session_file": display_path(root, session_path),
|
|
336
537
|
"current_task": str(task_id) if task_id else None,
|
|
337
538
|
"task": task,
|
|
539
|
+
"pending_transition": task.get("pending_transition") if task else None,
|
|
540
|
+
"memory_progress": task.get("memory_progress") if task else None,
|
|
338
541
|
"task_missing": missing,
|
|
339
542
|
"status": status,
|
|
340
543
|
"is_terminal": status in TERMINAL_STATUSES,
|
|
@@ -395,6 +598,13 @@ def build_machine_breadcrumbs(
|
|
|
395
598
|
last_agent = state.get("last_agent")
|
|
396
599
|
if agent and last_agent and last_agent != agent:
|
|
397
600
|
lines.append(f"[easy-coding:handoff-from:{last_agent}]")
|
|
601
|
+
pending = state.get("pending_transition")
|
|
602
|
+
if isinstance(pending, dict):
|
|
603
|
+
source = str(pending.get("from") or stage)
|
|
604
|
+
target = str(pending.get("to") or "")
|
|
605
|
+
if target:
|
|
606
|
+
lines.append(f"[easy-coding:pending-transition:{source}->{target}]")
|
|
607
|
+
lines.append("[easy-coding:transition-confirmation-required]")
|
|
398
608
|
|
|
399
609
|
if is_project_init_required(root):
|
|
400
610
|
lines.append("[easy-coding:init-required]")
|
|
@@ -644,15 +854,11 @@ def append_stage_history(task: dict, stage: str, agent: str) -> None:
|
|
|
644
854
|
history.append({"stage": stage, "agent": agent, "entered_at": now_iso()})
|
|
645
855
|
|
|
646
856
|
|
|
647
|
-
def
|
|
857
|
+
def resolve_current_task(
|
|
648
858
|
root: Path,
|
|
649
|
-
stage: str,
|
|
650
|
-
agent: str,
|
|
651
859
|
task_id: str | None = None,
|
|
652
860
|
session_file: str | Path | None = None,
|
|
653
|
-
) -> dict:
|
|
654
|
-
if stage not in VALID_TRANSITIONS:
|
|
655
|
-
raise StateError(f"Unknown stage: {stage}")
|
|
861
|
+
) -> tuple[dict, str, dict]:
|
|
656
862
|
session = ensure_session(root, session_file)
|
|
657
863
|
resolved_task_id = task_id or session.get("current_task")
|
|
658
864
|
if not resolved_task_id:
|
|
@@ -660,6 +866,64 @@ def transition_task(
|
|
|
660
866
|
task = load_task(root, str(resolved_task_id))
|
|
661
867
|
if task is None:
|
|
662
868
|
raise StateError(f"Task not found: {resolved_task_id}")
|
|
869
|
+
return session, str(resolved_task_id), task
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def request_transition(
|
|
873
|
+
root: Path,
|
|
874
|
+
stage: str,
|
|
875
|
+
agent: str,
|
|
876
|
+
task_id: str | None = None,
|
|
877
|
+
session_file: str | Path | None = None,
|
|
878
|
+
reason: str | None = None,
|
|
879
|
+
) -> dict:
|
|
880
|
+
if stage not in VALID_TRANSITIONS:
|
|
881
|
+
raise StateError(f"Unknown stage: {stage}")
|
|
882
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
883
|
+
previous = str(task.get("status") or "idle")
|
|
884
|
+
if previous == stage:
|
|
885
|
+
raise StateError(f"Transition target must differ from current stage: {stage}")
|
|
886
|
+
|
|
887
|
+
violation = validate_transition(previous, stage)
|
|
888
|
+
if violation:
|
|
889
|
+
raise StateError(violation)
|
|
890
|
+
if previous == "MEMORY" and stage == "COMPLETE":
|
|
891
|
+
progress = task.get("memory_progress")
|
|
892
|
+
if not isinstance(progress, dict) or progress.get("completed") is not True:
|
|
893
|
+
raise StateError("MEMORY cannot advance to COMPLETE before memory processing completes.")
|
|
894
|
+
|
|
895
|
+
existing = task.get("pending_transition")
|
|
896
|
+
if isinstance(existing, dict):
|
|
897
|
+
if existing.get("from") != previous or existing.get("to") != stage:
|
|
898
|
+
raise StateError(
|
|
899
|
+
"A different transition is already pending. Cancel it before requesting another."
|
|
900
|
+
)
|
|
901
|
+
else:
|
|
902
|
+
task["pending_transition"] = {
|
|
903
|
+
"from": previous,
|
|
904
|
+
"to": stage,
|
|
905
|
+
"requested_at": now_iso(),
|
|
906
|
+
"requested_by": agent,
|
|
907
|
+
**({"reason": reason.strip()} if reason and reason.strip() else {}),
|
|
908
|
+
}
|
|
909
|
+
task["last_agent"] = agent
|
|
910
|
+
write_task(root, resolved_task_id, task)
|
|
911
|
+
|
|
912
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
913
|
+
snapshot["action"] = "request-transition"
|
|
914
|
+
return snapshot
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def apply_transition(
|
|
918
|
+
root: Path,
|
|
919
|
+
stage: str,
|
|
920
|
+
agent: str,
|
|
921
|
+
task_id: str | None = None,
|
|
922
|
+
session_file: str | Path | None = None,
|
|
923
|
+
) -> dict:
|
|
924
|
+
if stage not in VALID_TRANSITIONS:
|
|
925
|
+
raise StateError(f"Unknown stage: {stage}")
|
|
926
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
663
927
|
|
|
664
928
|
previous = str(task.get("status") or "idle")
|
|
665
929
|
violation = validate_transition(previous, stage)
|
|
@@ -668,20 +932,189 @@ def transition_task(
|
|
|
668
932
|
if previous != stage:
|
|
669
933
|
task["status"] = stage
|
|
670
934
|
append_stage_history(task, stage, agent)
|
|
935
|
+
task.pop("pending_transition", None)
|
|
936
|
+
if stage == "MEMORY" and previous != stage:
|
|
937
|
+
task["memory_progress"] = {}
|
|
671
938
|
task["last_agent"] = agent
|
|
672
|
-
write_task(root,
|
|
939
|
+
write_task(root, resolved_task_id, task)
|
|
673
940
|
|
|
674
941
|
if session.get("current_task") == resolved_task_id:
|
|
675
942
|
if stage in TERMINAL_STATUSES:
|
|
676
943
|
clear_session_pointer(session, agent)
|
|
677
944
|
else:
|
|
678
|
-
session["last_seen_task"] =
|
|
945
|
+
session["last_seen_task"] = resolved_task_id
|
|
679
946
|
session["last_seen_stage"] = stage
|
|
680
947
|
session["last_agent"] = agent
|
|
681
948
|
write_session(root, session, session_file)
|
|
949
|
+
return snapshot_state(root, session_file, session)
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def confirm_transition(
|
|
953
|
+
root: Path,
|
|
954
|
+
agent: str,
|
|
955
|
+
stage: str | None = None,
|
|
956
|
+
task_id: str | None = None,
|
|
957
|
+
session_file: str | Path | None = None,
|
|
958
|
+
) -> dict:
|
|
959
|
+
_, _, task = resolve_current_task(root, task_id, session_file)
|
|
960
|
+
pending = task.get("pending_transition")
|
|
961
|
+
if not isinstance(pending, dict):
|
|
962
|
+
raise StateError("No transition is pending user confirmation.")
|
|
963
|
+
previous = str(task.get("status") or "idle")
|
|
964
|
+
source = str(pending.get("from") or "")
|
|
965
|
+
target = str(pending.get("to") or "")
|
|
966
|
+
if source != previous:
|
|
967
|
+
raise StateError(
|
|
968
|
+
f"Pending transition source {source or 'missing'} does not match current stage {previous}."
|
|
969
|
+
)
|
|
970
|
+
if stage and stage != target:
|
|
971
|
+
raise StateError(f"Pending transition targets {target}, not {stage}.")
|
|
972
|
+
|
|
973
|
+
snapshot = apply_transition(root, target, agent, task_id, session_file)
|
|
974
|
+
snapshot["action"] = "confirm-transition"
|
|
975
|
+
snapshot["confirmed_transition"] = {"from": source, "to": target}
|
|
976
|
+
return snapshot
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def cancel_transition(
|
|
980
|
+
root: Path,
|
|
981
|
+
agent: str,
|
|
982
|
+
task_id: str | None = None,
|
|
983
|
+
session_file: str | Path | None = None,
|
|
984
|
+
) -> dict:
|
|
985
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
986
|
+
pending = task.pop("pending_transition", None)
|
|
987
|
+
task["last_agent"] = agent
|
|
988
|
+
write_task(root, resolved_task_id, task)
|
|
989
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
990
|
+
snapshot["action"] = "cancel-transition"
|
|
991
|
+
snapshot["cancelled_transition"] = pending
|
|
992
|
+
return snapshot
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def memory_short_complete(
|
|
996
|
+
root: Path,
|
|
997
|
+
memory_file: str,
|
|
998
|
+
agent: str,
|
|
999
|
+
task_id: str | None = None,
|
|
1000
|
+
session_file: str | Path | None = None,
|
|
1001
|
+
) -> dict:
|
|
1002
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
1003
|
+
if task.get("status") != "MEMORY":
|
|
1004
|
+
raise StateError("Short-memory progress can only be recorded during MEMORY.")
|
|
1005
|
+
if not memory_file.strip():
|
|
1006
|
+
raise StateError("Short-memory file is required.")
|
|
1007
|
+
resolved_memory_path, digest = validate_short_memory_file(
|
|
1008
|
+
root, resolved_task_id, memory_file.strip()
|
|
1009
|
+
)
|
|
1010
|
+
progress = task.get("memory_progress")
|
|
1011
|
+
if not isinstance(progress, dict):
|
|
1012
|
+
progress = {}
|
|
1013
|
+
checkpoint_file = display_path(root, resolved_memory_path)
|
|
1014
|
+
if (
|
|
1015
|
+
progress.get("short_memory_written") is True
|
|
1016
|
+
and progress.get("legacy_short_memory_assumed") is not True
|
|
1017
|
+
):
|
|
1018
|
+
if (
|
|
1019
|
+
progress.get("short_memory_file") == checkpoint_file
|
|
1020
|
+
and progress.get("short_memory_sha256") == digest
|
|
1021
|
+
):
|
|
1022
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
1023
|
+
snapshot["action"] = "memory-short-complete"
|
|
1024
|
+
snapshot["checkpoint_unchanged"] = True
|
|
1025
|
+
return snapshot
|
|
1026
|
+
raise StateError("A different short-memory checkpoint is already recorded for this task.")
|
|
1027
|
+
progress["short_memory_written"] = True
|
|
1028
|
+
progress["short_memory_file"] = checkpoint_file
|
|
1029
|
+
progress["short_memory_sha256"] = digest
|
|
1030
|
+
progress.pop("legacy_short_memory_assumed", None)
|
|
1031
|
+
progress["updated_at"] = now_iso()
|
|
1032
|
+
task["memory_progress"] = progress
|
|
1033
|
+
task["last_agent"] = agent
|
|
1034
|
+
write_task(root, resolved_task_id, task)
|
|
682
1035
|
snapshot = snapshot_state(root, session_file, session)
|
|
683
|
-
|
|
684
|
-
|
|
1036
|
+
snapshot["action"] = "memory-short-complete"
|
|
1037
|
+
return snapshot
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def memory_instruction(
|
|
1041
|
+
root: Path,
|
|
1042
|
+
task_id: str | None = None,
|
|
1043
|
+
session_file: str | Path | None = None,
|
|
1044
|
+
) -> dict:
|
|
1045
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
1046
|
+
if task.get("status") != "MEMORY":
|
|
1047
|
+
raise StateError("Memory instruction is only available during MEMORY.")
|
|
1048
|
+
progress = task.get("memory_progress")
|
|
1049
|
+
if not isinstance(progress, dict) or progress.get("short_memory_written") is not True:
|
|
1050
|
+
raise StateError("Write and record the short memory before requesting memory instruction.")
|
|
1051
|
+
instruction = progress.get("instruction")
|
|
1052
|
+
allow_missing_checkpoint = bool(
|
|
1053
|
+
isinstance(instruction, dict)
|
|
1054
|
+
and instruction.get("action") == "distill"
|
|
1055
|
+
and instruction.get("checkpoint_disposition") == "candidate"
|
|
1056
|
+
)
|
|
1057
|
+
validate_recorded_short_memory(
|
|
1058
|
+
root,
|
|
1059
|
+
resolved_task_id,
|
|
1060
|
+
progress,
|
|
1061
|
+
allow_missing_after_distill=allow_missing_checkpoint,
|
|
1062
|
+
)
|
|
1063
|
+
if not isinstance(instruction, dict):
|
|
1064
|
+
legacy_checkpoint = progress.get("legacy_short_memory_assumed") is True
|
|
1065
|
+
checkpoint_file = None if legacy_checkpoint else str(progress.get("short_memory_file"))
|
|
1066
|
+
instruction = build_memory_instruction(root, checkpoint_file, legacy_checkpoint)
|
|
1067
|
+
progress["instruction"] = instruction
|
|
1068
|
+
progress["updated_at"] = now_iso()
|
|
1069
|
+
task["memory_progress"] = progress
|
|
1070
|
+
write_task(root, resolved_task_id, task)
|
|
1071
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
1072
|
+
snapshot["memory"] = instruction
|
|
1073
|
+
snapshot["action"] = "memory-instruction"
|
|
1074
|
+
return snapshot
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def memory_complete(
|
|
1078
|
+
root: Path,
|
|
1079
|
+
action: str,
|
|
1080
|
+
agent: str,
|
|
1081
|
+
task_id: str | None = None,
|
|
1082
|
+
session_file: str | Path | None = None,
|
|
1083
|
+
) -> dict:
|
|
1084
|
+
if action not in {"no-op", "distill"}:
|
|
1085
|
+
raise StateError(f"Unknown memory action: {action}")
|
|
1086
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
1087
|
+
if task.get("status") != "MEMORY":
|
|
1088
|
+
raise StateError("Memory completion can only be recorded during MEMORY.")
|
|
1089
|
+
progress = task.get("memory_progress")
|
|
1090
|
+
if not isinstance(progress, dict) or progress.get("short_memory_written") is not True:
|
|
1091
|
+
raise StateError("Short memory must be recorded before MEMORY can complete.")
|
|
1092
|
+
instruction = progress.get("instruction")
|
|
1093
|
+
if not isinstance(instruction, dict):
|
|
1094
|
+
raise StateError("Request the authoritative memory instruction before completing MEMORY.")
|
|
1095
|
+
if instruction["action"] != action:
|
|
1096
|
+
raise StateError(
|
|
1097
|
+
f"Memory action {action} does not match authoritative instruction {instruction['action']}."
|
|
1098
|
+
)
|
|
1099
|
+
validate_recorded_short_memory(
|
|
1100
|
+
root,
|
|
1101
|
+
resolved_task_id,
|
|
1102
|
+
progress,
|
|
1103
|
+
allow_missing_after_distill=(
|
|
1104
|
+
action == "distill" and instruction.get("checkpoint_disposition") == "candidate"
|
|
1105
|
+
),
|
|
1106
|
+
)
|
|
1107
|
+
if action == "distill":
|
|
1108
|
+
validate_distillation_file_sets(root, instruction)
|
|
1109
|
+
progress["long_memory_action"] = action
|
|
1110
|
+
progress["completed"] = True
|
|
1111
|
+
progress["updated_at"] = now_iso()
|
|
1112
|
+
task["memory_progress"] = progress
|
|
1113
|
+
task["last_agent"] = agent
|
|
1114
|
+
write_task(root, resolved_task_id, task)
|
|
1115
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
1116
|
+
snapshot["memory"] = instruction
|
|
1117
|
+
snapshot["action"] = "memory-complete"
|
|
685
1118
|
return snapshot
|
|
686
1119
|
|
|
687
1120
|
|
|
@@ -701,6 +1134,7 @@ def close_current_task(
|
|
|
701
1134
|
if task.get("status") != "CLOSED":
|
|
702
1135
|
task["status"] = "CLOSED"
|
|
703
1136
|
append_stage_history(task, "CLOSED", agent)
|
|
1137
|
+
task.pop("pending_transition", None)
|
|
704
1138
|
task["closed_reason"] = reason
|
|
705
1139
|
task["last_agent"] = agent
|
|
706
1140
|
write_task(root, str(task_id), task)
|
|
@@ -818,11 +1252,40 @@ def main() -> int:
|
|
|
818
1252
|
claim.add_argument("--task-id", required=True)
|
|
819
1253
|
claim.add_argument("--agent", required=True)
|
|
820
1254
|
|
|
1255
|
+
request = subcommands.add_parser("request-transition", parents=[common])
|
|
1256
|
+
request.add_argument("--stage", required=True)
|
|
1257
|
+
request.add_argument("--agent", required=True)
|
|
1258
|
+
request.add_argument("--task-id")
|
|
1259
|
+
request.add_argument("--reason")
|
|
1260
|
+
|
|
1261
|
+
confirm_transition_parser = subcommands.add_parser("confirm-transition", parents=[common])
|
|
1262
|
+
confirm_transition_parser.add_argument("--stage")
|
|
1263
|
+
confirm_transition_parser.add_argument("--agent", required=True)
|
|
1264
|
+
confirm_transition_parser.add_argument("--task-id")
|
|
1265
|
+
|
|
1266
|
+
# Compatibility alias: pre-0.6 callers still consume the pending gate instead of bypassing it.
|
|
821
1267
|
transition = subcommands.add_parser("transition", parents=[common])
|
|
822
|
-
transition.add_argument("--stage"
|
|
1268
|
+
transition.add_argument("--stage")
|
|
823
1269
|
transition.add_argument("--agent", required=True)
|
|
824
1270
|
transition.add_argument("--task-id")
|
|
825
1271
|
|
|
1272
|
+
cancel_transition_parser = subcommands.add_parser("cancel-transition", parents=[common])
|
|
1273
|
+
cancel_transition_parser.add_argument("--agent", required=True)
|
|
1274
|
+
cancel_transition_parser.add_argument("--task-id")
|
|
1275
|
+
|
|
1276
|
+
memory_short = subcommands.add_parser("memory-short-complete", parents=[common])
|
|
1277
|
+
memory_short.add_argument("--file", required=True)
|
|
1278
|
+
memory_short.add_argument("--agent", required=True)
|
|
1279
|
+
memory_short.add_argument("--task-id")
|
|
1280
|
+
|
|
1281
|
+
memory_instruction_parser = subcommands.add_parser("memory-instruction", parents=[common])
|
|
1282
|
+
memory_instruction_parser.add_argument("--task-id")
|
|
1283
|
+
|
|
1284
|
+
memory_complete_parser = subcommands.add_parser("memory-complete", parents=[common])
|
|
1285
|
+
memory_complete_parser.add_argument("--action", required=True, choices=["no-op", "distill"])
|
|
1286
|
+
memory_complete_parser.add_argument("--agent", required=True)
|
|
1287
|
+
memory_complete_parser.add_argument("--task-id")
|
|
1288
|
+
|
|
826
1289
|
close = subcommands.add_parser("close-current", parents=[common])
|
|
827
1290
|
close.add_argument("--reason", required=True)
|
|
828
1291
|
close.add_argument("--agent", required=True)
|
|
@@ -897,11 +1360,75 @@ def main() -> int:
|
|
|
897
1360
|
session_file,
|
|
898
1361
|
)
|
|
899
1362
|
)
|
|
900
|
-
elif command == "transition":
|
|
1363
|
+
elif command == "request-transition":
|
|
901
1364
|
emit(
|
|
902
1365
|
attach_status_context(
|
|
903
1366
|
root,
|
|
904
|
-
|
|
1367
|
+
request_transition(
|
|
1368
|
+
root,
|
|
1369
|
+
args.stage,
|
|
1370
|
+
args.agent,
|
|
1371
|
+
args.task_id,
|
|
1372
|
+
session_file,
|
|
1373
|
+
args.reason,
|
|
1374
|
+
),
|
|
1375
|
+
args.agent,
|
|
1376
|
+
session_file,
|
|
1377
|
+
)
|
|
1378
|
+
)
|
|
1379
|
+
elif command in {"confirm-transition", "transition"}:
|
|
1380
|
+
emit(
|
|
1381
|
+
attach_status_context(
|
|
1382
|
+
root,
|
|
1383
|
+
confirm_transition(root, args.agent, args.stage, args.task_id, session_file),
|
|
1384
|
+
args.agent,
|
|
1385
|
+
session_file,
|
|
1386
|
+
)
|
|
1387
|
+
)
|
|
1388
|
+
elif command == "cancel-transition":
|
|
1389
|
+
emit(
|
|
1390
|
+
attach_status_context(
|
|
1391
|
+
root,
|
|
1392
|
+
cancel_transition(root, args.agent, args.task_id, session_file),
|
|
1393
|
+
args.agent,
|
|
1394
|
+
session_file,
|
|
1395
|
+
)
|
|
1396
|
+
)
|
|
1397
|
+
elif command == "memory-short-complete":
|
|
1398
|
+
emit(
|
|
1399
|
+
attach_status_context(
|
|
1400
|
+
root,
|
|
1401
|
+
memory_short_complete(
|
|
1402
|
+
root,
|
|
1403
|
+
args.file,
|
|
1404
|
+
args.agent,
|
|
1405
|
+
args.task_id,
|
|
1406
|
+
session_file,
|
|
1407
|
+
),
|
|
1408
|
+
args.agent,
|
|
1409
|
+
session_file,
|
|
1410
|
+
)
|
|
1411
|
+
)
|
|
1412
|
+
elif command == "memory-instruction":
|
|
1413
|
+
emit(
|
|
1414
|
+
attach_status_context(
|
|
1415
|
+
root,
|
|
1416
|
+
memory_instruction(root, args.task_id, session_file),
|
|
1417
|
+
None,
|
|
1418
|
+
session_file,
|
|
1419
|
+
)
|
|
1420
|
+
)
|
|
1421
|
+
elif command == "memory-complete":
|
|
1422
|
+
emit(
|
|
1423
|
+
attach_status_context(
|
|
1424
|
+
root,
|
|
1425
|
+
memory_complete(
|
|
1426
|
+
root,
|
|
1427
|
+
args.action,
|
|
1428
|
+
args.agent,
|
|
1429
|
+
args.task_id,
|
|
1430
|
+
session_file,
|
|
1431
|
+
),
|
|
905
1432
|
args.agent,
|
|
906
1433
|
session_file,
|
|
907
1434
|
)
|