easy-coding-harness 0.10.0-beta.0 → 0.10.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.
@@ -5,6 +5,7 @@ import json
5
5
  import os
6
6
  import re
7
7
  import secrets
8
+ import shlex
8
9
  import subprocess
9
10
  import time
10
11
  import uuid
@@ -24,7 +25,8 @@ from easy_dev_spec import (
24
25
  TERMINAL_STATUSES = {"COMPLETE", "CLOSED"}
25
26
  HELP_SUFFIX = (
26
27
  "Use `ec-workflow` to start or resume a task, "
27
- "`ec-brainstorming` to brainstorm, or `ec-task-management` to manage tasks or session settings"
28
+ "`ec-brainstorming` to brainstorm, `ec-task-management` to manage tasks, "
29
+ "or `ec-config` to inspect or change modes"
28
30
  )
29
31
  READY_LINE = f"Ready · {HELP_SUFFIX}"
30
32
  WAITING_INIT_LINE = "Waiting init · Use `ec-init` to initialize"
@@ -78,6 +80,8 @@ STRICT_WORKFLOW_RISK_PATTERN = re.compile(
78
80
  )
79
81
  DEFAULT_APPROVAL_MODE = "guard"
80
82
  DEFAULT_WORKFLOW_MODE = "adaptive"
83
+ DEFAULT_TDD_ENABLED = False
84
+ DEFAULT_TDD_COVERAGE_THRESHOLD = 90
81
85
  CRITICAL_CONFIRM_TRANSITIONS = {
82
86
  ("ANALYSIS", "IMPLEMENT"),
83
87
  ("VERIFICATION", "MEMORY"),
@@ -310,16 +314,45 @@ def read_memory_config(root: Path) -> dict[str, int]:
310
314
  return config
311
315
 
312
316
 
313
- def read_project_behavior(root: Path) -> tuple[str, str]:
317
+ def parse_tdd_threshold(value: object, source: str) -> int:
318
+ if isinstance(value, bool):
319
+ raise StateError(f"Invalid {source}: expected an integer from 1 to 100.")
320
+ try:
321
+ threshold = int(str(value))
322
+ except (TypeError, ValueError) as error:
323
+ raise StateError(f"Invalid {source}: expected an integer from 1 to 100.") from error
324
+ if threshold < 1 or threshold > 100:
325
+ raise StateError(f"Invalid {source}: expected an integer from 1 to 100.")
326
+ return threshold
327
+
328
+
329
+ def parse_yaml_bool(value: str | None, source: str) -> bool:
330
+ if value is None:
331
+ return DEFAULT_TDD_ENABLED
332
+ normalized = value.lower()
333
+ if normalized in {"true", "yes", "on"}:
334
+ return True
335
+ if normalized in {"false", "no", "off"}:
336
+ return False
337
+ raise StateError(f"Invalid {source}: expected true or false.")
338
+
339
+
340
+ def read_project_behavior(root: Path) -> tuple[str, str, bool, int]:
314
341
  path = root / ".easy-coding" / "config.yaml"
315
342
  try:
316
343
  lines = path.read_text(encoding="utf-8").splitlines()
317
344
  except OSError:
318
- return DEFAULT_APPROVAL_MODE, DEFAULT_WORKFLOW_MODE
345
+ return (
346
+ DEFAULT_APPROVAL_MODE,
347
+ DEFAULT_WORKFLOW_MODE,
348
+ DEFAULT_TDD_ENABLED,
349
+ DEFAULT_TDD_COVERAGE_THRESHOLD,
350
+ )
319
351
 
320
352
  in_behavior = False
321
353
  behavior_indent = 0
322
354
  behavior: dict[str, str] = {}
355
+ schema_version = 0
323
356
  for raw_line in lines:
324
357
  without_comment = raw_line.split("#", 1)[0].rstrip()
325
358
  stripped = without_comment.strip()
@@ -332,6 +365,12 @@ def read_project_behavior(root: Path) -> tuple[str, str]:
332
365
  continue
333
366
  if in_behavior and indent <= behavior_indent:
334
367
  in_behavior = False
368
+ if not in_behavior and indent == 0 and stripped.startswith("version:"):
369
+ try:
370
+ schema_version = int(stripped.split(":", 1)[1].strip().strip("'\""))
371
+ except ValueError:
372
+ schema_version = 0
373
+ continue
335
374
  if not in_behavior or ":" not in stripped:
336
375
  continue
337
376
  key, value = stripped.split(":", 1)
@@ -359,16 +398,27 @@ def read_project_behavior(root: Path) -> tuple[str, str]:
359
398
  "Invalid behavior.workflow_mode in .easy-coding/config.yaml: "
360
399
  "expected adaptive, fast, standard, or strict."
361
400
  )
362
- return approval_mode, workflow_mode
401
+ if schema_version >= 4:
402
+ tdd_enabled = parse_yaml_bool(behavior.get("tdd_enabled"), "behavior.tdd_enabled")
403
+ tdd_threshold = parse_tdd_threshold(
404
+ behavior.get("tdd_coverage_threshold", DEFAULT_TDD_COVERAGE_THRESHOLD),
405
+ "behavior.tdd_coverage_threshold",
406
+ )
407
+ else:
408
+ tdd_enabled = DEFAULT_TDD_ENABLED
409
+ tdd_threshold = DEFAULT_TDD_COVERAGE_THRESHOLD
410
+ return approval_mode, workflow_mode, tdd_enabled, tdd_threshold
363
411
 
364
412
 
365
413
  def resolve_behavior(
366
414
  root: Path, session: dict
367
- ) -> tuple[str, str | None, str, str, str | None, str]:
368
- project_approval, project_workflow = read_project_behavior(root)
415
+ ) -> tuple[str, str | None, str, str, str | None, str, bool, bool | None, bool, int, int | None, int]:
416
+ project_approval, project_workflow, project_tdd, project_threshold = read_project_behavior(root)
369
417
  legacy = session.get("confirm_mode")
370
418
  session_approval = session.get("approval_mode")
371
419
  session_workflow = session.get("workflow_mode")
420
+ session_tdd = session.get("tdd_enabled")
421
+ session_threshold = session.get("tdd_coverage_threshold")
372
422
  if session_approval is None:
373
423
  if legacy == "lite":
374
424
  session_approval = "guard"
@@ -387,6 +437,12 @@ def resolve_behavior(
387
437
  raise StateError(
388
438
  "Invalid session workflow_mode: expected adaptive, fast, standard, or strict."
389
439
  )
440
+ if session_tdd is not None and not isinstance(session_tdd, bool):
441
+ raise StateError("Invalid session tdd_enabled: expected true or false.")
442
+ if session_threshold is not None:
443
+ session_threshold = parse_tdd_threshold(
444
+ session_threshold, "session tdd_coverage_threshold"
445
+ )
390
446
  return (
391
447
  project_approval,
392
448
  str(session_approval) if session_approval else None,
@@ -394,6 +450,12 @@ def resolve_behavior(
394
450
  project_workflow,
395
451
  str(session_workflow) if session_workflow else None,
396
452
  str(session_workflow or project_workflow),
453
+ project_tdd,
454
+ session_tdd,
455
+ session_tdd if session_tdd is not None else project_tdd,
456
+ project_threshold,
457
+ session_threshold,
458
+ session_threshold if session_threshold is not None else project_threshold,
397
459
  )
398
460
 
399
461
 
@@ -1624,6 +1686,77 @@ def task_repository_roots(root: Path, task: dict | None, plan: dict) -> list[Pat
1624
1686
  ]
1625
1687
 
1626
1688
 
1689
+ def tdd_repositories(root: Path, task: dict, plan: dict) -> dict[str, Path]:
1690
+ if isinstance(task.get("spec_source"), dict):
1691
+ repo_paths = task.get("repo_paths")
1692
+ if not isinstance(repo_paths, dict):
1693
+ raise StateError("TDD Canonical task is missing repository bindings.")
1694
+ repositories: dict[str, Path] = {}
1695
+ for unit in plan.get("units", []):
1696
+ if not isinstance(unit, dict) or not is_non_empty_string(unit.get("repo_id")):
1697
+ raise StateError("TDD Canonical unit is missing repo_id.")
1698
+ repo_id = str(unit["repo_id"])
1699
+ raw_path = repo_paths.get(repo_id)
1700
+ if not is_non_empty_string(raw_path):
1701
+ raise StateError(f"TDD repository path is missing: {repo_id}")
1702
+ candidate = Path(str(raw_path))
1703
+ resolved = (candidate if candidate.is_absolute() else root / candidate).resolve()
1704
+ repository = git_repository_root(resolved)
1705
+ if repository is None or repository.resolve() != resolved:
1706
+ raise StateError(f"TDD repository binding is not a Git root: {repo_id}")
1707
+ repositories[repo_id] = repository
1708
+ return repositories
1709
+
1710
+ repositories = task_repository_roots(root, task, plan)
1711
+ if len(repositories) != 1:
1712
+ raise StateError(
1713
+ "Non-Canonical TDD requires exactly one Git repository; use a Canonical Spec for multi-repository work."
1714
+ )
1715
+ return {"project": repositories[0]}
1716
+
1717
+
1718
+ def git_head_sha(repository: Path) -> str:
1719
+ result = run_git(repository, "rev-parse", "--verify", "HEAD")
1720
+ sha = result.stdout.decode("ascii", errors="ignore").strip() if result else ""
1721
+ if (
1722
+ result is None
1723
+ or result.returncode != 0
1724
+ or re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", sha) is None
1725
+ ):
1726
+ raise StateError(f"Cannot freeze TDD Git baseline for {repository.name}.")
1727
+ return sha
1728
+
1729
+
1730
+ def tdd_baseline_marker_reasons(
1731
+ dev_spec_content: str, strategy_content: str, baselines: dict[str, str]
1732
+ ) -> list[str]:
1733
+ reasons: list[str] = []
1734
+ canonical = set(baselines) != {"project"}
1735
+ for repo_id, baseline in sorted(baselines.items()):
1736
+ for artifact_name, content in (
1737
+ ("dev-spec.md", dev_spec_content),
1738
+ ("test-strategy.md", strategy_content),
1739
+ ):
1740
+ if baseline not in content:
1741
+ reasons.append(
1742
+ f"{artifact_name} must record the immutable TDD baseline SHA for {repo_id}: {baseline}"
1743
+ )
1744
+ if canonical and repo_id not in content:
1745
+ reasons.append(
1746
+ f"{artifact_name} must map the TDD baseline to repository {repo_id}"
1747
+ )
1748
+ return reasons
1749
+
1750
+
1751
+ def contains_tdd_threshold(content: str, threshold: int) -> bool:
1752
+ return re.search(
1753
+ rf"(?<!\d){threshold}\s*%|--threshold(?:\s+|=){threshold}(?!\d)|"
1754
+ rf"tdd_coverage_threshold\s*[:=]\s*{threshold}(?!\d)",
1755
+ content,
1756
+ re.IGNORECASE,
1757
+ ) is not None
1758
+
1759
+
1627
1760
  def repository_scope_pathspecs(repository: Path, scopes: list[Path]) -> list[str]:
1628
1761
  return [
1629
1762
  f":(literal){scope.relative_to(repository).as_posix()}"
@@ -1837,6 +1970,18 @@ def implementation_fingerprint(root: Path, task_id: str) -> str:
1837
1970
  digest.update(b"workflow-mode\0")
1838
1971
  digest.update(workflow_mode.encode("utf-8"))
1839
1972
  digest.update(b"\0")
1973
+ if task and task.get("tdd_enabled") is True:
1974
+ digest.update(b"tdd\0enabled\0")
1975
+ digest.update(str(task.get("tdd_coverage_threshold") or "").encode("utf-8"))
1976
+ digest.update(b"\0")
1977
+ digest.update(
1978
+ json.dumps(
1979
+ task.get("tdd_baselines") or {},
1980
+ sort_keys=True,
1981
+ separators=(",", ":"),
1982
+ ).encode("utf-8")
1983
+ )
1984
+ digest.update(b"\0")
1840
1985
  digest.update(b"execution-plan\0")
1841
1986
  digest.update(
1842
1987
  json.dumps(
@@ -1901,23 +2046,93 @@ def implementation_fingerprint(root: Path, task_id: str) -> str:
1901
2046
  return digest.hexdigest()
1902
2047
 
1903
2048
 
1904
- def behavior_config_fingerprint(root: Path) -> str:
2049
+ def config_without_frozen_tdd_settings(payload: bytes) -> bytes:
2050
+ """任务冻结 TDD 契约后,从证据指纹中排除仅影响未来任务的实时 TDD 配置。"""
2051
+ try:
2052
+ lines = payload.decode("utf-8").splitlines(keepends=True)
2053
+ except UnicodeDecodeError:
2054
+ return payload
2055
+ filtered: list[str] = []
2056
+ in_behavior = False
2057
+ behavior_indent = 0
2058
+ behavior_key_indent: int | None = None
2059
+ for line in lines:
2060
+ clean = line.split("#", 1)[0].rstrip()
2061
+ stripped = clean.strip()
2062
+ indent = len(clean) - len(clean.lstrip(" "))
2063
+ if stripped == "behavior:":
2064
+ in_behavior = True
2065
+ behavior_indent = indent
2066
+ behavior_key_indent = None
2067
+ filtered.append(line)
2068
+ continue
2069
+ if in_behavior and stripped and indent <= behavior_indent:
2070
+ in_behavior = False
2071
+ if in_behavior and stripped:
2072
+ if behavior_key_indent is None:
2073
+ behavior_key_indent = indent
2074
+ key = stripped.split(":", 1)[0]
2075
+ if (
2076
+ indent == behavior_key_indent
2077
+ and key in {"tdd_enabled", "tdd_coverage_threshold"}
2078
+ ):
2079
+ continue
2080
+ filtered.append(line)
2081
+ return "".join(filtered).encode("utf-8")
2082
+
2083
+
2084
+ def behavior_config_fingerprint(root: Path, task: dict | None = None) -> str:
1905
2085
  path = root / ".easy-coding" / "config.yaml"
1906
2086
  digest = hashlib.sha256()
1907
2087
  try:
1908
- digest.update(path.read_bytes())
2088
+ payload = path.read_bytes()
2089
+ if task and isinstance(task.get("tdd_enabled"), bool):
2090
+ payload = config_without_frozen_tdd_settings(payload)
2091
+ digest.update(payload)
1909
2092
  except OSError:
1910
2093
  digest.update(b"<missing-config>")
1911
2094
  return digest.hexdigest()
1912
2095
 
1913
2096
 
1914
2097
  def evidence_fingerprints(root: Path, task_id: str) -> dict[str, str]:
2098
+ task = load_task(root, task_id)
1915
2099
  return {
1916
2100
  "implementation_fingerprint": implementation_fingerprint(root, task_id),
1917
- "config_fingerprint": behavior_config_fingerprint(root),
2101
+ "config_fingerprint": behavior_config_fingerprint(root, task),
1918
2102
  }
1919
2103
 
1920
2104
 
2105
+ def command_option_value(command: str, option: str) -> str | None:
2106
+ try:
2107
+ tokens = shlex.split(command)
2108
+ except ValueError:
2109
+ return None
2110
+ for index, token in enumerate(tokens):
2111
+ if token == option and index + 1 < len(tokens):
2112
+ return tokens[index + 1]
2113
+ prefix = f"{option}="
2114
+ if token.startswith(prefix):
2115
+ return token[len(prefix) :]
2116
+ return None
2117
+
2118
+
2119
+ def coverage_command_matches_frozen_contract(
2120
+ command: object, baseline: str, threshold: int
2121
+ ) -> bool:
2122
+ if not is_non_empty_string(command):
2123
+ return False
2124
+ try:
2125
+ tokens = shlex.split(str(command))
2126
+ except ValueError:
2127
+ return False
2128
+ return (
2129
+ any(Path(token).name == "easy_coding_java_coverage.py" for token in tokens)
2130
+ and "check" in tokens
2131
+ and command_option_value(str(command), "--base") == baseline
2132
+ and command_option_value(str(command), "--threshold") == str(threshold)
2133
+ )
2134
+
2135
+
1921
2136
  def validate_spec_implementation_results(root: Path, task_id: str, task: dict) -> None:
1922
2137
  if not isinstance(task.get("spec_source"), dict):
1923
2138
  return
@@ -2074,6 +2289,25 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
2074
2289
  raise StateError(
2075
2290
  "REVIEW cannot advance to VERIFICATION while a current review dimension is not passed or has error findings."
2076
2291
  )
2292
+ if task.get("tdd_enabled") is True:
2293
+ if is_spec_task:
2294
+ missing_tdd_reviews = sorted(
2295
+ source_task_id
2296
+ for source_task_id, dimensions in reviewed_dimensions.items()
2297
+ if "tdd" not in {dimension.lower() for dimension in dimensions}
2298
+ )
2299
+ if missing_tdd_reviews:
2300
+ raise StateError(
2301
+ "TDD tasks require a passed TDD review dimension for every selected source task: "
2302
+ + ", ".join(missing_tdd_reviews)
2303
+ )
2304
+ elif not any(
2305
+ str(record.get("dimension") or "").lower() == "tdd"
2306
+ for record in latest_by_dimension.values()
2307
+ ):
2308
+ raise StateError(
2309
+ "TDD tasks require a passed TDD review dimension for test quality, boundaries, and mocking."
2310
+ )
2077
2311
  if task.get("workflow_mode") == "strict":
2078
2312
  if is_spec_task:
2079
2313
  missing_strict_dimensions = sorted(
@@ -2111,6 +2345,8 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
2111
2345
  and is_non_empty_string(record.get("check"))
2112
2346
  ):
2113
2347
  check = str(record["check"])
2348
+ if task.get("tdd_enabled") is True and record.get("check_type") == "coverage":
2349
+ check = f"{check}\0{record.get('coverage_scope') or ''}"
2114
2350
  if is_spec_task:
2115
2351
  check = f"{check}\0{record.get('source_task_id') or ''}"
2116
2352
  previous = latest_by_check.get(check)
@@ -2129,7 +2365,7 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
2129
2365
  for record in latest_by_check.values():
2130
2366
  check_type = str(record.get("check_type") or "")
2131
2367
  if (
2132
- check_type not in STRICT_VERIFICATION_CHECK_TYPES
2368
+ check_type not in STRICT_VERIFICATION_CHECK_TYPES | {"coverage"}
2133
2369
  or not is_non_empty_string(record.get("timestamp"))
2134
2370
  or (
2135
2371
  record.get("applicable") is not False
@@ -2175,6 +2411,139 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
2175
2411
  raise StateError(
2176
2412
  "VERIFICATION cannot advance to MEMORY while current verification evidence contains failures."
2177
2413
  )
2414
+ if task.get("tdd_enabled") is not True and any(
2415
+ record.get("check_type") == "coverage" for record in latest_by_check.values()
2416
+ ):
2417
+ raise StateError(
2418
+ "Coverage verification evidence is not allowed when the frozen TDD mode is off."
2419
+ )
2420
+ if task.get("tdd_enabled") is True:
2421
+ coverage_records = [
2422
+ record
2423
+ for record in latest_by_check.values()
2424
+ if record.get("check_type") == "coverage"
2425
+ ]
2426
+ if not coverage_records:
2427
+ raise StateError(
2428
+ "TDD verification requires changed-production-line JaCoCo coverage evidence."
2429
+ )
2430
+ if is_spec_task:
2431
+ covered_source_tasks = {
2432
+ str(record.get("source_task_id") or "") for record in coverage_records
2433
+ }
2434
+ missing_coverage_tasks = sorted(
2435
+ set(task_repositories) - covered_source_tasks
2436
+ )
2437
+ if missing_coverage_tasks:
2438
+ raise StateError(
2439
+ "TDD Canonical verification requires separate coverage evidence for every selected source task: "
2440
+ + ", ".join(missing_coverage_tasks)
2441
+ )
2442
+ coverage_scopes_by_owner: dict[str, set[str]] = {}
2443
+ for record in coverage_records:
2444
+ scope = str(record.get("coverage_scope") or "")
2445
+ if scope not in {"local", "gitlab"}:
2446
+ raise StateError(
2447
+ "TDD coverage evidence must identify coverage_scope as local or gitlab."
2448
+ )
2449
+ owner = (
2450
+ str(record.get("source_task_id") or "")
2451
+ if is_spec_task
2452
+ else "project"
2453
+ )
2454
+ coverage_scopes_by_owner.setdefault(owner, set()).add(scope)
2455
+ expected_threshold = task.get("tdd_coverage_threshold")
2456
+ expected_baselines = task.get("tdd_baselines")
2457
+ if (
2458
+ type(expected_threshold) is not int
2459
+ or expected_threshold < 1
2460
+ or expected_threshold > 100
2461
+ ):
2462
+ raise StateError("TDD task is missing a valid frozen coverage threshold.")
2463
+ if not isinstance(expected_baselines, dict) or not expected_baselines:
2464
+ raise StateError("TDD task is missing frozen Git baselines.")
2465
+ for record in coverage_records:
2466
+ coverage = record.get("coverage")
2467
+ if not isinstance(coverage, dict):
2468
+ raise StateError("TDD coverage evidence must include the coverage result object.")
2469
+ if record.get("coverage_scope") == "gitlab":
2470
+ ci = record.get("ci")
2471
+ if (
2472
+ not isinstance(ci, dict)
2473
+ or ci.get("provider") != "gitlab"
2474
+ or ci.get("status") != "success"
2475
+ or not is_non_empty_string(ci.get("pipeline_url"))
2476
+ or not is_non_empty_string(ci.get("job_name"))
2477
+ ):
2478
+ raise StateError(
2479
+ "GitLab coverage evidence requires a successful pipeline URL and job name."
2480
+ )
2481
+ total = coverage.get("total_lines")
2482
+ covered = coverage.get("covered_lines")
2483
+ percentage = coverage.get("percentage")
2484
+ threshold = coverage.get("threshold")
2485
+ baseline_key = str(record.get("repo_id") or "") if is_spec_task else "project"
2486
+ expected_baseline = expected_baselines.get(baseline_key)
2487
+ if (
2488
+ not is_non_empty_string(expected_baseline)
2489
+ or coverage.get("baseline_sha") != expected_baseline
2490
+ or re.fullmatch(
2491
+ r"[0-9a-f]{40}|[0-9a-f]{64}", str(coverage.get("baseline_sha") or "")
2492
+ )
2493
+ is None
2494
+ or not isinstance(total, int)
2495
+ or not isinstance(covered, int)
2496
+ or not isinstance(percentage, (int, float))
2497
+ or threshold != expected_threshold
2498
+ or not isinstance(coverage.get("report_paths"), list)
2499
+ or not coverage.get("report_paths")
2500
+ or not all(
2501
+ is_non_empty_string(path) for path in coverage.get("report_paths", [])
2502
+ )
2503
+ or not re.fullmatch(
2504
+ r"[0-9a-f]{64}", str(coverage.get("report_sha256") or "")
2505
+ )
2506
+ or covered < 0
2507
+ or total < 0
2508
+ or covered > total
2509
+ or percentage < 0
2510
+ or percentage > 100
2511
+ or not coverage_command_matches_frozen_contract(
2512
+ record.get("command"), str(expected_baseline), int(expected_threshold)
2513
+ )
2514
+ ):
2515
+ raise StateError(
2516
+ "TDD coverage evidence must preserve the exact gate command, baseline, counts, percentage, frozen threshold, reports, and report fingerprint."
2517
+ )
2518
+ if total == 0:
2519
+ if record.get("applicable") is not False or record.get("passed") is not True:
2520
+ raise StateError(
2521
+ "Coverage with no modified executable production Java lines must be explicit N/A."
2522
+ )
2523
+ elif abs(percentage - round(covered * 100.0 / total, 2)) > 0.01:
2524
+ raise StateError(
2525
+ "TDD coverage evidence percentage does not match covered/total counts."
2526
+ )
2527
+ elif (
2528
+ record.get("applicable") is False
2529
+ or record.get("passed") is not True
2530
+ or percentage < threshold
2531
+ ):
2532
+ raise StateError(
2533
+ f"TDD changed-line coverage must meet the frozen {threshold}% threshold."
2534
+ )
2535
+ expected_coverage_owners = set(task_repositories) if is_spec_task else {"project"}
2536
+ missing_scopes = [
2537
+ f"{owner}:{scope}"
2538
+ for owner in sorted(expected_coverage_owners)
2539
+ for scope in ("local", "gitlab")
2540
+ if scope not in coverage_scopes_by_owner.get(owner, set())
2541
+ ]
2542
+ if missing_scopes:
2543
+ raise StateError(
2544
+ "TDD verification requires both local and successful GitLab coverage gates: "
2545
+ + ", ".join(missing_scopes)
2546
+ )
2178
2547
  if task.get("workflow_mode") == "strict":
2179
2548
  if is_spec_task:
2180
2549
  check_types_by_repository: dict[str, set[str]] = {
@@ -2445,7 +2814,9 @@ def validate_mandatory_dev_spec_sections(content: str) -> tuple[list[str], list[
2445
2814
  return missing, empty
2446
2815
 
2447
2816
 
2448
- def validate_analysis_readiness(root: Path, task_id: str) -> None:
2817
+ def validate_analysis_readiness(
2818
+ root: Path, task_id: str, session: dict | None = None
2819
+ ) -> None:
2449
2820
  task_dir = task_json_path(root, task_id).parent
2450
2821
  task = load_task(root, task_id)
2451
2822
  task_type = str(task.get("type") or "").strip().lower() if task else ""
@@ -2454,6 +2825,9 @@ def validate_analysis_readiness(root: Path, task_id: str) -> None:
2454
2825
  skeleton = root / ".easy-coding" / "templates" / "dev-spec-skeleton.md"
2455
2826
  test_strategy = task_dir / "test-strategy.md"
2456
2827
  reasons: list[str] = []
2828
+ behavior = resolve_behavior(root, session or default_session())
2829
+ tdd_enabled = behavior[8]
2830
+ tdd_threshold = behavior[11]
2457
2831
 
2458
2832
  dev_spec_content = ""
2459
2833
  if not dev_spec.exists():
@@ -2500,6 +2874,81 @@ def validate_analysis_readiness(root: Path, task_id: str) -> None:
2500
2874
  plan_is_valid = has_valid_execution_plan(root, task_id)
2501
2875
  if not plan_is_valid:
2502
2876
  reasons.append("execution.jsonl has no valid plan record")
2877
+ if tdd_enabled and not is_read_only_task:
2878
+ plan = latest_execution_plan(root, task_id) or {}
2879
+ if re.search(
2880
+ r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
2881
+ ) is None:
2882
+ reasons.append("dev-spec.md is missing the required TDD Mode section")
2883
+ planned_files = [
2884
+ str(file_name)
2885
+ for unit in plan.get("units", [])
2886
+ if isinstance(unit, dict)
2887
+ for file_name in unit.get("files", [])
2888
+ ]
2889
+ if not any(file_name.endswith(".java") for file_name in planned_files):
2890
+ reasons.append("TDD is enabled but the confirmed implementation scope has no Java source")
2891
+ baselines: dict[str, str] = {}
2892
+ if task:
2893
+ try:
2894
+ repositories = tdd_repositories(root, task, plan)
2895
+ baselines = {
2896
+ repo_id: git_head_sha(repository)
2897
+ for repo_id, repository in repositories.items()
2898
+ }
2899
+ except StateError as error:
2900
+ reasons.append(str(error))
2901
+ try:
2902
+ strategy_content = test_strategy.read_text(encoding="utf-8")
2903
+ except OSError:
2904
+ strategy_content = ""
2905
+ required_tdd_markers = ["TDD", "JaCoCo", "baseline", "GitLab"]
2906
+ missing_tdd_markers = [
2907
+ marker for marker in required_tdd_markers if marker.lower() not in strategy_content.lower()
2908
+ ]
2909
+ if missing_tdd_markers:
2910
+ reasons.append(
2911
+ "TDD test strategy is missing: " + ", ".join(missing_tdd_markers)
2912
+ )
2913
+ if not contains_tdd_threshold(strategy_content, tdd_threshold):
2914
+ reasons.append(
2915
+ f"TDD test strategy must state the frozen {tdd_threshold}% coverage threshold"
2916
+ )
2917
+ if not contains_tdd_threshold(dev_spec_content, tdd_threshold):
2918
+ reasons.append(
2919
+ f"TDD dev spec must state the frozen {tdd_threshold}% coverage threshold"
2920
+ )
2921
+ if baselines:
2922
+ reasons.extend(
2923
+ tdd_baseline_marker_reasons(
2924
+ dev_spec_content, strategy_content, baselines
2925
+ )
2926
+ )
2927
+ elif not is_read_only_task:
2928
+ if re.search(
2929
+ r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
2930
+ ):
2931
+ reasons.append("dev-spec.md must omit the TDD Mode section when TDD is disabled")
2932
+ try:
2933
+ strategy_content = test_strategy.read_text(encoding="utf-8")
2934
+ except OSError:
2935
+ strategy_content = ""
2936
+ forbidden_tdd_markers = [
2937
+ marker
2938
+ for marker in (
2939
+ "easy_coding_java_coverage.py",
2940
+ "coverage_scope",
2941
+ "task.tdd_baselines",
2942
+ "RED -> GREEN -> REFACTOR",
2943
+ "RED/GREEN/REFACTOR",
2944
+ )
2945
+ if marker.lower() in strategy_content.lower()
2946
+ ]
2947
+ if forbidden_tdd_markers:
2948
+ reasons.append(
2949
+ "test-strategy.md contains TDD-only planning while TDD is disabled: "
2950
+ + ", ".join(forbidden_tdd_markers)
2951
+ )
2503
2952
  if task and isinstance(task.get("spec_source"), dict):
2504
2953
  try:
2505
2954
  inspection, selection = inspect_task_spec(root, task)
@@ -2785,6 +3234,12 @@ def snapshot_state(
2785
3234
  project_workflow_mode,
2786
3235
  session_workflow_mode,
2787
3236
  configured_workflow_mode,
3237
+ project_tdd_enabled,
3238
+ session_tdd_enabled,
3239
+ effective_tdd_enabled,
3240
+ project_tdd_coverage_threshold,
3241
+ session_tdd_coverage_threshold,
3242
+ effective_tdd_coverage_threshold,
2788
3243
  ) = resolve_behavior(root, resolved_session)
2789
3244
  concrete_workflow_mode = None
2790
3245
  if task:
@@ -2792,6 +3247,19 @@ def snapshot_state(
2792
3247
  proposal = task.get("workflow_mode_proposal")
2793
3248
  if concrete_workflow_mode is None and isinstance(proposal, dict):
2794
3249
  concrete_workflow_mode = proposal.get("selected_mode")
3250
+ task_tdd_enabled = task.get("tdd_enabled") if task else None
3251
+ task_tdd_coverage_threshold = task.get("tdd_coverage_threshold") if task else None
3252
+ frozen_tdd = bool(
3253
+ task
3254
+ and status not in {"ANALYSIS", "INIT"}
3255
+ and isinstance(task_tdd_enabled, bool)
3256
+ )
3257
+ displayed_tdd_enabled = task_tdd_enabled if frozen_tdd else effective_tdd_enabled
3258
+ displayed_tdd_threshold = (
3259
+ task_tdd_coverage_threshold
3260
+ if frozen_tdd and isinstance(task_tdd_coverage_threshold, int)
3261
+ else effective_tdd_coverage_threshold
3262
+ )
2795
3263
 
2796
3264
  return {
2797
3265
  "session_file": display_path(root, session_path),
@@ -2812,6 +3280,17 @@ def snapshot_state(
2812
3280
  "session_workflow_mode": session_workflow_mode,
2813
3281
  "configured_workflow_mode": configured_workflow_mode,
2814
3282
  "concrete_workflow_mode": concrete_workflow_mode,
3283
+ "project_tdd_enabled": project_tdd_enabled,
3284
+ "session_tdd_enabled": session_tdd_enabled,
3285
+ "effective_tdd_enabled": effective_tdd_enabled,
3286
+ "project_tdd_coverage_threshold": project_tdd_coverage_threshold,
3287
+ "session_tdd_coverage_threshold": session_tdd_coverage_threshold,
3288
+ "effective_tdd_coverage_threshold": effective_tdd_coverage_threshold,
3289
+ "task_tdd_enabled": task_tdd_enabled,
3290
+ "task_tdd_coverage_threshold": task_tdd_coverage_threshold,
3291
+ "task_tdd_baselines": task.get("tdd_baselines") if task else None,
3292
+ "displayed_tdd_enabled": displayed_tdd_enabled,
3293
+ "displayed_tdd_coverage_threshold": displayed_tdd_threshold,
2815
3294
  "spec_summary": spec_task_summary(task),
2816
3295
  # Compatibility output aliases for pre-0.9 clients.
2817
3296
  "project_confirm_mode": project_approval_mode,
@@ -2831,6 +3310,8 @@ def build_status_line(
2831
3310
  approval = str(state["effective_approval_mode"]).capitalize()
2832
3311
  workflow = str(state["concrete_workflow_mode"] or state["configured_workflow_mode"]).capitalize()
2833
3312
  status_brand = f"> **Easy Coding** · **Approval: {approval}** · **Workflow: {workflow}**"
3313
+ if state["displayed_tdd_enabled"] is True:
3314
+ status_brand += " · **TDD**"
2834
3315
  task_id = state["current_task"]
2835
3316
  if task_id:
2836
3317
  status = str(state["status"])
@@ -2874,6 +3355,11 @@ def build_machine_breadcrumbs(
2874
3355
  ]
2875
3356
  if state.get("concrete_workflow_mode"):
2876
3357
  lines.append(f"[easy-coding:workflow-mode:{state['concrete_workflow_mode']}]")
3358
+ if state.get("displayed_tdd_enabled") is True:
3359
+ lines.append("[easy-coding:tdd:enabled]")
3360
+ lines.append(
3361
+ f"[easy-coding:tdd-coverage-threshold:{state['displayed_tdd_coverage_threshold']}]"
3362
+ )
2877
3363
 
2878
3364
  if task_id:
2879
3365
  lines.append(f"[current-task:{task_id}]")
@@ -3181,6 +3667,43 @@ def clear_session_workflow_mode(
3181
3667
  return snapshot
3182
3668
 
3183
3669
 
3670
+ def set_session_tdd(
3671
+ root: Path,
3672
+ enabled: bool,
3673
+ agent: str,
3674
+ threshold: int | None = None,
3675
+ session_file: str | Path | None = None,
3676
+ ) -> dict:
3677
+ session = ensure_session(root, session_file)
3678
+ materialize_legacy_session_behavior(session)
3679
+ session["tdd_enabled"] = enabled
3680
+ if threshold is not None:
3681
+ session["tdd_coverage_threshold"] = parse_tdd_threshold(
3682
+ threshold, "session tdd_coverage_threshold"
3683
+ )
3684
+ session["last_agent"] = agent
3685
+ write_session(root, session, session_file)
3686
+ snapshot = snapshot_state(root, session_file, session)
3687
+ snapshot["action"] = "set-tdd"
3688
+ return snapshot
3689
+
3690
+
3691
+ def clear_session_tdd(
3692
+ root: Path,
3693
+ agent: str,
3694
+ session_file: str | Path | None = None,
3695
+ ) -> dict:
3696
+ session = ensure_session(root, session_file)
3697
+ materialize_legacy_session_behavior(session)
3698
+ session.pop("tdd_enabled", None)
3699
+ session.pop("tdd_coverage_threshold", None)
3700
+ session["last_agent"] = agent
3701
+ write_session(root, session, session_file)
3702
+ snapshot = snapshot_state(root, session_file, session)
3703
+ snapshot["action"] = "clear-tdd"
3704
+ return snapshot
3705
+
3706
+
3184
3707
  def set_harness_disabled(
3185
3708
  root: Path,
3186
3709
  disabled: bool,
@@ -3611,6 +4134,39 @@ def freeze_workflow_mode(
3611
4134
  task["workflow_mode_confirmed_by"] = agent
3612
4135
 
3613
4136
 
4137
+ def freeze_tdd_mode(
4138
+ root: Path, session: dict, task_id: str, task: dict, agent: str
4139
+ ) -> None:
4140
+ behavior = resolve_behavior(root, session)
4141
+ task_type = str(task.get("type") or "").strip().lower()
4142
+ task["tdd_enabled"] = behavior[8] if task_type not in NO_CODE_TASK_TYPES else False
4143
+ task["tdd_coverage_threshold"] = behavior[11]
4144
+ if task["tdd_enabled"] is True:
4145
+ plan = latest_execution_plan(root, task_id)
4146
+ if plan is None:
4147
+ raise StateError("Cannot freeze TDD baseline without a valid execution plan.")
4148
+ baselines = {
4149
+ key: git_head_sha(repository)
4150
+ for key, repository in tdd_repositories(root, task, plan).items()
4151
+ }
4152
+ task_dir = task_json_path(root, task_id).parent
4153
+ try:
4154
+ dev_spec_content = (task_dir / "dev-spec.md").read_text(encoding="utf-8")
4155
+ strategy_content = (task_dir / "test-strategy.md").read_text(encoding="utf-8")
4156
+ except OSError as error:
4157
+ raise StateError("Cannot freeze TDD without readable analysis artifacts.") from error
4158
+ marker_reasons = tdd_baseline_marker_reasons(
4159
+ dev_spec_content, strategy_content, baselines
4160
+ )
4161
+ if marker_reasons:
4162
+ raise StateError("; ".join(marker_reasons))
4163
+ task["tdd_baselines"] = baselines
4164
+ else:
4165
+ task.pop("tdd_baselines", None)
4166
+ task["tdd_confirmed_at"] = now_iso()
4167
+ task["tdd_confirmed_by"] = agent
4168
+
4169
+
3614
4170
  def raise_workflow_mode(
3615
4171
  root: Path,
3616
4172
  mode: str,
@@ -3676,7 +4232,7 @@ def request_transition(
3676
4232
  "use auto-transition instead."
3677
4233
  )
3678
4234
  if previous == "ANALYSIS" and stage == "IMPLEMENT":
3679
- validate_analysis_readiness(root, resolved_task_id)
4235
+ validate_analysis_readiness(root, resolved_task_id, session)
3680
4236
  if task.get("workflow_mode_legacy") is not True:
3681
4237
  validate_workflow_mode_proposal(
3682
4238
  root,
@@ -3729,9 +4285,10 @@ def apply_transition(
3729
4285
  if violation:
3730
4286
  raise StateError(violation)
3731
4287
  if previous == "ANALYSIS" and stage == "IMPLEMENT":
3732
- validate_analysis_readiness(root, resolved_task_id)
4288
+ validate_analysis_readiness(root, resolved_task_id, session)
3733
4289
  if task.get("workflow_mode_legacy") is not True:
3734
4290
  freeze_workflow_mode(root, session, resolved_task_id, task, agent)
4291
+ freeze_tdd_mode(root, session, resolved_task_id, task, agent)
3735
4292
  if previous == "REVIEW" and stage == "VERIFICATION":
3736
4293
  validate_review_readiness(root, resolved_task_id, task)
3737
4294
  if previous == "VERIFICATION" and stage == "MEMORY":
@@ -4156,6 +4713,14 @@ def main() -> int:
4156
4713
  clear_workflow_mode_parser = subcommands.add_parser("clear-workflow-mode", parents=[common])
4157
4714
  clear_workflow_mode_parser.add_argument("--agent", required=True)
4158
4715
 
4716
+ set_tdd_parser = subcommands.add_parser("set-tdd", parents=[common])
4717
+ set_tdd_parser.add_argument("--enabled", required=True, choices=["true", "false"])
4718
+ set_tdd_parser.add_argument("--threshold", type=int)
4719
+ set_tdd_parser.add_argument("--agent", required=True)
4720
+
4721
+ clear_tdd_parser = subcommands.add_parser("clear-tdd", parents=[common])
4722
+ clear_tdd_parser.add_argument("--agent", required=True)
4723
+
4159
4724
  # Compatibility aliases for pre-0.9 callers.
4160
4725
  set_confirm_mode_parser = subcommands.add_parser("set-confirm-mode", parents=[common])
4161
4726
  set_confirm_mode_parser.add_argument(
@@ -4441,6 +5006,30 @@ def main() -> int:
4441
5006
  session_file,
4442
5007
  )
4443
5008
  )
5009
+ elif command == "set-tdd":
5010
+ emit(
5011
+ attach_status_context(
5012
+ root,
5013
+ set_session_tdd(
5014
+ root,
5015
+ args.enabled == "true",
5016
+ agent,
5017
+ args.threshold,
5018
+ session_file,
5019
+ ),
5020
+ agent,
5021
+ session_file,
5022
+ )
5023
+ )
5024
+ elif command == "clear-tdd":
5025
+ emit(
5026
+ attach_status_context(
5027
+ root,
5028
+ clear_session_tdd(root, agent, session_file),
5029
+ agent,
5030
+ session_file,
5031
+ )
5032
+ )
4444
5033
  elif command == "propose-workflow-mode":
4445
5034
  emit(
4446
5035
  attach_status_context(