easy-coding-harness 1.1.0-beta.2 → 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 +20 -0
- package/README.md +4 -2
- package/package.json +1 -1
- package/templates/common/skills/ec-analysis/SKILL.md +9 -3
- package/templates/common/skills/ec-implementing/SKILL.md +3 -0
- package/templates/common/skills/ec-memory/SKILL.md +48 -24
- package/templates/common/skills/ec-quality/SKILL.md +11 -8
- package/templates/common/skills/ec-workflow/SKILL.md +3 -0
- package/templates/main-constraint/AGENTS.md.tpl +3 -2
- package/templates/main-constraint/CLAUDE.md.tpl +3 -2
- package/templates/runtime/memory/SHORT_MEMORY_TEMPLATE.md +21 -48
- 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 +216 -1816
- 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
|
@@ -41,51 +41,119 @@ from easy_dev_spec_execution import (
|
|
|
41
41
|
)
|
|
42
42
|
from easy_dev_spec_protocol import split_execution_region
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
from easy_coding_store import (
|
|
45
|
+
ALWAYS_AUTO_TRANSITIONS,
|
|
46
|
+
ANALYSIS_CONFIRM_TRANSITION,
|
|
47
|
+
APPROVAL_MODES,
|
|
48
|
+
CODEX_AGENT_PATH_PATTERN,
|
|
49
|
+
CONFIGURED_WORKFLOW_MODES,
|
|
50
|
+
COVERAGE_TOOL_PATH,
|
|
51
|
+
CRITICAL_CONFIRM_TRANSITIONS,
|
|
52
|
+
DEFAULT_APPROVAL_MODE,
|
|
53
|
+
DEFAULT_UNIT_TEST_MODE,
|
|
54
|
+
DEFAULT_UT_COVERAGE_THRESHOLD,
|
|
55
|
+
DEFAULT_WORKFLOW_MODE,
|
|
56
|
+
GITLAB_CI_ENTRY_FILES,
|
|
57
|
+
HELP_SUFFIX,
|
|
58
|
+
INSTALLED_WORKFLOW_AGENT,
|
|
59
|
+
JAVA_BUILD_FILE_NAMES,
|
|
60
|
+
LEGACY_DISPLAY_AGENT_IDENTITIES,
|
|
61
|
+
LEGACY_STAGE_MAP,
|
|
62
|
+
LEGACY_STATE_LOCK_POLL_SECONDS,
|
|
63
|
+
LEGACY_STATE_LOCK_STALE_SECONDS,
|
|
64
|
+
LEGACY_STATE_LOCK_TIMEOUT_SECONDS,
|
|
65
|
+
MANDATORY_DEV_SPEC_HEADERS,
|
|
66
|
+
MAX_SESSION_FILES,
|
|
67
|
+
READY_LINE,
|
|
68
|
+
SESSION_AGENT_NAMESPACES,
|
|
69
|
+
SESSION_ATTACHED_RETENTION_HOURS,
|
|
70
|
+
SESSION_COMMAND_LOCK_POLL_SECONDS,
|
|
71
|
+
SESSION_COMMAND_LOCK_STALE_SECONDS,
|
|
72
|
+
SESSION_COMMAND_LOCK_TIMEOUT_SECONDS,
|
|
73
|
+
SESSION_COMPONENT_PATTERN,
|
|
74
|
+
SESSION_IDLE_RETENTION_HOURS,
|
|
75
|
+
StateError,
|
|
76
|
+
TDD_BASE_VARIABLE,
|
|
77
|
+
TDD_INIT_TASK_TYPE,
|
|
78
|
+
TDD_READINESS_PATH,
|
|
79
|
+
TDD_READINESS_SCHEMA,
|
|
80
|
+
TDD_READINESS_SCOPE,
|
|
81
|
+
TDD_THRESHOLD_VARIABLE,
|
|
82
|
+
TERMINAL_STATUSES,
|
|
83
|
+
VALID_TRANSITIONS,
|
|
84
|
+
WAITING_INIT_LINE,
|
|
85
|
+
WORKFLOW_AGENT_IDENTITIES,
|
|
86
|
+
_execution_records,
|
|
87
|
+
_read_behavior_file,
|
|
88
|
+
acquire_legacy_state_lock,
|
|
89
|
+
acquire_session_command_lock,
|
|
90
|
+
agents_equivalent,
|
|
91
|
+
apply_hook_session_identity,
|
|
92
|
+
assert_safe_task_id,
|
|
93
|
+
behavior_layers,
|
|
94
|
+
canonical_agent_identity,
|
|
95
|
+
clean_orphan_acceptance_snapshots,
|
|
96
|
+
clean_session_runtime,
|
|
97
|
+
clean_stale_sessions,
|
|
98
|
+
clear_session_pointer,
|
|
99
|
+
default_session,
|
|
100
|
+
detect_runtime_agent,
|
|
101
|
+
display_path,
|
|
102
|
+
ensure_hook_session,
|
|
103
|
+
ensure_hook_session_unlocked,
|
|
104
|
+
ensure_session,
|
|
105
|
+
execution_log_path,
|
|
106
|
+
execution_records,
|
|
107
|
+
hook_session_identity,
|
|
108
|
+
is_automatic_transition,
|
|
109
|
+
is_non_empty_string,
|
|
110
|
+
load_json,
|
|
111
|
+
load_session,
|
|
112
|
+
load_task,
|
|
113
|
+
merge_legacy_session,
|
|
114
|
+
migrate_legacy_pid_session,
|
|
115
|
+
migrate_legacy_state,
|
|
116
|
+
migrate_unit_test_settings,
|
|
117
|
+
normalize_agent_identity,
|
|
118
|
+
normalize_legacy_stage,
|
|
119
|
+
normalize_legacy_task,
|
|
120
|
+
normalize_session_agent,
|
|
121
|
+
normalize_session_component,
|
|
122
|
+
now_iso,
|
|
123
|
+
parse_unit_test_mode,
|
|
124
|
+
parse_ut_threshold,
|
|
125
|
+
read_behavior_file,
|
|
126
|
+
read_project_behavior,
|
|
127
|
+
release_legacy_state_lock,
|
|
128
|
+
release_session_command_lock,
|
|
129
|
+
resolve_behavior,
|
|
130
|
+
resolve_hook_session_path,
|
|
131
|
+
resolve_session_path,
|
|
132
|
+
safe_tdd_report_pattern,
|
|
133
|
+
session_command_lock_path,
|
|
134
|
+
task_json_path,
|
|
135
|
+
tdd_ci_contract_reasons,
|
|
136
|
+
tdd_gate_uses_task_variables,
|
|
137
|
+
tdd_readiness,
|
|
138
|
+
transition_requires_confirmation,
|
|
139
|
+
unlink_session_if_unchanged,
|
|
140
|
+
validate_transition,
|
|
141
|
+
write_json,
|
|
142
|
+
write_session,
|
|
143
|
+
)
|
|
144
|
+
from easy_coding_status import (
|
|
145
|
+
build_machine_breadcrumbs,
|
|
146
|
+
build_status_context,
|
|
147
|
+
build_status_line,
|
|
148
|
+
get_pending_init_version,
|
|
149
|
+
is_project_init_required,
|
|
150
|
+
pending_handoff_record,
|
|
151
|
+
record_seen_stage,
|
|
152
|
+
snapshot_state,
|
|
153
|
+
spec_task_summary,
|
|
50
154
|
)
|
|
51
|
-
READY_LINE = f"Ready · {HELP_SUFFIX}"
|
|
52
|
-
WAITING_INIT_LINE = "Waiting init · Use `ec-init` to initialize"
|
|
53
|
-
|
|
54
|
-
MANDATORY_DEV_SPEC_HEADERS: list[str] = [
|
|
55
|
-
"## 技术方案",
|
|
56
|
-
"### 项目模式",
|
|
57
|
-
"### 任务类型",
|
|
58
|
-
"### 需求解析",
|
|
59
|
-
"### 现状",
|
|
60
|
-
"### 冲突摘要",
|
|
61
|
-
"### 决策闭环",
|
|
62
|
-
"### 影响面分析",
|
|
63
|
-
"### 改动范围",
|
|
64
|
-
"### 修改方案",
|
|
65
|
-
"### 实施拆解",
|
|
66
|
-
"### 测试策略",
|
|
67
|
-
"### Workflow Mode",
|
|
68
|
-
"### 风险与注意事项",
|
|
69
|
-
]
|
|
70
|
-
|
|
71
|
-
VALID_TRANSITIONS: dict[str, set[str]] = {
|
|
72
|
-
"idle": {"INIT"},
|
|
73
|
-
"INIT": {"ANALYSIS", "CLOSED"},
|
|
74
|
-
"ANALYSIS": {"IMPLEMENT", "CLOSED"},
|
|
75
|
-
"IMPLEMENT": {"QUALITY", "ANALYSIS", "CLOSED"},
|
|
76
|
-
"QUALITY": {"MEMORY", "IMPLEMENT", "ANALYSIS", "CLOSED"},
|
|
77
|
-
"MEMORY": {"COMPLETE", "CLOSED"},
|
|
78
|
-
"COMPLETE": set(),
|
|
79
|
-
"CLOSED": set(),
|
|
80
|
-
}
|
|
81
155
|
|
|
82
|
-
|
|
83
|
-
("INIT", "ANALYSIS"),
|
|
84
|
-
("MEMORY", "COMPLETE"),
|
|
85
|
-
}
|
|
86
|
-
TDD_INIT_TASK_TYPE = "tdd-init"
|
|
87
|
-
APPROVAL_MODES = {"approve", "guard", "confirm", "auto"}
|
|
88
|
-
CONFIGURED_WORKFLOW_MODES = {"adaptive", "fast", "standard", "strict"}
|
|
156
|
+
|
|
89
157
|
WORKFLOW_MODES = {"fast", "standard", "strict"}
|
|
90
158
|
WORKFLOW_MODE_RANK = {"fast": 0, "standard": 1, "strict": 2}
|
|
91
159
|
STRICT_VERIFICATION_CHECK_TYPES = {"lint", "typecheck", "test", "build"}
|
|
@@ -124,31 +192,7 @@ WIDE_WORKFLOW_CONTRACT_PATTERN = re.compile(
|
|
|
124
192
|
r"(cross[-_ ]?repo|public[-_ ]?(api|contract)|跨仓|公共接口|公共契约)",
|
|
125
193
|
re.IGNORECASE,
|
|
126
194
|
)
|
|
127
|
-
|
|
128
|
-
DEFAULT_WORKFLOW_MODE = "adaptive"
|
|
129
|
-
DEFAULT_UNIT_TEST_MODE = "none"
|
|
130
|
-
DEFAULT_UT_COVERAGE_THRESHOLD = 90
|
|
131
|
-
TDD_READINESS_SCHEMA = "easy-coding/tdd-readiness-v1"
|
|
132
|
-
TDD_READINESS_SCOPE = "changed-production-lines"
|
|
133
|
-
TDD_READINESS_PATH = Path(".easy-coding/tdd/readiness.json")
|
|
134
|
-
TDD_BASE_VARIABLE = "EASY_CODING_TDD_BASE_SHA"
|
|
135
|
-
TDD_THRESHOLD_VARIABLE = "EASY_CODING_TDD_THRESHOLD"
|
|
136
|
-
COVERAGE_TOOL_PATH = ".easy-coding/tools/easy_coding_java_coverage.py"
|
|
137
|
-
JAVA_BUILD_FILE_NAMES = {"pom.xml", "build.gradle", "build.gradle.kts"}
|
|
138
|
-
GITLAB_CI_ENTRY_FILES = {".gitlab-ci.yml", ".gitlab-ci.yaml"}
|
|
139
|
-
CRITICAL_CONFIRM_TRANSITIONS = {
|
|
140
|
-
("ANALYSIS", "IMPLEMENT"),
|
|
141
|
-
("QUALITY", "MEMORY"),
|
|
142
|
-
}
|
|
143
|
-
ANALYSIS_CONFIRM_TRANSITION = ("ANALYSIS", "IMPLEMENT")
|
|
144
|
-
|
|
145
|
-
LEGACY_STAGE_MAP = {
|
|
146
|
-
"WAITING_CONFIRM": "ANALYSIS",
|
|
147
|
-
"REVIEW": "QUALITY",
|
|
148
|
-
"VERIFICATION": "QUALITY",
|
|
149
|
-
"MEMORY_SHORT": "MEMORY",
|
|
150
|
-
"MEMORY_LONG": "MEMORY",
|
|
151
|
-
}
|
|
195
|
+
|
|
152
196
|
|
|
153
197
|
DEFAULT_SHORT_TERM_MAX = 10
|
|
154
198
|
DEFAULT_SHORT_TERM_KEEP = 5
|
|
@@ -160,28 +204,6 @@ ARCHITECTURE_CHANGELOG_PATH = Path(".easy-coding/CHANGELOG.md")
|
|
|
160
204
|
ARCHITECTURE_ACTIONS = {"no-op", "backfill", "update"}
|
|
161
205
|
ACCEPTANCE_SNAPSHOT_SCHEMA = 1
|
|
162
206
|
ACCEPTANCE_VERIFICATION_POLICIES = {"carry-forward", "targeted", "waived"}
|
|
163
|
-
SESSION_IDLE_RETENTION_HOURS = 7 * 24
|
|
164
|
-
SESSION_ATTACHED_RETENTION_HOURS = 30 * 24
|
|
165
|
-
MAX_SESSION_FILES = 100
|
|
166
|
-
SESSION_COMPONENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
167
|
-
WORKFLOW_AGENT_IDENTITIES = {"claude-code", "codex", "qoder"}
|
|
168
|
-
# 安装时固化的宿主身份是生产事实源;未渲染源码保留占位符供本仓测试直接加载。
|
|
169
|
-
INSTALLED_WORKFLOW_AGENT = "{{workflow_agent_id}}"
|
|
170
|
-
SESSION_AGENT_NAMESPACES = {"claude-code", "codex", "qoder", "unknown"}
|
|
171
|
-
CODEX_AGENT_PATH_PATTERN = re.compile(r"^/?root(?:/[a-z0-9._-]+)*$")
|
|
172
|
-
LEGACY_DISPLAY_AGENT_IDENTITIES = {
|
|
173
|
-
"claude with easy coding": "claude-code",
|
|
174
|
-
"claude-code with easy coding": "claude-code",
|
|
175
|
-
"claude code with easy coding": "claude-code",
|
|
176
|
-
"codex with easy coding": "codex",
|
|
177
|
-
"qoder with easy coding": "qoder",
|
|
178
|
-
}
|
|
179
|
-
LEGACY_STATE_LOCK_TIMEOUT_SECONDS = 5.0
|
|
180
|
-
LEGACY_STATE_LOCK_STALE_SECONDS = 60.0
|
|
181
|
-
LEGACY_STATE_LOCK_POLL_SECONDS = 0.02
|
|
182
|
-
SESSION_COMMAND_LOCK_TIMEOUT_SECONDS = 5.0
|
|
183
|
-
SESSION_COMMAND_LOCK_STALE_SECONDS = 60.0
|
|
184
|
-
SESSION_COMMAND_LOCK_POLL_SECONDS = 0.02
|
|
185
207
|
SHORT_MEMORY_UUID_V7_PATTERN = re.compile(
|
|
186
208
|
r"^SM-[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
187
209
|
)
|
|
@@ -221,20 +243,12 @@ TABLE_HEADER_CELLS = {
|
|
|
221
243
|
}
|
|
222
244
|
|
|
223
245
|
|
|
224
|
-
class StateError(Exception):
|
|
225
|
-
pass
|
|
226
|
-
|
|
227
|
-
|
|
228
246
|
def configure_stdio() -> None:
|
|
229
247
|
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
230
248
|
if hasattr(stream, "reconfigure"):
|
|
231
249
|
stream.reconfigure(encoding="utf-8")
|
|
232
250
|
|
|
233
251
|
|
|
234
|
-
def now_iso() -> str:
|
|
235
|
-
return datetime.now(timezone.utc).isoformat()
|
|
236
|
-
|
|
237
|
-
|
|
238
252
|
def generate_short_memory_id() -> str:
|
|
239
253
|
timestamp_ms = time.time_ns() // 1_000_000
|
|
240
254
|
random_bits = secrets.randbits(74)
|
|
@@ -258,56 +272,6 @@ def short_memory_id_sort_key(memory_id: str) -> tuple[int, str]:
|
|
|
258
272
|
return (2, memory_id)
|
|
259
273
|
|
|
260
274
|
|
|
261
|
-
def canonical_agent_identity(agent: str | None, allow_legacy_display: bool = False) -> str | None:
|
|
262
|
-
raw_agent = str(agent or "unknown").strip()
|
|
263
|
-
normalized = raw_agent.lower()
|
|
264
|
-
# Codex 可能把根执行者写成 root 或 /root;两者及其协作子路径都属于同一平台身份。
|
|
265
|
-
if CODEX_AGENT_PATH_PATTERN.fullmatch(normalized):
|
|
266
|
-
return "codex"
|
|
267
|
-
if normalized in WORKFLOW_AGENT_IDENTITIES:
|
|
268
|
-
return normalized
|
|
269
|
-
if allow_legacy_display:
|
|
270
|
-
return LEGACY_DISPLAY_AGENT_IDENTITIES.get(normalized)
|
|
271
|
-
return None
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
def normalize_agent_identity(agent: str | None) -> str:
|
|
275
|
-
raw_agent = str(agent or "unknown").strip()
|
|
276
|
-
# 旧数据可能误把展示署名写入 owner;只在读取兼容边界将其还原为规范身份。
|
|
277
|
-
canonical = canonical_agent_identity(raw_agent, allow_legacy_display=True)
|
|
278
|
-
if canonical is not None:
|
|
279
|
-
return canonical
|
|
280
|
-
return raw_agent
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
def normalize_session_agent(agent: str | None) -> str:
|
|
284
|
-
normalized = normalize_agent_identity(agent)
|
|
285
|
-
return normalized if normalized in SESSION_AGENT_NAMESPACES else "unknown"
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
def agents_equivalent(first: str | None, second: str | None) -> bool:
|
|
289
|
-
return normalize_agent_identity(first) == normalize_agent_identity(second)
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
def detect_runtime_agent() -> str:
|
|
293
|
-
if INSTALLED_WORKFLOW_AGENT in WORKFLOW_AGENT_IDENTITIES:
|
|
294
|
-
return INSTALLED_WORKFLOW_AGENT
|
|
295
|
-
# 仅供未渲染源码和旧安装兼容;新安装脚本始终走上面的固化身份。
|
|
296
|
-
script_path = Path(sys.argv[0]).as_posix()
|
|
297
|
-
if ".qoder/" in script_path or ".qodercn/" in script_path:
|
|
298
|
-
return "qoder"
|
|
299
|
-
if ".codex/" in script_path:
|
|
300
|
-
return "codex"
|
|
301
|
-
if ".claude/" in script_path:
|
|
302
|
-
return "claude-code"
|
|
303
|
-
# Qoder CLI 会暴露 Claude 兼容环境变量,专属信号必须优先于兼容信号。
|
|
304
|
-
if os.environ.get("QODER_PROJECT_DIR"):
|
|
305
|
-
return "qoder"
|
|
306
|
-
if os.environ.get("CLAUDE_PROJECT_DIR"):
|
|
307
|
-
return "claude-code"
|
|
308
|
-
return "unknown"
|
|
309
|
-
|
|
310
|
-
|
|
311
275
|
def resolve_state_agent(explicit_agent: str | None) -> str:
|
|
312
276
|
runtime_agent = detect_runtime_agent()
|
|
313
277
|
explicit_identity = None
|
|
@@ -347,47 +311,7 @@ def validate_session_agent(agent: str, session_file: str | Path | None) -> None:
|
|
|
347
311
|
)
|
|
348
312
|
|
|
349
313
|
|
|
350
|
-
def normalize_session_component(value: str) -> str:
|
|
351
|
-
if (
|
|
352
|
-
value not in {".", ".."}
|
|
353
|
-
and len(value) <= 120
|
|
354
|
-
and SESSION_COMPONENT_PATTERN.fullmatch(value)
|
|
355
|
-
):
|
|
356
|
-
return value
|
|
357
|
-
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:32]
|
|
358
|
-
return f"sha256-{digest}"
|
|
359
|
-
|
|
360
|
-
|
|
361
314
|
# 逻辑会话由 Agent 命名空间与平台会话 ID 共同标识;PPID 只用于缺少逻辑 ID 的兼容回退。
|
|
362
|
-
def hook_session_identity(
|
|
363
|
-
payload: dict,
|
|
364
|
-
agent: str | None,
|
|
365
|
-
ppid: int | None = None,
|
|
366
|
-
) -> dict:
|
|
367
|
-
namespace = normalize_session_agent(agent)
|
|
368
|
-
raw_session_id = payload.get("session_id") or payload.get("sessionId")
|
|
369
|
-
external_session_id = str(raw_session_id).strip() if raw_session_id is not None else ""
|
|
370
|
-
source = "hook-session-id"
|
|
371
|
-
if not external_session_id and namespace == "codex":
|
|
372
|
-
# Codex App 当前会把 thread ID 暴露在进程环境中;标准 hook session_id 仍保持最高优先级。
|
|
373
|
-
raw_thread_id = (
|
|
374
|
-
payload.get("thread_id")
|
|
375
|
-
or payload.get("threadId")
|
|
376
|
-
or os.environ.get("CODEX_THREAD_ID")
|
|
377
|
-
)
|
|
378
|
-
external_session_id = str(raw_thread_id).strip() if raw_thread_id is not None else ""
|
|
379
|
-
source = "codex-thread-id"
|
|
380
|
-
if external_session_id:
|
|
381
|
-
component = normalize_session_component(external_session_id)
|
|
382
|
-
else:
|
|
383
|
-
component = f"ppid-{ppid if ppid is not None else os.getppid()}"
|
|
384
|
-
source = "legacy-ppid"
|
|
385
|
-
return {
|
|
386
|
-
"agent": namespace,
|
|
387
|
-
"external_session_id": external_session_id or None,
|
|
388
|
-
"session_key": f"{namespace}-{component}",
|
|
389
|
-
"session_source": source,
|
|
390
|
-
}
|
|
391
315
|
|
|
392
316
|
|
|
393
317
|
def find_ec_root(start: Path) -> Path | None:
|
|
@@ -400,15 +324,6 @@ def find_ec_root(start: Path) -> Path | None:
|
|
|
400
324
|
current = current.parent
|
|
401
325
|
|
|
402
326
|
|
|
403
|
-
def load_json(path: Path) -> dict | None:
|
|
404
|
-
if not path.exists():
|
|
405
|
-
return None
|
|
406
|
-
try:
|
|
407
|
-
return json.loads(path.read_text(encoding="utf-8"))
|
|
408
|
-
except (OSError, json.JSONDecodeError):
|
|
409
|
-
return None
|
|
410
|
-
|
|
411
|
-
|
|
412
327
|
def parse_positive_int(value: str) -> int | None:
|
|
413
328
|
normalized = value.split("#", 1)[0].strip().strip("'\"")
|
|
414
329
|
try:
|
|
@@ -459,287 +374,6 @@ def read_memory_config(root: Path) -> dict[str, int]:
|
|
|
459
374
|
return config
|
|
460
375
|
|
|
461
376
|
|
|
462
|
-
def parse_ut_threshold(value: object, source: str) -> int:
|
|
463
|
-
if isinstance(value, bool):
|
|
464
|
-
raise StateError(f"Invalid {source}: expected an integer from 1 to 100.")
|
|
465
|
-
try:
|
|
466
|
-
threshold = int(str(value))
|
|
467
|
-
except (TypeError, ValueError) as error:
|
|
468
|
-
raise StateError(f"Invalid {source}: expected an integer from 1 to 100.") from error
|
|
469
|
-
if threshold < 1 or threshold > 100:
|
|
470
|
-
raise StateError(f"Invalid {source}: expected an integer from 1 to 100.")
|
|
471
|
-
return threshold
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
def parse_unit_test_mode(value: object, source: str) -> str:
|
|
475
|
-
if not isinstance(value, str) or value not in {"none", "ut", "tdd"}:
|
|
476
|
-
raise StateError(f"Invalid {source}: expected none, ut, or tdd.")
|
|
477
|
-
return str(value)
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
def read_behavior_file(path: Path) -> tuple[dict, int]:
|
|
481
|
-
return memo(("behavior", str(path)), lambda: _read_behavior_file(path))
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
def _read_behavior_file(path: Path) -> tuple[dict, int]:
|
|
485
|
-
try:
|
|
486
|
-
lines = path.read_text(encoding="utf-8").splitlines()
|
|
487
|
-
except FileNotFoundError:
|
|
488
|
-
return {}, 0
|
|
489
|
-
|
|
490
|
-
in_behavior = False
|
|
491
|
-
behavior_indent = 0
|
|
492
|
-
behavior: dict[str, str] = {}
|
|
493
|
-
schema_version = 0
|
|
494
|
-
for raw_line in lines:
|
|
495
|
-
without_comment = raw_line.split("#", 1)[0].rstrip()
|
|
496
|
-
stripped = without_comment.strip()
|
|
497
|
-
if not stripped:
|
|
498
|
-
continue
|
|
499
|
-
indent = len(without_comment) - len(without_comment.lstrip(" "))
|
|
500
|
-
if stripped.startswith("behavior:") and stripped != "behavior:":
|
|
501
|
-
raise StateError("Behavior configuration must use an indented YAML mapping; write it with easy-coding config.")
|
|
502
|
-
if stripped == "behavior:":
|
|
503
|
-
in_behavior = True
|
|
504
|
-
behavior_indent = indent
|
|
505
|
-
continue
|
|
506
|
-
if in_behavior and indent <= behavior_indent:
|
|
507
|
-
in_behavior = False
|
|
508
|
-
if not in_behavior and indent == 0 and stripped.startswith("version:"):
|
|
509
|
-
try:
|
|
510
|
-
schema_version = int(stripped.split(":", 1)[1].strip().strip("'\""))
|
|
511
|
-
except ValueError:
|
|
512
|
-
schema_version = 0
|
|
513
|
-
continue
|
|
514
|
-
if not in_behavior or ":" not in stripped:
|
|
515
|
-
continue
|
|
516
|
-
key, value = stripped.split(":", 1)
|
|
517
|
-
behavior[key] = value.strip().strip("'\"")
|
|
518
|
-
|
|
519
|
-
return behavior, schema_version
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
def read_project_behavior(root: Path) -> tuple[str, str, str, int]:
|
|
523
|
-
behavior, schema_version = read_behavior_file(root / ".easy-coding" / "config.yaml")
|
|
524
|
-
legacy = behavior.get("confirm_mode")
|
|
525
|
-
approval_mode = behavior.get("approval_mode")
|
|
526
|
-
workflow_mode = behavior.get("workflow_mode")
|
|
527
|
-
if approval_mode is None:
|
|
528
|
-
if legacy == "lite":
|
|
529
|
-
approval_mode = "guard"
|
|
530
|
-
elif legacy in APPROVAL_MODES:
|
|
531
|
-
approval_mode = legacy
|
|
532
|
-
else:
|
|
533
|
-
approval_mode = DEFAULT_APPROVAL_MODE
|
|
534
|
-
if workflow_mode is None:
|
|
535
|
-
workflow_mode = "fast" if legacy == "lite" else DEFAULT_WORKFLOW_MODE
|
|
536
|
-
if approval_mode not in APPROVAL_MODES:
|
|
537
|
-
raise StateError(
|
|
538
|
-
"Invalid behavior.approval_mode in .easy-coding/config.yaml: "
|
|
539
|
-
"expected approve, guard, confirm, or auto."
|
|
540
|
-
)
|
|
541
|
-
if workflow_mode not in CONFIGURED_WORKFLOW_MODES:
|
|
542
|
-
raise StateError(
|
|
543
|
-
"Invalid behavior.workflow_mode in .easy-coding/config.yaml: "
|
|
544
|
-
"expected adaptive, fast, standard, or strict."
|
|
545
|
-
)
|
|
546
|
-
if schema_version >= 6:
|
|
547
|
-
unit_test_mode = parse_unit_test_mode(
|
|
548
|
-
behavior.get("unit_test_mode", DEFAULT_UNIT_TEST_MODE), "behavior.unit_test_mode"
|
|
549
|
-
)
|
|
550
|
-
threshold = parse_ut_threshold(
|
|
551
|
-
behavior.get("ut_coverage_threshold", DEFAULT_UT_COVERAGE_THRESHOLD),
|
|
552
|
-
"behavior.ut_coverage_threshold",
|
|
553
|
-
)
|
|
554
|
-
else:
|
|
555
|
-
if schema_version >= 4:
|
|
556
|
-
raise StateError("Run easy-coding upgrade to migrate unit-test settings to schema 6.")
|
|
557
|
-
unit_test_mode = DEFAULT_UNIT_TEST_MODE
|
|
558
|
-
threshold = DEFAULT_UT_COVERAGE_THRESHOLD
|
|
559
|
-
return approval_mode, workflow_mode, unit_test_mode, threshold
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
def behavior_layers(root: Path, session: dict) -> dict:
|
|
563
|
-
project, _ = read_behavior_file(root / ".easy-coding" / "config.yaml")
|
|
564
|
-
local, _ = read_behavior_file(Path.home() / ".easy-coding" / "config.yaml")
|
|
565
|
-
defaults = {"approval_mode": DEFAULT_APPROVAL_MODE, "cooperate_mode": "default",
|
|
566
|
-
"unit_test_mode": DEFAULT_UNIT_TEST_MODE,
|
|
567
|
-
"ut_coverage_threshold": DEFAULT_UT_COVERAGE_THRESHOLD}
|
|
568
|
-
layers = {"project": project, "local": local, "session": session}
|
|
569
|
-
result = {}
|
|
570
|
-
for key, default in defaults.items():
|
|
571
|
-
source = next((name for name in ("session", "local", "project")
|
|
572
|
-
if layers[name].get(key) is not None), "default")
|
|
573
|
-
value = layers[source][key] if source != "default" else default
|
|
574
|
-
if key == "ut_coverage_threshold":
|
|
575
|
-
value = parse_ut_threshold(value, f"{source} {key}")
|
|
576
|
-
elif key == "unit_test_mode":
|
|
577
|
-
value = parse_unit_test_mode(value, f"{source} {key}")
|
|
578
|
-
elif value not in (APPROVAL_MODES if key == "approval_mode" else {"default", "dispatch"}):
|
|
579
|
-
raise StateError(f"Invalid {source} {key}: {value}")
|
|
580
|
-
result[key] = {"value": value, "source": source}
|
|
581
|
-
return result
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
def safe_tdd_report_pattern(value: object) -> bool:
|
|
585
|
-
if not is_non_empty_string(value):
|
|
586
|
-
return False
|
|
587
|
-
candidate = Path(str(value))
|
|
588
|
-
return not candidate.is_absolute() and ".." not in candidate.parts
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
def tdd_gate_uses_task_variables(command: object) -> bool:
|
|
592
|
-
if not is_non_empty_string(command):
|
|
593
|
-
return False
|
|
594
|
-
try:
|
|
595
|
-
tokens = shlex.split(str(command))
|
|
596
|
-
except ValueError:
|
|
597
|
-
return False
|
|
598
|
-
options: dict[str, str] = {}
|
|
599
|
-
for index, token in enumerate(tokens[:-1]):
|
|
600
|
-
if token in {"--base", "--threshold"}:
|
|
601
|
-
options[token] = tokens[index + 1]
|
|
602
|
-
return options.get("--base") in {
|
|
603
|
-
f"${TDD_BASE_VARIABLE}",
|
|
604
|
-
"$" + "{" + TDD_BASE_VARIABLE + "}",
|
|
605
|
-
} and options.get("--threshold") in {
|
|
606
|
-
f"${TDD_THRESHOLD_VARIABLE}",
|
|
607
|
-
"$" + "{" + TDD_THRESHOLD_VARIABLE + "}",
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
def tdd_ci_contract_reasons(contents: list[str]) -> list[str]:
|
|
612
|
-
combined = "\n".join(
|
|
613
|
-
re.sub(r"\s+#.*$", "", re.sub(r"^\s*#.*$", "", line))
|
|
614
|
-
for line in "\n".join(contents).splitlines()
|
|
615
|
-
)
|
|
616
|
-
lowered = combined.lower()
|
|
617
|
-
reasons: list[str] = []
|
|
618
|
-
for marker in (
|
|
619
|
-
"jacoco",
|
|
620
|
-
"artifacts",
|
|
621
|
-
COVERAGE_TOOL_PATH,
|
|
622
|
-
TDD_BASE_VARIABLE,
|
|
623
|
-
TDD_THRESHOLD_VARIABLE,
|
|
624
|
-
):
|
|
625
|
-
if marker.lower() not in lowered:
|
|
626
|
-
reasons.append(f"CI files do not contain required marker: {marker}")
|
|
627
|
-
if not tdd_gate_uses_task_variables(combined):
|
|
628
|
-
reasons.append(
|
|
629
|
-
"CI changed-line gate must use the task baseline and threshold variables"
|
|
630
|
-
)
|
|
631
|
-
if re.search(
|
|
632
|
-
r"(?:^|\n)\s*stage\s*:\s*['\"]?test['\"]?\s*(?:#.*)?(?:\n|$)",
|
|
633
|
-
combined,
|
|
634
|
-
re.IGNORECASE,
|
|
635
|
-
) is None:
|
|
636
|
-
reasons.append("CI files do not declare a TEST-stage job")
|
|
637
|
-
return reasons
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
def tdd_readiness(root: Path, include_ci: bool = False) -> dict[str, object]:
|
|
641
|
-
receipt = root / TDD_READINESS_PATH
|
|
642
|
-
if not receipt.is_file():
|
|
643
|
-
return {"status": "needs_init", "reasons": ["TDD readiness receipt is missing"]}
|
|
644
|
-
try:
|
|
645
|
-
manifest = json.loads(receipt.read_text(encoding="utf-8"))
|
|
646
|
-
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
647
|
-
return {"status": "needs_repair", "reasons": ["TDD readiness receipt is invalid"]}
|
|
648
|
-
if not isinstance(manifest, dict):
|
|
649
|
-
return {
|
|
650
|
-
"status": "needs_repair",
|
|
651
|
-
"reasons": ["TDD readiness receipt must be a JSON object"],
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
reasons: list[str] = []
|
|
655
|
-
if manifest.get("schema") != TDD_READINESS_SCHEMA:
|
|
656
|
-
reasons.append("unsupported readiness schema")
|
|
657
|
-
if manifest.get("provider") != "gitlab":
|
|
658
|
-
reasons.append("readiness provider must be gitlab")
|
|
659
|
-
if manifest.get("coverage_scope") != TDD_READINESS_SCOPE:
|
|
660
|
-
reasons.append("coverage scope must be changed-production-lines")
|
|
661
|
-
if manifest.get("historical_coverage_required") is not False:
|
|
662
|
-
reasons.append("historical coverage must remain disabled")
|
|
663
|
-
reports = manifest.get("coverage_report_patterns")
|
|
664
|
-
if not isinstance(reports, list) or not reports or not all(
|
|
665
|
-
safe_tdd_report_pattern(item) for item in reports
|
|
666
|
-
):
|
|
667
|
-
reasons.append(
|
|
668
|
-
"coverage_report_patterns must contain safe project-relative report patterns"
|
|
669
|
-
)
|
|
670
|
-
gate = manifest.get("changed_line_gate_command")
|
|
671
|
-
if not is_non_empty_string(gate) or COVERAGE_TOOL_PATH not in str(gate):
|
|
672
|
-
reasons.append("changed-line coverage gate command is missing")
|
|
673
|
-
elif not tdd_gate_uses_task_variables(gate):
|
|
674
|
-
reasons.append(
|
|
675
|
-
"changed-line coverage gate must use the task baseline and threshold variables"
|
|
676
|
-
)
|
|
677
|
-
|
|
678
|
-
contents: dict[str, list[str]] = {
|
|
679
|
-
"build_files": [],
|
|
680
|
-
"tool_files": [],
|
|
681
|
-
}
|
|
682
|
-
if include_ci:
|
|
683
|
-
contents["ci_files"] = []
|
|
684
|
-
for field in contents:
|
|
685
|
-
records = manifest.get(field)
|
|
686
|
-
if not isinstance(records, list) or not records:
|
|
687
|
-
reasons.append(f"{field} must contain at least one file")
|
|
688
|
-
continue
|
|
689
|
-
for record in records:
|
|
690
|
-
if not isinstance(record, dict):
|
|
691
|
-
reasons.append(f"{field} contains an invalid record")
|
|
692
|
-
continue
|
|
693
|
-
file_name = record.get("path")
|
|
694
|
-
if not is_non_empty_string(file_name):
|
|
695
|
-
reasons.append(f"{field} contains an invalid path")
|
|
696
|
-
continue
|
|
697
|
-
candidate = Path(str(file_name))
|
|
698
|
-
if candidate.is_absolute():
|
|
699
|
-
reasons.append(f"readiness file must be project-relative: {file_name}")
|
|
700
|
-
continue
|
|
701
|
-
resolved = (root / candidate).resolve()
|
|
702
|
-
try:
|
|
703
|
-
resolved.relative_to(root.resolve())
|
|
704
|
-
payload = resolved.read_bytes()
|
|
705
|
-
contents[field].append(payload.decode("utf-8"))
|
|
706
|
-
except (OSError, UnicodeError, ValueError):
|
|
707
|
-
reasons.append(f"readiness file is missing or unreadable: {file_name}")
|
|
708
|
-
|
|
709
|
-
manifest_build_files = manifest.get("build_files")
|
|
710
|
-
manifest_ci_files = manifest.get("ci_files")
|
|
711
|
-
manifest_tool_files = manifest.get("tool_files")
|
|
712
|
-
build_paths = {
|
|
713
|
-
Path(str(item.get("path", ""))).name
|
|
714
|
-
for item in manifest_build_files
|
|
715
|
-
if isinstance(item, dict) and is_non_empty_string(item.get("path"))
|
|
716
|
-
} if isinstance(manifest_build_files, list) else set()
|
|
717
|
-
ci_paths = {
|
|
718
|
-
str(item.get("path", "")).replace("\\", "/")
|
|
719
|
-
for item in manifest_ci_files
|
|
720
|
-
if isinstance(item, dict) and is_non_empty_string(item.get("path"))
|
|
721
|
-
} if isinstance(manifest_ci_files, list) else set()
|
|
722
|
-
if not build_paths.intersection(JAVA_BUILD_FILE_NAMES):
|
|
723
|
-
reasons.append("build_files must include a Maven or Gradle Java build file")
|
|
724
|
-
tool_paths = {
|
|
725
|
-
str(item.get("path", "")).replace("\\", "/")
|
|
726
|
-
for item in manifest_tool_files
|
|
727
|
-
if isinstance(item, dict) and is_non_empty_string(item.get("path"))
|
|
728
|
-
} if isinstance(manifest_tool_files, list) else set()
|
|
729
|
-
if COVERAGE_TOOL_PATH not in tool_paths:
|
|
730
|
-
reasons.append(f"tool_files must include {COVERAGE_TOOL_PATH}")
|
|
731
|
-
if include_ci:
|
|
732
|
-
if not ci_paths.intersection(GITLAB_CI_ENTRY_FILES):
|
|
733
|
-
reasons.append("ci_files must include the project-root GitLab CI entry file")
|
|
734
|
-
if not any("jacoco" in content.lower() for content in contents["build_files"]):
|
|
735
|
-
reasons.append("build files do not configure JaCoCo")
|
|
736
|
-
reasons.extend(tdd_ci_contract_reasons(contents["ci_files"]))
|
|
737
|
-
return {
|
|
738
|
-
"status": "ready" if not reasons else "needs_repair",
|
|
739
|
-
"reasons": list(dict.fromkeys(reasons)),
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
|
|
743
377
|
def require_tdd_readiness(root: Path) -> None:
|
|
744
378
|
readiness = tdd_readiness(root)
|
|
745
379
|
if readiness["status"] != "ready":
|
|
@@ -748,78 +382,11 @@ def require_tdd_readiness(root: Path) -> None:
|
|
|
748
382
|
raise StateError(f"{action}: {reasons}. TDD settings are unchanged.")
|
|
749
383
|
|
|
750
384
|
|
|
751
|
-
def resolve_behavior(
|
|
752
|
-
root: Path, session: dict
|
|
753
|
-
) -> tuple[str, str | None, str, str, str | None, str, str, str | None, str, int, int | None, int]:
|
|
754
|
-
project_approval, project_workflow, project_unit_test, project_threshold = read_project_behavior(root)
|
|
755
|
-
legacy = session.get("confirm_mode")
|
|
756
|
-
session_approval = session.get("approval_mode")
|
|
757
|
-
session_workflow = session.get("workflow_mode")
|
|
758
|
-
session_unit_test = session.get("unit_test_mode")
|
|
759
|
-
session_threshold = session.get("ut_coverage_threshold")
|
|
760
|
-
if session_approval is None:
|
|
761
|
-
if legacy == "lite":
|
|
762
|
-
session_approval = "guard"
|
|
763
|
-
elif legacy in APPROVAL_MODES:
|
|
764
|
-
session_approval = legacy
|
|
765
|
-
if session_workflow is None:
|
|
766
|
-
if legacy == "lite":
|
|
767
|
-
session_workflow = "fast"
|
|
768
|
-
elif legacy in APPROVAL_MODES:
|
|
769
|
-
session_workflow = "adaptive"
|
|
770
|
-
if session_approval is not None and session_approval not in APPROVAL_MODES:
|
|
771
|
-
raise StateError(
|
|
772
|
-
"Invalid session approval_mode: expected approve, guard, confirm, or auto."
|
|
773
|
-
)
|
|
774
|
-
if session_workflow is not None and session_workflow not in CONFIGURED_WORKFLOW_MODES:
|
|
775
|
-
raise StateError(
|
|
776
|
-
"Invalid session workflow_mode: expected adaptive, fast, standard, or strict."
|
|
777
|
-
)
|
|
778
|
-
if session_unit_test is not None:
|
|
779
|
-
session_unit_test = parse_unit_test_mode(session_unit_test, "session unit_test_mode")
|
|
780
|
-
if session_threshold is not None:
|
|
781
|
-
session_threshold = parse_ut_threshold(
|
|
782
|
-
session_threshold, "session ut_coverage_threshold"
|
|
783
|
-
)
|
|
784
|
-
local, _ = read_behavior_file(Path.home() / ".easy-coding" / "config.yaml")
|
|
785
|
-
effective = behavior_layers(root, session)
|
|
786
|
-
return (
|
|
787
|
-
project_approval,
|
|
788
|
-
str(session_approval) if session_approval else None,
|
|
789
|
-
str(session_approval or (effective["approval_mode"]["value"] if "approval_mode" in local else project_approval)),
|
|
790
|
-
project_workflow,
|
|
791
|
-
str(session_workflow) if session_workflow else None,
|
|
792
|
-
str(session_workflow or project_workflow),
|
|
793
|
-
project_unit_test,
|
|
794
|
-
session_unit_test,
|
|
795
|
-
session_unit_test if session_unit_test is not None else (
|
|
796
|
-
effective["unit_test_mode"]["value"] if "unit_test_mode" in local else project_unit_test),
|
|
797
|
-
project_threshold,
|
|
798
|
-
session_threshold,
|
|
799
|
-
session_threshold if session_threshold is not None else (
|
|
800
|
-
effective["ut_coverage_threshold"]["value"] if "ut_coverage_threshold" in local else project_threshold),
|
|
801
|
-
)
|
|
802
|
-
|
|
803
|
-
|
|
804
385
|
def resolve_approval_mode(root: Path, session: dict) -> tuple[str, str | None, str]:
|
|
805
386
|
behavior = resolve_behavior(root, session)
|
|
806
387
|
return behavior[0], behavior[1], behavior[2]
|
|
807
388
|
|
|
808
389
|
|
|
809
|
-
def migrate_unit_test_settings(record: dict) -> bool:
|
|
810
|
-
changed = False
|
|
811
|
-
if "tdd_enabled" in record:
|
|
812
|
-
enabled = record.pop("tdd_enabled")
|
|
813
|
-
if "unit_test_mode" not in record and isinstance(enabled, bool):
|
|
814
|
-
record["unit_test_mode"] = "tdd" if enabled else "none"
|
|
815
|
-
changed = True
|
|
816
|
-
if "tdd_coverage_threshold" in record:
|
|
817
|
-
threshold = record.pop("tdd_coverage_threshold")
|
|
818
|
-
record.setdefault("ut_coverage_threshold", threshold)
|
|
819
|
-
changed = True
|
|
820
|
-
return changed
|
|
821
|
-
|
|
822
|
-
|
|
823
390
|
def materialize_legacy_session_behavior(session: dict) -> None:
|
|
824
391
|
migrate_unit_test_settings(session)
|
|
825
392
|
legacy = session.get("confirm_mode")
|
|
@@ -1260,586 +827,53 @@ def validate_recorded_architecture_assessment(root: Path, progress: dict, instru
|
|
|
1260
827
|
raise StateError("Recorded architecture assessment trigger does not match its instruction.")
|
|
1261
828
|
reason = assessment.get("reason")
|
|
1262
829
|
if not isinstance(reason, str) or not reason.strip():
|
|
1263
|
-
raise StateError("Recorded architecture assessment is missing its reason.")
|
|
1264
|
-
evidence = assessment.get("evidence")
|
|
1265
|
-
if not isinstance(evidence, list) or not evidence or not all(
|
|
1266
|
-
isinstance(item, str) for item in evidence
|
|
1267
|
-
):
|
|
1268
|
-
raise StateError("Recorded architecture assessment has invalid evidence.")
|
|
1269
|
-
if any(item not in allowed_architecture_evidence(progress, instruction) for item in evidence):
|
|
1270
|
-
raise StateError("Recorded architecture assessment evidence is outside the frozen set.")
|
|
1271
|
-
affected_sections = assessment.get("affected_sections")
|
|
1272
|
-
if not isinstance(affected_sections, list) or not all(
|
|
1273
|
-
isinstance(item, str) and item.strip() for item in affected_sections
|
|
1274
|
-
):
|
|
1275
|
-
raise StateError("Recorded architecture assessment has invalid affected sections.")
|
|
1276
|
-
if action == "no-op" and affected_sections:
|
|
1277
|
-
raise StateError("Recorded architecture no-op must not declare affected sections.")
|
|
1278
|
-
if action in {"backfill", "update"} and not affected_sections:
|
|
1279
|
-
raise StateError("Recorded architecture backfill/update requires affected sections.")
|
|
1280
|
-
abstract, changelog = validate_architecture_action_result(
|
|
1281
|
-
root, assessment_instruction, action
|
|
1282
|
-
)
|
|
1283
|
-
if assessment.get("abstract_sha256") != abstract.get("sha256"):
|
|
1284
|
-
raise StateError("ABSTRACT.md changed after the architecture assessment was recorded.")
|
|
1285
|
-
if assessment.get("changelog_sha256") != changelog.get("sha256"):
|
|
1286
|
-
raise StateError("Architecture CHANGELOG.md changed after the assessment was recorded.")
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
def validate_distillation_file_sets(root: Path, instruction: dict) -> None:
|
|
1290
|
-
candidate_files = instruction.get("candidate_files")
|
|
1291
|
-
kept_files = instruction.get("kept_files")
|
|
1292
|
-
if not isinstance(candidate_files, list) or not all(
|
|
1293
|
-
isinstance(item, str) for item in candidate_files
|
|
1294
|
-
):
|
|
1295
|
-
raise StateError("Memory instruction is missing its frozen candidate file set.")
|
|
1296
|
-
if not isinstance(kept_files, list) or not all(isinstance(item, str) for item in kept_files):
|
|
1297
|
-
raise StateError("Memory instruction is missing its frozen kept file set.")
|
|
1298
|
-
for memory_file in candidate_files:
|
|
1299
|
-
if resolve_short_memory_path(root, memory_file).exists():
|
|
1300
|
-
raise StateError(f"Distillation candidate was not consumed: {memory_file}")
|
|
1301
|
-
for memory_file in kept_files:
|
|
1302
|
-
if not resolve_short_memory_path(root, memory_file).is_file():
|
|
1303
|
-
raise StateError(f"Short-memory file selected for retention is missing: {memory_file}")
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
def normalize_legacy_stage(stage: object) -> object:
|
|
1307
|
-
return LEGACY_STAGE_MAP.get(str(stage), stage)
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
def normalize_legacy_task(task: dict) -> bool:
|
|
1311
|
-
"""Normalize legacy task state without touching artifacts outside task.json."""
|
|
1312
|
-
legacy_status = str(task.get("status") or "")
|
|
1313
|
-
changed = migrate_unit_test_settings(task)
|
|
1314
|
-
|
|
1315
|
-
for field in ("created_by", "last_agent"):
|
|
1316
|
-
normalized_agent = canonical_agent_identity(
|
|
1317
|
-
task.get(field), allow_legacy_display=True
|
|
1318
|
-
)
|
|
1319
|
-
if normalized_agent is not None and normalized_agent != task.get(field):
|
|
1320
|
-
task[field] = normalized_agent
|
|
1321
|
-
changed = True
|
|
1322
|
-
|
|
1323
|
-
if legacy_status in LEGACY_STAGE_MAP:
|
|
1324
|
-
task["status"] = LEGACY_STAGE_MAP[legacy_status]
|
|
1325
|
-
changed = True
|
|
1326
|
-
|
|
1327
|
-
pending = task.get("pending_transition")
|
|
1328
|
-
if isinstance(pending, dict):
|
|
1329
|
-
source = normalize_legacy_stage(pending.get("from"))
|
|
1330
|
-
target = normalize_legacy_stage(pending.get("to"))
|
|
1331
|
-
if source == target:
|
|
1332
|
-
task.pop("pending_transition", None)
|
|
1333
|
-
changed = True
|
|
1334
|
-
elif source != pending.get("from") or target != pending.get("to"):
|
|
1335
|
-
task["pending_transition"] = {**pending, "from": source, "to": target}
|
|
1336
|
-
changed = True
|
|
1337
|
-
|
|
1338
|
-
if not isinstance(task.get("quality_checkpoint"), dict) and isinstance(
|
|
1339
|
-
task.get("verification_checkpoint"), dict
|
|
1340
|
-
):
|
|
1341
|
-
task["quality_checkpoint"] = task["verification_checkpoint"]
|
|
1342
|
-
changed = True
|
|
1343
|
-
if "verification_checkpoint" in task:
|
|
1344
|
-
task.pop("verification_checkpoint")
|
|
1345
|
-
changed = True
|
|
1346
|
-
|
|
1347
|
-
history = task.get("stage_history")
|
|
1348
|
-
if isinstance(history, list):
|
|
1349
|
-
normalized_history: list[dict] = []
|
|
1350
|
-
for raw_entry in history:
|
|
1351
|
-
if not isinstance(raw_entry, dict):
|
|
1352
|
-
continue
|
|
1353
|
-
entry = dict(raw_entry)
|
|
1354
|
-
mapped_stage = normalize_legacy_stage(entry.get("stage"))
|
|
1355
|
-
if mapped_stage != entry.get("stage"):
|
|
1356
|
-
entry["stage"] = mapped_stage
|
|
1357
|
-
changed = True
|
|
1358
|
-
normalized_agent = canonical_agent_identity(
|
|
1359
|
-
entry.get("agent"), allow_legacy_display=True
|
|
1360
|
-
)
|
|
1361
|
-
if normalized_agent is not None and normalized_agent != entry.get("agent"):
|
|
1362
|
-
entry["agent"] = normalized_agent
|
|
1363
|
-
changed = True
|
|
1364
|
-
if normalized_history and normalized_history[-1].get("stage") == entry.get("stage"):
|
|
1365
|
-
changed = True
|
|
1366
|
-
continue
|
|
1367
|
-
normalized_history.append(entry)
|
|
1368
|
-
if changed:
|
|
1369
|
-
task["stage_history"] = normalized_history
|
|
1370
|
-
|
|
1371
|
-
if legacy_status == "WAITING_CONFIRM" and not task.get("pending_transition"):
|
|
1372
|
-
requested_by = canonical_agent_identity(
|
|
1373
|
-
task.get("last_agent"), allow_legacy_display=True
|
|
1374
|
-
) or "legacy-migration"
|
|
1375
|
-
task["pending_transition"] = {
|
|
1376
|
-
"from": "ANALYSIS",
|
|
1377
|
-
"to": "IMPLEMENT",
|
|
1378
|
-
"requested_at": now_iso(),
|
|
1379
|
-
"requested_by": requested_by,
|
|
1380
|
-
"reason": "migrated-from-WAITING_CONFIRM",
|
|
1381
|
-
}
|
|
1382
|
-
changed = True
|
|
1383
|
-
|
|
1384
|
-
if legacy_status == "MEMORY_LONG":
|
|
1385
|
-
progress = task.get("memory_progress")
|
|
1386
|
-
if not isinstance(progress, dict):
|
|
1387
|
-
progress = {}
|
|
1388
|
-
if progress.get("short_memory_written") is not True:
|
|
1389
|
-
progress["short_memory_written"] = True
|
|
1390
|
-
progress["legacy_short_memory_assumed"] = True
|
|
1391
|
-
progress["updated_at"] = now_iso()
|
|
1392
|
-
task["memory_progress"] = progress
|
|
1393
|
-
changed = True
|
|
1394
|
-
elif progress.get("legacy_short_memory_assumed") is not True:
|
|
1395
|
-
progress["legacy_short_memory_assumed"] = True
|
|
1396
|
-
progress["updated_at"] = now_iso()
|
|
1397
|
-
task["memory_progress"] = progress
|
|
1398
|
-
changed = True
|
|
1399
|
-
|
|
1400
|
-
return changed
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
def write_json(path: Path, data: dict) -> None:
|
|
1404
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1405
|
-
descriptor, temporary_name = tempfile.mkstemp(
|
|
1406
|
-
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
|
|
1407
|
-
)
|
|
1408
|
-
temporary_path = Path(temporary_name)
|
|
1409
|
-
try:
|
|
1410
|
-
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
|
|
1411
|
-
handle.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
1412
|
-
handle.flush()
|
|
1413
|
-
os.fsync(handle.fileno())
|
|
1414
|
-
os.replace(temporary_path, path)
|
|
1415
|
-
try:
|
|
1416
|
-
directory_descriptor = os.open(path.parent, os.O_RDONLY)
|
|
1417
|
-
try:
|
|
1418
|
-
os.fsync(directory_descriptor)
|
|
1419
|
-
finally:
|
|
1420
|
-
os.close(directory_descriptor)
|
|
1421
|
-
except OSError:
|
|
1422
|
-
# Some platforms do not allow opening directories; file replacement is still atomic.
|
|
1423
|
-
pass
|
|
1424
|
-
finally:
|
|
1425
|
-
if temporary_path.exists():
|
|
1426
|
-
temporary_path.unlink()
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
def session_command_lock_path(root: Path, session_path: Path) -> Path:
|
|
1430
|
-
key = hashlib.sha256(str(session_path.resolve()).encode("utf-8")).hexdigest()[:24]
|
|
1431
|
-
return root / ".easy-coding" / "sessions" / f".session-{key}.lock"
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
def acquire_session_command_lock(root: Path, session_path: Path) -> Path:
|
|
1435
|
-
lock_path = session_command_lock_path(root, session_path)
|
|
1436
|
-
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1437
|
-
deadline = time.monotonic() + SESSION_COMMAND_LOCK_TIMEOUT_SECONDS
|
|
1438
|
-
while True:
|
|
1439
|
-
try:
|
|
1440
|
-
lock_path.mkdir()
|
|
1441
|
-
return lock_path
|
|
1442
|
-
except FileExistsError:
|
|
1443
|
-
try:
|
|
1444
|
-
if time.time() - lock_path.stat().st_mtime > SESSION_COMMAND_LOCK_STALE_SECONDS:
|
|
1445
|
-
lock_path.rmdir()
|
|
1446
|
-
continue
|
|
1447
|
-
except FileNotFoundError:
|
|
1448
|
-
continue
|
|
1449
|
-
except OSError:
|
|
1450
|
-
pass
|
|
1451
|
-
if time.monotonic() >= deadline:
|
|
1452
|
-
raise StateError("Timed out waiting for the logical session command lock.")
|
|
1453
|
-
time.sleep(SESSION_COMMAND_LOCK_POLL_SECONDS)
|
|
1454
|
-
except OSError as exc:
|
|
1455
|
-
raise StateError("Cannot acquire the logical session command lock.") from exc
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
def release_session_command_lock(lock_path: Path | None) -> None:
|
|
1459
|
-
if lock_path is None:
|
|
1460
|
-
return
|
|
1461
|
-
try:
|
|
1462
|
-
lock_path.rmdir()
|
|
1463
|
-
except OSError:
|
|
1464
|
-
pass
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
def acquire_legacy_state_lock(root: Path) -> Path | None:
|
|
1468
|
-
state_path = root / ".easy-coding" / "state.json"
|
|
1469
|
-
lock_path = root / ".easy-coding" / "sessions" / ".legacy-state-migration.lock"
|
|
1470
|
-
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1471
|
-
deadline = time.monotonic() + LEGACY_STATE_LOCK_TIMEOUT_SECONDS
|
|
1472
|
-
|
|
1473
|
-
while state_path.exists() or lock_path.exists():
|
|
1474
|
-
try:
|
|
1475
|
-
lock_path.mkdir()
|
|
1476
|
-
return lock_path
|
|
1477
|
-
except FileExistsError:
|
|
1478
|
-
try:
|
|
1479
|
-
lock_age = time.time() - lock_path.stat().st_mtime
|
|
1480
|
-
if lock_age > LEGACY_STATE_LOCK_STALE_SECONDS:
|
|
1481
|
-
lock_path.rmdir()
|
|
1482
|
-
continue
|
|
1483
|
-
except FileNotFoundError:
|
|
1484
|
-
continue
|
|
1485
|
-
except OSError:
|
|
1486
|
-
pass
|
|
1487
|
-
if time.monotonic() >= deadline:
|
|
1488
|
-
raise StateError("Timed out waiting for legacy state migration lock.")
|
|
1489
|
-
time.sleep(LEGACY_STATE_LOCK_POLL_SECONDS)
|
|
1490
|
-
except OSError as error:
|
|
1491
|
-
raise StateError("Cannot acquire legacy state migration lock.") from error
|
|
1492
|
-
return None
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
def release_legacy_state_lock(lock_path: Path | None) -> None:
|
|
1496
|
-
if lock_path is None:
|
|
1497
|
-
return
|
|
1498
|
-
try:
|
|
1499
|
-
lock_path.rmdir()
|
|
1500
|
-
except OSError:
|
|
1501
|
-
pass
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
def migrate_legacy_state(root: Path, agent: str) -> dict | None:
|
|
1505
|
-
"""Prepare old state.json data for the canonical session; the caller commits it first."""
|
|
1506
|
-
state_path = root / ".easy-coding" / "state.json"
|
|
1507
|
-
old_state = load_json(state_path)
|
|
1508
|
-
if old_state is None:
|
|
1509
|
-
return None
|
|
1510
|
-
|
|
1511
|
-
task_id = old_state.get("current_task")
|
|
1512
|
-
if task_id:
|
|
1513
|
-
task_path = task_json_path(root, str(task_id))
|
|
1514
|
-
task = load_json(task_path)
|
|
1515
|
-
if task:
|
|
1516
|
-
if "stage_history" not in task or not task["stage_history"]:
|
|
1517
|
-
task["stage_history"] = old_state.get("stage_history", [])
|
|
1518
|
-
if "last_agent" not in task or not task["last_agent"]:
|
|
1519
|
-
task["last_agent"] = (
|
|
1520
|
-
canonical_agent_identity(
|
|
1521
|
-
old_state.get("last_agent"), allow_legacy_display=True
|
|
1522
|
-
)
|
|
1523
|
-
or agent
|
|
1524
|
-
)
|
|
1525
|
-
if old_state.get("confirmed_by_user"):
|
|
1526
|
-
task["confirmed_by_user"] = True
|
|
1527
|
-
if old_state.get("test_strategy_confirmed"):
|
|
1528
|
-
task["test_strategy_confirmed"] = True
|
|
1529
|
-
if old_state.get("repo_paths"):
|
|
1530
|
-
task["repo_paths"] = old_state["repo_paths"]
|
|
1531
|
-
normalize_legacy_task(task)
|
|
1532
|
-
write_json(task_path, task)
|
|
1533
|
-
|
|
1534
|
-
return {"current_task": task_id, "created_at": now_iso()}
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
def resolve_session_path(root: Path, session_file: str | Path | None = None) -> Path:
|
|
1538
|
-
sessions_dir = (root / ".easy-coding" / "sessions").resolve()
|
|
1539
|
-
if session_file:
|
|
1540
|
-
path = Path(session_file)
|
|
1541
|
-
candidate = path if path.is_absolute() else root / path
|
|
1542
|
-
resolved = candidate.resolve()
|
|
1543
|
-
try:
|
|
1544
|
-
resolved.relative_to(sessions_dir)
|
|
1545
|
-
except ValueError as error:
|
|
1546
|
-
raise StateError(
|
|
1547
|
-
"Unsafe session file path: "
|
|
1548
|
-
f"{session_file}. Must be under .easy-coding/sessions/."
|
|
1549
|
-
) from error
|
|
1550
|
-
if resolved == sessions_dir:
|
|
1551
|
-
raise StateError(
|
|
1552
|
-
"Unsafe session file path: "
|
|
1553
|
-
f"{session_file}. Must be a file under .easy-coding/sessions/."
|
|
1554
|
-
)
|
|
1555
|
-
return resolved
|
|
1556
|
-
identity = hook_session_identity({}, detect_runtime_agent())
|
|
1557
|
-
return sessions_dir / f"{identity['session_key']}.json"
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
def resolve_hook_session_path(
|
|
1561
|
-
root: Path,
|
|
1562
|
-
payload: dict,
|
|
1563
|
-
agent: str | None,
|
|
1564
|
-
ppid: int | None = None,
|
|
1565
|
-
) -> Path:
|
|
1566
|
-
identity = hook_session_identity(payload, agent, ppid)
|
|
1567
|
-
return resolve_session_path(root, f".easy-coding/sessions/{identity['session_key']}.json")
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
def display_path(root: Path, path: Path) -> str:
|
|
1571
|
-
try:
|
|
1572
|
-
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
1573
|
-
except ValueError:
|
|
1574
|
-
return path.as_posix()
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
def default_session() -> dict:
|
|
1578
|
-
timestamp = now_iso()
|
|
1579
|
-
return {"current_task": None, "created_at": timestamp, "last_active_at": timestamp}
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
def apply_hook_session_identity(session: dict, identity: dict) -> None:
|
|
1583
|
-
timestamp = now_iso()
|
|
1584
|
-
if not session.get("created_at"):
|
|
1585
|
-
session["created_at"] = timestamp
|
|
1586
|
-
session["last_active_at"] = timestamp
|
|
1587
|
-
for key in ("agent", "external_session_id", "session_key", "session_source"):
|
|
1588
|
-
session[key] = identity.get(key)
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
def clear_session_pointer(session: dict, agent: str | None = None) -> None:
|
|
1592
|
-
session["current_task"] = None
|
|
1593
|
-
session["last_seen_task"] = None
|
|
1594
|
-
session["last_seen_stage"] = "idle"
|
|
1595
|
-
if agent:
|
|
1596
|
-
session["last_agent"] = agent
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
def load_session(root: Path, session_file: str | Path | None = None) -> dict | None:
|
|
1600
|
-
session = load_json(resolve_session_path(root, session_file))
|
|
1601
|
-
return session if isinstance(session, dict) else None
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
def write_session(root: Path, session: dict, session_file: str | Path | None = None) -> None:
|
|
1605
|
-
write_json(resolve_session_path(root, session_file), session)
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
def migrate_legacy_pid_session(
|
|
1609
|
-
root: Path,
|
|
1610
|
-
session_path: Path,
|
|
1611
|
-
identity: dict,
|
|
1612
|
-
ppid: int,
|
|
1613
|
-
) -> dict | None:
|
|
1614
|
-
sessions_dir = root / ".easy-coding" / "sessions"
|
|
1615
|
-
fallback_path = sessions_dir / f"{identity['agent']}-ppid-{ppid}.json"
|
|
1616
|
-
legacy_paths = [fallback_path, sessions_dir / f"{ppid}.json"]
|
|
1617
|
-
session_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1618
|
-
|
|
1619
|
-
for legacy_path in legacy_paths:
|
|
1620
|
-
if legacy_path == session_path or not legacy_path.is_file():
|
|
1621
|
-
continue
|
|
1622
|
-
try:
|
|
1623
|
-
legacy_path.replace(session_path)
|
|
1624
|
-
except FileNotFoundError:
|
|
1625
|
-
continue
|
|
1626
|
-
except OSError:
|
|
1627
|
-
if session_path.is_file():
|
|
1628
|
-
break
|
|
1629
|
-
continue
|
|
1630
|
-
migrated = load_session(root, session_path)
|
|
1631
|
-
if migrated is not None:
|
|
1632
|
-
return migrated
|
|
1633
|
-
return load_session(root, session_path)
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
def merge_legacy_session(session: dict, legacy_session: dict) -> dict:
|
|
1637
|
-
merged = dict(session)
|
|
1638
|
-
if not merged.get("current_task") and legacy_session.get("current_task"):
|
|
1639
|
-
merged["current_task"] = legacy_session["current_task"]
|
|
1640
|
-
if not merged.get("created_at") and legacy_session.get("created_at"):
|
|
1641
|
-
merged["created_at"] = legacy_session["created_at"]
|
|
1642
|
-
return merged
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
def ensure_hook_session(
|
|
1646
|
-
root: Path,
|
|
1647
|
-
payload: dict,
|
|
1648
|
-
agent: str | None,
|
|
1649
|
-
ppid: int | None = None,
|
|
1650
|
-
) -> tuple[dict, Path]:
|
|
1651
|
-
session_path = resolve_hook_session_path(root, payload, agent, ppid)
|
|
1652
|
-
lock_path = acquire_session_command_lock(root, session_path)
|
|
1653
|
-
try:
|
|
1654
|
-
return ensure_hook_session_unlocked(root, payload, agent, ppid)
|
|
1655
|
-
finally:
|
|
1656
|
-
release_session_command_lock(lock_path)
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
def ensure_hook_session_unlocked(
|
|
1660
|
-
root: Path,
|
|
1661
|
-
payload: dict,
|
|
1662
|
-
agent: str | None,
|
|
1663
|
-
ppid: int | None = None,
|
|
1664
|
-
) -> tuple[dict, Path]:
|
|
1665
|
-
identity = hook_session_identity(payload, agent, ppid)
|
|
1666
|
-
session_path = resolve_hook_session_path(root, payload, agent, ppid)
|
|
1667
|
-
resolved_ppid = ppid if ppid is not None else os.getppid()
|
|
1668
|
-
legacy_state_lock = acquire_legacy_state_lock(root)
|
|
1669
|
-
try:
|
|
1670
|
-
session = load_session(root, session_path)
|
|
1671
|
-
legacy_state = (
|
|
1672
|
-
migrate_legacy_state(root, str(identity["agent"]))
|
|
1673
|
-
if legacy_state_lock is not None
|
|
1674
|
-
else None
|
|
1675
|
-
)
|
|
1676
|
-
|
|
1677
|
-
if session is None:
|
|
1678
|
-
clean_session_runtime(root, reserve_slots=1)
|
|
1679
|
-
session = migrate_legacy_pid_session(root, session_path, identity, resolved_ppid)
|
|
1680
|
-
if session is None:
|
|
1681
|
-
session = load_session(root, session_path)
|
|
1682
|
-
if session is None:
|
|
1683
|
-
session = default_session()
|
|
1684
|
-
if legacy_state is not None:
|
|
1685
|
-
session = merge_legacy_session(session, legacy_state)
|
|
1686
|
-
|
|
1687
|
-
apply_hook_session_identity(session, identity)
|
|
1688
|
-
write_session(root, session, session_path)
|
|
1689
|
-
if legacy_state is not None:
|
|
1690
|
-
try:
|
|
1691
|
-
(root / ".easy-coding" / "state.json").unlink()
|
|
1692
|
-
except OSError:
|
|
1693
|
-
pass
|
|
1694
|
-
return session, session_path
|
|
1695
|
-
finally:
|
|
1696
|
-
release_legacy_state_lock(legacy_state_lock)
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
def clean_stale_sessions(
|
|
1700
|
-
root: Path,
|
|
1701
|
-
threshold_hours: int | None = None,
|
|
1702
|
-
idle_threshold_hours: int = SESSION_IDLE_RETENTION_HOURS,
|
|
1703
|
-
attached_threshold_hours: int = SESSION_ATTACHED_RETENTION_HOURS,
|
|
1704
|
-
max_sessions: int = MAX_SESSION_FILES,
|
|
1705
|
-
reserve_slots: int = 0,
|
|
1706
|
-
) -> int:
|
|
1707
|
-
sessions_dir = root / ".easy-coding" / "sessions"
|
|
1708
|
-
if not sessions_dir.is_dir():
|
|
1709
|
-
return 0
|
|
1710
|
-
|
|
1711
|
-
now = datetime.now(timezone.utc)
|
|
1712
|
-
if threshold_hours is not None:
|
|
1713
|
-
idle_threshold_hours = threshold_hours
|
|
1714
|
-
attached_threshold_hours = threshold_hours
|
|
1715
|
-
candidates: list[tuple[Path, str, dict, datetime]] = []
|
|
1716
|
-
for entry in sessions_dir.iterdir():
|
|
1717
|
-
if not entry.is_file() or entry.suffix != ".json":
|
|
1718
|
-
continue
|
|
1719
|
-
try:
|
|
1720
|
-
content = entry.read_text(encoding="utf-8")
|
|
1721
|
-
try:
|
|
1722
|
-
session = json.loads(content)
|
|
1723
|
-
except json.JSONDecodeError:
|
|
1724
|
-
session = {}
|
|
1725
|
-
if not isinstance(session, dict):
|
|
1726
|
-
session = {}
|
|
1727
|
-
activity_value = session.get("last_active_at") or session.get("created_at")
|
|
1728
|
-
try:
|
|
1729
|
-
if not isinstance(activity_value, str):
|
|
1730
|
-
raise ValueError
|
|
1731
|
-
last_active = datetime.fromisoformat(activity_value)
|
|
1732
|
-
if last_active.tzinfo is None:
|
|
1733
|
-
last_active = last_active.replace(tzinfo=timezone.utc)
|
|
1734
|
-
except (ValueError, TypeError):
|
|
1735
|
-
last_active = datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc)
|
|
1736
|
-
candidates.append((entry, content, session, last_active))
|
|
1737
|
-
except OSError:
|
|
1738
|
-
continue
|
|
1739
|
-
|
|
1740
|
-
removed: set[Path] = set()
|
|
1741
|
-
for entry, content, session, last_active in candidates:
|
|
1742
|
-
retention_hours = (
|
|
1743
|
-
attached_threshold_hours if session.get("current_task") else idle_threshold_hours
|
|
1744
|
-
)
|
|
1745
|
-
age_hours = (now - last_active).total_seconds() / 3600
|
|
1746
|
-
if age_hours <= retention_hours:
|
|
1747
|
-
continue
|
|
1748
|
-
if unlink_session_if_unchanged(entry, content):
|
|
1749
|
-
removed.add(entry)
|
|
1750
|
-
|
|
1751
|
-
allowed_existing = max(0, max_sessions - reserve_slots)
|
|
1752
|
-
remaining = sorted(
|
|
1753
|
-
(candidate for candidate in candidates if candidate[0] not in removed),
|
|
1754
|
-
key=lambda candidate: candidate[3],
|
|
1755
|
-
)
|
|
1756
|
-
overflow = max(0, len(remaining) - allowed_existing)
|
|
1757
|
-
for entry, content, _session, _last_active in remaining[:overflow]:
|
|
1758
|
-
if unlink_session_if_unchanged(entry, content):
|
|
1759
|
-
removed.add(entry)
|
|
1760
|
-
return len(removed)
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
def unlink_session_if_unchanged(entry: Path, expected_content: str) -> bool:
|
|
1764
|
-
try:
|
|
1765
|
-
if entry.read_text(encoding="utf-8") != expected_content:
|
|
1766
|
-
return False
|
|
1767
|
-
entry.unlink()
|
|
1768
|
-
return True
|
|
1769
|
-
except OSError:
|
|
1770
|
-
# GC 采用尽力清理;锁定、并发移除等失败文件留到后续新会话再次处理。
|
|
1771
|
-
return False
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
def clean_orphan_acceptance_snapshots(root: Path) -> int:
|
|
1775
|
-
acceptance_dir = root / ".easy-coding" / "sessions" / "acceptance"
|
|
1776
|
-
if not acceptance_dir.is_dir():
|
|
1777
|
-
return 0
|
|
1778
|
-
|
|
1779
|
-
cleaned = 0
|
|
1780
|
-
for entry in acceptance_dir.iterdir():
|
|
1781
|
-
if not entry.is_file() or entry.suffix != ".json":
|
|
1782
|
-
continue
|
|
1783
|
-
task_path = root / ".easy-coding" / "tasks" / entry.stem / "task.json"
|
|
1784
|
-
if task_path.is_file():
|
|
1785
|
-
try:
|
|
1786
|
-
task = json.loads(task_path.read_text(encoding="utf-8"))
|
|
1787
|
-
except (OSError, json.JSONDecodeError):
|
|
1788
|
-
continue
|
|
1789
|
-
if not isinstance(task, dict):
|
|
1790
|
-
continue
|
|
1791
|
-
else:
|
|
1792
|
-
task = None
|
|
1793
|
-
|
|
1794
|
-
checkpoint = None
|
|
1795
|
-
if task is not None:
|
|
1796
|
-
checkpoint = task.get("quality_checkpoint")
|
|
1797
|
-
if not isinstance(checkpoint, dict):
|
|
1798
|
-
checkpoint = task.get("verification_checkpoint")
|
|
1799
|
-
snapshot_file = checkpoint.get("snapshot_file") if isinstance(checkpoint, dict) else None
|
|
1800
|
-
referenced = bool(
|
|
1801
|
-
isinstance(snapshot_file, str)
|
|
1802
|
-
and (root / snapshot_file).resolve() == entry.resolve()
|
|
1803
|
-
)
|
|
1804
|
-
terminal = task is not None and task.get("status") in TERMINAL_STATUSES
|
|
1805
|
-
if task is not None and referenced and not terminal:
|
|
1806
|
-
continue
|
|
1807
|
-
try:
|
|
1808
|
-
entry.unlink()
|
|
1809
|
-
cleaned += 1
|
|
1810
|
-
except OSError:
|
|
1811
|
-
# 验收快照清理失败不能阻断新逻辑会话启动。
|
|
1812
|
-
continue
|
|
1813
|
-
return cleaned
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
def clean_session_runtime(root: Path, reserve_slots: int = 0) -> dict:
|
|
1817
|
-
return {
|
|
1818
|
-
"sessions_removed": clean_stale_sessions(root, reserve_slots=reserve_slots),
|
|
1819
|
-
"acceptance_snapshots_removed": clean_orphan_acceptance_snapshots(root),
|
|
1820
|
-
}
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
def task_json_path(root: Path, task_id: str) -> Path:
|
|
1824
|
-
assert_safe_task_id(task_id)
|
|
1825
|
-
return root / ".easy-coding" / "tasks" / task_id / "task.json"
|
|
830
|
+
raise StateError("Recorded architecture assessment is missing its reason.")
|
|
831
|
+
evidence = assessment.get("evidence")
|
|
832
|
+
if not isinstance(evidence, list) or not evidence or not all(
|
|
833
|
+
isinstance(item, str) for item in evidence
|
|
834
|
+
):
|
|
835
|
+
raise StateError("Recorded architecture assessment has invalid evidence.")
|
|
836
|
+
if any(item not in allowed_architecture_evidence(progress, instruction) for item in evidence):
|
|
837
|
+
raise StateError("Recorded architecture assessment evidence is outside the frozen set.")
|
|
838
|
+
affected_sections = assessment.get("affected_sections")
|
|
839
|
+
if not isinstance(affected_sections, list) or not all(
|
|
840
|
+
isinstance(item, str) and item.strip() for item in affected_sections
|
|
841
|
+
):
|
|
842
|
+
raise StateError("Recorded architecture assessment has invalid affected sections.")
|
|
843
|
+
if action == "no-op" and affected_sections:
|
|
844
|
+
raise StateError("Recorded architecture no-op must not declare affected sections.")
|
|
845
|
+
if action in {"backfill", "update"} and not affected_sections:
|
|
846
|
+
raise StateError("Recorded architecture backfill/update requires affected sections.")
|
|
847
|
+
abstract, changelog = validate_architecture_action_result(
|
|
848
|
+
root, assessment_instruction, action
|
|
849
|
+
)
|
|
850
|
+
if assessment.get("abstract_sha256") != abstract.get("sha256"):
|
|
851
|
+
raise StateError("ABSTRACT.md changed after the architecture assessment was recorded.")
|
|
852
|
+
if assessment.get("changelog_sha256") != changelog.get("sha256"):
|
|
853
|
+
raise StateError("Architecture CHANGELOG.md changed after the assessment was recorded.")
|
|
1826
854
|
|
|
1827
855
|
|
|
1828
|
-
def
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
856
|
+
def validate_distillation_file_sets(root: Path, instruction: dict) -> None:
|
|
857
|
+
candidate_files = instruction.get("candidate_files")
|
|
858
|
+
kept_files = instruction.get("kept_files")
|
|
859
|
+
if not isinstance(candidate_files, list) or not all(
|
|
860
|
+
isinstance(item, str) for item in candidate_files
|
|
861
|
+
):
|
|
862
|
+
raise StateError("Memory instruction is missing its frozen candidate file set.")
|
|
863
|
+
if not isinstance(kept_files, list) or not all(isinstance(item, str) for item in kept_files):
|
|
864
|
+
raise StateError("Memory instruction is missing its frozen kept file set.")
|
|
865
|
+
for memory_file in candidate_files:
|
|
866
|
+
if resolve_short_memory_path(root, memory_file).exists():
|
|
867
|
+
raise StateError(f"Distillation candidate was not consumed: {memory_file}")
|
|
868
|
+
for memory_file in kept_files:
|
|
869
|
+
if not resolve_short_memory_path(root, memory_file).is_file():
|
|
870
|
+
raise StateError(f"Short-memory file selected for retention is missing: {memory_file}")
|
|
1832
871
|
|
|
1833
872
|
|
|
1834
873
|
def write_task(root: Path, task_id: str, task: dict) -> None:
|
|
1835
874
|
write_json(task_json_path(root, task_id), task)
|
|
1836
875
|
|
|
1837
876
|
|
|
1838
|
-
def execution_log_path(root: Path, task_id: str) -> Path:
|
|
1839
|
-
assert_safe_task_id(task_id)
|
|
1840
|
-
return root / ".easy-coding" / "tasks" / task_id / "execution.jsonl"
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
877
|
def append_execution_record(root: Path, task_id: str, record: dict) -> None:
|
|
1844
878
|
path = execution_log_path(root, task_id)
|
|
1845
879
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -1854,10 +888,6 @@ def append_execution_record(root: Path, task_id: str, record: dict) -> None:
|
|
|
1854
888
|
invalidate_memo(("plan", str(path)))
|
|
1855
889
|
|
|
1856
890
|
|
|
1857
|
-
def is_non_empty_string(value: object) -> bool:
|
|
1858
|
-
return isinstance(value, str) and bool(value.strip())
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
891
|
def is_string_list(value: object, allow_empty: bool = True) -> bool:
|
|
1862
892
|
return (
|
|
1863
893
|
isinstance(value, list)
|
|
@@ -2323,7 +1353,7 @@ def resume_spec_context(
|
|
|
2323
1353
|
def require_spec_context(
|
|
2324
1354
|
root: Path, task: dict, agent: str, session_file: str | Path | None = None,
|
|
2325
1355
|
*, allow_pending_hard_dependencies: bool = False,
|
|
2326
|
-
) -> None:
|
|
1356
|
+
) -> dict | None:
|
|
2327
1357
|
if not isinstance(task.get("spec_source"), dict):
|
|
2328
1358
|
return
|
|
2329
1359
|
if isinstance(task.get("spec_change"), dict):
|
|
@@ -2344,6 +1374,8 @@ def require_spec_context(
|
|
|
2344
1374
|
if not isinstance(receipt, dict) or any(receipt.get(key) != value for key, value in expected.items()):
|
|
2345
1375
|
raise StateError("Current session must consume the bound Canonical Spec via resume-spec-context before advancing.")
|
|
2346
1376
|
|
|
1377
|
+
return inspection
|
|
1378
|
+
|
|
2347
1379
|
|
|
2348
1380
|
def refresh_correction_plan(root: Path, task_id: str, task: dict, inspection: dict) -> None:
|
|
2349
1381
|
plan = latest_execution_plan(root, task_id)
|
|
@@ -2686,27 +1718,6 @@ def has_valid_execution_plan(root: Path, task_id: str) -> bool:
|
|
|
2686
1718
|
return True
|
|
2687
1719
|
|
|
2688
1720
|
|
|
2689
|
-
def execution_records(root: Path, task_id: str) -> list[dict]:
|
|
2690
|
-
path = execution_log_path(root, task_id)
|
|
2691
|
-
return memo(("execution", str(path)), lambda: _execution_records(path))
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
def _execution_records(path: Path) -> list[dict]:
|
|
2695
|
-
if not path.exists():
|
|
2696
|
-
return []
|
|
2697
|
-
records: list[dict] = []
|
|
2698
|
-
try:
|
|
2699
|
-
for line in path.read_text(encoding="utf-8").splitlines():
|
|
2700
|
-
if not line.strip():
|
|
2701
|
-
continue
|
|
2702
|
-
record = json.loads(line)
|
|
2703
|
-
if isinstance(record, dict):
|
|
2704
|
-
records.append(record)
|
|
2705
|
-
except (OSError, json.JSONDecodeError):
|
|
2706
|
-
return []
|
|
2707
|
-
return records
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
1721
|
def latest_execution_plan(root: Path, task_id: str) -> dict | None:
|
|
2711
1722
|
return memo(("plan", str(execution_log_path(root, task_id))),
|
|
2712
1723
|
lambda: _latest_execution_plan(root, task_id))
|
|
@@ -2995,43 +2006,30 @@ def is_easy_coding_state_path(
|
|
|
2995
2006
|
|
|
2996
2007
|
def git_index_entries(
|
|
2997
2008
|
repository: Path, pathspecs: list[str]
|
|
2998
|
-
) -> dict[bytes, tuple[bytes, bytes]]:
|
|
2009
|
+
) -> dict[bytes, tuple[bytes, bytes, bytes]]:
|
|
2999
2010
|
result = run_git(
|
|
3000
2011
|
repository,
|
|
3001
2012
|
"ls-files",
|
|
3002
2013
|
"--stage",
|
|
2014
|
+
"-v",
|
|
3003
2015
|
"-z",
|
|
3004
2016
|
"--",
|
|
3005
2017
|
*pathspecs,
|
|
3006
2018
|
)
|
|
3007
2019
|
if result is None or result.returncode != 0:
|
|
3008
2020
|
return {}
|
|
3009
|
-
entries: dict[bytes, tuple[bytes, bytes]] = {}
|
|
2021
|
+
entries: dict[bytes, tuple[bytes, bytes, bytes]] = {}
|
|
3010
2022
|
for raw_entry in filter(None, result.stdout.split(b"\0")):
|
|
3011
2023
|
try:
|
|
3012
2024
|
metadata, raw_path = raw_entry.split(b"\t", 1)
|
|
3013
|
-
mode, object_id, stage = metadata.split()
|
|
2025
|
+
tag, mode, object_id, stage = metadata.split()
|
|
3014
2026
|
except ValueError:
|
|
3015
2027
|
continue
|
|
3016
2028
|
if stage == b"0":
|
|
3017
|
-
entries[raw_path] = (mode, object_id)
|
|
2029
|
+
entries[raw_path] = (mode, object_id, tag)
|
|
3018
2030
|
return entries
|
|
3019
2031
|
|
|
3020
2032
|
|
|
3021
|
-
def git_worktree_blob_oid(repository: Path, relative_name: str) -> bytes | None:
|
|
3022
|
-
result = run_git(
|
|
3023
|
-
repository,
|
|
3024
|
-
"hash-object",
|
|
3025
|
-
f"--path={relative_name}",
|
|
3026
|
-
"--",
|
|
3027
|
-
relative_name,
|
|
3028
|
-
)
|
|
3029
|
-
if result is None or result.returncode != 0:
|
|
3030
|
-
return None
|
|
3031
|
-
object_id = result.stdout.strip()
|
|
3032
|
-
return object_id or None
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
2033
|
def worktree_git_mode(path: Path) -> bytes:
|
|
3036
2034
|
if path.is_symlink():
|
|
3037
2035
|
return b"120000"
|
|
@@ -3041,141 +2039,6 @@ def worktree_git_mode(path: Path) -> bytes:
|
|
|
3041
2039
|
return b"<missing-mode>"
|
|
3042
2040
|
|
|
3043
2041
|
|
|
3044
|
-
def update_git_repository_content_fingerprint(
|
|
3045
|
-
digest,
|
|
3046
|
-
root: Path,
|
|
3047
|
-
repository: Path,
|
|
3048
|
-
scopes: list[Path],
|
|
3049
|
-
visited: set[tuple[Path, tuple[Path, ...]]],
|
|
3050
|
-
) -> None:
|
|
3051
|
-
normalized_repository = repository.resolve()
|
|
3052
|
-
normalized_scopes = tuple(scope.resolve() for scope in scopes)
|
|
3053
|
-
visit_key = (normalized_repository, normalized_scopes)
|
|
3054
|
-
if visit_key in visited:
|
|
3055
|
-
digest.update(b"<git-scope-cycle>\0")
|
|
3056
|
-
return
|
|
3057
|
-
visited.add(visit_key)
|
|
3058
|
-
try:
|
|
3059
|
-
digest.update(b"git-repository\0")
|
|
3060
|
-
digest.update(os.fsencode(display_path(root, normalized_repository)))
|
|
3061
|
-
digest.update(b"\0")
|
|
3062
|
-
pathspecs = repository_scope_pathspecs(
|
|
3063
|
-
normalized_repository, list(normalized_scopes)
|
|
3064
|
-
)
|
|
3065
|
-
for scope in normalized_scopes:
|
|
3066
|
-
relative_scope = scope.relative_to(normalized_repository).as_posix()
|
|
3067
|
-
digest.update(b"git-scope\0")
|
|
3068
|
-
digest.update(os.fsencode(relative_scope))
|
|
3069
|
-
digest.update(b"\0")
|
|
3070
|
-
|
|
3071
|
-
index_entries = git_index_entries(normalized_repository, pathspecs)
|
|
3072
|
-
listed = run_git(
|
|
3073
|
-
normalized_repository,
|
|
3074
|
-
"ls-files",
|
|
3075
|
-
"--cached",
|
|
3076
|
-
"--others",
|
|
3077
|
-
"--exclude-standard",
|
|
3078
|
-
"-z",
|
|
3079
|
-
"--",
|
|
3080
|
-
*pathspecs,
|
|
3081
|
-
)
|
|
3082
|
-
modified = run_git(
|
|
3083
|
-
normalized_repository,
|
|
3084
|
-
"diff-files",
|
|
3085
|
-
"--name-only",
|
|
3086
|
-
"-z",
|
|
3087
|
-
"--ignore-submodules=none",
|
|
3088
|
-
"--",
|
|
3089
|
-
*pathspecs,
|
|
3090
|
-
)
|
|
3091
|
-
if listed is None or listed.returncode != 0:
|
|
3092
|
-
digest.update(b"<git-files-error>\0")
|
|
3093
|
-
return
|
|
3094
|
-
if modified is None or modified.returncode != 0:
|
|
3095
|
-
digest.update(b"<git-diff-files-error>\0")
|
|
3096
|
-
return
|
|
3097
|
-
modified_paths = set(filter(None, modified.stdout.split(b"\0")))
|
|
3098
|
-
|
|
3099
|
-
for raw_path in sorted(set(filter(None, listed.stdout.split(b"\0")))):
|
|
3100
|
-
relative_name = os.fsdecode(raw_path)
|
|
3101
|
-
if is_easy_coding_state_path(
|
|
3102
|
-
normalized_repository, relative_name, list(normalized_scopes)
|
|
3103
|
-
):
|
|
3104
|
-
continue
|
|
3105
|
-
candidate = normalized_repository / relative_name
|
|
3106
|
-
index_entry = index_entries.get(raw_path)
|
|
3107
|
-
if index_entry is not None and index_entry[0] == b"160000":
|
|
3108
|
-
digest.update(b"git-entry\0")
|
|
3109
|
-
digest.update(raw_path)
|
|
3110
|
-
digest.update(b"\0gitlink\0")
|
|
3111
|
-
submodule_root = git_repository_root(candidate)
|
|
3112
|
-
if (
|
|
3113
|
-
submodule_root is not None
|
|
3114
|
-
and submodule_root.resolve() == candidate.resolve()
|
|
3115
|
-
):
|
|
3116
|
-
update_git_repository_content_fingerprint(
|
|
3117
|
-
digest,
|
|
3118
|
-
root,
|
|
3119
|
-
submodule_root,
|
|
3120
|
-
[submodule_root],
|
|
3121
|
-
visited,
|
|
3122
|
-
)
|
|
3123
|
-
else:
|
|
3124
|
-
digest.update(index_entry[1])
|
|
3125
|
-
digest.update(b"\0")
|
|
3126
|
-
continue
|
|
3127
|
-
|
|
3128
|
-
exists = candidate.exists() or candidate.is_symlink()
|
|
3129
|
-
if not exists:
|
|
3130
|
-
if raw_path in modified_paths or index_entry is None:
|
|
3131
|
-
# A worktree deletion is canonically absent before and after staging.
|
|
3132
|
-
continue
|
|
3133
|
-
# Sparse or otherwise intentionally absent tracked files retain index content.
|
|
3134
|
-
mode, object_id = index_entry
|
|
3135
|
-
elif index_entry is not None and raw_path not in modified_paths:
|
|
3136
|
-
mode, object_id = index_entry
|
|
3137
|
-
else:
|
|
3138
|
-
mode = worktree_git_mode(candidate)
|
|
3139
|
-
object_id = git_worktree_blob_oid(
|
|
3140
|
-
normalized_repository, relative_name
|
|
3141
|
-
)
|
|
3142
|
-
if object_id is None:
|
|
3143
|
-
try:
|
|
3144
|
-
content = (
|
|
3145
|
-
os.fsencode(os.readlink(candidate))
|
|
3146
|
-
if candidate.is_symlink()
|
|
3147
|
-
else candidate.read_bytes()
|
|
3148
|
-
)
|
|
3149
|
-
except OSError:
|
|
3150
|
-
content = b"<missing>"
|
|
3151
|
-
object_id = hashlib.sha256(content).hexdigest().encode("ascii")
|
|
3152
|
-
|
|
3153
|
-
digest.update(b"git-entry\0")
|
|
3154
|
-
digest.update(raw_path)
|
|
3155
|
-
digest.update(b"\0")
|
|
3156
|
-
digest.update(mode)
|
|
3157
|
-
digest.update(b"\0")
|
|
3158
|
-
digest.update(object_id)
|
|
3159
|
-
digest.update(b"\0")
|
|
3160
|
-
finally:
|
|
3161
|
-
visited.remove(visit_key)
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
def update_git_worktree_fingerprint(
|
|
3165
|
-
digest,
|
|
3166
|
-
root: Path,
|
|
3167
|
-
task: dict | None,
|
|
3168
|
-
plan: dict,
|
|
3169
|
-
) -> None:
|
|
3170
|
-
visited: set[tuple[Path, tuple[Path, ...]]] = set()
|
|
3171
|
-
for repository, scopes in task_repository_scopes(root, task, plan):
|
|
3172
|
-
if not scopes:
|
|
3173
|
-
continue
|
|
3174
|
-
update_git_repository_content_fingerprint(
|
|
3175
|
-
digest, root, repository, scopes, visited
|
|
3176
|
-
)
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
2042
|
def tdd_infrastructure_fingerprint(repositories: set[Path]) -> str:
|
|
3180
2043
|
# 初始化快照不参与就绪判断;当前输入同时约束验收和跨仓证据继承。
|
|
3181
2044
|
digest = hashlib.sha256()
|
|
@@ -4455,7 +3318,9 @@ def verification_contract_fingerprint(root: Path, task_id: str, task: dict) -> s
|
|
|
4455
3318
|
return canonical_json_sha256(contract)
|
|
4456
3319
|
|
|
4457
3320
|
|
|
4458
|
-
def acceptance_repository_entries(
|
|
3321
|
+
def acceptance_repository_entries(
|
|
3322
|
+
repository: Path, scopes: list[Path], previous: dict[str, dict] | None = None,
|
|
3323
|
+
) -> list[dict]:
|
|
4459
3324
|
pathspecs = repository_scope_pathspecs(repository, scopes)
|
|
4460
3325
|
index_entries = git_index_entries(repository, pathspecs)
|
|
4461
3326
|
listed = run_git(
|
|
@@ -4488,6 +3353,8 @@ def acceptance_repository_entries(repository: Path, scopes: list[Path]) -> list[
|
|
|
4488
3353
|
continue
|
|
4489
3354
|
candidate = repository / relative_name
|
|
4490
3355
|
index_entry = index_entries.get(raw_path)
|
|
3356
|
+
# 小写标记表示 assume-unchanged,S 表示 skip-worktree;Git 不检查其工作区变化。
|
|
3357
|
+
trusted_index = index_entry is not None and index_entry[2] == b"H"
|
|
4491
3358
|
if index_entry is not None and index_entry[0] == b"160000":
|
|
4492
3359
|
entries.append(
|
|
4493
3360
|
{
|
|
@@ -4510,6 +3377,17 @@ def acceptance_repository_entries(repository: Path, scopes: list[Path]) -> list[
|
|
|
4510
3377
|
}
|
|
4511
3378
|
)
|
|
4512
3379
|
continue
|
|
3380
|
+
mode = worktree_git_mode(candidate).decode("ascii", errors="replace")
|
|
3381
|
+
prior = (previous or {}).get(relative_name)
|
|
3382
|
+
if (
|
|
3383
|
+
prior is not None and prior.get("exists") is True
|
|
3384
|
+
and prior.get("mode") == mode and trusted_index
|
|
3385
|
+
and raw_path not in modified_paths
|
|
3386
|
+
and prior.get("git_oid") == index_entry[1].decode("ascii")
|
|
3387
|
+
):
|
|
3388
|
+
# Git 对象与工作区均未变化,沿用冻结快照的内容摘要。
|
|
3389
|
+
entries.append(prior)
|
|
3390
|
+
continue
|
|
4513
3391
|
try:
|
|
4514
3392
|
content = (
|
|
4515
3393
|
os.fsencode(os.readlink(candidate))
|
|
@@ -4518,14 +3396,13 @@ def acceptance_repository_entries(repository: Path, scopes: list[Path]) -> list[
|
|
|
4518
3396
|
)
|
|
4519
3397
|
except OSError as exc:
|
|
4520
3398
|
raise StateError(f"Cannot read verification snapshot file: {relative_name}") from exc
|
|
4521
|
-
mode = worktree_git_mode(candidate).decode("ascii", errors="replace")
|
|
4522
3399
|
entry = {
|
|
4523
3400
|
"path": relative_name,
|
|
4524
3401
|
"exists": True,
|
|
4525
3402
|
"mode": mode,
|
|
4526
3403
|
"sha256": hashlib.sha256(content).hexdigest(),
|
|
4527
3404
|
}
|
|
4528
|
-
if
|
|
3405
|
+
if trusted_index and raw_path not in modified_paths:
|
|
4529
3406
|
entry["git_oid"] = index_entry[1].decode("ascii", errors="replace")
|
|
4530
3407
|
else:
|
|
4531
3408
|
# 仅无法从 Git object 还原的工作区内容进入被忽略的临时快照。
|
|
@@ -4609,13 +3486,19 @@ def acceptance_filesystem_repositories(
|
|
|
4609
3486
|
return repositories
|
|
4610
3487
|
|
|
4611
3488
|
|
|
4612
|
-
def build_acceptance_snapshot(
|
|
3489
|
+
def build_acceptance_snapshot(
|
|
3490
|
+
root: Path, task_id: str, task: dict, baseline: dict | None = None,
|
|
3491
|
+
) -> dict:
|
|
4613
3492
|
plan = latest_execution_plan(root, task_id)
|
|
4614
3493
|
if plan is None:
|
|
4615
3494
|
raise StateError("Cannot capture verification snapshot without a valid plan.")
|
|
4616
3495
|
fingerprints = evidence_fingerprints(root, task_id)
|
|
4617
3496
|
repository_scopes = task_repository_scopes(root, task, plan)
|
|
4618
3497
|
repositories = []
|
|
3498
|
+
previous = {
|
|
3499
|
+
repo["root"]: {entry["path"]: entry for entry in repo["entries"]}
|
|
3500
|
+
for repo in (baseline or {}).get("repositories", [])
|
|
3501
|
+
}
|
|
4619
3502
|
for repository, scopes in repository_scopes:
|
|
4620
3503
|
repositories.append(
|
|
4621
3504
|
{
|
|
@@ -4624,7 +3507,9 @@ def build_acceptance_snapshot(root: Path, task_id: str, task: dict) -> dict:
|
|
|
4624
3507
|
"scopes": [
|
|
4625
3508
|
scope.relative_to(repository.resolve()).as_posix() for scope in scopes
|
|
4626
3509
|
],
|
|
4627
|
-
"entries": acceptance_repository_entries(
|
|
3510
|
+
"entries": acceptance_repository_entries(
|
|
3511
|
+
repository.resolve(), scopes, previous.get(str(repository.resolve())),
|
|
3512
|
+
),
|
|
4628
3513
|
}
|
|
4629
3514
|
)
|
|
4630
3515
|
repositories.extend(acceptance_filesystem_repositories(root, plan, repository_scopes))
|
|
@@ -4720,7 +3605,7 @@ def acceptance_change_patch(
|
|
|
4720
3605
|
def inspect_acceptance_drift(root: Path, task_id: str, task: dict) -> dict:
|
|
4721
3606
|
checkpoint = task.get("quality_checkpoint")
|
|
4722
3607
|
baseline = load_acceptance_snapshot(root, task)
|
|
4723
|
-
current = build_acceptance_snapshot(root, task_id, task)
|
|
3608
|
+
current = build_acceptance_snapshot(root, task_id, task, baseline)
|
|
4724
3609
|
baseline_entries = acceptance_snapshot_entries(baseline)
|
|
4725
3610
|
current_entries = acceptance_snapshot_entries(current)
|
|
4726
3611
|
changes: list[dict] = []
|
|
@@ -4963,8 +3848,10 @@ def append_transition_acceptance(
|
|
|
4963
3848
|
expected_diff_sha256: str | None = None,
|
|
4964
3849
|
verification_policy: str | None = None,
|
|
4965
3850
|
summary: str | None = None,
|
|
3851
|
+
*, drift: dict | None = None,
|
|
4966
3852
|
) -> dict:
|
|
4967
|
-
drift
|
|
3853
|
+
if drift is None:
|
|
3854
|
+
drift = inspect_acceptance_drift(root, task_id, task)
|
|
4968
3855
|
if drift["config_changed"]:
|
|
4969
3856
|
raise StateError(
|
|
4970
3857
|
"Behavior config changed after quality checks; rerun QUALITY before MEMORY."
|
|
@@ -7014,421 +5901,6 @@ def latest_handoff_record(root: Path, task_id: str) -> dict | None:
|
|
|
7014
5901
|
if r.get("type") == "handoff"), None)
|
|
7015
5902
|
|
|
7016
5903
|
|
|
7017
|
-
def pending_handoff_record(root: Path, task_id: str) -> dict | None:
|
|
7018
|
-
latest = next((r for r in reversed(execution_records(root, task_id))
|
|
7019
|
-
if r.get("type") in {"handoff", "claim"}), None)
|
|
7020
|
-
return latest if latest and latest["type"] == "handoff" else None
|
|
7021
|
-
|
|
7022
|
-
|
|
7023
|
-
def assert_safe_task_id(task_id: str) -> None:
|
|
7024
|
-
path = Path(task_id)
|
|
7025
|
-
if not task_id or path.is_absolute() or "/" in task_id or "\\" in task_id or ".." in path.parts:
|
|
7026
|
-
raise StateError(f"Unsafe task id: {task_id}")
|
|
7027
|
-
|
|
7028
|
-
|
|
7029
|
-
def is_project_init_required(root: Path) -> bool:
|
|
7030
|
-
project_init = load_json(root / ".easy-coding" / "tasks" / "project-init" / "task.json")
|
|
7031
|
-
return bool(project_init and project_init.get("status") != "COMPLETE")
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
def get_pending_init_version(root: Path) -> str | None:
|
|
7035
|
-
project_init = load_json(root / ".easy-coding" / "tasks" / "project-init" / "task.json")
|
|
7036
|
-
if project_init and project_init.get("pending_init_since"):
|
|
7037
|
-
return str(project_init["pending_init_since"])
|
|
7038
|
-
return None
|
|
7039
|
-
|
|
7040
|
-
|
|
7041
|
-
def spec_task_summary(task: dict | None) -> dict | None:
|
|
7042
|
-
if not task or not isinstance(task.get("spec_source"), dict):
|
|
7043
|
-
return None
|
|
7044
|
-
dependencies = task.get("spec_dependency_evidence")
|
|
7045
|
-
pending_dependencies = [
|
|
7046
|
-
{
|
|
7047
|
-
"source_task_id": record.get("source_task_id"),
|
|
7048
|
-
"task_id": record.get("task_id"),
|
|
7049
|
-
"dependency_type": record.get("dependency_type"),
|
|
7050
|
-
"required_evidence": record.get("required_evidence"),
|
|
7051
|
-
}
|
|
7052
|
-
for record in dependencies or []
|
|
7053
|
-
if isinstance(record, dict) and record.get("status") == "pending"
|
|
7054
|
-
]
|
|
7055
|
-
return {
|
|
7056
|
-
"source": task["spec_source"],
|
|
7057
|
-
"selected_spec_tasks": task.get("selected_spec_tasks", []),
|
|
7058
|
-
"repositories": task.get("spec_repositories", []),
|
|
7059
|
-
"pending_dependencies": pending_dependencies,
|
|
7060
|
-
"writeback": task.get("spec_writeback_progress"),
|
|
7061
|
-
"context": task.get("spec_context"),
|
|
7062
|
-
"pending_change": task.get("spec_change"),
|
|
7063
|
-
}
|
|
7064
|
-
|
|
7065
|
-
|
|
7066
|
-
def transition_requires_confirmation(
|
|
7067
|
-
previous: str,
|
|
7068
|
-
current: str,
|
|
7069
|
-
task_type: str,
|
|
7070
|
-
approval_mode: str,
|
|
7071
|
-
) -> bool:
|
|
7072
|
-
if (previous, current) in ALWAYS_AUTO_TRANSITIONS:
|
|
7073
|
-
return False
|
|
7074
|
-
if current == "CLOSED":
|
|
7075
|
-
return True
|
|
7076
|
-
if approval_mode == "auto":
|
|
7077
|
-
return False
|
|
7078
|
-
if approval_mode == "guard":
|
|
7079
|
-
return (previous, current) in CRITICAL_CONFIRM_TRANSITIONS
|
|
7080
|
-
if approval_mode == "confirm":
|
|
7081
|
-
return (previous, current) == ANALYSIS_CONFIRM_TRANSITION
|
|
7082
|
-
if approval_mode == "approve":
|
|
7083
|
-
return True
|
|
7084
|
-
raise StateError(f"Unknown approval mode: {approval_mode}")
|
|
7085
|
-
|
|
7086
|
-
|
|
7087
|
-
def is_automatic_transition(
|
|
7088
|
-
previous: str,
|
|
7089
|
-
current: str,
|
|
7090
|
-
task_type: str,
|
|
7091
|
-
approval_mode: str,
|
|
7092
|
-
) -> bool:
|
|
7093
|
-
return not transition_requires_confirmation(previous, current, task_type, approval_mode)
|
|
7094
|
-
|
|
7095
|
-
|
|
7096
|
-
def validate_transition(
|
|
7097
|
-
previous: str,
|
|
7098
|
-
current: str,
|
|
7099
|
-
task_type: str = "",
|
|
7100
|
-
task: dict | None = None,
|
|
7101
|
-
) -> str | None:
|
|
7102
|
-
if previous == current:
|
|
7103
|
-
return None
|
|
7104
|
-
allowed = set(VALID_TRANSITIONS.get(previous, set()))
|
|
7105
|
-
if previous == "IMPLEMENT":
|
|
7106
|
-
allowed.discard("COMPLETE")
|
|
7107
|
-
if current in allowed:
|
|
7108
|
-
return None
|
|
7109
|
-
return (
|
|
7110
|
-
f"ILLEGAL TRANSITION: {previous} -> {current}. "
|
|
7111
|
-
f"Allowed from {previous}: {sorted(allowed) or 'NONE (terminal state)'}."
|
|
7112
|
-
)
|
|
7113
|
-
|
|
7114
|
-
|
|
7115
|
-
def snapshot_state(
|
|
7116
|
-
root: Path,
|
|
7117
|
-
session_file: str | Path | None = None,
|
|
7118
|
-
session: dict | None = None,
|
|
7119
|
-
) -> dict:
|
|
7120
|
-
session_path = resolve_session_path(root, session_file)
|
|
7121
|
-
resolved_session = session if session is not None else load_session(root, session_path)
|
|
7122
|
-
if resolved_session is None:
|
|
7123
|
-
resolved_session = default_session()
|
|
7124
|
-
|
|
7125
|
-
task_id = resolved_session.get("current_task")
|
|
7126
|
-
task = load_task(root, str(task_id)) if task_id else None
|
|
7127
|
-
missing = bool(task_id and task is None)
|
|
7128
|
-
status = "idle"
|
|
7129
|
-
if missing:
|
|
7130
|
-
status = "MISSING"
|
|
7131
|
-
elif task and task.get("status"):
|
|
7132
|
-
status = str(task["status"])
|
|
7133
|
-
|
|
7134
|
-
if task_id and task and status in TERMINAL_STATUSES:
|
|
7135
|
-
clear_session_pointer(resolved_session, task.get("last_agent"))
|
|
7136
|
-
write_session(root, resolved_session, session_path)
|
|
7137
|
-
task_id = None
|
|
7138
|
-
task = None
|
|
7139
|
-
missing = False
|
|
7140
|
-
status = "idle"
|
|
7141
|
-
|
|
7142
|
-
(
|
|
7143
|
-
project_approval_mode,
|
|
7144
|
-
session_approval_mode,
|
|
7145
|
-
effective_approval_mode,
|
|
7146
|
-
project_workflow_mode,
|
|
7147
|
-
session_workflow_mode,
|
|
7148
|
-
configured_workflow_mode,
|
|
7149
|
-
project_unit_test_mode,
|
|
7150
|
-
session_unit_test_mode,
|
|
7151
|
-
effective_unit_test_mode,
|
|
7152
|
-
project_ut_coverage_threshold,
|
|
7153
|
-
session_ut_coverage_threshold,
|
|
7154
|
-
effective_ut_coverage_threshold,
|
|
7155
|
-
) = resolve_behavior(root, resolved_session)
|
|
7156
|
-
concrete_workflow_mode = None
|
|
7157
|
-
if task:
|
|
7158
|
-
concrete_workflow_mode = task.get("workflow_mode")
|
|
7159
|
-
proposal = task.get("workflow_mode_proposal")
|
|
7160
|
-
if concrete_workflow_mode is None and isinstance(proposal, dict):
|
|
7161
|
-
concrete_workflow_mode = proposal.get("selected_mode")
|
|
7162
|
-
task_unit_test_mode = task.get("unit_test_mode") if task else None
|
|
7163
|
-
task_ut_coverage_threshold = task.get("ut_coverage_threshold") if task else None
|
|
7164
|
-
frozen_unit_test = bool(
|
|
7165
|
-
task
|
|
7166
|
-
and status not in {"ANALYSIS", "INIT"}
|
|
7167
|
-
and task_unit_test_mode in {"none", "ut", "tdd"}
|
|
7168
|
-
)
|
|
7169
|
-
is_tdd_init = bool(
|
|
7170
|
-
task and str(task.get("type") or "").strip().lower() == TDD_INIT_TASK_TYPE
|
|
7171
|
-
)
|
|
7172
|
-
displayed_unit_test_mode = (
|
|
7173
|
-
"none" if is_tdd_init else task_unit_test_mode if frozen_unit_test else effective_unit_test_mode
|
|
7174
|
-
)
|
|
7175
|
-
displayed_ut_threshold = (
|
|
7176
|
-
task_ut_coverage_threshold
|
|
7177
|
-
if frozen_unit_test and isinstance(task_ut_coverage_threshold, int)
|
|
7178
|
-
else effective_ut_coverage_threshold
|
|
7179
|
-
)
|
|
7180
|
-
should_check_readiness = bool(
|
|
7181
|
-
effective_unit_test_mode in {"ut", "tdd"} or task_unit_test_mode in {"ut", "tdd"} or is_tdd_init
|
|
7182
|
-
)
|
|
7183
|
-
readiness = (
|
|
7184
|
-
tdd_readiness(root)
|
|
7185
|
-
if should_check_readiness
|
|
7186
|
-
else {"status": "not_checked", "reasons": []}
|
|
7187
|
-
)
|
|
7188
|
-
|
|
7189
|
-
layers = behavior_layers(root, resolved_session)
|
|
7190
|
-
local, _ = read_behavior_file(Path.home() / ".easy-coding" / "config.yaml")
|
|
7191
|
-
return {
|
|
7192
|
-
"session_file": display_path(root, session_path),
|
|
7193
|
-
"behavior_sources": {key: item["source"] for key, item in layers.items()},
|
|
7194
|
-
"local_behavior": local,
|
|
7195
|
-
"effective_cooperate_mode": layers["cooperate_mode"]["value"],
|
|
7196
|
-
"cooperation": task.get("cooperation") if task else None,
|
|
7197
|
-
"quality_repair": task.get("quality_repair") if task else None,
|
|
7198
|
-
"continuation": task.get("continuation") if task else None,
|
|
7199
|
-
"current_task": str(task_id) if task_id else None,
|
|
7200
|
-
"task": task,
|
|
7201
|
-
"pending_transition": task.get("pending_transition") if task else None,
|
|
7202
|
-
"memory_progress": task.get("memory_progress") if task else None,
|
|
7203
|
-
"task_missing": missing,
|
|
7204
|
-
"status": status,
|
|
7205
|
-
"is_terminal": status in TERMINAL_STATUSES,
|
|
7206
|
-
"last_agent": task.get("last_agent") if task else None,
|
|
7207
|
-
"project_init_required": is_project_init_required(root),
|
|
7208
|
-
"pending_init_version": get_pending_init_version(root),
|
|
7209
|
-
"project_approval_mode": project_approval_mode,
|
|
7210
|
-
"session_approval_mode": session_approval_mode,
|
|
7211
|
-
"effective_approval_mode": effective_approval_mode,
|
|
7212
|
-
"project_workflow_mode": project_workflow_mode,
|
|
7213
|
-
"session_workflow_mode": session_workflow_mode,
|
|
7214
|
-
"configured_workflow_mode": configured_workflow_mode,
|
|
7215
|
-
"concrete_workflow_mode": concrete_workflow_mode,
|
|
7216
|
-
"project_unit_test_mode": project_unit_test_mode,
|
|
7217
|
-
"session_unit_test_mode": session_unit_test_mode,
|
|
7218
|
-
"effective_unit_test_mode": effective_unit_test_mode,
|
|
7219
|
-
"project_ut_coverage_threshold": project_ut_coverage_threshold,
|
|
7220
|
-
"session_ut_coverage_threshold": session_ut_coverage_threshold,
|
|
7221
|
-
"effective_ut_coverage_threshold": effective_ut_coverage_threshold,
|
|
7222
|
-
"task_unit_test_mode": task_unit_test_mode,
|
|
7223
|
-
"task_ut_coverage_threshold": task_ut_coverage_threshold,
|
|
7224
|
-
"task_tdd_baselines": task.get("tdd_baselines") if task else None,
|
|
7225
|
-
"displayed_unit_test_mode": displayed_unit_test_mode,
|
|
7226
|
-
"displayed_ut_coverage_threshold": displayed_ut_threshold,
|
|
7227
|
-
"unit_test_readiness_status": readiness["status"],
|
|
7228
|
-
"unit_test_readiness_reasons": readiness["reasons"],
|
|
7229
|
-
"spec_summary": spec_task_summary(task),
|
|
7230
|
-
# Compatibility output aliases for pre-0.9 clients.
|
|
7231
|
-
"project_confirm_mode": project_approval_mode,
|
|
7232
|
-
"session_confirm_mode": session_approval_mode,
|
|
7233
|
-
"effective_confirm_mode": effective_approval_mode,
|
|
7234
|
-
"harness_disabled": resolved_session.get("harness_disabled") is True,
|
|
7235
|
-
"lite_mode": resolved_session.get("lite_mode") is True,
|
|
7236
|
-
"lite_proposal": resolved_session.get("lite_proposal"),
|
|
7237
|
-
}
|
|
7238
|
-
|
|
7239
|
-
|
|
7240
|
-
def build_status_line(
|
|
7241
|
-
root: Path,
|
|
7242
|
-
session: dict,
|
|
7243
|
-
agent: str | None = None,
|
|
7244
|
-
session_file: str | Path | None = None,
|
|
7245
|
-
state: dict | None = None,
|
|
7246
|
-
) -> str:
|
|
7247
|
-
state = state if state is not None else snapshot_state(root, session_file, session)
|
|
7248
|
-
if state["lite_mode"]:
|
|
7249
|
-
lite_state = (
|
|
7250
|
-
"Awaiting Confirmation"
|
|
7251
|
-
if isinstance(state.get("lite_proposal"), dict)
|
|
7252
|
-
and not state["lite_proposal"].get("confirmed_at")
|
|
7253
|
-
else "Ready"
|
|
7254
|
-
)
|
|
7255
|
-
return (
|
|
7256
|
-
f"> **Easy Coding** · **Lite Direct** · {lite_state} · "
|
|
7257
|
-
"No Task / Quality / Memory · Use `ec-lite` to exit"
|
|
7258
|
-
)
|
|
7259
|
-
approval = str(state["effective_approval_mode"]).capitalize()
|
|
7260
|
-
workflow = str(state["concrete_workflow_mode"] or state["configured_workflow_mode"]).capitalize()
|
|
7261
|
-
status_brand = f"> **Easy Coding** · **Approval: {approval}** · **Workflow: {workflow}**"
|
|
7262
|
-
if state["effective_cooperate_mode"] == "dispatch":
|
|
7263
|
-
status_brand += " · **Dispatch**"
|
|
7264
|
-
if state["displayed_unit_test_mode"] in {"ut", "tdd"}:
|
|
7265
|
-
status_brand += f" · **{state['displayed_unit_test_mode'].upper()}**"
|
|
7266
|
-
task_id = state["current_task"]
|
|
7267
|
-
if task_id:
|
|
7268
|
-
status = str(state["status"])
|
|
7269
|
-
line = f"{status_brand} · `{task_id}` · `{status}`"
|
|
7270
|
-
handoff = pending_handoff_record(root, str(task_id))
|
|
7271
|
-
handoff_from = handoff.get("from") if handoff else None
|
|
7272
|
-
if agent and handoff_from and not agents_equivalent(handoff_from, agent):
|
|
7273
|
-
line += f" · Handoff -> `{handoff_from}`"
|
|
7274
|
-
if state["is_terminal"] or state["task_missing"]:
|
|
7275
|
-
line += f" · {HELP_SUFFIX}"
|
|
7276
|
-
return line
|
|
7277
|
-
|
|
7278
|
-
if is_project_init_required(root):
|
|
7279
|
-
return f"{status_brand} · {WAITING_INIT_LINE}"
|
|
7280
|
-
|
|
7281
|
-
pending = get_pending_init_version(root)
|
|
7282
|
-
if pending:
|
|
7283
|
-
return (
|
|
7284
|
-
f"{status_brand} · Waiting init · "
|
|
7285
|
-
f"Upgrade to v{pending} — run `ec-init` to adapt"
|
|
7286
|
-
)
|
|
7287
|
-
|
|
7288
|
-
return f"{status_brand} · {READY_LINE}"
|
|
7289
|
-
|
|
7290
|
-
|
|
7291
|
-
def build_machine_breadcrumbs(
|
|
7292
|
-
root: Path,
|
|
7293
|
-
session: dict,
|
|
7294
|
-
agent: str | None = None,
|
|
7295
|
-
session_file: str | Path | None = None,
|
|
7296
|
-
state: dict | None = None,
|
|
7297
|
-
) -> list[str]:
|
|
7298
|
-
state = state if state is not None else snapshot_state(root, session_file, session)
|
|
7299
|
-
task_id = state["current_task"]
|
|
7300
|
-
task = state["task"]
|
|
7301
|
-
stage = str(state["status"]) if task else "idle"
|
|
7302
|
-
resolved_session_file = str(state["session_file"])
|
|
7303
|
-
lines = [
|
|
7304
|
-
f"[workflow-state:{stage}]",
|
|
7305
|
-
f"[easy-coding:session-file:{resolved_session_file}]",
|
|
7306
|
-
f"[easy-coding:approval-mode:{state['effective_approval_mode']}]",
|
|
7307
|
-
f"[easy-coding:configured-workflow-mode:{state['configured_workflow_mode']}]",
|
|
7308
|
-
f"[easy-coding:cooperate-mode:{state['effective_cooperate_mode']}]",
|
|
7309
|
-
]
|
|
7310
|
-
if state.get("concrete_workflow_mode"):
|
|
7311
|
-
lines.append(f"[easy-coding:workflow-mode:{state['concrete_workflow_mode']}]")
|
|
7312
|
-
if state.get("displayed_unit_test_mode") in {"ut", "tdd"}:
|
|
7313
|
-
lines.append(f"[easy-coding:unit-test-mode:{state['displayed_unit_test_mode']}]")
|
|
7314
|
-
lines.append(
|
|
7315
|
-
f"[easy-coding:ut-coverage-threshold:{state['displayed_ut_coverage_threshold']}]"
|
|
7316
|
-
)
|
|
7317
|
-
|
|
7318
|
-
if task_id:
|
|
7319
|
-
lines.append(f"[current-task:{task_id}]")
|
|
7320
|
-
continuation = state.get("continuation") or {}
|
|
7321
|
-
if continuation.get("next_action"):
|
|
7322
|
-
lines.append(f"[easy-coding:next-action:{continuation['next_action']}]")
|
|
7323
|
-
if continuation.get("stop_after"):
|
|
7324
|
-
lines.append(f"[easy-coding:stop-after:{continuation['stop_after']}]")
|
|
7325
|
-
if task and isinstance(task.get("spec_source"), dict):
|
|
7326
|
-
source = task["spec_source"]
|
|
7327
|
-
lines.append(f"[easy-coding:spec:{source.get('spec_id')}:revision:{source.get('revision')}]")
|
|
7328
|
-
lines.append("[easy-coding:spec-context:reuse-current-session-or-resume-if-missing]")
|
|
7329
|
-
if task.get("spec_change"):
|
|
7330
|
-
lines.append("[easy-coding:spec-change:pending-sync-spec-design]")
|
|
7331
|
-
if (task.get("spec_writeback_progress") or {}).get("pending_action"):
|
|
7332
|
-
lines.append("[easy-coding:spec-writeback:reconcile-spec-execution-required]")
|
|
7333
|
-
if state["task_missing"]:
|
|
7334
|
-
lines.append(f"[easy-coding:current-task-missing:{task_id}]")
|
|
7335
|
-
handoff = pending_handoff_record(root, str(task_id))
|
|
7336
|
-
handoff_from = handoff.get("from") if handoff else None
|
|
7337
|
-
if agent and handoff_from and not agents_equivalent(handoff_from, agent):
|
|
7338
|
-
lines.append(f"[easy-coding:handoff-from:{handoff_from}]")
|
|
7339
|
-
pending = state.get("pending_transition")
|
|
7340
|
-
if isinstance(pending, dict):
|
|
7341
|
-
source = str(pending.get("from") or stage)
|
|
7342
|
-
target = str(pending.get("to") or "")
|
|
7343
|
-
if target:
|
|
7344
|
-
lines.append(f"[easy-coding:pending-transition:{source}->{target}]")
|
|
7345
|
-
task_type = str(task.get("type") or "") if task else ""
|
|
7346
|
-
if pending.get("confirmation_override") == "evidence-drift":
|
|
7347
|
-
lines.append(
|
|
7348
|
-
"[easy-coding:acceptance-drift-confirmation-required]"
|
|
7349
|
-
)
|
|
7350
|
-
lines.append("[easy-coding:transition-confirmation-required]")
|
|
7351
|
-
elif is_automatic_transition(
|
|
7352
|
-
source,
|
|
7353
|
-
target,
|
|
7354
|
-
task_type,
|
|
7355
|
-
str(state["effective_approval_mode"]),
|
|
7356
|
-
):
|
|
7357
|
-
lines.append(f"[easy-coding:auto-transition-ready:{source}->{target}]")
|
|
7358
|
-
else:
|
|
7359
|
-
lines.append("[easy-coding:transition-confirmation-required]")
|
|
7360
|
-
|
|
7361
|
-
if is_project_init_required(root):
|
|
7362
|
-
lines.append("[easy-coding:init-required]")
|
|
7363
|
-
else:
|
|
7364
|
-
pending = get_pending_init_version(root)
|
|
7365
|
-
if pending:
|
|
7366
|
-
lines.append(f"[easy-coding:upgrade-init-pending:{pending}]")
|
|
7367
|
-
|
|
7368
|
-
# Stage-specific reminders
|
|
7369
|
-
if stage == "ANALYSIS" and task_id:
|
|
7370
|
-
dev_spec = root / ".easy-coding" / "tasks" / str(task_id) / "dev-spec.md"
|
|
7371
|
-
if dev_spec.exists():
|
|
7372
|
-
try:
|
|
7373
|
-
content = dev_spec.read_text(encoding="utf-8")
|
|
7374
|
-
missing = [h for h in MANDATORY_DEV_SPEC_HEADERS if h not in content]
|
|
7375
|
-
if missing:
|
|
7376
|
-
names = ",".join(h.lstrip("#").strip() for h in missing)
|
|
7377
|
-
lines.append(f"[easy-coding:analysis-template-drift:missing:{names}]")
|
|
7378
|
-
else:
|
|
7379
|
-
lines.append("[easy-coding:analysis-template-ok]")
|
|
7380
|
-
except OSError:
|
|
7381
|
-
lines.append("[easy-coding:analysis-gate:skeleton-first-then-fill]")
|
|
7382
|
-
else:
|
|
7383
|
-
lines.append("[easy-coding:analysis-gate:skeleton-first-then-fill]")
|
|
7384
|
-
|
|
7385
|
-
# State machine validation
|
|
7386
|
-
if task_id and task and task.get("status"):
|
|
7387
|
-
current_stage = str(task["status"])
|
|
7388
|
-
last_seen = session.get("last_seen_stage")
|
|
7389
|
-
violation = record_seen_stage(root, str(task_id), current_stage, resolved_session_file)
|
|
7390
|
-
if violation:
|
|
7391
|
-
lines.append(f"[ILLEGAL-TRANSITION:{last_seen}->{current_stage}]")
|
|
7392
|
-
lines.append(f"[easy-coding:transition-error:{violation}]")
|
|
7393
|
-
|
|
7394
|
-
return lines
|
|
7395
|
-
|
|
7396
|
-
|
|
7397
|
-
def build_status_context(
|
|
7398
|
-
root: Path,
|
|
7399
|
-
session: dict,
|
|
7400
|
-
agent: str | None = None,
|
|
7401
|
-
session_file: str | Path | None = None,
|
|
7402
|
-
state: dict | None = None,
|
|
7403
|
-
) -> str:
|
|
7404
|
-
if session.get("harness_disabled") is True:
|
|
7405
|
-
session_path = resolve_session_path(root, session_file)
|
|
7406
|
-
return "\n".join(
|
|
7407
|
-
[
|
|
7408
|
-
"[easy-coding:no-harness]",
|
|
7409
|
-
f"[easy-coding:session-file:{display_path(root, session_path)}]",
|
|
7410
|
-
]
|
|
7411
|
-
)
|
|
7412
|
-
if session.get("lite_mode") is True:
|
|
7413
|
-
session_path = resolve_session_path(root, session_file)
|
|
7414
|
-
proposal = session.get("lite_proposal")
|
|
7415
|
-
lines = [
|
|
7416
|
-
build_status_line(root, session, agent, session_file),
|
|
7417
|
-
"[easy-coding:lite-direct]",
|
|
7418
|
-
f"[easy-coding:session-file:{display_path(root, session_path)}]",
|
|
7419
|
-
]
|
|
7420
|
-
if isinstance(proposal, dict):
|
|
7421
|
-
lines.append(f"[easy-coding:lite-proposal:{proposal.get('digest', 'missing')}]")
|
|
7422
|
-
return "\n".join(lines)
|
|
7423
|
-
state = state if state is not None else snapshot_state(root, session_file, session)
|
|
7424
|
-
return "\n".join(
|
|
7425
|
-
[
|
|
7426
|
-
build_status_line(root, session, agent, session_file, state),
|
|
7427
|
-
*build_machine_breadcrumbs(root, session, agent, session_file, state),
|
|
7428
|
-
]
|
|
7429
|
-
)
|
|
7430
|
-
|
|
7431
|
-
|
|
7432
5904
|
def attach_status_context(
|
|
7433
5905
|
root: Path,
|
|
7434
5906
|
data: dict,
|
|
@@ -7488,16 +5960,6 @@ def list_tasks(root: Path, agent: str | None = None) -> list[dict]:
|
|
|
7488
5960
|
return items
|
|
7489
5961
|
|
|
7490
5962
|
|
|
7491
|
-
def ensure_session(root: Path, session_file: str | Path | None = None) -> dict:
|
|
7492
|
-
session = load_session(root, session_file)
|
|
7493
|
-
if session is None:
|
|
7494
|
-
session = default_session()
|
|
7495
|
-
if not session.get("created_at"):
|
|
7496
|
-
session["created_at"] = now_iso()
|
|
7497
|
-
session["last_active_at"] = now_iso()
|
|
7498
|
-
return session
|
|
7499
|
-
|
|
7500
|
-
|
|
7501
5963
|
def set_current_task(root: Path, task_id: str, agent: str, session_file: str | Path | None = None) -> dict:
|
|
7502
5964
|
task = load_task(root, task_id)
|
|
7503
5965
|
if task is None:
|
|
@@ -8379,13 +6841,14 @@ def _execute_spec_writeback(
|
|
|
8379
6841
|
action: dict,
|
|
8380
6842
|
idempotency_key: str,
|
|
8381
6843
|
invoke,
|
|
8382
|
-
*, allow_pending_hard_dependencies: bool = False,
|
|
6844
|
+
*, allow_pending_hard_dependencies: bool = False, inspection: dict | None = None,
|
|
8383
6845
|
) -> dict:
|
|
8384
|
-
inspection
|
|
8385
|
-
|
|
8386
|
-
|
|
8387
|
-
|
|
8388
|
-
|
|
6846
|
+
if inspection is None:
|
|
6847
|
+
inspection, _ = inspect_task_spec(
|
|
6848
|
+
root, task, allow_pending_hard_dependencies=(
|
|
6849
|
+
allow_pending_hard_dependencies or action.get("kind") == "dependency"
|
|
6850
|
+
),
|
|
6851
|
+
)
|
|
8389
6852
|
source = task["spec_source"]
|
|
8390
6853
|
progress = _writeback_progress(task)
|
|
8391
6854
|
serialized_action = json.dumps(action, ensure_ascii=False, sort_keys=True)
|
|
@@ -8463,23 +6926,14 @@ def _execute_spec_writeback(
|
|
|
8463
6926
|
raise StateError(f"Canonical Spec writeback failed: {exc}") from exc
|
|
8464
6927
|
|
|
8465
6928
|
event = _spec_event(execution, idempotency_key)
|
|
8466
|
-
|
|
8467
|
-
|
|
8468
|
-
except ExecutionStateError as exc:
|
|
8469
|
-
raise StateError(f"Canonical Spec writeback cannot be verified: {exc}") from exc
|
|
8470
|
-
source.update(
|
|
8471
|
-
{
|
|
8472
|
-
"revision": details["design_revision"],
|
|
8473
|
-
"design_sha256": details["design_sha256"],
|
|
8474
|
-
"document_sha256": details["document_sha256"],
|
|
8475
|
-
"execution_revision": execution["execution_revision"],
|
|
8476
|
-
}
|
|
8477
|
-
)
|
|
8478
|
-
inspect_task_spec(
|
|
6929
|
+
# 写后只消费一次真实文件,同时检查设计未漂移、事件可见并刷新元数据。
|
|
6930
|
+
confirmed, _ = inspect_task_spec(
|
|
8479
6931
|
root, task, allow_pending_hard_dependencies=(
|
|
8480
6932
|
allow_pending_hard_dependencies or action.get("kind") == "dependency"
|
|
8481
6933
|
),
|
|
8482
6934
|
)
|
|
6935
|
+
if _spec_event(confirmed["execution"], idempotency_key)["event_id"] != event["event_id"]:
|
|
6936
|
+
raise StateError("Canonical Spec writeback event changed before acknowledgment.")
|
|
8483
6937
|
progress.update(
|
|
8484
6938
|
{
|
|
8485
6939
|
"last_execution_revision": execution["execution_revision"],
|
|
@@ -8522,7 +6976,7 @@ def writeback_spec_task(
|
|
|
8522
6976
|
*, event_agent: str | None = None,
|
|
8523
6977
|
) -> dict:
|
|
8524
6978
|
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
8525
|
-
require_spec_context(root, task, agent, session_file)
|
|
6979
|
+
inspection = require_spec_context(root, task, agent, session_file)
|
|
8526
6980
|
writer_agent = event_agent or agent
|
|
8527
6981
|
if source_task_id not in set(task.get("selected_spec_tasks") or []):
|
|
8528
6982
|
raise StateError("Canonical source task is outside the Harness task selection.")
|
|
@@ -8554,6 +7008,7 @@ def writeback_spec_task(
|
|
|
8554
7008
|
run_id=resolved_task_id,
|
|
8555
7009
|
idempotency_key=idempotency_key,
|
|
8556
7010
|
),
|
|
7011
|
+
inspection=inspection,
|
|
8557
7012
|
)
|
|
8558
7013
|
snapshot = snapshot_state(root, session_file, session)
|
|
8559
7014
|
snapshot["spec_writeback"] = acknowledgment
|
|
@@ -8575,7 +7030,7 @@ def writeback_spec_step(
|
|
|
8575
7030
|
*, event_agent: str | None = None,
|
|
8576
7031
|
) -> dict:
|
|
8577
7032
|
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
8578
|
-
require_spec_context(root, task, agent, session_file)
|
|
7033
|
+
inspection = require_spec_context(root, task, agent, session_file)
|
|
8579
7034
|
writer_agent = event_agent or agent
|
|
8580
7035
|
if source_task_id not in set(task.get("selected_spec_tasks") or []):
|
|
8581
7036
|
raise StateError("Canonical source task is outside the Harness task selection.")
|
|
@@ -8609,6 +7064,7 @@ def writeback_spec_step(
|
|
|
8609
7064
|
run_id=resolved_task_id,
|
|
8610
7065
|
idempotency_key=idempotency_key,
|
|
8611
7066
|
),
|
|
7067
|
+
inspection=inspection,
|
|
8612
7068
|
)
|
|
8613
7069
|
snapshot = snapshot_state(root, session_file, session)
|
|
8614
7070
|
snapshot["spec_writeback"] = acknowledgment
|
|
@@ -8630,7 +7086,7 @@ def writeback_spec_dependency(
|
|
|
8630
7086
|
*, event_agent: str | None = None,
|
|
8631
7087
|
) -> dict:
|
|
8632
7088
|
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
8633
|
-
require_spec_context(root, task, agent, session_file, allow_pending_hard_dependencies=True)
|
|
7089
|
+
inspection = require_spec_context(root, task, agent, session_file, allow_pending_hard_dependencies=True)
|
|
8634
7090
|
writer_agent = event_agent or agent
|
|
8635
7091
|
if source_task_id not in set(task.get("selected_spec_tasks") or []):
|
|
8636
7092
|
raise StateError("Canonical source task is outside the Harness task selection.")
|
|
@@ -8664,6 +7120,7 @@ def writeback_spec_dependency(
|
|
|
8664
7120
|
run_id=resolved_task_id,
|
|
8665
7121
|
idempotency_key=idempotency_key,
|
|
8666
7122
|
),
|
|
7123
|
+
inspection=inspection,
|
|
8667
7124
|
)
|
|
8668
7125
|
snapshot = snapshot_state(root, session_file, session)
|
|
8669
7126
|
snapshot["spec_writeback"] = acknowledgment
|
|
@@ -9150,6 +7607,7 @@ def sync_spec_design_state(
|
|
|
9150
7607
|
try:
|
|
9151
7608
|
from easy_dev_spec_protocol import split_execution_region
|
|
9152
7609
|
|
|
7610
|
+
|
|
9153
7611
|
_, execution = split_execution_region(spec_path.read_text(encoding="utf-8"))
|
|
9154
7612
|
except (OSError, UnicodeError, ValueError) as exc:
|
|
9155
7613
|
raise StateError(f"Cannot inspect pre-sync Canonical execution state: {exc}") from exc
|
|
@@ -10347,6 +8805,7 @@ def auto_transition(
|
|
|
10347
8805
|
agent,
|
|
10348
8806
|
approval_mode,
|
|
10349
8807
|
"approval-policy",
|
|
8808
|
+
drift=drift,
|
|
10350
8809
|
)
|
|
10351
8810
|
|
|
10352
8811
|
snapshot = apply_transition(root, stage, agent, task_id, session_file)
|
|
@@ -10460,46 +8919,6 @@ def memory_short_complete(
|
|
|
10460
8919
|
memory_file.strip(),
|
|
10461
8920
|
require_current_id=True,
|
|
10462
8921
|
)
|
|
10463
|
-
acceptance = latest_acceptance_record(root, resolved_task_id, task)
|
|
10464
|
-
if isinstance(acceptance, dict) and acceptance.get("changed_files"):
|
|
10465
|
-
try:
|
|
10466
|
-
memory_text = resolved_memory_path.read_text(encoding="utf-8")
|
|
10467
|
-
except (OSError, UnicodeError) as exc:
|
|
10468
|
-
raise StateError(f"Cannot read short-memory file: {resolved_memory_path}") from exc
|
|
10469
|
-
required_decision_fields = {
|
|
10470
|
-
"diff_sha256": str(acceptance.get("diff_sha256") or ""),
|
|
10471
|
-
"authorization": str(acceptance.get("authorization") or ""),
|
|
10472
|
-
"approval_mode": str(acceptance.get("approval_mode") or ""),
|
|
10473
|
-
"review_policy": str(acceptance.get("review_policy") or ""),
|
|
10474
|
-
"verification_policy": str(acceptance.get("verification_policy") or ""),
|
|
10475
|
-
"summary": str(acceptance.get("summary") or ""),
|
|
10476
|
-
}
|
|
10477
|
-
missing_decision_fields = [
|
|
10478
|
-
field_name
|
|
10479
|
-
for field_name, value in required_decision_fields.items()
|
|
10480
|
-
if not value or value not in memory_text
|
|
10481
|
-
]
|
|
10482
|
-
missing_changed_files = [
|
|
10483
|
-
str(file_name)
|
|
10484
|
-
for file_name in acceptance.get("changed_files", [])
|
|
10485
|
-
if not is_non_empty_string(file_name) or str(file_name) not in memory_text
|
|
10486
|
-
]
|
|
10487
|
-
missing_targeted_tasks = [
|
|
10488
|
-
str(source_task_id)
|
|
10489
|
-
for source_task_id in acceptance.get("required_targeted_source_tasks", [])
|
|
10490
|
-
if not is_non_empty_string(source_task_id)
|
|
10491
|
-
or str(source_task_id) not in memory_text
|
|
10492
|
-
]
|
|
10493
|
-
if missing_decision_fields or missing_changed_files or missing_targeted_tasks:
|
|
10494
|
-
missing_labels = [
|
|
10495
|
-
*missing_decision_fields,
|
|
10496
|
-
*(f"changed_file:{file_name}" for file_name in missing_changed_files),
|
|
10497
|
-
*(f"targeted_source_task:{task_name}" for task_name in missing_targeted_tasks),
|
|
10498
|
-
]
|
|
10499
|
-
raise StateError(
|
|
10500
|
-
"Short memory must record the complete accepted post-quality decision; "
|
|
10501
|
-
"missing: " + ", ".join(missing_labels)
|
|
10502
|
-
)
|
|
10503
8922
|
progress = task.get("memory_progress")
|
|
10504
8923
|
if not isinstance(progress, dict):
|
|
10505
8924
|
progress = {}
|
|
@@ -10690,32 +9109,6 @@ def set_repo_path(
|
|
|
10690
9109
|
return {"task_id": str(resolved_task_id), "repo_paths": repo_paths}
|
|
10691
9110
|
|
|
10692
9111
|
|
|
10693
|
-
def record_seen_stage(
|
|
10694
|
-
root: Path,
|
|
10695
|
-
task_id: str | None,
|
|
10696
|
-
stage: str,
|
|
10697
|
-
session_file: str | Path | None = None,
|
|
10698
|
-
) -> str | None:
|
|
10699
|
-
if not task_id or stage in {"idle", "MISSING"}:
|
|
10700
|
-
return None
|
|
10701
|
-
session = ensure_session(root, session_file)
|
|
10702
|
-
last_seen_task = session.get("last_seen_task")
|
|
10703
|
-
last_seen_stage = session.get("last_seen_stage")
|
|
10704
|
-
|
|
10705
|
-
violation = None
|
|
10706
|
-
if last_seen_task == task_id and last_seen_stage:
|
|
10707
|
-
task = load_task(root, task_id)
|
|
10708
|
-
task_type = str(task.get("type") or "") if task else ""
|
|
10709
|
-
violation = validate_transition(str(last_seen_stage), stage, task_type, task)
|
|
10710
|
-
|
|
10711
|
-
if last_seen_task != task_id or last_seen_stage != stage:
|
|
10712
|
-
session["last_seen_task"] = task_id
|
|
10713
|
-
session["last_seen_stage"] = stage
|
|
10714
|
-
write_session(root, session, session_file)
|
|
10715
|
-
|
|
10716
|
-
return violation
|
|
10717
|
-
|
|
10718
|
-
|
|
10719
9112
|
def resolve_root(cwd: str | None) -> Path:
|
|
10720
9113
|
root = find_ec_root(Path(cwd or os.getcwd()))
|
|
10721
9114
|
if root is None:
|
|
@@ -11564,6 +9957,13 @@ def main() -> int:
|
|
|
11564
9957
|
if isinstance(results, list) else record_check(root, task_id, task, args.prepared_id, results, agent))
|
|
11565
9958
|
else:
|
|
11566
9959
|
result = begin_correction(root, task_id, task, args.file, args.summary, args.risk, agent)
|
|
9960
|
+
if command in {"prepare-check", "record-check"}:
|
|
9961
|
+
for item in result if isinstance(result, list) else [result]:
|
|
9962
|
+
item["task_id"] = task_id
|
|
9963
|
+
item["stage"] = task.get("status")
|
|
9964
|
+
item["next_action"] = (
|
|
9965
|
+
"reuse-check" if item.get("reusable") else "run-check"
|
|
9966
|
+
) if command == "prepare-check" else "continue"
|
|
11567
9967
|
emit(result)
|
|
11568
9968
|
elif command in {"start-quality-repair", "complete-quality-repair"}:
|
|
11569
9969
|
if command == "start-quality-repair":
|