easy-coding-harness 0.10.0-beta.0 → 0.10.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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"
@@ -64,6 +66,7 @@ ALWAYS_AUTO_TRANSITIONS = {
64
66
  }
65
67
  READ_ONLY_COMPLETION_TRANSITION = ("IMPLEMENT", "COMPLETE")
66
68
  NO_CODE_TASK_TYPES = {"analysis", "doc", "report"}
69
+ TDD_INIT_TASK_TYPE = "tdd-init"
67
70
  APPROVAL_MODES = {"approve", "guard", "confirm", "auto"}
68
71
  CONFIGURED_WORKFLOW_MODES = {"adaptive", "fast", "standard", "strict"}
69
72
  WORKFLOW_MODES = {"fast", "standard", "strict"}
@@ -78,6 +81,16 @@ STRICT_WORKFLOW_RISK_PATTERN = re.compile(
78
81
  )
79
82
  DEFAULT_APPROVAL_MODE = "guard"
80
83
  DEFAULT_WORKFLOW_MODE = "adaptive"
84
+ DEFAULT_TDD_ENABLED = False
85
+ DEFAULT_TDD_COVERAGE_THRESHOLD = 90
86
+ TDD_READINESS_SCHEMA = "easy-coding/tdd-readiness-v1"
87
+ TDD_READINESS_SCOPE = "changed-production-lines"
88
+ TDD_READINESS_PATH = Path(".easy-coding/tdd/readiness.json")
89
+ TDD_BASE_VARIABLE = "EASY_CODING_TDD_BASE_SHA"
90
+ TDD_THRESHOLD_VARIABLE = "EASY_CODING_TDD_THRESHOLD"
91
+ COVERAGE_TOOL_PATH = ".easy-coding/tools/easy_coding_java_coverage.py"
92
+ JAVA_BUILD_FILE_NAMES = {"pom.xml", "build.gradle", "build.gradle.kts"}
93
+ GITLAB_CI_ENTRY_FILES = {".gitlab-ci.yml", ".gitlab-ci.yaml"}
81
94
  CRITICAL_CONFIRM_TRANSITIONS = {
82
95
  ("ANALYSIS", "IMPLEMENT"),
83
96
  ("VERIFICATION", "MEMORY"),
@@ -310,16 +323,45 @@ def read_memory_config(root: Path) -> dict[str, int]:
310
323
  return config
311
324
 
312
325
 
313
- def read_project_behavior(root: Path) -> tuple[str, str]:
326
+ def parse_tdd_threshold(value: object, source: str) -> int:
327
+ if isinstance(value, bool):
328
+ raise StateError(f"Invalid {source}: expected an integer from 1 to 100.")
329
+ try:
330
+ threshold = int(str(value))
331
+ except (TypeError, ValueError) as error:
332
+ raise StateError(f"Invalid {source}: expected an integer from 1 to 100.") from error
333
+ if threshold < 1 or threshold > 100:
334
+ raise StateError(f"Invalid {source}: expected an integer from 1 to 100.")
335
+ return threshold
336
+
337
+
338
+ def parse_yaml_bool(value: str | None, source: str) -> bool:
339
+ if value is None:
340
+ return DEFAULT_TDD_ENABLED
341
+ normalized = value.lower()
342
+ if normalized in {"true", "yes", "on"}:
343
+ return True
344
+ if normalized in {"false", "no", "off"}:
345
+ return False
346
+ raise StateError(f"Invalid {source}: expected true or false.")
347
+
348
+
349
+ def read_project_behavior(root: Path) -> tuple[str, str, bool, int]:
314
350
  path = root / ".easy-coding" / "config.yaml"
315
351
  try:
316
352
  lines = path.read_text(encoding="utf-8").splitlines()
317
353
  except OSError:
318
- return DEFAULT_APPROVAL_MODE, DEFAULT_WORKFLOW_MODE
354
+ return (
355
+ DEFAULT_APPROVAL_MODE,
356
+ DEFAULT_WORKFLOW_MODE,
357
+ DEFAULT_TDD_ENABLED,
358
+ DEFAULT_TDD_COVERAGE_THRESHOLD,
359
+ )
319
360
 
320
361
  in_behavior = False
321
362
  behavior_indent = 0
322
363
  behavior: dict[str, str] = {}
364
+ schema_version = 0
323
365
  for raw_line in lines:
324
366
  without_comment = raw_line.split("#", 1)[0].rstrip()
325
367
  stripped = without_comment.strip()
@@ -332,6 +374,12 @@ def read_project_behavior(root: Path) -> tuple[str, str]:
332
374
  continue
333
375
  if in_behavior and indent <= behavior_indent:
334
376
  in_behavior = False
377
+ if not in_behavior and indent == 0 and stripped.startswith("version:"):
378
+ try:
379
+ schema_version = int(stripped.split(":", 1)[1].strip().strip("'\""))
380
+ except ValueError:
381
+ schema_version = 0
382
+ continue
335
383
  if not in_behavior or ":" not in stripped:
336
384
  continue
337
385
  key, value = stripped.split(":", 1)
@@ -359,16 +407,200 @@ def read_project_behavior(root: Path) -> tuple[str, str]:
359
407
  "Invalid behavior.workflow_mode in .easy-coding/config.yaml: "
360
408
  "expected adaptive, fast, standard, or strict."
361
409
  )
362
- return approval_mode, workflow_mode
410
+ if schema_version >= 4:
411
+ tdd_enabled = (
412
+ parse_yaml_bool(behavior.get("tdd_enabled"), "behavior.tdd_enabled")
413
+ if schema_version >= 5
414
+ else DEFAULT_TDD_ENABLED
415
+ )
416
+ tdd_threshold = parse_tdd_threshold(
417
+ behavior.get("tdd_coverage_threshold", DEFAULT_TDD_COVERAGE_THRESHOLD),
418
+ "behavior.tdd_coverage_threshold",
419
+ )
420
+ else:
421
+ tdd_enabled = DEFAULT_TDD_ENABLED
422
+ tdd_threshold = DEFAULT_TDD_COVERAGE_THRESHOLD
423
+ return approval_mode, workflow_mode, tdd_enabled, tdd_threshold
424
+
425
+
426
+ def safe_tdd_report_pattern(value: object) -> bool:
427
+ if not is_non_empty_string(value):
428
+ return False
429
+ candidate = Path(str(value))
430
+ return not candidate.is_absolute() and ".." not in candidate.parts
431
+
432
+
433
+ def tdd_gate_uses_task_variables(command: object) -> bool:
434
+ if not is_non_empty_string(command):
435
+ return False
436
+ try:
437
+ tokens = shlex.split(str(command))
438
+ except ValueError:
439
+ return False
440
+ options: dict[str, str] = {}
441
+ for index, token in enumerate(tokens[:-1]):
442
+ if token in {"--base", "--threshold"}:
443
+ options[token] = tokens[index + 1]
444
+ return options.get("--base") in {
445
+ f"${TDD_BASE_VARIABLE}",
446
+ "$" + "{" + TDD_BASE_VARIABLE + "}",
447
+ } and options.get("--threshold") in {
448
+ f"${TDD_THRESHOLD_VARIABLE}",
449
+ "$" + "{" + TDD_THRESHOLD_VARIABLE + "}",
450
+ }
451
+
452
+
453
+ def tdd_ci_contract_reasons(contents: list[str]) -> list[str]:
454
+ combined = "\n".join(
455
+ re.sub(r"\s+#.*$", "", re.sub(r"^\s*#.*$", "", line))
456
+ for line in "\n".join(contents).splitlines()
457
+ )
458
+ lowered = combined.lower()
459
+ reasons: list[str] = []
460
+ for marker in (
461
+ "jacoco",
462
+ "artifacts",
463
+ COVERAGE_TOOL_PATH,
464
+ TDD_BASE_VARIABLE,
465
+ TDD_THRESHOLD_VARIABLE,
466
+ ):
467
+ if marker.lower() not in lowered:
468
+ reasons.append(f"CI files do not contain required marker: {marker}")
469
+ if not tdd_gate_uses_task_variables(combined):
470
+ reasons.append(
471
+ "CI changed-line gate must use the task baseline and threshold variables"
472
+ )
473
+ if re.search(
474
+ r"(?:^|\n)\s*stage\s*:\s*['\"]?test['\"]?\s*(?:#.*)?(?:\n|$)",
475
+ combined,
476
+ re.IGNORECASE,
477
+ ) is None:
478
+ reasons.append("CI files do not declare a TEST-stage job")
479
+ return reasons
480
+
481
+
482
+ def tdd_readiness(root: Path) -> dict[str, object]:
483
+ receipt = root / TDD_READINESS_PATH
484
+ if not receipt.is_file():
485
+ return {"status": "needs_init", "reasons": ["TDD readiness receipt is missing"]}
486
+ try:
487
+ manifest = json.loads(receipt.read_text(encoding="utf-8"))
488
+ except (OSError, UnicodeError, json.JSONDecodeError):
489
+ return {"status": "needs_init", "reasons": ["TDD readiness receipt is invalid"]}
490
+ if not isinstance(manifest, dict):
491
+ return {
492
+ "status": "needs_init",
493
+ "reasons": ["TDD readiness receipt must be a JSON object"],
494
+ }
495
+
496
+ reasons: list[str] = []
497
+ if manifest.get("schema") != TDD_READINESS_SCHEMA:
498
+ reasons.append("unsupported readiness schema")
499
+ if manifest.get("provider") != "gitlab":
500
+ reasons.append("readiness provider must be gitlab")
501
+ if manifest.get("coverage_scope") != TDD_READINESS_SCOPE:
502
+ reasons.append("coverage scope must be changed-production-lines")
503
+ if manifest.get("historical_coverage_required") is not False:
504
+ reasons.append("historical coverage must remain disabled")
505
+ reports = manifest.get("coverage_report_patterns")
506
+ if not isinstance(reports, list) or not reports or not all(
507
+ safe_tdd_report_pattern(item) for item in reports
508
+ ):
509
+ reasons.append(
510
+ "coverage_report_patterns must contain safe project-relative report patterns"
511
+ )
512
+ gate = manifest.get("changed_line_gate_command")
513
+ if not is_non_empty_string(gate) or COVERAGE_TOOL_PATH not in str(gate):
514
+ reasons.append("changed-line coverage gate command is missing")
515
+ elif not tdd_gate_uses_task_variables(gate):
516
+ reasons.append(
517
+ "changed-line coverage gate must use the task baseline and threshold variables"
518
+ )
519
+
520
+ contents: dict[str, list[str]] = {
521
+ "build_files": [],
522
+ "ci_files": [],
523
+ "tool_files": [],
524
+ }
525
+ for field in contents:
526
+ records = manifest.get(field)
527
+ if not isinstance(records, list) or not records:
528
+ reasons.append(f"{field} must contain at least one file")
529
+ continue
530
+ for record in records:
531
+ if not isinstance(record, dict):
532
+ reasons.append(f"{field} contains an invalid record")
533
+ continue
534
+ file_name = record.get("path")
535
+ expected = record.get("sha256")
536
+ if not is_non_empty_string(file_name) or not re.fullmatch(
537
+ r"[a-f0-9]{64}", str(expected or "")
538
+ ):
539
+ reasons.append(f"{field} contains an invalid path or SHA-256")
540
+ continue
541
+ candidate = Path(str(file_name))
542
+ if candidate.is_absolute():
543
+ reasons.append(f"readiness file must be project-relative: {file_name}")
544
+ continue
545
+ resolved = (root / candidate).resolve()
546
+ try:
547
+ resolved.relative_to(root.resolve())
548
+ payload = resolved.read_bytes()
549
+ contents[field].append(payload.decode("utf-8"))
550
+ if hashlib.sha256(payload).hexdigest() != expected:
551
+ reasons.append(f"readiness file changed: {file_name}")
552
+ except (OSError, UnicodeError, ValueError):
553
+ reasons.append(f"readiness file is missing or unreadable: {file_name}")
554
+
555
+ manifest_build_files = manifest.get("build_files")
556
+ manifest_ci_files = manifest.get("ci_files")
557
+ manifest_tool_files = manifest.get("tool_files")
558
+ build_paths = {
559
+ Path(str(item.get("path", ""))).name
560
+ for item in manifest_build_files
561
+ if isinstance(item, dict) and is_non_empty_string(item.get("path"))
562
+ } if isinstance(manifest_build_files, list) else set()
563
+ ci_paths = {
564
+ str(item.get("path", "")).replace("\\", "/")
565
+ for item in manifest_ci_files
566
+ if isinstance(item, dict) and is_non_empty_string(item.get("path"))
567
+ } if isinstance(manifest_ci_files, list) else set()
568
+ if not build_paths.intersection(JAVA_BUILD_FILE_NAMES):
569
+ reasons.append("build_files must include a Maven or Gradle Java build file")
570
+ if not ci_paths.intersection(GITLAB_CI_ENTRY_FILES):
571
+ reasons.append("ci_files must include the project-root GitLab CI entry file")
572
+ tool_paths = {
573
+ str(item.get("path", "")).replace("\\", "/")
574
+ for item in manifest_tool_files
575
+ if isinstance(item, dict) and is_non_empty_string(item.get("path"))
576
+ } if isinstance(manifest_tool_files, list) else set()
577
+ if COVERAGE_TOOL_PATH not in tool_paths:
578
+ reasons.append(f"tool_files must include {COVERAGE_TOOL_PATH}")
579
+ if not any("jacoco" in content.lower() for content in contents["build_files"]):
580
+ reasons.append("build files do not configure JaCoCo")
581
+ reasons.extend(tdd_ci_contract_reasons(contents["ci_files"]))
582
+ return {
583
+ "status": "ready" if not reasons else "needs_init",
584
+ "reasons": list(dict.fromkeys(reasons)),
585
+ }
586
+
587
+
588
+ def require_tdd_readiness(root: Path) -> None:
589
+ readiness = tdd_readiness(root)
590
+ if readiness["status"] != "ready":
591
+ reasons = "; ".join(str(reason) for reason in readiness["reasons"])
592
+ raise StateError(f"TDD cannot be enabled before ec-tdd-init succeeds: {reasons}")
363
593
 
364
594
 
365
595
  def resolve_behavior(
366
596
  root: Path, session: dict
367
- ) -> tuple[str, str | None, str, str, str | None, str]:
368
- project_approval, project_workflow = read_project_behavior(root)
597
+ ) -> tuple[str, str | None, str, str, str | None, str, bool, bool | None, bool, int, int | None, int]:
598
+ project_approval, project_workflow, project_tdd, project_threshold = read_project_behavior(root)
369
599
  legacy = session.get("confirm_mode")
370
600
  session_approval = session.get("approval_mode")
371
601
  session_workflow = session.get("workflow_mode")
602
+ session_tdd = session.get("tdd_enabled")
603
+ session_threshold = session.get("tdd_coverage_threshold")
372
604
  if session_approval is None:
373
605
  if legacy == "lite":
374
606
  session_approval = "guard"
@@ -387,6 +619,12 @@ def resolve_behavior(
387
619
  raise StateError(
388
620
  "Invalid session workflow_mode: expected adaptive, fast, standard, or strict."
389
621
  )
622
+ if session_tdd is not None and not isinstance(session_tdd, bool):
623
+ raise StateError("Invalid session tdd_enabled: expected true or false.")
624
+ if session_threshold is not None:
625
+ session_threshold = parse_tdd_threshold(
626
+ session_threshold, "session tdd_coverage_threshold"
627
+ )
390
628
  return (
391
629
  project_approval,
392
630
  str(session_approval) if session_approval else None,
@@ -394,6 +632,12 @@ def resolve_behavior(
394
632
  project_workflow,
395
633
  str(session_workflow) if session_workflow else None,
396
634
  str(session_workflow or project_workflow),
635
+ project_tdd,
636
+ session_tdd,
637
+ session_tdd if session_tdd is not None else project_tdd,
638
+ project_threshold,
639
+ session_threshold,
640
+ session_threshold if session_threshold is not None else project_threshold,
397
641
  )
398
642
 
399
643
 
@@ -1624,6 +1868,77 @@ def task_repository_roots(root: Path, task: dict | None, plan: dict) -> list[Pat
1624
1868
  ]
1625
1869
 
1626
1870
 
1871
+ def tdd_repositories(root: Path, task: dict, plan: dict) -> dict[str, Path]:
1872
+ if isinstance(task.get("spec_source"), dict):
1873
+ repo_paths = task.get("repo_paths")
1874
+ if not isinstance(repo_paths, dict):
1875
+ raise StateError("TDD Canonical task is missing repository bindings.")
1876
+ repositories: dict[str, Path] = {}
1877
+ for unit in plan.get("units", []):
1878
+ if not isinstance(unit, dict) or not is_non_empty_string(unit.get("repo_id")):
1879
+ raise StateError("TDD Canonical unit is missing repo_id.")
1880
+ repo_id = str(unit["repo_id"])
1881
+ raw_path = repo_paths.get(repo_id)
1882
+ if not is_non_empty_string(raw_path):
1883
+ raise StateError(f"TDD repository path is missing: {repo_id}")
1884
+ candidate = Path(str(raw_path))
1885
+ resolved = (candidate if candidate.is_absolute() else root / candidate).resolve()
1886
+ repository = git_repository_root(resolved)
1887
+ if repository is None or repository.resolve() != resolved:
1888
+ raise StateError(f"TDD repository binding is not a Git root: {repo_id}")
1889
+ repositories[repo_id] = repository
1890
+ return repositories
1891
+
1892
+ repositories = task_repository_roots(root, task, plan)
1893
+ if len(repositories) != 1:
1894
+ raise StateError(
1895
+ "Non-Canonical TDD requires exactly one Git repository; use a Canonical Spec for multi-repository work."
1896
+ )
1897
+ return {"project": repositories[0]}
1898
+
1899
+
1900
+ def git_head_sha(repository: Path) -> str:
1901
+ result = run_git(repository, "rev-parse", "--verify", "HEAD")
1902
+ sha = result.stdout.decode("ascii", errors="ignore").strip() if result else ""
1903
+ if (
1904
+ result is None
1905
+ or result.returncode != 0
1906
+ or re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", sha) is None
1907
+ ):
1908
+ raise StateError(f"Cannot freeze TDD Git baseline for {repository.name}.")
1909
+ return sha
1910
+
1911
+
1912
+ def tdd_baseline_marker_reasons(
1913
+ dev_spec_content: str, strategy_content: str, baselines: dict[str, str]
1914
+ ) -> list[str]:
1915
+ reasons: list[str] = []
1916
+ canonical = set(baselines) != {"project"}
1917
+ for repo_id, baseline in sorted(baselines.items()):
1918
+ for artifact_name, content in (
1919
+ ("dev-spec.md", dev_spec_content),
1920
+ ("test-strategy.md", strategy_content),
1921
+ ):
1922
+ if baseline not in content:
1923
+ reasons.append(
1924
+ f"{artifact_name} must record the immutable TDD baseline SHA for {repo_id}: {baseline}"
1925
+ )
1926
+ if canonical and repo_id not in content:
1927
+ reasons.append(
1928
+ f"{artifact_name} must map the TDD baseline to repository {repo_id}"
1929
+ )
1930
+ return reasons
1931
+
1932
+
1933
+ def contains_tdd_threshold(content: str, threshold: int) -> bool:
1934
+ return re.search(
1935
+ rf"(?<!\d){threshold}\s*%|--threshold(?:\s+|=){threshold}(?!\d)|"
1936
+ rf"tdd_coverage_threshold\s*[:=]\s*{threshold}(?!\d)",
1937
+ content,
1938
+ re.IGNORECASE,
1939
+ ) is not None
1940
+
1941
+
1627
1942
  def repository_scope_pathspecs(repository: Path, scopes: list[Path]) -> list[str]:
1628
1943
  return [
1629
1944
  f":(literal){scope.relative_to(repository).as_posix()}"
@@ -1837,6 +2152,18 @@ def implementation_fingerprint(root: Path, task_id: str) -> str:
1837
2152
  digest.update(b"workflow-mode\0")
1838
2153
  digest.update(workflow_mode.encode("utf-8"))
1839
2154
  digest.update(b"\0")
2155
+ if task and task.get("tdd_enabled") is True:
2156
+ digest.update(b"tdd\0enabled\0")
2157
+ digest.update(str(task.get("tdd_coverage_threshold") or "").encode("utf-8"))
2158
+ digest.update(b"\0")
2159
+ digest.update(
2160
+ json.dumps(
2161
+ task.get("tdd_baselines") or {},
2162
+ sort_keys=True,
2163
+ separators=(",", ":"),
2164
+ ).encode("utf-8")
2165
+ )
2166
+ digest.update(b"\0")
1840
2167
  digest.update(b"execution-plan\0")
1841
2168
  digest.update(
1842
2169
  json.dumps(
@@ -1901,23 +2228,93 @@ def implementation_fingerprint(root: Path, task_id: str) -> str:
1901
2228
  return digest.hexdigest()
1902
2229
 
1903
2230
 
1904
- def behavior_config_fingerprint(root: Path) -> str:
2231
+ def config_without_frozen_tdd_settings(payload: bytes) -> bytes:
2232
+ """任务冻结 TDD 契约后,从证据指纹中排除仅影响未来任务的实时 TDD 配置。"""
2233
+ try:
2234
+ lines = payload.decode("utf-8").splitlines(keepends=True)
2235
+ except UnicodeDecodeError:
2236
+ return payload
2237
+ filtered: list[str] = []
2238
+ in_behavior = False
2239
+ behavior_indent = 0
2240
+ behavior_key_indent: int | None = None
2241
+ for line in lines:
2242
+ clean = line.split("#", 1)[0].rstrip()
2243
+ stripped = clean.strip()
2244
+ indent = len(clean) - len(clean.lstrip(" "))
2245
+ if stripped == "behavior:":
2246
+ in_behavior = True
2247
+ behavior_indent = indent
2248
+ behavior_key_indent = None
2249
+ filtered.append(line)
2250
+ continue
2251
+ if in_behavior and stripped and indent <= behavior_indent:
2252
+ in_behavior = False
2253
+ if in_behavior and stripped:
2254
+ if behavior_key_indent is None:
2255
+ behavior_key_indent = indent
2256
+ key = stripped.split(":", 1)[0]
2257
+ if (
2258
+ indent == behavior_key_indent
2259
+ and key in {"tdd_enabled", "tdd_coverage_threshold"}
2260
+ ):
2261
+ continue
2262
+ filtered.append(line)
2263
+ return "".join(filtered).encode("utf-8")
2264
+
2265
+
2266
+ def behavior_config_fingerprint(root: Path, task: dict | None = None) -> str:
1905
2267
  path = root / ".easy-coding" / "config.yaml"
1906
2268
  digest = hashlib.sha256()
1907
2269
  try:
1908
- digest.update(path.read_bytes())
2270
+ payload = path.read_bytes()
2271
+ if task and isinstance(task.get("tdd_enabled"), bool):
2272
+ payload = config_without_frozen_tdd_settings(payload)
2273
+ digest.update(payload)
1909
2274
  except OSError:
1910
2275
  digest.update(b"<missing-config>")
1911
2276
  return digest.hexdigest()
1912
2277
 
1913
2278
 
1914
2279
  def evidence_fingerprints(root: Path, task_id: str) -> dict[str, str]:
2280
+ task = load_task(root, task_id)
1915
2281
  return {
1916
2282
  "implementation_fingerprint": implementation_fingerprint(root, task_id),
1917
- "config_fingerprint": behavior_config_fingerprint(root),
2283
+ "config_fingerprint": behavior_config_fingerprint(root, task),
1918
2284
  }
1919
2285
 
1920
2286
 
2287
+ def command_option_value(command: str, option: str) -> str | None:
2288
+ try:
2289
+ tokens = shlex.split(command)
2290
+ except ValueError:
2291
+ return None
2292
+ for index, token in enumerate(tokens):
2293
+ if token == option and index + 1 < len(tokens):
2294
+ return tokens[index + 1]
2295
+ prefix = f"{option}="
2296
+ if token.startswith(prefix):
2297
+ return token[len(prefix) :]
2298
+ return None
2299
+
2300
+
2301
+ def coverage_command_matches_frozen_contract(
2302
+ command: object, baseline: str, threshold: int
2303
+ ) -> bool:
2304
+ if not is_non_empty_string(command):
2305
+ return False
2306
+ try:
2307
+ tokens = shlex.split(str(command))
2308
+ except ValueError:
2309
+ return False
2310
+ return (
2311
+ any(Path(token).name == "easy_coding_java_coverage.py" for token in tokens)
2312
+ and "check" in tokens
2313
+ and command_option_value(str(command), "--base") == baseline
2314
+ and command_option_value(str(command), "--threshold") == str(threshold)
2315
+ )
2316
+
2317
+
1921
2318
  def validate_spec_implementation_results(root: Path, task_id: str, task: dict) -> None:
1922
2319
  if not isinstance(task.get("spec_source"), dict):
1923
2320
  return
@@ -2074,6 +2471,25 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
2074
2471
  raise StateError(
2075
2472
  "REVIEW cannot advance to VERIFICATION while a current review dimension is not passed or has error findings."
2076
2473
  )
2474
+ if task.get("tdd_enabled") is True:
2475
+ if is_spec_task:
2476
+ missing_tdd_reviews = sorted(
2477
+ source_task_id
2478
+ for source_task_id, dimensions in reviewed_dimensions.items()
2479
+ if "tdd" not in {dimension.lower() for dimension in dimensions}
2480
+ )
2481
+ if missing_tdd_reviews:
2482
+ raise StateError(
2483
+ "TDD tasks require a passed TDD review dimension for every selected source task: "
2484
+ + ", ".join(missing_tdd_reviews)
2485
+ )
2486
+ elif not any(
2487
+ str(record.get("dimension") or "").lower() == "tdd"
2488
+ for record in latest_by_dimension.values()
2489
+ ):
2490
+ raise StateError(
2491
+ "TDD tasks require a passed TDD review dimension for test quality, boundaries, and mocking."
2492
+ )
2077
2493
  if task.get("workflow_mode") == "strict":
2078
2494
  if is_spec_task:
2079
2495
  missing_strict_dimensions = sorted(
@@ -2111,6 +2527,8 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
2111
2527
  and is_non_empty_string(record.get("check"))
2112
2528
  ):
2113
2529
  check = str(record["check"])
2530
+ if task.get("tdd_enabled") is True and record.get("check_type") == "coverage":
2531
+ check = f"{check}\0{record.get('coverage_scope') or ''}"
2114
2532
  if is_spec_task:
2115
2533
  check = f"{check}\0{record.get('source_task_id') or ''}"
2116
2534
  previous = latest_by_check.get(check)
@@ -2129,7 +2547,7 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
2129
2547
  for record in latest_by_check.values():
2130
2548
  check_type = str(record.get("check_type") or "")
2131
2549
  if (
2132
- check_type not in STRICT_VERIFICATION_CHECK_TYPES
2550
+ check_type not in STRICT_VERIFICATION_CHECK_TYPES | {"coverage"}
2133
2551
  or not is_non_empty_string(record.get("timestamp"))
2134
2552
  or (
2135
2553
  record.get("applicable") is not False
@@ -2175,6 +2593,147 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
2175
2593
  raise StateError(
2176
2594
  "VERIFICATION cannot advance to MEMORY while current verification evidence contains failures."
2177
2595
  )
2596
+ if str(task.get("type") or "").strip().lower() == TDD_INIT_TASK_TYPE:
2597
+ readiness = tdd_readiness(root)
2598
+ if readiness["status"] != "ready":
2599
+ raise StateError(
2600
+ "TDD initialization cannot advance to MEMORY until readiness passes: "
2601
+ + "; ".join(str(reason) for reason in readiness["reasons"])
2602
+ )
2603
+ if task.get("tdd_enabled") is not True and any(
2604
+ record.get("check_type") == "coverage" for record in latest_by_check.values()
2605
+ ):
2606
+ raise StateError(
2607
+ "Coverage verification evidence is not allowed when the frozen TDD mode is off."
2608
+ )
2609
+ if task.get("tdd_enabled") is True:
2610
+ require_tdd_readiness(root)
2611
+ coverage_records = [
2612
+ record
2613
+ for record in latest_by_check.values()
2614
+ if record.get("check_type") == "coverage"
2615
+ ]
2616
+ if not coverage_records:
2617
+ raise StateError(
2618
+ "TDD verification requires changed-production-line JaCoCo coverage evidence."
2619
+ )
2620
+ if is_spec_task:
2621
+ covered_source_tasks = {
2622
+ str(record.get("source_task_id") or "") for record in coverage_records
2623
+ }
2624
+ missing_coverage_tasks = sorted(
2625
+ set(task_repositories) - covered_source_tasks
2626
+ )
2627
+ if missing_coverage_tasks:
2628
+ raise StateError(
2629
+ "TDD Canonical verification requires separate coverage evidence for every selected source task: "
2630
+ + ", ".join(missing_coverage_tasks)
2631
+ )
2632
+ coverage_scopes_by_owner: dict[str, set[str]] = {}
2633
+ for record in coverage_records:
2634
+ scope = str(record.get("coverage_scope") or "")
2635
+ if scope not in {"local", "gitlab"}:
2636
+ raise StateError(
2637
+ "TDD coverage evidence must identify coverage_scope as local or gitlab."
2638
+ )
2639
+ owner = (
2640
+ str(record.get("source_task_id") or "")
2641
+ if is_spec_task
2642
+ else "project"
2643
+ )
2644
+ coverage_scopes_by_owner.setdefault(owner, set()).add(scope)
2645
+ expected_threshold = task.get("tdd_coverage_threshold")
2646
+ expected_baselines = task.get("tdd_baselines")
2647
+ if (
2648
+ type(expected_threshold) is not int
2649
+ or expected_threshold < 1
2650
+ or expected_threshold > 100
2651
+ ):
2652
+ raise StateError("TDD task is missing a valid frozen coverage threshold.")
2653
+ if not isinstance(expected_baselines, dict) or not expected_baselines:
2654
+ raise StateError("TDD task is missing frozen Git baselines.")
2655
+ for record in coverage_records:
2656
+ coverage = record.get("coverage")
2657
+ if not isinstance(coverage, dict):
2658
+ raise StateError("TDD coverage evidence must include the coverage result object.")
2659
+ if record.get("coverage_scope") == "gitlab":
2660
+ ci = record.get("ci")
2661
+ if (
2662
+ not isinstance(ci, dict)
2663
+ or ci.get("provider") != "gitlab"
2664
+ or ci.get("status") != "success"
2665
+ or not is_non_empty_string(ci.get("pipeline_url"))
2666
+ or not is_non_empty_string(ci.get("job_name"))
2667
+ ):
2668
+ raise StateError(
2669
+ "GitLab coverage evidence requires a successful pipeline URL and job name."
2670
+ )
2671
+ total = coverage.get("total_lines")
2672
+ covered = coverage.get("covered_lines")
2673
+ percentage = coverage.get("percentage")
2674
+ threshold = coverage.get("threshold")
2675
+ baseline_key = str(record.get("repo_id") or "") if is_spec_task else "project"
2676
+ expected_baseline = expected_baselines.get(baseline_key)
2677
+ if (
2678
+ not is_non_empty_string(expected_baseline)
2679
+ or coverage.get("baseline_sha") != expected_baseline
2680
+ or re.fullmatch(
2681
+ r"[0-9a-f]{40}|[0-9a-f]{64}", str(coverage.get("baseline_sha") or "")
2682
+ )
2683
+ is None
2684
+ or not isinstance(total, int)
2685
+ or not isinstance(covered, int)
2686
+ or not isinstance(percentage, (int, float))
2687
+ or threshold != expected_threshold
2688
+ or not isinstance(coverage.get("report_paths"), list)
2689
+ or not coverage.get("report_paths")
2690
+ or not all(
2691
+ is_non_empty_string(path) for path in coverage.get("report_paths", [])
2692
+ )
2693
+ or not re.fullmatch(
2694
+ r"[0-9a-f]{64}", str(coverage.get("report_sha256") or "")
2695
+ )
2696
+ or covered < 0
2697
+ or total < 0
2698
+ or covered > total
2699
+ or percentage < 0
2700
+ or percentage > 100
2701
+ or not coverage_command_matches_frozen_contract(
2702
+ record.get("command"), str(expected_baseline), int(expected_threshold)
2703
+ )
2704
+ ):
2705
+ raise StateError(
2706
+ "TDD coverage evidence must preserve the exact gate command, baseline, counts, percentage, frozen threshold, reports, and report fingerprint."
2707
+ )
2708
+ if total == 0:
2709
+ if record.get("applicable") is not False or record.get("passed") is not True:
2710
+ raise StateError(
2711
+ "Coverage with no modified executable production Java lines must be explicit N/A."
2712
+ )
2713
+ elif abs(percentage - round(covered * 100.0 / total, 2)) > 0.01:
2714
+ raise StateError(
2715
+ "TDD coverage evidence percentage does not match covered/total counts."
2716
+ )
2717
+ elif (
2718
+ record.get("applicable") is False
2719
+ or record.get("passed") is not True
2720
+ or percentage < threshold
2721
+ ):
2722
+ raise StateError(
2723
+ f"TDD changed-line coverage must meet the frozen {threshold}% threshold."
2724
+ )
2725
+ expected_coverage_owners = set(task_repositories) if is_spec_task else {"project"}
2726
+ missing_scopes = [
2727
+ f"{owner}:{scope}"
2728
+ for owner in sorted(expected_coverage_owners)
2729
+ for scope in ("local", "gitlab")
2730
+ if scope not in coverage_scopes_by_owner.get(owner, set())
2731
+ ]
2732
+ if missing_scopes:
2733
+ raise StateError(
2734
+ "TDD verification requires both local and successful GitLab coverage gates: "
2735
+ + ", ".join(missing_scopes)
2736
+ )
2178
2737
  if task.get("workflow_mode") == "strict":
2179
2738
  if is_spec_task:
2180
2739
  check_types_by_repository: dict[str, set[str]] = {
@@ -2445,7 +3004,9 @@ def validate_mandatory_dev_spec_sections(content: str) -> tuple[list[str], list[
2445
3004
  return missing, empty
2446
3005
 
2447
3006
 
2448
- def validate_analysis_readiness(root: Path, task_id: str) -> None:
3007
+ def validate_analysis_readiness(
3008
+ root: Path, task_id: str, session: dict | None = None
3009
+ ) -> None:
2449
3010
  task_dir = task_json_path(root, task_id).parent
2450
3011
  task = load_task(root, task_id)
2451
3012
  task_type = str(task.get("type") or "").strip().lower() if task else ""
@@ -2454,6 +3015,9 @@ def validate_analysis_readiness(root: Path, task_id: str) -> None:
2454
3015
  skeleton = root / ".easy-coding" / "templates" / "dev-spec-skeleton.md"
2455
3016
  test_strategy = task_dir / "test-strategy.md"
2456
3017
  reasons: list[str] = []
3018
+ behavior = resolve_behavior(root, session or default_session())
3019
+ tdd_enabled = behavior[8] if task_type != TDD_INIT_TASK_TYPE else False
3020
+ tdd_threshold = behavior[11]
2457
3021
 
2458
3022
  dev_spec_content = ""
2459
3023
  if not dev_spec.exists():
@@ -2500,6 +3064,113 @@ def validate_analysis_readiness(root: Path, task_id: str) -> None:
2500
3064
  plan_is_valid = has_valid_execution_plan(root, task_id)
2501
3065
  if not plan_is_valid:
2502
3066
  reasons.append("execution.jsonl has no valid plan record")
3067
+ if tdd_enabled and not is_read_only_task:
3068
+ readiness = tdd_readiness(root)
3069
+ if readiness["status"] != "ready":
3070
+ reasons.append(
3071
+ "TDD infrastructure is not ready; run ec-tdd-init first: "
3072
+ + "; ".join(str(reason) for reason in readiness["reasons"])
3073
+ )
3074
+ plan = latest_execution_plan(root, task_id) or {}
3075
+ if re.search(
3076
+ r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
3077
+ ) is None:
3078
+ reasons.append("dev-spec.md is missing the required TDD Mode section")
3079
+ planned_files = [
3080
+ str(file_name)
3081
+ for unit in plan.get("units", [])
3082
+ if isinstance(unit, dict)
3083
+ for file_name in unit.get("files", [])
3084
+ ]
3085
+ if not any(file_name.endswith(".java") for file_name in planned_files):
3086
+ reasons.append("TDD is enabled but the confirmed implementation scope has no Java source")
3087
+ baselines: dict[str, str] = {}
3088
+ if task:
3089
+ try:
3090
+ repositories = tdd_repositories(root, task, plan)
3091
+ baselines = {
3092
+ repo_id: git_head_sha(repository)
3093
+ for repo_id, repository in repositories.items()
3094
+ }
3095
+ except StateError as error:
3096
+ reasons.append(str(error))
3097
+ try:
3098
+ strategy_content = test_strategy.read_text(encoding="utf-8")
3099
+ except OSError:
3100
+ strategy_content = ""
3101
+ required_tdd_markers = ["TDD", "JaCoCo", "baseline", "GitLab"]
3102
+ missing_tdd_markers = [
3103
+ marker for marker in required_tdd_markers if marker.lower() not in strategy_content.lower()
3104
+ ]
3105
+ if missing_tdd_markers:
3106
+ reasons.append(
3107
+ "TDD test strategy is missing: " + ", ".join(missing_tdd_markers)
3108
+ )
3109
+ if not contains_tdd_threshold(strategy_content, tdd_threshold):
3110
+ reasons.append(
3111
+ f"TDD test strategy must state the frozen {tdd_threshold}% coverage threshold"
3112
+ )
3113
+ if not contains_tdd_threshold(dev_spec_content, tdd_threshold):
3114
+ reasons.append(
3115
+ f"TDD dev spec must state the frozen {tdd_threshold}% coverage threshold"
3116
+ )
3117
+ if baselines:
3118
+ reasons.extend(
3119
+ tdd_baseline_marker_reasons(
3120
+ dev_spec_content, strategy_content, baselines
3121
+ )
3122
+ )
3123
+ elif task_type == TDD_INIT_TASK_TYPE:
3124
+ try:
3125
+ strategy_content = test_strategy.read_text(encoding="utf-8")
3126
+ except OSError:
3127
+ strategy_content = ""
3128
+ required_init_markers = [
3129
+ "JaCoCo",
3130
+ "GitLab",
3131
+ "changed production lines",
3132
+ "historical coverage required: no",
3133
+ "easy_coding_tdd_readiness.py",
3134
+ ]
3135
+ missing_init_markers = [
3136
+ marker
3137
+ for marker in required_init_markers
3138
+ if marker.lower() not in strategy_content.lower()
3139
+ ]
3140
+ if missing_init_markers:
3141
+ reasons.append(
3142
+ "TDD initialization strategy is missing: "
3143
+ + ", ".join(missing_init_markers)
3144
+ )
3145
+ if re.search(
3146
+ r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
3147
+ ):
3148
+ reasons.append("tdd-init must keep TDD off and omit the TDD Mode section")
3149
+ elif not is_read_only_task:
3150
+ if re.search(
3151
+ r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
3152
+ ):
3153
+ reasons.append("dev-spec.md must omit the TDD Mode section when TDD is disabled")
3154
+ try:
3155
+ strategy_content = test_strategy.read_text(encoding="utf-8")
3156
+ except OSError:
3157
+ strategy_content = ""
3158
+ forbidden_tdd_markers = [
3159
+ marker
3160
+ for marker in (
3161
+ "easy_coding_java_coverage.py",
3162
+ "coverage_scope",
3163
+ "task.tdd_baselines",
3164
+ "RED -> GREEN -> REFACTOR",
3165
+ "RED/GREEN/REFACTOR",
3166
+ )
3167
+ if marker.lower() in strategy_content.lower()
3168
+ ]
3169
+ if forbidden_tdd_markers:
3170
+ reasons.append(
3171
+ "test-strategy.md contains TDD-only planning while TDD is disabled: "
3172
+ + ", ".join(forbidden_tdd_markers)
3173
+ )
2503
3174
  if task and isinstance(task.get("spec_source"), dict):
2504
3175
  try:
2505
3176
  inspection, selection = inspect_task_spec(root, task)
@@ -2785,6 +3456,12 @@ def snapshot_state(
2785
3456
  project_workflow_mode,
2786
3457
  session_workflow_mode,
2787
3458
  configured_workflow_mode,
3459
+ project_tdd_enabled,
3460
+ session_tdd_enabled,
3461
+ effective_tdd_enabled,
3462
+ project_tdd_coverage_threshold,
3463
+ session_tdd_coverage_threshold,
3464
+ effective_tdd_coverage_threshold,
2788
3465
  ) = resolve_behavior(root, resolved_session)
2789
3466
  concrete_workflow_mode = None
2790
3467
  if task:
@@ -2792,6 +3469,32 @@ def snapshot_state(
2792
3469
  proposal = task.get("workflow_mode_proposal")
2793
3470
  if concrete_workflow_mode is None and isinstance(proposal, dict):
2794
3471
  concrete_workflow_mode = proposal.get("selected_mode")
3472
+ task_tdd_enabled = task.get("tdd_enabled") if task else None
3473
+ task_tdd_coverage_threshold = task.get("tdd_coverage_threshold") if task else None
3474
+ frozen_tdd = bool(
3475
+ task
3476
+ and status not in {"ANALYSIS", "INIT"}
3477
+ and isinstance(task_tdd_enabled, bool)
3478
+ )
3479
+ is_tdd_init = bool(
3480
+ task and str(task.get("type") or "").strip().lower() == TDD_INIT_TASK_TYPE
3481
+ )
3482
+ displayed_tdd_enabled = (
3483
+ False if is_tdd_init else task_tdd_enabled if frozen_tdd else effective_tdd_enabled
3484
+ )
3485
+ displayed_tdd_threshold = (
3486
+ task_tdd_coverage_threshold
3487
+ if frozen_tdd and isinstance(task_tdd_coverage_threshold, int)
3488
+ else effective_tdd_coverage_threshold
3489
+ )
3490
+ should_check_readiness = bool(
3491
+ effective_tdd_enabled or task_tdd_enabled is True or is_tdd_init
3492
+ )
3493
+ readiness = (
3494
+ tdd_readiness(root)
3495
+ if should_check_readiness
3496
+ else {"status": "not_checked", "reasons": []}
3497
+ )
2795
3498
 
2796
3499
  return {
2797
3500
  "session_file": display_path(root, session_path),
@@ -2812,6 +3515,19 @@ def snapshot_state(
2812
3515
  "session_workflow_mode": session_workflow_mode,
2813
3516
  "configured_workflow_mode": configured_workflow_mode,
2814
3517
  "concrete_workflow_mode": concrete_workflow_mode,
3518
+ "project_tdd_enabled": project_tdd_enabled,
3519
+ "session_tdd_enabled": session_tdd_enabled,
3520
+ "effective_tdd_enabled": effective_tdd_enabled,
3521
+ "project_tdd_coverage_threshold": project_tdd_coverage_threshold,
3522
+ "session_tdd_coverage_threshold": session_tdd_coverage_threshold,
3523
+ "effective_tdd_coverage_threshold": effective_tdd_coverage_threshold,
3524
+ "task_tdd_enabled": task_tdd_enabled,
3525
+ "task_tdd_coverage_threshold": task_tdd_coverage_threshold,
3526
+ "task_tdd_baselines": task.get("tdd_baselines") if task else None,
3527
+ "displayed_tdd_enabled": displayed_tdd_enabled,
3528
+ "displayed_tdd_coverage_threshold": displayed_tdd_threshold,
3529
+ "tdd_readiness_status": readiness["status"],
3530
+ "tdd_readiness_reasons": readiness["reasons"],
2815
3531
  "spec_summary": spec_task_summary(task),
2816
3532
  # Compatibility output aliases for pre-0.9 clients.
2817
3533
  "project_confirm_mode": project_approval_mode,
@@ -2831,6 +3547,8 @@ def build_status_line(
2831
3547
  approval = str(state["effective_approval_mode"]).capitalize()
2832
3548
  workflow = str(state["concrete_workflow_mode"] or state["configured_workflow_mode"]).capitalize()
2833
3549
  status_brand = f"> **Easy Coding** · **Approval: {approval}** · **Workflow: {workflow}**"
3550
+ if state["displayed_tdd_enabled"] is True:
3551
+ status_brand += " · **TDD**"
2834
3552
  task_id = state["current_task"]
2835
3553
  if task_id:
2836
3554
  status = str(state["status"])
@@ -2874,6 +3592,11 @@ def build_machine_breadcrumbs(
2874
3592
  ]
2875
3593
  if state.get("concrete_workflow_mode"):
2876
3594
  lines.append(f"[easy-coding:workflow-mode:{state['concrete_workflow_mode']}]")
3595
+ if state.get("displayed_tdd_enabled") is True:
3596
+ lines.append("[easy-coding:tdd:enabled]")
3597
+ lines.append(
3598
+ f"[easy-coding:tdd-coverage-threshold:{state['displayed_tdd_coverage_threshold']}]"
3599
+ )
2877
3600
 
2878
3601
  if task_id:
2879
3602
  lines.append(f"[current-task:{task_id}]")
@@ -3181,6 +3904,45 @@ def clear_session_workflow_mode(
3181
3904
  return snapshot
3182
3905
 
3183
3906
 
3907
+ def set_session_tdd(
3908
+ root: Path,
3909
+ enabled: bool,
3910
+ agent: str,
3911
+ threshold: int | None = None,
3912
+ session_file: str | Path | None = None,
3913
+ ) -> dict:
3914
+ if enabled:
3915
+ require_tdd_readiness(root)
3916
+ session = ensure_session(root, session_file)
3917
+ materialize_legacy_session_behavior(session)
3918
+ session["tdd_enabled"] = enabled
3919
+ if threshold is not None:
3920
+ session["tdd_coverage_threshold"] = parse_tdd_threshold(
3921
+ threshold, "session tdd_coverage_threshold"
3922
+ )
3923
+ session["last_agent"] = agent
3924
+ write_session(root, session, session_file)
3925
+ snapshot = snapshot_state(root, session_file, session)
3926
+ snapshot["action"] = "set-tdd"
3927
+ return snapshot
3928
+
3929
+
3930
+ def clear_session_tdd(
3931
+ root: Path,
3932
+ agent: str,
3933
+ session_file: str | Path | None = None,
3934
+ ) -> dict:
3935
+ session = ensure_session(root, session_file)
3936
+ materialize_legacy_session_behavior(session)
3937
+ session.pop("tdd_enabled", None)
3938
+ session.pop("tdd_coverage_threshold", None)
3939
+ session["last_agent"] = agent
3940
+ write_session(root, session, session_file)
3941
+ snapshot = snapshot_state(root, session_file, session)
3942
+ snapshot["action"] = "clear-tdd"
3943
+ return snapshot
3944
+
3945
+
3184
3946
  def set_harness_disabled(
3185
3947
  root: Path,
3186
3948
  disabled: bool,
@@ -3611,6 +4373,44 @@ def freeze_workflow_mode(
3611
4373
  task["workflow_mode_confirmed_by"] = agent
3612
4374
 
3613
4375
 
4376
+ def freeze_tdd_mode(
4377
+ root: Path, session: dict, task_id: str, task: dict, agent: str
4378
+ ) -> None:
4379
+ behavior = resolve_behavior(root, session)
4380
+ task_type = str(task.get("type") or "").strip().lower()
4381
+ task["tdd_enabled"] = (
4382
+ behavior[8]
4383
+ if task_type not in NO_CODE_TASK_TYPES | {TDD_INIT_TASK_TYPE}
4384
+ else False
4385
+ )
4386
+ task["tdd_coverage_threshold"] = behavior[11]
4387
+ if task["tdd_enabled"] is True:
4388
+ require_tdd_readiness(root)
4389
+ plan = latest_execution_plan(root, task_id)
4390
+ if plan is None:
4391
+ raise StateError("Cannot freeze TDD baseline without a valid execution plan.")
4392
+ baselines = {
4393
+ key: git_head_sha(repository)
4394
+ for key, repository in tdd_repositories(root, task, plan).items()
4395
+ }
4396
+ task_dir = task_json_path(root, task_id).parent
4397
+ try:
4398
+ dev_spec_content = (task_dir / "dev-spec.md").read_text(encoding="utf-8")
4399
+ strategy_content = (task_dir / "test-strategy.md").read_text(encoding="utf-8")
4400
+ except OSError as error:
4401
+ raise StateError("Cannot freeze TDD without readable analysis artifacts.") from error
4402
+ marker_reasons = tdd_baseline_marker_reasons(
4403
+ dev_spec_content, strategy_content, baselines
4404
+ )
4405
+ if marker_reasons:
4406
+ raise StateError("; ".join(marker_reasons))
4407
+ task["tdd_baselines"] = baselines
4408
+ else:
4409
+ task.pop("tdd_baselines", None)
4410
+ task["tdd_confirmed_at"] = now_iso()
4411
+ task["tdd_confirmed_by"] = agent
4412
+
4413
+
3614
4414
  def raise_workflow_mode(
3615
4415
  root: Path,
3616
4416
  mode: str,
@@ -3676,7 +4476,7 @@ def request_transition(
3676
4476
  "use auto-transition instead."
3677
4477
  )
3678
4478
  if previous == "ANALYSIS" and stage == "IMPLEMENT":
3679
- validate_analysis_readiness(root, resolved_task_id)
4479
+ validate_analysis_readiness(root, resolved_task_id, session)
3680
4480
  if task.get("workflow_mode_legacy") is not True:
3681
4481
  validate_workflow_mode_proposal(
3682
4482
  root,
@@ -3729,9 +4529,10 @@ def apply_transition(
3729
4529
  if violation:
3730
4530
  raise StateError(violation)
3731
4531
  if previous == "ANALYSIS" and stage == "IMPLEMENT":
3732
- validate_analysis_readiness(root, resolved_task_id)
4532
+ validate_analysis_readiness(root, resolved_task_id, session)
3733
4533
  if task.get("workflow_mode_legacy") is not True:
3734
4534
  freeze_workflow_mode(root, session, resolved_task_id, task, agent)
4535
+ freeze_tdd_mode(root, session, resolved_task_id, task, agent)
3735
4536
  if previous == "REVIEW" and stage == "VERIFICATION":
3736
4537
  validate_review_readiness(root, resolved_task_id, task)
3737
4538
  if previous == "VERIFICATION" and stage == "MEMORY":
@@ -4156,6 +4957,14 @@ def main() -> int:
4156
4957
  clear_workflow_mode_parser = subcommands.add_parser("clear-workflow-mode", parents=[common])
4157
4958
  clear_workflow_mode_parser.add_argument("--agent", required=True)
4158
4959
 
4960
+ set_tdd_parser = subcommands.add_parser("set-tdd", parents=[common])
4961
+ set_tdd_parser.add_argument("--enabled", required=True, choices=["true", "false"])
4962
+ set_tdd_parser.add_argument("--threshold", type=int)
4963
+ set_tdd_parser.add_argument("--agent", required=True)
4964
+
4965
+ clear_tdd_parser = subcommands.add_parser("clear-tdd", parents=[common])
4966
+ clear_tdd_parser.add_argument("--agent", required=True)
4967
+
4159
4968
  # Compatibility aliases for pre-0.9 callers.
4160
4969
  set_confirm_mode_parser = subcommands.add_parser("set-confirm-mode", parents=[common])
4161
4970
  set_confirm_mode_parser.add_argument(
@@ -4441,6 +5250,30 @@ def main() -> int:
4441
5250
  session_file,
4442
5251
  )
4443
5252
  )
5253
+ elif command == "set-tdd":
5254
+ emit(
5255
+ attach_status_context(
5256
+ root,
5257
+ set_session_tdd(
5258
+ root,
5259
+ args.enabled == "true",
5260
+ agent,
5261
+ args.threshold,
5262
+ session_file,
5263
+ ),
5264
+ agent,
5265
+ session_file,
5266
+ )
5267
+ )
5268
+ elif command == "clear-tdd":
5269
+ emit(
5270
+ attach_status_context(
5271
+ root,
5272
+ clear_session_tdd(root, agent, session_file),
5273
+ agent,
5274
+ session_file,
5275
+ )
5276
+ )
4444
5277
  elif command == "propose-workflow-mode":
4445
5278
  emit(
4446
5279
  attach_status_context(