easy-coding-harness 1.1.0-beta.3 → 1.1.0-beta.4
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 +9 -0
- package/package.json +1 -1
- package/templates/common/skills/ec-analysis/SKILL.md +5 -1
- package/templates/common/skills/ec-implementing/SKILL.md +3 -0
- package/templates/common/skills/ec-quality/SKILL.md +11 -8
- package/templates/common/skills/ec-workflow/SKILL.md +3 -0
- package/templates/shared-hooks/easy_coding_inputs.py +55 -38
- package/templates/shared-hooks/easy_coding_operation.py +39 -0
- package/templates/shared-hooks/easy_coding_state.py +177 -1737
- package/templates/shared-hooks/easy_coding_status.py +412 -5
- package/templates/shared-hooks/easy_coding_store.py +1193 -0
- package/templates/shared-hooks/easy_dev_spec.py +5 -4
- package/templates/shared-hooks/inject-subagent-context.py +6 -1
- package/templates/shared-hooks/inject-workflow-state.py +6 -2
- package/templates/shared-hooks/session-start.py +6 -2
|
@@ -0,0 +1,1193 @@
|
|
|
1
|
+
"""Shared session/configuration storage; safe to import from prompt hooks."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from easy_coding_operation import memo, invalidate_memo
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
TERMINAL_STATUSES = {"COMPLETE", "CLOSED"}
|
|
18
|
+
HELP_SUFFIX = (
|
|
19
|
+
"Use `ec-workflow` to start or resume a task, "
|
|
20
|
+
"`ec-brainstorming` to brainstorm, `ec-task-management` to manage tasks, "
|
|
21
|
+
"or `ec-config` to inspect or change modes"
|
|
22
|
+
)
|
|
23
|
+
READY_LINE = f"Ready · {HELP_SUFFIX}"
|
|
24
|
+
WAITING_INIT_LINE = "Waiting init · Use `ec-init` to initialize"
|
|
25
|
+
MANDATORY_DEV_SPEC_HEADERS: list[str] = [
|
|
26
|
+
"## 技术方案",
|
|
27
|
+
"### 项目模式",
|
|
28
|
+
"### 任务类型",
|
|
29
|
+
"### 需求解析",
|
|
30
|
+
"### 现状",
|
|
31
|
+
"### 冲突摘要",
|
|
32
|
+
"### 决策闭环",
|
|
33
|
+
"### 影响面分析",
|
|
34
|
+
"### 改动范围",
|
|
35
|
+
"### 修改方案",
|
|
36
|
+
"### 实施拆解",
|
|
37
|
+
"### 测试策略",
|
|
38
|
+
"### Workflow Mode",
|
|
39
|
+
"### 风险与注意事项",
|
|
40
|
+
]
|
|
41
|
+
VALID_TRANSITIONS: dict[str, set[str]] = {
|
|
42
|
+
"idle": {"INIT"},
|
|
43
|
+
"INIT": {"ANALYSIS", "CLOSED"},
|
|
44
|
+
"ANALYSIS": {"IMPLEMENT", "CLOSED"},
|
|
45
|
+
"IMPLEMENT": {"QUALITY", "ANALYSIS", "CLOSED"},
|
|
46
|
+
"QUALITY": {"MEMORY", "IMPLEMENT", "ANALYSIS", "CLOSED"},
|
|
47
|
+
"MEMORY": {"COMPLETE", "CLOSED"},
|
|
48
|
+
"COMPLETE": set(),
|
|
49
|
+
"CLOSED": set(),
|
|
50
|
+
}
|
|
51
|
+
ALWAYS_AUTO_TRANSITIONS = {
|
|
52
|
+
("INIT", "ANALYSIS"),
|
|
53
|
+
("MEMORY", "COMPLETE"),
|
|
54
|
+
}
|
|
55
|
+
TDD_INIT_TASK_TYPE = "tdd-init"
|
|
56
|
+
APPROVAL_MODES = {"approve", "guard", "confirm", "auto"}
|
|
57
|
+
CONFIGURED_WORKFLOW_MODES = {"adaptive", "fast", "standard", "strict"}
|
|
58
|
+
DEFAULT_APPROVAL_MODE = "guard"
|
|
59
|
+
DEFAULT_WORKFLOW_MODE = "adaptive"
|
|
60
|
+
DEFAULT_UNIT_TEST_MODE = "none"
|
|
61
|
+
DEFAULT_UT_COVERAGE_THRESHOLD = 90
|
|
62
|
+
TDD_READINESS_SCHEMA = "easy-coding/tdd-readiness-v1"
|
|
63
|
+
TDD_READINESS_SCOPE = "changed-production-lines"
|
|
64
|
+
TDD_READINESS_PATH = Path(".easy-coding/tdd/readiness.json")
|
|
65
|
+
TDD_BASE_VARIABLE = "EASY_CODING_TDD_BASE_SHA"
|
|
66
|
+
TDD_THRESHOLD_VARIABLE = "EASY_CODING_TDD_THRESHOLD"
|
|
67
|
+
COVERAGE_TOOL_PATH = ".easy-coding/tools/easy_coding_java_coverage.py"
|
|
68
|
+
JAVA_BUILD_FILE_NAMES = {"pom.xml", "build.gradle", "build.gradle.kts"}
|
|
69
|
+
GITLAB_CI_ENTRY_FILES = {".gitlab-ci.yml", ".gitlab-ci.yaml"}
|
|
70
|
+
CRITICAL_CONFIRM_TRANSITIONS = {
|
|
71
|
+
("ANALYSIS", "IMPLEMENT"),
|
|
72
|
+
("QUALITY", "MEMORY"),
|
|
73
|
+
}
|
|
74
|
+
ANALYSIS_CONFIRM_TRANSITION = ("ANALYSIS", "IMPLEMENT")
|
|
75
|
+
LEGACY_STAGE_MAP = {
|
|
76
|
+
"WAITING_CONFIRM": "ANALYSIS",
|
|
77
|
+
"REVIEW": "QUALITY",
|
|
78
|
+
"VERIFICATION": "QUALITY",
|
|
79
|
+
"MEMORY_SHORT": "MEMORY",
|
|
80
|
+
"MEMORY_LONG": "MEMORY",
|
|
81
|
+
}
|
|
82
|
+
SESSION_IDLE_RETENTION_HOURS = 7 * 24
|
|
83
|
+
SESSION_ATTACHED_RETENTION_HOURS = 30 * 24
|
|
84
|
+
MAX_SESSION_FILES = 100
|
|
85
|
+
SESSION_COMPONENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
86
|
+
WORKFLOW_AGENT_IDENTITIES = {"claude-code", "codex", "qoder"}
|
|
87
|
+
# 安装时固化的宿主身份是生产事实源;未渲染源码保留占位符供本仓测试直接加载。
|
|
88
|
+
INSTALLED_WORKFLOW_AGENT = "{{workflow_agent_id}}"
|
|
89
|
+
SESSION_AGENT_NAMESPACES = {"claude-code", "codex", "qoder", "unknown"}
|
|
90
|
+
CODEX_AGENT_PATH_PATTERN = re.compile(r"^/?root(?:/[a-z0-9._-]+)*$")
|
|
91
|
+
LEGACY_DISPLAY_AGENT_IDENTITIES = {
|
|
92
|
+
"claude with easy coding": "claude-code",
|
|
93
|
+
"claude-code with easy coding": "claude-code",
|
|
94
|
+
"claude code with easy coding": "claude-code",
|
|
95
|
+
"codex with easy coding": "codex",
|
|
96
|
+
"qoder with easy coding": "qoder",
|
|
97
|
+
}
|
|
98
|
+
LEGACY_STATE_LOCK_TIMEOUT_SECONDS = 5.0
|
|
99
|
+
LEGACY_STATE_LOCK_STALE_SECONDS = 60.0
|
|
100
|
+
LEGACY_STATE_LOCK_POLL_SECONDS = 0.02
|
|
101
|
+
SESSION_COMMAND_LOCK_TIMEOUT_SECONDS = 5.0
|
|
102
|
+
SESSION_COMMAND_LOCK_STALE_SECONDS = 60.0
|
|
103
|
+
SESSION_COMMAND_LOCK_POLL_SECONDS = 0.02
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class StateError(Exception):
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def now_iso() -> str:
|
|
111
|
+
return datetime.now(timezone.utc).isoformat()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def canonical_agent_identity(agent: str | None, allow_legacy_display: bool = False) -> str | None:
|
|
115
|
+
raw_agent = str(agent or "unknown").strip()
|
|
116
|
+
normalized = raw_agent.lower()
|
|
117
|
+
# Codex 可能把根执行者写成 root 或 /root;两者及其协作子路径都属于同一平台身份。
|
|
118
|
+
if CODEX_AGENT_PATH_PATTERN.fullmatch(normalized):
|
|
119
|
+
return "codex"
|
|
120
|
+
if normalized in WORKFLOW_AGENT_IDENTITIES:
|
|
121
|
+
return normalized
|
|
122
|
+
if allow_legacy_display:
|
|
123
|
+
return LEGACY_DISPLAY_AGENT_IDENTITIES.get(normalized)
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def normalize_agent_identity(agent: str | None) -> str:
|
|
128
|
+
raw_agent = str(agent or "unknown").strip()
|
|
129
|
+
# 旧数据可能误把展示署名写入 owner;只在读取兼容边界将其还原为规范身份。
|
|
130
|
+
canonical = canonical_agent_identity(raw_agent, allow_legacy_display=True)
|
|
131
|
+
if canonical is not None:
|
|
132
|
+
return canonical
|
|
133
|
+
return raw_agent
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def normalize_session_agent(agent: str | None) -> str:
|
|
137
|
+
normalized = normalize_agent_identity(agent)
|
|
138
|
+
return normalized if normalized in SESSION_AGENT_NAMESPACES else "unknown"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def agents_equivalent(first: str | None, second: str | None) -> bool:
|
|
142
|
+
return normalize_agent_identity(first) == normalize_agent_identity(second)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def detect_runtime_agent() -> str:
|
|
146
|
+
if INSTALLED_WORKFLOW_AGENT in WORKFLOW_AGENT_IDENTITIES:
|
|
147
|
+
return INSTALLED_WORKFLOW_AGENT
|
|
148
|
+
# 仅供未渲染源码和旧安装兼容;新安装脚本始终走上面的固化身份。
|
|
149
|
+
script_path = Path(sys.argv[0]).as_posix()
|
|
150
|
+
if ".qoder/" in script_path or ".qodercn/" in script_path:
|
|
151
|
+
return "qoder"
|
|
152
|
+
if ".codex/" in script_path:
|
|
153
|
+
return "codex"
|
|
154
|
+
if ".claude/" in script_path:
|
|
155
|
+
return "claude-code"
|
|
156
|
+
# Qoder CLI 会暴露 Claude 兼容环境变量,专属信号必须优先于兼容信号。
|
|
157
|
+
if os.environ.get("QODER_PROJECT_DIR"):
|
|
158
|
+
return "qoder"
|
|
159
|
+
if os.environ.get("CLAUDE_PROJECT_DIR"):
|
|
160
|
+
return "claude-code"
|
|
161
|
+
return "unknown"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def normalize_session_component(value: str) -> str:
|
|
165
|
+
if (
|
|
166
|
+
value not in {".", ".."}
|
|
167
|
+
and len(value) <= 120
|
|
168
|
+
and SESSION_COMPONENT_PATTERN.fullmatch(value)
|
|
169
|
+
):
|
|
170
|
+
return value
|
|
171
|
+
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:32]
|
|
172
|
+
return f"sha256-{digest}"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def hook_session_identity(
|
|
176
|
+
payload: dict,
|
|
177
|
+
agent: str | None,
|
|
178
|
+
ppid: int | None = None,
|
|
179
|
+
) -> dict:
|
|
180
|
+
namespace = normalize_session_agent(agent)
|
|
181
|
+
raw_session_id = payload.get("session_id") or payload.get("sessionId")
|
|
182
|
+
external_session_id = str(raw_session_id).strip() if raw_session_id is not None else ""
|
|
183
|
+
source = "hook-session-id"
|
|
184
|
+
if not external_session_id and namespace == "codex":
|
|
185
|
+
# Codex App 当前会把 thread ID 暴露在进程环境中;标准 hook session_id 仍保持最高优先级。
|
|
186
|
+
raw_thread_id = (
|
|
187
|
+
payload.get("thread_id")
|
|
188
|
+
or payload.get("threadId")
|
|
189
|
+
or os.environ.get("CODEX_THREAD_ID")
|
|
190
|
+
)
|
|
191
|
+
external_session_id = str(raw_thread_id).strip() if raw_thread_id is not None else ""
|
|
192
|
+
source = "codex-thread-id"
|
|
193
|
+
if external_session_id:
|
|
194
|
+
component = normalize_session_component(external_session_id)
|
|
195
|
+
else:
|
|
196
|
+
component = f"ppid-{ppid if ppid is not None else os.getppid()}"
|
|
197
|
+
source = "legacy-ppid"
|
|
198
|
+
return {
|
|
199
|
+
"agent": namespace,
|
|
200
|
+
"external_session_id": external_session_id or None,
|
|
201
|
+
"session_key": f"{namespace}-{component}",
|
|
202
|
+
"session_source": source,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def load_json(path: Path) -> dict | None:
|
|
207
|
+
return memo(("json", str(path)), lambda: _load_json(path))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _load_json(path: Path) -> dict | None:
|
|
211
|
+
if not path.exists():
|
|
212
|
+
return None
|
|
213
|
+
try:
|
|
214
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
215
|
+
except (OSError, json.JSONDecodeError):
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def parse_ut_threshold(value: object, source: str) -> int:
|
|
220
|
+
if isinstance(value, bool):
|
|
221
|
+
raise StateError(f"Invalid {source}: expected an integer from 1 to 100.")
|
|
222
|
+
try:
|
|
223
|
+
threshold = int(str(value))
|
|
224
|
+
except (TypeError, ValueError) as error:
|
|
225
|
+
raise StateError(f"Invalid {source}: expected an integer from 1 to 100.") from error
|
|
226
|
+
if threshold < 1 or threshold > 100:
|
|
227
|
+
raise StateError(f"Invalid {source}: expected an integer from 1 to 100.")
|
|
228
|
+
return threshold
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def parse_unit_test_mode(value: object, source: str) -> str:
|
|
232
|
+
if not isinstance(value, str) or value not in {"none", "ut", "tdd"}:
|
|
233
|
+
raise StateError(f"Invalid {source}: expected none, ut, or tdd.")
|
|
234
|
+
return str(value)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def read_behavior_file(path: Path) -> tuple[dict, int]:
|
|
238
|
+
return memo(("behavior", str(path)), lambda: _read_behavior_file(path))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _read_behavior_file(path: Path) -> tuple[dict, int]:
|
|
242
|
+
try:
|
|
243
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
244
|
+
except FileNotFoundError:
|
|
245
|
+
return {}, 0
|
|
246
|
+
|
|
247
|
+
in_behavior = False
|
|
248
|
+
behavior_indent = 0
|
|
249
|
+
behavior: dict[str, str] = {}
|
|
250
|
+
schema_version = 0
|
|
251
|
+
for raw_line in lines:
|
|
252
|
+
without_comment = raw_line.split("#", 1)[0].rstrip()
|
|
253
|
+
stripped = without_comment.strip()
|
|
254
|
+
if not stripped:
|
|
255
|
+
continue
|
|
256
|
+
indent = len(without_comment) - len(without_comment.lstrip(" "))
|
|
257
|
+
if stripped.startswith("behavior:") and stripped != "behavior:":
|
|
258
|
+
raise StateError("Behavior configuration must use an indented YAML mapping; write it with easy-coding config.")
|
|
259
|
+
if stripped == "behavior:":
|
|
260
|
+
in_behavior = True
|
|
261
|
+
behavior_indent = indent
|
|
262
|
+
continue
|
|
263
|
+
if in_behavior and indent <= behavior_indent:
|
|
264
|
+
in_behavior = False
|
|
265
|
+
if not in_behavior and indent == 0 and stripped.startswith("version:"):
|
|
266
|
+
try:
|
|
267
|
+
schema_version = int(stripped.split(":", 1)[1].strip().strip("'\""))
|
|
268
|
+
except ValueError:
|
|
269
|
+
schema_version = 0
|
|
270
|
+
continue
|
|
271
|
+
if not in_behavior or ":" not in stripped:
|
|
272
|
+
continue
|
|
273
|
+
key, value = stripped.split(":", 1)
|
|
274
|
+
behavior[key] = value.strip().strip("'\"")
|
|
275
|
+
|
|
276
|
+
return behavior, schema_version
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def read_project_behavior(root: Path) -> tuple[str, str, str, int]:
|
|
280
|
+
behavior, schema_version = read_behavior_file(root / ".easy-coding" / "config.yaml")
|
|
281
|
+
legacy = behavior.get("confirm_mode")
|
|
282
|
+
approval_mode = behavior.get("approval_mode")
|
|
283
|
+
workflow_mode = behavior.get("workflow_mode")
|
|
284
|
+
if approval_mode is None:
|
|
285
|
+
if legacy == "lite":
|
|
286
|
+
approval_mode = "guard"
|
|
287
|
+
elif legacy in APPROVAL_MODES:
|
|
288
|
+
approval_mode = legacy
|
|
289
|
+
else:
|
|
290
|
+
approval_mode = DEFAULT_APPROVAL_MODE
|
|
291
|
+
if workflow_mode is None:
|
|
292
|
+
workflow_mode = "fast" if legacy == "lite" else DEFAULT_WORKFLOW_MODE
|
|
293
|
+
if approval_mode not in APPROVAL_MODES:
|
|
294
|
+
raise StateError(
|
|
295
|
+
"Invalid behavior.approval_mode in .easy-coding/config.yaml: "
|
|
296
|
+
"expected approve, guard, confirm, or auto."
|
|
297
|
+
)
|
|
298
|
+
if workflow_mode not in CONFIGURED_WORKFLOW_MODES:
|
|
299
|
+
raise StateError(
|
|
300
|
+
"Invalid behavior.workflow_mode in .easy-coding/config.yaml: "
|
|
301
|
+
"expected adaptive, fast, standard, or strict."
|
|
302
|
+
)
|
|
303
|
+
if schema_version >= 6:
|
|
304
|
+
unit_test_mode = parse_unit_test_mode(
|
|
305
|
+
behavior.get("unit_test_mode", DEFAULT_UNIT_TEST_MODE), "behavior.unit_test_mode"
|
|
306
|
+
)
|
|
307
|
+
threshold = parse_ut_threshold(
|
|
308
|
+
behavior.get("ut_coverage_threshold", DEFAULT_UT_COVERAGE_THRESHOLD),
|
|
309
|
+
"behavior.ut_coverage_threshold",
|
|
310
|
+
)
|
|
311
|
+
else:
|
|
312
|
+
if schema_version >= 4:
|
|
313
|
+
raise StateError("Run easy-coding upgrade to migrate unit-test settings to schema 6.")
|
|
314
|
+
unit_test_mode = DEFAULT_UNIT_TEST_MODE
|
|
315
|
+
threshold = DEFAULT_UT_COVERAGE_THRESHOLD
|
|
316
|
+
return approval_mode, workflow_mode, unit_test_mode, threshold
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def behavior_layers(root: Path, session: dict) -> dict:
|
|
320
|
+
project, _ = read_behavior_file(root / ".easy-coding" / "config.yaml")
|
|
321
|
+
local, _ = read_behavior_file(Path.home() / ".easy-coding" / "config.yaml")
|
|
322
|
+
defaults = {"approval_mode": DEFAULT_APPROVAL_MODE, "cooperate_mode": "default",
|
|
323
|
+
"unit_test_mode": DEFAULT_UNIT_TEST_MODE,
|
|
324
|
+
"ut_coverage_threshold": DEFAULT_UT_COVERAGE_THRESHOLD}
|
|
325
|
+
layers = {"project": project, "local": local, "session": session}
|
|
326
|
+
result = {}
|
|
327
|
+
for key, default in defaults.items():
|
|
328
|
+
source = next((name for name in ("session", "local", "project")
|
|
329
|
+
if layers[name].get(key) is not None), "default")
|
|
330
|
+
value = layers[source][key] if source != "default" else default
|
|
331
|
+
if key == "ut_coverage_threshold":
|
|
332
|
+
value = parse_ut_threshold(value, f"{source} {key}")
|
|
333
|
+
elif key == "unit_test_mode":
|
|
334
|
+
value = parse_unit_test_mode(value, f"{source} {key}")
|
|
335
|
+
elif value not in (APPROVAL_MODES if key == "approval_mode" else {"default", "dispatch"}):
|
|
336
|
+
raise StateError(f"Invalid {source} {key}: {value}")
|
|
337
|
+
result[key] = {"value": value, "source": source}
|
|
338
|
+
return result
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def safe_tdd_report_pattern(value: object) -> bool:
|
|
342
|
+
if not is_non_empty_string(value):
|
|
343
|
+
return False
|
|
344
|
+
candidate = Path(str(value))
|
|
345
|
+
return not candidate.is_absolute() and ".." not in candidate.parts
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def tdd_gate_uses_task_variables(command: object) -> bool:
|
|
349
|
+
if not is_non_empty_string(command):
|
|
350
|
+
return False
|
|
351
|
+
try:
|
|
352
|
+
tokens = shlex.split(str(command))
|
|
353
|
+
except ValueError:
|
|
354
|
+
return False
|
|
355
|
+
options: dict[str, str] = {}
|
|
356
|
+
for index, token in enumerate(tokens[:-1]):
|
|
357
|
+
if token in {"--base", "--threshold"}:
|
|
358
|
+
options[token] = tokens[index + 1]
|
|
359
|
+
return options.get("--base") in {
|
|
360
|
+
f"${TDD_BASE_VARIABLE}",
|
|
361
|
+
"$" + "{" + TDD_BASE_VARIABLE + "}",
|
|
362
|
+
} and options.get("--threshold") in {
|
|
363
|
+
f"${TDD_THRESHOLD_VARIABLE}",
|
|
364
|
+
"$" + "{" + TDD_THRESHOLD_VARIABLE + "}",
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def tdd_ci_contract_reasons(contents: list[str]) -> list[str]:
|
|
369
|
+
combined = "\n".join(
|
|
370
|
+
re.sub(r"\s+#.*$", "", re.sub(r"^\s*#.*$", "", line))
|
|
371
|
+
for line in "\n".join(contents).splitlines()
|
|
372
|
+
)
|
|
373
|
+
lowered = combined.lower()
|
|
374
|
+
reasons: list[str] = []
|
|
375
|
+
for marker in (
|
|
376
|
+
"jacoco",
|
|
377
|
+
"artifacts",
|
|
378
|
+
COVERAGE_TOOL_PATH,
|
|
379
|
+
TDD_BASE_VARIABLE,
|
|
380
|
+
TDD_THRESHOLD_VARIABLE,
|
|
381
|
+
):
|
|
382
|
+
if marker.lower() not in lowered:
|
|
383
|
+
reasons.append(f"CI files do not contain required marker: {marker}")
|
|
384
|
+
if not tdd_gate_uses_task_variables(combined):
|
|
385
|
+
reasons.append(
|
|
386
|
+
"CI changed-line gate must use the task baseline and threshold variables"
|
|
387
|
+
)
|
|
388
|
+
if re.search(
|
|
389
|
+
r"(?:^|\n)\s*stage\s*:\s*['\"]?test['\"]?\s*(?:#.*)?(?:\n|$)",
|
|
390
|
+
combined,
|
|
391
|
+
re.IGNORECASE,
|
|
392
|
+
) is None:
|
|
393
|
+
reasons.append("CI files do not declare a TEST-stage job")
|
|
394
|
+
return reasons
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def tdd_readiness(root: Path, include_ci: bool = False) -> dict[str, object]:
|
|
398
|
+
receipt = root / TDD_READINESS_PATH
|
|
399
|
+
if not receipt.is_file():
|
|
400
|
+
return {"status": "needs_init", "reasons": ["TDD readiness receipt is missing"]}
|
|
401
|
+
try:
|
|
402
|
+
manifest = json.loads(receipt.read_text(encoding="utf-8"))
|
|
403
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
404
|
+
return {"status": "needs_repair", "reasons": ["TDD readiness receipt is invalid"]}
|
|
405
|
+
if not isinstance(manifest, dict):
|
|
406
|
+
return {
|
|
407
|
+
"status": "needs_repair",
|
|
408
|
+
"reasons": ["TDD readiness receipt must be a JSON object"],
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
reasons: list[str] = []
|
|
412
|
+
if manifest.get("schema") != TDD_READINESS_SCHEMA:
|
|
413
|
+
reasons.append("unsupported readiness schema")
|
|
414
|
+
if manifest.get("provider") != "gitlab":
|
|
415
|
+
reasons.append("readiness provider must be gitlab")
|
|
416
|
+
if manifest.get("coverage_scope") != TDD_READINESS_SCOPE:
|
|
417
|
+
reasons.append("coverage scope must be changed-production-lines")
|
|
418
|
+
if manifest.get("historical_coverage_required") is not False:
|
|
419
|
+
reasons.append("historical coverage must remain disabled")
|
|
420
|
+
reports = manifest.get("coverage_report_patterns")
|
|
421
|
+
if not isinstance(reports, list) or not reports or not all(
|
|
422
|
+
safe_tdd_report_pattern(item) for item in reports
|
|
423
|
+
):
|
|
424
|
+
reasons.append(
|
|
425
|
+
"coverage_report_patterns must contain safe project-relative report patterns"
|
|
426
|
+
)
|
|
427
|
+
gate = manifest.get("changed_line_gate_command")
|
|
428
|
+
if not is_non_empty_string(gate) or COVERAGE_TOOL_PATH not in str(gate):
|
|
429
|
+
reasons.append("changed-line coverage gate command is missing")
|
|
430
|
+
elif not tdd_gate_uses_task_variables(gate):
|
|
431
|
+
reasons.append(
|
|
432
|
+
"changed-line coverage gate must use the task baseline and threshold variables"
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
contents: dict[str, list[str]] = {
|
|
436
|
+
"build_files": [],
|
|
437
|
+
"tool_files": [],
|
|
438
|
+
}
|
|
439
|
+
if include_ci:
|
|
440
|
+
contents["ci_files"] = []
|
|
441
|
+
for field in contents:
|
|
442
|
+
records = manifest.get(field)
|
|
443
|
+
if not isinstance(records, list) or not records:
|
|
444
|
+
reasons.append(f"{field} must contain at least one file")
|
|
445
|
+
continue
|
|
446
|
+
for record in records:
|
|
447
|
+
if not isinstance(record, dict):
|
|
448
|
+
reasons.append(f"{field} contains an invalid record")
|
|
449
|
+
continue
|
|
450
|
+
file_name = record.get("path")
|
|
451
|
+
if not is_non_empty_string(file_name):
|
|
452
|
+
reasons.append(f"{field} contains an invalid path")
|
|
453
|
+
continue
|
|
454
|
+
candidate = Path(str(file_name))
|
|
455
|
+
if candidate.is_absolute():
|
|
456
|
+
reasons.append(f"readiness file must be project-relative: {file_name}")
|
|
457
|
+
continue
|
|
458
|
+
resolved = (root / candidate).resolve()
|
|
459
|
+
try:
|
|
460
|
+
resolved.relative_to(root.resolve())
|
|
461
|
+
payload = resolved.read_bytes()
|
|
462
|
+
contents[field].append(payload.decode("utf-8"))
|
|
463
|
+
except (OSError, UnicodeError, ValueError):
|
|
464
|
+
reasons.append(f"readiness file is missing or unreadable: {file_name}")
|
|
465
|
+
|
|
466
|
+
manifest_build_files = manifest.get("build_files")
|
|
467
|
+
manifest_ci_files = manifest.get("ci_files")
|
|
468
|
+
manifest_tool_files = manifest.get("tool_files")
|
|
469
|
+
build_paths = {
|
|
470
|
+
Path(str(item.get("path", ""))).name
|
|
471
|
+
for item in manifest_build_files
|
|
472
|
+
if isinstance(item, dict) and is_non_empty_string(item.get("path"))
|
|
473
|
+
} if isinstance(manifest_build_files, list) else set()
|
|
474
|
+
ci_paths = {
|
|
475
|
+
str(item.get("path", "")).replace("\\", "/")
|
|
476
|
+
for item in manifest_ci_files
|
|
477
|
+
if isinstance(item, dict) and is_non_empty_string(item.get("path"))
|
|
478
|
+
} if isinstance(manifest_ci_files, list) else set()
|
|
479
|
+
if not build_paths.intersection(JAVA_BUILD_FILE_NAMES):
|
|
480
|
+
reasons.append("build_files must include a Maven or Gradle Java build file")
|
|
481
|
+
tool_paths = {
|
|
482
|
+
str(item.get("path", "")).replace("\\", "/")
|
|
483
|
+
for item in manifest_tool_files
|
|
484
|
+
if isinstance(item, dict) and is_non_empty_string(item.get("path"))
|
|
485
|
+
} if isinstance(manifest_tool_files, list) else set()
|
|
486
|
+
if COVERAGE_TOOL_PATH not in tool_paths:
|
|
487
|
+
reasons.append(f"tool_files must include {COVERAGE_TOOL_PATH}")
|
|
488
|
+
if include_ci:
|
|
489
|
+
if not ci_paths.intersection(GITLAB_CI_ENTRY_FILES):
|
|
490
|
+
reasons.append("ci_files must include the project-root GitLab CI entry file")
|
|
491
|
+
if not any("jacoco" in content.lower() for content in contents["build_files"]):
|
|
492
|
+
reasons.append("build files do not configure JaCoCo")
|
|
493
|
+
reasons.extend(tdd_ci_contract_reasons(contents["ci_files"]))
|
|
494
|
+
return {
|
|
495
|
+
"status": "ready" if not reasons else "needs_repair",
|
|
496
|
+
"reasons": list(dict.fromkeys(reasons)),
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def resolve_behavior(
|
|
501
|
+
root: Path, session: dict
|
|
502
|
+
) -> tuple[str, str | None, str, str, str | None, str, str, str | None, str, int, int | None, int]:
|
|
503
|
+
project_approval, project_workflow, project_unit_test, project_threshold = read_project_behavior(root)
|
|
504
|
+
legacy = session.get("confirm_mode")
|
|
505
|
+
session_approval = session.get("approval_mode")
|
|
506
|
+
session_workflow = session.get("workflow_mode")
|
|
507
|
+
session_unit_test = session.get("unit_test_mode")
|
|
508
|
+
session_threshold = session.get("ut_coverage_threshold")
|
|
509
|
+
if session_approval is None:
|
|
510
|
+
if legacy == "lite":
|
|
511
|
+
session_approval = "guard"
|
|
512
|
+
elif legacy in APPROVAL_MODES:
|
|
513
|
+
session_approval = legacy
|
|
514
|
+
if session_workflow is None:
|
|
515
|
+
if legacy == "lite":
|
|
516
|
+
session_workflow = "fast"
|
|
517
|
+
elif legacy in APPROVAL_MODES:
|
|
518
|
+
session_workflow = "adaptive"
|
|
519
|
+
if session_approval is not None and session_approval not in APPROVAL_MODES:
|
|
520
|
+
raise StateError(
|
|
521
|
+
"Invalid session approval_mode: expected approve, guard, confirm, or auto."
|
|
522
|
+
)
|
|
523
|
+
if session_workflow is not None and session_workflow not in CONFIGURED_WORKFLOW_MODES:
|
|
524
|
+
raise StateError(
|
|
525
|
+
"Invalid session workflow_mode: expected adaptive, fast, standard, or strict."
|
|
526
|
+
)
|
|
527
|
+
if session_unit_test is not None:
|
|
528
|
+
session_unit_test = parse_unit_test_mode(session_unit_test, "session unit_test_mode")
|
|
529
|
+
if session_threshold is not None:
|
|
530
|
+
session_threshold = parse_ut_threshold(
|
|
531
|
+
session_threshold, "session ut_coverage_threshold"
|
|
532
|
+
)
|
|
533
|
+
local, _ = read_behavior_file(Path.home() / ".easy-coding" / "config.yaml")
|
|
534
|
+
effective = behavior_layers(root, session)
|
|
535
|
+
return (
|
|
536
|
+
project_approval,
|
|
537
|
+
str(session_approval) if session_approval else None,
|
|
538
|
+
str(session_approval or (effective["approval_mode"]["value"] if "approval_mode" in local else project_approval)),
|
|
539
|
+
project_workflow,
|
|
540
|
+
str(session_workflow) if session_workflow else None,
|
|
541
|
+
str(session_workflow or project_workflow),
|
|
542
|
+
project_unit_test,
|
|
543
|
+
session_unit_test,
|
|
544
|
+
session_unit_test if session_unit_test is not None else (
|
|
545
|
+
effective["unit_test_mode"]["value"] if "unit_test_mode" in local else project_unit_test),
|
|
546
|
+
project_threshold,
|
|
547
|
+
session_threshold,
|
|
548
|
+
session_threshold if session_threshold is not None else (
|
|
549
|
+
effective["ut_coverage_threshold"]["value"] if "ut_coverage_threshold" in local else project_threshold),
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def migrate_unit_test_settings(record: dict) -> bool:
|
|
554
|
+
changed = False
|
|
555
|
+
if "tdd_enabled" in record:
|
|
556
|
+
enabled = record.pop("tdd_enabled")
|
|
557
|
+
if "unit_test_mode" not in record and isinstance(enabled, bool):
|
|
558
|
+
record["unit_test_mode"] = "tdd" if enabled else "none"
|
|
559
|
+
changed = True
|
|
560
|
+
if "tdd_coverage_threshold" in record:
|
|
561
|
+
threshold = record.pop("tdd_coverage_threshold")
|
|
562
|
+
record.setdefault("ut_coverage_threshold", threshold)
|
|
563
|
+
changed = True
|
|
564
|
+
return changed
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def normalize_legacy_stage(stage: object) -> object:
|
|
568
|
+
return LEGACY_STAGE_MAP.get(str(stage), stage)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def normalize_legacy_task(task: dict) -> bool:
|
|
572
|
+
"""Normalize legacy task state without touching artifacts outside task.json."""
|
|
573
|
+
legacy_status = str(task.get("status") or "")
|
|
574
|
+
changed = migrate_unit_test_settings(task)
|
|
575
|
+
|
|
576
|
+
for field in ("created_by", "last_agent"):
|
|
577
|
+
normalized_agent = canonical_agent_identity(
|
|
578
|
+
task.get(field), allow_legacy_display=True
|
|
579
|
+
)
|
|
580
|
+
if normalized_agent is not None and normalized_agent != task.get(field):
|
|
581
|
+
task[field] = normalized_agent
|
|
582
|
+
changed = True
|
|
583
|
+
|
|
584
|
+
if legacy_status in LEGACY_STAGE_MAP:
|
|
585
|
+
task["status"] = LEGACY_STAGE_MAP[legacy_status]
|
|
586
|
+
changed = True
|
|
587
|
+
|
|
588
|
+
pending = task.get("pending_transition")
|
|
589
|
+
if isinstance(pending, dict):
|
|
590
|
+
source = normalize_legacy_stage(pending.get("from"))
|
|
591
|
+
target = normalize_legacy_stage(pending.get("to"))
|
|
592
|
+
if source == target:
|
|
593
|
+
task.pop("pending_transition", None)
|
|
594
|
+
changed = True
|
|
595
|
+
elif source != pending.get("from") or target != pending.get("to"):
|
|
596
|
+
task["pending_transition"] = {**pending, "from": source, "to": target}
|
|
597
|
+
changed = True
|
|
598
|
+
|
|
599
|
+
if not isinstance(task.get("quality_checkpoint"), dict) and isinstance(
|
|
600
|
+
task.get("verification_checkpoint"), dict
|
|
601
|
+
):
|
|
602
|
+
task["quality_checkpoint"] = task["verification_checkpoint"]
|
|
603
|
+
changed = True
|
|
604
|
+
if "verification_checkpoint" in task:
|
|
605
|
+
task.pop("verification_checkpoint")
|
|
606
|
+
changed = True
|
|
607
|
+
|
|
608
|
+
history = task.get("stage_history")
|
|
609
|
+
if isinstance(history, list):
|
|
610
|
+
normalized_history: list[dict] = []
|
|
611
|
+
for raw_entry in history:
|
|
612
|
+
if not isinstance(raw_entry, dict):
|
|
613
|
+
continue
|
|
614
|
+
entry = dict(raw_entry)
|
|
615
|
+
mapped_stage = normalize_legacy_stage(entry.get("stage"))
|
|
616
|
+
if mapped_stage != entry.get("stage"):
|
|
617
|
+
entry["stage"] = mapped_stage
|
|
618
|
+
changed = True
|
|
619
|
+
normalized_agent = canonical_agent_identity(
|
|
620
|
+
entry.get("agent"), allow_legacy_display=True
|
|
621
|
+
)
|
|
622
|
+
if normalized_agent is not None and normalized_agent != entry.get("agent"):
|
|
623
|
+
entry["agent"] = normalized_agent
|
|
624
|
+
changed = True
|
|
625
|
+
if normalized_history and normalized_history[-1].get("stage") == entry.get("stage"):
|
|
626
|
+
changed = True
|
|
627
|
+
continue
|
|
628
|
+
normalized_history.append(entry)
|
|
629
|
+
if changed:
|
|
630
|
+
task["stage_history"] = normalized_history
|
|
631
|
+
|
|
632
|
+
if legacy_status == "WAITING_CONFIRM" and not task.get("pending_transition"):
|
|
633
|
+
requested_by = canonical_agent_identity(
|
|
634
|
+
task.get("last_agent"), allow_legacy_display=True
|
|
635
|
+
) or "legacy-migration"
|
|
636
|
+
task["pending_transition"] = {
|
|
637
|
+
"from": "ANALYSIS",
|
|
638
|
+
"to": "IMPLEMENT",
|
|
639
|
+
"requested_at": now_iso(),
|
|
640
|
+
"requested_by": requested_by,
|
|
641
|
+
"reason": "migrated-from-WAITING_CONFIRM",
|
|
642
|
+
}
|
|
643
|
+
changed = True
|
|
644
|
+
|
|
645
|
+
if legacy_status == "MEMORY_LONG":
|
|
646
|
+
progress = task.get("memory_progress")
|
|
647
|
+
if not isinstance(progress, dict):
|
|
648
|
+
progress = {}
|
|
649
|
+
if progress.get("short_memory_written") is not True:
|
|
650
|
+
progress["short_memory_written"] = True
|
|
651
|
+
progress["legacy_short_memory_assumed"] = True
|
|
652
|
+
progress["updated_at"] = now_iso()
|
|
653
|
+
task["memory_progress"] = progress
|
|
654
|
+
changed = True
|
|
655
|
+
elif progress.get("legacy_short_memory_assumed") is not True:
|
|
656
|
+
progress["legacy_short_memory_assumed"] = True
|
|
657
|
+
progress["updated_at"] = now_iso()
|
|
658
|
+
task["memory_progress"] = progress
|
|
659
|
+
changed = True
|
|
660
|
+
|
|
661
|
+
return changed
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def write_json(path: Path, data: dict) -> None:
|
|
665
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
666
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
667
|
+
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
|
|
668
|
+
)
|
|
669
|
+
temporary_path = Path(temporary_name)
|
|
670
|
+
try:
|
|
671
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
|
|
672
|
+
handle.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
673
|
+
handle.flush()
|
|
674
|
+
os.fsync(handle.fileno())
|
|
675
|
+
os.replace(temporary_path, path)
|
|
676
|
+
invalidate_memo(("json", str(path)))
|
|
677
|
+
memo(("json", str(path)), lambda: data)
|
|
678
|
+
try:
|
|
679
|
+
directory_descriptor = os.open(path.parent, os.O_RDONLY)
|
|
680
|
+
try:
|
|
681
|
+
os.fsync(directory_descriptor)
|
|
682
|
+
finally:
|
|
683
|
+
os.close(directory_descriptor)
|
|
684
|
+
except OSError:
|
|
685
|
+
# Some platforms do not allow opening directories; file replacement is still atomic.
|
|
686
|
+
pass
|
|
687
|
+
finally:
|
|
688
|
+
if temporary_path.exists():
|
|
689
|
+
temporary_path.unlink()
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def session_command_lock_path(root: Path, session_path: Path) -> Path:
|
|
693
|
+
key = hashlib.sha256(str(session_path.resolve()).encode("utf-8")).hexdigest()[:24]
|
|
694
|
+
return root / ".easy-coding" / "sessions" / f".session-{key}.lock"
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def acquire_session_command_lock(root: Path, session_path: Path) -> Path:
|
|
698
|
+
lock_path = session_command_lock_path(root, session_path)
|
|
699
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
700
|
+
deadline = time.monotonic() + SESSION_COMMAND_LOCK_TIMEOUT_SECONDS
|
|
701
|
+
while True:
|
|
702
|
+
try:
|
|
703
|
+
lock_path.mkdir()
|
|
704
|
+
return lock_path
|
|
705
|
+
except FileExistsError:
|
|
706
|
+
try:
|
|
707
|
+
if time.time() - lock_path.stat().st_mtime > SESSION_COMMAND_LOCK_STALE_SECONDS:
|
|
708
|
+
lock_path.rmdir()
|
|
709
|
+
continue
|
|
710
|
+
except FileNotFoundError:
|
|
711
|
+
continue
|
|
712
|
+
except OSError:
|
|
713
|
+
pass
|
|
714
|
+
if time.monotonic() >= deadline:
|
|
715
|
+
raise StateError("Timed out waiting for the logical session command lock.")
|
|
716
|
+
time.sleep(SESSION_COMMAND_LOCK_POLL_SECONDS)
|
|
717
|
+
except OSError as exc:
|
|
718
|
+
raise StateError("Cannot acquire the logical session command lock.") from exc
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def release_session_command_lock(lock_path: Path | None) -> None:
|
|
722
|
+
if lock_path is None:
|
|
723
|
+
return
|
|
724
|
+
try:
|
|
725
|
+
lock_path.rmdir()
|
|
726
|
+
except OSError:
|
|
727
|
+
pass
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def acquire_legacy_state_lock(root: Path) -> Path | None:
|
|
731
|
+
state_path = root / ".easy-coding" / "state.json"
|
|
732
|
+
lock_path = root / ".easy-coding" / "sessions" / ".legacy-state-migration.lock"
|
|
733
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
734
|
+
deadline = time.monotonic() + LEGACY_STATE_LOCK_TIMEOUT_SECONDS
|
|
735
|
+
|
|
736
|
+
while state_path.exists() or lock_path.exists():
|
|
737
|
+
try:
|
|
738
|
+
lock_path.mkdir()
|
|
739
|
+
return lock_path
|
|
740
|
+
except FileExistsError:
|
|
741
|
+
try:
|
|
742
|
+
lock_age = time.time() - lock_path.stat().st_mtime
|
|
743
|
+
if lock_age > LEGACY_STATE_LOCK_STALE_SECONDS:
|
|
744
|
+
lock_path.rmdir()
|
|
745
|
+
continue
|
|
746
|
+
except FileNotFoundError:
|
|
747
|
+
continue
|
|
748
|
+
except OSError:
|
|
749
|
+
pass
|
|
750
|
+
if time.monotonic() >= deadline:
|
|
751
|
+
raise StateError("Timed out waiting for legacy state migration lock.")
|
|
752
|
+
time.sleep(LEGACY_STATE_LOCK_POLL_SECONDS)
|
|
753
|
+
except OSError as error:
|
|
754
|
+
raise StateError("Cannot acquire legacy state migration lock.") from error
|
|
755
|
+
return None
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def release_legacy_state_lock(lock_path: Path | None) -> None:
|
|
759
|
+
if lock_path is None:
|
|
760
|
+
return
|
|
761
|
+
try:
|
|
762
|
+
lock_path.rmdir()
|
|
763
|
+
except OSError:
|
|
764
|
+
pass
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def migrate_legacy_state(root: Path, agent: str) -> dict | None:
|
|
768
|
+
"""Prepare old state.json data for the canonical session; the caller commits it first."""
|
|
769
|
+
state_path = root / ".easy-coding" / "state.json"
|
|
770
|
+
old_state = load_json(state_path)
|
|
771
|
+
if old_state is None:
|
|
772
|
+
return None
|
|
773
|
+
|
|
774
|
+
task_id = old_state.get("current_task")
|
|
775
|
+
if task_id:
|
|
776
|
+
task_path = task_json_path(root, str(task_id))
|
|
777
|
+
task = load_json(task_path)
|
|
778
|
+
if task:
|
|
779
|
+
if "stage_history" not in task or not task["stage_history"]:
|
|
780
|
+
task["stage_history"] = old_state.get("stage_history", [])
|
|
781
|
+
if "last_agent" not in task or not task["last_agent"]:
|
|
782
|
+
task["last_agent"] = (
|
|
783
|
+
canonical_agent_identity(
|
|
784
|
+
old_state.get("last_agent"), allow_legacy_display=True
|
|
785
|
+
)
|
|
786
|
+
or agent
|
|
787
|
+
)
|
|
788
|
+
if old_state.get("confirmed_by_user"):
|
|
789
|
+
task["confirmed_by_user"] = True
|
|
790
|
+
if old_state.get("test_strategy_confirmed"):
|
|
791
|
+
task["test_strategy_confirmed"] = True
|
|
792
|
+
if old_state.get("repo_paths"):
|
|
793
|
+
task["repo_paths"] = old_state["repo_paths"]
|
|
794
|
+
normalize_legacy_task(task)
|
|
795
|
+
write_json(task_path, task)
|
|
796
|
+
|
|
797
|
+
return {"current_task": task_id, "created_at": now_iso()}
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def resolve_session_path(root: Path, session_file: str | Path | None = None) -> Path:
|
|
801
|
+
sessions_dir = (root / ".easy-coding" / "sessions").resolve()
|
|
802
|
+
if session_file:
|
|
803
|
+
path = Path(session_file)
|
|
804
|
+
candidate = path if path.is_absolute() else root / path
|
|
805
|
+
resolved = candidate.resolve()
|
|
806
|
+
try:
|
|
807
|
+
resolved.relative_to(sessions_dir)
|
|
808
|
+
except ValueError as error:
|
|
809
|
+
raise StateError(
|
|
810
|
+
"Unsafe session file path: "
|
|
811
|
+
f"{session_file}. Must be under .easy-coding/sessions/."
|
|
812
|
+
) from error
|
|
813
|
+
if resolved == sessions_dir:
|
|
814
|
+
raise StateError(
|
|
815
|
+
"Unsafe session file path: "
|
|
816
|
+
f"{session_file}. Must be a file under .easy-coding/sessions/."
|
|
817
|
+
)
|
|
818
|
+
return resolved
|
|
819
|
+
identity = hook_session_identity({}, detect_runtime_agent())
|
|
820
|
+
return sessions_dir / f"{identity['session_key']}.json"
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def resolve_hook_session_path(
|
|
824
|
+
root: Path,
|
|
825
|
+
payload: dict,
|
|
826
|
+
agent: str | None,
|
|
827
|
+
ppid: int | None = None,
|
|
828
|
+
) -> Path:
|
|
829
|
+
identity = hook_session_identity(payload, agent, ppid)
|
|
830
|
+
return resolve_session_path(root, f".easy-coding/sessions/{identity['session_key']}.json")
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def display_path(root: Path, path: Path) -> str:
|
|
834
|
+
try:
|
|
835
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
836
|
+
except ValueError:
|
|
837
|
+
return path.as_posix()
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def default_session() -> dict:
|
|
841
|
+
timestamp = now_iso()
|
|
842
|
+
return {"current_task": None, "created_at": timestamp, "last_active_at": timestamp}
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def apply_hook_session_identity(session: dict, identity: dict) -> None:
|
|
846
|
+
timestamp = now_iso()
|
|
847
|
+
if not session.get("created_at"):
|
|
848
|
+
session["created_at"] = timestamp
|
|
849
|
+
session["last_active_at"] = timestamp
|
|
850
|
+
for key in ("agent", "external_session_id", "session_key", "session_source"):
|
|
851
|
+
session[key] = identity.get(key)
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def clear_session_pointer(session: dict, agent: str | None = None) -> None:
|
|
855
|
+
session["current_task"] = None
|
|
856
|
+
session["last_seen_task"] = None
|
|
857
|
+
session["last_seen_stage"] = "idle"
|
|
858
|
+
if agent:
|
|
859
|
+
session["last_agent"] = agent
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def load_session(root: Path, session_file: str | Path | None = None) -> dict | None:
|
|
863
|
+
session = load_json(resolve_session_path(root, session_file))
|
|
864
|
+
return session if isinstance(session, dict) else None
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def write_session(root: Path, session: dict, session_file: str | Path | None = None) -> None:
|
|
868
|
+
write_json(resolve_session_path(root, session_file), session)
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def migrate_legacy_pid_session(
|
|
872
|
+
root: Path,
|
|
873
|
+
session_path: Path,
|
|
874
|
+
identity: dict,
|
|
875
|
+
ppid: int,
|
|
876
|
+
) -> dict | None:
|
|
877
|
+
sessions_dir = root / ".easy-coding" / "sessions"
|
|
878
|
+
fallback_path = sessions_dir / f"{identity['agent']}-ppid-{ppid}.json"
|
|
879
|
+
legacy_paths = [fallback_path, sessions_dir / f"{ppid}.json"]
|
|
880
|
+
session_path.parent.mkdir(parents=True, exist_ok=True)
|
|
881
|
+
|
|
882
|
+
for legacy_path in legacy_paths:
|
|
883
|
+
if legacy_path == session_path or not legacy_path.is_file():
|
|
884
|
+
continue
|
|
885
|
+
try:
|
|
886
|
+
legacy_path.replace(session_path)
|
|
887
|
+
invalidate_memo(("json", str(legacy_path)))
|
|
888
|
+
invalidate_memo(("json", str(session_path)))
|
|
889
|
+
except FileNotFoundError:
|
|
890
|
+
continue
|
|
891
|
+
except OSError:
|
|
892
|
+
if session_path.is_file():
|
|
893
|
+
invalidate_memo(("json", str(session_path)))
|
|
894
|
+
break
|
|
895
|
+
continue
|
|
896
|
+
migrated = load_session(root, session_path)
|
|
897
|
+
if migrated is not None:
|
|
898
|
+
return migrated
|
|
899
|
+
return load_session(root, session_path)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def merge_legacy_session(session: dict, legacy_session: dict) -> dict:
|
|
903
|
+
merged = dict(session)
|
|
904
|
+
if not merged.get("current_task") and legacy_session.get("current_task"):
|
|
905
|
+
merged["current_task"] = legacy_session["current_task"]
|
|
906
|
+
if not merged.get("created_at") and legacy_session.get("created_at"):
|
|
907
|
+
merged["created_at"] = legacy_session["created_at"]
|
|
908
|
+
return merged
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def ensure_hook_session(
|
|
912
|
+
root: Path,
|
|
913
|
+
payload: dict,
|
|
914
|
+
agent: str | None,
|
|
915
|
+
ppid: int | None = None,
|
|
916
|
+
) -> tuple[dict, Path]:
|
|
917
|
+
session_path = resolve_hook_session_path(root, payload, agent, ppid)
|
|
918
|
+
lock_path = acquire_session_command_lock(root, session_path)
|
|
919
|
+
try:
|
|
920
|
+
return ensure_hook_session_unlocked(root, payload, agent, ppid)
|
|
921
|
+
finally:
|
|
922
|
+
release_session_command_lock(lock_path)
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def ensure_hook_session_unlocked(
|
|
926
|
+
root: Path,
|
|
927
|
+
payload: dict,
|
|
928
|
+
agent: str | None,
|
|
929
|
+
ppid: int | None = None,
|
|
930
|
+
) -> tuple[dict, Path]:
|
|
931
|
+
identity = hook_session_identity(payload, agent, ppid)
|
|
932
|
+
session_path = resolve_hook_session_path(root, payload, agent, ppid)
|
|
933
|
+
resolved_ppid = ppid if ppid is not None else os.getppid()
|
|
934
|
+
legacy_state_lock = acquire_legacy_state_lock(root)
|
|
935
|
+
try:
|
|
936
|
+
session = load_session(root, session_path)
|
|
937
|
+
legacy_state = (
|
|
938
|
+
migrate_legacy_state(root, str(identity["agent"]))
|
|
939
|
+
if legacy_state_lock is not None
|
|
940
|
+
else None
|
|
941
|
+
)
|
|
942
|
+
|
|
943
|
+
if session is None:
|
|
944
|
+
clean_session_runtime(root, reserve_slots=1)
|
|
945
|
+
session = migrate_legacy_pid_session(root, session_path, identity, resolved_ppid)
|
|
946
|
+
if session is None:
|
|
947
|
+
session = load_session(root, session_path)
|
|
948
|
+
if session is None:
|
|
949
|
+
session = default_session()
|
|
950
|
+
if legacy_state is not None:
|
|
951
|
+
session = merge_legacy_session(session, legacy_state)
|
|
952
|
+
|
|
953
|
+
apply_hook_session_identity(session, identity)
|
|
954
|
+
write_session(root, session, session_path)
|
|
955
|
+
if legacy_state is not None:
|
|
956
|
+
try:
|
|
957
|
+
(root / ".easy-coding" / "state.json").unlink()
|
|
958
|
+
except OSError:
|
|
959
|
+
pass
|
|
960
|
+
return session, session_path
|
|
961
|
+
finally:
|
|
962
|
+
release_legacy_state_lock(legacy_state_lock)
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def clean_stale_sessions(
|
|
966
|
+
root: Path,
|
|
967
|
+
threshold_hours: int | None = None,
|
|
968
|
+
idle_threshold_hours: int = SESSION_IDLE_RETENTION_HOURS,
|
|
969
|
+
attached_threshold_hours: int = SESSION_ATTACHED_RETENTION_HOURS,
|
|
970
|
+
max_sessions: int = MAX_SESSION_FILES,
|
|
971
|
+
reserve_slots: int = 0,
|
|
972
|
+
) -> int:
|
|
973
|
+
sessions_dir = root / ".easy-coding" / "sessions"
|
|
974
|
+
if not sessions_dir.is_dir():
|
|
975
|
+
return 0
|
|
976
|
+
|
|
977
|
+
now = datetime.now(timezone.utc)
|
|
978
|
+
if threshold_hours is not None:
|
|
979
|
+
idle_threshold_hours = threshold_hours
|
|
980
|
+
attached_threshold_hours = threshold_hours
|
|
981
|
+
candidates: list[tuple[Path, str, dict, datetime]] = []
|
|
982
|
+
for entry in sessions_dir.iterdir():
|
|
983
|
+
if not entry.is_file() or entry.suffix != ".json":
|
|
984
|
+
continue
|
|
985
|
+
try:
|
|
986
|
+
content = entry.read_text(encoding="utf-8")
|
|
987
|
+
try:
|
|
988
|
+
session = json.loads(content)
|
|
989
|
+
except json.JSONDecodeError:
|
|
990
|
+
session = {}
|
|
991
|
+
if not isinstance(session, dict):
|
|
992
|
+
session = {}
|
|
993
|
+
activity_value = session.get("last_active_at") or session.get("created_at")
|
|
994
|
+
try:
|
|
995
|
+
if not isinstance(activity_value, str):
|
|
996
|
+
raise ValueError
|
|
997
|
+
last_active = datetime.fromisoformat(activity_value)
|
|
998
|
+
if last_active.tzinfo is None:
|
|
999
|
+
last_active = last_active.replace(tzinfo=timezone.utc)
|
|
1000
|
+
except (ValueError, TypeError):
|
|
1001
|
+
last_active = datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc)
|
|
1002
|
+
candidates.append((entry, content, session, last_active))
|
|
1003
|
+
except OSError:
|
|
1004
|
+
continue
|
|
1005
|
+
|
|
1006
|
+
removed: set[Path] = set()
|
|
1007
|
+
for entry, content, session, last_active in candidates:
|
|
1008
|
+
retention_hours = (
|
|
1009
|
+
attached_threshold_hours if session.get("current_task") else idle_threshold_hours
|
|
1010
|
+
)
|
|
1011
|
+
age_hours = (now - last_active).total_seconds() / 3600
|
|
1012
|
+
if age_hours <= retention_hours:
|
|
1013
|
+
continue
|
|
1014
|
+
if unlink_session_if_unchanged(entry, content):
|
|
1015
|
+
removed.add(entry)
|
|
1016
|
+
|
|
1017
|
+
allowed_existing = max(0, max_sessions - reserve_slots)
|
|
1018
|
+
remaining = sorted(
|
|
1019
|
+
(candidate for candidate in candidates if candidate[0] not in removed),
|
|
1020
|
+
key=lambda candidate: candidate[3],
|
|
1021
|
+
)
|
|
1022
|
+
overflow = max(0, len(remaining) - allowed_existing)
|
|
1023
|
+
for entry, content, _session, _last_active in remaining[:overflow]:
|
|
1024
|
+
if unlink_session_if_unchanged(entry, content):
|
|
1025
|
+
removed.add(entry)
|
|
1026
|
+
return len(removed)
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def unlink_session_if_unchanged(entry: Path, expected_content: str) -> bool:
|
|
1030
|
+
try:
|
|
1031
|
+
if entry.read_text(encoding="utf-8") != expected_content:
|
|
1032
|
+
return False
|
|
1033
|
+
entry.unlink()
|
|
1034
|
+
invalidate_memo(("json", str(entry)))
|
|
1035
|
+
return True
|
|
1036
|
+
except OSError:
|
|
1037
|
+
# GC 采用尽力清理;锁定、并发移除等失败文件留到后续新会话再次处理。
|
|
1038
|
+
return False
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def clean_orphan_acceptance_snapshots(root: Path) -> int:
|
|
1042
|
+
acceptance_dir = root / ".easy-coding" / "sessions" / "acceptance"
|
|
1043
|
+
if not acceptance_dir.is_dir():
|
|
1044
|
+
return 0
|
|
1045
|
+
|
|
1046
|
+
cleaned = 0
|
|
1047
|
+
for entry in acceptance_dir.iterdir():
|
|
1048
|
+
if not entry.is_file() or entry.suffix != ".json":
|
|
1049
|
+
continue
|
|
1050
|
+
task_path = root / ".easy-coding" / "tasks" / entry.stem / "task.json"
|
|
1051
|
+
if task_path.is_file():
|
|
1052
|
+
try:
|
|
1053
|
+
task = json.loads(task_path.read_text(encoding="utf-8"))
|
|
1054
|
+
except (OSError, json.JSONDecodeError):
|
|
1055
|
+
continue
|
|
1056
|
+
if not isinstance(task, dict):
|
|
1057
|
+
continue
|
|
1058
|
+
else:
|
|
1059
|
+
task = None
|
|
1060
|
+
|
|
1061
|
+
checkpoint = None
|
|
1062
|
+
if task is not None:
|
|
1063
|
+
checkpoint = task.get("quality_checkpoint")
|
|
1064
|
+
if not isinstance(checkpoint, dict):
|
|
1065
|
+
checkpoint = task.get("verification_checkpoint")
|
|
1066
|
+
snapshot_file = checkpoint.get("snapshot_file") if isinstance(checkpoint, dict) else None
|
|
1067
|
+
referenced = bool(
|
|
1068
|
+
isinstance(snapshot_file, str)
|
|
1069
|
+
and (root / snapshot_file).resolve() == entry.resolve()
|
|
1070
|
+
)
|
|
1071
|
+
terminal = task is not None and task.get("status") in TERMINAL_STATUSES
|
|
1072
|
+
if task is not None and referenced and not terminal:
|
|
1073
|
+
continue
|
|
1074
|
+
try:
|
|
1075
|
+
entry.unlink()
|
|
1076
|
+
cleaned += 1
|
|
1077
|
+
except OSError:
|
|
1078
|
+
# 验收快照清理失败不能阻断新逻辑会话启动。
|
|
1079
|
+
continue
|
|
1080
|
+
return cleaned
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def clean_session_runtime(root: Path, reserve_slots: int = 0) -> dict:
|
|
1084
|
+
return {
|
|
1085
|
+
"sessions_removed": clean_stale_sessions(root, reserve_slots=reserve_slots),
|
|
1086
|
+
"acceptance_snapshots_removed": clean_orphan_acceptance_snapshots(root),
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def task_json_path(root: Path, task_id: str) -> Path:
|
|
1091
|
+
assert_safe_task_id(task_id)
|
|
1092
|
+
return root / ".easy-coding" / "tasks" / task_id / "task.json"
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def load_task(root: Path, task_id: str | None) -> dict | None:
|
|
1096
|
+
if not task_id:
|
|
1097
|
+
return None
|
|
1098
|
+
return load_json(task_json_path(root, str(task_id)))
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def execution_log_path(root: Path, task_id: str) -> Path:
|
|
1102
|
+
assert_safe_task_id(task_id)
|
|
1103
|
+
return root / ".easy-coding" / "tasks" / task_id / "execution.jsonl"
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def is_non_empty_string(value: object) -> bool:
|
|
1107
|
+
return isinstance(value, str) and bool(value.strip())
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def execution_records(root: Path, task_id: str) -> list[dict]:
|
|
1111
|
+
path = execution_log_path(root, task_id)
|
|
1112
|
+
return memo(("execution", str(path)), lambda: _execution_records(path))
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def _execution_records(path: Path) -> list[dict]:
|
|
1116
|
+
if not path.exists():
|
|
1117
|
+
return []
|
|
1118
|
+
records: list[dict] = []
|
|
1119
|
+
try:
|
|
1120
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
1121
|
+
if not line.strip():
|
|
1122
|
+
continue
|
|
1123
|
+
record = json.loads(line)
|
|
1124
|
+
if isinstance(record, dict):
|
|
1125
|
+
records.append(record)
|
|
1126
|
+
except (OSError, json.JSONDecodeError):
|
|
1127
|
+
return []
|
|
1128
|
+
return records
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def assert_safe_task_id(task_id: str) -> None:
|
|
1132
|
+
path = Path(task_id)
|
|
1133
|
+
if not task_id or path.is_absolute() or "/" in task_id or "\\" in task_id or ".." in path.parts:
|
|
1134
|
+
raise StateError(f"Unsafe task id: {task_id}")
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def transition_requires_confirmation(
|
|
1138
|
+
previous: str,
|
|
1139
|
+
current: str,
|
|
1140
|
+
task_type: str,
|
|
1141
|
+
approval_mode: str,
|
|
1142
|
+
) -> bool:
|
|
1143
|
+
if (previous, current) in ALWAYS_AUTO_TRANSITIONS:
|
|
1144
|
+
return False
|
|
1145
|
+
if current == "CLOSED":
|
|
1146
|
+
return True
|
|
1147
|
+
if approval_mode == "auto":
|
|
1148
|
+
return False
|
|
1149
|
+
if approval_mode == "guard":
|
|
1150
|
+
return (previous, current) in CRITICAL_CONFIRM_TRANSITIONS
|
|
1151
|
+
if approval_mode == "confirm":
|
|
1152
|
+
return (previous, current) == ANALYSIS_CONFIRM_TRANSITION
|
|
1153
|
+
if approval_mode == "approve":
|
|
1154
|
+
return True
|
|
1155
|
+
raise StateError(f"Unknown approval mode: {approval_mode}")
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def is_automatic_transition(
|
|
1159
|
+
previous: str,
|
|
1160
|
+
current: str,
|
|
1161
|
+
task_type: str,
|
|
1162
|
+
approval_mode: str,
|
|
1163
|
+
) -> bool:
|
|
1164
|
+
return not transition_requires_confirmation(previous, current, task_type, approval_mode)
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def validate_transition(
|
|
1168
|
+
previous: str,
|
|
1169
|
+
current: str,
|
|
1170
|
+
task_type: str = "",
|
|
1171
|
+
task: dict | None = None,
|
|
1172
|
+
) -> str | None:
|
|
1173
|
+
if previous == current:
|
|
1174
|
+
return None
|
|
1175
|
+
allowed = set(VALID_TRANSITIONS.get(previous, set()))
|
|
1176
|
+
if previous == "IMPLEMENT":
|
|
1177
|
+
allowed.discard("COMPLETE")
|
|
1178
|
+
if current in allowed:
|
|
1179
|
+
return None
|
|
1180
|
+
return (
|
|
1181
|
+
f"ILLEGAL TRANSITION: {previous} -> {current}. "
|
|
1182
|
+
f"Allowed from {previous}: {sorted(allowed) or 'NONE (terminal state)'}."
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def ensure_session(root: Path, session_file: str | Path | None = None) -> dict:
|
|
1187
|
+
session = load_session(root, session_file)
|
|
1188
|
+
if session is None:
|
|
1189
|
+
session = default_session()
|
|
1190
|
+
if not session.get("created_at"):
|
|
1191
|
+
session["created_at"] = now_iso()
|
|
1192
|
+
session["last_active_at"] = now_iso()
|
|
1193
|
+
return session
|