easy-coding-harness 0.9.1 → 0.10.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +24 -3
- package/dist/cli.js +102 -13
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/templates/common/bundled-skills/ec-init/SKILL.md +6 -0
- package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +28 -5
- package/templates/common/bundled-skills/ec-meta/references/platform-files/README.md +1 -1
- package/templates/common/skills/ec-analysis/SKILL.md +73 -1
- package/templates/common/skills/ec-config/SKILL.md +54 -0
- package/templates/common/skills/ec-git/SKILL.md +4 -0
- package/templates/common/skills/ec-implementing/SKILL.md +25 -3
- package/templates/common/skills/ec-memory/SKILL.md +4 -0
- package/templates/common/skills/ec-reviewing/SKILL.md +15 -0
- package/templates/common/skills/ec-task-management/SKILL.md +13 -31
- package/templates/common/skills/ec-verification/SKILL.md +69 -2
- package/templates/common/skills/ec-workflow/SKILL.md +39 -4
- package/templates/main-constraint/AGENTS.md.tpl +10 -3
- package/templates/main-constraint/CLAUDE.md.tpl +10 -3
- package/templates/runtime/templates/dev-spec-skeleton.md +12 -5
- package/templates/runtime/tools/easy_coding_java_coverage.py +317 -0
- package/templates/shared-hooks/easy_coding_state.py +1655 -60
- package/templates/shared-hooks/easy_dev_spec.py +417 -0
- package/templates/shared-hooks/easy_dev_spec_protocol.py +1971 -0
- package/templates/shared-hooks/inject-subagent-context.py +5 -0
|
@@ -5,6 +5,7 @@ import json
|
|
|
5
5
|
import os
|
|
6
6
|
import re
|
|
7
7
|
import secrets
|
|
8
|
+
import shlex
|
|
8
9
|
import subprocess
|
|
9
10
|
import time
|
|
10
11
|
import uuid
|
|
@@ -12,11 +13,20 @@ from datetime import datetime, timezone
|
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
import sys
|
|
14
15
|
|
|
16
|
+
from easy_dev_spec import (
|
|
17
|
+
EasyDevSpecError,
|
|
18
|
+
inspect_spec,
|
|
19
|
+
inspection_summary,
|
|
20
|
+
select_consumption_scopes,
|
|
21
|
+
select_tasks,
|
|
22
|
+
)
|
|
23
|
+
|
|
15
24
|
|
|
16
25
|
TERMINAL_STATUSES = {"COMPLETE", "CLOSED"}
|
|
17
26
|
HELP_SUFFIX = (
|
|
18
27
|
"Use `ec-workflow` to start or resume a task, "
|
|
19
|
-
"`ec-brainstorming` to brainstorm,
|
|
28
|
+
"`ec-brainstorming` to brainstorm, `ec-task-management` to manage tasks, "
|
|
29
|
+
"or `ec-config` to inspect or change modes"
|
|
20
30
|
)
|
|
21
31
|
READY_LINE = f"Ready · {HELP_SUFFIX}"
|
|
22
32
|
WAITING_INIT_LINE = "Waiting init · Use `ec-init` to initialize"
|
|
@@ -70,6 +80,8 @@ STRICT_WORKFLOW_RISK_PATTERN = re.compile(
|
|
|
70
80
|
)
|
|
71
81
|
DEFAULT_APPROVAL_MODE = "guard"
|
|
72
82
|
DEFAULT_WORKFLOW_MODE = "adaptive"
|
|
83
|
+
DEFAULT_TDD_ENABLED = False
|
|
84
|
+
DEFAULT_TDD_COVERAGE_THRESHOLD = 90
|
|
73
85
|
CRITICAL_CONFIRM_TRANSITIONS = {
|
|
74
86
|
("ANALYSIS", "IMPLEMENT"),
|
|
75
87
|
("VERIFICATION", "MEMORY"),
|
|
@@ -87,7 +99,7 @@ DEFAULT_SHORT_TERM_KEEP = 5
|
|
|
87
99
|
SESSION_STALE_THRESHOLD_HOURS = 30 * 24
|
|
88
100
|
SESSION_COMPONENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
89
101
|
SESSION_AGENT_NAMESPACES = {"claude-code", "codex", "qoder", "unknown"}
|
|
90
|
-
CODEX_AGENT_PATH_PATTERN = re.compile(r"
|
|
102
|
+
CODEX_AGENT_PATH_PATTERN = re.compile(r"^/?root(?:/[a-z0-9._-]+)*$")
|
|
91
103
|
LEGACY_STATE_LOCK_TIMEOUT_SECONDS = 5.0
|
|
92
104
|
LEGACY_STATE_LOCK_STALE_SECONDS = 60.0
|
|
93
105
|
LEGACY_STATE_LOCK_POLL_SECONDS = 0.02
|
|
@@ -157,7 +169,7 @@ def short_memory_id_sort_key(memory_id: str) -> tuple[int, str]:
|
|
|
157
169
|
def normalize_agent_identity(agent: str | None) -> str:
|
|
158
170
|
raw_agent = str(agent or "unknown").strip()
|
|
159
171
|
normalized = raw_agent.lower()
|
|
160
|
-
# Codex
|
|
172
|
+
# Codex 可能把根执行者写成 root 或 /root;两者及其协作子路径都属于同一平台身份。
|
|
161
173
|
if CODEX_AGENT_PATH_PATTERN.fullmatch(normalized):
|
|
162
174
|
return "codex"
|
|
163
175
|
if normalized in SESSION_AGENT_NAMESPACES:
|
|
@@ -302,16 +314,45 @@ def read_memory_config(root: Path) -> dict[str, int]:
|
|
|
302
314
|
return config
|
|
303
315
|
|
|
304
316
|
|
|
305
|
-
def
|
|
317
|
+
def parse_tdd_threshold(value: object, source: str) -> int:
|
|
318
|
+
if isinstance(value, bool):
|
|
319
|
+
raise StateError(f"Invalid {source}: expected an integer from 1 to 100.")
|
|
320
|
+
try:
|
|
321
|
+
threshold = int(str(value))
|
|
322
|
+
except (TypeError, ValueError) as error:
|
|
323
|
+
raise StateError(f"Invalid {source}: expected an integer from 1 to 100.") from error
|
|
324
|
+
if threshold < 1 or threshold > 100:
|
|
325
|
+
raise StateError(f"Invalid {source}: expected an integer from 1 to 100.")
|
|
326
|
+
return threshold
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def parse_yaml_bool(value: str | None, source: str) -> bool:
|
|
330
|
+
if value is None:
|
|
331
|
+
return DEFAULT_TDD_ENABLED
|
|
332
|
+
normalized = value.lower()
|
|
333
|
+
if normalized in {"true", "yes", "on"}:
|
|
334
|
+
return True
|
|
335
|
+
if normalized in {"false", "no", "off"}:
|
|
336
|
+
return False
|
|
337
|
+
raise StateError(f"Invalid {source}: expected true or false.")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def read_project_behavior(root: Path) -> tuple[str, str, bool, int]:
|
|
306
341
|
path = root / ".easy-coding" / "config.yaml"
|
|
307
342
|
try:
|
|
308
343
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
309
344
|
except OSError:
|
|
310
|
-
return
|
|
345
|
+
return (
|
|
346
|
+
DEFAULT_APPROVAL_MODE,
|
|
347
|
+
DEFAULT_WORKFLOW_MODE,
|
|
348
|
+
DEFAULT_TDD_ENABLED,
|
|
349
|
+
DEFAULT_TDD_COVERAGE_THRESHOLD,
|
|
350
|
+
)
|
|
311
351
|
|
|
312
352
|
in_behavior = False
|
|
313
353
|
behavior_indent = 0
|
|
314
354
|
behavior: dict[str, str] = {}
|
|
355
|
+
schema_version = 0
|
|
315
356
|
for raw_line in lines:
|
|
316
357
|
without_comment = raw_line.split("#", 1)[0].rstrip()
|
|
317
358
|
stripped = without_comment.strip()
|
|
@@ -324,6 +365,12 @@ def read_project_behavior(root: Path) -> tuple[str, str]:
|
|
|
324
365
|
continue
|
|
325
366
|
if in_behavior and indent <= behavior_indent:
|
|
326
367
|
in_behavior = False
|
|
368
|
+
if not in_behavior and indent == 0 and stripped.startswith("version:"):
|
|
369
|
+
try:
|
|
370
|
+
schema_version = int(stripped.split(":", 1)[1].strip().strip("'\""))
|
|
371
|
+
except ValueError:
|
|
372
|
+
schema_version = 0
|
|
373
|
+
continue
|
|
327
374
|
if not in_behavior or ":" not in stripped:
|
|
328
375
|
continue
|
|
329
376
|
key, value = stripped.split(":", 1)
|
|
@@ -351,16 +398,27 @@ def read_project_behavior(root: Path) -> tuple[str, str]:
|
|
|
351
398
|
"Invalid behavior.workflow_mode in .easy-coding/config.yaml: "
|
|
352
399
|
"expected adaptive, fast, standard, or strict."
|
|
353
400
|
)
|
|
354
|
-
|
|
401
|
+
if schema_version >= 4:
|
|
402
|
+
tdd_enabled = parse_yaml_bool(behavior.get("tdd_enabled"), "behavior.tdd_enabled")
|
|
403
|
+
tdd_threshold = parse_tdd_threshold(
|
|
404
|
+
behavior.get("tdd_coverage_threshold", DEFAULT_TDD_COVERAGE_THRESHOLD),
|
|
405
|
+
"behavior.tdd_coverage_threshold",
|
|
406
|
+
)
|
|
407
|
+
else:
|
|
408
|
+
tdd_enabled = DEFAULT_TDD_ENABLED
|
|
409
|
+
tdd_threshold = DEFAULT_TDD_COVERAGE_THRESHOLD
|
|
410
|
+
return approval_mode, workflow_mode, tdd_enabled, tdd_threshold
|
|
355
411
|
|
|
356
412
|
|
|
357
413
|
def resolve_behavior(
|
|
358
414
|
root: Path, session: dict
|
|
359
|
-
) -> tuple[str, str | None, str, str, str | None, str]:
|
|
360
|
-
project_approval, project_workflow = read_project_behavior(root)
|
|
415
|
+
) -> tuple[str, str | None, str, str, str | None, str, bool, bool | None, bool, int, int | None, int]:
|
|
416
|
+
project_approval, project_workflow, project_tdd, project_threshold = read_project_behavior(root)
|
|
361
417
|
legacy = session.get("confirm_mode")
|
|
362
418
|
session_approval = session.get("approval_mode")
|
|
363
419
|
session_workflow = session.get("workflow_mode")
|
|
420
|
+
session_tdd = session.get("tdd_enabled")
|
|
421
|
+
session_threshold = session.get("tdd_coverage_threshold")
|
|
364
422
|
if session_approval is None:
|
|
365
423
|
if legacy == "lite":
|
|
366
424
|
session_approval = "guard"
|
|
@@ -379,6 +437,12 @@ def resolve_behavior(
|
|
|
379
437
|
raise StateError(
|
|
380
438
|
"Invalid session workflow_mode: expected adaptive, fast, standard, or strict."
|
|
381
439
|
)
|
|
440
|
+
if session_tdd is not None and not isinstance(session_tdd, bool):
|
|
441
|
+
raise StateError("Invalid session tdd_enabled: expected true or false.")
|
|
442
|
+
if session_threshold is not None:
|
|
443
|
+
session_threshold = parse_tdd_threshold(
|
|
444
|
+
session_threshold, "session tdd_coverage_threshold"
|
|
445
|
+
)
|
|
382
446
|
return (
|
|
383
447
|
project_approval,
|
|
384
448
|
str(session_approval) if session_approval else None,
|
|
@@ -386,6 +450,12 @@ def resolve_behavior(
|
|
|
386
450
|
project_workflow,
|
|
387
451
|
str(session_workflow) if session_workflow else None,
|
|
388
452
|
str(session_workflow or project_workflow),
|
|
453
|
+
project_tdd,
|
|
454
|
+
session_tdd,
|
|
455
|
+
session_tdd if session_tdd is not None else project_tdd,
|
|
456
|
+
project_threshold,
|
|
457
|
+
session_threshold,
|
|
458
|
+
session_threshold if session_threshold is not None else project_threshold,
|
|
389
459
|
)
|
|
390
460
|
|
|
391
461
|
|
|
@@ -1071,6 +1141,337 @@ def is_read_only_execution_plan(plan: object) -> bool:
|
|
|
1071
1141
|
)
|
|
1072
1142
|
|
|
1073
1143
|
|
|
1144
|
+
def stored_spec_path(root: Path, task: dict) -> Path:
|
|
1145
|
+
source = task.get("spec_source")
|
|
1146
|
+
if not isinstance(source, dict) or not is_non_empty_string(source.get("path")):
|
|
1147
|
+
raise StateError("Spec-backed task is missing spec_source.path.")
|
|
1148
|
+
raw_path = Path(str(source["path"]))
|
|
1149
|
+
path = raw_path if raw_path.is_absolute() else root / raw_path
|
|
1150
|
+
resolved = path.resolve()
|
|
1151
|
+
try:
|
|
1152
|
+
resolved.relative_to(root.resolve())
|
|
1153
|
+
except ValueError as exc:
|
|
1154
|
+
raise StateError("Spec-backed task source path must remain inside the project root.") from exc
|
|
1155
|
+
return resolved
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
|
|
1159
|
+
source = task.get("spec_source")
|
|
1160
|
+
selected = task.get("selected_spec_tasks")
|
|
1161
|
+
repo_paths = task.get("repo_paths")
|
|
1162
|
+
if not isinstance(source, dict) or not is_string_list(selected, allow_empty=False):
|
|
1163
|
+
raise StateError("Spec-backed task source and selected task metadata are incomplete.")
|
|
1164
|
+
try:
|
|
1165
|
+
inspection = inspect_spec(
|
|
1166
|
+
stored_spec_path(root, task),
|
|
1167
|
+
root,
|
|
1168
|
+
repo_paths if isinstance(repo_paths, dict) else {},
|
|
1169
|
+
selected,
|
|
1170
|
+
)
|
|
1171
|
+
satisfied = {
|
|
1172
|
+
f"{record.get('source_task_id')}->{record.get('task_id')}": str(record.get("evidence"))
|
|
1173
|
+
for record in task.get("spec_dependency_evidence", [])
|
|
1174
|
+
if isinstance(record, dict)
|
|
1175
|
+
and record.get("status") == "satisfied"
|
|
1176
|
+
and is_non_empty_string(record.get("task_id"))
|
|
1177
|
+
and is_non_empty_string(record.get("evidence"))
|
|
1178
|
+
}
|
|
1179
|
+
selection = select_tasks(inspection, selected, satisfied)
|
|
1180
|
+
except EasyDevSpecError as exc:
|
|
1181
|
+
raise StateError(f"Canonical Spec validation failed: {exc}") from exc
|
|
1182
|
+
stored_dependencies = task.get("spec_dependency_evidence")
|
|
1183
|
+
if not isinstance(stored_dependencies, list):
|
|
1184
|
+
raise StateError("Spec-backed task dependency metadata is incomplete.")
|
|
1185
|
+
expected_by_edge = {
|
|
1186
|
+
(record.get("source_task_id"), record.get("task_id")): record
|
|
1187
|
+
for record in selection["dependency_records"]
|
|
1188
|
+
}
|
|
1189
|
+
stored_by_edge = {
|
|
1190
|
+
(record.get("source_task_id"), record.get("task_id")): record
|
|
1191
|
+
for record in stored_dependencies
|
|
1192
|
+
if isinstance(record, dict)
|
|
1193
|
+
}
|
|
1194
|
+
if (
|
|
1195
|
+
len(stored_by_edge) != len(stored_dependencies)
|
|
1196
|
+
or set(stored_by_edge) != set(expected_by_edge)
|
|
1197
|
+
):
|
|
1198
|
+
raise StateError("Canonical Spec dependency metadata no longer matches source selection.")
|
|
1199
|
+
for edge, expected in expected_by_edge.items():
|
|
1200
|
+
stored = stored_by_edge[edge]
|
|
1201
|
+
for field in ("dependency_type", "required_evidence", "status"):
|
|
1202
|
+
if stored.get(field) != expected.get(field):
|
|
1203
|
+
raise StateError(
|
|
1204
|
+
"Canonical Spec dependency metadata no longer matches source selection."
|
|
1205
|
+
)
|
|
1206
|
+
if stored.get("evidence") != expected.get("evidence"):
|
|
1207
|
+
raise StateError(
|
|
1208
|
+
"Canonical Spec dependency evidence no longer matches its recorded status."
|
|
1209
|
+
)
|
|
1210
|
+
if source.get("schema") != inspection.get("schema"):
|
|
1211
|
+
raise StateError("Canonical Spec schema no longer matches task.json.")
|
|
1212
|
+
if source.get("spec_id") != inspection.get("spec_id"):
|
|
1213
|
+
raise StateError("Canonical Spec ID no longer matches task.json.")
|
|
1214
|
+
if source.get("revision") != inspection.get("revision"):
|
|
1215
|
+
raise StateError("Canonical Spec revision no longer matches task.json.")
|
|
1216
|
+
if source.get("sha256") != inspection.get("source_sha256"):
|
|
1217
|
+
raise StateError("Canonical Spec SHA-256 changed after task creation.")
|
|
1218
|
+
selected_repo_ids = set(selection["selected_repo_ids"])
|
|
1219
|
+
stored_bindings = task.get("spec_repositories")
|
|
1220
|
+
if not isinstance(stored_bindings, list):
|
|
1221
|
+
raise StateError("Spec-backed task repository metadata is incomplete.")
|
|
1222
|
+
stored_by_repo = {
|
|
1223
|
+
str(binding.get("repo_id")): binding
|
|
1224
|
+
for binding in stored_bindings
|
|
1225
|
+
if isinstance(binding, dict) and is_non_empty_string(binding.get("repo_id"))
|
|
1226
|
+
}
|
|
1227
|
+
current_by_repo = {
|
|
1228
|
+
str(binding.get("repo_id")): binding
|
|
1229
|
+
for binding in inspection.get("repository_bindings", [])
|
|
1230
|
+
if isinstance(binding, dict)
|
|
1231
|
+
and str(binding.get("repo_id")) in selected_repo_ids
|
|
1232
|
+
}
|
|
1233
|
+
if (
|
|
1234
|
+
len(stored_by_repo) != len(stored_bindings)
|
|
1235
|
+
or set(stored_by_repo) != selected_repo_ids
|
|
1236
|
+
or set(current_by_repo) != selected_repo_ids
|
|
1237
|
+
):
|
|
1238
|
+
raise StateError("Canonical Spec repository bindings no longer match task.json.")
|
|
1239
|
+
for repo_id in selected_repo_ids:
|
|
1240
|
+
stored = stored_by_repo[repo_id]
|
|
1241
|
+
current = current_by_repo[repo_id]
|
|
1242
|
+
for field in ("repo_id", "name", "path", "baseline_commit"):
|
|
1243
|
+
if stored.get(field) != current.get(field):
|
|
1244
|
+
raise StateError("Canonical Spec repository bindings no longer match task.json.")
|
|
1245
|
+
return inspection, selection
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def is_valid_spec_execution_plan(root: Path, task: dict, plan: object) -> bool:
|
|
1249
|
+
if not isinstance(plan, dict):
|
|
1250
|
+
return False
|
|
1251
|
+
try:
|
|
1252
|
+
inspection, selection = inspect_task_spec(root, task)
|
|
1253
|
+
except StateError:
|
|
1254
|
+
return False
|
|
1255
|
+
selected_ids = set(selection["selected_task_ids"])
|
|
1256
|
+
task_by_id = {item["task_id"]: item for item in selection["selected_tasks"]}
|
|
1257
|
+
change_by_id = {
|
|
1258
|
+
str(change["change_id"]): change for change in selection["selected_changes"]
|
|
1259
|
+
}
|
|
1260
|
+
step_by_id = {
|
|
1261
|
+
str(step["step_id"]): step for step in selection["selected_steps"]
|
|
1262
|
+
}
|
|
1263
|
+
test_by_id = {
|
|
1264
|
+
str(test["test_id"]): test for test in selection["selected_tests"]
|
|
1265
|
+
}
|
|
1266
|
+
changes_by_task: dict[str, list[dict]] = {task_id: [] for task_id in selected_ids}
|
|
1267
|
+
tests_by_task: dict[str, list[dict]] = {task_id: [] for task_id in selected_ids}
|
|
1268
|
+
for change in selection["selected_changes"]:
|
|
1269
|
+
changes_by_task[str(change["task_id"])].append(change)
|
|
1270
|
+
for test in selection["selected_tests"]:
|
|
1271
|
+
tests_by_task[str(test["task_id"])].append(test)
|
|
1272
|
+
|
|
1273
|
+
units = [unit for unit in plan.get("units", []) if isinstance(unit, dict)]
|
|
1274
|
+
units_by_task: dict[str, list[dict]] = {task_id: [] for task_id in selected_ids}
|
|
1275
|
+
covered_steps: dict[str, list[str]] = {task_id: [] for task_id in selected_ids}
|
|
1276
|
+
covered_files: dict[str, set[str]] = {task_id: set() for task_id in selected_ids}
|
|
1277
|
+
covered_symbols: dict[str, set[str]] = {task_id: set() for task_id in selected_ids}
|
|
1278
|
+
covered_commands: dict[str, set[str]] = {task_id: set() for task_id in selected_ids}
|
|
1279
|
+
unit_by_id = {str(unit["id"]): unit for unit in units}
|
|
1280
|
+
unit_id_by_step: dict[str, str] = {}
|
|
1281
|
+
for unit in units:
|
|
1282
|
+
source_task_id = unit.get("source_task_id")
|
|
1283
|
+
if source_task_id not in selected_ids:
|
|
1284
|
+
return False
|
|
1285
|
+
source_task_id = str(source_task_id)
|
|
1286
|
+
source_task = task_by_id[source_task_id]
|
|
1287
|
+
if unit.get("repo_id") != source_task.get("repo_id"):
|
|
1288
|
+
return False
|
|
1289
|
+
for field in ("source_step_ids", "symbols", "test_commands"):
|
|
1290
|
+
if not is_string_list(unit.get(field), allow_empty=False):
|
|
1291
|
+
return False
|
|
1292
|
+
allowed_steps = set(source_task.get("step_ids", []))
|
|
1293
|
+
if not set(unit["source_step_ids"]).issubset(allowed_steps):
|
|
1294
|
+
return False
|
|
1295
|
+
source_steps = [step_by_id.get(str(step_id)) for step_id in unit["source_step_ids"]]
|
|
1296
|
+
if any(
|
|
1297
|
+
step is None or step.get("task_id") != source_task_id
|
|
1298
|
+
for step in source_steps
|
|
1299
|
+
):
|
|
1300
|
+
return False
|
|
1301
|
+
step_change_ids = {
|
|
1302
|
+
str(change_id)
|
|
1303
|
+
for step in source_steps
|
|
1304
|
+
if isinstance(step, dict)
|
|
1305
|
+
for change_id in step.get("change_ids", [])
|
|
1306
|
+
}
|
|
1307
|
+
step_test_ids = {
|
|
1308
|
+
str(test_id)
|
|
1309
|
+
for step in source_steps
|
|
1310
|
+
if isinstance(step, dict)
|
|
1311
|
+
for test_id in step.get("test_ids", [])
|
|
1312
|
+
}
|
|
1313
|
+
step_files = {
|
|
1314
|
+
str(change_by_id[change_id]["path"])
|
|
1315
|
+
for change_id in step_change_ids
|
|
1316
|
+
if change_id in change_by_id
|
|
1317
|
+
}
|
|
1318
|
+
step_symbols = {
|
|
1319
|
+
str(symbol)
|
|
1320
|
+
for change_id in step_change_ids
|
|
1321
|
+
if change_id in change_by_id
|
|
1322
|
+
for symbol in change_by_id[change_id].get("symbols", [])
|
|
1323
|
+
}
|
|
1324
|
+
step_commands = {
|
|
1325
|
+
str(test_by_id[test_id]["command"])
|
|
1326
|
+
for test_id in step_test_ids
|
|
1327
|
+
if test_id in test_by_id
|
|
1328
|
+
}
|
|
1329
|
+
# Unit 必须保存它声明的 source steps 的完整文件、符号和源测试映射;附加本地命令可保留。
|
|
1330
|
+
if (
|
|
1331
|
+
not step_change_ids.issubset(change_by_id)
|
|
1332
|
+
or not step_test_ids.issubset(test_by_id)
|
|
1333
|
+
or set(unit.get("files", [])) != step_files
|
|
1334
|
+
or set(unit["symbols"]) != step_symbols
|
|
1335
|
+
or not set(unit["test_commands"]).issuperset(step_commands)
|
|
1336
|
+
):
|
|
1337
|
+
return False
|
|
1338
|
+
for step_id in unit["source_step_ids"]:
|
|
1339
|
+
normalized_step_id = str(step_id)
|
|
1340
|
+
if normalized_step_id in unit_id_by_step:
|
|
1341
|
+
return False
|
|
1342
|
+
unit_id_by_step[normalized_step_id] = str(unit["id"])
|
|
1343
|
+
units_by_task[source_task_id].append(unit)
|
|
1344
|
+
covered_steps[source_task_id].extend(unit["source_step_ids"])
|
|
1345
|
+
covered_files[source_task_id].update(unit.get("files", []))
|
|
1346
|
+
covered_symbols[source_task_id].update(unit["symbols"])
|
|
1347
|
+
covered_commands[source_task_id].update(unit["test_commands"])
|
|
1348
|
+
|
|
1349
|
+
for source_task_id, source_task in task_by_id.items():
|
|
1350
|
+
if not units_by_task[source_task_id]:
|
|
1351
|
+
return False
|
|
1352
|
+
steps = covered_steps[source_task_id]
|
|
1353
|
+
if len(steps) != len(set(steps)) or set(steps) != set(source_task.get("step_ids", [])):
|
|
1354
|
+
return False
|
|
1355
|
+
if covered_files[source_task_id] != {
|
|
1356
|
+
str(change["path"]) for change in changes_by_task[source_task_id]
|
|
1357
|
+
}:
|
|
1358
|
+
return False
|
|
1359
|
+
if covered_symbols[source_task_id] != {
|
|
1360
|
+
str(symbol)
|
|
1361
|
+
for change in changes_by_task[source_task_id]
|
|
1362
|
+
for symbol in change.get("symbols", [])
|
|
1363
|
+
}:
|
|
1364
|
+
return False
|
|
1365
|
+
if not covered_commands[source_task_id].issuperset({
|
|
1366
|
+
str(test["command"]) for test in tests_by_task[source_task_id]
|
|
1367
|
+
}):
|
|
1368
|
+
return False
|
|
1369
|
+
|
|
1370
|
+
# 同一 source task 内的 Step DAG 也必须投影到 Unit DAG;合并在同一 Unit 的步骤无需自依赖。
|
|
1371
|
+
for step_id, step in step_by_id.items():
|
|
1372
|
+
owner_unit_id = unit_id_by_step.get(step_id)
|
|
1373
|
+
if owner_unit_id is None:
|
|
1374
|
+
return False
|
|
1375
|
+
owner_unit = unit_by_id[owner_unit_id]
|
|
1376
|
+
for dependency_step_id in step.get("depends_on_step_ids", []):
|
|
1377
|
+
dependency_unit_id = unit_id_by_step.get(str(dependency_step_id))
|
|
1378
|
+
if dependency_unit_id is None:
|
|
1379
|
+
return False
|
|
1380
|
+
if (
|
|
1381
|
+
dependency_unit_id != owner_unit_id
|
|
1382
|
+
and dependency_unit_id not in owner_unit.get("depends_on", [])
|
|
1383
|
+
):
|
|
1384
|
+
return False
|
|
1385
|
+
|
|
1386
|
+
# hard 依赖必须投影为 Unit DAG,不能只保存在说明文字中。
|
|
1387
|
+
unit_ids_by_task = {
|
|
1388
|
+
source_task_id: {str(unit["id"]) for unit in source_units}
|
|
1389
|
+
for source_task_id, source_units in units_by_task.items()
|
|
1390
|
+
}
|
|
1391
|
+
for edge in inspection.get("dependency_edges", []):
|
|
1392
|
+
source_task_id = str(edge.get("source_task_id") or "")
|
|
1393
|
+
dependency_task_id = str(edge.get("task_id") or "")
|
|
1394
|
+
if (
|
|
1395
|
+
edge.get("dependency_type") != "hard"
|
|
1396
|
+
or source_task_id not in selected_ids
|
|
1397
|
+
or dependency_task_id not in selected_ids
|
|
1398
|
+
):
|
|
1399
|
+
continue
|
|
1400
|
+
dependency_ids = unit_ids_by_task[dependency_task_id]
|
|
1401
|
+
depended_on_within_dependency = {
|
|
1402
|
+
dependency
|
|
1403
|
+
for unit in units_by_task[dependency_task_id]
|
|
1404
|
+
for dependency in unit.get("depends_on", [])
|
|
1405
|
+
if dependency in dependency_ids
|
|
1406
|
+
}
|
|
1407
|
+
dependency_terminals = dependency_ids - depended_on_within_dependency
|
|
1408
|
+
source_units = units_by_task[source_task_id]
|
|
1409
|
+
source_ids = unit_ids_by_task[source_task_id]
|
|
1410
|
+
source_roots = [
|
|
1411
|
+
unit
|
|
1412
|
+
for unit in source_units
|
|
1413
|
+
if not set(unit.get("depends_on", [])).intersection(source_ids)
|
|
1414
|
+
]
|
|
1415
|
+
if not dependency_terminals or any(
|
|
1416
|
+
not dependency_terminals.issubset(set(unit.get("depends_on", [])))
|
|
1417
|
+
for unit in source_roots
|
|
1418
|
+
):
|
|
1419
|
+
return False
|
|
1420
|
+
return True
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def contains_spec_marker(content: str, marker: str) -> bool:
|
|
1424
|
+
boundary_characters = (
|
|
1425
|
+
r"A-Za-z0-9_/" + ("." if "/" in marker or "." in marker else "") + "-"
|
|
1426
|
+
)
|
|
1427
|
+
return (
|
|
1428
|
+
re.search(
|
|
1429
|
+
rf"(?<![{boundary_characters}]){re.escape(marker)}(?![{boundary_characters}])",
|
|
1430
|
+
content,
|
|
1431
|
+
)
|
|
1432
|
+
is not None
|
|
1433
|
+
)
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
def missing_spec_test_strategy_markers(
|
|
1437
|
+
selection: dict, plan: dict, content: str
|
|
1438
|
+
) -> list[str]:
|
|
1439
|
+
unit_ids_by_step = {
|
|
1440
|
+
str(step_id): str(unit["id"])
|
|
1441
|
+
for unit in plan.get("units", [])
|
|
1442
|
+
if isinstance(unit, dict)
|
|
1443
|
+
for step_id in unit.get("source_step_ids", [])
|
|
1444
|
+
}
|
|
1445
|
+
owner_units_by_test: dict[str, set[str]] = {}
|
|
1446
|
+
for step in selection.get("selected_steps", []):
|
|
1447
|
+
if not isinstance(step, dict):
|
|
1448
|
+
continue
|
|
1449
|
+
owner_unit_id = unit_ids_by_step.get(str(step.get("step_id") or ""))
|
|
1450
|
+
if not owner_unit_id:
|
|
1451
|
+
continue
|
|
1452
|
+
for test_id in step.get("test_ids", []):
|
|
1453
|
+
owner_units_by_test.setdefault(str(test_id), set()).add(owner_unit_id)
|
|
1454
|
+
|
|
1455
|
+
missing: list[str] = []
|
|
1456
|
+
for test in selection.get("selected_tests", []):
|
|
1457
|
+
if not isinstance(test, dict):
|
|
1458
|
+
continue
|
|
1459
|
+
test_id = str(test.get("test_id") or "")
|
|
1460
|
+
markers = {
|
|
1461
|
+
test_id,
|
|
1462
|
+
str(test.get("task_id") or ""),
|
|
1463
|
+
str(test.get("file") or ""),
|
|
1464
|
+
str(test.get("command") or ""),
|
|
1465
|
+
*owner_units_by_test.get(test_id, set()),
|
|
1466
|
+
}
|
|
1467
|
+
missing.extend(
|
|
1468
|
+
marker
|
|
1469
|
+
for marker in sorted(markers)
|
|
1470
|
+
if marker and not contains_spec_marker(content, marker)
|
|
1471
|
+
)
|
|
1472
|
+
return list(dict.fromkeys(missing))
|
|
1473
|
+
|
|
1474
|
+
|
|
1074
1475
|
def read_project_schema_version(root: Path) -> int:
|
|
1075
1476
|
path = root / ".easy-coding" / "config.yaml"
|
|
1076
1477
|
try:
|
|
@@ -1105,10 +1506,15 @@ def has_valid_execution_plan(root: Path, task_id: str) -> bool:
|
|
|
1105
1506
|
task_type = str(task.get("type") or "").strip().lower() if task else ""
|
|
1106
1507
|
if task_type in NO_CODE_TASK_TYPES:
|
|
1107
1508
|
return is_read_only_execution_plan(latest_plan)
|
|
1108
|
-
|
|
1509
|
+
valid = is_valid_execution_plan(
|
|
1109
1510
|
latest_plan,
|
|
1110
1511
|
require_unit_contracts=read_project_schema_version(root) >= 3,
|
|
1111
1512
|
)
|
|
1513
|
+
if not valid:
|
|
1514
|
+
return False
|
|
1515
|
+
if task and isinstance(task.get("spec_source"), dict):
|
|
1516
|
+
return is_valid_spec_execution_plan(root, task, latest_plan)
|
|
1517
|
+
return True
|
|
1112
1518
|
|
|
1113
1519
|
|
|
1114
1520
|
def execution_records(root: Path, task_id: str) -> list[dict]:
|
|
@@ -1131,10 +1537,10 @@ def execution_records(root: Path, task_id: str) -> list[dict]:
|
|
|
1131
1537
|
def latest_execution_plan(root: Path, task_id: str) -> dict | None:
|
|
1132
1538
|
latest: dict | None = None
|
|
1133
1539
|
for record in execution_records(root, task_id):
|
|
1134
|
-
if record.get("type") == "plan"
|
|
1135
|
-
record, allow_empty_files=True
|
|
1136
|
-
):
|
|
1540
|
+
if record.get("type") == "plan":
|
|
1137
1541
|
latest = record
|
|
1542
|
+
if latest is None or not is_valid_execution_plan(latest, allow_empty_files=True):
|
|
1543
|
+
return None
|
|
1138
1544
|
return latest
|
|
1139
1545
|
|
|
1140
1546
|
|
|
@@ -1188,6 +1594,44 @@ def minimize_repository_scopes(repository: Path, scopes: set[Path]) -> list[Path
|
|
|
1188
1594
|
def task_repository_scopes(
|
|
1189
1595
|
root: Path, task: dict | None, plan: dict
|
|
1190
1596
|
) -> list[tuple[Path, list[Path]]]:
|
|
1597
|
+
if task and isinstance(task.get("spec_source"), dict):
|
|
1598
|
+
repo_paths = task.get("repo_paths")
|
|
1599
|
+
if not isinstance(repo_paths, dict):
|
|
1600
|
+
raise StateError("Spec-backed task is missing repo_paths.")
|
|
1601
|
+
repositories: dict[Path, set[Path]] = {}
|
|
1602
|
+
for unit in plan.get("units", []):
|
|
1603
|
+
if not isinstance(unit, dict) or not is_non_empty_string(unit.get("repo_id")):
|
|
1604
|
+
raise StateError("Spec-backed execution unit is missing repo_id.")
|
|
1605
|
+
repo_id = str(unit["repo_id"])
|
|
1606
|
+
raw_repo_path = repo_paths.get(repo_id)
|
|
1607
|
+
if not is_non_empty_string(raw_repo_path):
|
|
1608
|
+
raise StateError(f"Spec repository path is missing: {repo_id}")
|
|
1609
|
+
candidate = Path(str(raw_repo_path))
|
|
1610
|
+
repository_path = (candidate if candidate.is_absolute() else root / candidate).resolve()
|
|
1611
|
+
repository = git_repository_root(repository_path)
|
|
1612
|
+
if repository is None or repository.resolve() != repository_path:
|
|
1613
|
+
raise StateError(f"Spec repository binding is not a Git root: {repo_id}")
|
|
1614
|
+
scopes = repositories.setdefault(repository, set())
|
|
1615
|
+
scopes.add(repository)
|
|
1616
|
+
for file_name in unit.get("files", []):
|
|
1617
|
+
if not is_non_empty_string(file_name):
|
|
1618
|
+
raise StateError(f"Spec execution unit has an invalid file path: {repo_id}")
|
|
1619
|
+
relative_path = Path(str(file_name))
|
|
1620
|
+
if relative_path.is_absolute() or ".." in relative_path.parts:
|
|
1621
|
+
raise StateError(f"Spec execution unit path escapes repository {repo_id}: {file_name}")
|
|
1622
|
+
resolved_file = (repository / relative_path).resolve()
|
|
1623
|
+
if not is_path_within(resolved_file, repository):
|
|
1624
|
+
raise StateError(f"Spec execution unit path escapes repository {repo_id}: {file_name}")
|
|
1625
|
+
file_repository = git_repository_root(resolved_file)
|
|
1626
|
+
if file_repository is None or file_repository.resolve() != repository:
|
|
1627
|
+
raise StateError(
|
|
1628
|
+
f"Spec execution unit path belongs to another Git repository: {repo_id}:{file_name}"
|
|
1629
|
+
)
|
|
1630
|
+
return [
|
|
1631
|
+
(repository, [repository])
|
|
1632
|
+
for repository in sorted(repositories, key=lambda item: item.as_posix())
|
|
1633
|
+
]
|
|
1634
|
+
|
|
1191
1635
|
scope_candidates = [root]
|
|
1192
1636
|
if task:
|
|
1193
1637
|
repo_paths = task.get("repo_paths")
|
|
@@ -1242,6 +1686,77 @@ def task_repository_roots(root: Path, task: dict | None, plan: dict) -> list[Pat
|
|
|
1242
1686
|
]
|
|
1243
1687
|
|
|
1244
1688
|
|
|
1689
|
+
def tdd_repositories(root: Path, task: dict, plan: dict) -> dict[str, Path]:
|
|
1690
|
+
if isinstance(task.get("spec_source"), dict):
|
|
1691
|
+
repo_paths = task.get("repo_paths")
|
|
1692
|
+
if not isinstance(repo_paths, dict):
|
|
1693
|
+
raise StateError("TDD Canonical task is missing repository bindings.")
|
|
1694
|
+
repositories: dict[str, Path] = {}
|
|
1695
|
+
for unit in plan.get("units", []):
|
|
1696
|
+
if not isinstance(unit, dict) or not is_non_empty_string(unit.get("repo_id")):
|
|
1697
|
+
raise StateError("TDD Canonical unit is missing repo_id.")
|
|
1698
|
+
repo_id = str(unit["repo_id"])
|
|
1699
|
+
raw_path = repo_paths.get(repo_id)
|
|
1700
|
+
if not is_non_empty_string(raw_path):
|
|
1701
|
+
raise StateError(f"TDD repository path is missing: {repo_id}")
|
|
1702
|
+
candidate = Path(str(raw_path))
|
|
1703
|
+
resolved = (candidate if candidate.is_absolute() else root / candidate).resolve()
|
|
1704
|
+
repository = git_repository_root(resolved)
|
|
1705
|
+
if repository is None or repository.resolve() != resolved:
|
|
1706
|
+
raise StateError(f"TDD repository binding is not a Git root: {repo_id}")
|
|
1707
|
+
repositories[repo_id] = repository
|
|
1708
|
+
return repositories
|
|
1709
|
+
|
|
1710
|
+
repositories = task_repository_roots(root, task, plan)
|
|
1711
|
+
if len(repositories) != 1:
|
|
1712
|
+
raise StateError(
|
|
1713
|
+
"Non-Canonical TDD requires exactly one Git repository; use a Canonical Spec for multi-repository work."
|
|
1714
|
+
)
|
|
1715
|
+
return {"project": repositories[0]}
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
def git_head_sha(repository: Path) -> str:
|
|
1719
|
+
result = run_git(repository, "rev-parse", "--verify", "HEAD")
|
|
1720
|
+
sha = result.stdout.decode("ascii", errors="ignore").strip() if result else ""
|
|
1721
|
+
if (
|
|
1722
|
+
result is None
|
|
1723
|
+
or result.returncode != 0
|
|
1724
|
+
or re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", sha) is None
|
|
1725
|
+
):
|
|
1726
|
+
raise StateError(f"Cannot freeze TDD Git baseline for {repository.name}.")
|
|
1727
|
+
return sha
|
|
1728
|
+
|
|
1729
|
+
|
|
1730
|
+
def tdd_baseline_marker_reasons(
|
|
1731
|
+
dev_spec_content: str, strategy_content: str, baselines: dict[str, str]
|
|
1732
|
+
) -> list[str]:
|
|
1733
|
+
reasons: list[str] = []
|
|
1734
|
+
canonical = set(baselines) != {"project"}
|
|
1735
|
+
for repo_id, baseline in sorted(baselines.items()):
|
|
1736
|
+
for artifact_name, content in (
|
|
1737
|
+
("dev-spec.md", dev_spec_content),
|
|
1738
|
+
("test-strategy.md", strategy_content),
|
|
1739
|
+
):
|
|
1740
|
+
if baseline not in content:
|
|
1741
|
+
reasons.append(
|
|
1742
|
+
f"{artifact_name} must record the immutable TDD baseline SHA for {repo_id}: {baseline}"
|
|
1743
|
+
)
|
|
1744
|
+
if canonical and repo_id not in content:
|
|
1745
|
+
reasons.append(
|
|
1746
|
+
f"{artifact_name} must map the TDD baseline to repository {repo_id}"
|
|
1747
|
+
)
|
|
1748
|
+
return reasons
|
|
1749
|
+
|
|
1750
|
+
|
|
1751
|
+
def contains_tdd_threshold(content: str, threshold: int) -> bool:
|
|
1752
|
+
return re.search(
|
|
1753
|
+
rf"(?<!\d){threshold}\s*%|--threshold(?:\s+|=){threshold}(?!\d)|"
|
|
1754
|
+
rf"tdd_coverage_threshold\s*[:=]\s*{threshold}(?!\d)",
|
|
1755
|
+
content,
|
|
1756
|
+
re.IGNORECASE,
|
|
1757
|
+
) is not None
|
|
1758
|
+
|
|
1759
|
+
|
|
1245
1760
|
def repository_scope_pathspecs(repository: Path, scopes: list[Path]) -> list[str]:
|
|
1246
1761
|
return [
|
|
1247
1762
|
f":(literal){scope.relative_to(repository).as_posix()}"
|
|
@@ -1455,6 +1970,18 @@ def implementation_fingerprint(root: Path, task_id: str) -> str:
|
|
|
1455
1970
|
digest.update(b"workflow-mode\0")
|
|
1456
1971
|
digest.update(workflow_mode.encode("utf-8"))
|
|
1457
1972
|
digest.update(b"\0")
|
|
1973
|
+
if task and task.get("tdd_enabled") is True:
|
|
1974
|
+
digest.update(b"tdd\0enabled\0")
|
|
1975
|
+
digest.update(str(task.get("tdd_coverage_threshold") or "").encode("utf-8"))
|
|
1976
|
+
digest.update(b"\0")
|
|
1977
|
+
digest.update(
|
|
1978
|
+
json.dumps(
|
|
1979
|
+
task.get("tdd_baselines") or {},
|
|
1980
|
+
sort_keys=True,
|
|
1981
|
+
separators=(",", ":"),
|
|
1982
|
+
).encode("utf-8")
|
|
1983
|
+
)
|
|
1984
|
+
digest.update(b"\0")
|
|
1458
1985
|
digest.update(b"execution-plan\0")
|
|
1459
1986
|
digest.update(
|
|
1460
1987
|
json.dumps(
|
|
@@ -1465,28 +1992,51 @@ def implementation_fingerprint(root: Path, task_id: str) -> str:
|
|
|
1465
1992
|
).encode("utf-8")
|
|
1466
1993
|
)
|
|
1467
1994
|
digest.update(b"\0")
|
|
1995
|
+
if task and isinstance(task.get("spec_source"), dict):
|
|
1996
|
+
digest.update(b"canonical-spec\0")
|
|
1997
|
+
digest.update(
|
|
1998
|
+
json.dumps(
|
|
1999
|
+
{
|
|
2000
|
+
"source": task.get("spec_source"),
|
|
2001
|
+
"selected_tasks": task.get("selected_spec_tasks"),
|
|
2002
|
+
},
|
|
2003
|
+
ensure_ascii=False,
|
|
2004
|
+
sort_keys=True,
|
|
2005
|
+
separators=(",", ":"),
|
|
2006
|
+
).encode("utf-8")
|
|
2007
|
+
)
|
|
2008
|
+
digest.update(b"\0")
|
|
1468
2009
|
update_git_worktree_fingerprint(digest, root, task, plan)
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
)
|
|
1478
|
-
for file_name in file_names:
|
|
2010
|
+
repo_paths = task.get("repo_paths") if task else None
|
|
2011
|
+
file_entries: set[tuple[str, str | None]] = {
|
|
2012
|
+
(str(file_name), str(unit.get("repo_id")) if unit.get("repo_id") else None)
|
|
2013
|
+
for unit in plan.get("units", [])
|
|
2014
|
+
if isinstance(unit, dict)
|
|
2015
|
+
for file_name in unit.get("files", [])
|
|
2016
|
+
if is_non_empty_string(file_name)
|
|
2017
|
+
}
|
|
2018
|
+
for file_name, repo_id in sorted(file_entries, key=lambda item: (item[0], item[1] or "")):
|
|
1479
2019
|
candidate = Path(file_name)
|
|
1480
2020
|
was_absolute = candidate.is_absolute()
|
|
2021
|
+
base = root
|
|
2022
|
+
if (
|
|
2023
|
+
task
|
|
2024
|
+
and isinstance(task.get("spec_source"), dict)
|
|
2025
|
+
and isinstance(repo_paths, dict)
|
|
2026
|
+
and repo_id
|
|
2027
|
+
and is_non_empty_string(repo_paths.get(repo_id))
|
|
2028
|
+
):
|
|
2029
|
+
raw_base = Path(str(repo_paths[repo_id]))
|
|
2030
|
+
base = raw_base if raw_base.is_absolute() else root / raw_base
|
|
1481
2031
|
if not was_absolute:
|
|
1482
|
-
candidate =
|
|
2032
|
+
candidate = base / candidate
|
|
1483
2033
|
resolved = candidate.resolve()
|
|
1484
2034
|
if not was_absolute:
|
|
1485
2035
|
try:
|
|
1486
|
-
resolved.relative_to(
|
|
2036
|
+
resolved.relative_to(base.resolve())
|
|
1487
2037
|
except ValueError as error:
|
|
1488
|
-
raise StateError(f"Execution plan file escapes
|
|
1489
|
-
digest.update(file_name.encode("utf-8"))
|
|
2038
|
+
raise StateError(f"Execution plan file escapes repository: {file_name}") from error
|
|
2039
|
+
digest.update(f"{repo_id or ''}:{file_name}".encode("utf-8"))
|
|
1490
2040
|
digest.update(b"\0")
|
|
1491
2041
|
try:
|
|
1492
2042
|
digest.update(resolved.read_bytes())
|
|
@@ -1496,25 +2046,167 @@ def implementation_fingerprint(root: Path, task_id: str) -> str:
|
|
|
1496
2046
|
return digest.hexdigest()
|
|
1497
2047
|
|
|
1498
2048
|
|
|
1499
|
-
def
|
|
2049
|
+
def config_without_frozen_tdd_settings(payload: bytes) -> bytes:
|
|
2050
|
+
"""任务冻结 TDD 契约后,从证据指纹中排除仅影响未来任务的实时 TDD 配置。"""
|
|
2051
|
+
try:
|
|
2052
|
+
lines = payload.decode("utf-8").splitlines(keepends=True)
|
|
2053
|
+
except UnicodeDecodeError:
|
|
2054
|
+
return payload
|
|
2055
|
+
filtered: list[str] = []
|
|
2056
|
+
in_behavior = False
|
|
2057
|
+
behavior_indent = 0
|
|
2058
|
+
behavior_key_indent: int | None = None
|
|
2059
|
+
for line in lines:
|
|
2060
|
+
clean = line.split("#", 1)[0].rstrip()
|
|
2061
|
+
stripped = clean.strip()
|
|
2062
|
+
indent = len(clean) - len(clean.lstrip(" "))
|
|
2063
|
+
if stripped == "behavior:":
|
|
2064
|
+
in_behavior = True
|
|
2065
|
+
behavior_indent = indent
|
|
2066
|
+
behavior_key_indent = None
|
|
2067
|
+
filtered.append(line)
|
|
2068
|
+
continue
|
|
2069
|
+
if in_behavior and stripped and indent <= behavior_indent:
|
|
2070
|
+
in_behavior = False
|
|
2071
|
+
if in_behavior and stripped:
|
|
2072
|
+
if behavior_key_indent is None:
|
|
2073
|
+
behavior_key_indent = indent
|
|
2074
|
+
key = stripped.split(":", 1)[0]
|
|
2075
|
+
if (
|
|
2076
|
+
indent == behavior_key_indent
|
|
2077
|
+
and key in {"tdd_enabled", "tdd_coverage_threshold"}
|
|
2078
|
+
):
|
|
2079
|
+
continue
|
|
2080
|
+
filtered.append(line)
|
|
2081
|
+
return "".join(filtered).encode("utf-8")
|
|
2082
|
+
|
|
2083
|
+
|
|
2084
|
+
def behavior_config_fingerprint(root: Path, task: dict | None = None) -> str:
|
|
1500
2085
|
path = root / ".easy-coding" / "config.yaml"
|
|
1501
2086
|
digest = hashlib.sha256()
|
|
1502
2087
|
try:
|
|
1503
|
-
|
|
2088
|
+
payload = path.read_bytes()
|
|
2089
|
+
if task and isinstance(task.get("tdd_enabled"), bool):
|
|
2090
|
+
payload = config_without_frozen_tdd_settings(payload)
|
|
2091
|
+
digest.update(payload)
|
|
1504
2092
|
except OSError:
|
|
1505
2093
|
digest.update(b"<missing-config>")
|
|
1506
2094
|
return digest.hexdigest()
|
|
1507
2095
|
|
|
1508
2096
|
|
|
1509
2097
|
def evidence_fingerprints(root: Path, task_id: str) -> dict[str, str]:
|
|
2098
|
+
task = load_task(root, task_id)
|
|
1510
2099
|
return {
|
|
1511
2100
|
"implementation_fingerprint": implementation_fingerprint(root, task_id),
|
|
1512
|
-
"config_fingerprint": behavior_config_fingerprint(root),
|
|
2101
|
+
"config_fingerprint": behavior_config_fingerprint(root, task),
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
|
|
2105
|
+
def command_option_value(command: str, option: str) -> str | None:
|
|
2106
|
+
try:
|
|
2107
|
+
tokens = shlex.split(command)
|
|
2108
|
+
except ValueError:
|
|
2109
|
+
return None
|
|
2110
|
+
for index, token in enumerate(tokens):
|
|
2111
|
+
if token == option and index + 1 < len(tokens):
|
|
2112
|
+
return tokens[index + 1]
|
|
2113
|
+
prefix = f"{option}="
|
|
2114
|
+
if token.startswith(prefix):
|
|
2115
|
+
return token[len(prefix) :]
|
|
2116
|
+
return None
|
|
2117
|
+
|
|
2118
|
+
|
|
2119
|
+
def coverage_command_matches_frozen_contract(
|
|
2120
|
+
command: object, baseline: str, threshold: int
|
|
2121
|
+
) -> bool:
|
|
2122
|
+
if not is_non_empty_string(command):
|
|
2123
|
+
return False
|
|
2124
|
+
try:
|
|
2125
|
+
tokens = shlex.split(str(command))
|
|
2126
|
+
except ValueError:
|
|
2127
|
+
return False
|
|
2128
|
+
return (
|
|
2129
|
+
any(Path(token).name == "easy_coding_java_coverage.py" for token in tokens)
|
|
2130
|
+
and "check" in tokens
|
|
2131
|
+
and command_option_value(str(command), "--base") == baseline
|
|
2132
|
+
and command_option_value(str(command), "--threshold") == str(threshold)
|
|
2133
|
+
)
|
|
2134
|
+
|
|
2135
|
+
|
|
2136
|
+
def validate_spec_implementation_results(root: Path, task_id: str, task: dict) -> None:
|
|
2137
|
+
if not isinstance(task.get("spec_source"), dict):
|
|
2138
|
+
return
|
|
2139
|
+
plan = latest_execution_plan(root, task_id)
|
|
2140
|
+
if plan is None or not is_valid_spec_execution_plan(root, task, plan):
|
|
2141
|
+
raise StateError("Canonical Spec implementation has no valid source-traceable plan.")
|
|
2142
|
+
unit_by_id = {
|
|
2143
|
+
str(unit["id"]): unit for unit in plan.get("units", []) if isinstance(unit, dict)
|
|
1513
2144
|
}
|
|
2145
|
+
records = execution_records(root, task_id)
|
|
2146
|
+
latest_plan_index = max(
|
|
2147
|
+
(index for index, record in enumerate(records) if record.get("type") == "plan"),
|
|
2148
|
+
default=-1,
|
|
2149
|
+
)
|
|
2150
|
+
lifecycle_by_unit: dict[str, list[dict]] = {unit_id: [] for unit_id in unit_by_id}
|
|
2151
|
+
for record in records[latest_plan_index + 1 :]:
|
|
2152
|
+
unit_id = str(record.get("unit_id") or "")
|
|
2153
|
+
if record.get("type") in {"dispatch", "result"} and unit_id in unit_by_id:
|
|
2154
|
+
lifecycle_by_unit[unit_id].append(record)
|
|
2155
|
+
missing_dispatches = sorted(
|
|
2156
|
+
unit_id
|
|
2157
|
+
for unit_id, lifecycle in lifecycle_by_unit.items()
|
|
2158
|
+
if not any(record.get("type") == "dispatch" for record in lifecycle)
|
|
2159
|
+
)
|
|
2160
|
+
if missing_dispatches:
|
|
2161
|
+
raise StateError(
|
|
2162
|
+
"Canonical Spec implementation is missing dispatch records for units: "
|
|
2163
|
+
+ ", ".join(missing_dispatches)
|
|
2164
|
+
)
|
|
2165
|
+
missing_results = sorted(
|
|
2166
|
+
unit_id
|
|
2167
|
+
for unit_id, lifecycle in lifecycle_by_unit.items()
|
|
2168
|
+
if not lifecycle or lifecycle[-1].get("type") != "result"
|
|
2169
|
+
)
|
|
2170
|
+
if missing_results:
|
|
2171
|
+
raise StateError(
|
|
2172
|
+
"Canonical Spec implementation is missing result records for units: "
|
|
2173
|
+
+ ", ".join(missing_results)
|
|
2174
|
+
)
|
|
2175
|
+
for unit_id, unit in unit_by_id.items():
|
|
2176
|
+
lifecycle = lifecycle_by_unit[unit_id]
|
|
2177
|
+
if len(lifecycle) < 2 or lifecycle[-2].get("type") != "dispatch":
|
|
2178
|
+
raise StateError(
|
|
2179
|
+
f"Canonical Spec result {unit_id} has no matching preceding dispatch record."
|
|
2180
|
+
)
|
|
2181
|
+
dispatch = lifecycle[-2]
|
|
2182
|
+
if (
|
|
2183
|
+
dispatch.get("repo_id") != unit.get("repo_id")
|
|
2184
|
+
or dispatch.get("source_task_id") != unit.get("source_task_id")
|
|
2185
|
+
):
|
|
2186
|
+
raise StateError(
|
|
2187
|
+
f"Canonical Spec dispatch {unit_id} must preserve repository/source-task ownership."
|
|
2188
|
+
)
|
|
2189
|
+
result = lifecycle[-1]
|
|
2190
|
+
if (
|
|
2191
|
+
result.get("repo_id") != unit.get("repo_id")
|
|
2192
|
+
or result.get("source_task_id") != unit.get("source_task_id")
|
|
2193
|
+
or result.get("status") != "completed"
|
|
2194
|
+
or not isinstance(result.get("changed_files"), list)
|
|
2195
|
+
or not set(result.get("changed_files", [])).issubset(set(unit.get("files", [])))
|
|
2196
|
+
or not is_non_empty_string(result.get("summary"))
|
|
2197
|
+
or result.get("issues") != []
|
|
2198
|
+
or result.get("needs_attention") != []
|
|
2199
|
+
):
|
|
2200
|
+
raise StateError(
|
|
2201
|
+
f"Canonical Spec result {unit_id} must be completed without unresolved issues, "
|
|
2202
|
+
"preserve repository/source-task ownership, and remain within the Unit file scope."
|
|
2203
|
+
)
|
|
1514
2204
|
|
|
1515
2205
|
|
|
1516
2206
|
def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
|
|
1517
|
-
|
|
2207
|
+
validate_spec_implementation_results(root, task_id, task)
|
|
2208
|
+
is_spec_task = isinstance(task.get("spec_source"), dict)
|
|
2209
|
+
if task.get("workflow_mode_legacy") is True and not is_spec_task:
|
|
1518
2210
|
return
|
|
1519
2211
|
expected = implementation_fingerprint(root, task_id)
|
|
1520
2212
|
latest_by_dimension: dict[str, dict] = {}
|
|
@@ -1524,7 +2216,10 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
|
|
|
1524
2216
|
and record.get("implementation_fingerprint") == expected
|
|
1525
2217
|
and is_non_empty_string(record.get("dimension"))
|
|
1526
2218
|
):
|
|
1527
|
-
|
|
2219
|
+
dimension = str(record["dimension"])
|
|
2220
|
+
source_task_id = str(record.get("source_task_id") or "")
|
|
2221
|
+
record_key = f"{dimension}\0{source_task_id}" if is_spec_task else dimension
|
|
2222
|
+
latest_by_dimension[record_key] = record
|
|
1528
2223
|
if not latest_by_dimension:
|
|
1529
2224
|
raise StateError(
|
|
1530
2225
|
"REVIEW cannot advance to VERIFICATION without a review record for the current implementation fingerprint."
|
|
@@ -1543,6 +2238,42 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
|
|
|
1543
2238
|
"Each review finding must include a non-empty file and issue, a positive integer "
|
|
1544
2239
|
"line, and severity error, warning, or info."
|
|
1545
2240
|
)
|
|
2241
|
+
if is_spec_task:
|
|
2242
|
+
plan = latest_execution_plan(root, task_id) or {}
|
|
2243
|
+
task_repositories = {
|
|
2244
|
+
str(unit.get("source_task_id")): str(unit.get("repo_id"))
|
|
2245
|
+
for unit in plan.get("units", [])
|
|
2246
|
+
if isinstance(unit, dict)
|
|
2247
|
+
and is_non_empty_string(unit.get("source_task_id"))
|
|
2248
|
+
and is_non_empty_string(unit.get("repo_id"))
|
|
2249
|
+
}
|
|
2250
|
+
reviewed_dimensions: dict[str, set[str]] = {
|
|
2251
|
+
source_task_id: set() for source_task_id in task_repositories
|
|
2252
|
+
}
|
|
2253
|
+
for record in latest_by_dimension.values():
|
|
2254
|
+
source_task_id = str(record.get("source_task_id") or "")
|
|
2255
|
+
repo_id = str(record.get("repo_id") or "")
|
|
2256
|
+
if source_task_id not in task_repositories or repo_id != task_repositories[source_task_id]:
|
|
2257
|
+
raise StateError(
|
|
2258
|
+
"Canonical Spec review evidence must preserve repository/source-task ownership."
|
|
2259
|
+
)
|
|
2260
|
+
for finding in record["findings"]:
|
|
2261
|
+
finding_path = Path(str(finding["file"]))
|
|
2262
|
+
if finding_path.is_absolute() or ".." in finding_path.parts:
|
|
2263
|
+
raise StateError(
|
|
2264
|
+
"Canonical Spec review findings must use safe repository-relative paths."
|
|
2265
|
+
)
|
|
2266
|
+
reviewed_dimensions[source_task_id].add(str(record["dimension"]))
|
|
2267
|
+
missing_review_tasks = sorted(
|
|
2268
|
+
source_task_id
|
|
2269
|
+
for source_task_id, dimensions in reviewed_dimensions.items()
|
|
2270
|
+
if not dimensions
|
|
2271
|
+
)
|
|
2272
|
+
if missing_review_tasks:
|
|
2273
|
+
raise StateError(
|
|
2274
|
+
"Canonical Spec review evidence does not cover selected source tasks: "
|
|
2275
|
+
+ ", ".join(missing_review_tasks)
|
|
2276
|
+
)
|
|
1546
2277
|
has_failed_dimension = False
|
|
1547
2278
|
for record in latest_by_dimension.values():
|
|
1548
2279
|
findings = record.get("findings")
|
|
@@ -1558,16 +2289,48 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
|
|
|
1558
2289
|
raise StateError(
|
|
1559
2290
|
"REVIEW cannot advance to VERIFICATION while a current review dimension is not passed or has error findings."
|
|
1560
2291
|
)
|
|
1561
|
-
if task.get("
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
2292
|
+
if task.get("tdd_enabled") is True:
|
|
2293
|
+
if is_spec_task:
|
|
2294
|
+
missing_tdd_reviews = sorted(
|
|
2295
|
+
source_task_id
|
|
2296
|
+
for source_task_id, dimensions in reviewed_dimensions.items()
|
|
2297
|
+
if "tdd" not in {dimension.lower() for dimension in dimensions}
|
|
2298
|
+
)
|
|
2299
|
+
if missing_tdd_reviews:
|
|
2300
|
+
raise StateError(
|
|
2301
|
+
"TDD tasks require a passed TDD review dimension for every selected source task: "
|
|
2302
|
+
+ ", ".join(missing_tdd_reviews)
|
|
2303
|
+
)
|
|
2304
|
+
elif not any(
|
|
2305
|
+
str(record.get("dimension") or "").lower() == "tdd"
|
|
2306
|
+
for record in latest_by_dimension.values()
|
|
2307
|
+
):
|
|
2308
|
+
raise StateError(
|
|
2309
|
+
"TDD tasks require a passed TDD review dimension for test quality, boundaries, and mocking."
|
|
2310
|
+
)
|
|
2311
|
+
if task.get("workflow_mode") == "strict":
|
|
2312
|
+
if is_spec_task:
|
|
2313
|
+
missing_strict_dimensions = sorted(
|
|
2314
|
+
source_task_id
|
|
2315
|
+
for source_task_id, dimensions in reviewed_dimensions.items()
|
|
2316
|
+
if len(dimensions) < 2
|
|
2317
|
+
)
|
|
2318
|
+
if missing_strict_dimensions:
|
|
2319
|
+
raise StateError(
|
|
2320
|
+
"Strict Canonical Spec review requires at least two passed dimensions for "
|
|
2321
|
+
"every selected source task: " + ", ".join(missing_strict_dimensions)
|
|
2322
|
+
)
|
|
2323
|
+
elif len(latest_by_dimension) < 2:
|
|
2324
|
+
raise StateError(
|
|
2325
|
+
"Strict workflow requires at least two passed review dimensions for the current implementation fingerprint."
|
|
2326
|
+
)
|
|
1565
2327
|
|
|
1566
2328
|
|
|
1567
2329
|
def validate_verification_readiness(root: Path, task_id: str, task: dict) -> None:
|
|
1568
2330
|
fingerprints = evidence_fingerprints(root, task_id)
|
|
2331
|
+
is_spec_task = isinstance(task.get("spec_source"), dict)
|
|
1569
2332
|
if (
|
|
1570
|
-
task.get("workflow_mode_legacy") is not True
|
|
2333
|
+
(task.get("workflow_mode_legacy") is not True or is_spec_task)
|
|
1571
2334
|
and task.get("workflow_mode_legacy_review_bypass_fingerprint")
|
|
1572
2335
|
!= fingerprints["implementation_fingerprint"]
|
|
1573
2336
|
):
|
|
@@ -1582,6 +2345,10 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
1582
2345
|
and is_non_empty_string(record.get("check"))
|
|
1583
2346
|
):
|
|
1584
2347
|
check = str(record["check"])
|
|
2348
|
+
if task.get("tdd_enabled") is True and record.get("check_type") == "coverage":
|
|
2349
|
+
check = f"{check}\0{record.get('coverage_scope') or ''}"
|
|
2350
|
+
if is_spec_task:
|
|
2351
|
+
check = f"{check}\0{record.get('source_task_id') or ''}"
|
|
1585
2352
|
previous = latest_by_check.get(check)
|
|
1586
2353
|
if (
|
|
1587
2354
|
record.get("applicable") is False
|
|
@@ -1594,11 +2361,11 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
1594
2361
|
raise StateError(
|
|
1595
2362
|
"VERIFICATION cannot advance to MEMORY without verification evidence for the current implementation and config fingerprints."
|
|
1596
2363
|
)
|
|
1597
|
-
if task.get("workflow_mode_legacy") is not True:
|
|
2364
|
+
if task.get("workflow_mode_legacy") is not True or is_spec_task:
|
|
1598
2365
|
for record in latest_by_check.values():
|
|
1599
2366
|
check_type = str(record.get("check_type") or "")
|
|
1600
2367
|
if (
|
|
1601
|
-
check_type not in STRICT_VERIFICATION_CHECK_TYPES
|
|
2368
|
+
check_type not in STRICT_VERIFICATION_CHECK_TYPES | {"coverage"}
|
|
1602
2369
|
or not is_non_empty_string(record.get("timestamp"))
|
|
1603
2370
|
or (
|
|
1604
2371
|
record.get("applicable") is not False
|
|
@@ -1614,6 +2381,25 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
1614
2381
|
raise StateError(
|
|
1615
2382
|
"Verification evidence marked not applicable must include a non-empty not_applicable_reason."
|
|
1616
2383
|
)
|
|
2384
|
+
if is_spec_task:
|
|
2385
|
+
plan = latest_execution_plan(root, task_id) or {}
|
|
2386
|
+
task_repositories = {
|
|
2387
|
+
str(unit.get("source_task_id")): str(unit.get("repo_id"))
|
|
2388
|
+
for unit in plan.get("units", [])
|
|
2389
|
+
if isinstance(unit, dict)
|
|
2390
|
+
and is_non_empty_string(unit.get("source_task_id"))
|
|
2391
|
+
and is_non_empty_string(unit.get("repo_id"))
|
|
2392
|
+
}
|
|
2393
|
+
for record in latest_by_check.values():
|
|
2394
|
+
source_task_id = str(record.get("source_task_id") or "")
|
|
2395
|
+
if (
|
|
2396
|
+
source_task_id not in task_repositories
|
|
2397
|
+
or record.get("repo_id") != task_repositories[source_task_id]
|
|
2398
|
+
):
|
|
2399
|
+
raise StateError(
|
|
2400
|
+
"Canonical Spec verification evidence must preserve "
|
|
2401
|
+
"repository/source-task ownership."
|
|
2402
|
+
)
|
|
1617
2403
|
applicable_records = [
|
|
1618
2404
|
record for record in latest_by_check.values() if record.get("applicable") is not False
|
|
1619
2405
|
]
|
|
@@ -1625,27 +2411,243 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
1625
2411
|
raise StateError(
|
|
1626
2412
|
"VERIFICATION cannot advance to MEMORY while current verification evidence contains failures."
|
|
1627
2413
|
)
|
|
1628
|
-
if task.get("
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
2414
|
+
if task.get("tdd_enabled") is not True and any(
|
|
2415
|
+
record.get("check_type") == "coverage" for record in latest_by_check.values()
|
|
2416
|
+
):
|
|
2417
|
+
raise StateError(
|
|
2418
|
+
"Coverage verification evidence is not allowed when the frozen TDD mode is off."
|
|
2419
|
+
)
|
|
2420
|
+
if task.get("tdd_enabled") is True:
|
|
2421
|
+
coverage_records = [
|
|
2422
|
+
record
|
|
2423
|
+
for record in latest_by_check.values()
|
|
2424
|
+
if record.get("check_type") == "coverage"
|
|
2425
|
+
]
|
|
2426
|
+
if not coverage_records:
|
|
1636
2427
|
raise StateError(
|
|
1637
|
-
"
|
|
1638
|
-
+ ", ".join(missing_types)
|
|
1639
|
-
+ "."
|
|
2428
|
+
"TDD verification requires changed-production-line JaCoCo coverage evidence."
|
|
1640
2429
|
)
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
record.get("
|
|
2430
|
+
if is_spec_task:
|
|
2431
|
+
covered_source_tasks = {
|
|
2432
|
+
str(record.get("source_task_id") or "") for record in coverage_records
|
|
2433
|
+
}
|
|
2434
|
+
missing_coverage_tasks = sorted(
|
|
2435
|
+
set(task_repositories) - covered_source_tasks
|
|
2436
|
+
)
|
|
2437
|
+
if missing_coverage_tasks:
|
|
2438
|
+
raise StateError(
|
|
2439
|
+
"TDD Canonical verification requires separate coverage evidence for every selected source task: "
|
|
2440
|
+
+ ", ".join(missing_coverage_tasks)
|
|
2441
|
+
)
|
|
2442
|
+
coverage_scopes_by_owner: dict[str, set[str]] = {}
|
|
2443
|
+
for record in coverage_records:
|
|
2444
|
+
scope = str(record.get("coverage_scope") or "")
|
|
2445
|
+
if scope not in {"local", "gitlab"}:
|
|
2446
|
+
raise StateError(
|
|
2447
|
+
"TDD coverage evidence must identify coverage_scope as local or gitlab."
|
|
2448
|
+
)
|
|
2449
|
+
owner = (
|
|
2450
|
+
str(record.get("source_task_id") or "")
|
|
2451
|
+
if is_spec_task
|
|
2452
|
+
else "project"
|
|
2453
|
+
)
|
|
2454
|
+
coverage_scopes_by_owner.setdefault(owner, set()).add(scope)
|
|
2455
|
+
expected_threshold = task.get("tdd_coverage_threshold")
|
|
2456
|
+
expected_baselines = task.get("tdd_baselines")
|
|
2457
|
+
if (
|
|
2458
|
+
type(expected_threshold) is not int
|
|
2459
|
+
or expected_threshold < 1
|
|
2460
|
+
or expected_threshold > 100
|
|
2461
|
+
):
|
|
2462
|
+
raise StateError("TDD task is missing a valid frozen coverage threshold.")
|
|
2463
|
+
if not isinstance(expected_baselines, dict) or not expected_baselines:
|
|
2464
|
+
raise StateError("TDD task is missing frozen Git baselines.")
|
|
2465
|
+
for record in coverage_records:
|
|
2466
|
+
coverage = record.get("coverage")
|
|
2467
|
+
if not isinstance(coverage, dict):
|
|
2468
|
+
raise StateError("TDD coverage evidence must include the coverage result object.")
|
|
2469
|
+
if record.get("coverage_scope") == "gitlab":
|
|
2470
|
+
ci = record.get("ci")
|
|
2471
|
+
if (
|
|
2472
|
+
not isinstance(ci, dict)
|
|
2473
|
+
or ci.get("provider") != "gitlab"
|
|
2474
|
+
or ci.get("status") != "success"
|
|
2475
|
+
or not is_non_empty_string(ci.get("pipeline_url"))
|
|
2476
|
+
or not is_non_empty_string(ci.get("job_name"))
|
|
2477
|
+
):
|
|
2478
|
+
raise StateError(
|
|
2479
|
+
"GitLab coverage evidence requires a successful pipeline URL and job name."
|
|
2480
|
+
)
|
|
2481
|
+
total = coverage.get("total_lines")
|
|
2482
|
+
covered = coverage.get("covered_lines")
|
|
2483
|
+
percentage = coverage.get("percentage")
|
|
2484
|
+
threshold = coverage.get("threshold")
|
|
2485
|
+
baseline_key = str(record.get("repo_id") or "") if is_spec_task else "project"
|
|
2486
|
+
expected_baseline = expected_baselines.get(baseline_key)
|
|
2487
|
+
if (
|
|
2488
|
+
not is_non_empty_string(expected_baseline)
|
|
2489
|
+
or coverage.get("baseline_sha") != expected_baseline
|
|
2490
|
+
or re.fullmatch(
|
|
2491
|
+
r"[0-9a-f]{40}|[0-9a-f]{64}", str(coverage.get("baseline_sha") or "")
|
|
2492
|
+
)
|
|
2493
|
+
is None
|
|
2494
|
+
or not isinstance(total, int)
|
|
2495
|
+
or not isinstance(covered, int)
|
|
2496
|
+
or not isinstance(percentage, (int, float))
|
|
2497
|
+
or threshold != expected_threshold
|
|
2498
|
+
or not isinstance(coverage.get("report_paths"), list)
|
|
2499
|
+
or not coverage.get("report_paths")
|
|
2500
|
+
or not all(
|
|
2501
|
+
is_non_empty_string(path) for path in coverage.get("report_paths", [])
|
|
2502
|
+
)
|
|
2503
|
+
or not re.fullmatch(
|
|
2504
|
+
r"[0-9a-f]{64}", str(coverage.get("report_sha256") or "")
|
|
2505
|
+
)
|
|
2506
|
+
or covered < 0
|
|
2507
|
+
or total < 0
|
|
2508
|
+
or covered > total
|
|
2509
|
+
or percentage < 0
|
|
2510
|
+
or percentage > 100
|
|
2511
|
+
or not coverage_command_matches_frozen_contract(
|
|
2512
|
+
record.get("command"), str(expected_baseline), int(expected_threshold)
|
|
2513
|
+
)
|
|
1644
2514
|
):
|
|
1645
2515
|
raise StateError(
|
|
1646
|
-
"
|
|
1647
|
-
|
|
2516
|
+
"TDD coverage evidence must preserve the exact gate command, baseline, counts, percentage, frozen threshold, reports, and report fingerprint."
|
|
2517
|
+
)
|
|
2518
|
+
if total == 0:
|
|
2519
|
+
if record.get("applicable") is not False or record.get("passed") is not True:
|
|
2520
|
+
raise StateError(
|
|
2521
|
+
"Coverage with no modified executable production Java lines must be explicit N/A."
|
|
2522
|
+
)
|
|
2523
|
+
elif abs(percentage - round(covered * 100.0 / total, 2)) > 0.01:
|
|
2524
|
+
raise StateError(
|
|
2525
|
+
"TDD coverage evidence percentage does not match covered/total counts."
|
|
2526
|
+
)
|
|
2527
|
+
elif (
|
|
2528
|
+
record.get("applicable") is False
|
|
2529
|
+
or record.get("passed") is not True
|
|
2530
|
+
or percentage < threshold
|
|
2531
|
+
):
|
|
2532
|
+
raise StateError(
|
|
2533
|
+
f"TDD changed-line coverage must meet the frozen {threshold}% threshold."
|
|
2534
|
+
)
|
|
2535
|
+
expected_coverage_owners = set(task_repositories) if is_spec_task else {"project"}
|
|
2536
|
+
missing_scopes = [
|
|
2537
|
+
f"{owner}:{scope}"
|
|
2538
|
+
for owner in sorted(expected_coverage_owners)
|
|
2539
|
+
for scope in ("local", "gitlab")
|
|
2540
|
+
if scope not in coverage_scopes_by_owner.get(owner, set())
|
|
2541
|
+
]
|
|
2542
|
+
if missing_scopes:
|
|
2543
|
+
raise StateError(
|
|
2544
|
+
"TDD verification requires both local and successful GitLab coverage gates: "
|
|
2545
|
+
+ ", ".join(missing_scopes)
|
|
2546
|
+
)
|
|
2547
|
+
if task.get("workflow_mode") == "strict":
|
|
2548
|
+
if is_spec_task:
|
|
2549
|
+
check_types_by_repository: dict[str, set[str]] = {
|
|
2550
|
+
repo_id: set() for repo_id in set(task_repositories.values())
|
|
2551
|
+
}
|
|
2552
|
+
for record in latest_by_check.values():
|
|
2553
|
+
repo_id = str(record.get("repo_id") or "")
|
|
2554
|
+
check_type = str(record.get("check_type") or "")
|
|
2555
|
+
if repo_id in check_types_by_repository and check_type in STRICT_VERIFICATION_CHECK_TYPES:
|
|
2556
|
+
check_types_by_repository[repo_id].add(check_type)
|
|
2557
|
+
missing_by_repository = {
|
|
2558
|
+
repo_id: sorted(STRICT_VERIFICATION_CHECK_TYPES - check_types)
|
|
2559
|
+
for repo_id, check_types in check_types_by_repository.items()
|
|
2560
|
+
if check_types != STRICT_VERIFICATION_CHECK_TYPES
|
|
2561
|
+
}
|
|
2562
|
+
if missing_by_repository:
|
|
2563
|
+
raise StateError(
|
|
2564
|
+
"Strict Canonical Spec verification requires every repository to cover "
|
|
2565
|
+
"lint, typecheck, test, and build: "
|
|
2566
|
+
+ "; ".join(
|
|
2567
|
+
f"{repo_id} missing {', '.join(check_types)}"
|
|
2568
|
+
for repo_id, check_types in sorted(missing_by_repository.items())
|
|
2569
|
+
)
|
|
1648
2570
|
)
|
|
2571
|
+
else:
|
|
2572
|
+
latest_by_type: dict[str, dict] = {}
|
|
2573
|
+
for record in latest_by_check.values():
|
|
2574
|
+
check_type = str(record.get("check_type") or "")
|
|
2575
|
+
if check_type in STRICT_VERIFICATION_CHECK_TYPES:
|
|
2576
|
+
latest_by_type[check_type] = record
|
|
2577
|
+
missing_types = sorted(STRICT_VERIFICATION_CHECK_TYPES - latest_by_type.keys())
|
|
2578
|
+
if missing_types:
|
|
2579
|
+
raise StateError(
|
|
2580
|
+
"Strict workflow requires current verification evidence for every check type: "
|
|
2581
|
+
+ ", ".join(missing_types)
|
|
2582
|
+
+ "."
|
|
2583
|
+
)
|
|
2584
|
+
for check_type, record in latest_by_type.items():
|
|
2585
|
+
if record.get("applicable") is False and not is_non_empty_string(
|
|
2586
|
+
record.get("not_applicable_reason")
|
|
2587
|
+
):
|
|
2588
|
+
raise StateError(
|
|
2589
|
+
"Strict workflow requires a non-empty not_applicable_reason when "
|
|
2590
|
+
f"{check_type} is marked not applicable."
|
|
2591
|
+
)
|
|
2592
|
+
if is_spec_task:
|
|
2593
|
+
inspect_task_spec(root, task)
|
|
2594
|
+
plan = latest_execution_plan(root, task_id)
|
|
2595
|
+
required_test_commands = {
|
|
2596
|
+
(
|
|
2597
|
+
str(unit.get("source_task_id")),
|
|
2598
|
+
str(unit.get("repo_id")),
|
|
2599
|
+
str(command),
|
|
2600
|
+
)
|
|
2601
|
+
for unit in (plan or {}).get("units", [])
|
|
2602
|
+
if isinstance(unit, dict)
|
|
2603
|
+
for command in unit.get("test_commands", [])
|
|
2604
|
+
if is_non_empty_string(command)
|
|
2605
|
+
}
|
|
2606
|
+
executed_commands = {
|
|
2607
|
+
(
|
|
2608
|
+
str(record.get("source_task_id")),
|
|
2609
|
+
str(record.get("repo_id")),
|
|
2610
|
+
str(record.get("command")),
|
|
2611
|
+
)
|
|
2612
|
+
for record in applicable_records
|
|
2613
|
+
if is_non_empty_string(record.get("command"))
|
|
2614
|
+
}
|
|
2615
|
+
missing_commands = sorted(required_test_commands - executed_commands)
|
|
2616
|
+
if missing_commands:
|
|
2617
|
+
raise StateError(
|
|
2618
|
+
"Canonical Spec verification is missing source test commands: "
|
|
2619
|
+
+ ", ".join(
|
|
2620
|
+
f"{source_task_id}@{repo_id}: {command}"
|
|
2621
|
+
for source_task_id, repo_id, command in missing_commands
|
|
2622
|
+
)
|
|
2623
|
+
)
|
|
2624
|
+
covered_verification_tasks = {
|
|
2625
|
+
str(record.get("source_task_id")) for record in applicable_records
|
|
2626
|
+
}
|
|
2627
|
+
missing_verification_tasks = sorted(
|
|
2628
|
+
set(task_repositories) - covered_verification_tasks
|
|
2629
|
+
)
|
|
2630
|
+
if missing_verification_tasks:
|
|
2631
|
+
raise StateError(
|
|
2632
|
+
"Canonical Spec verification evidence does not cover selected source tasks: "
|
|
2633
|
+
+ ", ".join(missing_verification_tasks)
|
|
2634
|
+
)
|
|
2635
|
+
pending_integration = [
|
|
2636
|
+
record
|
|
2637
|
+
for record in task.get("spec_dependency_evidence", [])
|
|
2638
|
+
if isinstance(record, dict)
|
|
2639
|
+
and record.get("dependency_type") == "integration"
|
|
2640
|
+
and record.get("status") != "satisfied"
|
|
2641
|
+
]
|
|
2642
|
+
if pending_integration:
|
|
2643
|
+
edges = ", ".join(
|
|
2644
|
+
f"{record.get('source_task_id')}->{record.get('task_id')}"
|
|
2645
|
+
for record in pending_integration
|
|
2646
|
+
)
|
|
2647
|
+
raise StateError(
|
|
2648
|
+
"VERIFICATION cannot advance to MEMORY while Canonical Spec integration "
|
|
2649
|
+
f"dependencies are pending: {edges}."
|
|
2650
|
+
)
|
|
1649
2651
|
|
|
1650
2652
|
|
|
1651
2653
|
def validate_read_only_completion(root: Path, task_id: str) -> None:
|
|
@@ -1812,7 +2814,9 @@ def validate_mandatory_dev_spec_sections(content: str) -> tuple[list[str], list[
|
|
|
1812
2814
|
return missing, empty
|
|
1813
2815
|
|
|
1814
2816
|
|
|
1815
|
-
def validate_analysis_readiness(
|
|
2817
|
+
def validate_analysis_readiness(
|
|
2818
|
+
root: Path, task_id: str, session: dict | None = None
|
|
2819
|
+
) -> None:
|
|
1816
2820
|
task_dir = task_json_path(root, task_id).parent
|
|
1817
2821
|
task = load_task(root, task_id)
|
|
1818
2822
|
task_type = str(task.get("type") or "").strip().lower() if task else ""
|
|
@@ -1821,6 +2825,9 @@ def validate_analysis_readiness(root: Path, task_id: str) -> None:
|
|
|
1821
2825
|
skeleton = root / ".easy-coding" / "templates" / "dev-spec-skeleton.md"
|
|
1822
2826
|
test_strategy = task_dir / "test-strategy.md"
|
|
1823
2827
|
reasons: list[str] = []
|
|
2828
|
+
behavior = resolve_behavior(root, session or default_session())
|
|
2829
|
+
tdd_enabled = behavior[8]
|
|
2830
|
+
tdd_threshold = behavior[11]
|
|
1824
2831
|
|
|
1825
2832
|
dev_spec_content = ""
|
|
1826
2833
|
if not dev_spec.exists():
|
|
@@ -1864,8 +2871,201 @@ def validate_analysis_readiness(root: Path, task_id: str) -> None:
|
|
|
1864
2871
|
except OSError:
|
|
1865
2872
|
reasons.append("dev-spec skeleton template cannot be read")
|
|
1866
2873
|
|
|
1867
|
-
|
|
2874
|
+
plan_is_valid = has_valid_execution_plan(root, task_id)
|
|
2875
|
+
if not plan_is_valid:
|
|
1868
2876
|
reasons.append("execution.jsonl has no valid plan record")
|
|
2877
|
+
if tdd_enabled and not is_read_only_task:
|
|
2878
|
+
plan = latest_execution_plan(root, task_id) or {}
|
|
2879
|
+
if re.search(
|
|
2880
|
+
r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
|
|
2881
|
+
) is None:
|
|
2882
|
+
reasons.append("dev-spec.md is missing the required TDD Mode section")
|
|
2883
|
+
planned_files = [
|
|
2884
|
+
str(file_name)
|
|
2885
|
+
for unit in plan.get("units", [])
|
|
2886
|
+
if isinstance(unit, dict)
|
|
2887
|
+
for file_name in unit.get("files", [])
|
|
2888
|
+
]
|
|
2889
|
+
if not any(file_name.endswith(".java") for file_name in planned_files):
|
|
2890
|
+
reasons.append("TDD is enabled but the confirmed implementation scope has no Java source")
|
|
2891
|
+
baselines: dict[str, str] = {}
|
|
2892
|
+
if task:
|
|
2893
|
+
try:
|
|
2894
|
+
repositories = tdd_repositories(root, task, plan)
|
|
2895
|
+
baselines = {
|
|
2896
|
+
repo_id: git_head_sha(repository)
|
|
2897
|
+
for repo_id, repository in repositories.items()
|
|
2898
|
+
}
|
|
2899
|
+
except StateError as error:
|
|
2900
|
+
reasons.append(str(error))
|
|
2901
|
+
try:
|
|
2902
|
+
strategy_content = test_strategy.read_text(encoding="utf-8")
|
|
2903
|
+
except OSError:
|
|
2904
|
+
strategy_content = ""
|
|
2905
|
+
required_tdd_markers = ["TDD", "JaCoCo", "baseline", "GitLab"]
|
|
2906
|
+
missing_tdd_markers = [
|
|
2907
|
+
marker for marker in required_tdd_markers if marker.lower() not in strategy_content.lower()
|
|
2908
|
+
]
|
|
2909
|
+
if missing_tdd_markers:
|
|
2910
|
+
reasons.append(
|
|
2911
|
+
"TDD test strategy is missing: " + ", ".join(missing_tdd_markers)
|
|
2912
|
+
)
|
|
2913
|
+
if not contains_tdd_threshold(strategy_content, tdd_threshold):
|
|
2914
|
+
reasons.append(
|
|
2915
|
+
f"TDD test strategy must state the frozen {tdd_threshold}% coverage threshold"
|
|
2916
|
+
)
|
|
2917
|
+
if not contains_tdd_threshold(dev_spec_content, tdd_threshold):
|
|
2918
|
+
reasons.append(
|
|
2919
|
+
f"TDD dev spec must state the frozen {tdd_threshold}% coverage threshold"
|
|
2920
|
+
)
|
|
2921
|
+
if baselines:
|
|
2922
|
+
reasons.extend(
|
|
2923
|
+
tdd_baseline_marker_reasons(
|
|
2924
|
+
dev_spec_content, strategy_content, baselines
|
|
2925
|
+
)
|
|
2926
|
+
)
|
|
2927
|
+
elif not is_read_only_task:
|
|
2928
|
+
if re.search(
|
|
2929
|
+
r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
|
|
2930
|
+
):
|
|
2931
|
+
reasons.append("dev-spec.md must omit the TDD Mode section when TDD is disabled")
|
|
2932
|
+
try:
|
|
2933
|
+
strategy_content = test_strategy.read_text(encoding="utf-8")
|
|
2934
|
+
except OSError:
|
|
2935
|
+
strategy_content = ""
|
|
2936
|
+
forbidden_tdd_markers = [
|
|
2937
|
+
marker
|
|
2938
|
+
for marker in (
|
|
2939
|
+
"easy_coding_java_coverage.py",
|
|
2940
|
+
"coverage_scope",
|
|
2941
|
+
"task.tdd_baselines",
|
|
2942
|
+
"RED -> GREEN -> REFACTOR",
|
|
2943
|
+
"RED/GREEN/REFACTOR",
|
|
2944
|
+
)
|
|
2945
|
+
if marker.lower() in strategy_content.lower()
|
|
2946
|
+
]
|
|
2947
|
+
if forbidden_tdd_markers:
|
|
2948
|
+
reasons.append(
|
|
2949
|
+
"test-strategy.md contains TDD-only planning while TDD is disabled: "
|
|
2950
|
+
+ ", ".join(forbidden_tdd_markers)
|
|
2951
|
+
)
|
|
2952
|
+
if task and isinstance(task.get("spec_source"), dict):
|
|
2953
|
+
try:
|
|
2954
|
+
inspection, selection = inspect_task_spec(root, task)
|
|
2955
|
+
required_markers = [
|
|
2956
|
+
str(task["spec_source"].get("path") or ""),
|
|
2957
|
+
str(task["spec_source"].get("spec_id") or ""),
|
|
2958
|
+
str(task["spec_source"].get("sha256") or ""),
|
|
2959
|
+
*[str(task_id) for task_id in selection["selected_task_ids"]],
|
|
2960
|
+
*[str(repo_id) for repo_id in selection["selected_repo_ids"]],
|
|
2961
|
+
*[
|
|
2962
|
+
f"{repo_id}={inspection['baseline_status'].get(repo_id)}"
|
|
2963
|
+
for repo_id in selection["selected_repo_ids"]
|
|
2964
|
+
],
|
|
2965
|
+
]
|
|
2966
|
+
missing_markers = [
|
|
2967
|
+
marker
|
|
2968
|
+
for marker in required_markers
|
|
2969
|
+
if marker and not contains_spec_marker(dev_spec_content, marker)
|
|
2970
|
+
]
|
|
2971
|
+
revision = task["spec_source"].get("revision")
|
|
2972
|
+
if (
|
|
2973
|
+
type(revision) is not int
|
|
2974
|
+
or re.search(
|
|
2975
|
+
rf"\brevision\s*[::=]\s*{revision}(?!\d)",
|
|
2976
|
+
dev_spec_content,
|
|
2977
|
+
re.IGNORECASE,
|
|
2978
|
+
)
|
|
2979
|
+
is None
|
|
2980
|
+
):
|
|
2981
|
+
missing_markers.append(f"revision={revision}")
|
|
2982
|
+
if missing_markers:
|
|
2983
|
+
reasons.append(
|
|
2984
|
+
"dev-spec.md is missing Canonical Spec traceability markers: "
|
|
2985
|
+
+ ", ".join(missing_markers)
|
|
2986
|
+
)
|
|
2987
|
+
selected_repo_ids = set(selection["selected_repo_ids"])
|
|
2988
|
+
bindings = task.get("spec_repositories")
|
|
2989
|
+
bound_repo_ids = {
|
|
2990
|
+
str(binding.get("repo_id"))
|
|
2991
|
+
for binding in bindings or []
|
|
2992
|
+
if isinstance(binding, dict)
|
|
2993
|
+
}
|
|
2994
|
+
if bound_repo_ids != selected_repo_ids:
|
|
2995
|
+
reasons.append("spec_repositories do not cover selected Canonical Spec tasks")
|
|
2996
|
+
if inspection.get("unresolved_repositories"):
|
|
2997
|
+
reasons.append(
|
|
2998
|
+
"Canonical Spec repository bindings are unresolved: "
|
|
2999
|
+
+ ", ".join(inspection["unresolved_repositories"])
|
|
3000
|
+
)
|
|
3001
|
+
unavailable_repositories = [
|
|
3002
|
+
repo_id
|
|
3003
|
+
for repo_id in selection["selected_repo_ids"]
|
|
3004
|
+
if inspection["baseline_status"].get(repo_id) == "baseline-unavailable"
|
|
3005
|
+
]
|
|
3006
|
+
if unavailable_repositories:
|
|
3007
|
+
reasons.append(
|
|
3008
|
+
"Canonical Spec baselines are unavailable: "
|
|
3009
|
+
+ ", ".join(unavailable_repositories)
|
|
3010
|
+
)
|
|
3011
|
+
if plan_is_valid:
|
|
3012
|
+
plan = latest_execution_plan(root, task_id)
|
|
3013
|
+
if plan is None:
|
|
3014
|
+
reasons.append("Canonical Spec execution plan cannot be loaded")
|
|
3015
|
+
else:
|
|
3016
|
+
task_repository_scopes(root, task, plan)
|
|
3017
|
+
derived_markers = [
|
|
3018
|
+
*[
|
|
3019
|
+
str(unit.get("id") or "")
|
|
3020
|
+
for unit in plan.get("units", [])
|
|
3021
|
+
if isinstance(unit, dict)
|
|
3022
|
+
],
|
|
3023
|
+
*[
|
|
3024
|
+
str(step_id)
|
|
3025
|
+
for unit in plan.get("units", [])
|
|
3026
|
+
if isinstance(unit, dict)
|
|
3027
|
+
for step_id in unit.get("source_step_ids", [])
|
|
3028
|
+
],
|
|
3029
|
+
*[
|
|
3030
|
+
f"{record.get('source_task_id')}->{record.get('task_id')}"
|
|
3031
|
+
for record in task.get("spec_dependency_evidence", [])
|
|
3032
|
+
if isinstance(record, dict)
|
|
3033
|
+
and record.get("dependency_type") == "integration"
|
|
3034
|
+
and record.get("status") == "pending"
|
|
3035
|
+
],
|
|
3036
|
+
*[
|
|
3037
|
+
str(record.get("required_evidence") or "")
|
|
3038
|
+
for record in task.get("spec_dependency_evidence", [])
|
|
3039
|
+
if isinstance(record, dict)
|
|
3040
|
+
and record.get("dependency_type") == "integration"
|
|
3041
|
+
and record.get("status") == "pending"
|
|
3042
|
+
],
|
|
3043
|
+
]
|
|
3044
|
+
missing_derived_markers = [
|
|
3045
|
+
marker
|
|
3046
|
+
for marker in derived_markers
|
|
3047
|
+
if marker and not contains_spec_marker(dev_spec_content, marker)
|
|
3048
|
+
]
|
|
3049
|
+
if missing_derived_markers:
|
|
3050
|
+
reasons.append(
|
|
3051
|
+
"dev-spec.md is missing Canonical Spec Unit/dependency markers: "
|
|
3052
|
+
+ ", ".join(dict.fromkeys(missing_derived_markers))
|
|
3053
|
+
)
|
|
3054
|
+
if test_strategy.is_file():
|
|
3055
|
+
test_strategy_content = test_strategy.read_text(encoding="utf-8")
|
|
3056
|
+
if test_strategy_content.strip():
|
|
3057
|
+
missing_test_markers = missing_spec_test_strategy_markers(
|
|
3058
|
+
selection, plan, test_strategy_content
|
|
3059
|
+
)
|
|
3060
|
+
if missing_test_markers:
|
|
3061
|
+
reasons.append(
|
|
3062
|
+
"test-strategy.md is missing Canonical Spec markers: "
|
|
3063
|
+
+ ", ".join(missing_test_markers)
|
|
3064
|
+
)
|
|
3065
|
+
except StateError as exc:
|
|
3066
|
+
reasons.append(str(exc))
|
|
3067
|
+
except OSError:
|
|
3068
|
+
reasons.append("test-strategy.md cannot be read")
|
|
1869
3069
|
if is_read_only_task:
|
|
1870
3070
|
if test_strategy.exists():
|
|
1871
3071
|
reasons.append("read-only task must not create test-strategy.md")
|
|
@@ -1921,6 +3121,28 @@ def get_pending_init_version(root: Path) -> str | None:
|
|
|
1921
3121
|
return None
|
|
1922
3122
|
|
|
1923
3123
|
|
|
3124
|
+
def spec_task_summary(task: dict | None) -> dict | None:
|
|
3125
|
+
if not task or not isinstance(task.get("spec_source"), dict):
|
|
3126
|
+
return None
|
|
3127
|
+
dependencies = task.get("spec_dependency_evidence")
|
|
3128
|
+
pending_dependencies = [
|
|
3129
|
+
{
|
|
3130
|
+
"source_task_id": record.get("source_task_id"),
|
|
3131
|
+
"task_id": record.get("task_id"),
|
|
3132
|
+
"dependency_type": record.get("dependency_type"),
|
|
3133
|
+
"required_evidence": record.get("required_evidence"),
|
|
3134
|
+
}
|
|
3135
|
+
for record in dependencies or []
|
|
3136
|
+
if isinstance(record, dict) and record.get("status") == "pending"
|
|
3137
|
+
]
|
|
3138
|
+
return {
|
|
3139
|
+
"source": task["spec_source"],
|
|
3140
|
+
"selected_spec_tasks": task.get("selected_spec_tasks", []),
|
|
3141
|
+
"repositories": task.get("spec_repositories", []),
|
|
3142
|
+
"pending_dependencies": pending_dependencies,
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
|
|
1924
3146
|
def transition_requires_confirmation(
|
|
1925
3147
|
previous: str,
|
|
1926
3148
|
current: str,
|
|
@@ -2012,6 +3234,12 @@ def snapshot_state(
|
|
|
2012
3234
|
project_workflow_mode,
|
|
2013
3235
|
session_workflow_mode,
|
|
2014
3236
|
configured_workflow_mode,
|
|
3237
|
+
project_tdd_enabled,
|
|
3238
|
+
session_tdd_enabled,
|
|
3239
|
+
effective_tdd_enabled,
|
|
3240
|
+
project_tdd_coverage_threshold,
|
|
3241
|
+
session_tdd_coverage_threshold,
|
|
3242
|
+
effective_tdd_coverage_threshold,
|
|
2015
3243
|
) = resolve_behavior(root, resolved_session)
|
|
2016
3244
|
concrete_workflow_mode = None
|
|
2017
3245
|
if task:
|
|
@@ -2019,6 +3247,19 @@ def snapshot_state(
|
|
|
2019
3247
|
proposal = task.get("workflow_mode_proposal")
|
|
2020
3248
|
if concrete_workflow_mode is None and isinstance(proposal, dict):
|
|
2021
3249
|
concrete_workflow_mode = proposal.get("selected_mode")
|
|
3250
|
+
task_tdd_enabled = task.get("tdd_enabled") if task else None
|
|
3251
|
+
task_tdd_coverage_threshold = task.get("tdd_coverage_threshold") if task else None
|
|
3252
|
+
frozen_tdd = bool(
|
|
3253
|
+
task
|
|
3254
|
+
and status not in {"ANALYSIS", "INIT"}
|
|
3255
|
+
and isinstance(task_tdd_enabled, bool)
|
|
3256
|
+
)
|
|
3257
|
+
displayed_tdd_enabled = task_tdd_enabled if frozen_tdd else effective_tdd_enabled
|
|
3258
|
+
displayed_tdd_threshold = (
|
|
3259
|
+
task_tdd_coverage_threshold
|
|
3260
|
+
if frozen_tdd and isinstance(task_tdd_coverage_threshold, int)
|
|
3261
|
+
else effective_tdd_coverage_threshold
|
|
3262
|
+
)
|
|
2022
3263
|
|
|
2023
3264
|
return {
|
|
2024
3265
|
"session_file": display_path(root, session_path),
|
|
@@ -2039,6 +3280,18 @@ def snapshot_state(
|
|
|
2039
3280
|
"session_workflow_mode": session_workflow_mode,
|
|
2040
3281
|
"configured_workflow_mode": configured_workflow_mode,
|
|
2041
3282
|
"concrete_workflow_mode": concrete_workflow_mode,
|
|
3283
|
+
"project_tdd_enabled": project_tdd_enabled,
|
|
3284
|
+
"session_tdd_enabled": session_tdd_enabled,
|
|
3285
|
+
"effective_tdd_enabled": effective_tdd_enabled,
|
|
3286
|
+
"project_tdd_coverage_threshold": project_tdd_coverage_threshold,
|
|
3287
|
+
"session_tdd_coverage_threshold": session_tdd_coverage_threshold,
|
|
3288
|
+
"effective_tdd_coverage_threshold": effective_tdd_coverage_threshold,
|
|
3289
|
+
"task_tdd_enabled": task_tdd_enabled,
|
|
3290
|
+
"task_tdd_coverage_threshold": task_tdd_coverage_threshold,
|
|
3291
|
+
"task_tdd_baselines": task.get("tdd_baselines") if task else None,
|
|
3292
|
+
"displayed_tdd_enabled": displayed_tdd_enabled,
|
|
3293
|
+
"displayed_tdd_coverage_threshold": displayed_tdd_threshold,
|
|
3294
|
+
"spec_summary": spec_task_summary(task),
|
|
2042
3295
|
# Compatibility output aliases for pre-0.9 clients.
|
|
2043
3296
|
"project_confirm_mode": project_approval_mode,
|
|
2044
3297
|
"session_confirm_mode": session_approval_mode,
|
|
@@ -2057,6 +3310,8 @@ def build_status_line(
|
|
|
2057
3310
|
approval = str(state["effective_approval_mode"]).capitalize()
|
|
2058
3311
|
workflow = str(state["concrete_workflow_mode"] or state["configured_workflow_mode"]).capitalize()
|
|
2059
3312
|
status_brand = f"> **Easy Coding** · **Approval: {approval}** · **Workflow: {workflow}**"
|
|
3313
|
+
if state["displayed_tdd_enabled"] is True:
|
|
3314
|
+
status_brand += " · **TDD**"
|
|
2060
3315
|
task_id = state["current_task"]
|
|
2061
3316
|
if task_id:
|
|
2062
3317
|
status = str(state["status"])
|
|
@@ -2100,6 +3355,11 @@ def build_machine_breadcrumbs(
|
|
|
2100
3355
|
]
|
|
2101
3356
|
if state.get("concrete_workflow_mode"):
|
|
2102
3357
|
lines.append(f"[easy-coding:workflow-mode:{state['concrete_workflow_mode']}]")
|
|
3358
|
+
if state.get("displayed_tdd_enabled") is True:
|
|
3359
|
+
lines.append("[easy-coding:tdd:enabled]")
|
|
3360
|
+
lines.append(
|
|
3361
|
+
f"[easy-coding:tdd-coverage-threshold:{state['displayed_tdd_coverage_threshold']}]"
|
|
3362
|
+
)
|
|
2103
3363
|
|
|
2104
3364
|
if task_id:
|
|
2105
3365
|
lines.append(f"[current-task:{task_id}]")
|
|
@@ -2245,6 +3505,7 @@ def list_tasks(root: Path, agent: str | None = None) -> list[dict]:
|
|
|
2245
3505
|
"action": action,
|
|
2246
3506
|
"previous_agent": last_agent if action == "takeover" else None,
|
|
2247
3507
|
"latest_handoff": latest_handoff_record(root, entry.name),
|
|
3508
|
+
"spec_summary": spec_task_summary(task),
|
|
2248
3509
|
}
|
|
2249
3510
|
)
|
|
2250
3511
|
return items
|
|
@@ -2406,6 +3667,43 @@ def clear_session_workflow_mode(
|
|
|
2406
3667
|
return snapshot
|
|
2407
3668
|
|
|
2408
3669
|
|
|
3670
|
+
def set_session_tdd(
|
|
3671
|
+
root: Path,
|
|
3672
|
+
enabled: bool,
|
|
3673
|
+
agent: str,
|
|
3674
|
+
threshold: int | None = None,
|
|
3675
|
+
session_file: str | Path | None = None,
|
|
3676
|
+
) -> dict:
|
|
3677
|
+
session = ensure_session(root, session_file)
|
|
3678
|
+
materialize_legacy_session_behavior(session)
|
|
3679
|
+
session["tdd_enabled"] = enabled
|
|
3680
|
+
if threshold is not None:
|
|
3681
|
+
session["tdd_coverage_threshold"] = parse_tdd_threshold(
|
|
3682
|
+
threshold, "session tdd_coverage_threshold"
|
|
3683
|
+
)
|
|
3684
|
+
session["last_agent"] = agent
|
|
3685
|
+
write_session(root, session, session_file)
|
|
3686
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
3687
|
+
snapshot["action"] = "set-tdd"
|
|
3688
|
+
return snapshot
|
|
3689
|
+
|
|
3690
|
+
|
|
3691
|
+
def clear_session_tdd(
|
|
3692
|
+
root: Path,
|
|
3693
|
+
agent: str,
|
|
3694
|
+
session_file: str | Path | None = None,
|
|
3695
|
+
) -> dict:
|
|
3696
|
+
session = ensure_session(root, session_file)
|
|
3697
|
+
materialize_legacy_session_behavior(session)
|
|
3698
|
+
session.pop("tdd_enabled", None)
|
|
3699
|
+
session.pop("tdd_coverage_threshold", None)
|
|
3700
|
+
session["last_agent"] = agent
|
|
3701
|
+
write_session(root, session, session_file)
|
|
3702
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
3703
|
+
snapshot["action"] = "clear-tdd"
|
|
3704
|
+
return snapshot
|
|
3705
|
+
|
|
3706
|
+
|
|
2409
3707
|
def set_harness_disabled(
|
|
2410
3708
|
root: Path,
|
|
2411
3709
|
disabled: bool,
|
|
@@ -2507,6 +3805,7 @@ def create_task(
|
|
|
2507
3805
|
agent: str,
|
|
2508
3806
|
set_current: bool = True,
|
|
2509
3807
|
session_file: str | Path | None = None,
|
|
3808
|
+
task_fields: dict | None = None,
|
|
2510
3809
|
) -> dict:
|
|
2511
3810
|
assert_safe_task_id(task_id)
|
|
2512
3811
|
if set_current:
|
|
@@ -2529,12 +3828,137 @@ def create_task(
|
|
|
2529
3828
|
"closed_reason": None,
|
|
2530
3829
|
"repos": [],
|
|
2531
3830
|
}
|
|
3831
|
+
if task_fields:
|
|
3832
|
+
task.update(task_fields)
|
|
2532
3833
|
write_task(root, task_id, task)
|
|
2533
3834
|
if set_current:
|
|
2534
3835
|
return set_current_task(root, task_id, agent, session_file)
|
|
2535
3836
|
return {"task_id": task_id, "task": task}
|
|
2536
3837
|
|
|
2537
3838
|
|
|
3839
|
+
def ensure_path_inside_root(root: Path, path: Path, label: str) -> Path:
|
|
3840
|
+
resolved = path.resolve()
|
|
3841
|
+
try:
|
|
3842
|
+
resolved.relative_to(root.resolve())
|
|
3843
|
+
except ValueError as exc:
|
|
3844
|
+
raise StateError(f"{label} must be inside the Easy Coding project root.") from exc
|
|
3845
|
+
return resolved
|
|
3846
|
+
|
|
3847
|
+
|
|
3848
|
+
def create_task_from_spec(
|
|
3849
|
+
root: Path,
|
|
3850
|
+
spec_path: str,
|
|
3851
|
+
spec_task_ids: list[str],
|
|
3852
|
+
task_id: str,
|
|
3853
|
+
task_type: str,
|
|
3854
|
+
title: str,
|
|
3855
|
+
repo_paths: dict[str, str],
|
|
3856
|
+
dependency_evidence: dict[str, str],
|
|
3857
|
+
agent: str,
|
|
3858
|
+
set_current: bool = True,
|
|
3859
|
+
session_file: str | Path | None = None,
|
|
3860
|
+
) -> dict:
|
|
3861
|
+
raw_spec_path = Path(spec_path)
|
|
3862
|
+
resolved_spec_path = ensure_path_inside_root(
|
|
3863
|
+
root,
|
|
3864
|
+
raw_spec_path if raw_spec_path.is_absolute() else root / raw_spec_path,
|
|
3865
|
+
"Canonical Spec path",
|
|
3866
|
+
)
|
|
3867
|
+
try:
|
|
3868
|
+
inspection = inspect_spec(
|
|
3869
|
+
resolved_spec_path,
|
|
3870
|
+
root,
|
|
3871
|
+
repo_paths,
|
|
3872
|
+
spec_task_ids,
|
|
3873
|
+
)
|
|
3874
|
+
selection = select_tasks(inspection, spec_task_ids, dependency_evidence)
|
|
3875
|
+
except EasyDevSpecError as exc:
|
|
3876
|
+
raise StateError(f"Cannot create task from Canonical Spec: {exc}") from exc
|
|
3877
|
+
|
|
3878
|
+
selected_repo_ids = set(selection["selected_repo_ids"])
|
|
3879
|
+
bindings = [
|
|
3880
|
+
binding
|
|
3881
|
+
for binding in inspection["repository_bindings"]
|
|
3882
|
+
if binding.get("repo_id") in selected_repo_ids
|
|
3883
|
+
]
|
|
3884
|
+
if len(bindings) != len(selected_repo_ids):
|
|
3885
|
+
raise StateError("Canonical Spec repository bindings do not cover every selected task.")
|
|
3886
|
+
stored_repo_paths = {
|
|
3887
|
+
str(binding["repo_id"]): str(binding["path"])
|
|
3888
|
+
for binding in bindings
|
|
3889
|
+
}
|
|
3890
|
+
source_path = resolved_spec_path.relative_to(root.resolve()).as_posix()
|
|
3891
|
+
fields = {
|
|
3892
|
+
"repos": list(selection["selected_repo_ids"]),
|
|
3893
|
+
"repo_paths": stored_repo_paths,
|
|
3894
|
+
"spec_source": {
|
|
3895
|
+
"schema": inspection["schema"],
|
|
3896
|
+
"spec_id": inspection["spec_id"],
|
|
3897
|
+
"revision": inspection["revision"],
|
|
3898
|
+
"path": source_path,
|
|
3899
|
+
"sha256": inspection["source_sha256"],
|
|
3900
|
+
},
|
|
3901
|
+
"selected_spec_tasks": selection["selected_task_ids"],
|
|
3902
|
+
"spec_repositories": bindings,
|
|
3903
|
+
"spec_dependency_evidence": selection["dependency_records"],
|
|
3904
|
+
}
|
|
3905
|
+
return create_task(
|
|
3906
|
+
root,
|
|
3907
|
+
task_id,
|
|
3908
|
+
task_type,
|
|
3909
|
+
title,
|
|
3910
|
+
agent,
|
|
3911
|
+
set_current,
|
|
3912
|
+
session_file,
|
|
3913
|
+
fields,
|
|
3914
|
+
)
|
|
3915
|
+
|
|
3916
|
+
|
|
3917
|
+
def satisfy_spec_dependency(
|
|
3918
|
+
root: Path,
|
|
3919
|
+
dependency_task_id: str,
|
|
3920
|
+
evidence: str,
|
|
3921
|
+
agent: str,
|
|
3922
|
+
source_task_id: str | None = None,
|
|
3923
|
+
task_id: str | None = None,
|
|
3924
|
+
session_file: str | Path | None = None,
|
|
3925
|
+
) -> dict:
|
|
3926
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
3927
|
+
if task.get("status") in TERMINAL_STATUSES or task.get("status") == "MEMORY":
|
|
3928
|
+
raise StateError("Spec dependency evidence cannot change after MEMORY begins.")
|
|
3929
|
+
if not is_non_empty_string(evidence):
|
|
3930
|
+
raise StateError("Spec dependency evidence must be non-empty.")
|
|
3931
|
+
inspect_task_spec(root, task)
|
|
3932
|
+
records = task.get("spec_dependency_evidence")
|
|
3933
|
+
if not isinstance(records, list):
|
|
3934
|
+
raise StateError("Current task is not backed by Canonical Spec dependency metadata.")
|
|
3935
|
+
matches = [
|
|
3936
|
+
record
|
|
3937
|
+
for record in records
|
|
3938
|
+
if isinstance(record, dict)
|
|
3939
|
+
and record.get("task_id") == dependency_task_id
|
|
3940
|
+
and (source_task_id is None or record.get("source_task_id") == source_task_id)
|
|
3941
|
+
]
|
|
3942
|
+
if not matches:
|
|
3943
|
+
raise StateError("Canonical Spec dependency edge was not found.")
|
|
3944
|
+
if source_task_id is None and len(matches) > 1:
|
|
3945
|
+
raise StateError(
|
|
3946
|
+
"Canonical Spec dependency is ambiguous; pass --source-task to identify the edge."
|
|
3947
|
+
)
|
|
3948
|
+
record = matches[0]
|
|
3949
|
+
if record.get("dependency_type") == "contract":
|
|
3950
|
+
raise StateError("Contract dependencies are satisfied by the frozen READY Spec.")
|
|
3951
|
+
record["status"] = "satisfied"
|
|
3952
|
+
record["evidence"] = evidence.strip()
|
|
3953
|
+
record["satisfied_at"] = now_iso()
|
|
3954
|
+
record["satisfied_by"] = agent
|
|
3955
|
+
task["last_agent"] = agent
|
|
3956
|
+
write_task(root, resolved_task_id, task)
|
|
3957
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
3958
|
+
snapshot["action"] = "satisfy-spec-dependency"
|
|
3959
|
+
return snapshot
|
|
3960
|
+
|
|
3961
|
+
|
|
2538
3962
|
def append_stage_history(task: dict, stage: str, agent: str) -> None:
|
|
2539
3963
|
history = task.setdefault("stage_history", [])
|
|
2540
3964
|
history.append({"stage": stage, "agent": agent, "entered_at": now_iso()})
|
|
@@ -2710,6 +4134,39 @@ def freeze_workflow_mode(
|
|
|
2710
4134
|
task["workflow_mode_confirmed_by"] = agent
|
|
2711
4135
|
|
|
2712
4136
|
|
|
4137
|
+
def freeze_tdd_mode(
|
|
4138
|
+
root: Path, session: dict, task_id: str, task: dict, agent: str
|
|
4139
|
+
) -> None:
|
|
4140
|
+
behavior = resolve_behavior(root, session)
|
|
4141
|
+
task_type = str(task.get("type") or "").strip().lower()
|
|
4142
|
+
task["tdd_enabled"] = behavior[8] if task_type not in NO_CODE_TASK_TYPES else False
|
|
4143
|
+
task["tdd_coverage_threshold"] = behavior[11]
|
|
4144
|
+
if task["tdd_enabled"] is True:
|
|
4145
|
+
plan = latest_execution_plan(root, task_id)
|
|
4146
|
+
if plan is None:
|
|
4147
|
+
raise StateError("Cannot freeze TDD baseline without a valid execution plan.")
|
|
4148
|
+
baselines = {
|
|
4149
|
+
key: git_head_sha(repository)
|
|
4150
|
+
for key, repository in tdd_repositories(root, task, plan).items()
|
|
4151
|
+
}
|
|
4152
|
+
task_dir = task_json_path(root, task_id).parent
|
|
4153
|
+
try:
|
|
4154
|
+
dev_spec_content = (task_dir / "dev-spec.md").read_text(encoding="utf-8")
|
|
4155
|
+
strategy_content = (task_dir / "test-strategy.md").read_text(encoding="utf-8")
|
|
4156
|
+
except OSError as error:
|
|
4157
|
+
raise StateError("Cannot freeze TDD without readable analysis artifacts.") from error
|
|
4158
|
+
marker_reasons = tdd_baseline_marker_reasons(
|
|
4159
|
+
dev_spec_content, strategy_content, baselines
|
|
4160
|
+
)
|
|
4161
|
+
if marker_reasons:
|
|
4162
|
+
raise StateError("; ".join(marker_reasons))
|
|
4163
|
+
task["tdd_baselines"] = baselines
|
|
4164
|
+
else:
|
|
4165
|
+
task.pop("tdd_baselines", None)
|
|
4166
|
+
task["tdd_confirmed_at"] = now_iso()
|
|
4167
|
+
task["tdd_confirmed_by"] = agent
|
|
4168
|
+
|
|
4169
|
+
|
|
2713
4170
|
def raise_workflow_mode(
|
|
2714
4171
|
root: Path,
|
|
2715
4172
|
mode: str,
|
|
@@ -2775,7 +4232,7 @@ def request_transition(
|
|
|
2775
4232
|
"use auto-transition instead."
|
|
2776
4233
|
)
|
|
2777
4234
|
if previous == "ANALYSIS" and stage == "IMPLEMENT":
|
|
2778
|
-
validate_analysis_readiness(root, resolved_task_id)
|
|
4235
|
+
validate_analysis_readiness(root, resolved_task_id, session)
|
|
2779
4236
|
if task.get("workflow_mode_legacy") is not True:
|
|
2780
4237
|
validate_workflow_mode_proposal(
|
|
2781
4238
|
root,
|
|
@@ -2828,9 +4285,10 @@ def apply_transition(
|
|
|
2828
4285
|
if violation:
|
|
2829
4286
|
raise StateError(violation)
|
|
2830
4287
|
if previous == "ANALYSIS" and stage == "IMPLEMENT":
|
|
2831
|
-
validate_analysis_readiness(root, resolved_task_id)
|
|
4288
|
+
validate_analysis_readiness(root, resolved_task_id, session)
|
|
2832
4289
|
if task.get("workflow_mode_legacy") is not True:
|
|
2833
4290
|
freeze_workflow_mode(root, session, resolved_task_id, task, agent)
|
|
4291
|
+
freeze_tdd_mode(root, session, resolved_task_id, task, agent)
|
|
2834
4292
|
if previous == "REVIEW" and stage == "VERIFICATION":
|
|
2835
4293
|
validate_review_readiness(root, resolved_task_id, task)
|
|
2836
4294
|
if previous == "VERIFICATION" and stage == "MEMORY":
|
|
@@ -3180,6 +4638,19 @@ def add_common_args(parser: argparse.ArgumentParser) -> None:
|
|
|
3180
4638
|
parser.add_argument("--session-file", help="Session file path injected by the hook.")
|
|
3181
4639
|
|
|
3182
4640
|
|
|
4641
|
+
def parse_mapping_args(values: list[str], label: str) -> dict[str, str]:
|
|
4642
|
+
mappings: dict[str, str] = {}
|
|
4643
|
+
for value in values:
|
|
4644
|
+
key, separator, mapped_value = value.partition("=")
|
|
4645
|
+
if not separator or not key.strip() or not mapped_value.strip():
|
|
4646
|
+
raise StateError(f"{label} must use KEY=VALUE syntax: {value!r}")
|
|
4647
|
+
key = key.strip()
|
|
4648
|
+
if key in mappings:
|
|
4649
|
+
raise StateError(f"{label} contains a duplicate key: {key}")
|
|
4650
|
+
mappings[key] = mapped_value.strip()
|
|
4651
|
+
return mappings
|
|
4652
|
+
|
|
4653
|
+
|
|
3183
4654
|
def main() -> int:
|
|
3184
4655
|
configure_stdio()
|
|
3185
4656
|
common = argparse.ArgumentParser(add_help=False)
|
|
@@ -3193,6 +4664,14 @@ def main() -> int:
|
|
|
3193
4664
|
list_tasks_parser = subcommands.add_parser("list-tasks", parents=[common])
|
|
3194
4665
|
list_tasks_parser.add_argument("--agent")
|
|
3195
4666
|
|
|
4667
|
+
inspect_spec_parser = subcommands.add_parser("inspect-dev-spec", parents=[common])
|
|
4668
|
+
inspect_spec_parser.add_argument("--spec", required=True)
|
|
4669
|
+
inspect_spec_parser.add_argument("--repo-path", action="append", default=[])
|
|
4670
|
+
|
|
4671
|
+
select_spec_scope = subcommands.add_parser("select-dev-spec-scope", parents=[common])
|
|
4672
|
+
select_spec_scope.add_argument("--spec", required=True)
|
|
4673
|
+
select_spec_scope.add_argument("--spec-task", required=True, action="append")
|
|
4674
|
+
|
|
3196
4675
|
create = subcommands.add_parser("create-task", parents=[common])
|
|
3197
4676
|
create.add_argument("--task-id", required=True)
|
|
3198
4677
|
create.add_argument("--type", required=True)
|
|
@@ -3200,6 +4679,17 @@ def main() -> int:
|
|
|
3200
4679
|
create.add_argument("--agent", required=True)
|
|
3201
4680
|
create.add_argument("--no-set-current", action="store_true")
|
|
3202
4681
|
|
|
4682
|
+
create_from_spec = subcommands.add_parser("create-task-from-spec", parents=[common])
|
|
4683
|
+
create_from_spec.add_argument("--spec", required=True)
|
|
4684
|
+
create_from_spec.add_argument("--spec-task", required=True, action="append")
|
|
4685
|
+
create_from_spec.add_argument("--task-id", required=True)
|
|
4686
|
+
create_from_spec.add_argument("--type", required=True)
|
|
4687
|
+
create_from_spec.add_argument("--title", required=True)
|
|
4688
|
+
create_from_spec.add_argument("--repo-path", required=True, action="append")
|
|
4689
|
+
create_from_spec.add_argument("--dependency-evidence", action="append", default=[])
|
|
4690
|
+
create_from_spec.add_argument("--agent", required=True)
|
|
4691
|
+
create_from_spec.add_argument("--no-set-current", action="store_true")
|
|
4692
|
+
|
|
3203
4693
|
set_current = subcommands.add_parser("set-current", parents=[common])
|
|
3204
4694
|
set_current.add_argument("--task-id", required=True)
|
|
3205
4695
|
set_current.add_argument("--agent", required=True)
|
|
@@ -3223,6 +4713,14 @@ def main() -> int:
|
|
|
3223
4713
|
clear_workflow_mode_parser = subcommands.add_parser("clear-workflow-mode", parents=[common])
|
|
3224
4714
|
clear_workflow_mode_parser.add_argument("--agent", required=True)
|
|
3225
4715
|
|
|
4716
|
+
set_tdd_parser = subcommands.add_parser("set-tdd", parents=[common])
|
|
4717
|
+
set_tdd_parser.add_argument("--enabled", required=True, choices=["true", "false"])
|
|
4718
|
+
set_tdd_parser.add_argument("--threshold", type=int)
|
|
4719
|
+
set_tdd_parser.add_argument("--agent", required=True)
|
|
4720
|
+
|
|
4721
|
+
clear_tdd_parser = subcommands.add_parser("clear-tdd", parents=[common])
|
|
4722
|
+
clear_tdd_parser.add_argument("--agent", required=True)
|
|
4723
|
+
|
|
3226
4724
|
# Compatibility aliases for pre-0.9 callers.
|
|
3227
4725
|
set_confirm_mode_parser = subcommands.add_parser("set-confirm-mode", parents=[common])
|
|
3228
4726
|
set_confirm_mode_parser.add_argument(
|
|
@@ -3339,6 +4837,13 @@ def main() -> int:
|
|
|
3339
4837
|
repo_path.add_argument("--agent")
|
|
3340
4838
|
repo_path.add_argument("--task-id")
|
|
3341
4839
|
|
|
4840
|
+
satisfy_dependency = subcommands.add_parser("satisfy-spec-dependency", parents=[common])
|
|
4841
|
+
satisfy_dependency.add_argument("--spec-task", required=True)
|
|
4842
|
+
satisfy_dependency.add_argument("--source-task")
|
|
4843
|
+
satisfy_dependency.add_argument("--evidence", required=True)
|
|
4844
|
+
satisfy_dependency.add_argument("--agent", required=True)
|
|
4845
|
+
satisfy_dependency.add_argument("--task-id")
|
|
4846
|
+
|
|
3342
4847
|
args = parser.parse_args()
|
|
3343
4848
|
try:
|
|
3344
4849
|
root = resolve_root(getattr(args, "cwd", None))
|
|
@@ -3353,7 +4858,12 @@ def main() -> int:
|
|
|
3353
4858
|
raise StateError(
|
|
3354
4859
|
"project-init-complete requires --session-file from the current hook context."
|
|
3355
4860
|
)
|
|
3356
|
-
if session_file is None and command not in {
|
|
4861
|
+
if session_file is None and command not in {
|
|
4862
|
+
"inspect-dev-spec",
|
|
4863
|
+
"select-dev-spec-scope",
|
|
4864
|
+
"list-tasks",
|
|
4865
|
+
"memory-new-id",
|
|
4866
|
+
}:
|
|
3357
4867
|
if session_agent == "unknown":
|
|
3358
4868
|
raise StateError(
|
|
3359
4869
|
"Cannot resolve the logical session. Pass --session-file or --agent."
|
|
@@ -3361,6 +4871,26 @@ def main() -> int:
|
|
|
3361
4871
|
_, session_file = ensure_hook_session(root, {}, session_agent)
|
|
3362
4872
|
if command == "snapshot":
|
|
3363
4873
|
emit(snapshot_state(root, session_file))
|
|
4874
|
+
elif command == "inspect-dev-spec":
|
|
4875
|
+
spec_path = Path(args.spec)
|
|
4876
|
+
emit(
|
|
4877
|
+
inspection_summary(
|
|
4878
|
+
inspect_spec(
|
|
4879
|
+
spec_path if spec_path.is_absolute() else root / spec_path,
|
|
4880
|
+
root,
|
|
4881
|
+
parse_mapping_args(args.repo_path, "--repo-path"),
|
|
4882
|
+
)
|
|
4883
|
+
)
|
|
4884
|
+
)
|
|
4885
|
+
elif command == "select-dev-spec-scope":
|
|
4886
|
+
spec_path = Path(args.spec)
|
|
4887
|
+
emit(
|
|
4888
|
+
select_consumption_scopes(
|
|
4889
|
+
spec_path if spec_path.is_absolute() else root / spec_path,
|
|
4890
|
+
root,
|
|
4891
|
+
args.spec_task,
|
|
4892
|
+
)
|
|
4893
|
+
)
|
|
3364
4894
|
elif command == "list-tasks":
|
|
3365
4895
|
emit({"tasks": list_tasks(root, visible_agent)})
|
|
3366
4896
|
elif command == "create-task":
|
|
@@ -3380,6 +4910,30 @@ def main() -> int:
|
|
|
3380
4910
|
session_file,
|
|
3381
4911
|
)
|
|
3382
4912
|
)
|
|
4913
|
+
elif command == "create-task-from-spec":
|
|
4914
|
+
emit(
|
|
4915
|
+
attach_status_context(
|
|
4916
|
+
root,
|
|
4917
|
+
create_task_from_spec(
|
|
4918
|
+
root,
|
|
4919
|
+
args.spec,
|
|
4920
|
+
args.spec_task,
|
|
4921
|
+
args.task_id,
|
|
4922
|
+
args.type,
|
|
4923
|
+
args.title,
|
|
4924
|
+
parse_mapping_args(args.repo_path, "--repo-path"),
|
|
4925
|
+
parse_mapping_args(
|
|
4926
|
+
args.dependency_evidence,
|
|
4927
|
+
"--dependency-evidence",
|
|
4928
|
+
),
|
|
4929
|
+
agent,
|
|
4930
|
+
not args.no_set_current,
|
|
4931
|
+
session_file,
|
|
4932
|
+
),
|
|
4933
|
+
agent,
|
|
4934
|
+
session_file,
|
|
4935
|
+
)
|
|
4936
|
+
)
|
|
3383
4937
|
elif command == "set-current":
|
|
3384
4938
|
emit(
|
|
3385
4939
|
attach_status_context(
|
|
@@ -3452,6 +5006,30 @@ def main() -> int:
|
|
|
3452
5006
|
session_file,
|
|
3453
5007
|
)
|
|
3454
5008
|
)
|
|
5009
|
+
elif command == "set-tdd":
|
|
5010
|
+
emit(
|
|
5011
|
+
attach_status_context(
|
|
5012
|
+
root,
|
|
5013
|
+
set_session_tdd(
|
|
5014
|
+
root,
|
|
5015
|
+
args.enabled == "true",
|
|
5016
|
+
agent,
|
|
5017
|
+
args.threshold,
|
|
5018
|
+
session_file,
|
|
5019
|
+
),
|
|
5020
|
+
agent,
|
|
5021
|
+
session_file,
|
|
5022
|
+
)
|
|
5023
|
+
)
|
|
5024
|
+
elif command == "clear-tdd":
|
|
5025
|
+
emit(
|
|
5026
|
+
attach_status_context(
|
|
5027
|
+
root,
|
|
5028
|
+
clear_session_tdd(root, agent, session_file),
|
|
5029
|
+
agent,
|
|
5030
|
+
session_file,
|
|
5031
|
+
)
|
|
5032
|
+
)
|
|
3455
5033
|
elif command == "propose-workflow-mode":
|
|
3456
5034
|
emit(
|
|
3457
5035
|
attach_status_context(
|
|
@@ -3664,8 +5242,25 @@ def main() -> int:
|
|
|
3664
5242
|
session_file,
|
|
3665
5243
|
)
|
|
3666
5244
|
)
|
|
5245
|
+
elif command == "satisfy-spec-dependency":
|
|
5246
|
+
emit(
|
|
5247
|
+
attach_status_context(
|
|
5248
|
+
root,
|
|
5249
|
+
satisfy_spec_dependency(
|
|
5250
|
+
root,
|
|
5251
|
+
args.spec_task,
|
|
5252
|
+
args.evidence,
|
|
5253
|
+
agent,
|
|
5254
|
+
args.source_task,
|
|
5255
|
+
args.task_id,
|
|
5256
|
+
session_file,
|
|
5257
|
+
),
|
|
5258
|
+
agent,
|
|
5259
|
+
session_file,
|
|
5260
|
+
)
|
|
5261
|
+
)
|
|
3667
5262
|
return 0
|
|
3668
|
-
except StateError as error:
|
|
5263
|
+
except (StateError, EasyDevSpecError) as error:
|
|
3669
5264
|
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
|
|
3670
5265
|
return 1
|
|
3671
5266
|
|