easy-coding-harness 1.1.0-beta.0 → 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 (
@@ -125,8 +126,8 @@ WIDE_WORKFLOW_CONTRACT_PATTERN = re.compile(
125
126
  )
126
127
  DEFAULT_APPROVAL_MODE = "guard"
127
128
  DEFAULT_WORKFLOW_MODE = "adaptive"
128
- DEFAULT_TDD_ENABLED = False
129
- DEFAULT_TDD_COVERAGE_THRESHOLD = 90
129
+ DEFAULT_UNIT_TEST_MODE = "none"
130
+ DEFAULT_UT_COVERAGE_THRESHOLD = 90
130
131
  TDD_READINESS_SCHEMA = "easy-coding/tdd-readiness-v1"
131
132
  TDD_READINESS_SCOPE = "changed-production-lines"
132
133
  TDD_READINESS_PATH = Path(".easy-coding/tdd/readiness.json")
@@ -458,7 +459,7 @@ def read_memory_config(root: Path) -> dict[str, int]:
458
459
  return config
459
460
 
460
461
 
461
- def parse_tdd_threshold(value: object, source: str) -> int:
462
+ def parse_ut_threshold(value: object, source: str) -> int:
462
463
  if isinstance(value, bool):
463
464
  raise StateError(f"Invalid {source}: expected an integer from 1 to 100.")
464
465
  try:
@@ -470,28 +471,21 @@ def parse_tdd_threshold(value: object, source: str) -> int:
470
471
  return threshold
471
472
 
472
473
 
473
- def parse_yaml_bool(value: str | None, source: str) -> bool:
474
- if value is None:
475
- return DEFAULT_TDD_ENABLED
476
- normalized = value.lower()
477
- if normalized in {"true", "yes", "on"}:
478
- return True
479
- if normalized in {"false", "no", "off"}:
480
- return False
481
- raise StateError(f"Invalid {source}: expected true or false.")
474
+ def parse_unit_test_mode(value: object, source: str) -> str:
475
+ if not isinstance(value, str) or value not in {"none", "ut", "tdd"}:
476
+ raise StateError(f"Invalid {source}: expected none, ut, or tdd.")
477
+ return str(value)
482
478
 
483
479
 
484
- def read_project_behavior(root: Path) -> tuple[str, str, bool, int]:
485
- 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]:
486
485
  try:
487
486
  lines = path.read_text(encoding="utf-8").splitlines()
488
- except OSError:
489
- return (
490
- DEFAULT_APPROVAL_MODE,
491
- DEFAULT_WORKFLOW_MODE,
492
- DEFAULT_TDD_ENABLED,
493
- DEFAULT_TDD_COVERAGE_THRESHOLD,
494
- )
487
+ except FileNotFoundError:
488
+ return {}, 0
495
489
 
496
490
  in_behavior = False
497
491
  behavior_indent = 0
@@ -503,6 +497,8 @@ def read_project_behavior(root: Path) -> tuple[str, str, bool, int]:
503
497
  if not stripped:
504
498
  continue
505
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.")
506
502
  if stripped == "behavior:":
507
503
  in_behavior = True
508
504
  behavior_indent = indent
@@ -520,6 +516,11 @@ def read_project_behavior(root: Path) -> tuple[str, str, bool, int]:
520
516
  key, value = stripped.split(":", 1)
521
517
  behavior[key] = value.strip().strip("'\"")
522
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")
523
524
  legacy = behavior.get("confirm_mode")
524
525
  approval_mode = behavior.get("approval_mode")
525
526
  workflow_mode = behavior.get("workflow_mode")
@@ -542,16 +543,42 @@ def read_project_behavior(root: Path) -> tuple[str, str, bool, int]:
542
543
  "Invalid behavior.workflow_mode in .easy-coding/config.yaml: "
543
544
  "expected adaptive, fast, standard, or strict."
544
545
  )
545
- if schema_version >= 4:
546
- tdd_enabled = parse_yaml_bool(behavior.get("tdd_enabled"), "behavior.tdd_enabled")
547
- tdd_threshold = parse_tdd_threshold(
548
- behavior.get("tdd_coverage_threshold", DEFAULT_TDD_COVERAGE_THRESHOLD),
549
- "behavior.tdd_coverage_threshold",
546
+ if schema_version >= 6:
547
+ unit_test_mode = parse_unit_test_mode(
548
+ behavior.get("unit_test_mode", DEFAULT_UNIT_TEST_MODE), "behavior.unit_test_mode"
549
+ )
550
+ threshold = parse_ut_threshold(
551
+ behavior.get("ut_coverage_threshold", DEFAULT_UT_COVERAGE_THRESHOLD),
552
+ "behavior.ut_coverage_threshold",
550
553
  )
551
554
  else:
552
- tdd_enabled = DEFAULT_TDD_ENABLED
553
- tdd_threshold = DEFAULT_TDD_COVERAGE_THRESHOLD
554
- return approval_mode, workflow_mode, tdd_enabled, tdd_threshold
555
+ if schema_version >= 4:
556
+ raise StateError("Run easy-coding upgrade to migrate unit-test settings to schema 6.")
557
+ unit_test_mode = DEFAULT_UNIT_TEST_MODE
558
+ threshold = DEFAULT_UT_COVERAGE_THRESHOLD
559
+ return approval_mode, workflow_mode, unit_test_mode, threshold
560
+
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
555
582
 
556
583
 
557
584
  def safe_tdd_report_pattern(value: object) -> bool:
@@ -723,13 +750,13 @@ def require_tdd_readiness(root: Path) -> None:
723
750
 
724
751
  def resolve_behavior(
725
752
  root: Path, session: dict
726
- ) -> tuple[str, str | None, str, str, str | None, str, bool, bool | None, bool, int, int | None, int]:
727
- project_approval, project_workflow, project_tdd, project_threshold = read_project_behavior(root)
753
+ ) -> tuple[str, str | None, str, str, str | None, str, str, str | None, str, int, int | None, int]:
754
+ project_approval, project_workflow, project_unit_test, project_threshold = read_project_behavior(root)
728
755
  legacy = session.get("confirm_mode")
729
756
  session_approval = session.get("approval_mode")
730
757
  session_workflow = session.get("workflow_mode")
731
- session_tdd = session.get("tdd_enabled")
732
- session_threshold = session.get("tdd_coverage_threshold")
758
+ session_unit_test = session.get("unit_test_mode")
759
+ session_threshold = session.get("ut_coverage_threshold")
733
760
  if session_approval is None:
734
761
  if legacy == "lite":
735
762
  session_approval = "guard"
@@ -748,25 +775,29 @@ def resolve_behavior(
748
775
  raise StateError(
749
776
  "Invalid session workflow_mode: expected adaptive, fast, standard, or strict."
750
777
  )
751
- if session_tdd is not None and not isinstance(session_tdd, bool):
752
- raise StateError("Invalid session tdd_enabled: expected true or false.")
778
+ if session_unit_test is not None:
779
+ session_unit_test = parse_unit_test_mode(session_unit_test, "session unit_test_mode")
753
780
  if session_threshold is not None:
754
- session_threshold = parse_tdd_threshold(
755
- session_threshold, "session tdd_coverage_threshold"
781
+ session_threshold = parse_ut_threshold(
782
+ session_threshold, "session ut_coverage_threshold"
756
783
  )
784
+ local, _ = read_behavior_file(Path.home() / ".easy-coding" / "config.yaml")
785
+ effective = behavior_layers(root, session)
757
786
  return (
758
787
  project_approval,
759
788
  str(session_approval) if session_approval else None,
760
- str(session_approval or project_approval),
789
+ str(session_approval or (effective["approval_mode"]["value"] if "approval_mode" in local else project_approval)),
761
790
  project_workflow,
762
791
  str(session_workflow) if session_workflow else None,
763
792
  str(session_workflow or project_workflow),
764
- project_tdd,
765
- session_tdd,
766
- session_tdd if session_tdd is not None else project_tdd,
793
+ project_unit_test,
794
+ session_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),
767
797
  project_threshold,
768
798
  session_threshold,
769
- 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),
770
801
  )
771
802
 
772
803
 
@@ -775,7 +806,22 @@ def resolve_approval_mode(root: Path, session: dict) -> tuple[str, str | None, s
775
806
  return behavior[0], behavior[1], behavior[2]
776
807
 
777
808
 
809
+ def migrate_unit_test_settings(record: dict) -> bool:
810
+ changed = False
811
+ if "tdd_enabled" in record:
812
+ enabled = record.pop("tdd_enabled")
813
+ if "unit_test_mode" not in record and isinstance(enabled, bool):
814
+ record["unit_test_mode"] = "tdd" if enabled else "none"
815
+ changed = True
816
+ if "tdd_coverage_threshold" in record:
817
+ threshold = record.pop("tdd_coverage_threshold")
818
+ record.setdefault("ut_coverage_threshold", threshold)
819
+ changed = True
820
+ return changed
821
+
822
+
778
823
  def materialize_legacy_session_behavior(session: dict) -> None:
824
+ migrate_unit_test_settings(session)
779
825
  legacy = session.get("confirm_mode")
780
826
  if legacy == "lite":
781
827
  session.setdefault("approval_mode", "guard")
@@ -1264,7 +1310,7 @@ def normalize_legacy_stage(stage: object) -> object:
1264
1310
  def normalize_legacy_task(task: dict) -> bool:
1265
1311
  """Normalize legacy task state without touching artifacts outside task.json."""
1266
1312
  legacy_status = str(task.get("status") or "")
1267
- changed = False
1313
+ changed = migrate_unit_test_settings(task)
1268
1314
 
1269
1315
  for field in ("created_by", "last_agent"):
1270
1316
  normalized_agent = canonical_agent_identity(
@@ -1801,6 +1847,11 @@ def append_execution_record(root: Path, task_id: str, record: dict) -> None:
1801
1847
  handle.write(json.dumps(record, ensure_ascii=False) + "\n")
1802
1848
  handle.flush()
1803
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)))
1804
1855
 
1805
1856
 
1806
1857
  def is_non_empty_string(value: object) -> bool:
@@ -1830,13 +1881,18 @@ def is_valid_review_finding(value: object) -> bool:
1830
1881
 
1831
1882
 
1832
1883
  def gate_identity(record: dict) -> tuple:
1884
+ # 已有手写证据没有输入凭据,保留原覆盖语义;新证据与 prepare-check 共用身份。
1885
+ if isinstance(record.get("inputs"), dict):
1886
+ return check_identity(record)
1833
1887
  return (str(record.get("source_task_id") or ""), str(record.get("unit_id") or ""),
1834
1888
  str(record.get("dimension") or record.get("check") or ""),
1835
1889
  str(record.get("review_scope") or record.get("coverage_scope") or ""))
1836
1890
 
1837
1891
 
1838
1892
  def failure_label(record: dict) -> str:
1839
- _, 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 "")
1840
1896
  label = f"{record['type']}:{name}"
1841
1897
  if scope:
1842
1898
  label += f":{scope}"
@@ -2209,30 +2265,42 @@ def inspect_task_spec(
2209
2265
  def restore_spec_context(
2210
2266
  root: Path, task_id: str, task: dict, agent: str,
2211
2267
  session_file: str | Path | None = None,
2268
+ *, force: bool = False,
2212
2269
  ) -> dict | None:
2213
2270
  if not isinstance(task.get("spec_source"), dict):
2214
2271
  return None
2215
- task.pop("spec_context", None)
2272
+ session_key = resolve_session_path(root, session_file).relative_to(root.resolve()).as_posix()
2216
2273
  try:
2217
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}
2218
2290
  context = select_consumption_scopes(
2219
2291
  stored_spec_path(root, task), root, task["selected_spec_tasks"]
2220
2292
  )
2221
2293
  if context.get("design_sha256") != inspection.get("design_sha256"):
2222
2294
  raise StateError("Canonical Spec changed while restoring context; retry resume-spec-context.")
2223
- if isinstance(task.get("spec_change"), dict):
2224
- raise StateError("Confirmed Spec change is pending; update the bound source and run sync-spec-design.")
2225
2295
  task["spec_context"] = {
2226
- "session_file": resolve_session_path(root, session_file).relative_to(root.resolve()).as_posix(),
2227
- "agent": normalize_session_agent(agent),
2228
- "spec_id": inspection["spec_id"],
2229
- "revision": inspection["revision"],
2230
- "design_sha256": inspection["design_sha256"],
2231
- "selected_spec_tasks": task["selected_spec_tasks"],
2296
+ **expected,
2232
2297
  "loaded_at": now_iso(),
2233
2298
  }
2299
+ receipts[session_key] = task["spec_context"]
2234
2300
  result = {"status": "ready", "consumption": context}
2235
2301
  except (StateError, EasyDevSpecError, OSError, UnicodeError) as exc:
2302
+ task.pop("spec_context", None)
2303
+ task.get("spec_contexts", {}).pop(session_key, None)
2236
2304
  # 接手仍可成功,修复来源与同步状态后必须重新加载,不能沿用旧会话的消费记录。
2237
2305
  result = {"status": "blocked", "reason": str(exc), "source": task["spec_source"]}
2238
2306
  write_task(root, task_id, task)
@@ -2246,7 +2314,7 @@ def resume_spec_context(
2246
2314
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
2247
2315
  if not isinstance(task.get("spec_source"), dict):
2248
2316
  raise StateError("Current task is not backed by a Canonical Spec.")
2249
- 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)
2250
2318
  snapshot = snapshot_state(root, session_file, session)
2251
2319
  snapshot.update({"action": "resume-spec-context", "spec_context": context})
2252
2320
  return snapshot
@@ -2263,9 +2331,10 @@ def require_spec_context(
2263
2331
  inspection, _ = inspect_task_spec(
2264
2332
  root, task, allow_pending_hard_dependencies=allow_pending_hard_dependencies,
2265
2333
  )
2266
- 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")
2267
2336
  expected = {
2268
- "session_file": resolve_session_path(root, session_file).relative_to(root.resolve()).as_posix(),
2337
+ "session_file": session_key,
2269
2338
  "agent": normalize_session_agent(agent),
2270
2339
  "spec_id": inspection["spec_id"],
2271
2340
  "revision": inspection["revision"],
@@ -2619,6 +2688,10 @@ def has_valid_execution_plan(root: Path, task_id: str) -> bool:
2619
2688
 
2620
2689
  def execution_records(root: Path, task_id: str) -> list[dict]:
2621
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]:
2622
2695
  if not path.exists():
2623
2696
  return []
2624
2697
  records: list[dict] = []
@@ -2635,6 +2708,11 @@ def execution_records(root: Path, task_id: str) -> list[dict]:
2635
2708
 
2636
2709
 
2637
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:
2638
2716
  latest: dict | None = None
2639
2717
  for record in execution_records(root, task_id):
2640
2718
  if record.get("type") == "plan":
@@ -2889,7 +2967,7 @@ def tdd_baseline_marker_reasons(
2889
2967
  def contains_tdd_threshold(content: str, threshold: int) -> bool:
2890
2968
  return re.search(
2891
2969
  rf"(?<!\d){threshold}\s*%|--threshold(?:\s+|=){threshold}(?!\d)|"
2892
- rf"tdd_coverage_threshold\s*[:=]\s*{threshold}(?!\d)",
2970
+ rf"ut_coverage_threshold\s*[:=]\s*{threshold}(?!\d)",
2893
2971
  content,
2894
2972
  re.IGNORECASE,
2895
2973
  ) is not None
@@ -3133,9 +3211,9 @@ def tdd_infrastructure_fingerprint(repositories: set[Path]) -> str:
3133
3211
 
3134
3212
 
3135
3213
  def check_identity(record: dict) -> tuple:
3136
- return tuple(str(record.get(key) or "") for key in (
3137
- "type", "unit_id", "source_task_id", "dimension", "review_scope", "check", "check_type", "command", "coverage_scope"
3138
- ))
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 "")))
3139
3217
 
3140
3218
 
3141
3219
  def prepare_check(root: Path, task_id: str, task: dict, descriptor: dict, agent: str) -> dict:
@@ -3188,9 +3266,12 @@ def record_check(root: Path, task_id: str, task: dict, prepared_id: str, result:
3188
3266
  if type(result.get("exit_code")) is not int or result["passed"] != (result["exit_code"] == 0):
3189
3267
  raise StateError("Verification passed must agree with its real exit_code.")
3190
3268
  context = None
3191
- 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:
3192
3273
  context = ensure_quality_attempt_context(root, task_id, task, agent, persist=True)
3193
- elif task.get("status") != "IMPLEMENT":
3274
+ elif task.get("status") != "IMPLEMENT" and not repairing:
3194
3275
  raise StateError("Record checks only during implementation or quality.")
3195
3276
  record = {
3196
3277
  **result, **descriptor, **evidence_fingerprints(root, task_id),
@@ -3236,6 +3317,8 @@ def begin_correction(root: Path, task_id: str, task: dict, files: list[str], sum
3236
3317
  allowed = {f for unit in units for f in unit.get("files", [])}
3237
3318
  if not files or not set(files) <= allowed or not summary.strip():
3238
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)
3239
3322
  cancel_active_quality_attempt(root, task_id, task, agent, summary, "manual-return")
3240
3323
  task = load_task(root, task_id) or task
3241
3324
  cleanup_verification_checkpoint(root, task_id, task)
@@ -3265,6 +3348,136 @@ def begin_correction(root: Path, task_id: str, task: dict, files: list[str], sum
3265
3348
  "reasons": reasons, "correction": task["correction"]}
3266
3349
 
3267
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
+
3268
3481
  def implementation_fingerprint(root: Path, task_id: str) -> str:
3269
3482
  plan = latest_execution_plan(root, task_id)
3270
3483
  if not plan:
@@ -3286,11 +3499,22 @@ def canonical_repository_fingerprints(root: Path, task_id: str, task: dict) -> d
3286
3499
  }
3287
3500
 
3288
3501
 
3502
+ def unit_test_contract(task: dict) -> dict:
3503
+ mode = task.get("unit_test_mode")
3504
+ # 旧证据的序列化键保持不变,配置字段改名不触发全局失效;UT 单独标识。
3505
+ contract = {
3506
+ "tdd_enabled": None if mode is None else mode != "none",
3507
+ "tdd_coverage_threshold": task.get("ut_coverage_threshold"),
3508
+ "tdd_baselines": task.get("tdd_baselines"),
3509
+ }
3510
+ if mode == "ut":
3511
+ contract["unit_test_mode"] = "ut"
3512
+ return contract
3513
+
3514
+
3289
3515
  def behavior_config_fingerprint(root: Path, task: dict | None = None) -> str:
3290
3516
  # 审批方式、记忆策略等配置不影响已经执行的测试。
3291
- return digest({key: (task or {}).get(key) for key in (
3292
- "tdd_enabled", "tdd_coverage_threshold", "tdd_baselines"
3293
- )})
3517
+ return digest(unit_test_contract(task or {}))
3294
3518
 
3295
3519
 
3296
3520
  def evidence_fingerprints(root: Path, task_id: str) -> dict[str, str]:
@@ -3499,6 +3723,12 @@ def ensure_quality_attempt_context(
3499
3723
  persist: bool = False,
3500
3724
  infer_existing_evidence: bool = False,
3501
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)
3502
3732
  if isinstance(task.get("canonical_repair_transition"), dict):
3503
3733
  raise StateError(
3504
3734
  "Canonical repair transition is incomplete; resume it before collecting new QUALITY evidence."
@@ -4205,12 +4435,10 @@ def verification_contract_fingerprint(root: Path, task_id: str, task: dict) -> s
4205
4435
  source = task.get("spec_source") if isinstance(task.get("spec_source"), dict) else {}
4206
4436
  contract = {
4207
4437
  "workflow_mode": task.get("workflow_mode"),
4208
- "tdd_enabled": task.get("tdd_enabled"),
4209
- "tdd_coverage_threshold": task.get("tdd_coverage_threshold"),
4210
- "tdd_baselines": task.get("tdd_baselines"),
4438
+ **unit_test_contract(task),
4211
4439
  **({"tdd_infrastructure": tdd_infrastructure_fingerprint(
4212
4440
  {root.resolve(), *task_repository_roots(root, task, plan)}
4213
- )} if task.get("tdd_enabled") is True else {}),
4441
+ )} if task.get("unit_test_mode") in {"ut", "tdd"} else {}),
4214
4442
  "plan": plan,
4215
4443
  "canonical": {
4216
4444
  "schema": source.get("schema"),
@@ -5134,7 +5362,7 @@ def validate_review_readiness(
5134
5362
  raise StateError(
5135
5363
  "QUALITY cannot advance while a review dimension is not passed or has error findings."
5136
5364
  )
5137
- if task.get("tdd_enabled") is True:
5365
+ if task.get("unit_test_mode") == "tdd":
5138
5366
  if is_spec_task:
5139
5367
  missing_tdd_reviews = sorted(
5140
5368
  source_task_id
@@ -5206,7 +5434,7 @@ def validate_verification_readiness(
5206
5434
  and is_non_empty_string(record.get("check"))
5207
5435
  ):
5208
5436
  if (
5209
- task.get("tdd_enabled") is True
5437
+ task.get("unit_test_mode") in {"ut", "tdd"}
5210
5438
  and record.get("check_type") == "coverage"
5211
5439
  and record.get("coverage_scope") == "gitlab"
5212
5440
  ):
@@ -5309,13 +5537,13 @@ def validate_verification_readiness(
5309
5537
  "TDD initialization cannot advance to MEMORY until readiness passes: "
5310
5538
  + "; ".join(str(reason) for reason in readiness["reasons"])
5311
5539
  )
5312
- if task.get("tdd_enabled") is not True and any(
5540
+ if task.get("unit_test_mode") not in {"ut", "tdd"} and any(
5313
5541
  record.get("check_type") == "coverage" for record in latest_by_check.values()
5314
5542
  ):
5315
5543
  raise StateError(
5316
- "Coverage verification evidence is not allowed when the frozen TDD mode is off."
5544
+ "Coverage verification evidence is not allowed when the frozen unit test mode is none."
5317
5545
  )
5318
- if task.get("tdd_enabled") is True:
5546
+ if task.get("unit_test_mode") in {"ut", "tdd"}:
5319
5547
  require_tdd_readiness(root)
5320
5548
  test_records = [
5321
5549
  record
@@ -5330,7 +5558,7 @@ def validate_verification_readiness(
5330
5558
  ]
5331
5559
  if not coverage_records:
5332
5560
  raise StateError(
5333
- "TDD verification requires changed-production-line JaCoCo coverage evidence."
5561
+ "Unit test verification requires changed-production-line JaCoCo coverage evidence."
5334
5562
  )
5335
5563
  if is_spec_task:
5336
5564
  tested_source_tasks = {
@@ -5341,7 +5569,7 @@ def validate_verification_readiness(
5341
5569
  )
5342
5570
  if missing_test_tasks:
5343
5571
  raise StateError(
5344
- "TDD Canonical verification requires local unit-test evidence for every selected source task: "
5572
+ "Unit test Canonical verification requires local unit-test evidence for every selected source task: "
5345
5573
  + ", ".join(missing_test_tasks)
5346
5574
  )
5347
5575
  covered_source_tasks = {
@@ -5352,33 +5580,33 @@ def validate_verification_readiness(
5352
5580
  )
5353
5581
  if missing_coverage_tasks:
5354
5582
  raise StateError(
5355
- "TDD Canonical verification requires separate coverage evidence for every selected source task: "
5583
+ "Unit test Canonical verification requires separate coverage evidence for every selected source task: "
5356
5584
  + ", ".join(missing_coverage_tasks)
5357
5585
  )
5358
5586
  elif not test_records:
5359
5587
  raise StateError(
5360
- "TDD verification requires passed local unit-test evidence."
5588
+ "Unit test verification requires passed local unit-test evidence."
5361
5589
  )
5362
5590
  for record in coverage_records:
5363
5591
  scope = str(record.get("coverage_scope") or "")
5364
5592
  if scope != "local":
5365
5593
  raise StateError(
5366
- "TDD coverage evidence must identify coverage_scope as local."
5594
+ "Unit test coverage evidence must identify coverage_scope as local."
5367
5595
  )
5368
- expected_threshold = task.get("tdd_coverage_threshold")
5596
+ expected_threshold = task.get("ut_coverage_threshold")
5369
5597
  expected_baselines = task.get("tdd_baselines")
5370
5598
  if (
5371
5599
  type(expected_threshold) is not int
5372
5600
  or expected_threshold < 1
5373
5601
  or expected_threshold > 100
5374
5602
  ):
5375
- raise StateError("TDD task is missing a valid frozen coverage threshold.")
5603
+ raise StateError("Unit test task is missing a valid frozen coverage threshold.")
5376
5604
  if not isinstance(expected_baselines, dict) or not expected_baselines:
5377
- raise StateError("TDD task is missing frozen Git baselines.")
5605
+ raise StateError("Unit test task is missing frozen Git baselines.")
5378
5606
  for record in coverage_records:
5379
5607
  coverage = record.get("coverage")
5380
5608
  if not isinstance(coverage, dict):
5381
- raise StateError("TDD coverage evidence must include the coverage result object.")
5609
+ raise StateError("Unit test coverage evidence must include the coverage result object.")
5382
5610
  total = coverage.get("total_lines")
5383
5611
  covered = coverage.get("covered_lines")
5384
5612
  percentage = coverage.get("percentage")
@@ -5414,7 +5642,7 @@ def validate_verification_readiness(
5414
5642
  )
5415
5643
  ):
5416
5644
  raise StateError(
5417
- "TDD coverage evidence must preserve the exact gate command, baseline, counts, percentage, frozen threshold, reports, and report fingerprint."
5645
+ "Unit test coverage evidence must preserve the exact gate command, baseline, counts, percentage, frozen threshold, reports, and report fingerprint."
5418
5646
  )
5419
5647
  if total == 0:
5420
5648
  if record.get("applicable") is not False or record.get("passed") is not True:
@@ -5423,7 +5651,7 @@ def validate_verification_readiness(
5423
5651
  )
5424
5652
  elif abs(percentage - round(covered * 100.0 / total, 2)) > 0.01:
5425
5653
  raise StateError(
5426
- "TDD coverage evidence percentage does not match covered/total counts."
5654
+ "Unit test coverage evidence percentage does not match covered/total counts."
5427
5655
  )
5428
5656
  elif (
5429
5657
  record.get("applicable") is False
@@ -5431,7 +5659,7 @@ def validate_verification_readiness(
5431
5659
  or percentage < threshold
5432
5660
  ):
5433
5661
  raise StateError(
5434
- f"TDD changed-line coverage must meet the frozen {threshold}% threshold."
5662
+ f"Unit test changed-line coverage must meet the frozen {threshold}% threshold."
5435
5663
  )
5436
5664
  if task.get("workflow_mode") == "strict":
5437
5665
  if is_spec_task:
@@ -5637,13 +5865,12 @@ def quality_repair_failures_for_window(
5637
5865
  and repo_id == task_repositories[source_task_id]
5638
5866
  )
5639
5867
  ):
5640
- owner = source_task_id if canonical else task_id
5641
- latest_reviews[(owner, *gate_identity(record)[1:])] = record
5868
+ latest_reviews[gate_identity(record)] = record
5642
5869
  elif record_type == "verify" and record.get(
5643
5870
  "implementation_fingerprint"
5644
5871
  ) == implementation and record.get("config_fingerprint") == config:
5645
5872
  if (
5646
- task.get("tdd_enabled") is True
5873
+ task.get("unit_test_mode") in {"ut", "tdd"}
5647
5874
  and record.get("check_type") == "coverage"
5648
5875
  and record.get("coverage_scope") == "gitlab"
5649
5876
  ):
@@ -5666,11 +5893,11 @@ def quality_repair_failures_for_window(
5666
5893
  )
5667
5894
  ):
5668
5895
  continue
5669
- owner = source_task_id if canonical else task_id
5670
- latest_verifications[(owner, *gate_identity(record)[1:])] = record
5896
+ latest_verifications[gate_identity(record)] = record
5671
5897
 
5672
5898
  failures: dict[str, list[str]] = {}
5673
- 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
5674
5901
  findings = record.get("findings")
5675
5902
  has_error = isinstance(findings, list) and any(
5676
5903
  isinstance(finding, dict)
@@ -5679,7 +5906,8 @@ def quality_repair_failures_for_window(
5679
5906
  )
5680
5907
  if record.get("passed") is not True or has_error:
5681
5908
  failures.setdefault(source_task_id, []).append(failure_label(record))
5682
- 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
5683
5911
  if record.get("applicable") is not False and record.get("passed") is not True:
5684
5912
  failures.setdefault(source_task_id, []).append(failure_label(record))
5685
5913
  return failures
@@ -6408,10 +6636,18 @@ def validate_analysis_readiness(
6408
6636
  test_strategy = task_dir / "test-strategy.md"
6409
6637
  reasons: list[str] = []
6410
6638
  behavior = resolve_behavior(root, session or default_session())
6411
- tdd_enabled = behavior[8] if task_type != TDD_INIT_TASK_TYPE else False
6639
+ unit_test_mode = behavior[8] if task_type != TDD_INIT_TASK_TYPE else "none"
6412
6640
  tdd_threshold = behavior[11]
6413
6641
 
6414
- if dev_spec.is_file() and not tdd_enabled:
6642
+ if unit_test_mode == "ut":
6643
+ require_tdd_readiness(root)
6644
+ plan = latest_execution_plan(root, task_id) or {}
6645
+ if not any(str(file).endswith(".java") for unit in plan.get("units", []) for file in unit.get("files", [])):
6646
+ reasons.append("UT is enabled but the confirmed implementation scope has no Java source")
6647
+ if reasons:
6648
+ raise StateError("; ".join(reasons))
6649
+
6650
+ if dev_spec.is_file() and unit_test_mode != "tdd":
6415
6651
  compact = dev_spec.read_text(encoding="utf-8")
6416
6652
  if compact.startswith("<!-- easy-coding:compact -->"):
6417
6653
  mode, _ = calculate_workflow_floor(root, task_id)
@@ -6527,7 +6763,7 @@ def validate_analysis_readiness(
6527
6763
  plan_is_valid = has_valid_execution_plan(root, task_id)
6528
6764
  if not plan_is_valid:
6529
6765
  reasons.append("execution.jsonl has no valid plan record")
6530
- if tdd_enabled:
6766
+ if unit_test_mode == "tdd":
6531
6767
  readiness = tdd_readiness(root)
6532
6768
  if readiness["status"] != "ready":
6533
6769
  reasons.append(
@@ -6590,6 +6826,8 @@ def validate_analysis_readiness(
6590
6826
  dev_spec_content, strategy_content, baselines
6591
6827
  )
6592
6828
  )
6829
+ elif unit_test_mode == "ut":
6830
+ pass
6593
6831
  elif task_type == TDD_INIT_TASK_TYPE:
6594
6832
  try:
6595
6833
  strategy_content = test_strategy.read_text(encoding="utf-8")
@@ -6772,45 +7010,14 @@ def validate_analysis_readiness(
6772
7010
 
6773
7011
 
6774
7012
  def latest_handoff_record(root: Path, task_id: str) -> dict | None:
6775
- path = execution_log_path(root, task_id)
6776
- if not path.exists():
6777
- return None
6778
- latest: dict | None = None
6779
- try:
6780
- for line in path.read_text(encoding="utf-8").splitlines():
6781
- if not line.strip():
6782
- continue
6783
- try:
6784
- record = json.loads(line)
6785
- except json.JSONDecodeError:
6786
- continue
6787
- if isinstance(record, dict) and record.get("type") == "handoff":
6788
- latest = record
6789
- except OSError:
6790
- return None
6791
- return latest
7013
+ return next((r for r in reversed(execution_records(root, task_id))
7014
+ if r.get("type") == "handoff"), None)
6792
7015
 
6793
7016
 
6794
7017
  def pending_handoff_record(root: Path, task_id: str) -> dict | None:
6795
- path = execution_log_path(root, task_id)
6796
- if not path.exists():
6797
- return None
6798
- latest_coordination: dict | None = None
6799
- try:
6800
- for line in path.read_text(encoding="utf-8").splitlines():
6801
- if not line.strip():
6802
- continue
6803
- try:
6804
- record = json.loads(line)
6805
- except json.JSONDecodeError:
6806
- continue
6807
- if isinstance(record, dict) and record.get("type") in {"handoff", "claim"}:
6808
- latest_coordination = record
6809
- except OSError:
6810
- return None
6811
- if latest_coordination and latest_coordination.get("type") == "handoff":
6812
- return latest_coordination
6813
- 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
6814
7021
 
6815
7022
 
6816
7023
  def assert_safe_task_id(task_id: str) -> None:
@@ -6939,12 +7146,12 @@ def snapshot_state(
6939
7146
  project_workflow_mode,
6940
7147
  session_workflow_mode,
6941
7148
  configured_workflow_mode,
6942
- project_tdd_enabled,
6943
- session_tdd_enabled,
6944
- effective_tdd_enabled,
6945
- project_tdd_coverage_threshold,
6946
- session_tdd_coverage_threshold,
6947
- effective_tdd_coverage_threshold,
7149
+ project_unit_test_mode,
7150
+ session_unit_test_mode,
7151
+ effective_unit_test_mode,
7152
+ project_ut_coverage_threshold,
7153
+ session_ut_coverage_threshold,
7154
+ effective_ut_coverage_threshold,
6948
7155
  ) = resolve_behavior(root, resolved_session)
6949
7156
  concrete_workflow_mode = None
6950
7157
  if task:
@@ -6952,26 +7159,26 @@ def snapshot_state(
6952
7159
  proposal = task.get("workflow_mode_proposal")
6953
7160
  if concrete_workflow_mode is None and isinstance(proposal, dict):
6954
7161
  concrete_workflow_mode = proposal.get("selected_mode")
6955
- task_tdd_enabled = task.get("tdd_enabled") if task else None
6956
- task_tdd_coverage_threshold = task.get("tdd_coverage_threshold") if task else None
6957
- frozen_tdd = bool(
7162
+ task_unit_test_mode = task.get("unit_test_mode") if task else None
7163
+ task_ut_coverage_threshold = task.get("ut_coverage_threshold") if task else None
7164
+ frozen_unit_test = bool(
6958
7165
  task
6959
7166
  and status not in {"ANALYSIS", "INIT"}
6960
- and isinstance(task_tdd_enabled, bool)
7167
+ and task_unit_test_mode in {"none", "ut", "tdd"}
6961
7168
  )
6962
7169
  is_tdd_init = bool(
6963
7170
  task and str(task.get("type") or "").strip().lower() == TDD_INIT_TASK_TYPE
6964
7171
  )
6965
- displayed_tdd_enabled = (
6966
- False if is_tdd_init else task_tdd_enabled if frozen_tdd else effective_tdd_enabled
7172
+ displayed_unit_test_mode = (
7173
+ "none" if is_tdd_init else task_unit_test_mode if frozen_unit_test else effective_unit_test_mode
6967
7174
  )
6968
- displayed_tdd_threshold = (
6969
- task_tdd_coverage_threshold
6970
- if frozen_tdd and isinstance(task_tdd_coverage_threshold, int)
6971
- else effective_tdd_coverage_threshold
7175
+ displayed_ut_threshold = (
7176
+ task_ut_coverage_threshold
7177
+ if frozen_unit_test and isinstance(task_ut_coverage_threshold, int)
7178
+ else effective_ut_coverage_threshold
6972
7179
  )
6973
7180
  should_check_readiness = bool(
6974
- effective_tdd_enabled or task_tdd_enabled is True or is_tdd_init
7181
+ effective_unit_test_mode in {"ut", "tdd"} or task_unit_test_mode in {"ut", "tdd"} or is_tdd_init
6975
7182
  )
6976
7183
  readiness = (
6977
7184
  tdd_readiness(root)
@@ -6979,8 +7186,16 @@ def snapshot_state(
6979
7186
  else {"status": "not_checked", "reasons": []}
6980
7187
  )
6981
7188
 
7189
+ layers = behavior_layers(root, resolved_session)
7190
+ local, _ = read_behavior_file(Path.home() / ".easy-coding" / "config.yaml")
6982
7191
  return {
6983
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,
6984
7199
  "current_task": str(task_id) if task_id else None,
6985
7200
  "task": task,
6986
7201
  "pending_transition": task.get("pending_transition") if task else None,
@@ -6998,19 +7213,19 @@ def snapshot_state(
6998
7213
  "session_workflow_mode": session_workflow_mode,
6999
7214
  "configured_workflow_mode": configured_workflow_mode,
7000
7215
  "concrete_workflow_mode": concrete_workflow_mode,
7001
- "project_tdd_enabled": project_tdd_enabled,
7002
- "session_tdd_enabled": session_tdd_enabled,
7003
- "effective_tdd_enabled": effective_tdd_enabled,
7004
- "project_tdd_coverage_threshold": project_tdd_coverage_threshold,
7005
- "session_tdd_coverage_threshold": session_tdd_coverage_threshold,
7006
- "effective_tdd_coverage_threshold": effective_tdd_coverage_threshold,
7007
- "task_tdd_enabled": task_tdd_enabled,
7008
- "task_tdd_coverage_threshold": task_tdd_coverage_threshold,
7216
+ "project_unit_test_mode": project_unit_test_mode,
7217
+ "session_unit_test_mode": session_unit_test_mode,
7218
+ "effective_unit_test_mode": effective_unit_test_mode,
7219
+ "project_ut_coverage_threshold": project_ut_coverage_threshold,
7220
+ "session_ut_coverage_threshold": session_ut_coverage_threshold,
7221
+ "effective_ut_coverage_threshold": effective_ut_coverage_threshold,
7222
+ "task_unit_test_mode": task_unit_test_mode,
7223
+ "task_ut_coverage_threshold": task_ut_coverage_threshold,
7009
7224
  "task_tdd_baselines": task.get("tdd_baselines") if task else None,
7010
- "displayed_tdd_enabled": displayed_tdd_enabled,
7011
- "displayed_tdd_coverage_threshold": displayed_tdd_threshold,
7012
- "tdd_readiness_status": readiness["status"],
7013
- "tdd_readiness_reasons": readiness["reasons"],
7225
+ "displayed_unit_test_mode": displayed_unit_test_mode,
7226
+ "displayed_ut_coverage_threshold": displayed_ut_threshold,
7227
+ "unit_test_readiness_status": readiness["status"],
7228
+ "unit_test_readiness_reasons": readiness["reasons"],
7014
7229
  "spec_summary": spec_task_summary(task),
7015
7230
  # Compatibility output aliases for pre-0.9 clients.
7016
7231
  "project_confirm_mode": project_approval_mode,
@@ -7027,8 +7242,9 @@ def build_status_line(
7027
7242
  session: dict,
7028
7243
  agent: str | None = None,
7029
7244
  session_file: str | Path | None = None,
7245
+ state: dict | None = None,
7030
7246
  ) -> str:
7031
- state = snapshot_state(root, session_file, session)
7247
+ state = state if state is not None else snapshot_state(root, session_file, session)
7032
7248
  if state["lite_mode"]:
7033
7249
  lite_state = (
7034
7250
  "Awaiting Confirmation"
@@ -7043,8 +7259,10 @@ def build_status_line(
7043
7259
  approval = str(state["effective_approval_mode"]).capitalize()
7044
7260
  workflow = str(state["concrete_workflow_mode"] or state["configured_workflow_mode"]).capitalize()
7045
7261
  status_brand = f"> **Easy Coding** · **Approval: {approval}** · **Workflow: {workflow}**"
7046
- if state["displayed_tdd_enabled"] is True:
7047
- status_brand += " · **TDD**"
7262
+ if state["effective_cooperate_mode"] == "dispatch":
7263
+ status_brand += " · **Dispatch**"
7264
+ if state["displayed_unit_test_mode"] in {"ut", "tdd"}:
7265
+ status_brand += f" · **{state['displayed_unit_test_mode'].upper()}**"
7048
7266
  task_id = state["current_task"]
7049
7267
  if task_id:
7050
7268
  status = str(state["status"])
@@ -7075,8 +7293,9 @@ def build_machine_breadcrumbs(
7075
7293
  session: dict,
7076
7294
  agent: str | None = None,
7077
7295
  session_file: str | Path | None = None,
7296
+ state: dict | None = None,
7078
7297
  ) -> list[str]:
7079
- state = snapshot_state(root, session_file, session)
7298
+ state = state if state is not None else snapshot_state(root, session_file, session)
7080
7299
  task_id = state["current_task"]
7081
7300
  task = state["task"]
7082
7301
  stage = str(state["status"]) if task else "idle"
@@ -7086,21 +7305,27 @@ def build_machine_breadcrumbs(
7086
7305
  f"[easy-coding:session-file:{resolved_session_file}]",
7087
7306
  f"[easy-coding:approval-mode:{state['effective_approval_mode']}]",
7088
7307
  f"[easy-coding:configured-workflow-mode:{state['configured_workflow_mode']}]",
7308
+ f"[easy-coding:cooperate-mode:{state['effective_cooperate_mode']}]",
7089
7309
  ]
7090
7310
  if state.get("concrete_workflow_mode"):
7091
7311
  lines.append(f"[easy-coding:workflow-mode:{state['concrete_workflow_mode']}]")
7092
- if state.get("displayed_tdd_enabled") is True:
7093
- lines.append("[easy-coding:tdd:enabled]")
7312
+ if state.get("displayed_unit_test_mode") in {"ut", "tdd"}:
7313
+ lines.append(f"[easy-coding:unit-test-mode:{state['displayed_unit_test_mode']}]")
7094
7314
  lines.append(
7095
- f"[easy-coding:tdd-coverage-threshold:{state['displayed_tdd_coverage_threshold']}]"
7315
+ f"[easy-coding:ut-coverage-threshold:{state['displayed_ut_coverage_threshold']}]"
7096
7316
  )
7097
7317
 
7098
7318
  if task_id:
7099
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']}]")
7100
7325
  if task and isinstance(task.get("spec_source"), dict):
7101
7326
  source = task["spec_source"]
7102
7327
  lines.append(f"[easy-coding:spec:{source.get('spec_id')}:revision:{source.get('revision')}]")
7103
- 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]")
7104
7329
  if task.get("spec_change"):
7105
7330
  lines.append("[easy-coding:spec-change:pending-sync-spec-design]")
7106
7331
  if (task.get("spec_writeback_progress") or {}).get("pending_action"):
@@ -7174,6 +7399,7 @@ def build_status_context(
7174
7399
  session: dict,
7175
7400
  agent: str | None = None,
7176
7401
  session_file: str | Path | None = None,
7402
+ state: dict | None = None,
7177
7403
  ) -> str:
7178
7404
  if session.get("harness_disabled") is True:
7179
7405
  session_path = resolve_session_path(root, session_file)
@@ -7194,10 +7420,11 @@ def build_status_context(
7194
7420
  if isinstance(proposal, dict):
7195
7421
  lines.append(f"[easy-coding:lite-proposal:{proposal.get('digest', 'missing')}]")
7196
7422
  return "\n".join(lines)
7423
+ state = state if state is not None else snapshot_state(root, session_file, session)
7197
7424
  return "\n".join(
7198
7425
  [
7199
- build_status_line(root, session, agent, session_file),
7200
- *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),
7201
7428
  ]
7202
7429
  )
7203
7430
 
@@ -7212,7 +7439,8 @@ def attach_status_context(
7212
7439
  session = load_session(root, resolved_session_file)
7213
7440
  if session is None:
7214
7441
  session = default_session()
7215
- 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)
7216
7444
  first_line = context.splitlines()[0] if context.startswith("> ") else ""
7217
7445
  enriched = dict(data)
7218
7446
  enriched["status_line"] = first_line
@@ -7422,42 +7650,43 @@ def clear_session_workflow_mode(
7422
7650
  return snapshot
7423
7651
 
7424
7652
 
7425
- def set_session_tdd(
7653
+ def set_session_unit_test_mode(
7426
7654
  root: Path,
7427
- enabled: bool,
7655
+ mode: str,
7428
7656
  agent: str,
7429
7657
  threshold: int | None = None,
7430
7658
  session_file: str | Path | None = None,
7431
7659
  ) -> dict:
7432
- if enabled:
7660
+ mode = parse_unit_test_mode(mode, "session unit_test_mode")
7661
+ if mode != "none":
7433
7662
  require_tdd_readiness(root)
7434
7663
  session = ensure_session(root, session_file)
7435
7664
  materialize_legacy_session_behavior(session)
7436
- session["tdd_enabled"] = enabled
7665
+ session["unit_test_mode"] = mode
7437
7666
  if threshold is not None:
7438
- session["tdd_coverage_threshold"] = parse_tdd_threshold(
7439
- threshold, "session tdd_coverage_threshold"
7667
+ session["ut_coverage_threshold"] = parse_ut_threshold(
7668
+ threshold, "session ut_coverage_threshold"
7440
7669
  )
7441
7670
  session["last_agent"] = agent
7442
7671
  write_session(root, session, session_file)
7443
7672
  snapshot = snapshot_state(root, session_file, session)
7444
- snapshot["action"] = "set-tdd"
7673
+ snapshot["action"] = "set-unit-test-mode"
7445
7674
  return snapshot
7446
7675
 
7447
7676
 
7448
- def clear_session_tdd(
7677
+ def clear_session_unit_test_mode(
7449
7678
  root: Path,
7450
7679
  agent: str,
7451
7680
  session_file: str | Path | None = None,
7452
7681
  ) -> dict:
7453
7682
  session = ensure_session(root, session_file)
7454
7683
  materialize_legacy_session_behavior(session)
7455
- session.pop("tdd_enabled", None)
7456
- session.pop("tdd_coverage_threshold", None)
7684
+ session.pop("unit_test_mode", None)
7685
+ session.pop("ut_coverage_threshold", None)
7457
7686
  session["last_agent"] = agent
7458
7687
  write_session(root, session, session_file)
7459
7688
  snapshot = snapshot_state(root, session_file, session)
7460
- snapshot["action"] = "clear-tdd"
7689
+ snapshot["action"] = "clear-unit-test-mode"
7461
7690
  return snapshot
7462
7691
 
7463
7692
 
@@ -7818,6 +8047,7 @@ def handoff_task(
7818
8047
  summary: str,
7819
8048
  task_id: str | None = None,
7820
8049
  session_file: str | Path | None = None,
8050
+ continuation: dict | None = None,
7821
8051
  ) -> dict:
7822
8052
  if not summary.strip():
7823
8053
  raise StateError("Handoff summary is required.")
@@ -7831,6 +8061,24 @@ def handoff_task(
7831
8061
  stage = str(task.get("status") or "PENDING")
7832
8062
  if stage in TERMINAL_STATUSES:
7833
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
7834
8082
 
7835
8083
  record = {
7836
8084
  "type": "handoff",
@@ -7838,6 +8086,7 @@ def handoff_task(
7838
8086
  "stage": stage,
7839
8087
  "summary": summary.strip(),
7840
8088
  "timestamp": now_iso(),
8089
+ **(continuation or {}),
7841
8090
  }
7842
8091
  append_execution_record(root, str(resolved_task_id), record)
7843
8092
  task["last_agent"] = agent
@@ -7873,6 +8122,9 @@ def claim_task(root: Path, task_id: str, agent: str, session_file: str | Path |
7873
8122
  else "takeover"
7874
8123
  )
7875
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)
7876
8128
  task["last_agent"] = agent
7877
8129
  write_task(root, task_id, task)
7878
8130
 
@@ -7889,7 +8141,8 @@ def claim_task(root: Path, task_id: str, agent: str, session_file: str | Path |
7889
8141
  "action": action,
7890
8142
  "timestamp": now_iso(),
7891
8143
  }
7892
- append_execution_record(root, task_id, claim)
8144
+ if not already_current:
8145
+ append_execution_record(root, task_id, claim)
7893
8146
 
7894
8147
  context = restore_spec_context(root, task_id, task, agent, session_file)
7895
8148
  snapshot = snapshot_state(root, session_file, session)
@@ -7900,9 +8153,32 @@ def claim_task(root: Path, task_id: str, agent: str, session_file: str | Path |
7900
8153
  snapshot["previous_agent"] = previous_agent
7901
8154
  snapshot["latest_handoff"] = latest_handoff
7902
8155
  snapshot["claim"] = claim
8156
+ snapshot["claim_recorded"] = not already_current
7903
8157
  return snapshot
7904
8158
 
7905
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
+
7906
8182
  def create_task(
7907
8183
  root: Path,
7908
8184
  task_id: str,
@@ -7933,6 +8209,8 @@ def create_task(
7933
8209
  "created_by": agent,
7934
8210
  "last_agent": agent,
7935
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))}},
7936
8214
  "context": {},
7937
8215
  "spawned_from": None,
7938
8216
  "spawned_tasks": [],
@@ -9035,6 +9313,7 @@ def sync_spec_design_state(
9035
9313
  progress.pop("pending_action", None)
9036
9314
  task.pop("spec_change", None)
9037
9315
  task.pop("spec_context", None)
9316
+ task.pop("spec_contexts", None)
9038
9317
  already_acknowledged = any(
9039
9318
  record.get("type") == "spec-design-sync"
9040
9319
  and record.get("idempotency_key") == idempotency_key
@@ -9143,6 +9422,7 @@ def writeback_ready_tasks_for_implement(
9143
9422
  agent: str,
9144
9423
  restart_statuses: set[str] | None = None,
9145
9424
  source_task_ids: set[str] | None = None,
9425
+ *, repair_id: str | None = None,
9146
9426
  ) -> None:
9147
9427
  inspection, _ = inspect_task_spec(root, task)
9148
9428
  implement_attempt = 1 + sum(
@@ -9170,11 +9450,15 @@ def writeback_ready_tasks_for_implement(
9170
9450
  f"{harness_task_id}:{source_task_id}:enter-implement:"
9171
9451
  f"{task['spec_source']['revision']}:attempt-{implement_attempt}"
9172
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")
9173
9457
  action = {
9174
9458
  "kind": "task",
9175
9459
  "source_task_id": source_task_id,
9176
9460
  "status": "in_progress",
9177
- "summary": "Harness entered IMPLEMENT for a dependency-ready Canonical task",
9461
+ "summary": summary,
9178
9462
  "evidence": [],
9179
9463
  "idempotency_key": key,
9180
9464
  "agent": agent,
@@ -9189,7 +9473,7 @@ def writeback_ready_tasks_for_implement(
9189
9473
  stored_spec_path(root, task),
9190
9474
  str(source_task_id),
9191
9475
  "in_progress",
9192
- "Harness entered IMPLEMENT for a dependency-ready Canonical task",
9476
+ summary,
9193
9477
  SPEC_WRITEBACK_APP,
9194
9478
  spec_writeback_agent(agent),
9195
9479
  design_digest,
@@ -9711,35 +9995,36 @@ def freeze_workflow_mode(
9711
9995
  task["workflow_mode_confirmed_by"] = agent
9712
9996
 
9713
9997
 
9714
- def freeze_tdd_mode(
9998
+ def freeze_unit_test_mode(
9715
9999
  root: Path, session: dict, task_id: str, task: dict, agent: str
9716
10000
  ) -> None:
9717
10001
  behavior = resolve_behavior(root, session)
9718
10002
  task_type = str(task.get("type") or "").strip().lower()
9719
- task["tdd_enabled"] = (
9720
- behavior[8] if task_type != TDD_INIT_TASK_TYPE else False
10003
+ task["unit_test_mode"] = (
10004
+ behavior[8] if task_type != TDD_INIT_TASK_TYPE else "none"
9721
10005
  )
9722
- task["tdd_coverage_threshold"] = behavior[11]
9723
- if task["tdd_enabled"] is True:
10006
+ task["ut_coverage_threshold"] = behavior[11]
10007
+ if task["unit_test_mode"] in {"ut", "tdd"}:
9724
10008
  require_tdd_readiness(root)
9725
10009
  plan = latest_execution_plan(root, task_id)
9726
10010
  if plan is None:
9727
- raise StateError("Cannot freeze TDD baseline without a valid execution plan.")
10011
+ raise StateError("Cannot freeze unit test baseline without a valid execution plan.")
9728
10012
  baselines = {
9729
10013
  key: git_head_sha(repository)
9730
10014
  for key, repository in tdd_repositories(root, task, plan).items()
9731
10015
  }
9732
- task_dir = task_json_path(root, task_id).parent
9733
- try:
9734
- dev_spec_content = (task_dir / "dev-spec.md").read_text(encoding="utf-8")
9735
- strategy_content = (task_dir / "test-strategy.md").read_text(encoding="utf-8")
9736
- except OSError as error:
9737
- raise StateError("Cannot freeze TDD without readable analysis artifacts.") from error
9738
- marker_reasons = tdd_baseline_marker_reasons(
9739
- dev_spec_content, strategy_content, baselines
9740
- )
9741
- if marker_reasons:
9742
- raise StateError("; ".join(marker_reasons))
10016
+ if task["unit_test_mode"] == "tdd":
10017
+ task_dir = task_json_path(root, task_id).parent
10018
+ try:
10019
+ dev_spec_content = (task_dir / "dev-spec.md").read_text(encoding="utf-8")
10020
+ strategy_content = (task_dir / "test-strategy.md").read_text(encoding="utf-8")
10021
+ except OSError as error:
10022
+ raise StateError("Cannot freeze TDD without readable analysis artifacts.") from error
10023
+ marker_reasons = tdd_baseline_marker_reasons(
10024
+ dev_spec_content, strategy_content, baselines
10025
+ )
10026
+ if marker_reasons:
10027
+ raise StateError("; ".join(marker_reasons))
9743
10028
  task["tdd_baselines"] = baselines
9744
10029
  else:
9745
10030
  task.pop("tdd_baselines", None)
@@ -9899,7 +10184,7 @@ def apply_transition(
9899
10184
  validate_analysis_readiness(root, resolved_task_id, session)
9900
10185
  if task.get("workflow_mode_legacy") is not True:
9901
10186
  freeze_workflow_mode(root, session, resolved_task_id, task, agent)
9902
- freeze_tdd_mode(root, session, resolved_task_id, task, agent)
10187
+ freeze_unit_test_mode(root, session, resolved_task_id, task, agent)
9903
10188
  repair_source_task_ids: set[str] | None = None
9904
10189
  quality_exit_outcome: str | None = None
9905
10190
  if previous == "QUALITY" and stage in {"IMPLEMENT", "ANALYSIS"}:
@@ -10611,13 +10896,13 @@ def main() -> int:
10611
10896
  clear_workflow_mode_parser = subcommands.add_parser("clear-workflow-mode", parents=[common])
10612
10897
  clear_workflow_mode_parser.add_argument("--agent", required=True)
10613
10898
 
10614
- set_tdd_parser = subcommands.add_parser("set-tdd", parents=[common])
10615
- set_tdd_parser.add_argument("--enabled", required=True, choices=["true", "false"])
10616
- set_tdd_parser.add_argument("--threshold", type=int)
10617
- set_tdd_parser.add_argument("--agent", required=True)
10899
+ set_unit_test_parser = subcommands.add_parser("set-unit-test-mode", parents=[common])
10900
+ set_unit_test_parser.add_argument("--mode", required=True, choices=["none", "ut", "tdd"])
10901
+ set_unit_test_parser.add_argument("--threshold", type=int)
10902
+ set_unit_test_parser.add_argument("--agent", required=True)
10618
10903
 
10619
- clear_tdd_parser = subcommands.add_parser("clear-tdd", parents=[common])
10620
- clear_tdd_parser.add_argument("--agent", required=True)
10904
+ clear_unit_test_parser = subcommands.add_parser("clear-unit-test-mode", parents=[common])
10905
+ clear_unit_test_parser.add_argument("--agent", required=True)
10621
10906
 
10622
10907
  # Compatibility aliases for pre-0.9 callers.
10623
10908
  set_confirm_mode_parser = subcommands.add_parser("set-confirm-mode", parents=[common])
@@ -10671,7 +10956,7 @@ def main() -> int:
10671
10956
  if name == "prepare-check":
10672
10957
  check_parser.add_argument("--record", required=True)
10673
10958
  else:
10674
- check_parser.add_argument("--prepared-id", required=True)
10959
+ check_parser.add_argument("--prepared-id")
10675
10960
  check_parser.add_argument("--result", required=True)
10676
10961
  correction_parser = subcommands.add_parser("begin-correction", parents=[common])
10677
10962
  correction_parser.add_argument("--file", action="append", required=True)
@@ -10680,6 +10965,21 @@ def main() -> int:
10680
10965
  correction_parser.add_argument("--agent", required=True)
10681
10966
  correction_parser.add_argument("--task-id")
10682
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
+
10683
10983
  finalize_quality_parser = subcommands.add_parser(
10684
10984
  "finalize-quality", parents=[common]
10685
10985
  )
@@ -10757,6 +11057,7 @@ def main() -> int:
10757
11057
  handoff.add_argument("--agent", required=True)
10758
11058
  handoff.add_argument("--summary", required=True)
10759
11059
  handoff.add_argument("--task-id")
11060
+ handoff.add_argument("--continuation", help="JSON next_action, unit_ids, evidence_refs and stop_after")
10760
11061
 
10761
11062
  claim = subcommands.add_parser("claim-task", parents=[common])
10762
11063
  claim.add_argument("--task-id", required=True)
@@ -11128,13 +11429,13 @@ def main() -> int:
11128
11429
  session_file,
11129
11430
  )
11130
11431
  )
11131
- elif command == "set-tdd":
11432
+ elif command == "set-unit-test-mode":
11132
11433
  emit(
11133
11434
  attach_status_context(
11134
11435
  root,
11135
- set_session_tdd(
11436
+ set_session_unit_test_mode(
11136
11437
  root,
11137
- args.enabled == "true",
11438
+ args.mode,
11138
11439
  agent,
11139
11440
  args.threshold,
11140
11441
  session_file,
@@ -11143,11 +11444,11 @@ def main() -> int:
11143
11444
  session_file,
11144
11445
  )
11145
11446
  )
11146
- elif command == "clear-tdd":
11447
+ elif command == "clear-unit-test-mode":
11147
11448
  emit(
11148
11449
  attach_status_context(
11149
11450
  root,
11150
- clear_session_tdd(root, agent, session_file),
11451
+ clear_session_unit_test_mode(root, agent, session_file),
11151
11452
  agent,
11152
11453
  session_file,
11153
11454
  )
@@ -11254,12 +11555,26 @@ def main() -> int:
11254
11555
  session, task_id, task = resolve_current_task(root, args.task_id, session_file)
11255
11556
  require_spec_context(root, task, agent, session_file)
11256
11557
  if command == "prepare-check":
11257
- 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))
11258
11561
  elif command == "record-check":
11259
- 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))
11260
11565
  else:
11261
11566
  result = begin_correction(root, task_id, task, args.file, args.summary, args.risk, agent)
11262
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))
11263
11578
  elif command == "finalize-quality":
11264
11579
  emit(
11265
11580
  attach_status_context(
@@ -11376,7 +11691,8 @@ def main() -> int:
11376
11691
  emit(
11377
11692
  attach_status_context(
11378
11693
  root,
11379
- 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),
11380
11696
  agent,
11381
11697
  session_file,
11382
11698
  )