easy-coding-harness 1.1.0-beta.1 → 1.1.0-beta.2

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.
@@ -17,7 +17,8 @@ from pathlib import Path
17
17
  import sys
18
18
 
19
19
  from easy_coding_inputs import (
20
- evidence_operation, memo, digest, input_spec, capture, changed_inputs, command_covers,
20
+ evidence_operation, memo, cached_memo, invalidate_memo, digest, input_spec, capture, changed_inputs,
21
+ command_covers, command_identity,
21
22
  )
22
23
 
23
24
  from easy_dev_spec import (
@@ -476,17 +477,15 @@ def parse_unit_test_mode(value: object, source: str) -> str:
476
477
  return str(value)
477
478
 
478
479
 
479
- def read_project_behavior(root: Path) -> tuple[str, str, str, int]:
480
- path = root / ".easy-coding" / "config.yaml"
480
+ def read_behavior_file(path: Path) -> tuple[dict, int]:
481
+ return memo(("behavior", str(path)), lambda: _read_behavior_file(path))
482
+
483
+
484
+ def _read_behavior_file(path: Path) -> tuple[dict, int]:
481
485
  try:
482
486
  lines = path.read_text(encoding="utf-8").splitlines()
483
- except OSError:
484
- return (
485
- DEFAULT_APPROVAL_MODE,
486
- DEFAULT_WORKFLOW_MODE,
487
- DEFAULT_UNIT_TEST_MODE,
488
- DEFAULT_UT_COVERAGE_THRESHOLD,
489
- )
487
+ except FileNotFoundError:
488
+ return {}, 0
490
489
 
491
490
  in_behavior = False
492
491
  behavior_indent = 0
@@ -498,6 +497,8 @@ def read_project_behavior(root: Path) -> tuple[str, str, str, int]:
498
497
  if not stripped:
499
498
  continue
500
499
  indent = len(without_comment) - len(without_comment.lstrip(" "))
500
+ if stripped.startswith("behavior:") and stripped != "behavior:":
501
+ raise StateError("Behavior configuration must use an indented YAML mapping; write it with easy-coding config.")
501
502
  if stripped == "behavior:":
502
503
  in_behavior = True
503
504
  behavior_indent = indent
@@ -515,6 +516,11 @@ def read_project_behavior(root: Path) -> tuple[str, str, str, int]:
515
516
  key, value = stripped.split(":", 1)
516
517
  behavior[key] = value.strip().strip("'\"")
517
518
 
519
+ return behavior, schema_version
520
+
521
+
522
+ def read_project_behavior(root: Path) -> tuple[str, str, str, int]:
523
+ behavior, schema_version = read_behavior_file(root / ".easy-coding" / "config.yaml")
518
524
  legacy = behavior.get("confirm_mode")
519
525
  approval_mode = behavior.get("approval_mode")
520
526
  workflow_mode = behavior.get("workflow_mode")
@@ -553,6 +559,28 @@ def read_project_behavior(root: Path) -> tuple[str, str, str, int]:
553
559
  return approval_mode, workflow_mode, unit_test_mode, threshold
554
560
 
555
561
 
562
+ def behavior_layers(root: Path, session: dict) -> dict:
563
+ project, _ = read_behavior_file(root / ".easy-coding" / "config.yaml")
564
+ local, _ = read_behavior_file(Path.home() / ".easy-coding" / "config.yaml")
565
+ defaults = {"approval_mode": DEFAULT_APPROVAL_MODE, "cooperate_mode": "default",
566
+ "unit_test_mode": DEFAULT_UNIT_TEST_MODE,
567
+ "ut_coverage_threshold": DEFAULT_UT_COVERAGE_THRESHOLD}
568
+ layers = {"project": project, "local": local, "session": session}
569
+ result = {}
570
+ for key, default in defaults.items():
571
+ source = next((name for name in ("session", "local", "project")
572
+ if layers[name].get(key) is not None), "default")
573
+ value = layers[source][key] if source != "default" else default
574
+ if key == "ut_coverage_threshold":
575
+ value = parse_ut_threshold(value, f"{source} {key}")
576
+ elif key == "unit_test_mode":
577
+ value = parse_unit_test_mode(value, f"{source} {key}")
578
+ elif value not in (APPROVAL_MODES if key == "approval_mode" else {"default", "dispatch"}):
579
+ raise StateError(f"Invalid {source} {key}: {value}")
580
+ result[key] = {"value": value, "source": source}
581
+ return result
582
+
583
+
556
584
  def safe_tdd_report_pattern(value: object) -> bool:
557
585
  if not is_non_empty_string(value):
558
586
  return False
@@ -753,19 +781,23 @@ def resolve_behavior(
753
781
  session_threshold = parse_ut_threshold(
754
782
  session_threshold, "session ut_coverage_threshold"
755
783
  )
784
+ local, _ = read_behavior_file(Path.home() / ".easy-coding" / "config.yaml")
785
+ effective = behavior_layers(root, session)
756
786
  return (
757
787
  project_approval,
758
788
  str(session_approval) if session_approval else None,
759
- str(session_approval or project_approval),
789
+ str(session_approval or (effective["approval_mode"]["value"] if "approval_mode" in local else project_approval)),
760
790
  project_workflow,
761
791
  str(session_workflow) if session_workflow else None,
762
792
  str(session_workflow or project_workflow),
763
793
  project_unit_test,
764
794
  session_unit_test,
765
- session_unit_test if session_unit_test is not None else project_unit_test,
795
+ session_unit_test if session_unit_test is not None else (
796
+ effective["unit_test_mode"]["value"] if "unit_test_mode" in local else project_unit_test),
766
797
  project_threshold,
767
798
  session_threshold,
768
- session_threshold if session_threshold is not None else project_threshold,
799
+ session_threshold if session_threshold is not None else (
800
+ effective["ut_coverage_threshold"]["value"] if "ut_coverage_threshold" in local else project_threshold),
769
801
  )
770
802
 
771
803
 
@@ -1815,6 +1847,11 @@ def append_execution_record(root: Path, task_id: str, record: dict) -> None:
1815
1847
  handle.write(json.dumps(record, ensure_ascii=False) + "\n")
1816
1848
  handle.flush()
1817
1849
  os.fsync(handle.fileno())
1850
+ records = cached_memo(("execution", str(path)))
1851
+ if records is not None:
1852
+ records.append(record)
1853
+ if record.get("type") in {"plan", "spec-design-sync"}:
1854
+ invalidate_memo(("plan", str(path)))
1818
1855
 
1819
1856
 
1820
1857
  def is_non_empty_string(value: object) -> bool:
@@ -1844,13 +1881,18 @@ def is_valid_review_finding(value: object) -> bool:
1844
1881
 
1845
1882
 
1846
1883
  def gate_identity(record: dict) -> tuple:
1884
+ # 已有手写证据没有输入凭据,保留原覆盖语义;新证据与 prepare-check 共用身份。
1885
+ if isinstance(record.get("inputs"), dict):
1886
+ return check_identity(record)
1847
1887
  return (str(record.get("source_task_id") or ""), str(record.get("unit_id") or ""),
1848
1888
  str(record.get("dimension") or record.get("check") or ""),
1849
1889
  str(record.get("review_scope") or record.get("coverage_scope") or ""))
1850
1890
 
1851
1891
 
1852
1892
  def failure_label(record: dict) -> str:
1853
- _, unit, name, scope = gate_identity(record)
1893
+ unit = str(record.get("unit_id") or "")
1894
+ name = str(record.get("dimension") or record.get("check") or "")
1895
+ scope = str(record.get("review_scope") or record.get("coverage_scope") or "")
1854
1896
  label = f"{record['type']}:{name}"
1855
1897
  if scope:
1856
1898
  label += f":{scope}"
@@ -2223,30 +2265,42 @@ def inspect_task_spec(
2223
2265
  def restore_spec_context(
2224
2266
  root: Path, task_id: str, task: dict, agent: str,
2225
2267
  session_file: str | Path | None = None,
2268
+ *, force: bool = False,
2226
2269
  ) -> dict | None:
2227
2270
  if not isinstance(task.get("spec_source"), dict):
2228
2271
  return None
2229
- task.pop("spec_context", None)
2272
+ session_key = resolve_session_path(root, session_file).relative_to(root.resolve()).as_posix()
2230
2273
  try:
2231
2274
  inspection, _ = inspect_task_spec(root, task, allow_pending_hard_dependencies=True)
2275
+ if isinstance(task.get("spec_change"), dict):
2276
+ raise StateError("Confirmed Spec change is pending; update the bound source and run sync-spec-design.")
2277
+ expected = {
2278
+ "session_file": session_key, "agent": normalize_session_agent(agent),
2279
+ "spec_id": inspection["spec_id"], "revision": inspection["revision"],
2280
+ "design_sha256": inspection["design_sha256"],
2281
+ "selected_spec_tasks": task["selected_spec_tasks"],
2282
+ }
2283
+ receipts = task.setdefault("spec_contexts", {})
2284
+ receipt = receipts.get(session_key) or task.get("spec_context")
2285
+ if not force and isinstance(receipt, dict) and all(receipt.get(k) == v for k, v in expected.items()):
2286
+ receipts[session_key] = receipt
2287
+ task["spec_context"] = receipt
2288
+ write_task(root, task_id, task)
2289
+ return {"status": "ready", "reused": True, "receipt": receipt}
2232
2290
  context = select_consumption_scopes(
2233
2291
  stored_spec_path(root, task), root, task["selected_spec_tasks"]
2234
2292
  )
2235
2293
  if context.get("design_sha256") != inspection.get("design_sha256"):
2236
2294
  raise StateError("Canonical Spec changed while restoring context; retry resume-spec-context.")
2237
- if isinstance(task.get("spec_change"), dict):
2238
- raise StateError("Confirmed Spec change is pending; update the bound source and run sync-spec-design.")
2239
2295
  task["spec_context"] = {
2240
- "session_file": resolve_session_path(root, session_file).relative_to(root.resolve()).as_posix(),
2241
- "agent": normalize_session_agent(agent),
2242
- "spec_id": inspection["spec_id"],
2243
- "revision": inspection["revision"],
2244
- "design_sha256": inspection["design_sha256"],
2245
- "selected_spec_tasks": task["selected_spec_tasks"],
2296
+ **expected,
2246
2297
  "loaded_at": now_iso(),
2247
2298
  }
2299
+ receipts[session_key] = task["spec_context"]
2248
2300
  result = {"status": "ready", "consumption": context}
2249
2301
  except (StateError, EasyDevSpecError, OSError, UnicodeError) as exc:
2302
+ task.pop("spec_context", None)
2303
+ task.get("spec_contexts", {}).pop(session_key, None)
2250
2304
  # 接手仍可成功,修复来源与同步状态后必须重新加载,不能沿用旧会话的消费记录。
2251
2305
  result = {"status": "blocked", "reason": str(exc), "source": task["spec_source"]}
2252
2306
  write_task(root, task_id, task)
@@ -2260,7 +2314,7 @@ def resume_spec_context(
2260
2314
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
2261
2315
  if not isinstance(task.get("spec_source"), dict):
2262
2316
  raise StateError("Current task is not backed by a Canonical Spec.")
2263
- context = restore_spec_context(root, resolved_task_id, task, agent, session_file)
2317
+ context = restore_spec_context(root, resolved_task_id, task, agent, session_file, force=True)
2264
2318
  snapshot = snapshot_state(root, session_file, session)
2265
2319
  snapshot.update({"action": "resume-spec-context", "spec_context": context})
2266
2320
  return snapshot
@@ -2277,9 +2331,10 @@ def require_spec_context(
2277
2331
  inspection, _ = inspect_task_spec(
2278
2332
  root, task, allow_pending_hard_dependencies=allow_pending_hard_dependencies,
2279
2333
  )
2280
- receipt = task.get("spec_context")
2334
+ session_key = resolve_session_path(root, session_file).relative_to(root.resolve()).as_posix()
2335
+ receipt = task.get("spec_contexts", {}).get(session_key) or task.get("spec_context")
2281
2336
  expected = {
2282
- "session_file": resolve_session_path(root, session_file).relative_to(root.resolve()).as_posix(),
2337
+ "session_file": session_key,
2283
2338
  "agent": normalize_session_agent(agent),
2284
2339
  "spec_id": inspection["spec_id"],
2285
2340
  "revision": inspection["revision"],
@@ -2633,6 +2688,10 @@ def has_valid_execution_plan(root: Path, task_id: str) -> bool:
2633
2688
 
2634
2689
  def execution_records(root: Path, task_id: str) -> list[dict]:
2635
2690
  path = execution_log_path(root, task_id)
2691
+ return memo(("execution", str(path)), lambda: _execution_records(path))
2692
+
2693
+
2694
+ def _execution_records(path: Path) -> list[dict]:
2636
2695
  if not path.exists():
2637
2696
  return []
2638
2697
  records: list[dict] = []
@@ -2649,6 +2708,11 @@ def execution_records(root: Path, task_id: str) -> list[dict]:
2649
2708
 
2650
2709
 
2651
2710
  def latest_execution_plan(root: Path, task_id: str) -> dict | None:
2711
+ return memo(("plan", str(execution_log_path(root, task_id))),
2712
+ lambda: _latest_execution_plan(root, task_id))
2713
+
2714
+
2715
+ def _latest_execution_plan(root: Path, task_id: str) -> dict | None:
2652
2716
  latest: dict | None = None
2653
2717
  for record in execution_records(root, task_id):
2654
2718
  if record.get("type") == "plan":
@@ -3147,9 +3211,9 @@ def tdd_infrastructure_fingerprint(repositories: set[Path]) -> str:
3147
3211
 
3148
3212
 
3149
3213
  def check_identity(record: dict) -> tuple:
3150
- return tuple(str(record.get(key) or "") for key in (
3151
- "type", "unit_id", "source_task_id", "dimension", "review_scope", "check", "check_type", "command", "coverage_scope"
3152
- ))
3214
+ return (*tuple(str(record.get(key) or "") for key in (
3215
+ "type", "repo_id", "unit_id", "source_task_id", "dimension", "review_scope", "check_type", "coverage_scope"
3216
+ )), command_identity(str(record.get("command") or "")))
3153
3217
 
3154
3218
 
3155
3219
  def prepare_check(root: Path, task_id: str, task: dict, descriptor: dict, agent: str) -> dict:
@@ -3202,9 +3266,12 @@ def record_check(root: Path, task_id: str, task: dict, prepared_id: str, result:
3202
3266
  if type(result.get("exit_code")) is not int or result["passed"] != (result["exit_code"] == 0):
3203
3267
  raise StateError("Verification passed must agree with its real exit_code.")
3204
3268
  context = None
3205
- if task.get("status") == "QUALITY":
3269
+ repair = task.get("quality_repair")
3270
+ repairing = (task.get("status") == "QUALITY" and descriptor["type"] == "verify"
3271
+ and isinstance(repair, dict) and repair.get("approved_at") and not repair.get("completed_at"))
3272
+ if task.get("status") == "QUALITY" and not repairing:
3206
3273
  context = ensure_quality_attempt_context(root, task_id, task, agent, persist=True)
3207
- elif task.get("status") != "IMPLEMENT":
3274
+ elif task.get("status") != "IMPLEMENT" and not repairing:
3208
3275
  raise StateError("Record checks only during implementation or quality.")
3209
3276
  record = {
3210
3277
  **result, **descriptor, **evidence_fingerprints(root, task_id),
@@ -3250,6 +3317,8 @@ def begin_correction(root: Path, task_id: str, task: dict, files: list[str], sum
3250
3317
  allowed = {f for unit in units for f in unit.get("files", [])}
3251
3318
  if not files or not set(files) <= allowed or not summary.strip():
3252
3319
  raise StateError("A correction must name existing task files and the confirmed change.")
3320
+ if task.get("status") == "QUALITY":
3321
+ return prepare_quality_repair(root, task_id, task, files, summary, agent)
3253
3322
  cancel_active_quality_attempt(root, task_id, task, agent, summary, "manual-return")
3254
3323
  task = load_task(root, task_id) or task
3255
3324
  cleanup_verification_checkpoint(root, task_id, task)
@@ -3279,6 +3348,136 @@ def begin_correction(root: Path, task_id: str, task: dict, files: list[str], sum
3279
3348
  "reasons": reasons, "correction": task["correction"]}
3280
3349
 
3281
3350
 
3351
+ def prepare_quality_repair(root: Path, task_id: str, task: dict, files: list[str],
3352
+ summary: str, agent: str) -> dict:
3353
+ plan = latest_execution_plan(root, task_id) or {}
3354
+ records = validated_quality_records(root, task_id)
3355
+ last = records[-1][1] if records else {}
3356
+ if last.get("outcome") == "replan" and last.get("attempt") != task.get("quality_consumed_attempt"):
3357
+ raise StateError("The confirmed contract changed; finish the ANALYSIS replan first.")
3358
+ inputs = capture(input_spec(root, task, plan, {"type": "review"}))
3359
+ repair_id = digest([last.get("attempt", 0), inputs["signature"], sorted(files), summary.strip()])
3360
+ existing = task.get("quality_repair")
3361
+ if isinstance(existing, dict) and not existing.get("completed_at"):
3362
+ if existing["repair_id"] == repair_id:
3363
+ return {"task_id": task_id, "status": "QUALITY", "quality_repair": existing}
3364
+ if existing.get("approved_at"):
3365
+ raise StateError("Complete the approved repair before preparing another one.")
3366
+ repair = {
3367
+ "repair_id": repair_id, "summary": summary.strip(), "files": sorted(set(files)),
3368
+ "unit_ids": [u["id"] for u in plan.get("units", []) if set(u["files"]) & set(files)],
3369
+ "quality_attempt": last.get("attempt", 0),
3370
+ "implementation_fingerprint": inputs["signature"], "inputs": inputs,
3371
+ }
3372
+ task["quality_repair"] = repair
3373
+ write_task(root, task_id, task)
3374
+ return {"task_id": task_id, "status": "QUALITY", "quality_repair": repair}
3375
+
3376
+
3377
+ def start_quality_repair(root: Path, repair_id: str, executor: str, confirmed: bool, agent: str,
3378
+ task_id: str | None = None, session_file: str | Path | None = None) -> dict:
3379
+ session, task_id, task = resolve_current_task(root, task_id, session_file)
3380
+ require_spec_context(root, task, agent, session_file)
3381
+ repair = task.get("quality_repair")
3382
+ if task.get("status") != "QUALITY" or not isinstance(repair, dict) or repair.get("repair_id") != repair_id:
3383
+ raise StateError("Select the current QUALITY repair bundle.")
3384
+ if repair.get("completed_at"):
3385
+ return snapshot_state(root, session_file, session)
3386
+ mode = behavior_layers(root, session)["cooperate_mode"]["value"]
3387
+ if executor not in {"current", "other"} or (not repair.get("approved_at") and executor == "other" and mode != "dispatch"):
3388
+ raise StateError("QUALITY repair handoff requires cooperate_mode dispatch.")
3389
+ if repair.get("approved_at"):
3390
+ if repair.get("executor") != executor:
3391
+ raise StateError("This repair already has an approved executor; resume that handoff.")
3392
+ else:
3393
+ if (mode == "dispatch" or resolve_approval_mode(root, session)[2] == "approve") and not confirmed:
3394
+ raise StateError("Confirm the repair scope and executor once before starting this repair.")
3395
+ if implementation_fingerprint(root, task_id) != repair["implementation_fingerprint"]:
3396
+ raise StateError("Repair inputs changed before approval; refresh the displayed repair bundle.")
3397
+ if isinstance(task.get("spec_source"), dict):
3398
+ task, failed_sources = prepare_canonical_repair_transition(root, task_id, task, agent)
3399
+ plan = latest_execution_plan(root, task_id) or {}
3400
+ repair_sources = {u.get("source_task_id") for u in plan.get("units", [])
3401
+ if u["id"] in repair["unit_ids"]}
3402
+ if not failed_sources <= repair_sources:
3403
+ raise StateError("The repair bundle must cover all failed Canonical source tasks: "
3404
+ + ", ".join(sorted(failed_sources - repair_sources)))
3405
+ cancel_active_quality_attempt(root, task_id, task, agent, repair["summary"], "manual-return")
3406
+ task = load_task(root, task_id) or task
3407
+ repair = task["quality_repair"]
3408
+ repair.update(approved_at=now_iso(), approved_by=agent, executor=executor,
3409
+ authorization="explicit-user" if confirmed else "approval-policy",
3410
+ execution_start_index=len(execution_records(root, task_id)))
3411
+ cleanup_verification_checkpoint(root, task_id, task)
3412
+ task.pop("pending_transition", None)
3413
+ write_task(root, task_id, task)
3414
+ if isinstance(task.get("canonical_repair_transition"), dict):
3415
+ sources = set(task["canonical_repair_transition"]["source_task_ids"])
3416
+ writeback_ready_tasks_for_implement(root, task_id, task, agent, {"blocked"}, sources,
3417
+ repair_id=repair_id)
3418
+ task = load_task(root, task_id) or task
3419
+ task.pop("canonical_repair_transition", None)
3420
+ write_task(root, task_id, task)
3421
+ if executor == "other" and not repair.get("handed_off"):
3422
+ # 同一授权直接生成接力,接手不再经过阶段审批。
3423
+ result = handoff_task(root, agent, repair["summary"], task_id, session_file,
3424
+ {"next_action": "repair", "unit_ids": repair["unit_ids"],
3425
+ "stop_after": "repair", "repair_id": repair_id})
3426
+ task = load_task(root, task_id)
3427
+ task["quality_repair"]["handed_off"] = True
3428
+ write_task(root, task_id, task)
3429
+ return result
3430
+ return snapshot_state(root, session_file, session)
3431
+
3432
+
3433
+ def complete_quality_repair(root: Path, repair_id: str, agent: str,
3434
+ task_id: str | None = None, session_file: str | Path | None = None) -> dict:
3435
+ session, task_id, task = resolve_current_task(root, task_id, session_file)
3436
+ require_spec_context(root, task, agent, session_file)
3437
+ repair = task.get("quality_repair")
3438
+ if task.get("status") != "QUALITY" or not isinstance(repair, dict) or repair.get("repair_id") != repair_id or not repair.get("approved_at"):
3439
+ raise StateError("Complete only the approved current QUALITY repair.")
3440
+ if repair.get("completed_at"):
3441
+ return snapshot_state(root, session_file, session)
3442
+ plan = latest_execution_plan(root, task_id) or {}
3443
+ current = capture(input_spec(root, task, plan, {"type": "review"}))
3444
+ previous = repair["inputs"]
3445
+ if previous["spec"] != current["spec"]:
3446
+ raise StateError("Repair changed the contract or input scope; reconcile the plan before verification.")
3447
+ allowed = set()
3448
+ for unit in plan.get("units", []):
3449
+ if unit["id"] not in repair["unit_ids"]:
3450
+ continue
3451
+ binding = (task.get("repo_paths") or {}).get(unit.get("repo_id"), str(root))
3452
+ repository = Path(binding)
3453
+ if not repository.is_absolute():
3454
+ repository = root / repository
3455
+ allowed.update(str((repository / file).resolve()) for file in repair["files"] if file in unit["files"])
3456
+ changed = [str((Path(repo) / file).resolve()) for repo, files in current["files"].items()
3457
+ for file in set(files) | set(previous["files"].get(repo, {}))
3458
+ if files.get(file) != previous["files"].get(repo, {}).get(file)]
3459
+ if any(not any(path == scope or path.startswith(scope + "/") for scope in allowed) for path in changed):
3460
+ raise StateError("Repair changed files outside the approved bundle.")
3461
+ if isinstance(task.get("spec_source"), dict):
3462
+ inspection, _ = inspect_task_spec(root, task)
3463
+ snapshots = _selected_execution_snapshots(inspection, task)
3464
+ sources = {u["source_task_id"] for u in plan.get("units", []) if u["id"] in repair["unit_ids"]}
3465
+ if any(snapshots.get(source, {}).get("status") not in {"implemented", "verified", "completed"}
3466
+ for source in sources):
3467
+ raise StateError("Write back the repaired Canonical tasks as implemented before returning.")
3468
+ repair["completed_at"] = now_iso()
3469
+ task.pop("quality_return_required", None)
3470
+ records = validated_quality_records(root, task_id)
3471
+ if records:
3472
+ task["quality_consumed_attempt"] = records[-1][1]["attempt"]
3473
+ task["continuation"] = {"next_action": "quality", "unit_ids": repair["unit_ids"]}
3474
+ write_task(root, task_id, task)
3475
+ if repair["executor"] == "other":
3476
+ return handoff_task(root, agent, "Repair complete; review the delta and verify affected inputs.",
3477
+ task_id, session_file, task["continuation"])
3478
+ return snapshot_state(root, session_file, session)
3479
+
3480
+
3282
3481
  def implementation_fingerprint(root: Path, task_id: str) -> str:
3283
3482
  plan = latest_execution_plan(root, task_id)
3284
3483
  if not plan:
@@ -3524,6 +3723,12 @@ def ensure_quality_attempt_context(
3524
3723
  persist: bool = False,
3525
3724
  infer_existing_evidence: bool = False,
3526
3725
  ) -> dict:
3726
+ repair = task.get("quality_repair")
3727
+ if isinstance(repair, dict) and not repair.get("completed_at"):
3728
+ raise StateError("Complete the current QUALITY repair before collecting gate evidence.")
3729
+ if persist and (task.get("continuation") or {}).get("next_action") == "quality":
3730
+ task.pop("continuation", None)
3731
+ write_task(root, task_id, task)
3527
3732
  if isinstance(task.get("canonical_repair_transition"), dict):
3528
3733
  raise StateError(
3529
3734
  "Canonical repair transition is incomplete; resume it before collecting new QUALITY evidence."
@@ -5660,8 +5865,7 @@ def quality_repair_failures_for_window(
5660
5865
  and repo_id == task_repositories[source_task_id]
5661
5866
  )
5662
5867
  ):
5663
- owner = source_task_id if canonical else task_id
5664
- latest_reviews[(owner, *gate_identity(record)[1:])] = record
5868
+ latest_reviews[gate_identity(record)] = record
5665
5869
  elif record_type == "verify" and record.get(
5666
5870
  "implementation_fingerprint"
5667
5871
  ) == implementation and record.get("config_fingerprint") == config:
@@ -5689,11 +5893,11 @@ def quality_repair_failures_for_window(
5689
5893
  )
5690
5894
  ):
5691
5895
  continue
5692
- owner = source_task_id if canonical else task_id
5693
- latest_verifications[(owner, *gate_identity(record)[1:])] = record
5896
+ latest_verifications[gate_identity(record)] = record
5694
5897
 
5695
5898
  failures: dict[str, list[str]] = {}
5696
- for (source_task_id, unit_id, dimension, _scope), record in latest_reviews.items():
5899
+ for record in latest_reviews.values():
5900
+ source_task_id = str(record.get("source_task_id")) if canonical else task_id
5697
5901
  findings = record.get("findings")
5698
5902
  has_error = isinstance(findings, list) and any(
5699
5903
  isinstance(finding, dict)
@@ -5702,7 +5906,8 @@ def quality_repair_failures_for_window(
5702
5906
  )
5703
5907
  if record.get("passed") is not True or has_error:
5704
5908
  failures.setdefault(source_task_id, []).append(failure_label(record))
5705
- for (source_task_id, unit_id, check, scope), record in latest_verifications.items():
5909
+ for record in latest_verifications.values():
5910
+ source_task_id = str(record.get("source_task_id")) if canonical else task_id
5706
5911
  if record.get("applicable") is not False and record.get("passed") is not True:
5707
5912
  failures.setdefault(source_task_id, []).append(failure_label(record))
5708
5913
  return failures
@@ -6805,45 +7010,14 @@ def validate_analysis_readiness(
6805
7010
 
6806
7011
 
6807
7012
  def latest_handoff_record(root: Path, task_id: str) -> dict | None:
6808
- path = execution_log_path(root, task_id)
6809
- if not path.exists():
6810
- return None
6811
- latest: dict | None = None
6812
- try:
6813
- for line in path.read_text(encoding="utf-8").splitlines():
6814
- if not line.strip():
6815
- continue
6816
- try:
6817
- record = json.loads(line)
6818
- except json.JSONDecodeError:
6819
- continue
6820
- if isinstance(record, dict) and record.get("type") == "handoff":
6821
- latest = record
6822
- except OSError:
6823
- return None
6824
- return latest
7013
+ return next((r for r in reversed(execution_records(root, task_id))
7014
+ if r.get("type") == "handoff"), None)
6825
7015
 
6826
7016
 
6827
7017
  def pending_handoff_record(root: Path, task_id: str) -> dict | None:
6828
- path = execution_log_path(root, task_id)
6829
- if not path.exists():
6830
- return None
6831
- latest_coordination: dict | None = None
6832
- try:
6833
- for line in path.read_text(encoding="utf-8").splitlines():
6834
- if not line.strip():
6835
- continue
6836
- try:
6837
- record = json.loads(line)
6838
- except json.JSONDecodeError:
6839
- continue
6840
- if isinstance(record, dict) and record.get("type") in {"handoff", "claim"}:
6841
- latest_coordination = record
6842
- except OSError:
6843
- return None
6844
- if latest_coordination and latest_coordination.get("type") == "handoff":
6845
- return latest_coordination
6846
- return None
7018
+ latest = next((r for r in reversed(execution_records(root, task_id))
7019
+ if r.get("type") in {"handoff", "claim"}), None)
7020
+ return latest if latest and latest["type"] == "handoff" else None
6847
7021
 
6848
7022
 
6849
7023
  def assert_safe_task_id(task_id: str) -> None:
@@ -7012,8 +7186,16 @@ def snapshot_state(
7012
7186
  else {"status": "not_checked", "reasons": []}
7013
7187
  )
7014
7188
 
7189
+ layers = behavior_layers(root, resolved_session)
7190
+ local, _ = read_behavior_file(Path.home() / ".easy-coding" / "config.yaml")
7015
7191
  return {
7016
7192
  "session_file": display_path(root, session_path),
7193
+ "behavior_sources": {key: item["source"] for key, item in layers.items()},
7194
+ "local_behavior": local,
7195
+ "effective_cooperate_mode": layers["cooperate_mode"]["value"],
7196
+ "cooperation": task.get("cooperation") if task else None,
7197
+ "quality_repair": task.get("quality_repair") if task else None,
7198
+ "continuation": task.get("continuation") if task else None,
7017
7199
  "current_task": str(task_id) if task_id else None,
7018
7200
  "task": task,
7019
7201
  "pending_transition": task.get("pending_transition") if task else None,
@@ -7060,8 +7242,9 @@ def build_status_line(
7060
7242
  session: dict,
7061
7243
  agent: str | None = None,
7062
7244
  session_file: str | Path | None = None,
7245
+ state: dict | None = None,
7063
7246
  ) -> str:
7064
- state = snapshot_state(root, session_file, session)
7247
+ state = state if state is not None else snapshot_state(root, session_file, session)
7065
7248
  if state["lite_mode"]:
7066
7249
  lite_state = (
7067
7250
  "Awaiting Confirmation"
@@ -7076,6 +7259,8 @@ def build_status_line(
7076
7259
  approval = str(state["effective_approval_mode"]).capitalize()
7077
7260
  workflow = str(state["concrete_workflow_mode"] or state["configured_workflow_mode"]).capitalize()
7078
7261
  status_brand = f"> **Easy Coding** · **Approval: {approval}** · **Workflow: {workflow}**"
7262
+ if state["effective_cooperate_mode"] == "dispatch":
7263
+ status_brand += " · **Dispatch**"
7079
7264
  if state["displayed_unit_test_mode"] in {"ut", "tdd"}:
7080
7265
  status_brand += f" · **{state['displayed_unit_test_mode'].upper()}**"
7081
7266
  task_id = state["current_task"]
@@ -7108,8 +7293,9 @@ def build_machine_breadcrumbs(
7108
7293
  session: dict,
7109
7294
  agent: str | None = None,
7110
7295
  session_file: str | Path | None = None,
7296
+ state: dict | None = None,
7111
7297
  ) -> list[str]:
7112
- state = snapshot_state(root, session_file, session)
7298
+ state = state if state is not None else snapshot_state(root, session_file, session)
7113
7299
  task_id = state["current_task"]
7114
7300
  task = state["task"]
7115
7301
  stage = str(state["status"]) if task else "idle"
@@ -7119,6 +7305,7 @@ def build_machine_breadcrumbs(
7119
7305
  f"[easy-coding:session-file:{resolved_session_file}]",
7120
7306
  f"[easy-coding:approval-mode:{state['effective_approval_mode']}]",
7121
7307
  f"[easy-coding:configured-workflow-mode:{state['configured_workflow_mode']}]",
7308
+ f"[easy-coding:cooperate-mode:{state['effective_cooperate_mode']}]",
7122
7309
  ]
7123
7310
  if state.get("concrete_workflow_mode"):
7124
7311
  lines.append(f"[easy-coding:workflow-mode:{state['concrete_workflow_mode']}]")
@@ -7130,10 +7317,15 @@ def build_machine_breadcrumbs(
7130
7317
 
7131
7318
  if task_id:
7132
7319
  lines.append(f"[current-task:{task_id}]")
7320
+ continuation = state.get("continuation") or {}
7321
+ if continuation.get("next_action"):
7322
+ lines.append(f"[easy-coding:next-action:{continuation['next_action']}]")
7323
+ if continuation.get("stop_after"):
7324
+ lines.append(f"[easy-coding:stop-after:{continuation['stop_after']}]")
7133
7325
  if task and isinstance(task.get("spec_source"), dict):
7134
7326
  source = task["spec_source"]
7135
7327
  lines.append(f"[easy-coding:spec:{source.get('spec_id')}:revision:{source.get('revision')}]")
7136
- lines.append("[easy-coding:spec-context:resume-spec-context-on-session-resume]")
7328
+ lines.append("[easy-coding:spec-context:reuse-current-session-or-resume-if-missing]")
7137
7329
  if task.get("spec_change"):
7138
7330
  lines.append("[easy-coding:spec-change:pending-sync-spec-design]")
7139
7331
  if (task.get("spec_writeback_progress") or {}).get("pending_action"):
@@ -7207,6 +7399,7 @@ def build_status_context(
7207
7399
  session: dict,
7208
7400
  agent: str | None = None,
7209
7401
  session_file: str | Path | None = None,
7402
+ state: dict | None = None,
7210
7403
  ) -> str:
7211
7404
  if session.get("harness_disabled") is True:
7212
7405
  session_path = resolve_session_path(root, session_file)
@@ -7227,10 +7420,11 @@ def build_status_context(
7227
7420
  if isinstance(proposal, dict):
7228
7421
  lines.append(f"[easy-coding:lite-proposal:{proposal.get('digest', 'missing')}]")
7229
7422
  return "\n".join(lines)
7423
+ state = state if state is not None else snapshot_state(root, session_file, session)
7230
7424
  return "\n".join(
7231
7425
  [
7232
- build_status_line(root, session, agent, session_file),
7233
- *build_machine_breadcrumbs(root, session, agent, session_file),
7426
+ build_status_line(root, session, agent, session_file, state),
7427
+ *build_machine_breadcrumbs(root, session, agent, session_file, state),
7234
7428
  ]
7235
7429
  )
7236
7430
 
@@ -7245,7 +7439,8 @@ def attach_status_context(
7245
7439
  session = load_session(root, resolved_session_file)
7246
7440
  if session is None:
7247
7441
  session = default_session()
7248
- context = build_status_context(root, session, agent, resolved_session_file)
7442
+ state = data if "current_task" in data and "effective_approval_mode" in data else None
7443
+ context = build_status_context(root, session, agent, resolved_session_file, state)
7249
7444
  first_line = context.splitlines()[0] if context.startswith("> ") else ""
7250
7445
  enriched = dict(data)
7251
7446
  enriched["status_line"] = first_line
@@ -7852,6 +8047,7 @@ def handoff_task(
7852
8047
  summary: str,
7853
8048
  task_id: str | None = None,
7854
8049
  session_file: str | Path | None = None,
8050
+ continuation: dict | None = None,
7855
8051
  ) -> dict:
7856
8052
  if not summary.strip():
7857
8053
  raise StateError("Handoff summary is required.")
@@ -7865,6 +8061,24 @@ def handoff_task(
7865
8061
  stage = str(task.get("status") or "PENDING")
7866
8062
  if stage in TERMINAL_STATUSES:
7867
8063
  raise StateError(f"Cannot hand off terminal task: {resolved_task_id}")
8064
+ if continuation is not None:
8065
+ if not isinstance(continuation, dict) or set(continuation) - {
8066
+ "next_action", "unit_ids", "evidence_refs", "stop_after", "repair_id"
8067
+ }:
8068
+ raise StateError("Unknown handoff continuation fields.")
8069
+ if continuation.get("next_action") not in {"implement", "repair", "quality", "continue"}:
8070
+ raise StateError("Handoff must name the next action.")
8071
+ plan = latest_execution_plan(root, str(resolved_task_id)) or {}
8072
+ unit_ids = continuation.get("unit_ids", [])
8073
+ if not is_string_list(unit_ids) or not set(unit_ids) <= {u["id"] for u in plan.get("units", [])}:
8074
+ raise StateError("Handoff units must belong to the existing plan.")
8075
+ refs = continuation.get("evidence_refs", [])
8076
+ count = len(execution_records(root, str(resolved_task_id)))
8077
+ if not isinstance(refs, list) or any(type(i) is not int or i < 0 or i >= count for i in refs):
8078
+ raise StateError("Handoff evidence must reference existing execution records.")
8079
+ if continuation.get("stop_after") not in {None, "IMPLEMENT", "repair"}:
8080
+ raise StateError("Unknown handoff stop point.")
8081
+ task["continuation"] = continuation
7868
8082
 
7869
8083
  record = {
7870
8084
  "type": "handoff",
@@ -7872,6 +8086,7 @@ def handoff_task(
7872
8086
  "stage": stage,
7873
8087
  "summary": summary.strip(),
7874
8088
  "timestamp": now_iso(),
8089
+ **(continuation or {}),
7875
8090
  }
7876
8091
  append_execution_record(root, str(resolved_task_id), record)
7877
8092
  task["last_agent"] = agent
@@ -7907,6 +8122,9 @@ def claim_task(root: Path, task_id: str, agent: str, session_file: str | Path |
7907
8122
  else "takeover"
7908
8123
  )
7909
8124
  latest_handoff = latest_handoff_record(root, task_id)
8125
+ already_current = (session.get("current_task") == task_id
8126
+ and agents_equivalent(previous_agent, agent)
8127
+ and pending_handoff_record(root, task_id) is None)
7910
8128
  task["last_agent"] = agent
7911
8129
  write_task(root, task_id, task)
7912
8130
 
@@ -7923,7 +8141,8 @@ def claim_task(root: Path, task_id: str, agent: str, session_file: str | Path |
7923
8141
  "action": action,
7924
8142
  "timestamp": now_iso(),
7925
8143
  }
7926
- append_execution_record(root, task_id, claim)
8144
+ if not already_current:
8145
+ append_execution_record(root, task_id, claim)
7927
8146
 
7928
8147
  context = restore_spec_context(root, task_id, task, agent, session_file)
7929
8148
  snapshot = snapshot_state(root, session_file, session)
@@ -7934,9 +8153,32 @@ def claim_task(root: Path, task_id: str, agent: str, session_file: str | Path |
7934
8153
  snapshot["previous_agent"] = previous_agent
7935
8154
  snapshot["latest_handoff"] = latest_handoff
7936
8155
  snapshot["claim"] = claim
8156
+ snapshot["claim_recorded"] = not already_current
7937
8157
  return snapshot
7938
8158
 
7939
8159
 
8160
+ def set_cooperate_mode(root: Path, mode: str | None, agent: str,
8161
+ session_file: str | Path | None = None) -> dict:
8162
+ if mode not in {None, "default", "dispatch"}:
8163
+ raise StateError("cooperate_mode must be default or dispatch.")
8164
+ session = ensure_session(root, session_file)
8165
+ if mode is None:
8166
+ session.pop("cooperate_mode", None)
8167
+ else:
8168
+ session["cooperate_mode"] = mode
8169
+ write_session(root, session, session_file)
8170
+ task_id = session.get("current_task")
8171
+ task = load_task(root, task_id)
8172
+ if task and pending_handoff_record(root, task_id) is None:
8173
+ task["cooperation"] = {
8174
+ "mode": behavior_layers(root, session)["cooperate_mode"]["value"],
8175
+ "coordinator": (task.get("cooperation") or {}).get("coordinator") or {
8176
+ "agent": agent, "session_file": display_path(root, resolve_session_path(root, session_file))},
8177
+ }
8178
+ write_task(root, task_id, task)
8179
+ return snapshot_state(root, session_file, session)
8180
+
8181
+
7940
8182
  def create_task(
7941
8183
  root: Path,
7942
8184
  task_id: str,
@@ -7967,6 +8209,8 @@ def create_task(
7967
8209
  "created_by": agent,
7968
8210
  "last_agent": agent,
7969
8211
  "stage_history": [{"stage": "INIT", "agent": agent, "entered_at": timestamp}],
8212
+ "cooperation": {"mode": behavior_layers(root, session)["cooperate_mode"]["value"],
8213
+ "coordinator": {"agent": agent, "session_file": display_path(root, resolve_session_path(root, session_file))}},
7970
8214
  "context": {},
7971
8215
  "spawned_from": None,
7972
8216
  "spawned_tasks": [],
@@ -9069,6 +9313,7 @@ def sync_spec_design_state(
9069
9313
  progress.pop("pending_action", None)
9070
9314
  task.pop("spec_change", None)
9071
9315
  task.pop("spec_context", None)
9316
+ task.pop("spec_contexts", None)
9072
9317
  already_acknowledged = any(
9073
9318
  record.get("type") == "spec-design-sync"
9074
9319
  and record.get("idempotency_key") == idempotency_key
@@ -9177,6 +9422,7 @@ def writeback_ready_tasks_for_implement(
9177
9422
  agent: str,
9178
9423
  restart_statuses: set[str] | None = None,
9179
9424
  source_task_ids: set[str] | None = None,
9425
+ *, repair_id: str | None = None,
9180
9426
  ) -> None:
9181
9427
  inspection, _ = inspect_task_spec(root, task)
9182
9428
  implement_attempt = 1 + sum(
@@ -9204,11 +9450,15 @@ def writeback_ready_tasks_for_implement(
9204
9450
  f"{harness_task_id}:{source_task_id}:enter-implement:"
9205
9451
  f"{task['spec_source']['revision']}:attempt-{implement_attempt}"
9206
9452
  )
9453
+ if repair_id:
9454
+ key = f"{harness_task_id}:{source_task_id}:quality-repair:{repair_id}:start"
9455
+ summary = ("Harness started an approved repair within QUALITY" if repair_id else
9456
+ "Harness entered IMPLEMENT for a dependency-ready Canonical task")
9207
9457
  action = {
9208
9458
  "kind": "task",
9209
9459
  "source_task_id": source_task_id,
9210
9460
  "status": "in_progress",
9211
- "summary": "Harness entered IMPLEMENT for a dependency-ready Canonical task",
9461
+ "summary": summary,
9212
9462
  "evidence": [],
9213
9463
  "idempotency_key": key,
9214
9464
  "agent": agent,
@@ -9223,7 +9473,7 @@ def writeback_ready_tasks_for_implement(
9223
9473
  stored_spec_path(root, task),
9224
9474
  str(source_task_id),
9225
9475
  "in_progress",
9226
- "Harness entered IMPLEMENT for a dependency-ready Canonical task",
9476
+ summary,
9227
9477
  SPEC_WRITEBACK_APP,
9228
9478
  spec_writeback_agent(agent),
9229
9479
  design_digest,
@@ -10706,7 +10956,7 @@ def main() -> int:
10706
10956
  if name == "prepare-check":
10707
10957
  check_parser.add_argument("--record", required=True)
10708
10958
  else:
10709
- check_parser.add_argument("--prepared-id", required=True)
10959
+ check_parser.add_argument("--prepared-id")
10710
10960
  check_parser.add_argument("--result", required=True)
10711
10961
  correction_parser = subcommands.add_parser("begin-correction", parents=[common])
10712
10962
  correction_parser.add_argument("--file", action="append", required=True)
@@ -10715,6 +10965,21 @@ def main() -> int:
10715
10965
  correction_parser.add_argument("--agent", required=True)
10716
10966
  correction_parser.add_argument("--task-id")
10717
10967
 
10968
+ for name in ("start-quality-repair", "complete-quality-repair"):
10969
+ repair_parser = subcommands.add_parser(name, parents=[common])
10970
+ repair_parser.add_argument("--repair-id", required=True)
10971
+ repair_parser.add_argument("--agent", required=True)
10972
+ repair_parser.add_argument("--task-id")
10973
+ if name == "start-quality-repair":
10974
+ repair_parser.add_argument("--executor", choices=["current", "other"], required=True)
10975
+ repair_parser.add_argument("--confirmed", action="store_true")
10976
+
10977
+ for name in ("set-cooperate-mode", "clear-cooperate-mode"):
10978
+ cooperate_parser = subcommands.add_parser(name, parents=[common])
10979
+ cooperate_parser.add_argument("--agent", required=True)
10980
+ if name == "set-cooperate-mode":
10981
+ cooperate_parser.add_argument("--mode", choices=["default", "dispatch"], required=True)
10982
+
10718
10983
  finalize_quality_parser = subcommands.add_parser(
10719
10984
  "finalize-quality", parents=[common]
10720
10985
  )
@@ -10792,6 +11057,7 @@ def main() -> int:
10792
11057
  handoff.add_argument("--agent", required=True)
10793
11058
  handoff.add_argument("--summary", required=True)
10794
11059
  handoff.add_argument("--task-id")
11060
+ handoff.add_argument("--continuation", help="JSON next_action, unit_ids, evidence_refs and stop_after")
10795
11061
 
10796
11062
  claim = subcommands.add_parser("claim-task", parents=[common])
10797
11063
  claim.add_argument("--task-id", required=True)
@@ -11289,12 +11555,26 @@ def main() -> int:
11289
11555
  session, task_id, task = resolve_current_task(root, args.task_id, session_file)
11290
11556
  require_spec_context(root, task, agent, session_file)
11291
11557
  if command == "prepare-check":
11292
- result = prepare_check(root, task_id, task, json.loads(args.record), agent)
11558
+ descriptors = json.loads(args.record)
11559
+ result = ([prepare_check(root, task_id, task, item, agent) for item in descriptors]
11560
+ if isinstance(descriptors, list) else prepare_check(root, task_id, task, descriptors, agent))
11293
11561
  elif command == "record-check":
11294
- result = record_check(root, task_id, task, args.prepared_id, json.loads(args.result), agent)
11562
+ results = json.loads(args.result)
11563
+ result = ([record_check(root, task_id, task, item["prepared_id"], item["result"], agent) for item in results]
11564
+ if isinstance(results, list) else record_check(root, task_id, task, args.prepared_id, results, agent))
11295
11565
  else:
11296
11566
  result = begin_correction(root, task_id, task, args.file, args.summary, args.risk, agent)
11297
11567
  emit(result)
11568
+ elif command in {"start-quality-repair", "complete-quality-repair"}:
11569
+ if command == "start-quality-repair":
11570
+ result = start_quality_repair(root, args.repair_id, args.executor, args.confirmed,
11571
+ agent, args.task_id, session_file)
11572
+ else:
11573
+ result = complete_quality_repair(root, args.repair_id, agent, args.task_id, session_file)
11574
+ emit(attach_status_context(root, result, agent, session_file))
11575
+ elif command in {"set-cooperate-mode", "clear-cooperate-mode"}:
11576
+ emit(attach_status_context(root, set_cooperate_mode(root,
11577
+ args.mode if command == "set-cooperate-mode" else None, agent, session_file), agent, session_file))
11298
11578
  elif command == "finalize-quality":
11299
11579
  emit(
11300
11580
  attach_status_context(
@@ -11411,7 +11691,8 @@ def main() -> int:
11411
11691
  emit(
11412
11692
  attach_status_context(
11413
11693
  root,
11414
- handoff_task(root, agent, args.summary, args.task_id, session_file),
11694
+ handoff_task(root, agent, args.summary, args.task_id, session_file,
11695
+ json.loads(args.continuation) if args.continuation else None),
11415
11696
  agent,
11416
11697
  session_file,
11417
11698
  )