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

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.
@@ -12,6 +12,14 @@ from datetime import datetime, timezone
12
12
  from pathlib import Path
13
13
  import sys
14
14
 
15
+ from easy_dev_spec import (
16
+ EasyDevSpecError,
17
+ inspect_spec,
18
+ inspection_summary,
19
+ select_consumption_scopes,
20
+ select_tasks,
21
+ )
22
+
15
23
 
16
24
  TERMINAL_STATUSES = {"COMPLETE", "CLOSED"}
17
25
  HELP_SUFFIX = (
@@ -87,7 +95,7 @@ DEFAULT_SHORT_TERM_KEEP = 5
87
95
  SESSION_STALE_THRESHOLD_HOURS = 30 * 24
88
96
  SESSION_COMPONENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
89
97
  SESSION_AGENT_NAMESPACES = {"claude-code", "codex", "qoder", "unknown"}
90
- CODEX_AGENT_PATH_PATTERN = re.compile(r"^/root(?:/[a-z0-9._-]+)*$")
98
+ CODEX_AGENT_PATH_PATTERN = re.compile(r"^/?root(?:/[a-z0-9._-]+)*$")
91
99
  LEGACY_STATE_LOCK_TIMEOUT_SECONDS = 5.0
92
100
  LEGACY_STATE_LOCK_STALE_SECONDS = 60.0
93
101
  LEGACY_STATE_LOCK_POLL_SECONDS = 0.02
@@ -157,7 +165,7 @@ def short_memory_id_sort_key(memory_id: str) -> tuple[int, str]:
157
165
  def normalize_agent_identity(agent: str | None) -> str:
158
166
  raw_agent = str(agent or "unknown").strip()
159
167
  normalized = raw_agent.lower()
160
- # Codex 协作树中的 /root 路径表示 Codex 内部执行者,不是一个新的跨平台 Agent。
168
+ # Codex 可能把根执行者写成 root /root;两者及其协作子路径都属于同一平台身份。
161
169
  if CODEX_AGENT_PATH_PATTERN.fullmatch(normalized):
162
170
  return "codex"
163
171
  if normalized in SESSION_AGENT_NAMESPACES:
@@ -1071,6 +1079,337 @@ def is_read_only_execution_plan(plan: object) -> bool:
1071
1079
  )
1072
1080
 
1073
1081
 
1082
+ def stored_spec_path(root: Path, task: dict) -> Path:
1083
+ source = task.get("spec_source")
1084
+ if not isinstance(source, dict) or not is_non_empty_string(source.get("path")):
1085
+ raise StateError("Spec-backed task is missing spec_source.path.")
1086
+ raw_path = Path(str(source["path"]))
1087
+ path = raw_path if raw_path.is_absolute() else root / raw_path
1088
+ resolved = path.resolve()
1089
+ try:
1090
+ resolved.relative_to(root.resolve())
1091
+ except ValueError as exc:
1092
+ raise StateError("Spec-backed task source path must remain inside the project root.") from exc
1093
+ return resolved
1094
+
1095
+
1096
+ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
1097
+ source = task.get("spec_source")
1098
+ selected = task.get("selected_spec_tasks")
1099
+ repo_paths = task.get("repo_paths")
1100
+ if not isinstance(source, dict) or not is_string_list(selected, allow_empty=False):
1101
+ raise StateError("Spec-backed task source and selected task metadata are incomplete.")
1102
+ try:
1103
+ inspection = inspect_spec(
1104
+ stored_spec_path(root, task),
1105
+ root,
1106
+ repo_paths if isinstance(repo_paths, dict) else {},
1107
+ selected,
1108
+ )
1109
+ satisfied = {
1110
+ f"{record.get('source_task_id')}->{record.get('task_id')}": str(record.get("evidence"))
1111
+ for record in task.get("spec_dependency_evidence", [])
1112
+ if isinstance(record, dict)
1113
+ and record.get("status") == "satisfied"
1114
+ and is_non_empty_string(record.get("task_id"))
1115
+ and is_non_empty_string(record.get("evidence"))
1116
+ }
1117
+ selection = select_tasks(inspection, selected, satisfied)
1118
+ except EasyDevSpecError as exc:
1119
+ raise StateError(f"Canonical Spec validation failed: {exc}") from exc
1120
+ stored_dependencies = task.get("spec_dependency_evidence")
1121
+ if not isinstance(stored_dependencies, list):
1122
+ raise StateError("Spec-backed task dependency metadata is incomplete.")
1123
+ expected_by_edge = {
1124
+ (record.get("source_task_id"), record.get("task_id")): record
1125
+ for record in selection["dependency_records"]
1126
+ }
1127
+ stored_by_edge = {
1128
+ (record.get("source_task_id"), record.get("task_id")): record
1129
+ for record in stored_dependencies
1130
+ if isinstance(record, dict)
1131
+ }
1132
+ if (
1133
+ len(stored_by_edge) != len(stored_dependencies)
1134
+ or set(stored_by_edge) != set(expected_by_edge)
1135
+ ):
1136
+ raise StateError("Canonical Spec dependency metadata no longer matches source selection.")
1137
+ for edge, expected in expected_by_edge.items():
1138
+ stored = stored_by_edge[edge]
1139
+ for field in ("dependency_type", "required_evidence", "status"):
1140
+ if stored.get(field) != expected.get(field):
1141
+ raise StateError(
1142
+ "Canonical Spec dependency metadata no longer matches source selection."
1143
+ )
1144
+ if stored.get("evidence") != expected.get("evidence"):
1145
+ raise StateError(
1146
+ "Canonical Spec dependency evidence no longer matches its recorded status."
1147
+ )
1148
+ if source.get("schema") != inspection.get("schema"):
1149
+ raise StateError("Canonical Spec schema no longer matches task.json.")
1150
+ if source.get("spec_id") != inspection.get("spec_id"):
1151
+ raise StateError("Canonical Spec ID no longer matches task.json.")
1152
+ if source.get("revision") != inspection.get("revision"):
1153
+ raise StateError("Canonical Spec revision no longer matches task.json.")
1154
+ if source.get("sha256") != inspection.get("source_sha256"):
1155
+ raise StateError("Canonical Spec SHA-256 changed after task creation.")
1156
+ selected_repo_ids = set(selection["selected_repo_ids"])
1157
+ stored_bindings = task.get("spec_repositories")
1158
+ if not isinstance(stored_bindings, list):
1159
+ raise StateError("Spec-backed task repository metadata is incomplete.")
1160
+ stored_by_repo = {
1161
+ str(binding.get("repo_id")): binding
1162
+ for binding in stored_bindings
1163
+ if isinstance(binding, dict) and is_non_empty_string(binding.get("repo_id"))
1164
+ }
1165
+ current_by_repo = {
1166
+ str(binding.get("repo_id")): binding
1167
+ for binding in inspection.get("repository_bindings", [])
1168
+ if isinstance(binding, dict)
1169
+ and str(binding.get("repo_id")) in selected_repo_ids
1170
+ }
1171
+ if (
1172
+ len(stored_by_repo) != len(stored_bindings)
1173
+ or set(stored_by_repo) != selected_repo_ids
1174
+ or set(current_by_repo) != selected_repo_ids
1175
+ ):
1176
+ raise StateError("Canonical Spec repository bindings no longer match task.json.")
1177
+ for repo_id in selected_repo_ids:
1178
+ stored = stored_by_repo[repo_id]
1179
+ current = current_by_repo[repo_id]
1180
+ for field in ("repo_id", "name", "path", "baseline_commit"):
1181
+ if stored.get(field) != current.get(field):
1182
+ raise StateError("Canonical Spec repository bindings no longer match task.json.")
1183
+ return inspection, selection
1184
+
1185
+
1186
+ def is_valid_spec_execution_plan(root: Path, task: dict, plan: object) -> bool:
1187
+ if not isinstance(plan, dict):
1188
+ return False
1189
+ try:
1190
+ inspection, selection = inspect_task_spec(root, task)
1191
+ except StateError:
1192
+ return False
1193
+ selected_ids = set(selection["selected_task_ids"])
1194
+ task_by_id = {item["task_id"]: item for item in selection["selected_tasks"]}
1195
+ change_by_id = {
1196
+ str(change["change_id"]): change for change in selection["selected_changes"]
1197
+ }
1198
+ step_by_id = {
1199
+ str(step["step_id"]): step for step in selection["selected_steps"]
1200
+ }
1201
+ test_by_id = {
1202
+ str(test["test_id"]): test for test in selection["selected_tests"]
1203
+ }
1204
+ changes_by_task: dict[str, list[dict]] = {task_id: [] for task_id in selected_ids}
1205
+ tests_by_task: dict[str, list[dict]] = {task_id: [] for task_id in selected_ids}
1206
+ for change in selection["selected_changes"]:
1207
+ changes_by_task[str(change["task_id"])].append(change)
1208
+ for test in selection["selected_tests"]:
1209
+ tests_by_task[str(test["task_id"])].append(test)
1210
+
1211
+ units = [unit for unit in plan.get("units", []) if isinstance(unit, dict)]
1212
+ units_by_task: dict[str, list[dict]] = {task_id: [] for task_id in selected_ids}
1213
+ covered_steps: dict[str, list[str]] = {task_id: [] for task_id in selected_ids}
1214
+ covered_files: dict[str, set[str]] = {task_id: set() for task_id in selected_ids}
1215
+ covered_symbols: dict[str, set[str]] = {task_id: set() for task_id in selected_ids}
1216
+ covered_commands: dict[str, set[str]] = {task_id: set() for task_id in selected_ids}
1217
+ unit_by_id = {str(unit["id"]): unit for unit in units}
1218
+ unit_id_by_step: dict[str, str] = {}
1219
+ for unit in units:
1220
+ source_task_id = unit.get("source_task_id")
1221
+ if source_task_id not in selected_ids:
1222
+ return False
1223
+ source_task_id = str(source_task_id)
1224
+ source_task = task_by_id[source_task_id]
1225
+ if unit.get("repo_id") != source_task.get("repo_id"):
1226
+ return False
1227
+ for field in ("source_step_ids", "symbols", "test_commands"):
1228
+ if not is_string_list(unit.get(field), allow_empty=False):
1229
+ return False
1230
+ allowed_steps = set(source_task.get("step_ids", []))
1231
+ if not set(unit["source_step_ids"]).issubset(allowed_steps):
1232
+ return False
1233
+ source_steps = [step_by_id.get(str(step_id)) for step_id in unit["source_step_ids"]]
1234
+ if any(
1235
+ step is None or step.get("task_id") != source_task_id
1236
+ for step in source_steps
1237
+ ):
1238
+ return False
1239
+ step_change_ids = {
1240
+ str(change_id)
1241
+ for step in source_steps
1242
+ if isinstance(step, dict)
1243
+ for change_id in step.get("change_ids", [])
1244
+ }
1245
+ step_test_ids = {
1246
+ str(test_id)
1247
+ for step in source_steps
1248
+ if isinstance(step, dict)
1249
+ for test_id in step.get("test_ids", [])
1250
+ }
1251
+ step_files = {
1252
+ str(change_by_id[change_id]["path"])
1253
+ for change_id in step_change_ids
1254
+ if change_id in change_by_id
1255
+ }
1256
+ step_symbols = {
1257
+ str(symbol)
1258
+ for change_id in step_change_ids
1259
+ if change_id in change_by_id
1260
+ for symbol in change_by_id[change_id].get("symbols", [])
1261
+ }
1262
+ step_commands = {
1263
+ str(test_by_id[test_id]["command"])
1264
+ for test_id in step_test_ids
1265
+ if test_id in test_by_id
1266
+ }
1267
+ # Unit 必须保存它声明的 source steps 的完整文件、符号和源测试映射;附加本地命令可保留。
1268
+ if (
1269
+ not step_change_ids.issubset(change_by_id)
1270
+ or not step_test_ids.issubset(test_by_id)
1271
+ or set(unit.get("files", [])) != step_files
1272
+ or set(unit["symbols"]) != step_symbols
1273
+ or not set(unit["test_commands"]).issuperset(step_commands)
1274
+ ):
1275
+ return False
1276
+ for step_id in unit["source_step_ids"]:
1277
+ normalized_step_id = str(step_id)
1278
+ if normalized_step_id in unit_id_by_step:
1279
+ return False
1280
+ unit_id_by_step[normalized_step_id] = str(unit["id"])
1281
+ units_by_task[source_task_id].append(unit)
1282
+ covered_steps[source_task_id].extend(unit["source_step_ids"])
1283
+ covered_files[source_task_id].update(unit.get("files", []))
1284
+ covered_symbols[source_task_id].update(unit["symbols"])
1285
+ covered_commands[source_task_id].update(unit["test_commands"])
1286
+
1287
+ for source_task_id, source_task in task_by_id.items():
1288
+ if not units_by_task[source_task_id]:
1289
+ return False
1290
+ steps = covered_steps[source_task_id]
1291
+ if len(steps) != len(set(steps)) or set(steps) != set(source_task.get("step_ids", [])):
1292
+ return False
1293
+ if covered_files[source_task_id] != {
1294
+ str(change["path"]) for change in changes_by_task[source_task_id]
1295
+ }:
1296
+ return False
1297
+ if covered_symbols[source_task_id] != {
1298
+ str(symbol)
1299
+ for change in changes_by_task[source_task_id]
1300
+ for symbol in change.get("symbols", [])
1301
+ }:
1302
+ return False
1303
+ if not covered_commands[source_task_id].issuperset({
1304
+ str(test["command"]) for test in tests_by_task[source_task_id]
1305
+ }):
1306
+ return False
1307
+
1308
+ # 同一 source task 内的 Step DAG 也必须投影到 Unit DAG;合并在同一 Unit 的步骤无需自依赖。
1309
+ for step_id, step in step_by_id.items():
1310
+ owner_unit_id = unit_id_by_step.get(step_id)
1311
+ if owner_unit_id is None:
1312
+ return False
1313
+ owner_unit = unit_by_id[owner_unit_id]
1314
+ for dependency_step_id in step.get("depends_on_step_ids", []):
1315
+ dependency_unit_id = unit_id_by_step.get(str(dependency_step_id))
1316
+ if dependency_unit_id is None:
1317
+ return False
1318
+ if (
1319
+ dependency_unit_id != owner_unit_id
1320
+ and dependency_unit_id not in owner_unit.get("depends_on", [])
1321
+ ):
1322
+ return False
1323
+
1324
+ # hard 依赖必须投影为 Unit DAG,不能只保存在说明文字中。
1325
+ unit_ids_by_task = {
1326
+ source_task_id: {str(unit["id"]) for unit in source_units}
1327
+ for source_task_id, source_units in units_by_task.items()
1328
+ }
1329
+ for edge in inspection.get("dependency_edges", []):
1330
+ source_task_id = str(edge.get("source_task_id") or "")
1331
+ dependency_task_id = str(edge.get("task_id") or "")
1332
+ if (
1333
+ edge.get("dependency_type") != "hard"
1334
+ or source_task_id not in selected_ids
1335
+ or dependency_task_id not in selected_ids
1336
+ ):
1337
+ continue
1338
+ dependency_ids = unit_ids_by_task[dependency_task_id]
1339
+ depended_on_within_dependency = {
1340
+ dependency
1341
+ for unit in units_by_task[dependency_task_id]
1342
+ for dependency in unit.get("depends_on", [])
1343
+ if dependency in dependency_ids
1344
+ }
1345
+ dependency_terminals = dependency_ids - depended_on_within_dependency
1346
+ source_units = units_by_task[source_task_id]
1347
+ source_ids = unit_ids_by_task[source_task_id]
1348
+ source_roots = [
1349
+ unit
1350
+ for unit in source_units
1351
+ if not set(unit.get("depends_on", [])).intersection(source_ids)
1352
+ ]
1353
+ if not dependency_terminals or any(
1354
+ not dependency_terminals.issubset(set(unit.get("depends_on", [])))
1355
+ for unit in source_roots
1356
+ ):
1357
+ return False
1358
+ return True
1359
+
1360
+
1361
+ def contains_spec_marker(content: str, marker: str) -> bool:
1362
+ boundary_characters = (
1363
+ r"A-Za-z0-9_/" + ("." if "/" in marker or "." in marker else "") + "-"
1364
+ )
1365
+ return (
1366
+ re.search(
1367
+ rf"(?<![{boundary_characters}]){re.escape(marker)}(?![{boundary_characters}])",
1368
+ content,
1369
+ )
1370
+ is not None
1371
+ )
1372
+
1373
+
1374
+ def missing_spec_test_strategy_markers(
1375
+ selection: dict, plan: dict, content: str
1376
+ ) -> list[str]:
1377
+ unit_ids_by_step = {
1378
+ str(step_id): str(unit["id"])
1379
+ for unit in plan.get("units", [])
1380
+ if isinstance(unit, dict)
1381
+ for step_id in unit.get("source_step_ids", [])
1382
+ }
1383
+ owner_units_by_test: dict[str, set[str]] = {}
1384
+ for step in selection.get("selected_steps", []):
1385
+ if not isinstance(step, dict):
1386
+ continue
1387
+ owner_unit_id = unit_ids_by_step.get(str(step.get("step_id") or ""))
1388
+ if not owner_unit_id:
1389
+ continue
1390
+ for test_id in step.get("test_ids", []):
1391
+ owner_units_by_test.setdefault(str(test_id), set()).add(owner_unit_id)
1392
+
1393
+ missing: list[str] = []
1394
+ for test in selection.get("selected_tests", []):
1395
+ if not isinstance(test, dict):
1396
+ continue
1397
+ test_id = str(test.get("test_id") or "")
1398
+ markers = {
1399
+ test_id,
1400
+ str(test.get("task_id") or ""),
1401
+ str(test.get("file") or ""),
1402
+ str(test.get("command") or ""),
1403
+ *owner_units_by_test.get(test_id, set()),
1404
+ }
1405
+ missing.extend(
1406
+ marker
1407
+ for marker in sorted(markers)
1408
+ if marker and not contains_spec_marker(content, marker)
1409
+ )
1410
+ return list(dict.fromkeys(missing))
1411
+
1412
+
1074
1413
  def read_project_schema_version(root: Path) -> int:
1075
1414
  path = root / ".easy-coding" / "config.yaml"
1076
1415
  try:
@@ -1105,10 +1444,15 @@ def has_valid_execution_plan(root: Path, task_id: str) -> bool:
1105
1444
  task_type = str(task.get("type") or "").strip().lower() if task else ""
1106
1445
  if task_type in NO_CODE_TASK_TYPES:
1107
1446
  return is_read_only_execution_plan(latest_plan)
1108
- return is_valid_execution_plan(
1447
+ valid = is_valid_execution_plan(
1109
1448
  latest_plan,
1110
1449
  require_unit_contracts=read_project_schema_version(root) >= 3,
1111
1450
  )
1451
+ if not valid:
1452
+ return False
1453
+ if task and isinstance(task.get("spec_source"), dict):
1454
+ return is_valid_spec_execution_plan(root, task, latest_plan)
1455
+ return True
1112
1456
 
1113
1457
 
1114
1458
  def execution_records(root: Path, task_id: str) -> list[dict]:
@@ -1131,10 +1475,10 @@ def execution_records(root: Path, task_id: str) -> list[dict]:
1131
1475
  def latest_execution_plan(root: Path, task_id: str) -> dict | None:
1132
1476
  latest: dict | None = None
1133
1477
  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
- ):
1478
+ if record.get("type") == "plan":
1137
1479
  latest = record
1480
+ if latest is None or not is_valid_execution_plan(latest, allow_empty_files=True):
1481
+ return None
1138
1482
  return latest
1139
1483
 
1140
1484
 
@@ -1188,6 +1532,44 @@ def minimize_repository_scopes(repository: Path, scopes: set[Path]) -> list[Path
1188
1532
  def task_repository_scopes(
1189
1533
  root: Path, task: dict | None, plan: dict
1190
1534
  ) -> list[tuple[Path, list[Path]]]:
1535
+ if task and isinstance(task.get("spec_source"), dict):
1536
+ repo_paths = task.get("repo_paths")
1537
+ if not isinstance(repo_paths, dict):
1538
+ raise StateError("Spec-backed task is missing repo_paths.")
1539
+ repositories: dict[Path, set[Path]] = {}
1540
+ for unit in plan.get("units", []):
1541
+ if not isinstance(unit, dict) or not is_non_empty_string(unit.get("repo_id")):
1542
+ raise StateError("Spec-backed execution unit is missing repo_id.")
1543
+ repo_id = str(unit["repo_id"])
1544
+ raw_repo_path = repo_paths.get(repo_id)
1545
+ if not is_non_empty_string(raw_repo_path):
1546
+ raise StateError(f"Spec repository path is missing: {repo_id}")
1547
+ candidate = Path(str(raw_repo_path))
1548
+ repository_path = (candidate if candidate.is_absolute() else root / candidate).resolve()
1549
+ repository = git_repository_root(repository_path)
1550
+ if repository is None or repository.resolve() != repository_path:
1551
+ raise StateError(f"Spec repository binding is not a Git root: {repo_id}")
1552
+ scopes = repositories.setdefault(repository, set())
1553
+ scopes.add(repository)
1554
+ for file_name in unit.get("files", []):
1555
+ if not is_non_empty_string(file_name):
1556
+ raise StateError(f"Spec execution unit has an invalid file path: {repo_id}")
1557
+ relative_path = Path(str(file_name))
1558
+ if relative_path.is_absolute() or ".." in relative_path.parts:
1559
+ raise StateError(f"Spec execution unit path escapes repository {repo_id}: {file_name}")
1560
+ resolved_file = (repository / relative_path).resolve()
1561
+ if not is_path_within(resolved_file, repository):
1562
+ raise StateError(f"Spec execution unit path escapes repository {repo_id}: {file_name}")
1563
+ file_repository = git_repository_root(resolved_file)
1564
+ if file_repository is None or file_repository.resolve() != repository:
1565
+ raise StateError(
1566
+ f"Spec execution unit path belongs to another Git repository: {repo_id}:{file_name}"
1567
+ )
1568
+ return [
1569
+ (repository, [repository])
1570
+ for repository in sorted(repositories, key=lambda item: item.as_posix())
1571
+ ]
1572
+
1191
1573
  scope_candidates = [root]
1192
1574
  if task:
1193
1575
  repo_paths = task.get("repo_paths")
@@ -1465,28 +1847,51 @@ def implementation_fingerprint(root: Path, task_id: str) -> str:
1465
1847
  ).encode("utf-8")
1466
1848
  )
1467
1849
  digest.update(b"\0")
1850
+ if task and isinstance(task.get("spec_source"), dict):
1851
+ digest.update(b"canonical-spec\0")
1852
+ digest.update(
1853
+ json.dumps(
1854
+ {
1855
+ "source": task.get("spec_source"),
1856
+ "selected_tasks": task.get("selected_spec_tasks"),
1857
+ },
1858
+ ensure_ascii=False,
1859
+ sort_keys=True,
1860
+ separators=(",", ":"),
1861
+ ).encode("utf-8")
1862
+ )
1863
+ digest.update(b"\0")
1468
1864
  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:
1865
+ repo_paths = task.get("repo_paths") if task else None
1866
+ file_entries: set[tuple[str, str | None]] = {
1867
+ (str(file_name), str(unit.get("repo_id")) if unit.get("repo_id") else None)
1868
+ for unit in plan.get("units", [])
1869
+ if isinstance(unit, dict)
1870
+ for file_name in unit.get("files", [])
1871
+ if is_non_empty_string(file_name)
1872
+ }
1873
+ for file_name, repo_id in sorted(file_entries, key=lambda item: (item[0], item[1] or "")):
1479
1874
  candidate = Path(file_name)
1480
1875
  was_absolute = candidate.is_absolute()
1876
+ base = root
1877
+ if (
1878
+ task
1879
+ and isinstance(task.get("spec_source"), dict)
1880
+ and isinstance(repo_paths, dict)
1881
+ and repo_id
1882
+ and is_non_empty_string(repo_paths.get(repo_id))
1883
+ ):
1884
+ raw_base = Path(str(repo_paths[repo_id]))
1885
+ base = raw_base if raw_base.is_absolute() else root / raw_base
1481
1886
  if not was_absolute:
1482
- candidate = root / candidate
1887
+ candidate = base / candidate
1483
1888
  resolved = candidate.resolve()
1484
1889
  if not was_absolute:
1485
1890
  try:
1486
- resolved.relative_to(root.resolve())
1891
+ resolved.relative_to(base.resolve())
1487
1892
  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"))
1893
+ raise StateError(f"Execution plan file escapes repository: {file_name}") from error
1894
+ digest.update(f"{repo_id or ''}:{file_name}".encode("utf-8"))
1490
1895
  digest.update(b"\0")
1491
1896
  try:
1492
1897
  digest.update(resolved.read_bytes())
@@ -1513,8 +1918,80 @@ def evidence_fingerprints(root: Path, task_id: str) -> dict[str, str]:
1513
1918
  }
1514
1919
 
1515
1920
 
1921
+ def validate_spec_implementation_results(root: Path, task_id: str, task: dict) -> None:
1922
+ if not isinstance(task.get("spec_source"), dict):
1923
+ return
1924
+ plan = latest_execution_plan(root, task_id)
1925
+ if plan is None or not is_valid_spec_execution_plan(root, task, plan):
1926
+ raise StateError("Canonical Spec implementation has no valid source-traceable plan.")
1927
+ unit_by_id = {
1928
+ str(unit["id"]): unit for unit in plan.get("units", []) if isinstance(unit, dict)
1929
+ }
1930
+ records = execution_records(root, task_id)
1931
+ latest_plan_index = max(
1932
+ (index for index, record in enumerate(records) if record.get("type") == "plan"),
1933
+ default=-1,
1934
+ )
1935
+ lifecycle_by_unit: dict[str, list[dict]] = {unit_id: [] for unit_id in unit_by_id}
1936
+ for record in records[latest_plan_index + 1 :]:
1937
+ unit_id = str(record.get("unit_id") or "")
1938
+ if record.get("type") in {"dispatch", "result"} and unit_id in unit_by_id:
1939
+ lifecycle_by_unit[unit_id].append(record)
1940
+ missing_dispatches = sorted(
1941
+ unit_id
1942
+ for unit_id, lifecycle in lifecycle_by_unit.items()
1943
+ if not any(record.get("type") == "dispatch" for record in lifecycle)
1944
+ )
1945
+ if missing_dispatches:
1946
+ raise StateError(
1947
+ "Canonical Spec implementation is missing dispatch records for units: "
1948
+ + ", ".join(missing_dispatches)
1949
+ )
1950
+ missing_results = sorted(
1951
+ unit_id
1952
+ for unit_id, lifecycle in lifecycle_by_unit.items()
1953
+ if not lifecycle or lifecycle[-1].get("type") != "result"
1954
+ )
1955
+ if missing_results:
1956
+ raise StateError(
1957
+ "Canonical Spec implementation is missing result records for units: "
1958
+ + ", ".join(missing_results)
1959
+ )
1960
+ for unit_id, unit in unit_by_id.items():
1961
+ lifecycle = lifecycle_by_unit[unit_id]
1962
+ if len(lifecycle) < 2 or lifecycle[-2].get("type") != "dispatch":
1963
+ raise StateError(
1964
+ f"Canonical Spec result {unit_id} has no matching preceding dispatch record."
1965
+ )
1966
+ dispatch = lifecycle[-2]
1967
+ if (
1968
+ dispatch.get("repo_id") != unit.get("repo_id")
1969
+ or dispatch.get("source_task_id") != unit.get("source_task_id")
1970
+ ):
1971
+ raise StateError(
1972
+ f"Canonical Spec dispatch {unit_id} must preserve repository/source-task ownership."
1973
+ )
1974
+ result = lifecycle[-1]
1975
+ if (
1976
+ result.get("repo_id") != unit.get("repo_id")
1977
+ or result.get("source_task_id") != unit.get("source_task_id")
1978
+ or result.get("status") != "completed"
1979
+ or not isinstance(result.get("changed_files"), list)
1980
+ or not set(result.get("changed_files", [])).issubset(set(unit.get("files", [])))
1981
+ or not is_non_empty_string(result.get("summary"))
1982
+ or result.get("issues") != []
1983
+ or result.get("needs_attention") != []
1984
+ ):
1985
+ raise StateError(
1986
+ f"Canonical Spec result {unit_id} must be completed without unresolved issues, "
1987
+ "preserve repository/source-task ownership, and remain within the Unit file scope."
1988
+ )
1989
+
1990
+
1516
1991
  def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
1517
- if task.get("workflow_mode_legacy") is True:
1992
+ validate_spec_implementation_results(root, task_id, task)
1993
+ is_spec_task = isinstance(task.get("spec_source"), dict)
1994
+ if task.get("workflow_mode_legacy") is True and not is_spec_task:
1518
1995
  return
1519
1996
  expected = implementation_fingerprint(root, task_id)
1520
1997
  latest_by_dimension: dict[str, dict] = {}
@@ -1524,7 +2001,10 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
1524
2001
  and record.get("implementation_fingerprint") == expected
1525
2002
  and is_non_empty_string(record.get("dimension"))
1526
2003
  ):
1527
- latest_by_dimension[str(record["dimension"])] = record
2004
+ dimension = str(record["dimension"])
2005
+ source_task_id = str(record.get("source_task_id") or "")
2006
+ record_key = f"{dimension}\0{source_task_id}" if is_spec_task else dimension
2007
+ latest_by_dimension[record_key] = record
1528
2008
  if not latest_by_dimension:
1529
2009
  raise StateError(
1530
2010
  "REVIEW cannot advance to VERIFICATION without a review record for the current implementation fingerprint."
@@ -1543,6 +2023,42 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
1543
2023
  "Each review finding must include a non-empty file and issue, a positive integer "
1544
2024
  "line, and severity error, warning, or info."
1545
2025
  )
2026
+ if is_spec_task:
2027
+ plan = latest_execution_plan(root, task_id) or {}
2028
+ task_repositories = {
2029
+ str(unit.get("source_task_id")): str(unit.get("repo_id"))
2030
+ for unit in plan.get("units", [])
2031
+ if isinstance(unit, dict)
2032
+ and is_non_empty_string(unit.get("source_task_id"))
2033
+ and is_non_empty_string(unit.get("repo_id"))
2034
+ }
2035
+ reviewed_dimensions: dict[str, set[str]] = {
2036
+ source_task_id: set() for source_task_id in task_repositories
2037
+ }
2038
+ for record in latest_by_dimension.values():
2039
+ source_task_id = str(record.get("source_task_id") or "")
2040
+ repo_id = str(record.get("repo_id") or "")
2041
+ if source_task_id not in task_repositories or repo_id != task_repositories[source_task_id]:
2042
+ raise StateError(
2043
+ "Canonical Spec review evidence must preserve repository/source-task ownership."
2044
+ )
2045
+ for finding in record["findings"]:
2046
+ finding_path = Path(str(finding["file"]))
2047
+ if finding_path.is_absolute() or ".." in finding_path.parts:
2048
+ raise StateError(
2049
+ "Canonical Spec review findings must use safe repository-relative paths."
2050
+ )
2051
+ reviewed_dimensions[source_task_id].add(str(record["dimension"]))
2052
+ missing_review_tasks = sorted(
2053
+ source_task_id
2054
+ for source_task_id, dimensions in reviewed_dimensions.items()
2055
+ if not dimensions
2056
+ )
2057
+ if missing_review_tasks:
2058
+ raise StateError(
2059
+ "Canonical Spec review evidence does not cover selected source tasks: "
2060
+ + ", ".join(missing_review_tasks)
2061
+ )
1546
2062
  has_failed_dimension = False
1547
2063
  for record in latest_by_dimension.values():
1548
2064
  findings = record.get("findings")
@@ -1558,16 +2074,29 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
1558
2074
  raise StateError(
1559
2075
  "REVIEW cannot advance to VERIFICATION while a current review dimension is not passed or has error findings."
1560
2076
  )
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
- )
2077
+ if task.get("workflow_mode") == "strict":
2078
+ if is_spec_task:
2079
+ missing_strict_dimensions = sorted(
2080
+ source_task_id
2081
+ for source_task_id, dimensions in reviewed_dimensions.items()
2082
+ if len(dimensions) < 2
2083
+ )
2084
+ if missing_strict_dimensions:
2085
+ raise StateError(
2086
+ "Strict Canonical Spec review requires at least two passed dimensions for "
2087
+ "every selected source task: " + ", ".join(missing_strict_dimensions)
2088
+ )
2089
+ elif len(latest_by_dimension) < 2:
2090
+ raise StateError(
2091
+ "Strict workflow requires at least two passed review dimensions for the current implementation fingerprint."
2092
+ )
1565
2093
 
1566
2094
 
1567
2095
  def validate_verification_readiness(root: Path, task_id: str, task: dict) -> None:
1568
2096
  fingerprints = evidence_fingerprints(root, task_id)
2097
+ is_spec_task = isinstance(task.get("spec_source"), dict)
1569
2098
  if (
1570
- task.get("workflow_mode_legacy") is not True
2099
+ (task.get("workflow_mode_legacy") is not True or is_spec_task)
1571
2100
  and task.get("workflow_mode_legacy_review_bypass_fingerprint")
1572
2101
  != fingerprints["implementation_fingerprint"]
1573
2102
  ):
@@ -1582,6 +2111,8 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
1582
2111
  and is_non_empty_string(record.get("check"))
1583
2112
  ):
1584
2113
  check = str(record["check"])
2114
+ if is_spec_task:
2115
+ check = f"{check}\0{record.get('source_task_id') or ''}"
1585
2116
  previous = latest_by_check.get(check)
1586
2117
  if (
1587
2118
  record.get("applicable") is False
@@ -1594,7 +2125,7 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
1594
2125
  raise StateError(
1595
2126
  "VERIFICATION cannot advance to MEMORY without verification evidence for the current implementation and config fingerprints."
1596
2127
  )
1597
- if task.get("workflow_mode_legacy") is not True:
2128
+ if task.get("workflow_mode_legacy") is not True or is_spec_task:
1598
2129
  for record in latest_by_check.values():
1599
2130
  check_type = str(record.get("check_type") or "")
1600
2131
  if (
@@ -1614,6 +2145,25 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
1614
2145
  raise StateError(
1615
2146
  "Verification evidence marked not applicable must include a non-empty not_applicable_reason."
1616
2147
  )
2148
+ if is_spec_task:
2149
+ plan = latest_execution_plan(root, task_id) or {}
2150
+ task_repositories = {
2151
+ str(unit.get("source_task_id")): str(unit.get("repo_id"))
2152
+ for unit in plan.get("units", [])
2153
+ if isinstance(unit, dict)
2154
+ and is_non_empty_string(unit.get("source_task_id"))
2155
+ and is_non_empty_string(unit.get("repo_id"))
2156
+ }
2157
+ for record in latest_by_check.values():
2158
+ source_task_id = str(record.get("source_task_id") or "")
2159
+ if (
2160
+ source_task_id not in task_repositories
2161
+ or record.get("repo_id") != task_repositories[source_task_id]
2162
+ ):
2163
+ raise StateError(
2164
+ "Canonical Spec verification evidence must preserve "
2165
+ "repository/source-task ownership."
2166
+ )
1617
2167
  applicable_records = [
1618
2168
  record for record in latest_by_check.values() if record.get("applicable") is not False
1619
2169
  ]
@@ -1626,26 +2176,109 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
1626
2176
  "VERIFICATION cannot advance to MEMORY while current verification evidence contains failures."
1627
2177
  )
1628
2178
  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:
1636
- raise StateError(
1637
- "Strict workflow requires current verification evidence for every check type: "
1638
- + ", ".join(missing_types)
1639
- + "."
1640
- )
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")
1644
- ):
2179
+ if is_spec_task:
2180
+ check_types_by_repository: dict[str, set[str]] = {
2181
+ repo_id: set() for repo_id in set(task_repositories.values())
2182
+ }
2183
+ for record in latest_by_check.values():
2184
+ repo_id = str(record.get("repo_id") or "")
2185
+ check_type = str(record.get("check_type") or "")
2186
+ if repo_id in check_types_by_repository and check_type in STRICT_VERIFICATION_CHECK_TYPES:
2187
+ check_types_by_repository[repo_id].add(check_type)
2188
+ missing_by_repository = {
2189
+ repo_id: sorted(STRICT_VERIFICATION_CHECK_TYPES - check_types)
2190
+ for repo_id, check_types in check_types_by_repository.items()
2191
+ if check_types != STRICT_VERIFICATION_CHECK_TYPES
2192
+ }
2193
+ if missing_by_repository:
2194
+ raise StateError(
2195
+ "Strict Canonical Spec verification requires every repository to cover "
2196
+ "lint, typecheck, test, and build: "
2197
+ + "; ".join(
2198
+ f"{repo_id} missing {', '.join(check_types)}"
2199
+ for repo_id, check_types in sorted(missing_by_repository.items())
2200
+ )
2201
+ )
2202
+ else:
2203
+ latest_by_type: dict[str, dict] = {}
2204
+ for record in latest_by_check.values():
2205
+ check_type = str(record.get("check_type") or "")
2206
+ if check_type in STRICT_VERIFICATION_CHECK_TYPES:
2207
+ latest_by_type[check_type] = record
2208
+ missing_types = sorted(STRICT_VERIFICATION_CHECK_TYPES - latest_by_type.keys())
2209
+ if missing_types:
1645
2210
  raise StateError(
1646
- "Strict workflow requires a non-empty not_applicable_reason when "
1647
- f"{check_type} is marked not applicable."
2211
+ "Strict workflow requires current verification evidence for every check type: "
2212
+ + ", ".join(missing_types)
2213
+ + "."
2214
+ )
2215
+ for check_type, record in latest_by_type.items():
2216
+ if record.get("applicable") is False and not is_non_empty_string(
2217
+ record.get("not_applicable_reason")
2218
+ ):
2219
+ raise StateError(
2220
+ "Strict workflow requires a non-empty not_applicable_reason when "
2221
+ f"{check_type} is marked not applicable."
2222
+ )
2223
+ if is_spec_task:
2224
+ inspect_task_spec(root, task)
2225
+ plan = latest_execution_plan(root, task_id)
2226
+ required_test_commands = {
2227
+ (
2228
+ str(unit.get("source_task_id")),
2229
+ str(unit.get("repo_id")),
2230
+ str(command),
2231
+ )
2232
+ for unit in (plan or {}).get("units", [])
2233
+ if isinstance(unit, dict)
2234
+ for command in unit.get("test_commands", [])
2235
+ if is_non_empty_string(command)
2236
+ }
2237
+ executed_commands = {
2238
+ (
2239
+ str(record.get("source_task_id")),
2240
+ str(record.get("repo_id")),
2241
+ str(record.get("command")),
2242
+ )
2243
+ for record in applicable_records
2244
+ if is_non_empty_string(record.get("command"))
2245
+ }
2246
+ missing_commands = sorted(required_test_commands - executed_commands)
2247
+ if missing_commands:
2248
+ raise StateError(
2249
+ "Canonical Spec verification is missing source test commands: "
2250
+ + ", ".join(
2251
+ f"{source_task_id}@{repo_id}: {command}"
2252
+ for source_task_id, repo_id, command in missing_commands
1648
2253
  )
2254
+ )
2255
+ covered_verification_tasks = {
2256
+ str(record.get("source_task_id")) for record in applicable_records
2257
+ }
2258
+ missing_verification_tasks = sorted(
2259
+ set(task_repositories) - covered_verification_tasks
2260
+ )
2261
+ if missing_verification_tasks:
2262
+ raise StateError(
2263
+ "Canonical Spec verification evidence does not cover selected source tasks: "
2264
+ + ", ".join(missing_verification_tasks)
2265
+ )
2266
+ pending_integration = [
2267
+ record
2268
+ for record in task.get("spec_dependency_evidence", [])
2269
+ if isinstance(record, dict)
2270
+ and record.get("dependency_type") == "integration"
2271
+ and record.get("status") != "satisfied"
2272
+ ]
2273
+ if pending_integration:
2274
+ edges = ", ".join(
2275
+ f"{record.get('source_task_id')}->{record.get('task_id')}"
2276
+ for record in pending_integration
2277
+ )
2278
+ raise StateError(
2279
+ "VERIFICATION cannot advance to MEMORY while Canonical Spec integration "
2280
+ f"dependencies are pending: {edges}."
2281
+ )
1649
2282
 
1650
2283
 
1651
2284
  def validate_read_only_completion(root: Path, task_id: str) -> None:
@@ -1864,8 +2497,126 @@ def validate_analysis_readiness(root: Path, task_id: str) -> None:
1864
2497
  except OSError:
1865
2498
  reasons.append("dev-spec skeleton template cannot be read")
1866
2499
 
1867
- if not has_valid_execution_plan(root, task_id):
2500
+ plan_is_valid = has_valid_execution_plan(root, task_id)
2501
+ if not plan_is_valid:
1868
2502
  reasons.append("execution.jsonl has no valid plan record")
2503
+ if task and isinstance(task.get("spec_source"), dict):
2504
+ try:
2505
+ inspection, selection = inspect_task_spec(root, task)
2506
+ required_markers = [
2507
+ str(task["spec_source"].get("path") or ""),
2508
+ str(task["spec_source"].get("spec_id") or ""),
2509
+ str(task["spec_source"].get("sha256") or ""),
2510
+ *[str(task_id) for task_id in selection["selected_task_ids"]],
2511
+ *[str(repo_id) for repo_id in selection["selected_repo_ids"]],
2512
+ *[
2513
+ f"{repo_id}={inspection['baseline_status'].get(repo_id)}"
2514
+ for repo_id in selection["selected_repo_ids"]
2515
+ ],
2516
+ ]
2517
+ missing_markers = [
2518
+ marker
2519
+ for marker in required_markers
2520
+ if marker and not contains_spec_marker(dev_spec_content, marker)
2521
+ ]
2522
+ revision = task["spec_source"].get("revision")
2523
+ if (
2524
+ type(revision) is not int
2525
+ or re.search(
2526
+ rf"\brevision\s*[::=]\s*{revision}(?!\d)",
2527
+ dev_spec_content,
2528
+ re.IGNORECASE,
2529
+ )
2530
+ is None
2531
+ ):
2532
+ missing_markers.append(f"revision={revision}")
2533
+ if missing_markers:
2534
+ reasons.append(
2535
+ "dev-spec.md is missing Canonical Spec traceability markers: "
2536
+ + ", ".join(missing_markers)
2537
+ )
2538
+ selected_repo_ids = set(selection["selected_repo_ids"])
2539
+ bindings = task.get("spec_repositories")
2540
+ bound_repo_ids = {
2541
+ str(binding.get("repo_id"))
2542
+ for binding in bindings or []
2543
+ if isinstance(binding, dict)
2544
+ }
2545
+ if bound_repo_ids != selected_repo_ids:
2546
+ reasons.append("spec_repositories do not cover selected Canonical Spec tasks")
2547
+ if inspection.get("unresolved_repositories"):
2548
+ reasons.append(
2549
+ "Canonical Spec repository bindings are unresolved: "
2550
+ + ", ".join(inspection["unresolved_repositories"])
2551
+ )
2552
+ unavailable_repositories = [
2553
+ repo_id
2554
+ for repo_id in selection["selected_repo_ids"]
2555
+ if inspection["baseline_status"].get(repo_id) == "baseline-unavailable"
2556
+ ]
2557
+ if unavailable_repositories:
2558
+ reasons.append(
2559
+ "Canonical Spec baselines are unavailable: "
2560
+ + ", ".join(unavailable_repositories)
2561
+ )
2562
+ if plan_is_valid:
2563
+ plan = latest_execution_plan(root, task_id)
2564
+ if plan is None:
2565
+ reasons.append("Canonical Spec execution plan cannot be loaded")
2566
+ else:
2567
+ task_repository_scopes(root, task, plan)
2568
+ derived_markers = [
2569
+ *[
2570
+ str(unit.get("id") or "")
2571
+ for unit in plan.get("units", [])
2572
+ if isinstance(unit, dict)
2573
+ ],
2574
+ *[
2575
+ str(step_id)
2576
+ for unit in plan.get("units", [])
2577
+ if isinstance(unit, dict)
2578
+ for step_id in unit.get("source_step_ids", [])
2579
+ ],
2580
+ *[
2581
+ f"{record.get('source_task_id')}->{record.get('task_id')}"
2582
+ for record in task.get("spec_dependency_evidence", [])
2583
+ if isinstance(record, dict)
2584
+ and record.get("dependency_type") == "integration"
2585
+ and record.get("status") == "pending"
2586
+ ],
2587
+ *[
2588
+ str(record.get("required_evidence") or "")
2589
+ for record in task.get("spec_dependency_evidence", [])
2590
+ if isinstance(record, dict)
2591
+ and record.get("dependency_type") == "integration"
2592
+ and record.get("status") == "pending"
2593
+ ],
2594
+ ]
2595
+ missing_derived_markers = [
2596
+ marker
2597
+ for marker in derived_markers
2598
+ if marker and not contains_spec_marker(dev_spec_content, marker)
2599
+ ]
2600
+ if missing_derived_markers:
2601
+ reasons.append(
2602
+ "dev-spec.md is missing Canonical Spec Unit/dependency markers: "
2603
+ + ", ".join(dict.fromkeys(missing_derived_markers))
2604
+ )
2605
+ if test_strategy.is_file():
2606
+ test_strategy_content = test_strategy.read_text(encoding="utf-8")
2607
+ if test_strategy_content.strip():
2608
+ missing_test_markers = missing_spec_test_strategy_markers(
2609
+ selection, plan, test_strategy_content
2610
+ )
2611
+ if missing_test_markers:
2612
+ reasons.append(
2613
+ "test-strategy.md is missing Canonical Spec markers: "
2614
+ + ", ".join(missing_test_markers)
2615
+ )
2616
+ except StateError as exc:
2617
+ reasons.append(str(exc))
2618
+ except OSError:
2619
+ reasons.append("test-strategy.md cannot be read")
1869
2620
  if is_read_only_task:
1870
2621
  if test_strategy.exists():
1871
2622
  reasons.append("read-only task must not create test-strategy.md")
@@ -1921,6 +2672,28 @@ def get_pending_init_version(root: Path) -> str | None:
1921
2672
  return None
1922
2673
 
1923
2674
 
2675
+ def spec_task_summary(task: dict | None) -> dict | None:
2676
+ if not task or not isinstance(task.get("spec_source"), dict):
2677
+ return None
2678
+ dependencies = task.get("spec_dependency_evidence")
2679
+ pending_dependencies = [
2680
+ {
2681
+ "source_task_id": record.get("source_task_id"),
2682
+ "task_id": record.get("task_id"),
2683
+ "dependency_type": record.get("dependency_type"),
2684
+ "required_evidence": record.get("required_evidence"),
2685
+ }
2686
+ for record in dependencies or []
2687
+ if isinstance(record, dict) and record.get("status") == "pending"
2688
+ ]
2689
+ return {
2690
+ "source": task["spec_source"],
2691
+ "selected_spec_tasks": task.get("selected_spec_tasks", []),
2692
+ "repositories": task.get("spec_repositories", []),
2693
+ "pending_dependencies": pending_dependencies,
2694
+ }
2695
+
2696
+
1924
2697
  def transition_requires_confirmation(
1925
2698
  previous: str,
1926
2699
  current: str,
@@ -2039,6 +2812,7 @@ def snapshot_state(
2039
2812
  "session_workflow_mode": session_workflow_mode,
2040
2813
  "configured_workflow_mode": configured_workflow_mode,
2041
2814
  "concrete_workflow_mode": concrete_workflow_mode,
2815
+ "spec_summary": spec_task_summary(task),
2042
2816
  # Compatibility output aliases for pre-0.9 clients.
2043
2817
  "project_confirm_mode": project_approval_mode,
2044
2818
  "session_confirm_mode": session_approval_mode,
@@ -2245,6 +3019,7 @@ def list_tasks(root: Path, agent: str | None = None) -> list[dict]:
2245
3019
  "action": action,
2246
3020
  "previous_agent": last_agent if action == "takeover" else None,
2247
3021
  "latest_handoff": latest_handoff_record(root, entry.name),
3022
+ "spec_summary": spec_task_summary(task),
2248
3023
  }
2249
3024
  )
2250
3025
  return items
@@ -2507,6 +3282,7 @@ def create_task(
2507
3282
  agent: str,
2508
3283
  set_current: bool = True,
2509
3284
  session_file: str | Path | None = None,
3285
+ task_fields: dict | None = None,
2510
3286
  ) -> dict:
2511
3287
  assert_safe_task_id(task_id)
2512
3288
  if set_current:
@@ -2529,12 +3305,137 @@ def create_task(
2529
3305
  "closed_reason": None,
2530
3306
  "repos": [],
2531
3307
  }
3308
+ if task_fields:
3309
+ task.update(task_fields)
2532
3310
  write_task(root, task_id, task)
2533
3311
  if set_current:
2534
3312
  return set_current_task(root, task_id, agent, session_file)
2535
3313
  return {"task_id": task_id, "task": task}
2536
3314
 
2537
3315
 
3316
+ def ensure_path_inside_root(root: Path, path: Path, label: str) -> Path:
3317
+ resolved = path.resolve()
3318
+ try:
3319
+ resolved.relative_to(root.resolve())
3320
+ except ValueError as exc:
3321
+ raise StateError(f"{label} must be inside the Easy Coding project root.") from exc
3322
+ return resolved
3323
+
3324
+
3325
+ def create_task_from_spec(
3326
+ root: Path,
3327
+ spec_path: str,
3328
+ spec_task_ids: list[str],
3329
+ task_id: str,
3330
+ task_type: str,
3331
+ title: str,
3332
+ repo_paths: dict[str, str],
3333
+ dependency_evidence: dict[str, str],
3334
+ agent: str,
3335
+ set_current: bool = True,
3336
+ session_file: str | Path | None = None,
3337
+ ) -> dict:
3338
+ raw_spec_path = Path(spec_path)
3339
+ resolved_spec_path = ensure_path_inside_root(
3340
+ root,
3341
+ raw_spec_path if raw_spec_path.is_absolute() else root / raw_spec_path,
3342
+ "Canonical Spec path",
3343
+ )
3344
+ try:
3345
+ inspection = inspect_spec(
3346
+ resolved_spec_path,
3347
+ root,
3348
+ repo_paths,
3349
+ spec_task_ids,
3350
+ )
3351
+ selection = select_tasks(inspection, spec_task_ids, dependency_evidence)
3352
+ except EasyDevSpecError as exc:
3353
+ raise StateError(f"Cannot create task from Canonical Spec: {exc}") from exc
3354
+
3355
+ selected_repo_ids = set(selection["selected_repo_ids"])
3356
+ bindings = [
3357
+ binding
3358
+ for binding in inspection["repository_bindings"]
3359
+ if binding.get("repo_id") in selected_repo_ids
3360
+ ]
3361
+ if len(bindings) != len(selected_repo_ids):
3362
+ raise StateError("Canonical Spec repository bindings do not cover every selected task.")
3363
+ stored_repo_paths = {
3364
+ str(binding["repo_id"]): str(binding["path"])
3365
+ for binding in bindings
3366
+ }
3367
+ source_path = resolved_spec_path.relative_to(root.resolve()).as_posix()
3368
+ fields = {
3369
+ "repos": list(selection["selected_repo_ids"]),
3370
+ "repo_paths": stored_repo_paths,
3371
+ "spec_source": {
3372
+ "schema": inspection["schema"],
3373
+ "spec_id": inspection["spec_id"],
3374
+ "revision": inspection["revision"],
3375
+ "path": source_path,
3376
+ "sha256": inspection["source_sha256"],
3377
+ },
3378
+ "selected_spec_tasks": selection["selected_task_ids"],
3379
+ "spec_repositories": bindings,
3380
+ "spec_dependency_evidence": selection["dependency_records"],
3381
+ }
3382
+ return create_task(
3383
+ root,
3384
+ task_id,
3385
+ task_type,
3386
+ title,
3387
+ agent,
3388
+ set_current,
3389
+ session_file,
3390
+ fields,
3391
+ )
3392
+
3393
+
3394
+ def satisfy_spec_dependency(
3395
+ root: Path,
3396
+ dependency_task_id: str,
3397
+ evidence: str,
3398
+ agent: str,
3399
+ source_task_id: str | None = None,
3400
+ task_id: str | None = None,
3401
+ session_file: str | Path | None = None,
3402
+ ) -> dict:
3403
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
3404
+ if task.get("status") in TERMINAL_STATUSES or task.get("status") == "MEMORY":
3405
+ raise StateError("Spec dependency evidence cannot change after MEMORY begins.")
3406
+ if not is_non_empty_string(evidence):
3407
+ raise StateError("Spec dependency evidence must be non-empty.")
3408
+ inspect_task_spec(root, task)
3409
+ records = task.get("spec_dependency_evidence")
3410
+ if not isinstance(records, list):
3411
+ raise StateError("Current task is not backed by Canonical Spec dependency metadata.")
3412
+ matches = [
3413
+ record
3414
+ for record in records
3415
+ if isinstance(record, dict)
3416
+ and record.get("task_id") == dependency_task_id
3417
+ and (source_task_id is None or record.get("source_task_id") == source_task_id)
3418
+ ]
3419
+ if not matches:
3420
+ raise StateError("Canonical Spec dependency edge was not found.")
3421
+ if source_task_id is None and len(matches) > 1:
3422
+ raise StateError(
3423
+ "Canonical Spec dependency is ambiguous; pass --source-task to identify the edge."
3424
+ )
3425
+ record = matches[0]
3426
+ if record.get("dependency_type") == "contract":
3427
+ raise StateError("Contract dependencies are satisfied by the frozen READY Spec.")
3428
+ record["status"] = "satisfied"
3429
+ record["evidence"] = evidence.strip()
3430
+ record["satisfied_at"] = now_iso()
3431
+ record["satisfied_by"] = agent
3432
+ task["last_agent"] = agent
3433
+ write_task(root, resolved_task_id, task)
3434
+ snapshot = snapshot_state(root, session_file, session)
3435
+ snapshot["action"] = "satisfy-spec-dependency"
3436
+ return snapshot
3437
+
3438
+
2538
3439
  def append_stage_history(task: dict, stage: str, agent: str) -> None:
2539
3440
  history = task.setdefault("stage_history", [])
2540
3441
  history.append({"stage": stage, "agent": agent, "entered_at": now_iso()})
@@ -3180,6 +4081,19 @@ def add_common_args(parser: argparse.ArgumentParser) -> None:
3180
4081
  parser.add_argument("--session-file", help="Session file path injected by the hook.")
3181
4082
 
3182
4083
 
4084
+ def parse_mapping_args(values: list[str], label: str) -> dict[str, str]:
4085
+ mappings: dict[str, str] = {}
4086
+ for value in values:
4087
+ key, separator, mapped_value = value.partition("=")
4088
+ if not separator or not key.strip() or not mapped_value.strip():
4089
+ raise StateError(f"{label} must use KEY=VALUE syntax: {value!r}")
4090
+ key = key.strip()
4091
+ if key in mappings:
4092
+ raise StateError(f"{label} contains a duplicate key: {key}")
4093
+ mappings[key] = mapped_value.strip()
4094
+ return mappings
4095
+
4096
+
3183
4097
  def main() -> int:
3184
4098
  configure_stdio()
3185
4099
  common = argparse.ArgumentParser(add_help=False)
@@ -3193,6 +4107,14 @@ def main() -> int:
3193
4107
  list_tasks_parser = subcommands.add_parser("list-tasks", parents=[common])
3194
4108
  list_tasks_parser.add_argument("--agent")
3195
4109
 
4110
+ inspect_spec_parser = subcommands.add_parser("inspect-dev-spec", parents=[common])
4111
+ inspect_spec_parser.add_argument("--spec", required=True)
4112
+ inspect_spec_parser.add_argument("--repo-path", action="append", default=[])
4113
+
4114
+ select_spec_scope = subcommands.add_parser("select-dev-spec-scope", parents=[common])
4115
+ select_spec_scope.add_argument("--spec", required=True)
4116
+ select_spec_scope.add_argument("--spec-task", required=True, action="append")
4117
+
3196
4118
  create = subcommands.add_parser("create-task", parents=[common])
3197
4119
  create.add_argument("--task-id", required=True)
3198
4120
  create.add_argument("--type", required=True)
@@ -3200,6 +4122,17 @@ def main() -> int:
3200
4122
  create.add_argument("--agent", required=True)
3201
4123
  create.add_argument("--no-set-current", action="store_true")
3202
4124
 
4125
+ create_from_spec = subcommands.add_parser("create-task-from-spec", parents=[common])
4126
+ create_from_spec.add_argument("--spec", required=True)
4127
+ create_from_spec.add_argument("--spec-task", required=True, action="append")
4128
+ create_from_spec.add_argument("--task-id", required=True)
4129
+ create_from_spec.add_argument("--type", required=True)
4130
+ create_from_spec.add_argument("--title", required=True)
4131
+ create_from_spec.add_argument("--repo-path", required=True, action="append")
4132
+ create_from_spec.add_argument("--dependency-evidence", action="append", default=[])
4133
+ create_from_spec.add_argument("--agent", required=True)
4134
+ create_from_spec.add_argument("--no-set-current", action="store_true")
4135
+
3203
4136
  set_current = subcommands.add_parser("set-current", parents=[common])
3204
4137
  set_current.add_argument("--task-id", required=True)
3205
4138
  set_current.add_argument("--agent", required=True)
@@ -3339,6 +4272,13 @@ def main() -> int:
3339
4272
  repo_path.add_argument("--agent")
3340
4273
  repo_path.add_argument("--task-id")
3341
4274
 
4275
+ satisfy_dependency = subcommands.add_parser("satisfy-spec-dependency", parents=[common])
4276
+ satisfy_dependency.add_argument("--spec-task", required=True)
4277
+ satisfy_dependency.add_argument("--source-task")
4278
+ satisfy_dependency.add_argument("--evidence", required=True)
4279
+ satisfy_dependency.add_argument("--agent", required=True)
4280
+ satisfy_dependency.add_argument("--task-id")
4281
+
3342
4282
  args = parser.parse_args()
3343
4283
  try:
3344
4284
  root = resolve_root(getattr(args, "cwd", None))
@@ -3353,7 +4293,12 @@ def main() -> int:
3353
4293
  raise StateError(
3354
4294
  "project-init-complete requires --session-file from the current hook context."
3355
4295
  )
3356
- if session_file is None and command not in {"list-tasks", "memory-new-id"}:
4296
+ if session_file is None and command not in {
4297
+ "inspect-dev-spec",
4298
+ "select-dev-spec-scope",
4299
+ "list-tasks",
4300
+ "memory-new-id",
4301
+ }:
3357
4302
  if session_agent == "unknown":
3358
4303
  raise StateError(
3359
4304
  "Cannot resolve the logical session. Pass --session-file or --agent."
@@ -3361,6 +4306,26 @@ def main() -> int:
3361
4306
  _, session_file = ensure_hook_session(root, {}, session_agent)
3362
4307
  if command == "snapshot":
3363
4308
  emit(snapshot_state(root, session_file))
4309
+ elif command == "inspect-dev-spec":
4310
+ spec_path = Path(args.spec)
4311
+ emit(
4312
+ inspection_summary(
4313
+ inspect_spec(
4314
+ spec_path if spec_path.is_absolute() else root / spec_path,
4315
+ root,
4316
+ parse_mapping_args(args.repo_path, "--repo-path"),
4317
+ )
4318
+ )
4319
+ )
4320
+ elif command == "select-dev-spec-scope":
4321
+ spec_path = Path(args.spec)
4322
+ emit(
4323
+ select_consumption_scopes(
4324
+ spec_path if spec_path.is_absolute() else root / spec_path,
4325
+ root,
4326
+ args.spec_task,
4327
+ )
4328
+ )
3364
4329
  elif command == "list-tasks":
3365
4330
  emit({"tasks": list_tasks(root, visible_agent)})
3366
4331
  elif command == "create-task":
@@ -3380,6 +4345,30 @@ def main() -> int:
3380
4345
  session_file,
3381
4346
  )
3382
4347
  )
4348
+ elif command == "create-task-from-spec":
4349
+ emit(
4350
+ attach_status_context(
4351
+ root,
4352
+ create_task_from_spec(
4353
+ root,
4354
+ args.spec,
4355
+ args.spec_task,
4356
+ args.task_id,
4357
+ args.type,
4358
+ args.title,
4359
+ parse_mapping_args(args.repo_path, "--repo-path"),
4360
+ parse_mapping_args(
4361
+ args.dependency_evidence,
4362
+ "--dependency-evidence",
4363
+ ),
4364
+ agent,
4365
+ not args.no_set_current,
4366
+ session_file,
4367
+ ),
4368
+ agent,
4369
+ session_file,
4370
+ )
4371
+ )
3383
4372
  elif command == "set-current":
3384
4373
  emit(
3385
4374
  attach_status_context(
@@ -3664,8 +4653,25 @@ def main() -> int:
3664
4653
  session_file,
3665
4654
  )
3666
4655
  )
4656
+ elif command == "satisfy-spec-dependency":
4657
+ emit(
4658
+ attach_status_context(
4659
+ root,
4660
+ satisfy_spec_dependency(
4661
+ root,
4662
+ args.spec_task,
4663
+ args.evidence,
4664
+ agent,
4665
+ args.source_task,
4666
+ args.task_id,
4667
+ session_file,
4668
+ ),
4669
+ agent,
4670
+ session_file,
4671
+ )
4672
+ )
3667
4673
  return 0
3668
- except StateError as error:
4674
+ except (StateError, EasyDevSpecError) as error:
3669
4675
  print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
3670
4676
  return 1
3671
4677