easy-coding-harness 0.8.3-beta.0 → 0.9.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +16 -10
- package/dist/cli.js +256 -47
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/templates/claude/agents/ec-fixer.md +3 -2
- package/templates/claude/agents/ec-implementer.md +4 -1
- package/templates/claude/agents/ec-reviewer.md +4 -2
- package/templates/claude/agents/ec-verifier.md +6 -3
- package/templates/codex/agents/ec-fixer.toml +3 -2
- package/templates/codex/agents/ec-implementer.toml +4 -1
- package/templates/codex/agents/ec-reviewer.toml +4 -2
- package/templates/codex/agents/ec-verifier.toml +6 -3
- package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +9 -7
- package/templates/common/skills/ec-analysis/SKILL.md +92 -266
- package/templates/common/skills/ec-implementing/SKILL.md +82 -131
- package/templates/common/skills/ec-memory/SKILL.md +23 -149
- package/templates/common/skills/ec-reviewing/SKILL.md +54 -73
- package/templates/common/skills/ec-task-management/SKILL.md +34 -94
- package/templates/common/skills/ec-verification/SKILL.md +54 -79
- package/templates/common/skills/ec-workflow/SKILL.md +109 -302
- package/templates/main-constraint/AGENTS.md.tpl +19 -17
- package/templates/main-constraint/CLAUDE.md.tpl +19 -17
- package/templates/qoder/agents/ec-fixer.md +3 -2
- package/templates/qoder/agents/ec-implementer.md +4 -1
- package/templates/qoder/agents/ec-reviewer.md +4 -2
- package/templates/qoder/agents/ec-verifier.md +6 -3
- package/templates/runtime/memory/SHORT_MEMORY_TEMPLATE.md +2 -0
- package/templates/runtime/templates/dev-spec-skeleton.md +14 -6
- package/templates/shared-hooks/easy_coding_state.py +1240 -89
- package/templates/shared-hooks/inject-subagent-context.py +3 -0
|
@@ -5,6 +5,7 @@ import json
|
|
|
5
5
|
import os
|
|
6
6
|
import re
|
|
7
7
|
import secrets
|
|
8
|
+
import subprocess
|
|
8
9
|
import time
|
|
9
10
|
import uuid
|
|
10
11
|
from datetime import datetime, timezone
|
|
@@ -32,6 +33,7 @@ MANDATORY_DEV_SPEC_HEADERS: list[str] = [
|
|
|
32
33
|
"### 修改方案",
|
|
33
34
|
"### 实施拆解",
|
|
34
35
|
"### 测试策略",
|
|
36
|
+
"### Workflow Mode",
|
|
35
37
|
"### 风险与注意事项",
|
|
36
38
|
]
|
|
37
39
|
|
|
@@ -39,6 +41,7 @@ VALID_TRANSITIONS: dict[str, set[str]] = {
|
|
|
39
41
|
"idle": {"INIT"},
|
|
40
42
|
"INIT": {"ANALYSIS", "CLOSED"},
|
|
41
43
|
"ANALYSIS": {"IMPLEMENT", "CLOSED"},
|
|
44
|
+
# IMPLEMENT -> VERIFICATION remains parseable only for pre-0.9 in-flight tasks.
|
|
42
45
|
"IMPLEMENT": {"REVIEW", "VERIFICATION", "ANALYSIS", "COMPLETE", "CLOSED"},
|
|
43
46
|
"REVIEW": {"VERIFICATION", "IMPLEMENT", "ANALYSIS", "CLOSED"},
|
|
44
47
|
"VERIFICATION": {"MEMORY", "IMPLEMENT", "CLOSED"},
|
|
@@ -53,13 +56,25 @@ ALWAYS_AUTO_TRANSITIONS = {
|
|
|
53
56
|
}
|
|
54
57
|
READ_ONLY_COMPLETION_TRANSITION = ("IMPLEMENT", "COMPLETE")
|
|
55
58
|
NO_CODE_TASK_TYPES = {"analysis", "doc", "report"}
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
APPROVAL_MODES = {"approve", "guard", "confirm", "auto"}
|
|
60
|
+
CONFIGURED_WORKFLOW_MODES = {"adaptive", "fast", "standard", "strict"}
|
|
61
|
+
WORKFLOW_MODES = {"fast", "standard", "strict"}
|
|
62
|
+
WORKFLOW_MODE_RANK = {"fast": 0, "standard": 1, "strict": 2}
|
|
63
|
+
STRICT_VERIFICATION_CHECK_TYPES = {"lint", "typecheck", "test", "build"}
|
|
64
|
+
REVIEW_FINDING_SEVERITIES = {"error", "warning", "info"}
|
|
65
|
+
STRICT_WORKFLOW_RISK_PATTERN = re.compile(
|
|
66
|
+
r"(migration|migrate|schema|state[-_ ]?machine|security|payment|data[-_ ]?loss|"
|
|
67
|
+
r"concurren|cross[-_ ]?repo|public[-_ ]?(api|contract)|迁移|状态机|安全|支付|"
|
|
68
|
+
r"数据丢失|并发|跨仓|公共接口|公共契约)",
|
|
69
|
+
re.IGNORECASE,
|
|
70
|
+
)
|
|
71
|
+
DEFAULT_APPROVAL_MODE = "guard"
|
|
72
|
+
DEFAULT_WORKFLOW_MODE = "adaptive"
|
|
58
73
|
CRITICAL_CONFIRM_TRANSITIONS = {
|
|
59
74
|
("ANALYSIS", "IMPLEMENT"),
|
|
60
75
|
("VERIFICATION", "MEMORY"),
|
|
61
76
|
}
|
|
62
|
-
|
|
77
|
+
ANALYSIS_CONFIRM_TRANSITION = ("ANALYSIS", "IMPLEMENT")
|
|
63
78
|
|
|
64
79
|
LEGACY_STAGE_MAP = {
|
|
65
80
|
"WAITING_CONFIRM": "ANALYSIS",
|
|
@@ -96,6 +111,8 @@ TABLE_HEADER_CELLS = {
|
|
|
96
111
|
"归属单元",
|
|
97
112
|
"方式",
|
|
98
113
|
"验证命令",
|
|
114
|
+
"验收条件",
|
|
115
|
+
"跨单元契约",
|
|
99
116
|
}
|
|
100
117
|
|
|
101
118
|
|
|
@@ -269,15 +286,16 @@ def read_memory_config(root: Path) -> dict[str, int]:
|
|
|
269
286
|
return config
|
|
270
287
|
|
|
271
288
|
|
|
272
|
-
def
|
|
289
|
+
def read_project_behavior(root: Path) -> tuple[str, str]:
|
|
273
290
|
path = root / ".easy-coding" / "config.yaml"
|
|
274
291
|
try:
|
|
275
292
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
276
293
|
except OSError:
|
|
277
|
-
return
|
|
294
|
+
return DEFAULT_APPROVAL_MODE, DEFAULT_WORKFLOW_MODE
|
|
278
295
|
|
|
279
296
|
in_behavior = False
|
|
280
297
|
behavior_indent = 0
|
|
298
|
+
behavior: dict[str, str] = {}
|
|
281
299
|
for raw_line in lines:
|
|
282
300
|
without_comment = raw_line.split("#", 1)[0].rstrip()
|
|
283
301
|
stripped = without_comment.strip()
|
|
@@ -293,25 +311,88 @@ def read_project_confirm_mode(root: Path) -> str:
|
|
|
293
311
|
if not in_behavior or ":" not in stripped:
|
|
294
312
|
continue
|
|
295
313
|
key, value = stripped.split(":", 1)
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
314
|
+
behavior[key] = value.strip().strip("'\"")
|
|
315
|
+
|
|
316
|
+
legacy = behavior.get("confirm_mode")
|
|
317
|
+
approval_mode = behavior.get("approval_mode")
|
|
318
|
+
workflow_mode = behavior.get("workflow_mode")
|
|
319
|
+
if approval_mode is None:
|
|
320
|
+
if legacy == "lite":
|
|
321
|
+
approval_mode = "guard"
|
|
322
|
+
elif legacy in APPROVAL_MODES:
|
|
323
|
+
approval_mode = legacy
|
|
324
|
+
else:
|
|
325
|
+
approval_mode = DEFAULT_APPROVAL_MODE
|
|
326
|
+
if workflow_mode is None:
|
|
327
|
+
workflow_mode = "fast" if legacy == "lite" else DEFAULT_WORKFLOW_MODE
|
|
328
|
+
if approval_mode not in APPROVAL_MODES:
|
|
329
|
+
raise StateError(
|
|
330
|
+
"Invalid behavior.approval_mode in .easy-coding/config.yaml: "
|
|
331
|
+
"expected approve, guard, confirm, or auto."
|
|
332
|
+
)
|
|
333
|
+
if workflow_mode not in CONFIGURED_WORKFLOW_MODES:
|
|
334
|
+
raise StateError(
|
|
335
|
+
"Invalid behavior.workflow_mode in .easy-coding/config.yaml: "
|
|
336
|
+
"expected adaptive, fast, standard, or strict."
|
|
337
|
+
)
|
|
338
|
+
return approval_mode, workflow_mode
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def resolve_behavior(
|
|
342
|
+
root: Path, session: dict
|
|
343
|
+
) -> tuple[str, str | None, str, str, str | None, str]:
|
|
344
|
+
project_approval, project_workflow = read_project_behavior(root)
|
|
345
|
+
legacy = session.get("confirm_mode")
|
|
346
|
+
session_approval = session.get("approval_mode")
|
|
347
|
+
session_workflow = session.get("workflow_mode")
|
|
348
|
+
if session_approval is None:
|
|
349
|
+
if legacy == "lite":
|
|
350
|
+
session_approval = "guard"
|
|
351
|
+
elif legacy in APPROVAL_MODES:
|
|
352
|
+
session_approval = legacy
|
|
353
|
+
if session_workflow is None:
|
|
354
|
+
if legacy == "lite":
|
|
355
|
+
session_workflow = "fast"
|
|
356
|
+
elif legacy in APPROVAL_MODES:
|
|
357
|
+
session_workflow = "adaptive"
|
|
358
|
+
if session_approval is not None and session_approval not in APPROVAL_MODES:
|
|
359
|
+
raise StateError(
|
|
360
|
+
"Invalid session approval_mode: expected approve, guard, confirm, or auto."
|
|
361
|
+
)
|
|
362
|
+
if session_workflow is not None and session_workflow not in CONFIGURED_WORKFLOW_MODES:
|
|
363
|
+
raise StateError(
|
|
364
|
+
"Invalid session workflow_mode: expected adaptive, fast, standard, or strict."
|
|
365
|
+
)
|
|
366
|
+
return (
|
|
367
|
+
project_approval,
|
|
368
|
+
str(session_approval) if session_approval else None,
|
|
369
|
+
str(session_approval or project_approval),
|
|
370
|
+
project_workflow,
|
|
371
|
+
str(session_workflow) if session_workflow else None,
|
|
372
|
+
str(session_workflow or project_workflow),
|
|
373
|
+
)
|
|
306
374
|
|
|
307
375
|
|
|
308
|
-
def
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
376
|
+
def resolve_approval_mode(root: Path, session: dict) -> tuple[str, str | None, str]:
|
|
377
|
+
behavior = resolve_behavior(root, session)
|
|
378
|
+
return behavior[0], behavior[1], behavior[2]
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def materialize_legacy_session_behavior(session: dict) -> None:
|
|
382
|
+
legacy = session.get("confirm_mode")
|
|
383
|
+
if legacy == "lite":
|
|
384
|
+
session.setdefault("approval_mode", "guard")
|
|
385
|
+
if "workflow_mode" not in session:
|
|
386
|
+
session["workflow_mode"] = "fast"
|
|
387
|
+
session["workflow_mode_legacy_confirm_override"] = True
|
|
388
|
+
session.pop("workflow_mode_legacy_alias_override", None)
|
|
389
|
+
elif legacy in APPROVAL_MODES:
|
|
390
|
+
session.setdefault("approval_mode", legacy)
|
|
391
|
+
if "workflow_mode" not in session:
|
|
392
|
+
session["workflow_mode"] = "adaptive"
|
|
393
|
+
session["workflow_mode_legacy_alias_override"] = True
|
|
394
|
+
session.pop("workflow_mode_legacy_confirm_override", None)
|
|
395
|
+
session.pop("confirm_mode", None)
|
|
315
396
|
|
|
316
397
|
|
|
317
398
|
def short_memory_entries(root: Path) -> list[dict[str, object]]:
|
|
@@ -846,6 +927,20 @@ def is_string_list(value: object, allow_empty: bool = True) -> bool:
|
|
|
846
927
|
)
|
|
847
928
|
|
|
848
929
|
|
|
930
|
+
def is_valid_review_finding(value: object) -> bool:
|
|
931
|
+
if not isinstance(value, dict):
|
|
932
|
+
return False
|
|
933
|
+
line = value.get("line")
|
|
934
|
+
return (
|
|
935
|
+
is_non_empty_string(value.get("file"))
|
|
936
|
+
and isinstance(line, int)
|
|
937
|
+
and not isinstance(line, bool)
|
|
938
|
+
and line >= 1
|
|
939
|
+
and is_non_empty_string(value.get("issue"))
|
|
940
|
+
and value.get("severity") in REVIEW_FINDING_SEVERITIES
|
|
941
|
+
)
|
|
942
|
+
|
|
943
|
+
|
|
849
944
|
def has_acyclic_dependencies(dependencies_by_unit: dict[str, set[str]]) -> bool:
|
|
850
945
|
remaining = {unit_id: set(dependencies) for unit_id, dependencies in dependencies_by_unit.items()}
|
|
851
946
|
resolved: set[str] = set()
|
|
@@ -861,7 +956,11 @@ def has_acyclic_dependencies(dependencies_by_unit: dict[str, set[str]]) -> bool:
|
|
|
861
956
|
return True
|
|
862
957
|
|
|
863
958
|
|
|
864
|
-
def is_valid_execution_plan(
|
|
959
|
+
def is_valid_execution_plan(
|
|
960
|
+
plan: object,
|
|
961
|
+
allow_empty_files: bool = False,
|
|
962
|
+
require_unit_contracts: bool = False,
|
|
963
|
+
) -> bool:
|
|
865
964
|
if not isinstance(plan, dict):
|
|
866
965
|
return False
|
|
867
966
|
strategy = plan.get("strategy")
|
|
@@ -889,6 +988,15 @@ def is_valid_execution_plan(plan: object, allow_empty_files: bool = False) -> bo
|
|
|
889
988
|
for optional_list in ("rules_sections", "abstract_modules"):
|
|
890
989
|
if optional_list in unit and not is_string_list(unit.get(optional_list)):
|
|
891
990
|
return False
|
|
991
|
+
if require_unit_contracts:
|
|
992
|
+
for contract_field in (
|
|
993
|
+
"acceptance_criteria",
|
|
994
|
+
"test_points",
|
|
995
|
+
"contracts",
|
|
996
|
+
"risks",
|
|
997
|
+
):
|
|
998
|
+
if not is_string_list(unit.get(contract_field), allow_empty=False):
|
|
999
|
+
return False
|
|
892
1000
|
unit_ids.append(str(unit["id"]))
|
|
893
1001
|
|
|
894
1002
|
if has_empty_file_scope and (not allow_empty_files or strategy != "single" or len(units) != 1):
|
|
@@ -947,6 +1055,19 @@ def is_read_only_execution_plan(plan: object) -> bool:
|
|
|
947
1055
|
)
|
|
948
1056
|
|
|
949
1057
|
|
|
1058
|
+
def read_project_schema_version(root: Path) -> int:
|
|
1059
|
+
path = root / ".easy-coding" / "config.yaml"
|
|
1060
|
+
try:
|
|
1061
|
+
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
|
1062
|
+
line = raw_line.split("#", 1)[0].strip()
|
|
1063
|
+
if not line.startswith("version:"):
|
|
1064
|
+
continue
|
|
1065
|
+
return int(line.split(":", 1)[1].strip())
|
|
1066
|
+
except (OSError, ValueError):
|
|
1067
|
+
return 0
|
|
1068
|
+
return 0
|
|
1069
|
+
|
|
1070
|
+
|
|
950
1071
|
def has_valid_execution_plan(root: Path, task_id: str) -> bool:
|
|
951
1072
|
path = execution_log_path(root, task_id)
|
|
952
1073
|
if not path.exists():
|
|
@@ -968,7 +1089,547 @@ def has_valid_execution_plan(root: Path, task_id: str) -> bool:
|
|
|
968
1089
|
task_type = str(task.get("type") or "").strip().lower() if task else ""
|
|
969
1090
|
if task_type in NO_CODE_TASK_TYPES:
|
|
970
1091
|
return is_read_only_execution_plan(latest_plan)
|
|
971
|
-
return is_valid_execution_plan(
|
|
1092
|
+
return is_valid_execution_plan(
|
|
1093
|
+
latest_plan,
|
|
1094
|
+
require_unit_contracts=read_project_schema_version(root) >= 3,
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def execution_records(root: Path, task_id: str) -> list[dict]:
|
|
1099
|
+
path = execution_log_path(root, task_id)
|
|
1100
|
+
if not path.exists():
|
|
1101
|
+
return []
|
|
1102
|
+
records: list[dict] = []
|
|
1103
|
+
try:
|
|
1104
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
1105
|
+
if not line.strip():
|
|
1106
|
+
continue
|
|
1107
|
+
record = json.loads(line)
|
|
1108
|
+
if isinstance(record, dict):
|
|
1109
|
+
records.append(record)
|
|
1110
|
+
except (OSError, json.JSONDecodeError):
|
|
1111
|
+
return []
|
|
1112
|
+
return records
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def latest_execution_plan(root: Path, task_id: str) -> dict | None:
|
|
1116
|
+
latest: dict | None = None
|
|
1117
|
+
for record in execution_records(root, task_id):
|
|
1118
|
+
if record.get("type") == "plan" and is_valid_execution_plan(
|
|
1119
|
+
record, allow_empty_files=True
|
|
1120
|
+
):
|
|
1121
|
+
latest = record
|
|
1122
|
+
return latest
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def existing_parent(path: Path) -> Path:
|
|
1126
|
+
candidate = path
|
|
1127
|
+
while not candidate.exists() and candidate != candidate.parent:
|
|
1128
|
+
candidate = candidate.parent
|
|
1129
|
+
return candidate.parent if candidate.is_file() else candidate
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def run_git(repository: Path, *args: str) -> subprocess.CompletedProcess[bytes] | None:
|
|
1133
|
+
try:
|
|
1134
|
+
return subprocess.run(
|
|
1135
|
+
["git", "-C", str(repository), *args],
|
|
1136
|
+
stdout=subprocess.PIPE,
|
|
1137
|
+
stderr=subprocess.DEVNULL,
|
|
1138
|
+
check=False,
|
|
1139
|
+
)
|
|
1140
|
+
except OSError:
|
|
1141
|
+
return None
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def git_repository_root(path: Path) -> Path | None:
|
|
1145
|
+
candidate = existing_parent(path).resolve()
|
|
1146
|
+
for directory in (candidate, *candidate.parents):
|
|
1147
|
+
if (directory / ".git").exists():
|
|
1148
|
+
return directory
|
|
1149
|
+
return None
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def is_path_within(path: Path, parent: Path) -> bool:
|
|
1153
|
+
try:
|
|
1154
|
+
path.relative_to(parent)
|
|
1155
|
+
return True
|
|
1156
|
+
except ValueError:
|
|
1157
|
+
return False
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def minimize_repository_scopes(repository: Path, scopes: set[Path]) -> list[Path]:
|
|
1161
|
+
minimized: list[Path] = []
|
|
1162
|
+
for scope in sorted(scopes, key=lambda path: (len(path.parts), path.as_posix())):
|
|
1163
|
+
normalized = scope.resolve()
|
|
1164
|
+
if not is_path_within(normalized, repository):
|
|
1165
|
+
continue
|
|
1166
|
+
if any(is_path_within(normalized, existing) for existing in minimized):
|
|
1167
|
+
continue
|
|
1168
|
+
minimized.append(normalized)
|
|
1169
|
+
return minimized
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def task_repository_scopes(
|
|
1173
|
+
root: Path, task: dict | None, plan: dict
|
|
1174
|
+
) -> list[tuple[Path, list[Path]]]:
|
|
1175
|
+
scope_candidates = [root]
|
|
1176
|
+
if task:
|
|
1177
|
+
repo_paths = task.get("repo_paths")
|
|
1178
|
+
if isinstance(repo_paths, dict):
|
|
1179
|
+
for repo_path in repo_paths.values():
|
|
1180
|
+
if is_non_empty_string(repo_path):
|
|
1181
|
+
candidate = Path(str(repo_path))
|
|
1182
|
+
scope_candidates.append(
|
|
1183
|
+
candidate if candidate.is_absolute() else root / candidate
|
|
1184
|
+
)
|
|
1185
|
+
|
|
1186
|
+
repositories: dict[Path, set[Path]] = {}
|
|
1187
|
+
for candidate in scope_candidates:
|
|
1188
|
+
normalized = candidate.resolve()
|
|
1189
|
+
if candidate.exists() and candidate.is_file():
|
|
1190
|
+
normalized = normalized.parent
|
|
1191
|
+
repository = git_repository_root(normalized)
|
|
1192
|
+
if repository is not None:
|
|
1193
|
+
repositories.setdefault(repository, set()).add(normalized)
|
|
1194
|
+
|
|
1195
|
+
for unit in plan.get("units", []):
|
|
1196
|
+
if not isinstance(unit, dict):
|
|
1197
|
+
continue
|
|
1198
|
+
for file_name in unit.get("files", []):
|
|
1199
|
+
if not is_non_empty_string(file_name):
|
|
1200
|
+
continue
|
|
1201
|
+
candidate = Path(str(file_name))
|
|
1202
|
+
normalized = (
|
|
1203
|
+
candidate if candidate.is_absolute() else root / candidate
|
|
1204
|
+
).resolve()
|
|
1205
|
+
repository = git_repository_root(normalized)
|
|
1206
|
+
if repository is None:
|
|
1207
|
+
continue
|
|
1208
|
+
scopes = repositories.setdefault(repository, set())
|
|
1209
|
+
if not any(is_path_within(normalized, scope) for scope in scopes):
|
|
1210
|
+
# Without project metadata for an external file, conservatively cover
|
|
1211
|
+
# the full repository so unplanned sibling changes remain visible.
|
|
1212
|
+
scopes.add(repository)
|
|
1213
|
+
|
|
1214
|
+
return [
|
|
1215
|
+
(repository, minimize_repository_scopes(repository, scopes))
|
|
1216
|
+
for repository, scopes in sorted(
|
|
1217
|
+
repositories.items(), key=lambda item: item[0].as_posix()
|
|
1218
|
+
)
|
|
1219
|
+
]
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def task_repository_roots(root: Path, task: dict | None, plan: dict) -> list[Path]:
|
|
1223
|
+
return [
|
|
1224
|
+
repository
|
|
1225
|
+
for repository, _scopes in task_repository_scopes(root, task, plan)
|
|
1226
|
+
]
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
def repository_scope_pathspecs(repository: Path, scopes: list[Path]) -> list[str]:
|
|
1230
|
+
return [
|
|
1231
|
+
f":(literal){scope.relative_to(repository).as_posix()}"
|
|
1232
|
+
for scope in scopes
|
|
1233
|
+
]
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def is_easy_coding_state_path(
|
|
1237
|
+
repository: Path, relative_name: str, scopes: list[Path]
|
|
1238
|
+
) -> bool:
|
|
1239
|
+
candidate = repository / relative_name
|
|
1240
|
+
for scope in scopes:
|
|
1241
|
+
if not is_path_within(candidate, scope):
|
|
1242
|
+
continue
|
|
1243
|
+
scoped_name = candidate.relative_to(scope).as_posix()
|
|
1244
|
+
if scoped_name == ".easy-coding" or scoped_name.startswith(".easy-coding/"):
|
|
1245
|
+
return True
|
|
1246
|
+
return False
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def git_index_entries(
|
|
1250
|
+
repository: Path, pathspecs: list[str]
|
|
1251
|
+
) -> dict[bytes, tuple[bytes, bytes]]:
|
|
1252
|
+
result = run_git(
|
|
1253
|
+
repository,
|
|
1254
|
+
"ls-files",
|
|
1255
|
+
"--stage",
|
|
1256
|
+
"-z",
|
|
1257
|
+
"--",
|
|
1258
|
+
*pathspecs,
|
|
1259
|
+
)
|
|
1260
|
+
if result is None or result.returncode != 0:
|
|
1261
|
+
return {}
|
|
1262
|
+
entries: dict[bytes, tuple[bytes, bytes]] = {}
|
|
1263
|
+
for raw_entry in filter(None, result.stdout.split(b"\0")):
|
|
1264
|
+
try:
|
|
1265
|
+
metadata, raw_path = raw_entry.split(b"\t", 1)
|
|
1266
|
+
mode, object_id, stage = metadata.split()
|
|
1267
|
+
except ValueError:
|
|
1268
|
+
continue
|
|
1269
|
+
if stage == b"0":
|
|
1270
|
+
entries[raw_path] = (mode, object_id)
|
|
1271
|
+
return entries
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def git_worktree_blob_oid(repository: Path, relative_name: str) -> bytes | None:
|
|
1275
|
+
result = run_git(
|
|
1276
|
+
repository,
|
|
1277
|
+
"hash-object",
|
|
1278
|
+
f"--path={relative_name}",
|
|
1279
|
+
"--",
|
|
1280
|
+
relative_name,
|
|
1281
|
+
)
|
|
1282
|
+
if result is None or result.returncode != 0:
|
|
1283
|
+
return None
|
|
1284
|
+
object_id = result.stdout.strip()
|
|
1285
|
+
return object_id or None
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
def worktree_git_mode(path: Path) -> bytes:
|
|
1289
|
+
if path.is_symlink():
|
|
1290
|
+
return b"120000"
|
|
1291
|
+
try:
|
|
1292
|
+
return b"100755" if path.stat().st_mode & 0o111 else b"100644"
|
|
1293
|
+
except OSError:
|
|
1294
|
+
return b"<missing-mode>"
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
def update_git_repository_content_fingerprint(
|
|
1298
|
+
digest,
|
|
1299
|
+
root: Path,
|
|
1300
|
+
repository: Path,
|
|
1301
|
+
scopes: list[Path],
|
|
1302
|
+
visited: set[tuple[Path, tuple[Path, ...]]],
|
|
1303
|
+
) -> None:
|
|
1304
|
+
normalized_repository = repository.resolve()
|
|
1305
|
+
normalized_scopes = tuple(scope.resolve() for scope in scopes)
|
|
1306
|
+
visit_key = (normalized_repository, normalized_scopes)
|
|
1307
|
+
if visit_key in visited:
|
|
1308
|
+
digest.update(b"<git-scope-cycle>\0")
|
|
1309
|
+
return
|
|
1310
|
+
visited.add(visit_key)
|
|
1311
|
+
try:
|
|
1312
|
+
digest.update(b"git-repository\0")
|
|
1313
|
+
digest.update(os.fsencode(display_path(root, normalized_repository)))
|
|
1314
|
+
digest.update(b"\0")
|
|
1315
|
+
pathspecs = repository_scope_pathspecs(
|
|
1316
|
+
normalized_repository, list(normalized_scopes)
|
|
1317
|
+
)
|
|
1318
|
+
for scope in normalized_scopes:
|
|
1319
|
+
relative_scope = scope.relative_to(normalized_repository).as_posix()
|
|
1320
|
+
digest.update(b"git-scope\0")
|
|
1321
|
+
digest.update(os.fsencode(relative_scope))
|
|
1322
|
+
digest.update(b"\0")
|
|
1323
|
+
|
|
1324
|
+
index_entries = git_index_entries(normalized_repository, pathspecs)
|
|
1325
|
+
listed = run_git(
|
|
1326
|
+
normalized_repository,
|
|
1327
|
+
"ls-files",
|
|
1328
|
+
"--cached",
|
|
1329
|
+
"--others",
|
|
1330
|
+
"--exclude-standard",
|
|
1331
|
+
"-z",
|
|
1332
|
+
"--",
|
|
1333
|
+
*pathspecs,
|
|
1334
|
+
)
|
|
1335
|
+
modified = run_git(
|
|
1336
|
+
normalized_repository,
|
|
1337
|
+
"diff-files",
|
|
1338
|
+
"--name-only",
|
|
1339
|
+
"-z",
|
|
1340
|
+
"--ignore-submodules=none",
|
|
1341
|
+
"--",
|
|
1342
|
+
*pathspecs,
|
|
1343
|
+
)
|
|
1344
|
+
if listed is None or listed.returncode != 0:
|
|
1345
|
+
digest.update(b"<git-files-error>\0")
|
|
1346
|
+
return
|
|
1347
|
+
if modified is None or modified.returncode != 0:
|
|
1348
|
+
digest.update(b"<git-diff-files-error>\0")
|
|
1349
|
+
return
|
|
1350
|
+
modified_paths = set(filter(None, modified.stdout.split(b"\0")))
|
|
1351
|
+
|
|
1352
|
+
for raw_path in sorted(set(filter(None, listed.stdout.split(b"\0")))):
|
|
1353
|
+
relative_name = os.fsdecode(raw_path)
|
|
1354
|
+
if is_easy_coding_state_path(
|
|
1355
|
+
normalized_repository, relative_name, list(normalized_scopes)
|
|
1356
|
+
):
|
|
1357
|
+
continue
|
|
1358
|
+
candidate = normalized_repository / relative_name
|
|
1359
|
+
index_entry = index_entries.get(raw_path)
|
|
1360
|
+
if index_entry is not None and index_entry[0] == b"160000":
|
|
1361
|
+
digest.update(b"git-entry\0")
|
|
1362
|
+
digest.update(raw_path)
|
|
1363
|
+
digest.update(b"\0gitlink\0")
|
|
1364
|
+
submodule_root = git_repository_root(candidate)
|
|
1365
|
+
if (
|
|
1366
|
+
submodule_root is not None
|
|
1367
|
+
and submodule_root.resolve() == candidate.resolve()
|
|
1368
|
+
):
|
|
1369
|
+
update_git_repository_content_fingerprint(
|
|
1370
|
+
digest,
|
|
1371
|
+
root,
|
|
1372
|
+
submodule_root,
|
|
1373
|
+
[submodule_root],
|
|
1374
|
+
visited,
|
|
1375
|
+
)
|
|
1376
|
+
else:
|
|
1377
|
+
digest.update(index_entry[1])
|
|
1378
|
+
digest.update(b"\0")
|
|
1379
|
+
continue
|
|
1380
|
+
|
|
1381
|
+
exists = candidate.exists() or candidate.is_symlink()
|
|
1382
|
+
if not exists:
|
|
1383
|
+
if raw_path in modified_paths or index_entry is None:
|
|
1384
|
+
# A worktree deletion is canonically absent before and after staging.
|
|
1385
|
+
continue
|
|
1386
|
+
# Sparse or otherwise intentionally absent tracked files retain index content.
|
|
1387
|
+
mode, object_id = index_entry
|
|
1388
|
+
elif index_entry is not None and raw_path not in modified_paths:
|
|
1389
|
+
mode, object_id = index_entry
|
|
1390
|
+
else:
|
|
1391
|
+
mode = worktree_git_mode(candidate)
|
|
1392
|
+
object_id = git_worktree_blob_oid(
|
|
1393
|
+
normalized_repository, relative_name
|
|
1394
|
+
)
|
|
1395
|
+
if object_id is None:
|
|
1396
|
+
try:
|
|
1397
|
+
content = (
|
|
1398
|
+
os.fsencode(os.readlink(candidate))
|
|
1399
|
+
if candidate.is_symlink()
|
|
1400
|
+
else candidate.read_bytes()
|
|
1401
|
+
)
|
|
1402
|
+
except OSError:
|
|
1403
|
+
content = b"<missing>"
|
|
1404
|
+
object_id = hashlib.sha256(content).hexdigest().encode("ascii")
|
|
1405
|
+
|
|
1406
|
+
digest.update(b"git-entry\0")
|
|
1407
|
+
digest.update(raw_path)
|
|
1408
|
+
digest.update(b"\0")
|
|
1409
|
+
digest.update(mode)
|
|
1410
|
+
digest.update(b"\0")
|
|
1411
|
+
digest.update(object_id)
|
|
1412
|
+
digest.update(b"\0")
|
|
1413
|
+
finally:
|
|
1414
|
+
visited.remove(visit_key)
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def update_git_worktree_fingerprint(
|
|
1418
|
+
digest,
|
|
1419
|
+
root: Path,
|
|
1420
|
+
task: dict | None,
|
|
1421
|
+
plan: dict,
|
|
1422
|
+
) -> None:
|
|
1423
|
+
visited: set[tuple[Path, tuple[Path, ...]]] = set()
|
|
1424
|
+
for repository, scopes in task_repository_scopes(root, task, plan):
|
|
1425
|
+
if not scopes:
|
|
1426
|
+
continue
|
|
1427
|
+
update_git_repository_content_fingerprint(
|
|
1428
|
+
digest, root, repository, scopes, visited
|
|
1429
|
+
)
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
def implementation_fingerprint(root: Path, task_id: str) -> str:
|
|
1433
|
+
plan = latest_execution_plan(root, task_id)
|
|
1434
|
+
if not plan:
|
|
1435
|
+
raise StateError("Cannot calculate implementation fingerprint without a valid plan.")
|
|
1436
|
+
task = load_task(root, task_id)
|
|
1437
|
+
workflow_mode = str(task.get("workflow_mode") or "") if task else ""
|
|
1438
|
+
digest = hashlib.sha256()
|
|
1439
|
+
digest.update(b"workflow-mode\0")
|
|
1440
|
+
digest.update(workflow_mode.encode("utf-8"))
|
|
1441
|
+
digest.update(b"\0")
|
|
1442
|
+
digest.update(b"execution-plan\0")
|
|
1443
|
+
digest.update(
|
|
1444
|
+
json.dumps(
|
|
1445
|
+
plan,
|
|
1446
|
+
ensure_ascii=False,
|
|
1447
|
+
sort_keys=True,
|
|
1448
|
+
separators=(",", ":"),
|
|
1449
|
+
).encode("utf-8")
|
|
1450
|
+
)
|
|
1451
|
+
digest.update(b"\0")
|
|
1452
|
+
update_git_worktree_fingerprint(digest, root, task, plan)
|
|
1453
|
+
file_names = sorted(
|
|
1454
|
+
{
|
|
1455
|
+
str(file_name)
|
|
1456
|
+
for unit in plan.get("units", [])
|
|
1457
|
+
if isinstance(unit, dict)
|
|
1458
|
+
for file_name in unit.get("files", [])
|
|
1459
|
+
if is_non_empty_string(file_name)
|
|
1460
|
+
}
|
|
1461
|
+
)
|
|
1462
|
+
for file_name in file_names:
|
|
1463
|
+
candidate = Path(file_name)
|
|
1464
|
+
was_absolute = candidate.is_absolute()
|
|
1465
|
+
if not was_absolute:
|
|
1466
|
+
candidate = root / candidate
|
|
1467
|
+
resolved = candidate.resolve()
|
|
1468
|
+
if not was_absolute:
|
|
1469
|
+
try:
|
|
1470
|
+
resolved.relative_to(root.resolve())
|
|
1471
|
+
except ValueError as error:
|
|
1472
|
+
raise StateError(f"Execution plan file escapes project root: {file_name}") from error
|
|
1473
|
+
digest.update(file_name.encode("utf-8"))
|
|
1474
|
+
digest.update(b"\0")
|
|
1475
|
+
try:
|
|
1476
|
+
digest.update(resolved.read_bytes())
|
|
1477
|
+
except OSError:
|
|
1478
|
+
digest.update(b"<missing>")
|
|
1479
|
+
digest.update(b"\0")
|
|
1480
|
+
return digest.hexdigest()
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
def behavior_config_fingerprint(root: Path) -> str:
|
|
1484
|
+
path = root / ".easy-coding" / "config.yaml"
|
|
1485
|
+
digest = hashlib.sha256()
|
|
1486
|
+
try:
|
|
1487
|
+
digest.update(path.read_bytes())
|
|
1488
|
+
except OSError:
|
|
1489
|
+
digest.update(b"<missing-config>")
|
|
1490
|
+
return digest.hexdigest()
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
def evidence_fingerprints(root: Path, task_id: str) -> dict[str, str]:
|
|
1494
|
+
return {
|
|
1495
|
+
"implementation_fingerprint": implementation_fingerprint(root, task_id),
|
|
1496
|
+
"config_fingerprint": behavior_config_fingerprint(root),
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
|
|
1501
|
+
if task.get("workflow_mode_legacy") is True:
|
|
1502
|
+
return
|
|
1503
|
+
expected = implementation_fingerprint(root, task_id)
|
|
1504
|
+
latest_by_dimension: dict[str, dict] = {}
|
|
1505
|
+
for record in execution_records(root, task_id):
|
|
1506
|
+
if (
|
|
1507
|
+
record.get("type") == "review"
|
|
1508
|
+
and record.get("implementation_fingerprint") == expected
|
|
1509
|
+
and is_non_empty_string(record.get("dimension"))
|
|
1510
|
+
):
|
|
1511
|
+
latest_by_dimension[str(record["dimension"])] = record
|
|
1512
|
+
if not latest_by_dimension:
|
|
1513
|
+
raise StateError(
|
|
1514
|
+
"REVIEW cannot advance to VERIFICATION without a review record for the current implementation fingerprint."
|
|
1515
|
+
)
|
|
1516
|
+
for record in latest_by_dimension.values():
|
|
1517
|
+
if (
|
|
1518
|
+
not is_non_empty_string(record.get("reviewer"))
|
|
1519
|
+
or not is_non_empty_string(record.get("timestamp"))
|
|
1520
|
+
or not isinstance(record.get("findings"), list)
|
|
1521
|
+
):
|
|
1522
|
+
raise StateError(
|
|
1523
|
+
"Review evidence for new tasks must include reviewer, timestamp, and a findings array."
|
|
1524
|
+
)
|
|
1525
|
+
if not all(is_valid_review_finding(finding) for finding in record["findings"]):
|
|
1526
|
+
raise StateError(
|
|
1527
|
+
"Each review finding must include a non-empty file and issue, a positive integer "
|
|
1528
|
+
"line, and severity error, warning, or info."
|
|
1529
|
+
)
|
|
1530
|
+
has_failed_dimension = False
|
|
1531
|
+
for record in latest_by_dimension.values():
|
|
1532
|
+
findings = record.get("findings")
|
|
1533
|
+
has_blocker = isinstance(findings, list) and any(
|
|
1534
|
+
isinstance(finding, dict)
|
|
1535
|
+
and str(finding.get("severity") or "").lower() == "error"
|
|
1536
|
+
for finding in findings
|
|
1537
|
+
)
|
|
1538
|
+
if record.get("passed") is not True or has_blocker:
|
|
1539
|
+
has_failed_dimension = True
|
|
1540
|
+
break
|
|
1541
|
+
if has_failed_dimension:
|
|
1542
|
+
raise StateError(
|
|
1543
|
+
"REVIEW cannot advance to VERIFICATION while a current review dimension is not passed or has error findings."
|
|
1544
|
+
)
|
|
1545
|
+
if task.get("workflow_mode") == "strict" and len(latest_by_dimension) < 2:
|
|
1546
|
+
raise StateError(
|
|
1547
|
+
"Strict workflow requires at least two passed review dimensions for the current implementation fingerprint."
|
|
1548
|
+
)
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
def validate_verification_readiness(root: Path, task_id: str, task: dict) -> None:
|
|
1552
|
+
fingerprints = evidence_fingerprints(root, task_id)
|
|
1553
|
+
if (
|
|
1554
|
+
task.get("workflow_mode_legacy") is not True
|
|
1555
|
+
and task.get("workflow_mode_legacy_review_bypass_fingerprint")
|
|
1556
|
+
!= fingerprints["implementation_fingerprint"]
|
|
1557
|
+
):
|
|
1558
|
+
validate_review_readiness(root, task_id, task)
|
|
1559
|
+
latest_by_check: dict[str, dict] = {}
|
|
1560
|
+
for record in execution_records(root, task_id):
|
|
1561
|
+
if (
|
|
1562
|
+
record.get("type") == "verify"
|
|
1563
|
+
and record.get("implementation_fingerprint")
|
|
1564
|
+
== fingerprints["implementation_fingerprint"]
|
|
1565
|
+
and record.get("config_fingerprint") == fingerprints["config_fingerprint"]
|
|
1566
|
+
and is_non_empty_string(record.get("check"))
|
|
1567
|
+
):
|
|
1568
|
+
check = str(record["check"])
|
|
1569
|
+
previous = latest_by_check.get(check)
|
|
1570
|
+
if (
|
|
1571
|
+
record.get("applicable") is False
|
|
1572
|
+
and previous is not None
|
|
1573
|
+
and previous.get("applicable") is not False
|
|
1574
|
+
):
|
|
1575
|
+
continue
|
|
1576
|
+
latest_by_check[check] = record
|
|
1577
|
+
if not latest_by_check:
|
|
1578
|
+
raise StateError(
|
|
1579
|
+
"VERIFICATION cannot advance to MEMORY without verification evidence for the current implementation and config fingerprints."
|
|
1580
|
+
)
|
|
1581
|
+
if task.get("workflow_mode_legacy") is not True:
|
|
1582
|
+
for record in latest_by_check.values():
|
|
1583
|
+
check_type = str(record.get("check_type") or "")
|
|
1584
|
+
if (
|
|
1585
|
+
check_type not in STRICT_VERIFICATION_CHECK_TYPES
|
|
1586
|
+
or not is_non_empty_string(record.get("timestamp"))
|
|
1587
|
+
or (
|
|
1588
|
+
record.get("applicable") is not False
|
|
1589
|
+
and not is_non_empty_string(record.get("command"))
|
|
1590
|
+
)
|
|
1591
|
+
):
|
|
1592
|
+
raise StateError(
|
|
1593
|
+
"Verification evidence for new tasks must include check_type, timestamp, and command for applicable checks."
|
|
1594
|
+
)
|
|
1595
|
+
if record.get("applicable") is False and not is_non_empty_string(
|
|
1596
|
+
record.get("not_applicable_reason")
|
|
1597
|
+
):
|
|
1598
|
+
raise StateError(
|
|
1599
|
+
"Verification evidence marked not applicable must include a non-empty not_applicable_reason."
|
|
1600
|
+
)
|
|
1601
|
+
applicable_records = [
|
|
1602
|
+
record for record in latest_by_check.values() if record.get("applicable") is not False
|
|
1603
|
+
]
|
|
1604
|
+
if not applicable_records:
|
|
1605
|
+
raise StateError(
|
|
1606
|
+
"VERIFICATION cannot advance to MEMORY without at least one applicable executed check."
|
|
1607
|
+
)
|
|
1608
|
+
if any(record.get("passed") is not True for record in applicable_records):
|
|
1609
|
+
raise StateError(
|
|
1610
|
+
"VERIFICATION cannot advance to MEMORY while current verification evidence contains failures."
|
|
1611
|
+
)
|
|
1612
|
+
if task.get("workflow_mode") == "strict":
|
|
1613
|
+
latest_by_type: dict[str, dict] = {}
|
|
1614
|
+
for record in latest_by_check.values():
|
|
1615
|
+
check_type = str(record.get("check_type") or "")
|
|
1616
|
+
if check_type in STRICT_VERIFICATION_CHECK_TYPES:
|
|
1617
|
+
latest_by_type[check_type] = record
|
|
1618
|
+
missing_types = sorted(STRICT_VERIFICATION_CHECK_TYPES - latest_by_type.keys())
|
|
1619
|
+
if missing_types:
|
|
1620
|
+
raise StateError(
|
|
1621
|
+
"Strict workflow requires current verification evidence for every check type: "
|
|
1622
|
+
+ ", ".join(missing_types)
|
|
1623
|
+
+ "."
|
|
1624
|
+
)
|
|
1625
|
+
for check_type, record in latest_by_type.items():
|
|
1626
|
+
if record.get("applicable") is False and not is_non_empty_string(
|
|
1627
|
+
record.get("not_applicable_reason")
|
|
1628
|
+
):
|
|
1629
|
+
raise StateError(
|
|
1630
|
+
"Strict workflow requires a non-empty not_applicable_reason when "
|
|
1631
|
+
f"{check_type} is marked not applicable."
|
|
1632
|
+
)
|
|
972
1633
|
|
|
973
1634
|
|
|
974
1635
|
def validate_read_only_completion(root: Path, task_id: str) -> None:
|
|
@@ -1248,41 +1909,38 @@ def transition_requires_confirmation(
|
|
|
1248
1909
|
previous: str,
|
|
1249
1910
|
current: str,
|
|
1250
1911
|
task_type: str,
|
|
1251
|
-
|
|
1912
|
+
approval_mode: str,
|
|
1252
1913
|
) -> bool:
|
|
1253
1914
|
if (previous, current) in ALWAYS_AUTO_TRANSITIONS:
|
|
1254
1915
|
return False
|
|
1255
1916
|
if current == "CLOSED":
|
|
1256
1917
|
return True
|
|
1257
|
-
if
|
|
1918
|
+
if approval_mode == "auto":
|
|
1258
1919
|
return False
|
|
1259
|
-
if
|
|
1920
|
+
if approval_mode == "guard":
|
|
1260
1921
|
return (previous, current) in CRITICAL_CONFIRM_TRANSITIONS
|
|
1261
|
-
if
|
|
1922
|
+
if approval_mode == "confirm":
|
|
1923
|
+
return (previous, current) == ANALYSIS_CONFIRM_TRANSITION
|
|
1924
|
+
if approval_mode == "approve":
|
|
1262
1925
|
return True
|
|
1263
|
-
raise StateError(f"Unknown
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
def validate_confirm_mode_transition(
|
|
1267
|
-
previous: str,
|
|
1268
|
-
current: str,
|
|
1269
|
-
confirm_mode: str,
|
|
1270
|
-
) -> str | None:
|
|
1271
|
-
if confirm_mode == "lite" and (previous, current) == LITE_SKIPPED_TRANSITION:
|
|
1272
|
-
return "LITE MODE TRANSITION: IMPLEMENT -> REVIEW is disabled; use IMPLEMENT -> VERIFICATION."
|
|
1273
|
-
return None
|
|
1926
|
+
raise StateError(f"Unknown approval mode: {approval_mode}")
|
|
1274
1927
|
|
|
1275
1928
|
|
|
1276
1929
|
def is_automatic_transition(
|
|
1277
1930
|
previous: str,
|
|
1278
1931
|
current: str,
|
|
1279
1932
|
task_type: str,
|
|
1280
|
-
|
|
1933
|
+
approval_mode: str,
|
|
1281
1934
|
) -> bool:
|
|
1282
|
-
return not transition_requires_confirmation(previous, current, task_type,
|
|
1935
|
+
return not transition_requires_confirmation(previous, current, task_type, approval_mode)
|
|
1283
1936
|
|
|
1284
1937
|
|
|
1285
|
-
def validate_transition(
|
|
1938
|
+
def validate_transition(
|
|
1939
|
+
previous: str,
|
|
1940
|
+
current: str,
|
|
1941
|
+
task_type: str = "",
|
|
1942
|
+
task: dict | None = None,
|
|
1943
|
+
) -> str | None:
|
|
1286
1944
|
if previous == current:
|
|
1287
1945
|
return None
|
|
1288
1946
|
normalized_task_type = task_type.strip().lower()
|
|
@@ -1291,6 +1949,11 @@ def validate_transition(previous: str, current: str, task_type: str = "") -> str
|
|
|
1291
1949
|
allowed = {"ANALYSIS", "COMPLETE", "CLOSED"}
|
|
1292
1950
|
elif previous == "IMPLEMENT":
|
|
1293
1951
|
allowed.discard("COMPLETE")
|
|
1952
|
+
if not (
|
|
1953
|
+
isinstance(task, dict)
|
|
1954
|
+
and task.get("workflow_mode_legacy_direct_edge") is True
|
|
1955
|
+
):
|
|
1956
|
+
allowed.discard("VERIFICATION")
|
|
1294
1957
|
if current in allowed:
|
|
1295
1958
|
return None
|
|
1296
1959
|
return (
|
|
@@ -1326,9 +1989,20 @@ def snapshot_state(
|
|
|
1326
1989
|
missing = False
|
|
1327
1990
|
status = "idle"
|
|
1328
1991
|
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1992
|
+
(
|
|
1993
|
+
project_approval_mode,
|
|
1994
|
+
session_approval_mode,
|
|
1995
|
+
effective_approval_mode,
|
|
1996
|
+
project_workflow_mode,
|
|
1997
|
+
session_workflow_mode,
|
|
1998
|
+
configured_workflow_mode,
|
|
1999
|
+
) = resolve_behavior(root, resolved_session)
|
|
2000
|
+
concrete_workflow_mode = None
|
|
2001
|
+
if task:
|
|
2002
|
+
concrete_workflow_mode = task.get("workflow_mode")
|
|
2003
|
+
proposal = task.get("workflow_mode_proposal")
|
|
2004
|
+
if concrete_workflow_mode is None and isinstance(proposal, dict):
|
|
2005
|
+
concrete_workflow_mode = proposal.get("selected_mode")
|
|
1332
2006
|
|
|
1333
2007
|
return {
|
|
1334
2008
|
"session_file": display_path(root, session_path),
|
|
@@ -1342,9 +2016,17 @@ def snapshot_state(
|
|
|
1342
2016
|
"last_agent": task.get("last_agent") if task else None,
|
|
1343
2017
|
"project_init_required": is_project_init_required(root),
|
|
1344
2018
|
"pending_init_version": get_pending_init_version(root),
|
|
1345
|
-
"
|
|
1346
|
-
"
|
|
1347
|
-
"
|
|
2019
|
+
"project_approval_mode": project_approval_mode,
|
|
2020
|
+
"session_approval_mode": session_approval_mode,
|
|
2021
|
+
"effective_approval_mode": effective_approval_mode,
|
|
2022
|
+
"project_workflow_mode": project_workflow_mode,
|
|
2023
|
+
"session_workflow_mode": session_workflow_mode,
|
|
2024
|
+
"configured_workflow_mode": configured_workflow_mode,
|
|
2025
|
+
"concrete_workflow_mode": concrete_workflow_mode,
|
|
2026
|
+
# Compatibility output aliases for pre-0.9 clients.
|
|
2027
|
+
"project_confirm_mode": project_approval_mode,
|
|
2028
|
+
"session_confirm_mode": session_approval_mode,
|
|
2029
|
+
"effective_confirm_mode": effective_approval_mode,
|
|
1348
2030
|
"harness_disabled": resolved_session.get("harness_disabled") is True,
|
|
1349
2031
|
}
|
|
1350
2032
|
|
|
@@ -1356,7 +2038,9 @@ def build_status_line(
|
|
|
1356
2038
|
session_file: str | Path | None = None,
|
|
1357
2039
|
) -> str:
|
|
1358
2040
|
state = snapshot_state(root, session_file, session)
|
|
1359
|
-
|
|
2041
|
+
approval = str(state["effective_approval_mode"]).capitalize()
|
|
2042
|
+
workflow = str(state["concrete_workflow_mode"] or state["configured_workflow_mode"]).capitalize()
|
|
2043
|
+
status_brand = f"> **Easy Coding** · **Approval: {approval}** · **Workflow: {workflow}**"
|
|
1360
2044
|
task_id = state["current_task"]
|
|
1361
2045
|
if task_id:
|
|
1362
2046
|
status = str(state["status"])
|
|
@@ -1395,8 +2079,11 @@ def build_machine_breadcrumbs(
|
|
|
1395
2079
|
lines = [
|
|
1396
2080
|
f"[workflow-state:{stage}]",
|
|
1397
2081
|
f"[easy-coding:session-file:{resolved_session_file}]",
|
|
1398
|
-
f"[easy-coding:
|
|
2082
|
+
f"[easy-coding:approval-mode:{state['effective_approval_mode']}]",
|
|
2083
|
+
f"[easy-coding:configured-workflow-mode:{state['configured_workflow_mode']}]",
|
|
1399
2084
|
]
|
|
2085
|
+
if state.get("concrete_workflow_mode"):
|
|
2086
|
+
lines.append(f"[easy-coding:workflow-mode:{state['concrete_workflow_mode']}]")
|
|
1400
2087
|
|
|
1401
2088
|
if task_id:
|
|
1402
2089
|
lines.append(f"[current-task:{task_id}]")
|
|
@@ -1412,19 +2099,21 @@ def build_machine_breadcrumbs(
|
|
|
1412
2099
|
if target:
|
|
1413
2100
|
lines.append(f"[easy-coding:pending-transition:{source}->{target}]")
|
|
1414
2101
|
task_type = str(task.get("type") or "") if task else ""
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
2102
|
+
legacy_review_bypass = (
|
|
2103
|
+
source == "IMPLEMENT"
|
|
2104
|
+
and target == "REVIEW"
|
|
2105
|
+
and isinstance(task, dict)
|
|
2106
|
+
and task.get("workflow_mode_legacy_direct_edge") is True
|
|
1420
2107
|
)
|
|
1421
|
-
if
|
|
1422
|
-
lines.append(
|
|
2108
|
+
if legacy_review_bypass:
|
|
2109
|
+
lines.append(
|
|
2110
|
+
"[easy-coding:lite-review-bypass-required:IMPLEMENT->REVIEW]"
|
|
2111
|
+
)
|
|
1423
2112
|
elif is_automatic_transition(
|
|
1424
2113
|
source,
|
|
1425
2114
|
target,
|
|
1426
2115
|
task_type,
|
|
1427
|
-
str(state["
|
|
2116
|
+
str(state["effective_approval_mode"]),
|
|
1428
2117
|
):
|
|
1429
2118
|
lines.append(f"[easy-coding:auto-transition-ready:{source}->{target}]")
|
|
1430
2119
|
else:
|
|
@@ -1575,16 +2264,52 @@ def clear_current_task(root: Path, agent: str, session_file: str | Path | None =
|
|
|
1575
2264
|
return snapshot_state(root, session_file, session)
|
|
1576
2265
|
|
|
1577
2266
|
|
|
1578
|
-
def
|
|
2267
|
+
def set_session_approval_mode(
|
|
2268
|
+
root: Path,
|
|
2269
|
+
mode: str,
|
|
2270
|
+
agent: str,
|
|
2271
|
+
session_file: str | Path | None = None,
|
|
2272
|
+
) -> dict:
|
|
2273
|
+
if mode not in APPROVAL_MODES:
|
|
2274
|
+
raise StateError("Invalid approval mode: expected approve, guard, confirm, or auto.")
|
|
2275
|
+
session = ensure_session(root, session_file)
|
|
2276
|
+
materialize_legacy_session_behavior(session)
|
|
2277
|
+
session["approval_mode"] = mode
|
|
2278
|
+
session["last_agent"] = agent
|
|
2279
|
+
write_session(root, session, session_file)
|
|
2280
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
2281
|
+
snapshot["action"] = "set-approval-mode"
|
|
2282
|
+
return snapshot
|
|
2283
|
+
|
|
2284
|
+
|
|
2285
|
+
def set_session_legacy_confirm_mode(
|
|
1579
2286
|
root: Path,
|
|
1580
2287
|
mode: str,
|
|
1581
2288
|
agent: str,
|
|
1582
2289
|
session_file: str | Path | None = None,
|
|
1583
2290
|
) -> dict:
|
|
1584
|
-
if mode not in CONFIRM_MODES:
|
|
1585
|
-
raise StateError("Invalid confirm mode: expected approve, guard, lite, or auto.")
|
|
1586
2291
|
session = ensure_session(root, session_file)
|
|
1587
|
-
session
|
|
2292
|
+
materialize_legacy_session_behavior(session)
|
|
2293
|
+
if mode == "lite":
|
|
2294
|
+
session["approval_mode"] = "guard"
|
|
2295
|
+
session["workflow_mode"] = "fast"
|
|
2296
|
+
session["workflow_mode_legacy_confirm_override"] = True
|
|
2297
|
+
session.pop("workflow_mode_legacy_alias_override", None)
|
|
2298
|
+
else:
|
|
2299
|
+
session["approval_mode"] = mode
|
|
2300
|
+
legacy_lite_owned = (
|
|
2301
|
+
session.pop("workflow_mode_legacy_confirm_override", None) is True
|
|
2302
|
+
)
|
|
2303
|
+
legacy_alias_owned = (
|
|
2304
|
+
session.get("workflow_mode_legacy_alias_override") is True
|
|
2305
|
+
)
|
|
2306
|
+
if (
|
|
2307
|
+
"workflow_mode" not in session
|
|
2308
|
+
or legacy_lite_owned
|
|
2309
|
+
or legacy_alias_owned
|
|
2310
|
+
):
|
|
2311
|
+
session["workflow_mode"] = "adaptive"
|
|
2312
|
+
session["workflow_mode_legacy_alias_override"] = True
|
|
1588
2313
|
session["last_agent"] = agent
|
|
1589
2314
|
write_session(root, session, session_file)
|
|
1590
2315
|
snapshot = snapshot_state(root, session_file, session)
|
|
@@ -1592,13 +2317,33 @@ def set_session_confirm_mode(
|
|
|
1592
2317
|
return snapshot
|
|
1593
2318
|
|
|
1594
2319
|
|
|
1595
|
-
def
|
|
2320
|
+
def clear_session_approval_mode(
|
|
1596
2321
|
root: Path,
|
|
1597
2322
|
agent: str,
|
|
1598
2323
|
session_file: str | Path | None = None,
|
|
1599
2324
|
) -> dict:
|
|
1600
2325
|
session = ensure_session(root, session_file)
|
|
1601
|
-
session
|
|
2326
|
+
materialize_legacy_session_behavior(session)
|
|
2327
|
+
session.pop("approval_mode", None)
|
|
2328
|
+
session["last_agent"] = agent
|
|
2329
|
+
write_session(root, session, session_file)
|
|
2330
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
2331
|
+
snapshot["action"] = "clear-approval-mode"
|
|
2332
|
+
return snapshot
|
|
2333
|
+
|
|
2334
|
+
|
|
2335
|
+
def clear_session_legacy_confirm_mode(
|
|
2336
|
+
root: Path,
|
|
2337
|
+
agent: str,
|
|
2338
|
+
session_file: str | Path | None = None,
|
|
2339
|
+
) -> dict:
|
|
2340
|
+
session = ensure_session(root, session_file)
|
|
2341
|
+
materialize_legacy_session_behavior(session)
|
|
2342
|
+
session.pop("approval_mode", None)
|
|
2343
|
+
legacy_lite_owned = session.pop("workflow_mode_legacy_confirm_override", False)
|
|
2344
|
+
legacy_alias_owned = session.pop("workflow_mode_legacy_alias_override", False)
|
|
2345
|
+
if legacy_lite_owned or legacy_alias_owned:
|
|
2346
|
+
session.pop("workflow_mode", None)
|
|
1602
2347
|
session["last_agent"] = agent
|
|
1603
2348
|
write_session(root, session, session_file)
|
|
1604
2349
|
snapshot = snapshot_state(root, session_file, session)
|
|
@@ -1606,6 +2351,45 @@ def clear_session_confirm_mode(
|
|
|
1606
2351
|
return snapshot
|
|
1607
2352
|
|
|
1608
2353
|
|
|
2354
|
+
def set_session_workflow_mode(
|
|
2355
|
+
root: Path,
|
|
2356
|
+
mode: str,
|
|
2357
|
+
agent: str,
|
|
2358
|
+
session_file: str | Path | None = None,
|
|
2359
|
+
) -> dict:
|
|
2360
|
+
if mode not in CONFIGURED_WORKFLOW_MODES:
|
|
2361
|
+
raise StateError(
|
|
2362
|
+
"Invalid workflow mode: expected adaptive, fast, standard, or strict."
|
|
2363
|
+
)
|
|
2364
|
+
session = ensure_session(root, session_file)
|
|
2365
|
+
materialize_legacy_session_behavior(session)
|
|
2366
|
+
session["workflow_mode"] = mode
|
|
2367
|
+
session.pop("workflow_mode_legacy_confirm_override", None)
|
|
2368
|
+
session.pop("workflow_mode_legacy_alias_override", None)
|
|
2369
|
+
session["last_agent"] = agent
|
|
2370
|
+
write_session(root, session, session_file)
|
|
2371
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
2372
|
+
snapshot["action"] = "set-workflow-mode"
|
|
2373
|
+
return snapshot
|
|
2374
|
+
|
|
2375
|
+
|
|
2376
|
+
def clear_session_workflow_mode(
|
|
2377
|
+
root: Path,
|
|
2378
|
+
agent: str,
|
|
2379
|
+
session_file: str | Path | None = None,
|
|
2380
|
+
) -> dict:
|
|
2381
|
+
session = ensure_session(root, session_file)
|
|
2382
|
+
materialize_legacy_session_behavior(session)
|
|
2383
|
+
session.pop("workflow_mode", None)
|
|
2384
|
+
session.pop("workflow_mode_legacy_confirm_override", None)
|
|
2385
|
+
session.pop("workflow_mode_legacy_alias_override", None)
|
|
2386
|
+
session["last_agent"] = agent
|
|
2387
|
+
write_session(root, session, session_file)
|
|
2388
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
2389
|
+
snapshot["action"] = "clear-workflow-mode"
|
|
2390
|
+
return snapshot
|
|
2391
|
+
|
|
2392
|
+
|
|
1609
2393
|
def set_harness_disabled(
|
|
1610
2394
|
root: Path,
|
|
1611
2395
|
disabled: bool,
|
|
@@ -1751,6 +2535,200 @@ def resolve_current_task(
|
|
|
1751
2535
|
return session, str(resolved_task_id), task
|
|
1752
2536
|
|
|
1753
2537
|
|
|
2538
|
+
def validate_workflow_mode_proposal(
|
|
2539
|
+
root: Path,
|
|
2540
|
+
session: dict,
|
|
2541
|
+
proposal: object,
|
|
2542
|
+
task_id: str | None = None,
|
|
2543
|
+
) -> dict:
|
|
2544
|
+
if not isinstance(proposal, dict):
|
|
2545
|
+
raise StateError("workflow_mode_proposal is missing.")
|
|
2546
|
+
configured = str(proposal.get("configured_mode") or "")
|
|
2547
|
+
selected = str(proposal.get("selected_mode") or "")
|
|
2548
|
+
minimum = str(proposal.get("minimum_mode") or "")
|
|
2549
|
+
source = str(proposal.get("source") or "")
|
|
2550
|
+
reasons = proposal.get("reasons")
|
|
2551
|
+
effective_configured = resolve_behavior(root, session)[5]
|
|
2552
|
+
if configured != effective_configured:
|
|
2553
|
+
raise StateError(
|
|
2554
|
+
"Workflow proposal configured_mode no longer matches the effective project/session setting."
|
|
2555
|
+
)
|
|
2556
|
+
if configured not in CONFIGURED_WORKFLOW_MODES:
|
|
2557
|
+
raise StateError("Invalid configured workflow mode.")
|
|
2558
|
+
if selected not in WORKFLOW_MODES or minimum not in WORKFLOW_MODES:
|
|
2559
|
+
raise StateError("selected_mode and minimum_mode must be fast, standard, or strict.")
|
|
2560
|
+
if source not in {"project", "session", "adaptive", "user", "migration"}:
|
|
2561
|
+
raise StateError("Invalid workflow proposal source.")
|
|
2562
|
+
if not is_string_list(reasons, allow_empty=False):
|
|
2563
|
+
raise StateError("Workflow proposal reasons must contain at least one non-empty reason.")
|
|
2564
|
+
required_rank = WORKFLOW_MODE_RANK[minimum]
|
|
2565
|
+
if configured in WORKFLOW_MODES and WORKFLOW_MODE_RANK[minimum] < WORKFLOW_MODE_RANK[configured]:
|
|
2566
|
+
raise StateError(
|
|
2567
|
+
f"Workflow minimum {minimum} is below configured floor {configured}."
|
|
2568
|
+
)
|
|
2569
|
+
if task_id:
|
|
2570
|
+
calculated_minimum, calculated_reasons = calculate_workflow_floor(root, task_id)
|
|
2571
|
+
calculated_rank = WORKFLOW_MODE_RANK[calculated_minimum]
|
|
2572
|
+
if WORKFLOW_MODE_RANK[minimum] < calculated_rank:
|
|
2573
|
+
raise StateError(
|
|
2574
|
+
f"Workflow minimum {minimum} is below calculated floor {calculated_minimum}: "
|
|
2575
|
+
+ ", ".join(calculated_reasons)
|
|
2576
|
+
)
|
|
2577
|
+
required_rank = max(required_rank, calculated_rank)
|
|
2578
|
+
if configured in WORKFLOW_MODES:
|
|
2579
|
+
required_rank = max(required_rank, WORKFLOW_MODE_RANK[configured])
|
|
2580
|
+
if WORKFLOW_MODE_RANK[selected] < required_rank:
|
|
2581
|
+
raise StateError(
|
|
2582
|
+
f"Workflow mode {selected} is below the allowed minimum for this task."
|
|
2583
|
+
)
|
|
2584
|
+
return proposal
|
|
2585
|
+
|
|
2586
|
+
|
|
2587
|
+
def calculate_workflow_floor(root: Path, task_id: str) -> tuple[str, list[str]]:
|
|
2588
|
+
task = load_task(root, task_id)
|
|
2589
|
+
if task is None:
|
|
2590
|
+
raise StateError(f"Task not found: {task_id}")
|
|
2591
|
+
task_type = str(task.get("type") or "").strip().lower()
|
|
2592
|
+
if task_type in NO_CODE_TASK_TYPES:
|
|
2593
|
+
return "fast", ["read-only-task"]
|
|
2594
|
+
|
|
2595
|
+
plan = latest_execution_plan(root, task_id)
|
|
2596
|
+
if not plan:
|
|
2597
|
+
raise StateError("Cannot calculate workflow floor without a valid execution plan.")
|
|
2598
|
+
units = [unit for unit in plan.get("units", []) if isinstance(unit, dict)]
|
|
2599
|
+
files = {
|
|
2600
|
+
str(file_name)
|
|
2601
|
+
for unit in units
|
|
2602
|
+
for file_name in unit.get("files", [])
|
|
2603
|
+
if is_non_empty_string(file_name)
|
|
2604
|
+
}
|
|
2605
|
+
repositories = task_repository_roots(root, task, plan)
|
|
2606
|
+
repos = task.get("repos")
|
|
2607
|
+
repo_paths = task.get("repo_paths")
|
|
2608
|
+
metadata_repo_count = max(
|
|
2609
|
+
len(repos) if isinstance(repos, list) else 0,
|
|
2610
|
+
len(repo_paths) if isinstance(repo_paths, dict) else 0,
|
|
2611
|
+
)
|
|
2612
|
+
repo_count = max(len(repositories), metadata_repo_count)
|
|
2613
|
+
risk_text = " ".join(
|
|
2614
|
+
[
|
|
2615
|
+
str(task.get("title") or ""),
|
|
2616
|
+
task_type,
|
|
2617
|
+
*files,
|
|
2618
|
+
*[
|
|
2619
|
+
str(item)
|
|
2620
|
+
for unit in units
|
|
2621
|
+
for field in ("risks", "contracts")
|
|
2622
|
+
for item in unit.get(field, [])
|
|
2623
|
+
if is_non_empty_string(item)
|
|
2624
|
+
and str(item).strip().lower() not in {"none", "no", "n/a", "无", "无风险"}
|
|
2625
|
+
],
|
|
2626
|
+
]
|
|
2627
|
+
)
|
|
2628
|
+
strict_reasons: list[str] = []
|
|
2629
|
+
if repo_count > 1:
|
|
2630
|
+
strict_reasons.append("cross-repository-scope")
|
|
2631
|
+
if len(units) >= 4 or len(files) >= 8:
|
|
2632
|
+
strict_reasons.append("broad-change-scope")
|
|
2633
|
+
if STRICT_WORKFLOW_RISK_PATTERN.search(risk_text):
|
|
2634
|
+
strict_reasons.append("high-risk-contract-or-domain")
|
|
2635
|
+
if strict_reasons:
|
|
2636
|
+
return "strict", strict_reasons
|
|
2637
|
+
|
|
2638
|
+
standard_reasons: list[str] = []
|
|
2639
|
+
if len(units) > 1:
|
|
2640
|
+
standard_reasons.append("multiple-units")
|
|
2641
|
+
if len(files) >= 3:
|
|
2642
|
+
standard_reasons.append("multi-file-impact")
|
|
2643
|
+
if plan.get("strategy") == "parallel":
|
|
2644
|
+
standard_reasons.append("parallel-execution")
|
|
2645
|
+
if standard_reasons:
|
|
2646
|
+
return "standard", standard_reasons
|
|
2647
|
+
return "fast", ["single-bounded-unit"]
|
|
2648
|
+
|
|
2649
|
+
|
|
2650
|
+
def propose_workflow_mode(
|
|
2651
|
+
root: Path,
|
|
2652
|
+
configured_mode: str,
|
|
2653
|
+
selected_mode: str,
|
|
2654
|
+
minimum_mode: str,
|
|
2655
|
+
source: str,
|
|
2656
|
+
reasons: list[str],
|
|
2657
|
+
agent: str,
|
|
2658
|
+
task_id: str | None = None,
|
|
2659
|
+
session_file: str | Path | None = None,
|
|
2660
|
+
) -> dict:
|
|
2661
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
2662
|
+
if str(task.get("status") or "") != "ANALYSIS":
|
|
2663
|
+
raise StateError("Workflow mode can only be proposed during ANALYSIS.")
|
|
2664
|
+
proposal = {
|
|
2665
|
+
"configured_mode": configured_mode,
|
|
2666
|
+
"selected_mode": selected_mode,
|
|
2667
|
+
"minimum_mode": minimum_mode,
|
|
2668
|
+
"source": source,
|
|
2669
|
+
"reasons": [reason.strip() for reason in reasons if reason.strip()],
|
|
2670
|
+
"proposed_at": now_iso(),
|
|
2671
|
+
"proposed_by": agent,
|
|
2672
|
+
}
|
|
2673
|
+
validate_workflow_mode_proposal(root, session, proposal, resolved_task_id)
|
|
2674
|
+
task["workflow_mode_proposal"] = proposal
|
|
2675
|
+
task["last_agent"] = agent
|
|
2676
|
+
write_task(root, resolved_task_id, task)
|
|
2677
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
2678
|
+
snapshot["action"] = "propose-workflow-mode"
|
|
2679
|
+
return snapshot
|
|
2680
|
+
|
|
2681
|
+
|
|
2682
|
+
def freeze_workflow_mode(
|
|
2683
|
+
root: Path, session: dict, task_id: str, task: dict, agent: str
|
|
2684
|
+
) -> None:
|
|
2685
|
+
proposal = validate_workflow_mode_proposal(
|
|
2686
|
+
root, session, task.get("workflow_mode_proposal"), task_id
|
|
2687
|
+
)
|
|
2688
|
+
task["workflow_mode"] = proposal["selected_mode"]
|
|
2689
|
+
task["workflow_mode_confirmed_at"] = now_iso()
|
|
2690
|
+
task["workflow_mode_confirmed_by"] = agent
|
|
2691
|
+
|
|
2692
|
+
|
|
2693
|
+
def raise_workflow_mode(
|
|
2694
|
+
root: Path,
|
|
2695
|
+
mode: str,
|
|
2696
|
+
reason: str,
|
|
2697
|
+
agent: str,
|
|
2698
|
+
task_id: str | None = None,
|
|
2699
|
+
session_file: str | Path | None = None,
|
|
2700
|
+
) -> dict:
|
|
2701
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
2702
|
+
stage = str(task.get("status") or "")
|
|
2703
|
+
if stage == "VERIFICATION":
|
|
2704
|
+
raise StateError(
|
|
2705
|
+
"Return to IMPLEMENT before raising workflow mode from VERIFICATION so the "
|
|
2706
|
+
"task can re-enter REVIEW with fresh evidence."
|
|
2707
|
+
)
|
|
2708
|
+
if stage not in {"IMPLEMENT", "REVIEW"}:
|
|
2709
|
+
raise StateError("A frozen workflow mode can only be raised during active execution.")
|
|
2710
|
+
current = str(task.get("workflow_mode") or "")
|
|
2711
|
+
if current not in WORKFLOW_MODES or mode not in WORKFLOW_MODES:
|
|
2712
|
+
raise StateError("Workflow mode must be frozen before it can be raised.")
|
|
2713
|
+
if WORKFLOW_MODE_RANK[mode] <= WORKFLOW_MODE_RANK[current]:
|
|
2714
|
+
raise StateError(f"Workflow mode can only be raised above {current}.")
|
|
2715
|
+
task["workflow_mode"] = mode
|
|
2716
|
+
task.setdefault("workflow_mode_escalations", []).append(
|
|
2717
|
+
{
|
|
2718
|
+
"from": current,
|
|
2719
|
+
"to": mode,
|
|
2720
|
+
"reason": reason.strip(),
|
|
2721
|
+
"raised_at": now_iso(),
|
|
2722
|
+
"raised_by": agent,
|
|
2723
|
+
}
|
|
2724
|
+
)
|
|
2725
|
+
task["last_agent"] = agent
|
|
2726
|
+
write_task(root, resolved_task_id, task)
|
|
2727
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
2728
|
+
snapshot["action"] = "raise-workflow-mode"
|
|
2729
|
+
return snapshot
|
|
2730
|
+
|
|
2731
|
+
|
|
1754
2732
|
def request_transition(
|
|
1755
2733
|
root: Path,
|
|
1756
2734
|
stage: str,
|
|
@@ -1764,23 +2742,31 @@ def request_transition(
|
|
|
1764
2742
|
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
1765
2743
|
previous = str(task.get("status") or "idle")
|
|
1766
2744
|
task_type = str(task.get("type") or "")
|
|
1767
|
-
|
|
2745
|
+
approval_mode = resolve_approval_mode(root, session)[2]
|
|
1768
2746
|
if previous == stage:
|
|
1769
2747
|
raise StateError(f"Transition target must differ from current stage: {stage}")
|
|
1770
2748
|
|
|
1771
|
-
violation = validate_transition(previous, stage, task_type)
|
|
2749
|
+
violation = validate_transition(previous, stage, task_type, task)
|
|
1772
2750
|
if violation:
|
|
1773
2751
|
raise StateError(violation)
|
|
1774
|
-
|
|
1775
|
-
if mode_violation:
|
|
1776
|
-
raise StateError(mode_violation)
|
|
1777
|
-
if is_automatic_transition(previous, stage, task_type, confirm_mode):
|
|
2752
|
+
if is_automatic_transition(previous, stage, task_type, approval_mode):
|
|
1778
2753
|
raise StateError(
|
|
1779
|
-
f"Transition {previous} -> {stage} is automatic in {
|
|
2754
|
+
f"Transition {previous} -> {stage} is automatic in {approval_mode} mode; "
|
|
1780
2755
|
"use auto-transition instead."
|
|
1781
2756
|
)
|
|
1782
2757
|
if previous == "ANALYSIS" and stage == "IMPLEMENT":
|
|
1783
2758
|
validate_analysis_readiness(root, resolved_task_id)
|
|
2759
|
+
if task.get("workflow_mode_legacy") is not True:
|
|
2760
|
+
validate_workflow_mode_proposal(
|
|
2761
|
+
root,
|
|
2762
|
+
session,
|
|
2763
|
+
task.get("workflow_mode_proposal"),
|
|
2764
|
+
resolved_task_id,
|
|
2765
|
+
)
|
|
2766
|
+
if previous == "REVIEW" and stage == "VERIFICATION":
|
|
2767
|
+
validate_review_readiness(root, resolved_task_id, task)
|
|
2768
|
+
if previous == "VERIFICATION" and stage == "MEMORY":
|
|
2769
|
+
validate_verification_readiness(root, resolved_task_id, task)
|
|
1784
2770
|
existing = task.get("pending_transition")
|
|
1785
2771
|
if isinstance(existing, dict):
|
|
1786
2772
|
if existing.get("from") != previous or existing.get("to") != stage:
|
|
@@ -1816,15 +2802,19 @@ def apply_transition(
|
|
|
1816
2802
|
|
|
1817
2803
|
previous = str(task.get("status") or "idle")
|
|
1818
2804
|
task_type = str(task.get("type") or "")
|
|
1819
|
-
|
|
1820
|
-
|
|
2805
|
+
approval_mode = resolve_approval_mode(root, session)[2]
|
|
2806
|
+
legacy_edge = task.get("workflow_mode_legacy") is True
|
|
2807
|
+
violation = validate_transition(previous, stage, task_type, task)
|
|
1821
2808
|
if violation:
|
|
1822
2809
|
raise StateError(violation)
|
|
1823
|
-
mode_violation = validate_confirm_mode_transition(previous, stage, confirm_mode)
|
|
1824
|
-
if mode_violation:
|
|
1825
|
-
raise StateError(mode_violation)
|
|
1826
2810
|
if previous == "ANALYSIS" and stage == "IMPLEMENT":
|
|
1827
2811
|
validate_analysis_readiness(root, resolved_task_id)
|
|
2812
|
+
if task.get("workflow_mode_legacy") is not True:
|
|
2813
|
+
freeze_workflow_mode(root, session, resolved_task_id, task, agent)
|
|
2814
|
+
if previous == "REVIEW" and stage == "VERIFICATION":
|
|
2815
|
+
validate_review_readiness(root, resolved_task_id, task)
|
|
2816
|
+
if previous == "VERIFICATION" and stage == "MEMORY":
|
|
2817
|
+
validate_verification_readiness(root, resolved_task_id, task)
|
|
1828
2818
|
if previous == "MEMORY" and stage == "COMPLETE":
|
|
1829
2819
|
progress = task.get("memory_progress")
|
|
1830
2820
|
if not isinstance(progress, dict) or progress.get("completed") is not True:
|
|
@@ -1834,6 +2824,15 @@ def apply_transition(
|
|
|
1834
2824
|
if previous != stage:
|
|
1835
2825
|
task["status"] = stage
|
|
1836
2826
|
append_stage_history(task, stage, agent)
|
|
2827
|
+
if legacy_edge:
|
|
2828
|
+
task.pop("workflow_mode_legacy", None)
|
|
2829
|
+
if previous in {"IMPLEMENT", "REVIEW"} and stage == "VERIFICATION":
|
|
2830
|
+
task["workflow_mode_legacy_review_bypass_fingerprint"] = (
|
|
2831
|
+
implementation_fingerprint(root, resolved_task_id)
|
|
2832
|
+
)
|
|
2833
|
+
task.pop("workflow_mode_legacy_direct_edge", None)
|
|
2834
|
+
if stage in {"ANALYSIS", "IMPLEMENT", "MEMORY", "COMPLETE", "CLOSED"}:
|
|
2835
|
+
task.pop("workflow_mode_legacy_review_bypass_fingerprint", None)
|
|
1837
2836
|
task.pop("pending_transition", None)
|
|
1838
2837
|
if stage == "MEMORY" and previous != stage:
|
|
1839
2838
|
task["memory_progress"] = {}
|
|
@@ -1861,10 +2860,10 @@ def auto_transition(
|
|
|
1861
2860
|
session, _, task = resolve_current_task(root, task_id, session_file)
|
|
1862
2861
|
previous = str(task.get("status") or "idle")
|
|
1863
2862
|
task_type = str(task.get("type") or "")
|
|
1864
|
-
|
|
1865
|
-
if not is_automatic_transition(previous, stage, task_type,
|
|
2863
|
+
approval_mode = resolve_approval_mode(root, session)[2]
|
|
2864
|
+
if not is_automatic_transition(previous, stage, task_type, approval_mode):
|
|
1866
2865
|
raise StateError(
|
|
1867
|
-
f"Automatic transition is not allowed in {
|
|
2866
|
+
f"Automatic transition is not allowed in {approval_mode} mode: {previous} -> {stage}."
|
|
1868
2867
|
)
|
|
1869
2868
|
|
|
1870
2869
|
pending = task.get("pending_transition")
|
|
@@ -1894,7 +2893,7 @@ def confirm_transition(
|
|
|
1894
2893
|
raise StateError("No transition is pending user confirmation.")
|
|
1895
2894
|
previous = str(task.get("status") or "idle")
|
|
1896
2895
|
task_type = str(task.get("type") or "")
|
|
1897
|
-
|
|
2896
|
+
approval_mode = resolve_approval_mode(root, session)[2]
|
|
1898
2897
|
source = str(pending.get("from") or "")
|
|
1899
2898
|
target = str(pending.get("to") or "")
|
|
1900
2899
|
if source != previous:
|
|
@@ -1903,12 +2902,9 @@ def confirm_transition(
|
|
|
1903
2902
|
)
|
|
1904
2903
|
if stage and stage != target:
|
|
1905
2904
|
raise StateError(f"Pending transition targets {target}, not {stage}.")
|
|
1906
|
-
|
|
1907
|
-
if mode_violation:
|
|
1908
|
-
raise StateError(mode_violation)
|
|
1909
|
-
if is_automatic_transition(source, target, task_type, confirm_mode):
|
|
2905
|
+
if is_automatic_transition(source, target, task_type, approval_mode):
|
|
1910
2906
|
raise StateError(
|
|
1911
|
-
f"Transition {source} -> {target} is automatic in {
|
|
2907
|
+
f"Transition {source} -> {target} is automatic in {approval_mode} mode; "
|
|
1912
2908
|
"use auto-transition instead."
|
|
1913
2909
|
)
|
|
1914
2910
|
|
|
@@ -2138,7 +3134,7 @@ def record_seen_stage(
|
|
|
2138
3134
|
if last_seen_task == task_id and last_seen_stage:
|
|
2139
3135
|
task = load_task(root, task_id)
|
|
2140
3136
|
task_type = str(task.get("type") or "") if task else ""
|
|
2141
|
-
violation = validate_transition(str(last_seen_stage), stage, task_type)
|
|
3137
|
+
violation = validate_transition(str(last_seen_stage), stage, task_type, task)
|
|
2142
3138
|
|
|
2143
3139
|
if last_seen_task != task_id or last_seen_stage != stage:
|
|
2144
3140
|
session["last_seen_task"] = task_id
|
|
@@ -2191,13 +3187,67 @@ def main() -> int:
|
|
|
2191
3187
|
clear_current = subcommands.add_parser("clear-current", parents=[common])
|
|
2192
3188
|
clear_current.add_argument("--agent", required=True)
|
|
2193
3189
|
|
|
3190
|
+
set_approval_mode_parser = subcommands.add_parser("set-approval-mode", parents=[common])
|
|
3191
|
+
set_approval_mode_parser.add_argument("--mode", required=True, choices=sorted(APPROVAL_MODES))
|
|
3192
|
+
set_approval_mode_parser.add_argument("--agent", required=True)
|
|
3193
|
+
|
|
3194
|
+
clear_approval_mode_parser = subcommands.add_parser("clear-approval-mode", parents=[common])
|
|
3195
|
+
clear_approval_mode_parser.add_argument("--agent", required=True)
|
|
3196
|
+
|
|
3197
|
+
set_workflow_mode_parser = subcommands.add_parser("set-workflow-mode", parents=[common])
|
|
3198
|
+
set_workflow_mode_parser.add_argument(
|
|
3199
|
+
"--mode", required=True, choices=sorted(CONFIGURED_WORKFLOW_MODES)
|
|
3200
|
+
)
|
|
3201
|
+
set_workflow_mode_parser.add_argument("--agent", required=True)
|
|
3202
|
+
|
|
3203
|
+
clear_workflow_mode_parser = subcommands.add_parser("clear-workflow-mode", parents=[common])
|
|
3204
|
+
clear_workflow_mode_parser.add_argument("--agent", required=True)
|
|
3205
|
+
|
|
3206
|
+
# Compatibility aliases for pre-0.9 callers.
|
|
2194
3207
|
set_confirm_mode_parser = subcommands.add_parser("set-confirm-mode", parents=[common])
|
|
2195
|
-
set_confirm_mode_parser.add_argument(
|
|
3208
|
+
set_confirm_mode_parser.add_argument(
|
|
3209
|
+
"--mode", required=True, choices=sorted(APPROVAL_MODES | {"lite"})
|
|
3210
|
+
)
|
|
2196
3211
|
set_confirm_mode_parser.add_argument("--agent", required=True)
|
|
2197
3212
|
|
|
2198
3213
|
clear_confirm_mode_parser = subcommands.add_parser("clear-confirm-mode", parents=[common])
|
|
2199
3214
|
clear_confirm_mode_parser.add_argument("--agent", required=True)
|
|
2200
3215
|
|
|
3216
|
+
propose_workflow_parser = subcommands.add_parser(
|
|
3217
|
+
"propose-workflow-mode", parents=[common]
|
|
3218
|
+
)
|
|
3219
|
+
propose_workflow_parser.add_argument(
|
|
3220
|
+
"--configured", required=True, choices=sorted(CONFIGURED_WORKFLOW_MODES)
|
|
3221
|
+
)
|
|
3222
|
+
propose_workflow_parser.add_argument(
|
|
3223
|
+
"--selected", required=True, choices=sorted(WORKFLOW_MODES)
|
|
3224
|
+
)
|
|
3225
|
+
propose_workflow_parser.add_argument(
|
|
3226
|
+
"--minimum", required=True, choices=sorted(WORKFLOW_MODES)
|
|
3227
|
+
)
|
|
3228
|
+
propose_workflow_parser.add_argument(
|
|
3229
|
+
"--source",
|
|
3230
|
+
required=True,
|
|
3231
|
+
choices=["project", "session", "adaptive", "user", "migration"],
|
|
3232
|
+
)
|
|
3233
|
+
propose_workflow_parser.add_argument("--reason", required=True, action="append")
|
|
3234
|
+
propose_workflow_parser.add_argument("--agent", required=True)
|
|
3235
|
+
propose_workflow_parser.add_argument("--task-id")
|
|
3236
|
+
|
|
3237
|
+
workflow_floor_parser = subcommands.add_parser("workflow-floor", parents=[common])
|
|
3238
|
+
workflow_floor_parser.add_argument("--agent", required=True)
|
|
3239
|
+
workflow_floor_parser.add_argument("--task-id")
|
|
3240
|
+
|
|
3241
|
+
raise_workflow_parser = subcommands.add_parser("raise-workflow-mode", parents=[common])
|
|
3242
|
+
raise_workflow_parser.add_argument("--mode", required=True, choices=sorted(WORKFLOW_MODES))
|
|
3243
|
+
raise_workflow_parser.add_argument("--reason", required=True)
|
|
3244
|
+
raise_workflow_parser.add_argument("--agent", required=True)
|
|
3245
|
+
raise_workflow_parser.add_argument("--task-id")
|
|
3246
|
+
|
|
3247
|
+
fingerprints_parser = subcommands.add_parser("evidence-fingerprints", parents=[common])
|
|
3248
|
+
fingerprints_parser.add_argument("--agent", required=True)
|
|
3249
|
+
fingerprints_parser.add_argument("--task-id")
|
|
3250
|
+
|
|
2201
3251
|
disable_harness_parser = subcommands.add_parser("disable-harness", parents=[common])
|
|
2202
3252
|
disable_harness_parser.add_argument("--agent", required=True)
|
|
2203
3253
|
|
|
@@ -2324,11 +3374,29 @@ def main() -> int:
|
|
|
2324
3374
|
session_file,
|
|
2325
3375
|
)
|
|
2326
3376
|
)
|
|
3377
|
+
elif command == "set-approval-mode":
|
|
3378
|
+
emit(
|
|
3379
|
+
attach_status_context(
|
|
3380
|
+
root,
|
|
3381
|
+
set_session_approval_mode(root, args.mode, args.agent, session_file),
|
|
3382
|
+
args.agent,
|
|
3383
|
+
session_file,
|
|
3384
|
+
)
|
|
3385
|
+
)
|
|
2327
3386
|
elif command == "set-confirm-mode":
|
|
2328
3387
|
emit(
|
|
2329
3388
|
attach_status_context(
|
|
2330
3389
|
root,
|
|
2331
|
-
|
|
3390
|
+
set_session_legacy_confirm_mode(root, args.mode, args.agent, session_file),
|
|
3391
|
+
args.agent,
|
|
3392
|
+
session_file,
|
|
3393
|
+
)
|
|
3394
|
+
)
|
|
3395
|
+
elif command == "clear-approval-mode":
|
|
3396
|
+
emit(
|
|
3397
|
+
attach_status_context(
|
|
3398
|
+
root,
|
|
3399
|
+
clear_session_approval_mode(root, args.agent, session_file),
|
|
2332
3400
|
args.agent,
|
|
2333
3401
|
session_file,
|
|
2334
3402
|
)
|
|
@@ -2337,7 +3405,90 @@ def main() -> int:
|
|
|
2337
3405
|
emit(
|
|
2338
3406
|
attach_status_context(
|
|
2339
3407
|
root,
|
|
2340
|
-
|
|
3408
|
+
clear_session_legacy_confirm_mode(root, args.agent, session_file),
|
|
3409
|
+
args.agent,
|
|
3410
|
+
session_file,
|
|
3411
|
+
)
|
|
3412
|
+
)
|
|
3413
|
+
elif command == "set-workflow-mode":
|
|
3414
|
+
emit(
|
|
3415
|
+
attach_status_context(
|
|
3416
|
+
root,
|
|
3417
|
+
set_session_workflow_mode(root, args.mode, args.agent, session_file),
|
|
3418
|
+
args.agent,
|
|
3419
|
+
session_file,
|
|
3420
|
+
)
|
|
3421
|
+
)
|
|
3422
|
+
elif command == "clear-workflow-mode":
|
|
3423
|
+
emit(
|
|
3424
|
+
attach_status_context(
|
|
3425
|
+
root,
|
|
3426
|
+
clear_session_workflow_mode(root, args.agent, session_file),
|
|
3427
|
+
args.agent,
|
|
3428
|
+
session_file,
|
|
3429
|
+
)
|
|
3430
|
+
)
|
|
3431
|
+
elif command == "propose-workflow-mode":
|
|
3432
|
+
emit(
|
|
3433
|
+
attach_status_context(
|
|
3434
|
+
root,
|
|
3435
|
+
propose_workflow_mode(
|
|
3436
|
+
root,
|
|
3437
|
+
args.configured,
|
|
3438
|
+
args.selected,
|
|
3439
|
+
args.minimum,
|
|
3440
|
+
args.source,
|
|
3441
|
+
args.reason,
|
|
3442
|
+
args.agent,
|
|
3443
|
+
args.task_id,
|
|
3444
|
+
session_file,
|
|
3445
|
+
),
|
|
3446
|
+
args.agent,
|
|
3447
|
+
session_file,
|
|
3448
|
+
)
|
|
3449
|
+
)
|
|
3450
|
+
elif command == "workflow-floor":
|
|
3451
|
+
_, resolved_task_id, _ = resolve_current_task(root, args.task_id, session_file)
|
|
3452
|
+
minimum_mode, reasons = calculate_workflow_floor(root, resolved_task_id)
|
|
3453
|
+
emit(
|
|
3454
|
+
attach_status_context(
|
|
3455
|
+
root,
|
|
3456
|
+
{
|
|
3457
|
+
"task_id": resolved_task_id,
|
|
3458
|
+
"minimum_mode": minimum_mode,
|
|
3459
|
+
"reasons": reasons,
|
|
3460
|
+
},
|
|
3461
|
+
args.agent,
|
|
3462
|
+
session_file,
|
|
3463
|
+
)
|
|
3464
|
+
)
|
|
3465
|
+
elif command == "raise-workflow-mode":
|
|
3466
|
+
emit(
|
|
3467
|
+
attach_status_context(
|
|
3468
|
+
root,
|
|
3469
|
+
raise_workflow_mode(
|
|
3470
|
+
root,
|
|
3471
|
+
args.mode,
|
|
3472
|
+
args.reason,
|
|
3473
|
+
args.agent,
|
|
3474
|
+
args.task_id,
|
|
3475
|
+
session_file,
|
|
3476
|
+
),
|
|
3477
|
+
args.agent,
|
|
3478
|
+
session_file,
|
|
3479
|
+
)
|
|
3480
|
+
)
|
|
3481
|
+
elif command == "evidence-fingerprints":
|
|
3482
|
+
session, resolved_task_id, _ = resolve_current_task(
|
|
3483
|
+
root, args.task_id, session_file
|
|
3484
|
+
)
|
|
3485
|
+
emit(
|
|
3486
|
+
attach_status_context(
|
|
3487
|
+
root,
|
|
3488
|
+
{
|
|
3489
|
+
"task_id": resolved_task_id,
|
|
3490
|
+
**evidence_fingerprints(root, resolved_task_id),
|
|
3491
|
+
},
|
|
2341
3492
|
args.agent,
|
|
2342
3493
|
session_file,
|
|
2343
3494
|
)
|