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.
- package/CHANGELOG.md +25 -0
- package/README.md +17 -0
- package/package.json +1 -1
- package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +19 -3
- package/templates/common/skills/ec-analysis/SKILL.md +43 -1
- package/templates/common/skills/ec-git/SKILL.md +4 -0
- package/templates/common/skills/ec-implementing/SKILL.md +14 -3
- package/templates/common/skills/ec-reviewing/SKILL.md +9 -0
- package/templates/common/skills/ec-task-management/SKILL.md +7 -0
- package/templates/common/skills/ec-verification/SKILL.md +24 -1
- package/templates/common/skills/ec-workflow/SKILL.md +32 -3
- package/templates/runtime/templates/dev-spec-skeleton.md +12 -5
- package/templates/shared-hooks/easy_coding_state.py +1055 -49
- package/templates/shared-hooks/easy_dev_spec.py +417 -0
- package/templates/shared-hooks/easy_dev_spec_protocol.py +1971 -0
|
@@ -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"
|
|
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
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
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 =
|
|
1887
|
+
candidate = base / candidate
|
|
1483
1888
|
resolved = candidate.resolve()
|
|
1484
1889
|
if not was_absolute:
|
|
1485
1890
|
try:
|
|
1486
|
-
resolved.relative_to(
|
|
1891
|
+
resolved.relative_to(base.resolve())
|
|
1487
1892
|
except ValueError as error:
|
|
1488
|
-
raise StateError(f"Execution plan file escapes
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
1562
|
-
|
|
1563
|
-
|
|
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
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
|
1647
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|