easy-coding-harness 0.9.1 → 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
@@ -12,11 +13,20 @@ from datetime import datetime, timezone
12
13
  from pathlib import Path
13
14
  import sys
14
15
 
16
+ from easy_dev_spec import (
17
+ EasyDevSpecError,
18
+ inspect_spec,
19
+ inspection_summary,
20
+ select_consumption_scopes,
21
+ select_tasks,
22
+ )
23
+
15
24
 
16
25
  TERMINAL_STATUSES = {"COMPLETE", "CLOSED"}
17
26
  HELP_SUFFIX = (
18
27
  "Use `ec-workflow` to start or resume a task, "
19
- "`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"
20
30
  )
21
31
  READY_LINE = f"Ready · {HELP_SUFFIX}"
22
32
  WAITING_INIT_LINE = "Waiting init · Use `ec-init` to initialize"
@@ -70,6 +80,8 @@ STRICT_WORKFLOW_RISK_PATTERN = re.compile(
70
80
  )
71
81
  DEFAULT_APPROVAL_MODE = "guard"
72
82
  DEFAULT_WORKFLOW_MODE = "adaptive"
83
+ DEFAULT_TDD_ENABLED = False
84
+ DEFAULT_TDD_COVERAGE_THRESHOLD = 90
73
85
  CRITICAL_CONFIRM_TRANSITIONS = {
74
86
  ("ANALYSIS", "IMPLEMENT"),
75
87
  ("VERIFICATION", "MEMORY"),
@@ -87,7 +99,7 @@ DEFAULT_SHORT_TERM_KEEP = 5
87
99
  SESSION_STALE_THRESHOLD_HOURS = 30 * 24
88
100
  SESSION_COMPONENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
89
101
  SESSION_AGENT_NAMESPACES = {"claude-code", "codex", "qoder", "unknown"}
90
- CODEX_AGENT_PATH_PATTERN = re.compile(r"^/root(?:/[a-z0-9._-]+)*$")
102
+ CODEX_AGENT_PATH_PATTERN = re.compile(r"^/?root(?:/[a-z0-9._-]+)*$")
91
103
  LEGACY_STATE_LOCK_TIMEOUT_SECONDS = 5.0
92
104
  LEGACY_STATE_LOCK_STALE_SECONDS = 60.0
93
105
  LEGACY_STATE_LOCK_POLL_SECONDS = 0.02
@@ -157,7 +169,7 @@ def short_memory_id_sort_key(memory_id: str) -> tuple[int, str]:
157
169
  def normalize_agent_identity(agent: str | None) -> str:
158
170
  raw_agent = str(agent or "unknown").strip()
159
171
  normalized = raw_agent.lower()
160
- # Codex 协作树中的 /root 路径表示 Codex 内部执行者,不是一个新的跨平台 Agent。
172
+ # Codex 可能把根执行者写成 root /root;两者及其协作子路径都属于同一平台身份。
161
173
  if CODEX_AGENT_PATH_PATTERN.fullmatch(normalized):
162
174
  return "codex"
163
175
  if normalized in SESSION_AGENT_NAMESPACES:
@@ -302,16 +314,45 @@ def read_memory_config(root: Path) -> dict[str, int]:
302
314
  return config
303
315
 
304
316
 
305
- 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]:
306
341
  path = root / ".easy-coding" / "config.yaml"
307
342
  try:
308
343
  lines = path.read_text(encoding="utf-8").splitlines()
309
344
  except OSError:
310
- 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
+ )
311
351
 
312
352
  in_behavior = False
313
353
  behavior_indent = 0
314
354
  behavior: dict[str, str] = {}
355
+ schema_version = 0
315
356
  for raw_line in lines:
316
357
  without_comment = raw_line.split("#", 1)[0].rstrip()
317
358
  stripped = without_comment.strip()
@@ -324,6 +365,12 @@ def read_project_behavior(root: Path) -> tuple[str, str]:
324
365
  continue
325
366
  if in_behavior and indent <= behavior_indent:
326
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
327
374
  if not in_behavior or ":" not in stripped:
328
375
  continue
329
376
  key, value = stripped.split(":", 1)
@@ -351,16 +398,27 @@ def read_project_behavior(root: Path) -> tuple[str, str]:
351
398
  "Invalid behavior.workflow_mode in .easy-coding/config.yaml: "
352
399
  "expected adaptive, fast, standard, or strict."
353
400
  )
354
- 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
355
411
 
356
412
 
357
413
  def resolve_behavior(
358
414
  root: Path, session: dict
359
- ) -> tuple[str, str | None, str, str, str | None, str]:
360
- 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)
361
417
  legacy = session.get("confirm_mode")
362
418
  session_approval = session.get("approval_mode")
363
419
  session_workflow = session.get("workflow_mode")
420
+ session_tdd = session.get("tdd_enabled")
421
+ session_threshold = session.get("tdd_coverage_threshold")
364
422
  if session_approval is None:
365
423
  if legacy == "lite":
366
424
  session_approval = "guard"
@@ -379,6 +437,12 @@ def resolve_behavior(
379
437
  raise StateError(
380
438
  "Invalid session workflow_mode: expected adaptive, fast, standard, or strict."
381
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
+ )
382
446
  return (
383
447
  project_approval,
384
448
  str(session_approval) if session_approval else None,
@@ -386,6 +450,12 @@ def resolve_behavior(
386
450
  project_workflow,
387
451
  str(session_workflow) if session_workflow else None,
388
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,
389
459
  )
390
460
 
391
461
 
@@ -1071,6 +1141,337 @@ def is_read_only_execution_plan(plan: object) -> bool:
1071
1141
  )
1072
1142
 
1073
1143
 
1144
+ def stored_spec_path(root: Path, task: dict) -> Path:
1145
+ source = task.get("spec_source")
1146
+ if not isinstance(source, dict) or not is_non_empty_string(source.get("path")):
1147
+ raise StateError("Spec-backed task is missing spec_source.path.")
1148
+ raw_path = Path(str(source["path"]))
1149
+ path = raw_path if raw_path.is_absolute() else root / raw_path
1150
+ resolved = path.resolve()
1151
+ try:
1152
+ resolved.relative_to(root.resolve())
1153
+ except ValueError as exc:
1154
+ raise StateError("Spec-backed task source path must remain inside the project root.") from exc
1155
+ return resolved
1156
+
1157
+
1158
+ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
1159
+ source = task.get("spec_source")
1160
+ selected = task.get("selected_spec_tasks")
1161
+ repo_paths = task.get("repo_paths")
1162
+ if not isinstance(source, dict) or not is_string_list(selected, allow_empty=False):
1163
+ raise StateError("Spec-backed task source and selected task metadata are incomplete.")
1164
+ try:
1165
+ inspection = inspect_spec(
1166
+ stored_spec_path(root, task),
1167
+ root,
1168
+ repo_paths if isinstance(repo_paths, dict) else {},
1169
+ selected,
1170
+ )
1171
+ satisfied = {
1172
+ f"{record.get('source_task_id')}->{record.get('task_id')}": str(record.get("evidence"))
1173
+ for record in task.get("spec_dependency_evidence", [])
1174
+ if isinstance(record, dict)
1175
+ and record.get("status") == "satisfied"
1176
+ and is_non_empty_string(record.get("task_id"))
1177
+ and is_non_empty_string(record.get("evidence"))
1178
+ }
1179
+ selection = select_tasks(inspection, selected, satisfied)
1180
+ except EasyDevSpecError as exc:
1181
+ raise StateError(f"Canonical Spec validation failed: {exc}") from exc
1182
+ stored_dependencies = task.get("spec_dependency_evidence")
1183
+ if not isinstance(stored_dependencies, list):
1184
+ raise StateError("Spec-backed task dependency metadata is incomplete.")
1185
+ expected_by_edge = {
1186
+ (record.get("source_task_id"), record.get("task_id")): record
1187
+ for record in selection["dependency_records"]
1188
+ }
1189
+ stored_by_edge = {
1190
+ (record.get("source_task_id"), record.get("task_id")): record
1191
+ for record in stored_dependencies
1192
+ if isinstance(record, dict)
1193
+ }
1194
+ if (
1195
+ len(stored_by_edge) != len(stored_dependencies)
1196
+ or set(stored_by_edge) != set(expected_by_edge)
1197
+ ):
1198
+ raise StateError("Canonical Spec dependency metadata no longer matches source selection.")
1199
+ for edge, expected in expected_by_edge.items():
1200
+ stored = stored_by_edge[edge]
1201
+ for field in ("dependency_type", "required_evidence", "status"):
1202
+ if stored.get(field) != expected.get(field):
1203
+ raise StateError(
1204
+ "Canonical Spec dependency metadata no longer matches source selection."
1205
+ )
1206
+ if stored.get("evidence") != expected.get("evidence"):
1207
+ raise StateError(
1208
+ "Canonical Spec dependency evidence no longer matches its recorded status."
1209
+ )
1210
+ if source.get("schema") != inspection.get("schema"):
1211
+ raise StateError("Canonical Spec schema no longer matches task.json.")
1212
+ if source.get("spec_id") != inspection.get("spec_id"):
1213
+ raise StateError("Canonical Spec ID no longer matches task.json.")
1214
+ if source.get("revision") != inspection.get("revision"):
1215
+ raise StateError("Canonical Spec revision no longer matches task.json.")
1216
+ if source.get("sha256") != inspection.get("source_sha256"):
1217
+ raise StateError("Canonical Spec SHA-256 changed after task creation.")
1218
+ selected_repo_ids = set(selection["selected_repo_ids"])
1219
+ stored_bindings = task.get("spec_repositories")
1220
+ if not isinstance(stored_bindings, list):
1221
+ raise StateError("Spec-backed task repository metadata is incomplete.")
1222
+ stored_by_repo = {
1223
+ str(binding.get("repo_id")): binding
1224
+ for binding in stored_bindings
1225
+ if isinstance(binding, dict) and is_non_empty_string(binding.get("repo_id"))
1226
+ }
1227
+ current_by_repo = {
1228
+ str(binding.get("repo_id")): binding
1229
+ for binding in inspection.get("repository_bindings", [])
1230
+ if isinstance(binding, dict)
1231
+ and str(binding.get("repo_id")) in selected_repo_ids
1232
+ }
1233
+ if (
1234
+ len(stored_by_repo) != len(stored_bindings)
1235
+ or set(stored_by_repo) != selected_repo_ids
1236
+ or set(current_by_repo) != selected_repo_ids
1237
+ ):
1238
+ raise StateError("Canonical Spec repository bindings no longer match task.json.")
1239
+ for repo_id in selected_repo_ids:
1240
+ stored = stored_by_repo[repo_id]
1241
+ current = current_by_repo[repo_id]
1242
+ for field in ("repo_id", "name", "path", "baseline_commit"):
1243
+ if stored.get(field) != current.get(field):
1244
+ raise StateError("Canonical Spec repository bindings no longer match task.json.")
1245
+ return inspection, selection
1246
+
1247
+
1248
+ def is_valid_spec_execution_plan(root: Path, task: dict, plan: object) -> bool:
1249
+ if not isinstance(plan, dict):
1250
+ return False
1251
+ try:
1252
+ inspection, selection = inspect_task_spec(root, task)
1253
+ except StateError:
1254
+ return False
1255
+ selected_ids = set(selection["selected_task_ids"])
1256
+ task_by_id = {item["task_id"]: item for item in selection["selected_tasks"]}
1257
+ change_by_id = {
1258
+ str(change["change_id"]): change for change in selection["selected_changes"]
1259
+ }
1260
+ step_by_id = {
1261
+ str(step["step_id"]): step for step in selection["selected_steps"]
1262
+ }
1263
+ test_by_id = {
1264
+ str(test["test_id"]): test for test in selection["selected_tests"]
1265
+ }
1266
+ changes_by_task: dict[str, list[dict]] = {task_id: [] for task_id in selected_ids}
1267
+ tests_by_task: dict[str, list[dict]] = {task_id: [] for task_id in selected_ids}
1268
+ for change in selection["selected_changes"]:
1269
+ changes_by_task[str(change["task_id"])].append(change)
1270
+ for test in selection["selected_tests"]:
1271
+ tests_by_task[str(test["task_id"])].append(test)
1272
+
1273
+ units = [unit for unit in plan.get("units", []) if isinstance(unit, dict)]
1274
+ units_by_task: dict[str, list[dict]] = {task_id: [] for task_id in selected_ids}
1275
+ covered_steps: dict[str, list[str]] = {task_id: [] for task_id in selected_ids}
1276
+ covered_files: dict[str, set[str]] = {task_id: set() for task_id in selected_ids}
1277
+ covered_symbols: dict[str, set[str]] = {task_id: set() for task_id in selected_ids}
1278
+ covered_commands: dict[str, set[str]] = {task_id: set() for task_id in selected_ids}
1279
+ unit_by_id = {str(unit["id"]): unit for unit in units}
1280
+ unit_id_by_step: dict[str, str] = {}
1281
+ for unit in units:
1282
+ source_task_id = unit.get("source_task_id")
1283
+ if source_task_id not in selected_ids:
1284
+ return False
1285
+ source_task_id = str(source_task_id)
1286
+ source_task = task_by_id[source_task_id]
1287
+ if unit.get("repo_id") != source_task.get("repo_id"):
1288
+ return False
1289
+ for field in ("source_step_ids", "symbols", "test_commands"):
1290
+ if not is_string_list(unit.get(field), allow_empty=False):
1291
+ return False
1292
+ allowed_steps = set(source_task.get("step_ids", []))
1293
+ if not set(unit["source_step_ids"]).issubset(allowed_steps):
1294
+ return False
1295
+ source_steps = [step_by_id.get(str(step_id)) for step_id in unit["source_step_ids"]]
1296
+ if any(
1297
+ step is None or step.get("task_id") != source_task_id
1298
+ for step in source_steps
1299
+ ):
1300
+ return False
1301
+ step_change_ids = {
1302
+ str(change_id)
1303
+ for step in source_steps
1304
+ if isinstance(step, dict)
1305
+ for change_id in step.get("change_ids", [])
1306
+ }
1307
+ step_test_ids = {
1308
+ str(test_id)
1309
+ for step in source_steps
1310
+ if isinstance(step, dict)
1311
+ for test_id in step.get("test_ids", [])
1312
+ }
1313
+ step_files = {
1314
+ str(change_by_id[change_id]["path"])
1315
+ for change_id in step_change_ids
1316
+ if change_id in change_by_id
1317
+ }
1318
+ step_symbols = {
1319
+ str(symbol)
1320
+ for change_id in step_change_ids
1321
+ if change_id in change_by_id
1322
+ for symbol in change_by_id[change_id].get("symbols", [])
1323
+ }
1324
+ step_commands = {
1325
+ str(test_by_id[test_id]["command"])
1326
+ for test_id in step_test_ids
1327
+ if test_id in test_by_id
1328
+ }
1329
+ # Unit 必须保存它声明的 source steps 的完整文件、符号和源测试映射;附加本地命令可保留。
1330
+ if (
1331
+ not step_change_ids.issubset(change_by_id)
1332
+ or not step_test_ids.issubset(test_by_id)
1333
+ or set(unit.get("files", [])) != step_files
1334
+ or set(unit["symbols"]) != step_symbols
1335
+ or not set(unit["test_commands"]).issuperset(step_commands)
1336
+ ):
1337
+ return False
1338
+ for step_id in unit["source_step_ids"]:
1339
+ normalized_step_id = str(step_id)
1340
+ if normalized_step_id in unit_id_by_step:
1341
+ return False
1342
+ unit_id_by_step[normalized_step_id] = str(unit["id"])
1343
+ units_by_task[source_task_id].append(unit)
1344
+ covered_steps[source_task_id].extend(unit["source_step_ids"])
1345
+ covered_files[source_task_id].update(unit.get("files", []))
1346
+ covered_symbols[source_task_id].update(unit["symbols"])
1347
+ covered_commands[source_task_id].update(unit["test_commands"])
1348
+
1349
+ for source_task_id, source_task in task_by_id.items():
1350
+ if not units_by_task[source_task_id]:
1351
+ return False
1352
+ steps = covered_steps[source_task_id]
1353
+ if len(steps) != len(set(steps)) or set(steps) != set(source_task.get("step_ids", [])):
1354
+ return False
1355
+ if covered_files[source_task_id] != {
1356
+ str(change["path"]) for change in changes_by_task[source_task_id]
1357
+ }:
1358
+ return False
1359
+ if covered_symbols[source_task_id] != {
1360
+ str(symbol)
1361
+ for change in changes_by_task[source_task_id]
1362
+ for symbol in change.get("symbols", [])
1363
+ }:
1364
+ return False
1365
+ if not covered_commands[source_task_id].issuperset({
1366
+ str(test["command"]) for test in tests_by_task[source_task_id]
1367
+ }):
1368
+ return False
1369
+
1370
+ # 同一 source task 内的 Step DAG 也必须投影到 Unit DAG;合并在同一 Unit 的步骤无需自依赖。
1371
+ for step_id, step in step_by_id.items():
1372
+ owner_unit_id = unit_id_by_step.get(step_id)
1373
+ if owner_unit_id is None:
1374
+ return False
1375
+ owner_unit = unit_by_id[owner_unit_id]
1376
+ for dependency_step_id in step.get("depends_on_step_ids", []):
1377
+ dependency_unit_id = unit_id_by_step.get(str(dependency_step_id))
1378
+ if dependency_unit_id is None:
1379
+ return False
1380
+ if (
1381
+ dependency_unit_id != owner_unit_id
1382
+ and dependency_unit_id not in owner_unit.get("depends_on", [])
1383
+ ):
1384
+ return False
1385
+
1386
+ # hard 依赖必须投影为 Unit DAG,不能只保存在说明文字中。
1387
+ unit_ids_by_task = {
1388
+ source_task_id: {str(unit["id"]) for unit in source_units}
1389
+ for source_task_id, source_units in units_by_task.items()
1390
+ }
1391
+ for edge in inspection.get("dependency_edges", []):
1392
+ source_task_id = str(edge.get("source_task_id") or "")
1393
+ dependency_task_id = str(edge.get("task_id") or "")
1394
+ if (
1395
+ edge.get("dependency_type") != "hard"
1396
+ or source_task_id not in selected_ids
1397
+ or dependency_task_id not in selected_ids
1398
+ ):
1399
+ continue
1400
+ dependency_ids = unit_ids_by_task[dependency_task_id]
1401
+ depended_on_within_dependency = {
1402
+ dependency
1403
+ for unit in units_by_task[dependency_task_id]
1404
+ for dependency in unit.get("depends_on", [])
1405
+ if dependency in dependency_ids
1406
+ }
1407
+ dependency_terminals = dependency_ids - depended_on_within_dependency
1408
+ source_units = units_by_task[source_task_id]
1409
+ source_ids = unit_ids_by_task[source_task_id]
1410
+ source_roots = [
1411
+ unit
1412
+ for unit in source_units
1413
+ if not set(unit.get("depends_on", [])).intersection(source_ids)
1414
+ ]
1415
+ if not dependency_terminals or any(
1416
+ not dependency_terminals.issubset(set(unit.get("depends_on", [])))
1417
+ for unit in source_roots
1418
+ ):
1419
+ return False
1420
+ return True
1421
+
1422
+
1423
+ def contains_spec_marker(content: str, marker: str) -> bool:
1424
+ boundary_characters = (
1425
+ r"A-Za-z0-9_/" + ("." if "/" in marker or "." in marker else "") + "-"
1426
+ )
1427
+ return (
1428
+ re.search(
1429
+ rf"(?<![{boundary_characters}]){re.escape(marker)}(?![{boundary_characters}])",
1430
+ content,
1431
+ )
1432
+ is not None
1433
+ )
1434
+
1435
+
1436
+ def missing_spec_test_strategy_markers(
1437
+ selection: dict, plan: dict, content: str
1438
+ ) -> list[str]:
1439
+ unit_ids_by_step = {
1440
+ str(step_id): str(unit["id"])
1441
+ for unit in plan.get("units", [])
1442
+ if isinstance(unit, dict)
1443
+ for step_id in unit.get("source_step_ids", [])
1444
+ }
1445
+ owner_units_by_test: dict[str, set[str]] = {}
1446
+ for step in selection.get("selected_steps", []):
1447
+ if not isinstance(step, dict):
1448
+ continue
1449
+ owner_unit_id = unit_ids_by_step.get(str(step.get("step_id") or ""))
1450
+ if not owner_unit_id:
1451
+ continue
1452
+ for test_id in step.get("test_ids", []):
1453
+ owner_units_by_test.setdefault(str(test_id), set()).add(owner_unit_id)
1454
+
1455
+ missing: list[str] = []
1456
+ for test in selection.get("selected_tests", []):
1457
+ if not isinstance(test, dict):
1458
+ continue
1459
+ test_id = str(test.get("test_id") or "")
1460
+ markers = {
1461
+ test_id,
1462
+ str(test.get("task_id") or ""),
1463
+ str(test.get("file") or ""),
1464
+ str(test.get("command") or ""),
1465
+ *owner_units_by_test.get(test_id, set()),
1466
+ }
1467
+ missing.extend(
1468
+ marker
1469
+ for marker in sorted(markers)
1470
+ if marker and not contains_spec_marker(content, marker)
1471
+ )
1472
+ return list(dict.fromkeys(missing))
1473
+
1474
+
1074
1475
  def read_project_schema_version(root: Path) -> int:
1075
1476
  path = root / ".easy-coding" / "config.yaml"
1076
1477
  try:
@@ -1105,10 +1506,15 @@ def has_valid_execution_plan(root: Path, task_id: str) -> bool:
1105
1506
  task_type = str(task.get("type") or "").strip().lower() if task else ""
1106
1507
  if task_type in NO_CODE_TASK_TYPES:
1107
1508
  return is_read_only_execution_plan(latest_plan)
1108
- return is_valid_execution_plan(
1509
+ valid = is_valid_execution_plan(
1109
1510
  latest_plan,
1110
1511
  require_unit_contracts=read_project_schema_version(root) >= 3,
1111
1512
  )
1513
+ if not valid:
1514
+ return False
1515
+ if task and isinstance(task.get("spec_source"), dict):
1516
+ return is_valid_spec_execution_plan(root, task, latest_plan)
1517
+ return True
1112
1518
 
1113
1519
 
1114
1520
  def execution_records(root: Path, task_id: str) -> list[dict]:
@@ -1131,10 +1537,10 @@ def execution_records(root: Path, task_id: str) -> list[dict]:
1131
1537
  def latest_execution_plan(root: Path, task_id: str) -> dict | None:
1132
1538
  latest: dict | None = None
1133
1539
  for record in execution_records(root, task_id):
1134
- if record.get("type") == "plan" and is_valid_execution_plan(
1135
- record, allow_empty_files=True
1136
- ):
1540
+ if record.get("type") == "plan":
1137
1541
  latest = record
1542
+ if latest is None or not is_valid_execution_plan(latest, allow_empty_files=True):
1543
+ return None
1138
1544
  return latest
1139
1545
 
1140
1546
 
@@ -1188,6 +1594,44 @@ def minimize_repository_scopes(repository: Path, scopes: set[Path]) -> list[Path
1188
1594
  def task_repository_scopes(
1189
1595
  root: Path, task: dict | None, plan: dict
1190
1596
  ) -> list[tuple[Path, list[Path]]]:
1597
+ if task and isinstance(task.get("spec_source"), dict):
1598
+ repo_paths = task.get("repo_paths")
1599
+ if not isinstance(repo_paths, dict):
1600
+ raise StateError("Spec-backed task is missing repo_paths.")
1601
+ repositories: dict[Path, set[Path]] = {}
1602
+ for unit in plan.get("units", []):
1603
+ if not isinstance(unit, dict) or not is_non_empty_string(unit.get("repo_id")):
1604
+ raise StateError("Spec-backed execution unit is missing repo_id.")
1605
+ repo_id = str(unit["repo_id"])
1606
+ raw_repo_path = repo_paths.get(repo_id)
1607
+ if not is_non_empty_string(raw_repo_path):
1608
+ raise StateError(f"Spec repository path is missing: {repo_id}")
1609
+ candidate = Path(str(raw_repo_path))
1610
+ repository_path = (candidate if candidate.is_absolute() else root / candidate).resolve()
1611
+ repository = git_repository_root(repository_path)
1612
+ if repository is None or repository.resolve() != repository_path:
1613
+ raise StateError(f"Spec repository binding is not a Git root: {repo_id}")
1614
+ scopes = repositories.setdefault(repository, set())
1615
+ scopes.add(repository)
1616
+ for file_name in unit.get("files", []):
1617
+ if not is_non_empty_string(file_name):
1618
+ raise StateError(f"Spec execution unit has an invalid file path: {repo_id}")
1619
+ relative_path = Path(str(file_name))
1620
+ if relative_path.is_absolute() or ".." in relative_path.parts:
1621
+ raise StateError(f"Spec execution unit path escapes repository {repo_id}: {file_name}")
1622
+ resolved_file = (repository / relative_path).resolve()
1623
+ if not is_path_within(resolved_file, repository):
1624
+ raise StateError(f"Spec execution unit path escapes repository {repo_id}: {file_name}")
1625
+ file_repository = git_repository_root(resolved_file)
1626
+ if file_repository is None or file_repository.resolve() != repository:
1627
+ raise StateError(
1628
+ f"Spec execution unit path belongs to another Git repository: {repo_id}:{file_name}"
1629
+ )
1630
+ return [
1631
+ (repository, [repository])
1632
+ for repository in sorted(repositories, key=lambda item: item.as_posix())
1633
+ ]
1634
+
1191
1635
  scope_candidates = [root]
1192
1636
  if task:
1193
1637
  repo_paths = task.get("repo_paths")
@@ -1242,6 +1686,77 @@ def task_repository_roots(root: Path, task: dict | None, plan: dict) -> list[Pat
1242
1686
  ]
1243
1687
 
1244
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
+
1245
1760
  def repository_scope_pathspecs(repository: Path, scopes: list[Path]) -> list[str]:
1246
1761
  return [
1247
1762
  f":(literal){scope.relative_to(repository).as_posix()}"
@@ -1455,6 +1970,18 @@ def implementation_fingerprint(root: Path, task_id: str) -> str:
1455
1970
  digest.update(b"workflow-mode\0")
1456
1971
  digest.update(workflow_mode.encode("utf-8"))
1457
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")
1458
1985
  digest.update(b"execution-plan\0")
1459
1986
  digest.update(
1460
1987
  json.dumps(
@@ -1465,28 +1992,51 @@ def implementation_fingerprint(root: Path, task_id: str) -> str:
1465
1992
  ).encode("utf-8")
1466
1993
  )
1467
1994
  digest.update(b"\0")
1995
+ if task and isinstance(task.get("spec_source"), dict):
1996
+ digest.update(b"canonical-spec\0")
1997
+ digest.update(
1998
+ json.dumps(
1999
+ {
2000
+ "source": task.get("spec_source"),
2001
+ "selected_tasks": task.get("selected_spec_tasks"),
2002
+ },
2003
+ ensure_ascii=False,
2004
+ sort_keys=True,
2005
+ separators=(",", ":"),
2006
+ ).encode("utf-8")
2007
+ )
2008
+ digest.update(b"\0")
1468
2009
  update_git_worktree_fingerprint(digest, root, task, plan)
1469
- file_names = sorted(
1470
- {
1471
- str(file_name)
1472
- for unit in plan.get("units", [])
1473
- if isinstance(unit, dict)
1474
- for file_name in unit.get("files", [])
1475
- if is_non_empty_string(file_name)
1476
- }
1477
- )
1478
- for file_name in file_names:
2010
+ repo_paths = task.get("repo_paths") if task else None
2011
+ file_entries: set[tuple[str, str | None]] = {
2012
+ (str(file_name), str(unit.get("repo_id")) if unit.get("repo_id") else None)
2013
+ for unit in plan.get("units", [])
2014
+ if isinstance(unit, dict)
2015
+ for file_name in unit.get("files", [])
2016
+ if is_non_empty_string(file_name)
2017
+ }
2018
+ for file_name, repo_id in sorted(file_entries, key=lambda item: (item[0], item[1] or "")):
1479
2019
  candidate = Path(file_name)
1480
2020
  was_absolute = candidate.is_absolute()
2021
+ base = root
2022
+ if (
2023
+ task
2024
+ and isinstance(task.get("spec_source"), dict)
2025
+ and isinstance(repo_paths, dict)
2026
+ and repo_id
2027
+ and is_non_empty_string(repo_paths.get(repo_id))
2028
+ ):
2029
+ raw_base = Path(str(repo_paths[repo_id]))
2030
+ base = raw_base if raw_base.is_absolute() else root / raw_base
1481
2031
  if not was_absolute:
1482
- candidate = root / candidate
2032
+ candidate = base / candidate
1483
2033
  resolved = candidate.resolve()
1484
2034
  if not was_absolute:
1485
2035
  try:
1486
- resolved.relative_to(root.resolve())
2036
+ resolved.relative_to(base.resolve())
1487
2037
  except ValueError as error:
1488
- raise StateError(f"Execution plan file escapes project root: {file_name}") from error
1489
- digest.update(file_name.encode("utf-8"))
2038
+ raise StateError(f"Execution plan file escapes repository: {file_name}") from error
2039
+ digest.update(f"{repo_id or ''}:{file_name}".encode("utf-8"))
1490
2040
  digest.update(b"\0")
1491
2041
  try:
1492
2042
  digest.update(resolved.read_bytes())
@@ -1496,25 +2046,167 @@ def implementation_fingerprint(root: Path, task_id: str) -> str:
1496
2046
  return digest.hexdigest()
1497
2047
 
1498
2048
 
1499
- 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:
1500
2085
  path = root / ".easy-coding" / "config.yaml"
1501
2086
  digest = hashlib.sha256()
1502
2087
  try:
1503
- 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)
1504
2092
  except OSError:
1505
2093
  digest.update(b"<missing-config>")
1506
2094
  return digest.hexdigest()
1507
2095
 
1508
2096
 
1509
2097
  def evidence_fingerprints(root: Path, task_id: str) -> dict[str, str]:
2098
+ task = load_task(root, task_id)
1510
2099
  return {
1511
2100
  "implementation_fingerprint": implementation_fingerprint(root, task_id),
1512
- "config_fingerprint": behavior_config_fingerprint(root),
2101
+ "config_fingerprint": behavior_config_fingerprint(root, task),
2102
+ }
2103
+
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
+
2136
+ def validate_spec_implementation_results(root: Path, task_id: str, task: dict) -> None:
2137
+ if not isinstance(task.get("spec_source"), dict):
2138
+ return
2139
+ plan = latest_execution_plan(root, task_id)
2140
+ if plan is None or not is_valid_spec_execution_plan(root, task, plan):
2141
+ raise StateError("Canonical Spec implementation has no valid source-traceable plan.")
2142
+ unit_by_id = {
2143
+ str(unit["id"]): unit for unit in plan.get("units", []) if isinstance(unit, dict)
1513
2144
  }
2145
+ records = execution_records(root, task_id)
2146
+ latest_plan_index = max(
2147
+ (index for index, record in enumerate(records) if record.get("type") == "plan"),
2148
+ default=-1,
2149
+ )
2150
+ lifecycle_by_unit: dict[str, list[dict]] = {unit_id: [] for unit_id in unit_by_id}
2151
+ for record in records[latest_plan_index + 1 :]:
2152
+ unit_id = str(record.get("unit_id") or "")
2153
+ if record.get("type") in {"dispatch", "result"} and unit_id in unit_by_id:
2154
+ lifecycle_by_unit[unit_id].append(record)
2155
+ missing_dispatches = sorted(
2156
+ unit_id
2157
+ for unit_id, lifecycle in lifecycle_by_unit.items()
2158
+ if not any(record.get("type") == "dispatch" for record in lifecycle)
2159
+ )
2160
+ if missing_dispatches:
2161
+ raise StateError(
2162
+ "Canonical Spec implementation is missing dispatch records for units: "
2163
+ + ", ".join(missing_dispatches)
2164
+ )
2165
+ missing_results = sorted(
2166
+ unit_id
2167
+ for unit_id, lifecycle in lifecycle_by_unit.items()
2168
+ if not lifecycle or lifecycle[-1].get("type") != "result"
2169
+ )
2170
+ if missing_results:
2171
+ raise StateError(
2172
+ "Canonical Spec implementation is missing result records for units: "
2173
+ + ", ".join(missing_results)
2174
+ )
2175
+ for unit_id, unit in unit_by_id.items():
2176
+ lifecycle = lifecycle_by_unit[unit_id]
2177
+ if len(lifecycle) < 2 or lifecycle[-2].get("type") != "dispatch":
2178
+ raise StateError(
2179
+ f"Canonical Spec result {unit_id} has no matching preceding dispatch record."
2180
+ )
2181
+ dispatch = lifecycle[-2]
2182
+ if (
2183
+ dispatch.get("repo_id") != unit.get("repo_id")
2184
+ or dispatch.get("source_task_id") != unit.get("source_task_id")
2185
+ ):
2186
+ raise StateError(
2187
+ f"Canonical Spec dispatch {unit_id} must preserve repository/source-task ownership."
2188
+ )
2189
+ result = lifecycle[-1]
2190
+ if (
2191
+ result.get("repo_id") != unit.get("repo_id")
2192
+ or result.get("source_task_id") != unit.get("source_task_id")
2193
+ or result.get("status") != "completed"
2194
+ or not isinstance(result.get("changed_files"), list)
2195
+ or not set(result.get("changed_files", [])).issubset(set(unit.get("files", [])))
2196
+ or not is_non_empty_string(result.get("summary"))
2197
+ or result.get("issues") != []
2198
+ or result.get("needs_attention") != []
2199
+ ):
2200
+ raise StateError(
2201
+ f"Canonical Spec result {unit_id} must be completed without unresolved issues, "
2202
+ "preserve repository/source-task ownership, and remain within the Unit file scope."
2203
+ )
1514
2204
 
1515
2205
 
1516
2206
  def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
1517
- if task.get("workflow_mode_legacy") is True:
2207
+ validate_spec_implementation_results(root, task_id, task)
2208
+ is_spec_task = isinstance(task.get("spec_source"), dict)
2209
+ if task.get("workflow_mode_legacy") is True and not is_spec_task:
1518
2210
  return
1519
2211
  expected = implementation_fingerprint(root, task_id)
1520
2212
  latest_by_dimension: dict[str, dict] = {}
@@ -1524,7 +2216,10 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
1524
2216
  and record.get("implementation_fingerprint") == expected
1525
2217
  and is_non_empty_string(record.get("dimension"))
1526
2218
  ):
1527
- latest_by_dimension[str(record["dimension"])] = record
2219
+ dimension = str(record["dimension"])
2220
+ source_task_id = str(record.get("source_task_id") or "")
2221
+ record_key = f"{dimension}\0{source_task_id}" if is_spec_task else dimension
2222
+ latest_by_dimension[record_key] = record
1528
2223
  if not latest_by_dimension:
1529
2224
  raise StateError(
1530
2225
  "REVIEW cannot advance to VERIFICATION without a review record for the current implementation fingerprint."
@@ -1543,6 +2238,42 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
1543
2238
  "Each review finding must include a non-empty file and issue, a positive integer "
1544
2239
  "line, and severity error, warning, or info."
1545
2240
  )
2241
+ if is_spec_task:
2242
+ plan = latest_execution_plan(root, task_id) or {}
2243
+ task_repositories = {
2244
+ str(unit.get("source_task_id")): str(unit.get("repo_id"))
2245
+ for unit in plan.get("units", [])
2246
+ if isinstance(unit, dict)
2247
+ and is_non_empty_string(unit.get("source_task_id"))
2248
+ and is_non_empty_string(unit.get("repo_id"))
2249
+ }
2250
+ reviewed_dimensions: dict[str, set[str]] = {
2251
+ source_task_id: set() for source_task_id in task_repositories
2252
+ }
2253
+ for record in latest_by_dimension.values():
2254
+ source_task_id = str(record.get("source_task_id") or "")
2255
+ repo_id = str(record.get("repo_id") or "")
2256
+ if source_task_id not in task_repositories or repo_id != task_repositories[source_task_id]:
2257
+ raise StateError(
2258
+ "Canonical Spec review evidence must preserve repository/source-task ownership."
2259
+ )
2260
+ for finding in record["findings"]:
2261
+ finding_path = Path(str(finding["file"]))
2262
+ if finding_path.is_absolute() or ".." in finding_path.parts:
2263
+ raise StateError(
2264
+ "Canonical Spec review findings must use safe repository-relative paths."
2265
+ )
2266
+ reviewed_dimensions[source_task_id].add(str(record["dimension"]))
2267
+ missing_review_tasks = sorted(
2268
+ source_task_id
2269
+ for source_task_id, dimensions in reviewed_dimensions.items()
2270
+ if not dimensions
2271
+ )
2272
+ if missing_review_tasks:
2273
+ raise StateError(
2274
+ "Canonical Spec review evidence does not cover selected source tasks: "
2275
+ + ", ".join(missing_review_tasks)
2276
+ )
1546
2277
  has_failed_dimension = False
1547
2278
  for record in latest_by_dimension.values():
1548
2279
  findings = record.get("findings")
@@ -1558,16 +2289,48 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
1558
2289
  raise StateError(
1559
2290
  "REVIEW cannot advance to VERIFICATION while a current review dimension is not passed or has error findings."
1560
2291
  )
1561
- if task.get("workflow_mode") == "strict" and len(latest_by_dimension) < 2:
1562
- raise StateError(
1563
- "Strict workflow requires at least two passed review dimensions for the current implementation fingerprint."
1564
- )
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
+ )
2311
+ if task.get("workflow_mode") == "strict":
2312
+ if is_spec_task:
2313
+ missing_strict_dimensions = sorted(
2314
+ source_task_id
2315
+ for source_task_id, dimensions in reviewed_dimensions.items()
2316
+ if len(dimensions) < 2
2317
+ )
2318
+ if missing_strict_dimensions:
2319
+ raise StateError(
2320
+ "Strict Canonical Spec review requires at least two passed dimensions for "
2321
+ "every selected source task: " + ", ".join(missing_strict_dimensions)
2322
+ )
2323
+ elif len(latest_by_dimension) < 2:
2324
+ raise StateError(
2325
+ "Strict workflow requires at least two passed review dimensions for the current implementation fingerprint."
2326
+ )
1565
2327
 
1566
2328
 
1567
2329
  def validate_verification_readiness(root: Path, task_id: str, task: dict) -> None:
1568
2330
  fingerprints = evidence_fingerprints(root, task_id)
2331
+ is_spec_task = isinstance(task.get("spec_source"), dict)
1569
2332
  if (
1570
- task.get("workflow_mode_legacy") is not True
2333
+ (task.get("workflow_mode_legacy") is not True or is_spec_task)
1571
2334
  and task.get("workflow_mode_legacy_review_bypass_fingerprint")
1572
2335
  != fingerprints["implementation_fingerprint"]
1573
2336
  ):
@@ -1582,6 +2345,10 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
1582
2345
  and is_non_empty_string(record.get("check"))
1583
2346
  ):
1584
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 ''}"
2350
+ if is_spec_task:
2351
+ check = f"{check}\0{record.get('source_task_id') or ''}"
1585
2352
  previous = latest_by_check.get(check)
1586
2353
  if (
1587
2354
  record.get("applicable") is False
@@ -1594,11 +2361,11 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
1594
2361
  raise StateError(
1595
2362
  "VERIFICATION cannot advance to MEMORY without verification evidence for the current implementation and config fingerprints."
1596
2363
  )
1597
- if task.get("workflow_mode_legacy") is not True:
2364
+ if task.get("workflow_mode_legacy") is not True or is_spec_task:
1598
2365
  for record in latest_by_check.values():
1599
2366
  check_type = str(record.get("check_type") or "")
1600
2367
  if (
1601
- check_type not in STRICT_VERIFICATION_CHECK_TYPES
2368
+ check_type not in STRICT_VERIFICATION_CHECK_TYPES | {"coverage"}
1602
2369
  or not is_non_empty_string(record.get("timestamp"))
1603
2370
  or (
1604
2371
  record.get("applicable") is not False
@@ -1614,6 +2381,25 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
1614
2381
  raise StateError(
1615
2382
  "Verification evidence marked not applicable must include a non-empty not_applicable_reason."
1616
2383
  )
2384
+ if is_spec_task:
2385
+ plan = latest_execution_plan(root, task_id) or {}
2386
+ task_repositories = {
2387
+ str(unit.get("source_task_id")): str(unit.get("repo_id"))
2388
+ for unit in plan.get("units", [])
2389
+ if isinstance(unit, dict)
2390
+ and is_non_empty_string(unit.get("source_task_id"))
2391
+ and is_non_empty_string(unit.get("repo_id"))
2392
+ }
2393
+ for record in latest_by_check.values():
2394
+ source_task_id = str(record.get("source_task_id") or "")
2395
+ if (
2396
+ source_task_id not in task_repositories
2397
+ or record.get("repo_id") != task_repositories[source_task_id]
2398
+ ):
2399
+ raise StateError(
2400
+ "Canonical Spec verification evidence must preserve "
2401
+ "repository/source-task ownership."
2402
+ )
1617
2403
  applicable_records = [
1618
2404
  record for record in latest_by_check.values() if record.get("applicable") is not False
1619
2405
  ]
@@ -1625,27 +2411,243 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
1625
2411
  raise StateError(
1626
2412
  "VERIFICATION cannot advance to MEMORY while current verification evidence contains failures."
1627
2413
  )
1628
- if task.get("workflow_mode") == "strict":
1629
- latest_by_type: dict[str, dict] = {}
1630
- for record in latest_by_check.values():
1631
- check_type = str(record.get("check_type") or "")
1632
- if check_type in STRICT_VERIFICATION_CHECK_TYPES:
1633
- latest_by_type[check_type] = record
1634
- missing_types = sorted(STRICT_VERIFICATION_CHECK_TYPES - latest_by_type.keys())
1635
- if missing_types:
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:
1636
2427
  raise StateError(
1637
- "Strict workflow requires current verification evidence for every check type: "
1638
- + ", ".join(missing_types)
1639
- + "."
2428
+ "TDD verification requires changed-production-line JaCoCo coverage evidence."
1640
2429
  )
1641
- for check_type, record in latest_by_type.items():
1642
- if record.get("applicable") is False and not is_non_empty_string(
1643
- record.get("not_applicable_reason")
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
+ )
1644
2514
  ):
1645
2515
  raise StateError(
1646
- "Strict workflow requires a non-empty not_applicable_reason when "
1647
- f"{check_type} is marked not applicable."
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
+ )
2547
+ if task.get("workflow_mode") == "strict":
2548
+ if is_spec_task:
2549
+ check_types_by_repository: dict[str, set[str]] = {
2550
+ repo_id: set() for repo_id in set(task_repositories.values())
2551
+ }
2552
+ for record in latest_by_check.values():
2553
+ repo_id = str(record.get("repo_id") or "")
2554
+ check_type = str(record.get("check_type") or "")
2555
+ if repo_id in check_types_by_repository and check_type in STRICT_VERIFICATION_CHECK_TYPES:
2556
+ check_types_by_repository[repo_id].add(check_type)
2557
+ missing_by_repository = {
2558
+ repo_id: sorted(STRICT_VERIFICATION_CHECK_TYPES - check_types)
2559
+ for repo_id, check_types in check_types_by_repository.items()
2560
+ if check_types != STRICT_VERIFICATION_CHECK_TYPES
2561
+ }
2562
+ if missing_by_repository:
2563
+ raise StateError(
2564
+ "Strict Canonical Spec verification requires every repository to cover "
2565
+ "lint, typecheck, test, and build: "
2566
+ + "; ".join(
2567
+ f"{repo_id} missing {', '.join(check_types)}"
2568
+ for repo_id, check_types in sorted(missing_by_repository.items())
2569
+ )
1648
2570
  )
2571
+ else:
2572
+ latest_by_type: dict[str, dict] = {}
2573
+ for record in latest_by_check.values():
2574
+ check_type = str(record.get("check_type") or "")
2575
+ if check_type in STRICT_VERIFICATION_CHECK_TYPES:
2576
+ latest_by_type[check_type] = record
2577
+ missing_types = sorted(STRICT_VERIFICATION_CHECK_TYPES - latest_by_type.keys())
2578
+ if missing_types:
2579
+ raise StateError(
2580
+ "Strict workflow requires current verification evidence for every check type: "
2581
+ + ", ".join(missing_types)
2582
+ + "."
2583
+ )
2584
+ for check_type, record in latest_by_type.items():
2585
+ if record.get("applicable") is False and not is_non_empty_string(
2586
+ record.get("not_applicable_reason")
2587
+ ):
2588
+ raise StateError(
2589
+ "Strict workflow requires a non-empty not_applicable_reason when "
2590
+ f"{check_type} is marked not applicable."
2591
+ )
2592
+ if is_spec_task:
2593
+ inspect_task_spec(root, task)
2594
+ plan = latest_execution_plan(root, task_id)
2595
+ required_test_commands = {
2596
+ (
2597
+ str(unit.get("source_task_id")),
2598
+ str(unit.get("repo_id")),
2599
+ str(command),
2600
+ )
2601
+ for unit in (plan or {}).get("units", [])
2602
+ if isinstance(unit, dict)
2603
+ for command in unit.get("test_commands", [])
2604
+ if is_non_empty_string(command)
2605
+ }
2606
+ executed_commands = {
2607
+ (
2608
+ str(record.get("source_task_id")),
2609
+ str(record.get("repo_id")),
2610
+ str(record.get("command")),
2611
+ )
2612
+ for record in applicable_records
2613
+ if is_non_empty_string(record.get("command"))
2614
+ }
2615
+ missing_commands = sorted(required_test_commands - executed_commands)
2616
+ if missing_commands:
2617
+ raise StateError(
2618
+ "Canonical Spec verification is missing source test commands: "
2619
+ + ", ".join(
2620
+ f"{source_task_id}@{repo_id}: {command}"
2621
+ for source_task_id, repo_id, command in missing_commands
2622
+ )
2623
+ )
2624
+ covered_verification_tasks = {
2625
+ str(record.get("source_task_id")) for record in applicable_records
2626
+ }
2627
+ missing_verification_tasks = sorted(
2628
+ set(task_repositories) - covered_verification_tasks
2629
+ )
2630
+ if missing_verification_tasks:
2631
+ raise StateError(
2632
+ "Canonical Spec verification evidence does not cover selected source tasks: "
2633
+ + ", ".join(missing_verification_tasks)
2634
+ )
2635
+ pending_integration = [
2636
+ record
2637
+ for record in task.get("spec_dependency_evidence", [])
2638
+ if isinstance(record, dict)
2639
+ and record.get("dependency_type") == "integration"
2640
+ and record.get("status") != "satisfied"
2641
+ ]
2642
+ if pending_integration:
2643
+ edges = ", ".join(
2644
+ f"{record.get('source_task_id')}->{record.get('task_id')}"
2645
+ for record in pending_integration
2646
+ )
2647
+ raise StateError(
2648
+ "VERIFICATION cannot advance to MEMORY while Canonical Spec integration "
2649
+ f"dependencies are pending: {edges}."
2650
+ )
1649
2651
 
1650
2652
 
1651
2653
  def validate_read_only_completion(root: Path, task_id: str) -> None:
@@ -1812,7 +2814,9 @@ def validate_mandatory_dev_spec_sections(content: str) -> tuple[list[str], list[
1812
2814
  return missing, empty
1813
2815
 
1814
2816
 
1815
- 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:
1816
2820
  task_dir = task_json_path(root, task_id).parent
1817
2821
  task = load_task(root, task_id)
1818
2822
  task_type = str(task.get("type") or "").strip().lower() if task else ""
@@ -1821,6 +2825,9 @@ def validate_analysis_readiness(root: Path, task_id: str) -> None:
1821
2825
  skeleton = root / ".easy-coding" / "templates" / "dev-spec-skeleton.md"
1822
2826
  test_strategy = task_dir / "test-strategy.md"
1823
2827
  reasons: list[str] = []
2828
+ behavior = resolve_behavior(root, session or default_session())
2829
+ tdd_enabled = behavior[8]
2830
+ tdd_threshold = behavior[11]
1824
2831
 
1825
2832
  dev_spec_content = ""
1826
2833
  if not dev_spec.exists():
@@ -1864,8 +2871,201 @@ def validate_analysis_readiness(root: Path, task_id: str) -> None:
1864
2871
  except OSError:
1865
2872
  reasons.append("dev-spec skeleton template cannot be read")
1866
2873
 
1867
- if not has_valid_execution_plan(root, task_id):
2874
+ plan_is_valid = has_valid_execution_plan(root, task_id)
2875
+ if not plan_is_valid:
1868
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
+ )
2952
+ if task and isinstance(task.get("spec_source"), dict):
2953
+ try:
2954
+ inspection, selection = inspect_task_spec(root, task)
2955
+ required_markers = [
2956
+ str(task["spec_source"].get("path") or ""),
2957
+ str(task["spec_source"].get("spec_id") or ""),
2958
+ str(task["spec_source"].get("sha256") or ""),
2959
+ *[str(task_id) for task_id in selection["selected_task_ids"]],
2960
+ *[str(repo_id) for repo_id in selection["selected_repo_ids"]],
2961
+ *[
2962
+ f"{repo_id}={inspection['baseline_status'].get(repo_id)}"
2963
+ for repo_id in selection["selected_repo_ids"]
2964
+ ],
2965
+ ]
2966
+ missing_markers = [
2967
+ marker
2968
+ for marker in required_markers
2969
+ if marker and not contains_spec_marker(dev_spec_content, marker)
2970
+ ]
2971
+ revision = task["spec_source"].get("revision")
2972
+ if (
2973
+ type(revision) is not int
2974
+ or re.search(
2975
+ rf"\brevision\s*[::=]\s*{revision}(?!\d)",
2976
+ dev_spec_content,
2977
+ re.IGNORECASE,
2978
+ )
2979
+ is None
2980
+ ):
2981
+ missing_markers.append(f"revision={revision}")
2982
+ if missing_markers:
2983
+ reasons.append(
2984
+ "dev-spec.md is missing Canonical Spec traceability markers: "
2985
+ + ", ".join(missing_markers)
2986
+ )
2987
+ selected_repo_ids = set(selection["selected_repo_ids"])
2988
+ bindings = task.get("spec_repositories")
2989
+ bound_repo_ids = {
2990
+ str(binding.get("repo_id"))
2991
+ for binding in bindings or []
2992
+ if isinstance(binding, dict)
2993
+ }
2994
+ if bound_repo_ids != selected_repo_ids:
2995
+ reasons.append("spec_repositories do not cover selected Canonical Spec tasks")
2996
+ if inspection.get("unresolved_repositories"):
2997
+ reasons.append(
2998
+ "Canonical Spec repository bindings are unresolved: "
2999
+ + ", ".join(inspection["unresolved_repositories"])
3000
+ )
3001
+ unavailable_repositories = [
3002
+ repo_id
3003
+ for repo_id in selection["selected_repo_ids"]
3004
+ if inspection["baseline_status"].get(repo_id) == "baseline-unavailable"
3005
+ ]
3006
+ if unavailable_repositories:
3007
+ reasons.append(
3008
+ "Canonical Spec baselines are unavailable: "
3009
+ + ", ".join(unavailable_repositories)
3010
+ )
3011
+ if plan_is_valid:
3012
+ plan = latest_execution_plan(root, task_id)
3013
+ if plan is None:
3014
+ reasons.append("Canonical Spec execution plan cannot be loaded")
3015
+ else:
3016
+ task_repository_scopes(root, task, plan)
3017
+ derived_markers = [
3018
+ *[
3019
+ str(unit.get("id") or "")
3020
+ for unit in plan.get("units", [])
3021
+ if isinstance(unit, dict)
3022
+ ],
3023
+ *[
3024
+ str(step_id)
3025
+ for unit in plan.get("units", [])
3026
+ if isinstance(unit, dict)
3027
+ for step_id in unit.get("source_step_ids", [])
3028
+ ],
3029
+ *[
3030
+ f"{record.get('source_task_id')}->{record.get('task_id')}"
3031
+ for record in task.get("spec_dependency_evidence", [])
3032
+ if isinstance(record, dict)
3033
+ and record.get("dependency_type") == "integration"
3034
+ and record.get("status") == "pending"
3035
+ ],
3036
+ *[
3037
+ str(record.get("required_evidence") or "")
3038
+ for record in task.get("spec_dependency_evidence", [])
3039
+ if isinstance(record, dict)
3040
+ and record.get("dependency_type") == "integration"
3041
+ and record.get("status") == "pending"
3042
+ ],
3043
+ ]
3044
+ missing_derived_markers = [
3045
+ marker
3046
+ for marker in derived_markers
3047
+ if marker and not contains_spec_marker(dev_spec_content, marker)
3048
+ ]
3049
+ if missing_derived_markers:
3050
+ reasons.append(
3051
+ "dev-spec.md is missing Canonical Spec Unit/dependency markers: "
3052
+ + ", ".join(dict.fromkeys(missing_derived_markers))
3053
+ )
3054
+ if test_strategy.is_file():
3055
+ test_strategy_content = test_strategy.read_text(encoding="utf-8")
3056
+ if test_strategy_content.strip():
3057
+ missing_test_markers = missing_spec_test_strategy_markers(
3058
+ selection, plan, test_strategy_content
3059
+ )
3060
+ if missing_test_markers:
3061
+ reasons.append(
3062
+ "test-strategy.md is missing Canonical Spec markers: "
3063
+ + ", ".join(missing_test_markers)
3064
+ )
3065
+ except StateError as exc:
3066
+ reasons.append(str(exc))
3067
+ except OSError:
3068
+ reasons.append("test-strategy.md cannot be read")
1869
3069
  if is_read_only_task:
1870
3070
  if test_strategy.exists():
1871
3071
  reasons.append("read-only task must not create test-strategy.md")
@@ -1921,6 +3121,28 @@ def get_pending_init_version(root: Path) -> str | None:
1921
3121
  return None
1922
3122
 
1923
3123
 
3124
+ def spec_task_summary(task: dict | None) -> dict | None:
3125
+ if not task or not isinstance(task.get("spec_source"), dict):
3126
+ return None
3127
+ dependencies = task.get("spec_dependency_evidence")
3128
+ pending_dependencies = [
3129
+ {
3130
+ "source_task_id": record.get("source_task_id"),
3131
+ "task_id": record.get("task_id"),
3132
+ "dependency_type": record.get("dependency_type"),
3133
+ "required_evidence": record.get("required_evidence"),
3134
+ }
3135
+ for record in dependencies or []
3136
+ if isinstance(record, dict) and record.get("status") == "pending"
3137
+ ]
3138
+ return {
3139
+ "source": task["spec_source"],
3140
+ "selected_spec_tasks": task.get("selected_spec_tasks", []),
3141
+ "repositories": task.get("spec_repositories", []),
3142
+ "pending_dependencies": pending_dependencies,
3143
+ }
3144
+
3145
+
1924
3146
  def transition_requires_confirmation(
1925
3147
  previous: str,
1926
3148
  current: str,
@@ -2012,6 +3234,12 @@ def snapshot_state(
2012
3234
  project_workflow_mode,
2013
3235
  session_workflow_mode,
2014
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,
2015
3243
  ) = resolve_behavior(root, resolved_session)
2016
3244
  concrete_workflow_mode = None
2017
3245
  if task:
@@ -2019,6 +3247,19 @@ def snapshot_state(
2019
3247
  proposal = task.get("workflow_mode_proposal")
2020
3248
  if concrete_workflow_mode is None and isinstance(proposal, dict):
2021
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
+ )
2022
3263
 
2023
3264
  return {
2024
3265
  "session_file": display_path(root, session_path),
@@ -2039,6 +3280,18 @@ def snapshot_state(
2039
3280
  "session_workflow_mode": session_workflow_mode,
2040
3281
  "configured_workflow_mode": configured_workflow_mode,
2041
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,
3294
+ "spec_summary": spec_task_summary(task),
2042
3295
  # Compatibility output aliases for pre-0.9 clients.
2043
3296
  "project_confirm_mode": project_approval_mode,
2044
3297
  "session_confirm_mode": session_approval_mode,
@@ -2057,6 +3310,8 @@ def build_status_line(
2057
3310
  approval = str(state["effective_approval_mode"]).capitalize()
2058
3311
  workflow = str(state["concrete_workflow_mode"] or state["configured_workflow_mode"]).capitalize()
2059
3312
  status_brand = f"> **Easy Coding** · **Approval: {approval}** · **Workflow: {workflow}**"
3313
+ if state["displayed_tdd_enabled"] is True:
3314
+ status_brand += " · **TDD**"
2060
3315
  task_id = state["current_task"]
2061
3316
  if task_id:
2062
3317
  status = str(state["status"])
@@ -2100,6 +3355,11 @@ def build_machine_breadcrumbs(
2100
3355
  ]
2101
3356
  if state.get("concrete_workflow_mode"):
2102
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
+ )
2103
3363
 
2104
3364
  if task_id:
2105
3365
  lines.append(f"[current-task:{task_id}]")
@@ -2245,6 +3505,7 @@ def list_tasks(root: Path, agent: str | None = None) -> list[dict]:
2245
3505
  "action": action,
2246
3506
  "previous_agent": last_agent if action == "takeover" else None,
2247
3507
  "latest_handoff": latest_handoff_record(root, entry.name),
3508
+ "spec_summary": spec_task_summary(task),
2248
3509
  }
2249
3510
  )
2250
3511
  return items
@@ -2406,6 +3667,43 @@ def clear_session_workflow_mode(
2406
3667
  return snapshot
2407
3668
 
2408
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
+
2409
3707
  def set_harness_disabled(
2410
3708
  root: Path,
2411
3709
  disabled: bool,
@@ -2507,6 +3805,7 @@ def create_task(
2507
3805
  agent: str,
2508
3806
  set_current: bool = True,
2509
3807
  session_file: str | Path | None = None,
3808
+ task_fields: dict | None = None,
2510
3809
  ) -> dict:
2511
3810
  assert_safe_task_id(task_id)
2512
3811
  if set_current:
@@ -2529,12 +3828,137 @@ def create_task(
2529
3828
  "closed_reason": None,
2530
3829
  "repos": [],
2531
3830
  }
3831
+ if task_fields:
3832
+ task.update(task_fields)
2532
3833
  write_task(root, task_id, task)
2533
3834
  if set_current:
2534
3835
  return set_current_task(root, task_id, agent, session_file)
2535
3836
  return {"task_id": task_id, "task": task}
2536
3837
 
2537
3838
 
3839
+ def ensure_path_inside_root(root: Path, path: Path, label: str) -> Path:
3840
+ resolved = path.resolve()
3841
+ try:
3842
+ resolved.relative_to(root.resolve())
3843
+ except ValueError as exc:
3844
+ raise StateError(f"{label} must be inside the Easy Coding project root.") from exc
3845
+ return resolved
3846
+
3847
+
3848
+ def create_task_from_spec(
3849
+ root: Path,
3850
+ spec_path: str,
3851
+ spec_task_ids: list[str],
3852
+ task_id: str,
3853
+ task_type: str,
3854
+ title: str,
3855
+ repo_paths: dict[str, str],
3856
+ dependency_evidence: dict[str, str],
3857
+ agent: str,
3858
+ set_current: bool = True,
3859
+ session_file: str | Path | None = None,
3860
+ ) -> dict:
3861
+ raw_spec_path = Path(spec_path)
3862
+ resolved_spec_path = ensure_path_inside_root(
3863
+ root,
3864
+ raw_spec_path if raw_spec_path.is_absolute() else root / raw_spec_path,
3865
+ "Canonical Spec path",
3866
+ )
3867
+ try:
3868
+ inspection = inspect_spec(
3869
+ resolved_spec_path,
3870
+ root,
3871
+ repo_paths,
3872
+ spec_task_ids,
3873
+ )
3874
+ selection = select_tasks(inspection, spec_task_ids, dependency_evidence)
3875
+ except EasyDevSpecError as exc:
3876
+ raise StateError(f"Cannot create task from Canonical Spec: {exc}") from exc
3877
+
3878
+ selected_repo_ids = set(selection["selected_repo_ids"])
3879
+ bindings = [
3880
+ binding
3881
+ for binding in inspection["repository_bindings"]
3882
+ if binding.get("repo_id") in selected_repo_ids
3883
+ ]
3884
+ if len(bindings) != len(selected_repo_ids):
3885
+ raise StateError("Canonical Spec repository bindings do not cover every selected task.")
3886
+ stored_repo_paths = {
3887
+ str(binding["repo_id"]): str(binding["path"])
3888
+ for binding in bindings
3889
+ }
3890
+ source_path = resolved_spec_path.relative_to(root.resolve()).as_posix()
3891
+ fields = {
3892
+ "repos": list(selection["selected_repo_ids"]),
3893
+ "repo_paths": stored_repo_paths,
3894
+ "spec_source": {
3895
+ "schema": inspection["schema"],
3896
+ "spec_id": inspection["spec_id"],
3897
+ "revision": inspection["revision"],
3898
+ "path": source_path,
3899
+ "sha256": inspection["source_sha256"],
3900
+ },
3901
+ "selected_spec_tasks": selection["selected_task_ids"],
3902
+ "spec_repositories": bindings,
3903
+ "spec_dependency_evidence": selection["dependency_records"],
3904
+ }
3905
+ return create_task(
3906
+ root,
3907
+ task_id,
3908
+ task_type,
3909
+ title,
3910
+ agent,
3911
+ set_current,
3912
+ session_file,
3913
+ fields,
3914
+ )
3915
+
3916
+
3917
+ def satisfy_spec_dependency(
3918
+ root: Path,
3919
+ dependency_task_id: str,
3920
+ evidence: str,
3921
+ agent: str,
3922
+ source_task_id: str | None = None,
3923
+ task_id: str | None = None,
3924
+ session_file: str | Path | None = None,
3925
+ ) -> dict:
3926
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
3927
+ if task.get("status") in TERMINAL_STATUSES or task.get("status") == "MEMORY":
3928
+ raise StateError("Spec dependency evidence cannot change after MEMORY begins.")
3929
+ if not is_non_empty_string(evidence):
3930
+ raise StateError("Spec dependency evidence must be non-empty.")
3931
+ inspect_task_spec(root, task)
3932
+ records = task.get("spec_dependency_evidence")
3933
+ if not isinstance(records, list):
3934
+ raise StateError("Current task is not backed by Canonical Spec dependency metadata.")
3935
+ matches = [
3936
+ record
3937
+ for record in records
3938
+ if isinstance(record, dict)
3939
+ and record.get("task_id") == dependency_task_id
3940
+ and (source_task_id is None or record.get("source_task_id") == source_task_id)
3941
+ ]
3942
+ if not matches:
3943
+ raise StateError("Canonical Spec dependency edge was not found.")
3944
+ if source_task_id is None and len(matches) > 1:
3945
+ raise StateError(
3946
+ "Canonical Spec dependency is ambiguous; pass --source-task to identify the edge."
3947
+ )
3948
+ record = matches[0]
3949
+ if record.get("dependency_type") == "contract":
3950
+ raise StateError("Contract dependencies are satisfied by the frozen READY Spec.")
3951
+ record["status"] = "satisfied"
3952
+ record["evidence"] = evidence.strip()
3953
+ record["satisfied_at"] = now_iso()
3954
+ record["satisfied_by"] = agent
3955
+ task["last_agent"] = agent
3956
+ write_task(root, resolved_task_id, task)
3957
+ snapshot = snapshot_state(root, session_file, session)
3958
+ snapshot["action"] = "satisfy-spec-dependency"
3959
+ return snapshot
3960
+
3961
+
2538
3962
  def append_stage_history(task: dict, stage: str, agent: str) -> None:
2539
3963
  history = task.setdefault("stage_history", [])
2540
3964
  history.append({"stage": stage, "agent": agent, "entered_at": now_iso()})
@@ -2710,6 +4134,39 @@ def freeze_workflow_mode(
2710
4134
  task["workflow_mode_confirmed_by"] = agent
2711
4135
 
2712
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
+
2713
4170
  def raise_workflow_mode(
2714
4171
  root: Path,
2715
4172
  mode: str,
@@ -2775,7 +4232,7 @@ def request_transition(
2775
4232
  "use auto-transition instead."
2776
4233
  )
2777
4234
  if previous == "ANALYSIS" and stage == "IMPLEMENT":
2778
- validate_analysis_readiness(root, resolved_task_id)
4235
+ validate_analysis_readiness(root, resolved_task_id, session)
2779
4236
  if task.get("workflow_mode_legacy") is not True:
2780
4237
  validate_workflow_mode_proposal(
2781
4238
  root,
@@ -2828,9 +4285,10 @@ def apply_transition(
2828
4285
  if violation:
2829
4286
  raise StateError(violation)
2830
4287
  if previous == "ANALYSIS" and stage == "IMPLEMENT":
2831
- validate_analysis_readiness(root, resolved_task_id)
4288
+ validate_analysis_readiness(root, resolved_task_id, session)
2832
4289
  if task.get("workflow_mode_legacy") is not True:
2833
4290
  freeze_workflow_mode(root, session, resolved_task_id, task, agent)
4291
+ freeze_tdd_mode(root, session, resolved_task_id, task, agent)
2834
4292
  if previous == "REVIEW" and stage == "VERIFICATION":
2835
4293
  validate_review_readiness(root, resolved_task_id, task)
2836
4294
  if previous == "VERIFICATION" and stage == "MEMORY":
@@ -3180,6 +4638,19 @@ def add_common_args(parser: argparse.ArgumentParser) -> None:
3180
4638
  parser.add_argument("--session-file", help="Session file path injected by the hook.")
3181
4639
 
3182
4640
 
4641
+ def parse_mapping_args(values: list[str], label: str) -> dict[str, str]:
4642
+ mappings: dict[str, str] = {}
4643
+ for value in values:
4644
+ key, separator, mapped_value = value.partition("=")
4645
+ if not separator or not key.strip() or not mapped_value.strip():
4646
+ raise StateError(f"{label} must use KEY=VALUE syntax: {value!r}")
4647
+ key = key.strip()
4648
+ if key in mappings:
4649
+ raise StateError(f"{label} contains a duplicate key: {key}")
4650
+ mappings[key] = mapped_value.strip()
4651
+ return mappings
4652
+
4653
+
3183
4654
  def main() -> int:
3184
4655
  configure_stdio()
3185
4656
  common = argparse.ArgumentParser(add_help=False)
@@ -3193,6 +4664,14 @@ def main() -> int:
3193
4664
  list_tasks_parser = subcommands.add_parser("list-tasks", parents=[common])
3194
4665
  list_tasks_parser.add_argument("--agent")
3195
4666
 
4667
+ inspect_spec_parser = subcommands.add_parser("inspect-dev-spec", parents=[common])
4668
+ inspect_spec_parser.add_argument("--spec", required=True)
4669
+ inspect_spec_parser.add_argument("--repo-path", action="append", default=[])
4670
+
4671
+ select_spec_scope = subcommands.add_parser("select-dev-spec-scope", parents=[common])
4672
+ select_spec_scope.add_argument("--spec", required=True)
4673
+ select_spec_scope.add_argument("--spec-task", required=True, action="append")
4674
+
3196
4675
  create = subcommands.add_parser("create-task", parents=[common])
3197
4676
  create.add_argument("--task-id", required=True)
3198
4677
  create.add_argument("--type", required=True)
@@ -3200,6 +4679,17 @@ def main() -> int:
3200
4679
  create.add_argument("--agent", required=True)
3201
4680
  create.add_argument("--no-set-current", action="store_true")
3202
4681
 
4682
+ create_from_spec = subcommands.add_parser("create-task-from-spec", parents=[common])
4683
+ create_from_spec.add_argument("--spec", required=True)
4684
+ create_from_spec.add_argument("--spec-task", required=True, action="append")
4685
+ create_from_spec.add_argument("--task-id", required=True)
4686
+ create_from_spec.add_argument("--type", required=True)
4687
+ create_from_spec.add_argument("--title", required=True)
4688
+ create_from_spec.add_argument("--repo-path", required=True, action="append")
4689
+ create_from_spec.add_argument("--dependency-evidence", action="append", default=[])
4690
+ create_from_spec.add_argument("--agent", required=True)
4691
+ create_from_spec.add_argument("--no-set-current", action="store_true")
4692
+
3203
4693
  set_current = subcommands.add_parser("set-current", parents=[common])
3204
4694
  set_current.add_argument("--task-id", required=True)
3205
4695
  set_current.add_argument("--agent", required=True)
@@ -3223,6 +4713,14 @@ def main() -> int:
3223
4713
  clear_workflow_mode_parser = subcommands.add_parser("clear-workflow-mode", parents=[common])
3224
4714
  clear_workflow_mode_parser.add_argument("--agent", required=True)
3225
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
+
3226
4724
  # Compatibility aliases for pre-0.9 callers.
3227
4725
  set_confirm_mode_parser = subcommands.add_parser("set-confirm-mode", parents=[common])
3228
4726
  set_confirm_mode_parser.add_argument(
@@ -3339,6 +4837,13 @@ def main() -> int:
3339
4837
  repo_path.add_argument("--agent")
3340
4838
  repo_path.add_argument("--task-id")
3341
4839
 
4840
+ satisfy_dependency = subcommands.add_parser("satisfy-spec-dependency", parents=[common])
4841
+ satisfy_dependency.add_argument("--spec-task", required=True)
4842
+ satisfy_dependency.add_argument("--source-task")
4843
+ satisfy_dependency.add_argument("--evidence", required=True)
4844
+ satisfy_dependency.add_argument("--agent", required=True)
4845
+ satisfy_dependency.add_argument("--task-id")
4846
+
3342
4847
  args = parser.parse_args()
3343
4848
  try:
3344
4849
  root = resolve_root(getattr(args, "cwd", None))
@@ -3353,7 +4858,12 @@ def main() -> int:
3353
4858
  raise StateError(
3354
4859
  "project-init-complete requires --session-file from the current hook context."
3355
4860
  )
3356
- if session_file is None and command not in {"list-tasks", "memory-new-id"}:
4861
+ if session_file is None and command not in {
4862
+ "inspect-dev-spec",
4863
+ "select-dev-spec-scope",
4864
+ "list-tasks",
4865
+ "memory-new-id",
4866
+ }:
3357
4867
  if session_agent == "unknown":
3358
4868
  raise StateError(
3359
4869
  "Cannot resolve the logical session. Pass --session-file or --agent."
@@ -3361,6 +4871,26 @@ def main() -> int:
3361
4871
  _, session_file = ensure_hook_session(root, {}, session_agent)
3362
4872
  if command == "snapshot":
3363
4873
  emit(snapshot_state(root, session_file))
4874
+ elif command == "inspect-dev-spec":
4875
+ spec_path = Path(args.spec)
4876
+ emit(
4877
+ inspection_summary(
4878
+ inspect_spec(
4879
+ spec_path if spec_path.is_absolute() else root / spec_path,
4880
+ root,
4881
+ parse_mapping_args(args.repo_path, "--repo-path"),
4882
+ )
4883
+ )
4884
+ )
4885
+ elif command == "select-dev-spec-scope":
4886
+ spec_path = Path(args.spec)
4887
+ emit(
4888
+ select_consumption_scopes(
4889
+ spec_path if spec_path.is_absolute() else root / spec_path,
4890
+ root,
4891
+ args.spec_task,
4892
+ )
4893
+ )
3364
4894
  elif command == "list-tasks":
3365
4895
  emit({"tasks": list_tasks(root, visible_agent)})
3366
4896
  elif command == "create-task":
@@ -3380,6 +4910,30 @@ def main() -> int:
3380
4910
  session_file,
3381
4911
  )
3382
4912
  )
4913
+ elif command == "create-task-from-spec":
4914
+ emit(
4915
+ attach_status_context(
4916
+ root,
4917
+ create_task_from_spec(
4918
+ root,
4919
+ args.spec,
4920
+ args.spec_task,
4921
+ args.task_id,
4922
+ args.type,
4923
+ args.title,
4924
+ parse_mapping_args(args.repo_path, "--repo-path"),
4925
+ parse_mapping_args(
4926
+ args.dependency_evidence,
4927
+ "--dependency-evidence",
4928
+ ),
4929
+ agent,
4930
+ not args.no_set_current,
4931
+ session_file,
4932
+ ),
4933
+ agent,
4934
+ session_file,
4935
+ )
4936
+ )
3383
4937
  elif command == "set-current":
3384
4938
  emit(
3385
4939
  attach_status_context(
@@ -3452,6 +5006,30 @@ def main() -> int:
3452
5006
  session_file,
3453
5007
  )
3454
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
+ )
3455
5033
  elif command == "propose-workflow-mode":
3456
5034
  emit(
3457
5035
  attach_status_context(
@@ -3664,8 +5242,25 @@ def main() -> int:
3664
5242
  session_file,
3665
5243
  )
3666
5244
  )
5245
+ elif command == "satisfy-spec-dependency":
5246
+ emit(
5247
+ attach_status_context(
5248
+ root,
5249
+ satisfy_spec_dependency(
5250
+ root,
5251
+ args.spec_task,
5252
+ args.evidence,
5253
+ agent,
5254
+ args.source_task,
5255
+ args.task_id,
5256
+ session_file,
5257
+ ),
5258
+ agent,
5259
+ session_file,
5260
+ )
5261
+ )
3667
5262
  return 0
3668
- except StateError as error:
5263
+ except (StateError, EasyDevSpecError) as error:
3669
5264
  print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
3670
5265
  return 1
3671
5266