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.
@@ -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": {"WAITING_CONFIRM", "CLOSED"},
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": {"MEMORY_SHORT", "IMPLEMENT", "CLOSED"},
45
- "MEMORY_SHORT": {"MEMORY_LONG", "CLOSED"},
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 count_short_memories(root: Path) -> int:
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 0
137
- count = 0
146
+ return []
147
+ entries: list[dict[str, object]] = []
138
148
  for entry in short_dir.glob("*.md"):
139
149
  try:
140
- if is_schema_v2_short_memory(entry.read_text(encoding="utf-8")):
141
- count += 1
150
+ content = entry.read_text(encoding="utf-8")
142
151
  except OSError:
143
152
  continue
144
- return count
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 is_schema_v2_short_memory(content: str) -> bool:
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 False
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 False
155
- if not stripped.startswith("memory_schema:"):
194
+ return frontmatter
195
+ if not stripped or stripped.startswith("#") or ":" not in stripped:
156
196
  continue
157
- _, value = stripped.split(":", 1)
158
- return parse_positive_int(value) == 2
159
- return False
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
- def build_memory_long_instruction(root: Path) -> dict:
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
- short_count = count_short_memories(root)
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 transition_task(
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, str(resolved_task_id), task)
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"] = str(resolved_task_id)
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
- if stage == "MEMORY_LONG":
684
- snapshot["memory_long"] = build_memory_long_instruction(root)
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", required=True)
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
- transition_task(root, args.stage, args.agent, args.task_id, session_file),
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
  )