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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +17 -15
  3. package/dist/cli.js +209 -195
  4. package/dist/cli.js.map +1 -1
  5. package/package.json +1 -1
  6. package/templates/claude/agents/ec-implementer.md +4 -0
  7. package/templates/claude/agents/ec-reviewer.md +6 -0
  8. package/templates/codex/agents/ec-implementer.toml +4 -0
  9. package/templates/codex/agents/ec-reviewer.toml +6 -0
  10. package/templates/common/bundled-skills/ec-init/SKILL.md +2 -2
  11. package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +6 -6
  12. package/templates/common/skills/ec-analysis/SKILL.md +56 -104
  13. package/templates/common/skills/ec-config/SKILL.md +37 -47
  14. package/templates/common/skills/ec-implementing/SKILL.md +26 -10
  15. package/templates/common/skills/ec-lite/SKILL.md +1 -1
  16. package/templates/common/skills/ec-memory/SKILL.md +3 -4
  17. package/templates/common/skills/ec-quality/SKILL.md +46 -25
  18. package/templates/common/skills/ec-task-management/SKILL.md +2 -2
  19. package/templates/common/skills/ec-tdd-init/SKILL.md +7 -7
  20. package/templates/common/skills/ec-workflow/SKILL.md +24 -22
  21. package/templates/main-constraint/AGENTS.md.tpl +23 -16
  22. package/templates/main-constraint/CLAUDE.md.tpl +23 -16
  23. package/templates/qoder/agents/ec-implementer.md +4 -0
  24. package/templates/qoder/agents/ec-reviewer.md +6 -0
  25. package/templates/runtime/tools/easy_coding_java_coverage.py +5 -5
  26. package/templates/shared-hooks/easy_coding_inputs.py +305 -0
  27. package/templates/shared-hooks/easy_coding_state.py +495 -461
  28. package/templates/shared-hooks/inject-subagent-context.py +3 -3
@@ -16,6 +16,10 @@ from datetime import datetime, timezone
16
16
  from pathlib import Path
17
17
  import sys
18
18
 
19
+ from easy_coding_inputs import (
20
+ evidence_operation, memo, digest, input_spec, capture, changed_inputs, command_covers,
21
+ )
22
+
19
23
  from easy_dev_spec import (
20
24
  EasyDevSpecError,
21
25
  inspect_manifest,
@@ -121,8 +125,8 @@ WIDE_WORKFLOW_CONTRACT_PATTERN = re.compile(
121
125
  )
122
126
  DEFAULT_APPROVAL_MODE = "guard"
123
127
  DEFAULT_WORKFLOW_MODE = "adaptive"
124
- DEFAULT_TDD_ENABLED = False
125
- DEFAULT_TDD_COVERAGE_THRESHOLD = 90
128
+ DEFAULT_UNIT_TEST_MODE = "none"
129
+ DEFAULT_UT_COVERAGE_THRESHOLD = 90
126
130
  TDD_READINESS_SCHEMA = "easy-coding/tdd-readiness-v1"
127
131
  TDD_READINESS_SCOPE = "changed-production-lines"
128
132
  TDD_READINESS_PATH = Path(".easy-coding/tdd/readiness.json")
@@ -454,7 +458,7 @@ def read_memory_config(root: Path) -> dict[str, int]:
454
458
  return config
455
459
 
456
460
 
457
- def parse_tdd_threshold(value: object, source: str) -> int:
461
+ def parse_ut_threshold(value: object, source: str) -> int:
458
462
  if isinstance(value, bool):
459
463
  raise StateError(f"Invalid {source}: expected an integer from 1 to 100.")
460
464
  try:
@@ -466,18 +470,13 @@ def parse_tdd_threshold(value: object, source: str) -> int:
466
470
  return threshold
467
471
 
468
472
 
469
- def parse_yaml_bool(value: str | None, source: str) -> bool:
470
- if value is None:
471
- return DEFAULT_TDD_ENABLED
472
- normalized = value.lower()
473
- if normalized in {"true", "yes", "on"}:
474
- return True
475
- if normalized in {"false", "no", "off"}:
476
- return False
477
- raise StateError(f"Invalid {source}: expected true or false.")
473
+ def parse_unit_test_mode(value: object, source: str) -> str:
474
+ if not isinstance(value, str) or value not in {"none", "ut", "tdd"}:
475
+ raise StateError(f"Invalid {source}: expected none, ut, or tdd.")
476
+ return str(value)
478
477
 
479
478
 
480
- def read_project_behavior(root: Path) -> tuple[str, str, bool, int]:
479
+ def read_project_behavior(root: Path) -> tuple[str, str, str, int]:
481
480
  path = root / ".easy-coding" / "config.yaml"
482
481
  try:
483
482
  lines = path.read_text(encoding="utf-8").splitlines()
@@ -485,8 +484,8 @@ def read_project_behavior(root: Path) -> tuple[str, str, bool, int]:
485
484
  return (
486
485
  DEFAULT_APPROVAL_MODE,
487
486
  DEFAULT_WORKFLOW_MODE,
488
- DEFAULT_TDD_ENABLED,
489
- DEFAULT_TDD_COVERAGE_THRESHOLD,
487
+ DEFAULT_UNIT_TEST_MODE,
488
+ DEFAULT_UT_COVERAGE_THRESHOLD,
490
489
  )
491
490
 
492
491
  in_behavior = False
@@ -538,16 +537,20 @@ def read_project_behavior(root: Path) -> tuple[str, str, bool, int]:
538
537
  "Invalid behavior.workflow_mode in .easy-coding/config.yaml: "
539
538
  "expected adaptive, fast, standard, or strict."
540
539
  )
541
- if schema_version >= 4:
542
- tdd_enabled = parse_yaml_bool(behavior.get("tdd_enabled"), "behavior.tdd_enabled")
543
- tdd_threshold = parse_tdd_threshold(
544
- behavior.get("tdd_coverage_threshold", DEFAULT_TDD_COVERAGE_THRESHOLD),
545
- "behavior.tdd_coverage_threshold",
540
+ if schema_version >= 6:
541
+ unit_test_mode = parse_unit_test_mode(
542
+ behavior.get("unit_test_mode", DEFAULT_UNIT_TEST_MODE), "behavior.unit_test_mode"
543
+ )
544
+ threshold = parse_ut_threshold(
545
+ behavior.get("ut_coverage_threshold", DEFAULT_UT_COVERAGE_THRESHOLD),
546
+ "behavior.ut_coverage_threshold",
546
547
  )
547
548
  else:
548
- tdd_enabled = DEFAULT_TDD_ENABLED
549
- tdd_threshold = DEFAULT_TDD_COVERAGE_THRESHOLD
550
- return approval_mode, workflow_mode, tdd_enabled, tdd_threshold
549
+ if schema_version >= 4:
550
+ raise StateError("Run easy-coding upgrade to migrate unit-test settings to schema 6.")
551
+ unit_test_mode = DEFAULT_UNIT_TEST_MODE
552
+ threshold = DEFAULT_UT_COVERAGE_THRESHOLD
553
+ return approval_mode, workflow_mode, unit_test_mode, threshold
551
554
 
552
555
 
553
556
  def safe_tdd_report_pattern(value: object) -> bool:
@@ -719,13 +722,13 @@ def require_tdd_readiness(root: Path) -> None:
719
722
 
720
723
  def resolve_behavior(
721
724
  root: Path, session: dict
722
- ) -> tuple[str, str | None, str, str, str | None, str, bool, bool | None, bool, int, int | None, int]:
723
- project_approval, project_workflow, project_tdd, project_threshold = read_project_behavior(root)
725
+ ) -> tuple[str, str | None, str, str, str | None, str, str, str | None, str, int, int | None, int]:
726
+ project_approval, project_workflow, project_unit_test, project_threshold = read_project_behavior(root)
724
727
  legacy = session.get("confirm_mode")
725
728
  session_approval = session.get("approval_mode")
726
729
  session_workflow = session.get("workflow_mode")
727
- session_tdd = session.get("tdd_enabled")
728
- session_threshold = session.get("tdd_coverage_threshold")
730
+ session_unit_test = session.get("unit_test_mode")
731
+ session_threshold = session.get("ut_coverage_threshold")
729
732
  if session_approval is None:
730
733
  if legacy == "lite":
731
734
  session_approval = "guard"
@@ -744,11 +747,11 @@ def resolve_behavior(
744
747
  raise StateError(
745
748
  "Invalid session workflow_mode: expected adaptive, fast, standard, or strict."
746
749
  )
747
- if session_tdd is not None and not isinstance(session_tdd, bool):
748
- raise StateError("Invalid session tdd_enabled: expected true or false.")
750
+ if session_unit_test is not None:
751
+ session_unit_test = parse_unit_test_mode(session_unit_test, "session unit_test_mode")
749
752
  if session_threshold is not None:
750
- session_threshold = parse_tdd_threshold(
751
- session_threshold, "session tdd_coverage_threshold"
753
+ session_threshold = parse_ut_threshold(
754
+ session_threshold, "session ut_coverage_threshold"
752
755
  )
753
756
  return (
754
757
  project_approval,
@@ -757,9 +760,9 @@ def resolve_behavior(
757
760
  project_workflow,
758
761
  str(session_workflow) if session_workflow else None,
759
762
  str(session_workflow or project_workflow),
760
- project_tdd,
761
- session_tdd,
762
- session_tdd if session_tdd is not None else project_tdd,
763
+ project_unit_test,
764
+ session_unit_test,
765
+ session_unit_test if session_unit_test is not None else project_unit_test,
763
766
  project_threshold,
764
767
  session_threshold,
765
768
  session_threshold if session_threshold is not None else project_threshold,
@@ -771,7 +774,22 @@ def resolve_approval_mode(root: Path, session: dict) -> tuple[str, str | None, s
771
774
  return behavior[0], behavior[1], behavior[2]
772
775
 
773
776
 
777
+ def migrate_unit_test_settings(record: dict) -> bool:
778
+ changed = False
779
+ if "tdd_enabled" in record:
780
+ enabled = record.pop("tdd_enabled")
781
+ if "unit_test_mode" not in record and isinstance(enabled, bool):
782
+ record["unit_test_mode"] = "tdd" if enabled else "none"
783
+ changed = True
784
+ if "tdd_coverage_threshold" in record:
785
+ threshold = record.pop("tdd_coverage_threshold")
786
+ record.setdefault("ut_coverage_threshold", threshold)
787
+ changed = True
788
+ return changed
789
+
790
+
774
791
  def materialize_legacy_session_behavior(session: dict) -> None:
792
+ migrate_unit_test_settings(session)
775
793
  legacy = session.get("confirm_mode")
776
794
  if legacy == "lite":
777
795
  session.setdefault("approval_mode", "guard")
@@ -1260,7 +1278,7 @@ def normalize_legacy_stage(stage: object) -> object:
1260
1278
  def normalize_legacy_task(task: dict) -> bool:
1261
1279
  """Normalize legacy task state without touching artifacts outside task.json."""
1262
1280
  legacy_status = str(task.get("status") or "")
1263
- changed = False
1281
+ changed = migrate_unit_test_settings(task)
1264
1282
 
1265
1283
  for field in ("created_by", "last_agent"):
1266
1284
  normalized_agent = canonical_agent_identity(
@@ -1825,13 +1843,42 @@ def is_valid_review_finding(value: object) -> bool:
1825
1843
  )
1826
1844
 
1827
1845
 
1846
+ def gate_identity(record: dict) -> tuple:
1847
+ return (str(record.get("source_task_id") or ""), str(record.get("unit_id") or ""),
1848
+ str(record.get("dimension") or record.get("check") or ""),
1849
+ str(record.get("review_scope") or record.get("coverage_scope") or ""))
1850
+
1851
+
1852
+ def failure_label(record: dict) -> str:
1853
+ _, unit, name, scope = gate_identity(record)
1854
+ label = f"{record['type']}:{name}"
1855
+ if scope:
1856
+ label += f":{scope}"
1857
+ if unit:
1858
+ label += f":unit={unit}"
1859
+ return label
1860
+
1861
+
1862
+ def require_unit_evidence(plan: dict, records: list[dict], label: str, dimensions: int = 0) -> None:
1863
+ if not any(record.get("unit_id") for record in records):
1864
+ return
1865
+ for unit in plan.get("units", []):
1866
+ applicable = [record for record in records
1867
+ if record.get("unit_id") == unit["id"] or
1868
+ (not record.get("unit_id") and
1869
+ (not record.get("source_task_id") or
1870
+ record["source_task_id"] == unit.get("source_task_id")))]
1871
+ if not applicable or (dimensions and len({r.get("dimension") for r in applicable}) < dimensions):
1872
+ raise StateError(f"{label} evidence does not cover Unit {unit['id']} at the required depth.")
1873
+
1874
+
1828
1875
  def validate_quality_gate_record_schemas(
1829
1876
  review_records: list[dict], verification_records: list[dict]
1830
1877
  ) -> None:
1831
1878
  latest_reviews: dict[tuple[str, str], dict] = {}
1832
1879
  for index, record in enumerate(review_records):
1833
1880
  dimension = str(record.get("dimension") or f"<missing-{index}>")
1834
- latest_reviews[(str(record.get("source_task_id") or ""), dimension)] = record
1881
+ latest_reviews[gate_identity(record)] = record
1835
1882
  for record in latest_reviews.values():
1836
1883
  findings = record.get("findings")
1837
1884
  if (
@@ -1859,13 +1906,7 @@ def validate_quality_gate_record_schemas(
1859
1906
  latest_verifications: dict[tuple[str, str, str], dict] = {}
1860
1907
  for index, record in enumerate(verification_records):
1861
1908
  check = str(record.get("check") or f"<missing-{index}>")
1862
- latest_verifications[
1863
- (
1864
- str(record.get("source_task_id") or ""),
1865
- check,
1866
- str(record.get("coverage_scope") or ""),
1867
- )
1868
- ] = record
1909
+ latest_verifications[gate_identity(record)] = record
1869
1910
  for record in latest_verifications.values():
1870
1911
  applicable = record.get("applicable") is not False
1871
1912
  if (
@@ -2249,6 +2290,34 @@ def require_spec_context(
2249
2290
  raise StateError("Current session must consume the bound Canonical Spec via resume-spec-context before advancing.")
2250
2291
 
2251
2292
 
2293
+ def refresh_correction_plan(root: Path, task_id: str, task: dict, inspection: dict) -> None:
2294
+ plan = latest_execution_plan(root, task_id)
2295
+ if plan is None:
2296
+ raise StateError("A correction must preserve its original execution plan.")
2297
+ selection = select_tasks(inspection, task["selected_spec_tasks"], allow_pending_hard_dependencies=True)
2298
+ steps = {s["step_id"]: s for s in selection["selected_steps"]}
2299
+ changes = {c["change_id"]: c for c in selection["selected_changes"]}
2300
+ tests = {t["test_id"]: t for t in selection["selected_tests"]}
2301
+ correction = task["correction"]
2302
+ plan["spec_design_sha256"] = inspection["design_sha256"]
2303
+ for unit in plan["units"]:
2304
+ if unit["id"] not in correction["unit_ids"]:
2305
+ continue
2306
+ source_steps = [steps[s] for s in unit["source_step_ids"] if s in steps]
2307
+ selected_changes = [changes[c] for s in source_steps for c in s.get("change_ids", [])]
2308
+ original_files = set(unit["files"])
2309
+ mapped_files = {c["path"] for c in selected_changes}
2310
+ if not mapped_files <= original_files:
2311
+ raise StateError("Source design expands the correction scope; analyze the new requirement.")
2312
+ unit["source_step_ids"] = [s["step_id"] for s in source_steps]
2313
+ unit["files"] = sorted(mapped_files | (original_files & set(correction["files"])))
2314
+ unit["symbols"] = sorted({symbol for c in selected_changes for symbol in c.get("symbols", [])})
2315
+ unit["test_commands"] = sorted({tests[t]["command"] for s in source_steps for t in s.get("test_ids", [])})
2316
+ unit["acceptance_criteria"] = [correction["summary"]]
2317
+ unit["contracts"] = [correction["summary"]]
2318
+ append_execution_record(root, task_id, plan)
2319
+
2320
+
2252
2321
  def begin_spec_change(
2253
2322
  root: Path, affected_task_ids: list[str], summary: str, agent: str,
2254
2323
  task_id: str | None = None, session_file: str | Path | None = None,
@@ -2375,7 +2444,7 @@ def is_valid_spec_execution_plan(root: Path, task: dict, plan: object) -> bool:
2375
2444
  if (
2376
2445
  not step_change_ids.issubset(change_by_id)
2377
2446
  or not step_test_ids.issubset(test_by_id)
2378
- or set(unit.get("files", [])) != step_files
2447
+ or (set(unit.get("files", [])) - set(task.get("correction", {}).get("files", []))) != (step_files - set(task.get("correction", {}).get("files", [])))
2379
2448
  or set(unit["symbols"]) != step_symbols
2380
2449
  or not set(unit["test_commands"]).issuperset(step_commands)
2381
2450
  ):
@@ -2397,9 +2466,10 @@ def is_valid_spec_execution_plan(root: Path, task: dict, plan: object) -> bool:
2397
2466
  steps = covered_steps[source_task_id]
2398
2467
  if len(steps) != len(set(steps)) or set(steps) != set(source_task.get("step_ids", [])):
2399
2468
  return False
2400
- if covered_files[source_task_id] != {
2469
+ restoration_files = set(task.get("correction", {}).get("files", []))
2470
+ if covered_files[source_task_id] - restoration_files != {
2401
2471
  str(change["path"]) for change in changes_by_task[source_task_id]
2402
- }:
2472
+ } - restoration_files:
2403
2473
  return False
2404
2474
  if covered_symbols[source_task_id] != {
2405
2475
  str(symbol)
@@ -2545,7 +2615,7 @@ def has_valid_execution_plan(root: Path, task_id: str) -> bool:
2545
2615
  return False
2546
2616
  if isinstance(record, dict) and record.get("type") == "plan":
2547
2617
  latest_plan = record
2548
- elif isinstance(record, dict) and record.get("type") == "spec-design-sync":
2618
+ elif isinstance(record, dict) and record.get("type") == "spec-design-sync" and not record.get("preserve_plan"):
2549
2619
  latest_plan = None
2550
2620
  except OSError:
2551
2621
  return False
@@ -2583,7 +2653,7 @@ def latest_execution_plan(root: Path, task_id: str) -> dict | None:
2583
2653
  for record in execution_records(root, task_id):
2584
2654
  if record.get("type") == "plan":
2585
2655
  latest = record
2586
- elif record.get("type") == "spec-design-sync":
2656
+ elif record.get("type") == "spec-design-sync" and not record.get("preserve_plan"):
2587
2657
  latest = None
2588
2658
  if latest is None or not is_valid_execution_plan(latest, allow_empty_files=True):
2589
2659
  return None
@@ -2833,7 +2903,7 @@ def tdd_baseline_marker_reasons(
2833
2903
  def contains_tdd_threshold(content: str, threshold: int) -> bool:
2834
2904
  return re.search(
2835
2905
  rf"(?<!\d){threshold}\s*%|--threshold(?:\s+|=){threshold}(?!\d)|"
2836
- rf"tdd_coverage_threshold\s*[:=]\s*{threshold}(?!\d)",
2906
+ rf"ut_coverage_threshold\s*[:=]\s*{threshold}(?!\d)",
2837
2907
  content,
2838
2908
  re.IGNORECASE,
2839
2909
  ) is not None
@@ -3076,220 +3146,176 @@ def tdd_infrastructure_fingerprint(repositories: set[Path]) -> str:
3076
3146
  return digest.hexdigest()
3077
3147
 
3078
3148
 
3149
+ 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
+ ))
3153
+
3154
+
3155
+ def prepare_check(root: Path, task_id: str, task: dict, descriptor: dict, agent: str) -> dict:
3156
+ if task.get("status") not in {"IMPLEMENT", "QUALITY"}:
3157
+ raise StateError("Checks belong to IMPLEMENT or QUALITY.")
3158
+ if descriptor.get("type") not in {"review", "verify"}:
3159
+ raise StateError("A check must identify type review or verify.")
3160
+ if descriptor.get("type") == "verify" and not is_non_empty_string(descriptor.get("command")):
3161
+ raise StateError("Verification must identify the actual command before execution.")
3162
+ plan = latest_execution_plan(root, task_id) or {}
3163
+ if descriptor.get("unit_id"):
3164
+ owner = next((u for u in plan.get("units", []) if u["id"] == descriptor["unit_id"]), None)
3165
+ if owner is None:
3166
+ raise StateError("Check unit does not belong to the current plan.")
3167
+ for field in ("repo_id", "source_task_id"):
3168
+ if field in owner:
3169
+ descriptor[field] = owner[field]
3170
+ inputs = capture(input_spec(root, task, plan, descriptor))
3171
+ records = execution_records(root, task_id)
3172
+ previous = next((r for r in reversed(records)
3173
+ if check_identity(r) == check_identity(descriptor)), None)
3174
+ if previous and previous.get("passed") is True and previous.get("inputs") == inputs:
3175
+ return {"reusable": True, "evidence_index": records.index(previous),
3176
+ "input_signature": inputs["signature"], "changed_inputs": []}
3177
+ prepared_id = digest([descriptor, inputs["signature"]])
3178
+ if not any(r.get("prepared_id") == prepared_id and r.get("type") == "check-inputs" for r in records):
3179
+ append_execution_record(root, task_id, {
3180
+ "type": "check-inputs", "prepared_id": prepared_id, "descriptor": descriptor,
3181
+ "inputs": inputs, "timestamp": now_iso(), "agent": agent,
3182
+ })
3183
+ return {"reusable": False, "prepared_id": prepared_id,
3184
+ "input_signature": inputs["signature"],
3185
+ "changed_inputs": changed_inputs(previous["inputs"], inputs)
3186
+ if previous and isinstance(previous.get("inputs"), dict) else ["no matching input-bound evidence"]}
3187
+
3188
+
3189
+ def record_check(root: Path, task_id: str, task: dict, prepared_id: str, result: dict, agent: str) -> dict:
3190
+ prepared = next((r for r in reversed(execution_records(root, task_id))
3191
+ if r.get("type") == "check-inputs" and r.get("prepared_id") == prepared_id), None)
3192
+ if prepared is None:
3193
+ raise StateError("Prepare the check before executing it.")
3194
+ descriptor = prepared["descriptor"]
3195
+ current = capture(input_spec(root, task, latest_execution_plan(root, task_id) or {}, descriptor))
3196
+ changes = changed_inputs(prepared["inputs"], current)
3197
+ if changes:
3198
+ raise StateError("Check inputs changed during execution: " + "; ".join(changes))
3199
+ if type(result.get("passed")) is not bool:
3200
+ raise StateError("Check result must include passed.")
3201
+ if descriptor["type"] == "verify" and result.get("applicable") is not False:
3202
+ if type(result.get("exit_code")) is not int or result["passed"] != (result["exit_code"] == 0):
3203
+ raise StateError("Verification passed must agree with its real exit_code.")
3204
+ context = None
3205
+ if task.get("status") == "QUALITY":
3206
+ context = ensure_quality_attempt_context(root, task_id, task, agent, persist=True)
3207
+ elif task.get("status") != "IMPLEMENT":
3208
+ raise StateError("Record checks only during implementation or quality.")
3209
+ record = {
3210
+ **result, **descriptor, **evidence_fingerprints(root, task_id),
3211
+ "inputs": current, "prepared_id": prepared_id, "timestamp": now_iso(),
3212
+ "quality_attempt": context["attempt"] if context else 0,
3213
+ }
3214
+ append_execution_record(root, task_id, record)
3215
+ return {"recorded": True, "passed": record["passed"], "input_signature": current["signature"]}
3216
+
3217
+
3218
+ def carry_forward_scoped_evidence(root: Path, task_id: str, task: dict, context: dict) -> None:
3219
+ plan = latest_execution_plan(root, task_id) or {}
3220
+ latest = {}
3221
+ for index, record in enumerate(execution_records(root, task_id)):
3222
+ if record.get("type") in {"review", "verify"}:
3223
+ latest[check_identity(record)] = (index, record)
3224
+ for index, record in latest.values():
3225
+ if record.get("passed") is not True or not isinstance(record.get("inputs"), dict):
3226
+ continue
3227
+ if any(f.get("severity") == "error" for f in record.get("findings", [])):
3228
+ continue
3229
+ if record.get("unit_id") and not any(u["id"] == record["unit_id"] for u in plan.get("units", [])):
3230
+ continue
3231
+ current = capture(input_spec(root, task, plan, record))
3232
+ if current != record["inputs"]:
3233
+ continue
3234
+ if record.get("quality_attempt") == context["attempt"]:
3235
+ continue
3236
+ # 仅运行时生成引用,保留原始执行时间与输入;Agent 不重新包装历史结论。
3237
+ append_execution_record(root, task_id, {
3238
+ **record, "reused_from": index, "quality_attempt": context["attempt"],
3239
+ "implementation_fingerprint": context["implementation_fingerprint"],
3240
+ "config_fingerprint": context["config_fingerprint"],
3241
+ })
3242
+
3243
+
3244
+ def begin_correction(root: Path, task_id: str, task: dict, files: list[str], summary: str,
3245
+ risks: list[str], agent: str) -> dict:
3246
+ if task.get("status") not in {"IMPLEMENT", "QUALITY", "MEMORY", "ANALYSIS"}:
3247
+ raise StateError("A correction needs an active implementation task.")
3248
+ plan = latest_execution_plan(root, task_id) or {}
3249
+ units = plan.get("units", [])
3250
+ allowed = {f for unit in units for f in unit.get("files", [])}
3251
+ if not files or not set(files) <= allowed or not summary.strip():
3252
+ raise StateError("A correction must name existing task files and the confirmed change.")
3253
+ cancel_active_quality_attempt(root, task_id, task, agent, summary, "manual-return")
3254
+ task = load_task(root, task_id) or task
3255
+ cleanup_verification_checkpoint(root, task_id, task)
3256
+ task["correction"] = {
3257
+ "files": sorted(set(files)), "summary": summary.strip(), "risks": risks,
3258
+ "unit_ids": [u["id"] for u in units if set(u.get("files", [])) & set(files)],
3259
+ "started_at": now_iso(),
3260
+ }
3261
+ records = validated_quality_records(root, task_id)
3262
+ if records:
3263
+ task["quality_consumed_attempt"] = records[-1][1]["attempt"]
3264
+ task.pop("quality_return_required", None)
3265
+ task.pop("pending_transition", None)
3266
+ task["status"] = "IMPLEMENT"
3267
+ append_stage_history(task, "IMPLEMENT", agent)
3268
+ write_task(root, task_id, task)
3269
+ task["workflow_mode"], reasons = calculate_workflow_floor(root, task_id)
3270
+ write_task(root, task_id, task)
3271
+ if isinstance(task.get("spec_source"), dict):
3272
+ writeback_ready_tasks_for_implement(root, task_id, task, agent, source_task_ids={
3273
+ str(u["source_task_id"]) for u in units if u["id"] in task["correction"]["unit_ids"]
3274
+ })
3275
+ append_execution_record(root, task_id, {
3276
+ "type": "correction", **task["correction"], "workflow_mode": task["workflow_mode"],
3277
+ })
3278
+ return {"task_id": task_id, "status": "IMPLEMENT", "workflow_mode": task["workflow_mode"],
3279
+ "reasons": reasons, "correction": task["correction"]}
3280
+
3281
+
3079
3282
  def implementation_fingerprint(root: Path, task_id: str) -> str:
3080
3283
  plan = latest_execution_plan(root, task_id)
3081
3284
  if not plan:
3082
3285
  raise StateError("Cannot calculate implementation fingerprint without a valid plan.")
3083
- task = load_task(root, task_id)
3084
- workflow_mode = str(task.get("workflow_mode") or "") if task else ""
3085
- digest = hashlib.sha256()
3086
- digest.update(b"workflow-mode\0")
3087
- digest.update(workflow_mode.encode("utf-8"))
3088
- digest.update(b"\0")
3089
- if task and task.get("tdd_enabled") is True:
3090
- digest.update(b"tdd\0enabled\0")
3091
- digest.update(str(task.get("tdd_coverage_threshold") or "").encode("utf-8"))
3092
- digest.update(b"\0")
3093
- digest.update(
3094
- json.dumps(
3095
- task.get("tdd_baselines") or {},
3096
- sort_keys=True,
3097
- separators=(",", ":"),
3098
- ).encode("utf-8")
3099
- )
3100
- digest.update(b"\0")
3101
- digest.update(tdd_infrastructure_fingerprint(
3102
- {root.resolve(), *task_repository_roots(root, task, plan)}
3103
- ).encode("ascii"))
3104
- digest.update(b"execution-plan\0")
3105
- digest.update(
3106
- json.dumps(
3107
- plan,
3108
- ensure_ascii=False,
3109
- sort_keys=True,
3110
- separators=(",", ":"),
3111
- ).encode("utf-8")
3112
- )
3113
- digest.update(b"\0")
3114
- if task and isinstance(task.get("spec_source"), dict):
3115
- digest.update(b"canonical-spec\0")
3116
- source = task.get("spec_source") or {}
3117
- digest.update(
3118
- json.dumps(
3119
- {
3120
- "source": {
3121
- "schema": source.get("schema"),
3122
- "spec_id": source.get("spec_id"),
3123
- "revision": source.get("revision"),
3124
- "design_sha256": source.get("design_sha256"),
3125
- },
3126
- "selected_tasks": task.get("selected_spec_tasks"),
3127
- },
3128
- ensure_ascii=False,
3129
- sort_keys=True,
3130
- separators=(",", ":"),
3131
- ).encode("utf-8")
3132
- )
3133
- digest.update(b"\0")
3134
- update_git_worktree_fingerprint(digest, root, task, plan)
3135
- repo_paths = task.get("repo_paths") if task else None
3136
- file_entries: set[tuple[str, str | None]] = {
3137
- (str(file_name), str(unit.get("repo_id")) if unit.get("repo_id") else None)
3138
- for unit in plan.get("units", [])
3139
- if isinstance(unit, dict)
3140
- for file_name in unit.get("files", [])
3141
- if is_non_empty_string(file_name)
3142
- }
3143
- for file_name, repo_id in sorted(file_entries, key=lambda item: (item[0], item[1] or "")):
3144
- candidate = Path(file_name)
3145
- was_absolute = candidate.is_absolute()
3146
- base = root
3147
- if (
3148
- task
3149
- and isinstance(task.get("spec_source"), dict)
3150
- and isinstance(repo_paths, dict)
3151
- and repo_id
3152
- and is_non_empty_string(repo_paths.get(repo_id))
3153
- ):
3154
- raw_base = Path(str(repo_paths[repo_id]))
3155
- base = raw_base if raw_base.is_absolute() else root / raw_base
3156
- if not was_absolute:
3157
- candidate = base / candidate
3158
- resolved = candidate.resolve()
3159
- if not was_absolute:
3160
- try:
3161
- resolved.relative_to(base.resolve())
3162
- except ValueError as error:
3163
- raise StateError(f"Execution plan file escapes repository: {file_name}") from error
3164
- digest.update(f"{repo_id or ''}:{file_name}".encode("utf-8"))
3165
- digest.update(b"\0")
3166
- try:
3167
- digest.update(resolved.read_bytes())
3168
- except OSError:
3169
- digest.update(b"<missing>")
3170
- digest.update(b"\0")
3171
- return digest.hexdigest()
3286
+ task = load_task(root, task_id) or {}
3287
+ # 候选只绑定实际输入与验收契约,执行状态、revision、模式和计划说明不参与。
3288
+ return capture(input_spec(root, task, plan, {"type": "review"}))["signature"]
3172
3289
 
3173
3290
 
3174
- def canonical_repository_fingerprints(
3175
- root: Path, task_id: str, task: dict
3176
- ) -> dict[str, str]:
3291
+ def canonical_repository_fingerprints(root: Path, task_id: str, task: dict) -> dict[str, str]:
3177
3292
  if not isinstance(task.get("spec_source"), dict):
3178
3293
  return {}
3179
3294
  plan = latest_execution_plan(root, task_id) or {}
3180
- repo_paths = task.get("repo_paths") if isinstance(task.get("repo_paths"), dict) else {}
3181
- fingerprints: dict[str, str] = {}
3182
- for repo_id in sorted(
3183
- {
3184
- str(unit.get("repo_id"))
3185
- for unit in plan.get("units", [])
3186
- if isinstance(unit, dict) and is_non_empty_string(unit.get("repo_id"))
3187
- }
3188
- ):
3189
- raw_base = repo_paths.get(repo_id)
3190
- if not is_non_empty_string(raw_base):
3191
- continue
3192
- base = Path(str(raw_base))
3193
- if not base.is_absolute():
3194
- base = root / base
3195
- base = base.resolve()
3196
- digest = hashlib.sha256()
3197
- if task.get("tdd_enabled") is True:
3198
- digest.update(tdd_infrastructure_fingerprint({root.resolve(), base}).encode("ascii"))
3199
- units = [
3200
- unit
3201
- for unit in plan.get("units", [])
3202
- if isinstance(unit, dict) and unit.get("repo_id") == repo_id
3203
- ]
3204
- digest.update(
3205
- json.dumps(
3206
- units,
3207
- ensure_ascii=False,
3208
- sort_keys=True,
3209
- separators=(",", ":"),
3210
- ).encode("utf-8")
3211
- )
3212
- digest.update(b"\0")
3213
- repository = git_repository_root(base)
3214
- if repository is not None and repository.resolve() == base:
3215
- update_git_repository_content_fingerprint(
3216
- digest,
3217
- root,
3218
- repository,
3219
- [base],
3220
- set(),
3221
- )
3222
- else:
3223
- for unit in units:
3224
- for file_name in sorted(
3225
- str(value)
3226
- for value in unit.get("files", [])
3227
- if is_non_empty_string(value)
3228
- ):
3229
- candidate = (base / file_name).resolve()
3230
- try:
3231
- candidate.relative_to(base)
3232
- except ValueError as error:
3233
- raise StateError(
3234
- f"Execution plan file escapes repository: {file_name}"
3235
- ) from error
3236
- digest.update(file_name.encode("utf-8"))
3237
- digest.update(b"\0")
3238
- try:
3239
- digest.update(candidate.read_bytes())
3240
- except OSError:
3241
- digest.update(b"<missing>")
3242
- digest.update(b"\0")
3243
- fingerprints[repo_id] = digest.hexdigest()
3244
- return fingerprints
3295
+ return {
3296
+ repo: digest([capture(input_spec(root, task, plan, {
3297
+ "type": "review", "unit_id": unit["id"]
3298
+ }))["signature"] for unit in plan.get("units", []) if unit.get("repo_id") == repo])
3299
+ for repo in sorted({unit["repo_id"] for unit in plan.get("units", [])})
3300
+ }
3245
3301
 
3246
3302
 
3247
- def config_without_frozen_tdd_settings(payload: bytes) -> bytes:
3248
- """任务冻结 TDD 契约后,从证据指纹中排除仅影响未来任务的实时 TDD 配置。"""
3249
- try:
3250
- lines = payload.decode("utf-8").splitlines(keepends=True)
3251
- except UnicodeDecodeError:
3252
- return payload
3253
- filtered: list[str] = []
3254
- in_behavior = False
3255
- behavior_indent = 0
3256
- behavior_key_indent: int | None = None
3257
- for line in lines:
3258
- clean = line.split("#", 1)[0].rstrip()
3259
- stripped = clean.strip()
3260
- indent = len(clean) - len(clean.lstrip(" "))
3261
- if stripped == "behavior:":
3262
- in_behavior = True
3263
- behavior_indent = indent
3264
- behavior_key_indent = None
3265
- filtered.append(line)
3266
- continue
3267
- if in_behavior and stripped and indent <= behavior_indent:
3268
- in_behavior = False
3269
- if in_behavior and stripped:
3270
- if behavior_key_indent is None:
3271
- behavior_key_indent = indent
3272
- key = stripped.split(":", 1)[0]
3273
- if (
3274
- indent == behavior_key_indent
3275
- and key in {"tdd_enabled", "tdd_coverage_threshold"}
3276
- ):
3277
- continue
3278
- filtered.append(line)
3279
- return "".join(filtered).encode("utf-8")
3303
+ def unit_test_contract(task: dict) -> dict:
3304
+ mode = task.get("unit_test_mode")
3305
+ # 旧证据的序列化键保持不变,配置字段改名不触发全局失效;UT 单独标识。
3306
+ contract = {
3307
+ "tdd_enabled": None if mode is None else mode != "none",
3308
+ "tdd_coverage_threshold": task.get("ut_coverage_threshold"),
3309
+ "tdd_baselines": task.get("tdd_baselines"),
3310
+ }
3311
+ if mode == "ut":
3312
+ contract["unit_test_mode"] = "ut"
3313
+ return contract
3280
3314
 
3281
3315
 
3282
3316
  def behavior_config_fingerprint(root: Path, task: dict | None = None) -> str:
3283
- path = root / ".easy-coding" / "config.yaml"
3284
- digest = hashlib.sha256()
3285
- try:
3286
- payload = path.read_bytes()
3287
- if task and isinstance(task.get("tdd_enabled"), bool):
3288
- payload = config_without_frozen_tdd_settings(payload)
3289
- digest.update(payload)
3290
- except OSError:
3291
- digest.update(b"<missing-config>")
3292
- return digest.hexdigest()
3317
+ # 审批方式、记忆策略等配置不影响已经执行的测试。
3318
+ return digest(unit_test_contract(task or {}))
3293
3319
 
3294
3320
 
3295
3321
  def evidence_fingerprints(root: Path, task_id: str) -> dict[str, str]:
@@ -3599,6 +3625,7 @@ def ensure_quality_attempt_context(
3599
3625
  )
3600
3626
  if (
3601
3627
  finalized.get("outcome") in {"passed", "repair", "replan"}
3628
+ and finalized.get("attempt") != task.get("quality_consumed_attempt")
3602
3629
  and finalized.get("implementation_fingerprint")
3603
3630
  == expected["implementation_fingerprint"]
3604
3631
  and finalized.get("config_fingerprint") == expected["config_fingerprint"]
@@ -3612,6 +3639,7 @@ def ensure_quality_attempt_context(
3612
3639
  )
3613
3640
  if persist:
3614
3641
  append_canonical_quality_carry_forward(root, task_id, task, context, agent)
3642
+ carry_forward_scoped_evidence(root, task_id, task, context)
3615
3643
  task["quality_attempt"] = context
3616
3644
  task["last_agent"] = agent
3617
3645
  write_task(root, task_id, task)
@@ -3888,19 +3916,9 @@ def finalize_quality_attempt(
3888
3916
  latest_failure_records: dict[tuple[str, str], dict] = {}
3889
3917
  for record in [*current_reviews, *current_verifications]:
3890
3918
  owner = str(record.get("source_task_id")) if canonical else task_id
3891
- if record.get("type") == "review" and is_non_empty_string(
3892
- record.get("dimension")
3893
- ):
3894
- label = f"review:{record['dimension']}"
3895
- elif record.get("type") == "verify" and is_non_empty_string(
3896
- record.get("check")
3897
- ):
3898
- coverage_scope = str(record.get("coverage_scope") or "")
3899
- label = f"verify:{record['check']}"
3900
- if coverage_scope:
3901
- label = f"{label}:{coverage_scope}"
3902
- else:
3919
+ if record.get("type") not in {"review", "verify"}:
3903
3920
  continue
3921
+ label = failure_label(record)
3904
3922
  latest_failure_records[(owner, label)] = record
3905
3923
  evidence_failure_classes: set[str] = set()
3906
3924
  for owner, labels in failures.items():
@@ -4212,12 +4230,10 @@ def verification_contract_fingerprint(root: Path, task_id: str, task: dict) -> s
4212
4230
  source = task.get("spec_source") if isinstance(task.get("spec_source"), dict) else {}
4213
4231
  contract = {
4214
4232
  "workflow_mode": task.get("workflow_mode"),
4215
- "tdd_enabled": task.get("tdd_enabled"),
4216
- "tdd_coverage_threshold": task.get("tdd_coverage_threshold"),
4217
- "tdd_baselines": task.get("tdd_baselines"),
4233
+ **unit_test_contract(task),
4218
4234
  **({"tdd_infrastructure": tdd_infrastructure_fingerprint(
4219
4235
  {root.resolve(), *task_repository_roots(root, task, plan)}
4220
- )} if task.get("tdd_enabled") is True else {}),
4236
+ )} if task.get("unit_test_mode") in {"ut", "tdd"} else {}),
4221
4237
  "plan": plan,
4222
4238
  "canonical": {
4223
4239
  "schema": source.get("schema"),
@@ -4981,8 +4997,10 @@ def validate_spec_implementation_results(root: Path, task_id: str, task: dict) -
4981
4997
  default=-1,
4982
4998
  )
4983
4999
  lifecycle_by_unit: dict[str, list[dict]] = {unit_id: [] for unit_id in unit_by_id}
4984
- for record in records[latest_plan_index + 1 :]:
5000
+ for index, record in enumerate(records):
4985
5001
  unit_id = str(record.get("unit_id") or "")
5002
+ if index <= latest_plan_index and unit_id in task.get("correction", {}).get("unit_ids", list(unit_by_id)):
5003
+ continue
4986
5004
  if record.get("type") in {"dispatch", "result"} and unit_id in unit_by_id:
4987
5005
  lifecycle_by_unit[unit_id].append(record)
4988
5006
  missing_dispatches = sorted(
@@ -5065,7 +5083,7 @@ def validate_review_readiness(
5065
5083
  ):
5066
5084
  dimension = str(record["dimension"])
5067
5085
  source_task_id = str(record.get("source_task_id") or "")
5068
- record_key = f"{dimension}\0{source_task_id}" if is_spec_task else dimension
5086
+ record_key = gate_identity(record)
5069
5087
  latest_by_dimension[record_key] = record
5070
5088
  if not latest_by_dimension:
5071
5089
  raise StateError(
@@ -5121,6 +5139,9 @@ def validate_review_readiness(
5121
5139
  "Canonical Spec review evidence does not cover selected source tasks: "
5122
5140
  + ", ".join(missing_review_tasks)
5123
5141
  )
5142
+ require_unit_evidence(latest_execution_plan(root, task_id) or {},
5143
+ list(latest_by_dimension.values()), "Review",
5144
+ 2 if task.get("workflow_mode") == "strict" else 1)
5124
5145
  has_failed_dimension = False
5125
5146
  for record in latest_by_dimension.values():
5126
5147
  findings = record.get("findings")
@@ -5136,7 +5157,7 @@ def validate_review_readiness(
5136
5157
  raise StateError(
5137
5158
  "QUALITY cannot advance while a review dimension is not passed or has error findings."
5138
5159
  )
5139
- if task.get("tdd_enabled") is True:
5160
+ if task.get("unit_test_mode") == "tdd":
5140
5161
  if is_spec_task:
5141
5162
  missing_tdd_reviews = sorted(
5142
5163
  source_task_id
@@ -5167,7 +5188,7 @@ def validate_review_readiness(
5167
5188
  "Strict Canonical Spec review requires at least two passed dimensions for "
5168
5189
  "every selected source task: " + ", ".join(missing_strict_dimensions)
5169
5190
  )
5170
- elif len(latest_by_dimension) < 2:
5191
+ elif len({record["dimension"] for record in latest_by_dimension.values()}) < 2:
5171
5192
  raise StateError(
5172
5193
  "Strict workflow requires at least two passed review dimensions for the current implementation fingerprint."
5173
5194
  )
@@ -5208,17 +5229,13 @@ def validate_verification_readiness(
5208
5229
  and is_non_empty_string(record.get("check"))
5209
5230
  ):
5210
5231
  if (
5211
- task.get("tdd_enabled") is True
5232
+ task.get("unit_test_mode") in {"ut", "tdd"}
5212
5233
  and record.get("check_type") == "coverage"
5213
5234
  and record.get("coverage_scope") == "gitlab"
5214
5235
  ):
5215
5236
  # 远程 CI 只作为生成的自动化能力,历史 pending/failed 记录不再参与本地验收。
5216
5237
  continue
5217
- check = str(record["check"])
5218
- if task.get("tdd_enabled") is True and record.get("check_type") == "coverage":
5219
- check = f"{check}\0{record.get('coverage_scope') or ''}"
5220
- if is_spec_task:
5221
- check = f"{check}\0{record.get('source_task_id') or ''}"
5238
+ check = gate_identity(record)
5222
5239
  previous = latest_by_check.get(check)
5223
5240
  if (
5224
5241
  record.get("applicable") is False
@@ -5273,6 +5290,7 @@ def validate_verification_readiness(
5273
5290
  applicable_records = [
5274
5291
  record for record in latest_by_check.values() if record.get("applicable") is not False
5275
5292
  ]
5293
+ require_unit_evidence(latest_execution_plan(root, task_id) or {}, applicable_records, "Verification")
5276
5294
  if not applicable_records:
5277
5295
  raise StateError(
5278
5296
  "QUALITY cannot advance to MEMORY without at least one applicable executed check."
@@ -5314,13 +5332,13 @@ def validate_verification_readiness(
5314
5332
  "TDD initialization cannot advance to MEMORY until readiness passes: "
5315
5333
  + "; ".join(str(reason) for reason in readiness["reasons"])
5316
5334
  )
5317
- if task.get("tdd_enabled") is not True and any(
5335
+ if task.get("unit_test_mode") not in {"ut", "tdd"} and any(
5318
5336
  record.get("check_type") == "coverage" for record in latest_by_check.values()
5319
5337
  ):
5320
5338
  raise StateError(
5321
- "Coverage verification evidence is not allowed when the frozen TDD mode is off."
5339
+ "Coverage verification evidence is not allowed when the frozen unit test mode is none."
5322
5340
  )
5323
- if task.get("tdd_enabled") is True:
5341
+ if task.get("unit_test_mode") in {"ut", "tdd"}:
5324
5342
  require_tdd_readiness(root)
5325
5343
  test_records = [
5326
5344
  record
@@ -5335,7 +5353,7 @@ def validate_verification_readiness(
5335
5353
  ]
5336
5354
  if not coverage_records:
5337
5355
  raise StateError(
5338
- "TDD verification requires changed-production-line JaCoCo coverage evidence."
5356
+ "Unit test verification requires changed-production-line JaCoCo coverage evidence."
5339
5357
  )
5340
5358
  if is_spec_task:
5341
5359
  tested_source_tasks = {
@@ -5346,7 +5364,7 @@ def validate_verification_readiness(
5346
5364
  )
5347
5365
  if missing_test_tasks:
5348
5366
  raise StateError(
5349
- "TDD Canonical verification requires local unit-test evidence for every selected source task: "
5367
+ "Unit test Canonical verification requires local unit-test evidence for every selected source task: "
5350
5368
  + ", ".join(missing_test_tasks)
5351
5369
  )
5352
5370
  covered_source_tasks = {
@@ -5357,33 +5375,33 @@ def validate_verification_readiness(
5357
5375
  )
5358
5376
  if missing_coverage_tasks:
5359
5377
  raise StateError(
5360
- "TDD Canonical verification requires separate coverage evidence for every selected source task: "
5378
+ "Unit test Canonical verification requires separate coverage evidence for every selected source task: "
5361
5379
  + ", ".join(missing_coverage_tasks)
5362
5380
  )
5363
5381
  elif not test_records:
5364
5382
  raise StateError(
5365
- "TDD verification requires passed local unit-test evidence."
5383
+ "Unit test verification requires passed local unit-test evidence."
5366
5384
  )
5367
5385
  for record in coverage_records:
5368
5386
  scope = str(record.get("coverage_scope") or "")
5369
5387
  if scope != "local":
5370
5388
  raise StateError(
5371
- "TDD coverage evidence must identify coverage_scope as local."
5389
+ "Unit test coverage evidence must identify coverage_scope as local."
5372
5390
  )
5373
- expected_threshold = task.get("tdd_coverage_threshold")
5391
+ expected_threshold = task.get("ut_coverage_threshold")
5374
5392
  expected_baselines = task.get("tdd_baselines")
5375
5393
  if (
5376
5394
  type(expected_threshold) is not int
5377
5395
  or expected_threshold < 1
5378
5396
  or expected_threshold > 100
5379
5397
  ):
5380
- raise StateError("TDD task is missing a valid frozen coverage threshold.")
5398
+ raise StateError("Unit test task is missing a valid frozen coverage threshold.")
5381
5399
  if not isinstance(expected_baselines, dict) or not expected_baselines:
5382
- raise StateError("TDD task is missing frozen Git baselines.")
5400
+ raise StateError("Unit test task is missing frozen Git baselines.")
5383
5401
  for record in coverage_records:
5384
5402
  coverage = record.get("coverage")
5385
5403
  if not isinstance(coverage, dict):
5386
- raise StateError("TDD coverage evidence must include the coverage result object.")
5404
+ raise StateError("Unit test coverage evidence must include the coverage result object.")
5387
5405
  total = coverage.get("total_lines")
5388
5406
  covered = coverage.get("covered_lines")
5389
5407
  percentage = coverage.get("percentage")
@@ -5419,7 +5437,7 @@ def validate_verification_readiness(
5419
5437
  )
5420
5438
  ):
5421
5439
  raise StateError(
5422
- "TDD coverage evidence must preserve the exact gate command, baseline, counts, percentage, frozen threshold, reports, and report fingerprint."
5440
+ "Unit test coverage evidence must preserve the exact gate command, baseline, counts, percentage, frozen threshold, reports, and report fingerprint."
5423
5441
  )
5424
5442
  if total == 0:
5425
5443
  if record.get("applicable") is not False or record.get("passed") is not True:
@@ -5428,7 +5446,7 @@ def validate_verification_readiness(
5428
5446
  )
5429
5447
  elif abs(percentage - round(covered * 100.0 / total, 2)) > 0.01:
5430
5448
  raise StateError(
5431
- "TDD coverage evidence percentage does not match covered/total counts."
5449
+ "Unit test coverage evidence percentage does not match covered/total counts."
5432
5450
  )
5433
5451
  elif (
5434
5452
  record.get("applicable") is False
@@ -5436,7 +5454,7 @@ def validate_verification_readiness(
5436
5454
  or percentage < threshold
5437
5455
  ):
5438
5456
  raise StateError(
5439
- f"TDD changed-line coverage must meet the frozen {threshold}% threshold."
5457
+ f"Unit test changed-line coverage must meet the frozen {threshold}% threshold."
5440
5458
  )
5441
5459
  if task.get("workflow_mode") == "strict":
5442
5460
  if is_spec_task:
@@ -5506,7 +5524,10 @@ def validate_verification_readiness(
5506
5524
  for record in applicable_records
5507
5525
  if is_non_empty_string(record.get("command"))
5508
5526
  }
5509
- missing_commands = sorted(required_test_commands - executed_commands)
5527
+ missing_commands = sorted(required for required in required_test_commands if not any(
5528
+ actual[:2] == required[:2] and command_covers(actual[2], required[2])
5529
+ for actual in executed_commands
5530
+ ))
5510
5531
  if missing_commands:
5511
5532
  raise StateError(
5512
5533
  "Canonical Spec verification is missing source test commands: "
@@ -5575,7 +5596,7 @@ def quality_repair_failures_for_window(
5575
5596
  and is_non_empty_string(unit.get("source_task_id"))
5576
5597
  and is_non_empty_string(unit.get("repo_id"))
5577
5598
  }
5578
- fingerprints = evidence_fingerprints(root, task_id)
5599
+ fingerprints = evidence_fingerprints(root, task_id) if not (implementation_fingerprint_value and config_fingerprint_value) else {}
5579
5600
  implementation = (
5580
5601
  implementation_fingerprint_value or fingerprints["implementation_fingerprint"]
5581
5602
  )
@@ -5640,12 +5661,12 @@ def quality_repair_failures_for_window(
5640
5661
  )
5641
5662
  ):
5642
5663
  owner = source_task_id if canonical else task_id
5643
- latest_reviews[(owner, str(record["dimension"]))] = record
5664
+ latest_reviews[(owner, *gate_identity(record)[1:])] = record
5644
5665
  elif record_type == "verify" and record.get(
5645
5666
  "implementation_fingerprint"
5646
5667
  ) == implementation and record.get("config_fingerprint") == config:
5647
5668
  if (
5648
- task.get("tdd_enabled") is True
5669
+ task.get("unit_test_mode") in {"ut", "tdd"}
5649
5670
  and record.get("check_type") == "coverage"
5650
5671
  and record.get("coverage_scope") == "gitlab"
5651
5672
  ):
@@ -5669,16 +5690,10 @@ def quality_repair_failures_for_window(
5669
5690
  ):
5670
5691
  continue
5671
5692
  owner = source_task_id if canonical else task_id
5672
- latest_verifications[
5673
- (
5674
- owner,
5675
- str(record["check"]),
5676
- str(record.get("coverage_scope") or ""),
5677
- )
5678
- ] = record
5693
+ latest_verifications[(owner, *gate_identity(record)[1:])] = record
5679
5694
 
5680
5695
  failures: dict[str, list[str]] = {}
5681
- for (source_task_id, dimension), record in latest_reviews.items():
5696
+ for (source_task_id, unit_id, dimension, _scope), record in latest_reviews.items():
5682
5697
  findings = record.get("findings")
5683
5698
  has_error = isinstance(findings, list) and any(
5684
5699
  isinstance(finding, dict)
@@ -5686,13 +5701,10 @@ def quality_repair_failures_for_window(
5686
5701
  for finding in findings
5687
5702
  )
5688
5703
  if record.get("passed") is not True or has_error:
5689
- failures.setdefault(source_task_id, []).append(f"review:{dimension}")
5690
- for (source_task_id, check, scope), record in latest_verifications.items():
5704
+ failures.setdefault(source_task_id, []).append(failure_label(record))
5705
+ for (source_task_id, unit_id, check, scope), record in latest_verifications.items():
5691
5706
  if record.get("applicable") is not False and record.get("passed") is not True:
5692
- label = f"verify:{check}"
5693
- if scope:
5694
- label = f"{label}:{scope}"
5695
- failures.setdefault(source_task_id, []).append(label)
5707
+ failures.setdefault(source_task_id, []).append(failure_label(record))
5696
5708
  return failures
5697
5709
 
5698
5710
 
@@ -6419,9 +6431,27 @@ def validate_analysis_readiness(
6419
6431
  test_strategy = task_dir / "test-strategy.md"
6420
6432
  reasons: list[str] = []
6421
6433
  behavior = resolve_behavior(root, session or default_session())
6422
- tdd_enabled = behavior[8] if task_type != TDD_INIT_TASK_TYPE else False
6434
+ unit_test_mode = behavior[8] if task_type != TDD_INIT_TASK_TYPE else "none"
6423
6435
  tdd_threshold = behavior[11]
6424
6436
 
6437
+ if unit_test_mode == "ut":
6438
+ require_tdd_readiness(root)
6439
+ plan = latest_execution_plan(root, task_id) or {}
6440
+ if not any(str(file).endswith(".java") for unit in plan.get("units", []) for file in unit.get("files", [])):
6441
+ reasons.append("UT is enabled but the confirmed implementation scope has no Java source")
6442
+ if reasons:
6443
+ raise StateError("; ".join(reasons))
6444
+
6445
+ if dev_spec.is_file() and unit_test_mode != "tdd":
6446
+ compact = dev_spec.read_text(encoding="utf-8")
6447
+ if compact.startswith("<!-- easy-coding:compact -->"):
6448
+ mode, _ = calculate_workflow_floor(root, task_id)
6449
+ if mode != "fast" or not has_valid_execution_plan(root, task_id):
6450
+ raise StateError("Compact analysis requires a valid Fast implementation plan.")
6451
+ if re.findall(r"^decision_status:\s*(\w+)\s*$", compact, re.MULTILINE) != ["closed"]:
6452
+ raise StateError("Compact analysis must record the confirmed scope.")
6453
+ return
6454
+
6425
6455
  dev_spec_content = ""
6426
6456
  if not dev_spec.exists():
6427
6457
  reasons.append("dev-spec.md is missing")
@@ -6528,7 +6558,7 @@ def validate_analysis_readiness(
6528
6558
  plan_is_valid = has_valid_execution_plan(root, task_id)
6529
6559
  if not plan_is_valid:
6530
6560
  reasons.append("execution.jsonl has no valid plan record")
6531
- if tdd_enabled:
6561
+ if unit_test_mode == "tdd":
6532
6562
  readiness = tdd_readiness(root)
6533
6563
  if readiness["status"] != "ready":
6534
6564
  reasons.append(
@@ -6591,6 +6621,8 @@ def validate_analysis_readiness(
6591
6621
  dev_spec_content, strategy_content, baselines
6592
6622
  )
6593
6623
  )
6624
+ elif unit_test_mode == "ut":
6625
+ pass
6594
6626
  elif task_type == TDD_INIT_TASK_TYPE:
6595
6627
  try:
6596
6628
  strategy_content = test_strategy.read_text(encoding="utf-8")
@@ -6940,12 +6972,12 @@ def snapshot_state(
6940
6972
  project_workflow_mode,
6941
6973
  session_workflow_mode,
6942
6974
  configured_workflow_mode,
6943
- project_tdd_enabled,
6944
- session_tdd_enabled,
6945
- effective_tdd_enabled,
6946
- project_tdd_coverage_threshold,
6947
- session_tdd_coverage_threshold,
6948
- effective_tdd_coverage_threshold,
6975
+ project_unit_test_mode,
6976
+ session_unit_test_mode,
6977
+ effective_unit_test_mode,
6978
+ project_ut_coverage_threshold,
6979
+ session_ut_coverage_threshold,
6980
+ effective_ut_coverage_threshold,
6949
6981
  ) = resolve_behavior(root, resolved_session)
6950
6982
  concrete_workflow_mode = None
6951
6983
  if task:
@@ -6953,26 +6985,26 @@ def snapshot_state(
6953
6985
  proposal = task.get("workflow_mode_proposal")
6954
6986
  if concrete_workflow_mode is None and isinstance(proposal, dict):
6955
6987
  concrete_workflow_mode = proposal.get("selected_mode")
6956
- task_tdd_enabled = task.get("tdd_enabled") if task else None
6957
- task_tdd_coverage_threshold = task.get("tdd_coverage_threshold") if task else None
6958
- frozen_tdd = bool(
6988
+ task_unit_test_mode = task.get("unit_test_mode") if task else None
6989
+ task_ut_coverage_threshold = task.get("ut_coverage_threshold") if task else None
6990
+ frozen_unit_test = bool(
6959
6991
  task
6960
6992
  and status not in {"ANALYSIS", "INIT"}
6961
- and isinstance(task_tdd_enabled, bool)
6993
+ and task_unit_test_mode in {"none", "ut", "tdd"}
6962
6994
  )
6963
6995
  is_tdd_init = bool(
6964
6996
  task and str(task.get("type") or "").strip().lower() == TDD_INIT_TASK_TYPE
6965
6997
  )
6966
- displayed_tdd_enabled = (
6967
- False if is_tdd_init else task_tdd_enabled if frozen_tdd else effective_tdd_enabled
6998
+ displayed_unit_test_mode = (
6999
+ "none" if is_tdd_init else task_unit_test_mode if frozen_unit_test else effective_unit_test_mode
6968
7000
  )
6969
- displayed_tdd_threshold = (
6970
- task_tdd_coverage_threshold
6971
- if frozen_tdd and isinstance(task_tdd_coverage_threshold, int)
6972
- else effective_tdd_coverage_threshold
7001
+ displayed_ut_threshold = (
7002
+ task_ut_coverage_threshold
7003
+ if frozen_unit_test and isinstance(task_ut_coverage_threshold, int)
7004
+ else effective_ut_coverage_threshold
6973
7005
  )
6974
7006
  should_check_readiness = bool(
6975
- effective_tdd_enabled or task_tdd_enabled is True or is_tdd_init
7007
+ effective_unit_test_mode in {"ut", "tdd"} or task_unit_test_mode in {"ut", "tdd"} or is_tdd_init
6976
7008
  )
6977
7009
  readiness = (
6978
7010
  tdd_readiness(root)
@@ -6999,19 +7031,19 @@ def snapshot_state(
6999
7031
  "session_workflow_mode": session_workflow_mode,
7000
7032
  "configured_workflow_mode": configured_workflow_mode,
7001
7033
  "concrete_workflow_mode": concrete_workflow_mode,
7002
- "project_tdd_enabled": project_tdd_enabled,
7003
- "session_tdd_enabled": session_tdd_enabled,
7004
- "effective_tdd_enabled": effective_tdd_enabled,
7005
- "project_tdd_coverage_threshold": project_tdd_coverage_threshold,
7006
- "session_tdd_coverage_threshold": session_tdd_coverage_threshold,
7007
- "effective_tdd_coverage_threshold": effective_tdd_coverage_threshold,
7008
- "task_tdd_enabled": task_tdd_enabled,
7009
- "task_tdd_coverage_threshold": task_tdd_coverage_threshold,
7034
+ "project_unit_test_mode": project_unit_test_mode,
7035
+ "session_unit_test_mode": session_unit_test_mode,
7036
+ "effective_unit_test_mode": effective_unit_test_mode,
7037
+ "project_ut_coverage_threshold": project_ut_coverage_threshold,
7038
+ "session_ut_coverage_threshold": session_ut_coverage_threshold,
7039
+ "effective_ut_coverage_threshold": effective_ut_coverage_threshold,
7040
+ "task_unit_test_mode": task_unit_test_mode,
7041
+ "task_ut_coverage_threshold": task_ut_coverage_threshold,
7010
7042
  "task_tdd_baselines": task.get("tdd_baselines") if task else None,
7011
- "displayed_tdd_enabled": displayed_tdd_enabled,
7012
- "displayed_tdd_coverage_threshold": displayed_tdd_threshold,
7013
- "tdd_readiness_status": readiness["status"],
7014
- "tdd_readiness_reasons": readiness["reasons"],
7043
+ "displayed_unit_test_mode": displayed_unit_test_mode,
7044
+ "displayed_ut_coverage_threshold": displayed_ut_threshold,
7045
+ "unit_test_readiness_status": readiness["status"],
7046
+ "unit_test_readiness_reasons": readiness["reasons"],
7015
7047
  "spec_summary": spec_task_summary(task),
7016
7048
  # Compatibility output aliases for pre-0.9 clients.
7017
7049
  "project_confirm_mode": project_approval_mode,
@@ -7044,8 +7076,8 @@ def build_status_line(
7044
7076
  approval = str(state["effective_approval_mode"]).capitalize()
7045
7077
  workflow = str(state["concrete_workflow_mode"] or state["configured_workflow_mode"]).capitalize()
7046
7078
  status_brand = f"> **Easy Coding** · **Approval: {approval}** · **Workflow: {workflow}**"
7047
- if state["displayed_tdd_enabled"] is True:
7048
- status_brand += " · **TDD**"
7079
+ if state["displayed_unit_test_mode"] in {"ut", "tdd"}:
7080
+ status_brand += f" · **{state['displayed_unit_test_mode'].upper()}**"
7049
7081
  task_id = state["current_task"]
7050
7082
  if task_id:
7051
7083
  status = str(state["status"])
@@ -7090,10 +7122,10 @@ def build_machine_breadcrumbs(
7090
7122
  ]
7091
7123
  if state.get("concrete_workflow_mode"):
7092
7124
  lines.append(f"[easy-coding:workflow-mode:{state['concrete_workflow_mode']}]")
7093
- if state.get("displayed_tdd_enabled") is True:
7094
- lines.append("[easy-coding:tdd:enabled]")
7125
+ if state.get("displayed_unit_test_mode") in {"ut", "tdd"}:
7126
+ lines.append(f"[easy-coding:unit-test-mode:{state['displayed_unit_test_mode']}]")
7095
7127
  lines.append(
7096
- f"[easy-coding:tdd-coverage-threshold:{state['displayed_tdd_coverage_threshold']}]"
7128
+ f"[easy-coding:ut-coverage-threshold:{state['displayed_ut_coverage_threshold']}]"
7097
7129
  )
7098
7130
 
7099
7131
  if task_id:
@@ -7423,42 +7455,43 @@ def clear_session_workflow_mode(
7423
7455
  return snapshot
7424
7456
 
7425
7457
 
7426
- def set_session_tdd(
7458
+ def set_session_unit_test_mode(
7427
7459
  root: Path,
7428
- enabled: bool,
7460
+ mode: str,
7429
7461
  agent: str,
7430
7462
  threshold: int | None = None,
7431
7463
  session_file: str | Path | None = None,
7432
7464
  ) -> dict:
7433
- if enabled:
7465
+ mode = parse_unit_test_mode(mode, "session unit_test_mode")
7466
+ if mode != "none":
7434
7467
  require_tdd_readiness(root)
7435
7468
  session = ensure_session(root, session_file)
7436
7469
  materialize_legacy_session_behavior(session)
7437
- session["tdd_enabled"] = enabled
7470
+ session["unit_test_mode"] = mode
7438
7471
  if threshold is not None:
7439
- session["tdd_coverage_threshold"] = parse_tdd_threshold(
7440
- threshold, "session tdd_coverage_threshold"
7472
+ session["ut_coverage_threshold"] = parse_ut_threshold(
7473
+ threshold, "session ut_coverage_threshold"
7441
7474
  )
7442
7475
  session["last_agent"] = agent
7443
7476
  write_session(root, session, session_file)
7444
7477
  snapshot = snapshot_state(root, session_file, session)
7445
- snapshot["action"] = "set-tdd"
7478
+ snapshot["action"] = "set-unit-test-mode"
7446
7479
  return snapshot
7447
7480
 
7448
7481
 
7449
- def clear_session_tdd(
7482
+ def clear_session_unit_test_mode(
7450
7483
  root: Path,
7451
7484
  agent: str,
7452
7485
  session_file: str | Path | None = None,
7453
7486
  ) -> dict:
7454
7487
  session = ensure_session(root, session_file)
7455
7488
  materialize_legacy_session_behavior(session)
7456
- session.pop("tdd_enabled", None)
7457
- session.pop("tdd_coverage_threshold", None)
7489
+ session.pop("unit_test_mode", None)
7490
+ session.pop("ut_coverage_threshold", None)
7458
7491
  session["last_agent"] = agent
7459
7492
  write_session(root, session, session_file)
7460
7493
  snapshot = snapshot_state(root, session_file, session)
7461
- snapshot["action"] = "clear-tdd"
7494
+ snapshot["action"] = "clear-unit-test-mode"
7462
7495
  return snapshot
7463
7496
 
7464
7497
 
@@ -8592,7 +8625,8 @@ def reconcile_local_result_evidence(
8592
8625
  and check.get("passed") is True
8593
8626
  and is_non_empty_string(check.get("command"))
8594
8627
  }
8595
- missing_unit_commands = sorted(set(unit.get("test_commands", [])) - passed_commands)
8628
+ missing_unit_commands = sorted(command for command in unit.get("test_commands", [])
8629
+ if not any(command_covers(actual, command) for actual in passed_commands))
8596
8630
  if missing_unit_commands:
8597
8631
  unresolved.append(
8598
8632
  f"{unit_id}:missing-passed-command=" + ",".join(missing_unit_commands)
@@ -8630,7 +8664,7 @@ def reconcile_local_result_evidence(
8630
8664
  missing_commands = [
8631
8665
  str(test.get("command"))
8632
8666
  for test in tests
8633
- if str(test.get("command")) not in passed_commands
8667
+ if not any(command_covers(actual, str(test.get("command"))) for actual in passed_commands)
8634
8668
  ]
8635
8669
  if missing_commands:
8636
8670
  unresolved.append(
@@ -9035,23 +9069,29 @@ def sync_spec_design_state(
9035
9069
  progress.pop("pending_action", None)
9036
9070
  task.pop("spec_change", None)
9037
9071
  task.pop("spec_context", None)
9038
- if task.get("status") not in {"INIT", "ANALYSIS"}:
9039
- cleanup_verification_checkpoint(root, resolved_task_id, task)
9040
- task["status"] = "ANALYSIS"
9041
- append_stage_history(task, "ANALYSIS", agent)
9042
- task.pop("pending_transition", None)
9043
- task["last_agent"] = agent
9044
9072
  already_acknowledged = any(
9045
9073
  record.get("type") == "spec-design-sync"
9046
9074
  and record.get("idempotency_key") == idempotency_key
9047
9075
  for record in execution_records(root, resolved_task_id)
9048
9076
  )
9077
+ if task.get("correction") and not already_acknowledged:
9078
+ refresh_correction_plan(root, resolved_task_id, task, inspection)
9079
+ if task.get("correction") and not already_acknowledged:
9080
+ task["status"] = "IMPLEMENT"
9081
+ append_stage_history(task, "IMPLEMENT", agent)
9082
+ if not task.get("correction") and task.get("status") not in {"INIT", "ANALYSIS"}:
9083
+ cleanup_verification_checkpoint(root, resolved_task_id, task)
9084
+ task["status"] = "ANALYSIS"
9085
+ append_stage_history(task, "ANALYSIS", agent)
9086
+ task.pop("pending_transition", None)
9087
+ task["last_agent"] = agent
9049
9088
  if not already_acknowledged:
9050
9089
  append_execution_record(
9051
9090
  root,
9052
9091
  resolved_task_id,
9053
9092
  {
9054
9093
  "type": "spec-design-sync",
9094
+ "preserve_plan": bool(task.get("correction")),
9055
9095
  "affected_task_ids": requested_task_ids,
9056
9096
  "event_id": event["event_id"],
9057
9097
  "design_sha256": details["design_sha256"],
@@ -9061,6 +9101,10 @@ def sync_spec_design_state(
9061
9101
  },
9062
9102
  )
9063
9103
  write_task(root, resolved_task_id, task)
9104
+ if task.get("correction"):
9105
+ writeback_ready_tasks_for_implement(root, resolved_task_id, task, agent,
9106
+ source_task_ids=set(requested_task_ids),
9107
+ restart_statuses={"not_started"})
9064
9108
  snapshot = snapshot_state(root, session_file, session)
9065
9109
  snapshot["action"] = "sync-spec-design"
9066
9110
  return snapshot
@@ -9297,7 +9341,7 @@ def writeback_verified_tasks(
9297
9341
  if record.get("passed") is True
9298
9342
  and str(record.get("source_task_id") or "") == source_task_id
9299
9343
  and str(record.get("repo_id") or "") == repo_id
9300
- and str(record.get("command") or "") == command
9344
+ and command_covers(str(record.get("command") or ""), command)
9301
9345
  ),
9302
9346
  None,
9303
9347
  )
@@ -9569,52 +9613,16 @@ def resolve_current_task(
9569
9613
 
9570
9614
 
9571
9615
  def validate_workflow_mode_proposal(
9572
- root: Path,
9573
- session: dict,
9574
- proposal: object,
9575
- task_id: str | None = None,
9616
+ root: Path, session: dict, proposal: object, task_id: str | None = None,
9576
9617
  ) -> dict:
9577
- if not isinstance(proposal, dict):
9578
- raise StateError("workflow_mode_proposal is missing.")
9579
- configured = str(proposal.get("configured_mode") or "")
9580
- selected = str(proposal.get("selected_mode") or "")
9581
- minimum = str(proposal.get("minimum_mode") or "")
9582
- source = str(proposal.get("source") or "")
9583
- reasons = proposal.get("reasons")
9584
- effective_configured = resolve_behavior(root, session)[5]
9585
- if configured != effective_configured:
9586
- raise StateError(
9587
- "Workflow proposal configured_mode no longer matches the effective project/session setting."
9588
- )
9589
- if configured not in CONFIGURED_WORKFLOW_MODES:
9590
- raise StateError("Invalid configured workflow mode.")
9591
- if selected not in WORKFLOW_MODES or minimum not in WORKFLOW_MODES:
9592
- raise StateError("selected_mode and minimum_mode must be fast, standard, or strict.")
9593
- if source not in {"project", "session", "adaptive", "user", "migration"}:
9594
- raise StateError("Invalid workflow proposal source.")
9595
- if not is_string_list(reasons, allow_empty=False):
9596
- raise StateError("Workflow proposal reasons must contain at least one non-empty reason.")
9597
- required_rank = WORKFLOW_MODE_RANK[minimum]
9598
- if configured in WORKFLOW_MODES and WORKFLOW_MODE_RANK[minimum] < WORKFLOW_MODE_RANK[configured]:
9599
- raise StateError(
9600
- f"Workflow minimum {minimum} is below configured floor {configured}."
9601
- )
9602
- if task_id:
9603
- calculated_minimum, calculated_reasons = calculate_workflow_floor(root, task_id)
9604
- calculated_rank = WORKFLOW_MODE_RANK[calculated_minimum]
9605
- if WORKFLOW_MODE_RANK[minimum] < calculated_rank:
9606
- raise StateError(
9607
- f"Workflow minimum {minimum} is below calculated floor {calculated_minimum}: "
9608
- + ", ".join(calculated_reasons)
9609
- )
9610
- required_rank = max(required_rank, calculated_rank)
9611
- if configured in WORKFLOW_MODES:
9612
- required_rank = max(required_rank, WORKFLOW_MODE_RANK[configured])
9613
- if WORKFLOW_MODE_RANK[selected] < required_rank:
9614
- raise StateError(
9615
- f"Workflow mode {selected} is below the allowed minimum for this task."
9616
- )
9617
- return proposal
9618
+ if task_id is None:
9619
+ raise StateError("A task is required to calculate the mechanical workflow mode.")
9620
+ mode, reasons = calculate_workflow_floor(root, task_id)
9621
+ return {
9622
+ "configured_mode": resolve_behavior(root, session)[5],
9623
+ "selected_mode": mode, "minimum_mode": mode, "source": "adaptive",
9624
+ "reasons": reasons,
9625
+ }
9618
9626
 
9619
9627
 
9620
9628
  def calculate_workflow_floor(root: Path, task_id: str) -> tuple[str, list[str]]:
@@ -9626,6 +9634,12 @@ def calculate_workflow_floor(root: Path, task_id: str) -> tuple[str, list[str]]:
9626
9634
  if not plan:
9627
9635
  raise StateError("Cannot calculate workflow floor without a valid execution plan.")
9628
9636
  units = [unit for unit in plan.get("units", []) if isinstance(unit, dict)]
9637
+ correction = task.get("correction")
9638
+ if isinstance(correction, dict):
9639
+ files = set(correction["files"])
9640
+ units = [{**unit, "files": sorted(set(unit.get("files", [])) & files),
9641
+ "risks": correction.get("risks", []), "contracts": []}
9642
+ for unit in units if unit["id"] in correction["unit_ids"]]
9629
9643
  missing_local_baseline = [
9630
9644
  str(unit.get("id") or "<unknown>")
9631
9645
  for unit in units
@@ -9642,7 +9656,7 @@ def calculate_workflow_floor(root: Path, task_id: str) -> tuple[str, list[str]]:
9642
9656
  for file_name in unit.get("files", [])
9643
9657
  if is_non_empty_string(file_name)
9644
9658
  }
9645
- repositories = workflow_plan_repository_roots(root, task, plan)
9659
+ repositories = workflow_plan_repository_roots(root, task, {"units": units})
9646
9660
  ignored_values = {"none", "no", "n/a", "无", "无风险"}
9647
9661
  risk_values = [
9648
9662
  str(item)
@@ -9711,7 +9725,7 @@ def propose_workflow_mode(
9711
9725
  "proposed_at": now_iso(),
9712
9726
  "proposed_by": agent,
9713
9727
  }
9714
- validate_workflow_mode_proposal(root, session, proposal, resolved_task_id)
9728
+ proposal.update(validate_workflow_mode_proposal(root, session, proposal, resolved_task_id))
9715
9729
  task["workflow_mode_proposal"] = proposal
9716
9730
  task["last_agent"] = agent
9717
9731
  write_task(root, resolved_task_id, task)
@@ -9731,35 +9745,36 @@ def freeze_workflow_mode(
9731
9745
  task["workflow_mode_confirmed_by"] = agent
9732
9746
 
9733
9747
 
9734
- def freeze_tdd_mode(
9748
+ def freeze_unit_test_mode(
9735
9749
  root: Path, session: dict, task_id: str, task: dict, agent: str
9736
9750
  ) -> None:
9737
9751
  behavior = resolve_behavior(root, session)
9738
9752
  task_type = str(task.get("type") or "").strip().lower()
9739
- task["tdd_enabled"] = (
9740
- behavior[8] if task_type != TDD_INIT_TASK_TYPE else False
9753
+ task["unit_test_mode"] = (
9754
+ behavior[8] if task_type != TDD_INIT_TASK_TYPE else "none"
9741
9755
  )
9742
- task["tdd_coverage_threshold"] = behavior[11]
9743
- if task["tdd_enabled"] is True:
9756
+ task["ut_coverage_threshold"] = behavior[11]
9757
+ if task["unit_test_mode"] in {"ut", "tdd"}:
9744
9758
  require_tdd_readiness(root)
9745
9759
  plan = latest_execution_plan(root, task_id)
9746
9760
  if plan is None:
9747
- raise StateError("Cannot freeze TDD baseline without a valid execution plan.")
9761
+ raise StateError("Cannot freeze unit test baseline without a valid execution plan.")
9748
9762
  baselines = {
9749
9763
  key: git_head_sha(repository)
9750
9764
  for key, repository in tdd_repositories(root, task, plan).items()
9751
9765
  }
9752
- task_dir = task_json_path(root, task_id).parent
9753
- try:
9754
- dev_spec_content = (task_dir / "dev-spec.md").read_text(encoding="utf-8")
9755
- strategy_content = (task_dir / "test-strategy.md").read_text(encoding="utf-8")
9756
- except OSError as error:
9757
- raise StateError("Cannot freeze TDD without readable analysis artifacts.") from error
9758
- marker_reasons = tdd_baseline_marker_reasons(
9759
- dev_spec_content, strategy_content, baselines
9760
- )
9761
- if marker_reasons:
9762
- raise StateError("; ".join(marker_reasons))
9766
+ if task["unit_test_mode"] == "tdd":
9767
+ task_dir = task_json_path(root, task_id).parent
9768
+ try:
9769
+ dev_spec_content = (task_dir / "dev-spec.md").read_text(encoding="utf-8")
9770
+ strategy_content = (task_dir / "test-strategy.md").read_text(encoding="utf-8")
9771
+ except OSError as error:
9772
+ raise StateError("Cannot freeze TDD without readable analysis artifacts.") from error
9773
+ marker_reasons = tdd_baseline_marker_reasons(
9774
+ dev_spec_content, strategy_content, baselines
9775
+ )
9776
+ if marker_reasons:
9777
+ raise StateError("; ".join(marker_reasons))
9763
9778
  task["tdd_baselines"] = baselines
9764
9779
  else:
9765
9780
  task.pop("tdd_baselines", None)
@@ -9784,21 +9799,8 @@ def raise_workflow_mode(
9784
9799
  )
9785
9800
  if stage != "IMPLEMENT":
9786
9801
  raise StateError("A frozen workflow mode can only be raised during active execution.")
9787
- current = str(task.get("workflow_mode") or "")
9788
- if current not in WORKFLOW_MODES or mode not in WORKFLOW_MODES:
9789
- raise StateError("Workflow mode must be frozen before it can be raised.")
9790
- if WORKFLOW_MODE_RANK[mode] <= WORKFLOW_MODE_RANK[current]:
9791
- raise StateError(f"Workflow mode can only be raised above {current}.")
9802
+ mode, _reasons = calculate_workflow_floor(root, resolved_task_id)
9792
9803
  task["workflow_mode"] = mode
9793
- task.setdefault("workflow_mode_escalations", []).append(
9794
- {
9795
- "from": current,
9796
- "to": mode,
9797
- "reason": reason.strip(),
9798
- "raised_at": now_iso(),
9799
- "raised_by": agent,
9800
- }
9801
- )
9802
9804
  task["last_agent"] = agent
9803
9805
  write_task(root, resolved_task_id, task)
9804
9806
  snapshot = snapshot_state(root, session_file, session)
@@ -9932,7 +9934,7 @@ def apply_transition(
9932
9934
  validate_analysis_readiness(root, resolved_task_id, session)
9933
9935
  if task.get("workflow_mode_legacy") is not True:
9934
9936
  freeze_workflow_mode(root, session, resolved_task_id, task, agent)
9935
- freeze_tdd_mode(root, session, resolved_task_id, task, agent)
9937
+ freeze_unit_test_mode(root, session, resolved_task_id, task, agent)
9936
9938
  repair_source_task_ids: set[str] | None = None
9937
9939
  quality_exit_outcome: str | None = None
9938
9940
  if previous == "QUALITY" and stage in {"IMPLEMENT", "ANALYSIS"}:
@@ -9987,7 +9989,7 @@ def apply_transition(
9987
9989
  if (
9988
9990
  previous == "QUALITY"
9989
9991
  and stage in {"IMPLEMENT", "ANALYSIS"}
9990
- and quality_exit_outcome in {"repair", "replan"}
9992
+ and quality_exit_outcome in {"repair", "replan", "cancelled"}
9991
9993
  ):
9992
9994
  quality_records = validated_quality_records(root, resolved_task_id)
9993
9995
  task["quality_consumed_attempt"] = quality_records[-1][1]["attempt"]
@@ -10012,6 +10014,11 @@ def apply_transition(
10012
10014
  task = load_task(root, resolved_task_id) or task
10013
10015
  if stage in {"ANALYSIS", "IMPLEMENT", "MEMORY", "COMPLETE", "CLOSED"}:
10014
10016
  cleanup_verification_checkpoint(root, resolved_task_id, task)
10017
+ if previous == "IMPLEMENT" and stage == "QUALITY":
10018
+ quality_records = validated_quality_records(root, resolved_task_id)
10019
+ if quality_records:
10020
+ task["quality_consumed_attempt"] = quality_records[-1][1]["attempt"]
10021
+ task.pop("quality_return_required", None)
10015
10022
  task.pop("pending_transition", None)
10016
10023
  if stage == "MEMORY" and previous != stage:
10017
10024
  task["memory_progress"] = {}
@@ -10501,6 +10508,7 @@ def parse_evidence_args(values: list[str]) -> list[dict]:
10501
10508
  return evidence
10502
10509
 
10503
10510
 
10511
+ @evidence_operation()
10504
10512
  def main() -> int:
10505
10513
  configure_stdio()
10506
10514
  common = argparse.ArgumentParser(add_help=False)
@@ -10638,13 +10646,13 @@ def main() -> int:
10638
10646
  clear_workflow_mode_parser = subcommands.add_parser("clear-workflow-mode", parents=[common])
10639
10647
  clear_workflow_mode_parser.add_argument("--agent", required=True)
10640
10648
 
10641
- set_tdd_parser = subcommands.add_parser("set-tdd", parents=[common])
10642
- set_tdd_parser.add_argument("--enabled", required=True, choices=["true", "false"])
10643
- set_tdd_parser.add_argument("--threshold", type=int)
10644
- set_tdd_parser.add_argument("--agent", required=True)
10649
+ set_unit_test_parser = subcommands.add_parser("set-unit-test-mode", parents=[common])
10650
+ set_unit_test_parser.add_argument("--mode", required=True, choices=["none", "ut", "tdd"])
10651
+ set_unit_test_parser.add_argument("--threshold", type=int)
10652
+ set_unit_test_parser.add_argument("--agent", required=True)
10645
10653
 
10646
- clear_tdd_parser = subcommands.add_parser("clear-tdd", parents=[common])
10647
- clear_tdd_parser.add_argument("--agent", required=True)
10654
+ clear_unit_test_parser = subcommands.add_parser("clear-unit-test-mode", parents=[common])
10655
+ clear_unit_test_parser.add_argument("--agent", required=True)
10648
10656
 
10649
10657
  # Compatibility aliases for pre-0.9 callers.
10650
10658
  set_confirm_mode_parser = subcommands.add_parser("set-confirm-mode", parents=[common])
@@ -10660,20 +10668,20 @@ def main() -> int:
10660
10668
  "propose-workflow-mode", parents=[common]
10661
10669
  )
10662
10670
  propose_workflow_parser.add_argument(
10663
- "--configured", required=True, choices=sorted(CONFIGURED_WORKFLOW_MODES)
10671
+ "--configured", default="adaptive", choices=sorted(CONFIGURED_WORKFLOW_MODES)
10664
10672
  )
10665
10673
  propose_workflow_parser.add_argument(
10666
- "--selected", required=True, choices=sorted(WORKFLOW_MODES)
10674
+ "--selected", default="fast", choices=sorted(WORKFLOW_MODES)
10667
10675
  )
10668
10676
  propose_workflow_parser.add_argument(
10669
- "--minimum", required=True, choices=sorted(WORKFLOW_MODES)
10677
+ "--minimum", default="fast", choices=sorted(WORKFLOW_MODES)
10670
10678
  )
10671
10679
  propose_workflow_parser.add_argument(
10672
10680
  "--source",
10673
- required=True,
10681
+ default="adaptive",
10674
10682
  choices=["project", "session", "adaptive", "user", "migration"],
10675
10683
  )
10676
- propose_workflow_parser.add_argument("--reason", required=True, action="append")
10684
+ propose_workflow_parser.add_argument("--reason", action="append", default=[])
10677
10685
  propose_workflow_parser.add_argument("--agent", required=True)
10678
10686
  propose_workflow_parser.add_argument("--task-id")
10679
10687
 
@@ -10691,6 +10699,22 @@ def main() -> int:
10691
10699
  fingerprints_parser.add_argument("--agent", required=True)
10692
10700
  fingerprints_parser.add_argument("--task-id")
10693
10701
 
10702
+ for name in ("prepare-check", "record-check"):
10703
+ check_parser = subcommands.add_parser(name, parents=[common])
10704
+ check_parser.add_argument("--agent", required=True)
10705
+ check_parser.add_argument("--task-id")
10706
+ if name == "prepare-check":
10707
+ check_parser.add_argument("--record", required=True)
10708
+ else:
10709
+ check_parser.add_argument("--prepared-id", required=True)
10710
+ check_parser.add_argument("--result", required=True)
10711
+ correction_parser = subcommands.add_parser("begin-correction", parents=[common])
10712
+ correction_parser.add_argument("--file", action="append", required=True)
10713
+ correction_parser.add_argument("--summary", required=True)
10714
+ correction_parser.add_argument("--risk", action="append", default=[])
10715
+ correction_parser.add_argument("--agent", required=True)
10716
+ correction_parser.add_argument("--task-id")
10717
+
10694
10718
  finalize_quality_parser = subcommands.add_parser(
10695
10719
  "finalize-quality", parents=[common]
10696
10720
  )
@@ -11139,13 +11163,13 @@ def main() -> int:
11139
11163
  session_file,
11140
11164
  )
11141
11165
  )
11142
- elif command == "set-tdd":
11166
+ elif command == "set-unit-test-mode":
11143
11167
  emit(
11144
11168
  attach_status_context(
11145
11169
  root,
11146
- set_session_tdd(
11170
+ set_session_unit_test_mode(
11147
11171
  root,
11148
- args.enabled == "true",
11172
+ args.mode,
11149
11173
  agent,
11150
11174
  args.threshold,
11151
11175
  session_file,
@@ -11154,11 +11178,11 @@ def main() -> int:
11154
11178
  session_file,
11155
11179
  )
11156
11180
  )
11157
- elif command == "clear-tdd":
11181
+ elif command == "clear-unit-test-mode":
11158
11182
  emit(
11159
11183
  attach_status_context(
11160
11184
  root,
11161
- clear_session_tdd(root, agent, session_file),
11185
+ clear_session_unit_test_mode(root, agent, session_file),
11162
11186
  agent,
11163
11187
  session_file,
11164
11188
  )
@@ -11261,6 +11285,16 @@ def main() -> int:
11261
11285
  session_file,
11262
11286
  )
11263
11287
  )
11288
+ elif command in {"prepare-check", "record-check", "begin-correction"}:
11289
+ session, task_id, task = resolve_current_task(root, args.task_id, session_file)
11290
+ require_spec_context(root, task, agent, session_file)
11291
+ if command == "prepare-check":
11292
+ result = prepare_check(root, task_id, task, json.loads(args.record), agent)
11293
+ elif command == "record-check":
11294
+ result = record_check(root, task_id, task, args.prepared_id, json.loads(args.result), agent)
11295
+ else:
11296
+ result = begin_correction(root, task_id, task, args.file, args.summary, args.risk, agent)
11297
+ emit(result)
11264
11298
  elif command == "finalize-quality":
11265
11299
  emit(
11266
11300
  attach_status_context(
@@ -11547,7 +11581,7 @@ def main() -> int:
11547
11581
  )
11548
11582
  )
11549
11583
  return 0
11550
- except (StateError, EasyDevSpecError) as error:
11584
+ except (StateError, EasyDevSpecError, ValueError) as error:
11551
11585
  print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
11552
11586
  return 1
11553
11587
  finally: