easy-coding-harness 0.10.0-beta.1 → 0.10.0-beta.10
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 +151 -0
- package/README.md +50 -20
- package/dist/cli.js +478 -47
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/templates/claude/agents/ec-implementer.md +11 -0
- package/templates/claude/agents/ec-reviewer.md +6 -1
- package/templates/codex/agents/ec-implementer.toml +11 -0
- package/templates/codex/agents/ec-reviewer.toml +6 -1
- package/templates/common/bundled-skills/ec-init/SKILL.md +18 -6
- package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +27 -12
- package/templates/common/bundled-skills/ec-meta/references/platform-files/README.md +1 -1
- package/templates/common/skills/ec-analysis/SKILL.md +141 -35
- package/templates/common/skills/ec-config/SKILL.md +24 -2
- package/templates/common/skills/ec-git/SKILL.md +7 -1
- package/templates/common/skills/ec-implementing/SKILL.md +61 -1
- package/templates/common/skills/ec-memory/SKILL.md +76 -6
- package/templates/common/skills/ec-reviewing/SKILL.md +21 -3
- package/templates/common/skills/ec-task-close/SKILL.md +4 -0
- package/templates/common/skills/ec-task-management/SKILL.md +7 -1
- package/templates/common/skills/ec-tdd-init/SKILL.md +101 -0
- package/templates/common/skills/ec-verification/SKILL.md +68 -26
- package/templates/common/skills/ec-workflow/SKILL.md +81 -22
- package/templates/main-constraint/AGENTS.md.tpl +49 -14
- package/templates/main-constraint/CLAUDE.md.tpl +46 -14
- package/templates/qoder/agents/ec-implementer.md +11 -0
- package/templates/qoder/agents/ec-reviewer.md +6 -1
- package/templates/runtime/templates/dev-spec-skeleton.md +8 -1
- package/templates/runtime/tools/easy_coding_tdd_readiness.py +306 -0
- package/templates/shared-hooks/easy_coding_state.py +3878 -259
- package/templates/shared-hooks/easy_dev_spec.py +444 -30
- package/templates/shared-hooks/easy_dev_spec_execution.py +1014 -0
- package/templates/shared-hooks/easy_dev_spec_protocol.py +1426 -18
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
import argparse
|
|
3
|
+
import base64
|
|
4
|
+
import difflib
|
|
3
5
|
import hashlib
|
|
4
6
|
import json
|
|
5
7
|
import os
|
|
@@ -7,6 +9,7 @@ import re
|
|
|
7
9
|
import secrets
|
|
8
10
|
import shlex
|
|
9
11
|
import subprocess
|
|
12
|
+
import tempfile
|
|
10
13
|
import time
|
|
11
14
|
import uuid
|
|
12
15
|
from datetime import datetime, timezone
|
|
@@ -15,11 +18,23 @@ import sys
|
|
|
15
18
|
|
|
16
19
|
from easy_dev_spec import (
|
|
17
20
|
EasyDevSpecError,
|
|
21
|
+
inspect_manifest,
|
|
18
22
|
inspect_spec,
|
|
19
23
|
inspection_summary,
|
|
20
24
|
select_consumption_scopes,
|
|
21
25
|
select_tasks,
|
|
22
26
|
)
|
|
27
|
+
from easy_dev_spec_execution import (
|
|
28
|
+
ExecutionConflictError,
|
|
29
|
+
ExecutionStateError,
|
|
30
|
+
initialize_execution,
|
|
31
|
+
record_dependency_status,
|
|
32
|
+
record_step_status,
|
|
33
|
+
record_task_status,
|
|
34
|
+
show_execution,
|
|
35
|
+
sync_design,
|
|
36
|
+
)
|
|
37
|
+
from easy_dev_spec_protocol import split_execution_region
|
|
23
38
|
|
|
24
39
|
|
|
25
40
|
TERMINAL_STATUSES = {"COMPLETE", "CLOSED"}
|
|
@@ -38,6 +53,7 @@ MANDATORY_DEV_SPEC_HEADERS: list[str] = [
|
|
|
38
53
|
"### 需求解析",
|
|
39
54
|
"### 现状",
|
|
40
55
|
"### 冲突摘要",
|
|
56
|
+
"### 决策闭环",
|
|
41
57
|
"### 影响面分析",
|
|
42
58
|
"### 改动范围",
|
|
43
59
|
"### 修改方案",
|
|
@@ -66,22 +82,45 @@ ALWAYS_AUTO_TRANSITIONS = {
|
|
|
66
82
|
}
|
|
67
83
|
READ_ONLY_COMPLETION_TRANSITION = ("IMPLEMENT", "COMPLETE")
|
|
68
84
|
NO_CODE_TASK_TYPES = {"analysis", "doc", "report"}
|
|
85
|
+
TDD_INIT_TASK_TYPE = "tdd-init"
|
|
69
86
|
APPROVAL_MODES = {"approve", "guard", "confirm", "auto"}
|
|
70
87
|
CONFIGURED_WORKFLOW_MODES = {"adaptive", "fast", "standard", "strict"}
|
|
71
88
|
WORKFLOW_MODES = {"fast", "standard", "strict"}
|
|
72
89
|
WORKFLOW_MODE_RANK = {"fast": 0, "standard": 1, "strict": 2}
|
|
73
90
|
STRICT_VERIFICATION_CHECK_TYPES = {"lint", "typecheck", "test", "build"}
|
|
74
91
|
REVIEW_FINDING_SEVERITIES = {"error", "warning", "info"}
|
|
75
|
-
|
|
76
|
-
r"(
|
|
77
|
-
r"
|
|
78
|
-
r"
|
|
92
|
+
HIGH_WORKFLOW_RISK_PATTERN = re.compile(
|
|
93
|
+
r"(\bhigh[-_ ]?risk\b|\bcritical\b|\bsevere\b|\birreversible\b|"
|
|
94
|
+
r"\bdata[-_ ]?loss\b|\bfinancial[-_ ]?loss\b|"
|
|
95
|
+
r"\bsecurity[-_ ]?(boundary|breach)\b|\bprivilege[-_ ]?escalation\b|"
|
|
96
|
+
r"\bproduction[-_ ]?outage\b|"
|
|
97
|
+
r"高风险|严重|不可逆|数据丢失|资损|安全边界|安全事件|权限提升|生产故障)",
|
|
98
|
+
re.IGNORECASE,
|
|
99
|
+
)
|
|
100
|
+
NEGATED_HIGH_WORKFLOW_RISK_PATTERN = re.compile(
|
|
101
|
+
r"(\b(?:non[-_ ]?|not[-_ ]+|no[-_ ]+)(?:high[-_ ]?risk|critical|severe|irreversible)\b|"
|
|
102
|
+
r"\b(?:no|without)[-_ ]+(?:risk[-_ ]+of[-_ ]+)?(?:data[-_ ]?loss|"
|
|
103
|
+
r"financial[-_ ]?loss|security[-_ ]?breach|production[-_ ]?outage)\b|"
|
|
104
|
+
r"低风险|非高风险|不严重|(?<!不)可逆|无(?:数据丢失|资损|安全事件|生产故障)|"
|
|
105
|
+
r"不会导致(?:数据丢失|资损|安全事件|生产故障))",
|
|
106
|
+
re.IGNORECASE,
|
|
107
|
+
)
|
|
108
|
+
WIDE_WORKFLOW_CONTRACT_PATTERN = re.compile(
|
|
109
|
+
r"(cross[-_ ]?repo|public[-_ ]?(api|contract)|跨仓|公共接口|公共契约)",
|
|
79
110
|
re.IGNORECASE,
|
|
80
111
|
)
|
|
81
112
|
DEFAULT_APPROVAL_MODE = "guard"
|
|
82
113
|
DEFAULT_WORKFLOW_MODE = "adaptive"
|
|
83
114
|
DEFAULT_TDD_ENABLED = False
|
|
84
115
|
DEFAULT_TDD_COVERAGE_THRESHOLD = 90
|
|
116
|
+
TDD_READINESS_SCHEMA = "easy-coding/tdd-readiness-v1"
|
|
117
|
+
TDD_READINESS_SCOPE = "changed-production-lines"
|
|
118
|
+
TDD_READINESS_PATH = Path(".easy-coding/tdd/readiness.json")
|
|
119
|
+
TDD_BASE_VARIABLE = "EASY_CODING_TDD_BASE_SHA"
|
|
120
|
+
TDD_THRESHOLD_VARIABLE = "EASY_CODING_TDD_THRESHOLD"
|
|
121
|
+
COVERAGE_TOOL_PATH = ".easy-coding/tools/easy_coding_java_coverage.py"
|
|
122
|
+
JAVA_BUILD_FILE_NAMES = {"pom.xml", "build.gradle", "build.gradle.kts"}
|
|
123
|
+
GITLAB_CI_ENTRY_FILES = {".gitlab-ci.yml", ".gitlab-ci.yaml"}
|
|
85
124
|
CRITICAL_CONFIRM_TRANSITIONS = {
|
|
86
125
|
("ANALYSIS", "IMPLEMENT"),
|
|
87
126
|
("VERIFICATION", "MEMORY"),
|
|
@@ -96,10 +135,30 @@ LEGACY_STAGE_MAP = {
|
|
|
96
135
|
|
|
97
136
|
DEFAULT_SHORT_TERM_MAX = 10
|
|
98
137
|
DEFAULT_SHORT_TERM_KEEP = 5
|
|
99
|
-
|
|
138
|
+
# 架构认知正文的项目相对路径,用于冻结与复核 ABSTRACT 内容指纹。
|
|
139
|
+
ARCHITECTURE_ABSTRACT_PATH = Path(".easy-coding/ABSTRACT.md")
|
|
140
|
+
# 架构认知变更日志的项目相对路径,用于验证 backfill/update 留下审计记录。
|
|
141
|
+
ARCHITECTURE_CHANGELOG_PATH = Path(".easy-coding/CHANGELOG.md")
|
|
142
|
+
# MEMORY 架构评估唯一允许的动作集合;状态 API 和 CLI 参数共享该契约。
|
|
143
|
+
ARCHITECTURE_ACTIONS = {"no-op", "backfill", "update"}
|
|
144
|
+
ACCEPTANCE_SNAPSHOT_SCHEMA = 1
|
|
145
|
+
ACCEPTANCE_VERIFICATION_POLICIES = {"carry-forward", "targeted", "waived"}
|
|
146
|
+
SESSION_IDLE_RETENTION_HOURS = 7 * 24
|
|
147
|
+
SESSION_ATTACHED_RETENTION_HOURS = 30 * 24
|
|
148
|
+
MAX_SESSION_FILES = 100
|
|
100
149
|
SESSION_COMPONENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
150
|
+
WORKFLOW_AGENT_IDENTITIES = {"claude-code", "codex", "qoder"}
|
|
151
|
+
# 安装时固化的宿主身份是生产事实源;未渲染源码保留占位符供本仓测试直接加载。
|
|
152
|
+
INSTALLED_WORKFLOW_AGENT = "{{workflow_agent_id}}"
|
|
101
153
|
SESSION_AGENT_NAMESPACES = {"claude-code", "codex", "qoder", "unknown"}
|
|
102
154
|
CODEX_AGENT_PATH_PATTERN = re.compile(r"^/?root(?:/[a-z0-9._-]+)*$")
|
|
155
|
+
LEGACY_DISPLAY_AGENT_IDENTITIES = {
|
|
156
|
+
"claude with easy coding": "claude-code",
|
|
157
|
+
"claude-code with easy coding": "claude-code",
|
|
158
|
+
"claude code with easy coding": "claude-code",
|
|
159
|
+
"codex with easy coding": "codex",
|
|
160
|
+
"qoder with easy coding": "qoder",
|
|
161
|
+
}
|
|
103
162
|
LEGACY_STATE_LOCK_TIMEOUT_SECONDS = 5.0
|
|
104
163
|
LEGACY_STATE_LOCK_STALE_SECONDS = 60.0
|
|
105
164
|
LEGACY_STATE_LOCK_POLL_SECONDS = 0.02
|
|
@@ -108,6 +167,19 @@ SHORT_MEMORY_UUID_V7_PATTERN = re.compile(
|
|
|
108
167
|
)
|
|
109
168
|
LEGACY_SHORT_MEMORY_ID_PATTERN = re.compile(r"^SM-\d{8}-\d+$")
|
|
110
169
|
DEV_SPEC_PLACEHOLDER_PATTERN = re.compile(r"\[\[EC_TODO:[^\]\n]+\]\]")
|
|
170
|
+
DECISION_STATUS_PATTERN = re.compile(
|
|
171
|
+
r"\s*decision_status\s*:\s*([a-z][a-z0-9_-]*)\s*", re.IGNORECASE
|
|
172
|
+
)
|
|
173
|
+
DECISION_CONCLUSIONS_PATTERN = re.compile(
|
|
174
|
+
r"\s*(?:[-+*]\s+)?(?:\*\*)?已解决问题与结论(?:\*\*)?\s*[::]\s*(.+?)\s*"
|
|
175
|
+
)
|
|
176
|
+
DECISION_EVIDENCE_PATTERN = re.compile(
|
|
177
|
+
r"\s*(?:[-+*]\s+)?(?:\*\*)?确认依据(?:\*\*)?\s*[::]\s*(.+?)\s*"
|
|
178
|
+
)
|
|
179
|
+
UNRESOLVED_DECISION_VALUE_PATTERN = re.compile(
|
|
180
|
+
r"(?:待确认|待决策|未确认|未决|todo|tbd|unknown|open|pending|unresolved)[。.!!]?",
|
|
181
|
+
re.IGNORECASE,
|
|
182
|
+
)
|
|
111
183
|
MARKDOWN_HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
|
|
112
184
|
TABLE_HEADER_CELLS = {
|
|
113
185
|
"改动文件",
|
|
@@ -166,14 +238,25 @@ def short_memory_id_sort_key(memory_id: str) -> tuple[int, str]:
|
|
|
166
238
|
return (2, memory_id)
|
|
167
239
|
|
|
168
240
|
|
|
169
|
-
def
|
|
241
|
+
def canonical_agent_identity(agent: str | None, allow_legacy_display: bool = False) -> str | None:
|
|
170
242
|
raw_agent = str(agent or "unknown").strip()
|
|
171
243
|
normalized = raw_agent.lower()
|
|
172
244
|
# Codex 可能把根执行者写成 root 或 /root;两者及其协作子路径都属于同一平台身份。
|
|
173
245
|
if CODEX_AGENT_PATH_PATTERN.fullmatch(normalized):
|
|
174
246
|
return "codex"
|
|
175
|
-
if normalized in
|
|
247
|
+
if normalized in WORKFLOW_AGENT_IDENTITIES:
|
|
176
248
|
return normalized
|
|
249
|
+
if allow_legacy_display:
|
|
250
|
+
return LEGACY_DISPLAY_AGENT_IDENTITIES.get(normalized)
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def normalize_agent_identity(agent: str | None) -> str:
|
|
255
|
+
raw_agent = str(agent or "unknown").strip()
|
|
256
|
+
# 旧数据可能误把展示署名写入 owner;只在读取兼容边界将其还原为规范身份。
|
|
257
|
+
canonical = canonical_agent_identity(raw_agent, allow_legacy_display=True)
|
|
258
|
+
if canonical is not None:
|
|
259
|
+
return canonical
|
|
177
260
|
return raw_agent
|
|
178
261
|
|
|
179
262
|
|
|
@@ -187,6 +270,9 @@ def agents_equivalent(first: str | None, second: str | None) -> bool:
|
|
|
187
270
|
|
|
188
271
|
|
|
189
272
|
def detect_runtime_agent() -> str:
|
|
273
|
+
if INSTALLED_WORKFLOW_AGENT in WORKFLOW_AGENT_IDENTITIES:
|
|
274
|
+
return INSTALLED_WORKFLOW_AGENT
|
|
275
|
+
# 仅供未渲染源码和旧安装兼容;新安装脚本始终走上面的固化身份。
|
|
190
276
|
script_path = Path(sys.argv[0]).as_posix()
|
|
191
277
|
if ".qoder/" in script_path or ".qodercn/" in script_path:
|
|
192
278
|
return "qoder"
|
|
@@ -202,6 +288,45 @@ def detect_runtime_agent() -> str:
|
|
|
202
288
|
return "unknown"
|
|
203
289
|
|
|
204
290
|
|
|
291
|
+
def resolve_state_agent(explicit_agent: str | None) -> str:
|
|
292
|
+
runtime_agent = detect_runtime_agent()
|
|
293
|
+
explicit_identity = None
|
|
294
|
+
if explicit_agent is not None:
|
|
295
|
+
explicit_identity = canonical_agent_identity(explicit_agent)
|
|
296
|
+
if explicit_identity is None:
|
|
297
|
+
raise StateError(
|
|
298
|
+
"Workflow --agent must be one of claude-code, codex, or qoder; "
|
|
299
|
+
"display attribution such as 'Codex with Easy Coding' is not an agent identity."
|
|
300
|
+
)
|
|
301
|
+
if runtime_agent in WORKFLOW_AGENT_IDENTITIES:
|
|
302
|
+
if explicit_identity is not None and explicit_identity != runtime_agent:
|
|
303
|
+
raise StateError(
|
|
304
|
+
f"Workflow agent mismatch: script belongs to {runtime_agent}, "
|
|
305
|
+
f"but --agent resolved to {explicit_identity}. Use the active platform's state script."
|
|
306
|
+
)
|
|
307
|
+
return runtime_agent
|
|
308
|
+
return explicit_identity or "unknown"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def validate_session_agent(agent: str, session_file: str | Path | None) -> None:
|
|
312
|
+
if session_file is None or agent not in WORKFLOW_AGENT_IDENTITIES:
|
|
313
|
+
return
|
|
314
|
+
session_name = Path(str(session_file)).name
|
|
315
|
+
session_agent = next(
|
|
316
|
+
(
|
|
317
|
+
candidate
|
|
318
|
+
for candidate in WORKFLOW_AGENT_IDENTITIES
|
|
319
|
+
if session_name.startswith(f"{candidate}-")
|
|
320
|
+
),
|
|
321
|
+
None,
|
|
322
|
+
)
|
|
323
|
+
if session_agent is not None and session_agent != agent:
|
|
324
|
+
raise StateError(
|
|
325
|
+
f"Workflow session mismatch: session belongs to {session_agent}, "
|
|
326
|
+
f"but the state operation resolved to {agent}. Use the active session's state script."
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
205
330
|
def normalize_session_component(value: str) -> str:
|
|
206
331
|
if (
|
|
207
332
|
value not in {".", ".."}
|
|
@@ -399,7 +524,11 @@ def read_project_behavior(root: Path) -> tuple[str, str, bool, int]:
|
|
|
399
524
|
"expected adaptive, fast, standard, or strict."
|
|
400
525
|
)
|
|
401
526
|
if schema_version >= 4:
|
|
402
|
-
tdd_enabled =
|
|
527
|
+
tdd_enabled = (
|
|
528
|
+
parse_yaml_bool(behavior.get("tdd_enabled"), "behavior.tdd_enabled")
|
|
529
|
+
if schema_version >= 5
|
|
530
|
+
else DEFAULT_TDD_ENABLED
|
|
531
|
+
)
|
|
403
532
|
tdd_threshold = parse_tdd_threshold(
|
|
404
533
|
behavior.get("tdd_coverage_threshold", DEFAULT_TDD_COVERAGE_THRESHOLD),
|
|
405
534
|
"behavior.tdd_coverage_threshold",
|
|
@@ -410,6 +539,175 @@ def read_project_behavior(root: Path) -> tuple[str, str, bool, int]:
|
|
|
410
539
|
return approval_mode, workflow_mode, tdd_enabled, tdd_threshold
|
|
411
540
|
|
|
412
541
|
|
|
542
|
+
def safe_tdd_report_pattern(value: object) -> bool:
|
|
543
|
+
if not is_non_empty_string(value):
|
|
544
|
+
return False
|
|
545
|
+
candidate = Path(str(value))
|
|
546
|
+
return not candidate.is_absolute() and ".." not in candidate.parts
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def tdd_gate_uses_task_variables(command: object) -> bool:
|
|
550
|
+
if not is_non_empty_string(command):
|
|
551
|
+
return False
|
|
552
|
+
try:
|
|
553
|
+
tokens = shlex.split(str(command))
|
|
554
|
+
except ValueError:
|
|
555
|
+
return False
|
|
556
|
+
options: dict[str, str] = {}
|
|
557
|
+
for index, token in enumerate(tokens[:-1]):
|
|
558
|
+
if token in {"--base", "--threshold"}:
|
|
559
|
+
options[token] = tokens[index + 1]
|
|
560
|
+
return options.get("--base") in {
|
|
561
|
+
f"${TDD_BASE_VARIABLE}",
|
|
562
|
+
"$" + "{" + TDD_BASE_VARIABLE + "}",
|
|
563
|
+
} and options.get("--threshold") in {
|
|
564
|
+
f"${TDD_THRESHOLD_VARIABLE}",
|
|
565
|
+
"$" + "{" + TDD_THRESHOLD_VARIABLE + "}",
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def tdd_ci_contract_reasons(contents: list[str]) -> list[str]:
|
|
570
|
+
combined = "\n".join(
|
|
571
|
+
re.sub(r"\s+#.*$", "", re.sub(r"^\s*#.*$", "", line))
|
|
572
|
+
for line in "\n".join(contents).splitlines()
|
|
573
|
+
)
|
|
574
|
+
lowered = combined.lower()
|
|
575
|
+
reasons: list[str] = []
|
|
576
|
+
for marker in (
|
|
577
|
+
"jacoco",
|
|
578
|
+
"artifacts",
|
|
579
|
+
COVERAGE_TOOL_PATH,
|
|
580
|
+
TDD_BASE_VARIABLE,
|
|
581
|
+
TDD_THRESHOLD_VARIABLE,
|
|
582
|
+
):
|
|
583
|
+
if marker.lower() not in lowered:
|
|
584
|
+
reasons.append(f"CI files do not contain required marker: {marker}")
|
|
585
|
+
if not tdd_gate_uses_task_variables(combined):
|
|
586
|
+
reasons.append(
|
|
587
|
+
"CI changed-line gate must use the task baseline and threshold variables"
|
|
588
|
+
)
|
|
589
|
+
if re.search(
|
|
590
|
+
r"(?:^|\n)\s*stage\s*:\s*['\"]?test['\"]?\s*(?:#.*)?(?:\n|$)",
|
|
591
|
+
combined,
|
|
592
|
+
re.IGNORECASE,
|
|
593
|
+
) is None:
|
|
594
|
+
reasons.append("CI files do not declare a TEST-stage job")
|
|
595
|
+
return reasons
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def tdd_readiness(root: Path) -> dict[str, object]:
|
|
599
|
+
receipt = root / TDD_READINESS_PATH
|
|
600
|
+
if not receipt.is_file():
|
|
601
|
+
return {"status": "needs_init", "reasons": ["TDD readiness receipt is missing"]}
|
|
602
|
+
try:
|
|
603
|
+
manifest = json.loads(receipt.read_text(encoding="utf-8"))
|
|
604
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
605
|
+
return {"status": "needs_init", "reasons": ["TDD readiness receipt is invalid"]}
|
|
606
|
+
if not isinstance(manifest, dict):
|
|
607
|
+
return {
|
|
608
|
+
"status": "needs_init",
|
|
609
|
+
"reasons": ["TDD readiness receipt must be a JSON object"],
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
reasons: list[str] = []
|
|
613
|
+
if manifest.get("schema") != TDD_READINESS_SCHEMA:
|
|
614
|
+
reasons.append("unsupported readiness schema")
|
|
615
|
+
if manifest.get("provider") != "gitlab":
|
|
616
|
+
reasons.append("readiness provider must be gitlab")
|
|
617
|
+
if manifest.get("coverage_scope") != TDD_READINESS_SCOPE:
|
|
618
|
+
reasons.append("coverage scope must be changed-production-lines")
|
|
619
|
+
if manifest.get("historical_coverage_required") is not False:
|
|
620
|
+
reasons.append("historical coverage must remain disabled")
|
|
621
|
+
reports = manifest.get("coverage_report_patterns")
|
|
622
|
+
if not isinstance(reports, list) or not reports or not all(
|
|
623
|
+
safe_tdd_report_pattern(item) for item in reports
|
|
624
|
+
):
|
|
625
|
+
reasons.append(
|
|
626
|
+
"coverage_report_patterns must contain safe project-relative report patterns"
|
|
627
|
+
)
|
|
628
|
+
gate = manifest.get("changed_line_gate_command")
|
|
629
|
+
if not is_non_empty_string(gate) or COVERAGE_TOOL_PATH not in str(gate):
|
|
630
|
+
reasons.append("changed-line coverage gate command is missing")
|
|
631
|
+
elif not tdd_gate_uses_task_variables(gate):
|
|
632
|
+
reasons.append(
|
|
633
|
+
"changed-line coverage gate must use the task baseline and threshold variables"
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
contents: dict[str, list[str]] = {
|
|
637
|
+
"build_files": [],
|
|
638
|
+
"ci_files": [],
|
|
639
|
+
"tool_files": [],
|
|
640
|
+
}
|
|
641
|
+
for field in contents:
|
|
642
|
+
records = manifest.get(field)
|
|
643
|
+
if not isinstance(records, list) or not records:
|
|
644
|
+
reasons.append(f"{field} must contain at least one file")
|
|
645
|
+
continue
|
|
646
|
+
for record in records:
|
|
647
|
+
if not isinstance(record, dict):
|
|
648
|
+
reasons.append(f"{field} contains an invalid record")
|
|
649
|
+
continue
|
|
650
|
+
file_name = record.get("path")
|
|
651
|
+
expected = record.get("sha256")
|
|
652
|
+
if not is_non_empty_string(file_name) or not re.fullmatch(
|
|
653
|
+
r"[a-f0-9]{64}", str(expected or "")
|
|
654
|
+
):
|
|
655
|
+
reasons.append(f"{field} contains an invalid path or SHA-256")
|
|
656
|
+
continue
|
|
657
|
+
candidate = Path(str(file_name))
|
|
658
|
+
if candidate.is_absolute():
|
|
659
|
+
reasons.append(f"readiness file must be project-relative: {file_name}")
|
|
660
|
+
continue
|
|
661
|
+
resolved = (root / candidate).resolve()
|
|
662
|
+
try:
|
|
663
|
+
resolved.relative_to(root.resolve())
|
|
664
|
+
payload = resolved.read_bytes()
|
|
665
|
+
contents[field].append(payload.decode("utf-8"))
|
|
666
|
+
if hashlib.sha256(payload).hexdigest() != expected:
|
|
667
|
+
reasons.append(f"readiness file changed: {file_name}")
|
|
668
|
+
except (OSError, UnicodeError, ValueError):
|
|
669
|
+
reasons.append(f"readiness file is missing or unreadable: {file_name}")
|
|
670
|
+
|
|
671
|
+
manifest_build_files = manifest.get("build_files")
|
|
672
|
+
manifest_ci_files = manifest.get("ci_files")
|
|
673
|
+
manifest_tool_files = manifest.get("tool_files")
|
|
674
|
+
build_paths = {
|
|
675
|
+
Path(str(item.get("path", ""))).name
|
|
676
|
+
for item in manifest_build_files
|
|
677
|
+
if isinstance(item, dict) and is_non_empty_string(item.get("path"))
|
|
678
|
+
} if isinstance(manifest_build_files, list) else set()
|
|
679
|
+
ci_paths = {
|
|
680
|
+
str(item.get("path", "")).replace("\\", "/")
|
|
681
|
+
for item in manifest_ci_files
|
|
682
|
+
if isinstance(item, dict) and is_non_empty_string(item.get("path"))
|
|
683
|
+
} if isinstance(manifest_ci_files, list) else set()
|
|
684
|
+
if not build_paths.intersection(JAVA_BUILD_FILE_NAMES):
|
|
685
|
+
reasons.append("build_files must include a Maven or Gradle Java build file")
|
|
686
|
+
if not ci_paths.intersection(GITLAB_CI_ENTRY_FILES):
|
|
687
|
+
reasons.append("ci_files must include the project-root GitLab CI entry file")
|
|
688
|
+
tool_paths = {
|
|
689
|
+
str(item.get("path", "")).replace("\\", "/")
|
|
690
|
+
for item in manifest_tool_files
|
|
691
|
+
if isinstance(item, dict) and is_non_empty_string(item.get("path"))
|
|
692
|
+
} if isinstance(manifest_tool_files, list) else set()
|
|
693
|
+
if COVERAGE_TOOL_PATH not in tool_paths:
|
|
694
|
+
reasons.append(f"tool_files must include {COVERAGE_TOOL_PATH}")
|
|
695
|
+
if not any("jacoco" in content.lower() for content in contents["build_files"]):
|
|
696
|
+
reasons.append("build files do not configure JaCoCo")
|
|
697
|
+
reasons.extend(tdd_ci_contract_reasons(contents["ci_files"]))
|
|
698
|
+
return {
|
|
699
|
+
"status": "ready" if not reasons else "needs_init",
|
|
700
|
+
"reasons": list(dict.fromkeys(reasons)),
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def require_tdd_readiness(root: Path) -> None:
|
|
705
|
+
readiness = tdd_readiness(root)
|
|
706
|
+
if readiness["status"] != "ready":
|
|
707
|
+
reasons = "; ".join(str(reason) for reason in readiness["reasons"])
|
|
708
|
+
raise StateError(f"TDD cannot be enabled before ec-tdd-init succeeds: {reasons}")
|
|
709
|
+
|
|
710
|
+
|
|
413
711
|
def resolve_behavior(
|
|
414
712
|
root: Path, session: dict
|
|
415
713
|
) -> tuple[str, str | None, str, str, str | None, str, bool, bool | None, bool, int, int | None, int]:
|
|
@@ -616,6 +914,76 @@ def validate_recorded_short_memory(
|
|
|
616
914
|
validate_short_memory_file(root, task_id, memory_file, expected_sha256)
|
|
617
915
|
|
|
618
916
|
|
|
917
|
+
def architecture_asset_baseline(root: Path, relative_path: Path) -> dict:
|
|
918
|
+
path = root / relative_path
|
|
919
|
+
if not path.exists():
|
|
920
|
+
return {
|
|
921
|
+
"path": str(relative_path),
|
|
922
|
+
"exists": False,
|
|
923
|
+
"non_empty": False,
|
|
924
|
+
"sha256": None,
|
|
925
|
+
}
|
|
926
|
+
if not path.is_file():
|
|
927
|
+
raise StateError(f"Architecture asset is not a file: {relative_path}")
|
|
928
|
+
try:
|
|
929
|
+
content = path.read_text(encoding="utf-8")
|
|
930
|
+
except (OSError, UnicodeError) as error:
|
|
931
|
+
raise StateError(f"Cannot read architecture asset as UTF-8: {relative_path}") from error
|
|
932
|
+
return {
|
|
933
|
+
"path": str(relative_path),
|
|
934
|
+
"exists": True,
|
|
935
|
+
"non_empty": bool(content.strip()),
|
|
936
|
+
"sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def read_project_mode(root: Path) -> str | None:
|
|
941
|
+
project_profile = root / ".easy-coding" / "project.yaml"
|
|
942
|
+
if not project_profile.is_file():
|
|
943
|
+
return None
|
|
944
|
+
try:
|
|
945
|
+
content = project_profile.read_text(encoding="utf-8")
|
|
946
|
+
except (OSError, UnicodeError) as error:
|
|
947
|
+
raise StateError("Cannot read .easy-coding/project.yaml as UTF-8.") from error
|
|
948
|
+
for raw_line in content.splitlines():
|
|
949
|
+
match = re.fullmatch(
|
|
950
|
+
r"\s*mode\s*:\s*(['\"]?)(startup|iterative)\1\s*(?:#.*)?", raw_line
|
|
951
|
+
)
|
|
952
|
+
if match:
|
|
953
|
+
return match.group(2)
|
|
954
|
+
return None
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def build_architecture_assessment_instruction(root: Path, memory_action: str) -> dict:
|
|
958
|
+
abstract = architecture_asset_baseline(root, ARCHITECTURE_ABSTRACT_PATH)
|
|
959
|
+
changelog = architecture_asset_baseline(root, ARCHITECTURE_CHANGELOG_PATH)
|
|
960
|
+
if not abstract["non_empty"] and read_project_mode(root) == "startup":
|
|
961
|
+
required = True
|
|
962
|
+
trigger = "missing-abstract"
|
|
963
|
+
allowed_actions = ["backfill"]
|
|
964
|
+
elif not abstract["non_empty"]:
|
|
965
|
+
raise StateError(
|
|
966
|
+
"ABSTRACT.md is missing or empty outside the startup backfill exception; "
|
|
967
|
+
"run ec-init supplementary initialization before completing MEMORY."
|
|
968
|
+
)
|
|
969
|
+
elif memory_action == "distill":
|
|
970
|
+
required = True
|
|
971
|
+
trigger = "distillation"
|
|
972
|
+
allowed_actions = ["no-op", "update"]
|
|
973
|
+
else:
|
|
974
|
+
required = False
|
|
975
|
+
trigger = "none"
|
|
976
|
+
allowed_actions = []
|
|
977
|
+
instruction = {
|
|
978
|
+
"required": required,
|
|
979
|
+
"trigger": trigger,
|
|
980
|
+
"allowed_actions": allowed_actions,
|
|
981
|
+
"abstract": abstract,
|
|
982
|
+
"changelog": changelog,
|
|
983
|
+
}
|
|
984
|
+
return instruction
|
|
985
|
+
|
|
986
|
+
|
|
619
987
|
def build_memory_instruction(
|
|
620
988
|
root: Path,
|
|
621
989
|
checkpoint_file: str | None = None,
|
|
@@ -636,7 +1004,7 @@ def build_memory_instruction(
|
|
|
636
1004
|
checkpoint_disposition = "kept"
|
|
637
1005
|
else:
|
|
638
1006
|
raise StateError("Recorded short-memory checkpoint is absent from the frozen memory set.")
|
|
639
|
-
|
|
1007
|
+
instruction = {
|
|
640
1008
|
"short_count": short_count,
|
|
641
1009
|
"short_term_max": config["short_term_max"],
|
|
642
1010
|
"short_term_keep": config["short_term_keep"],
|
|
@@ -646,6 +1014,216 @@ def build_memory_instruction(
|
|
|
646
1014
|
"kept_files": kept_files,
|
|
647
1015
|
"checkpoint_disposition": checkpoint_disposition,
|
|
648
1016
|
}
|
|
1017
|
+
if not legacy_checkpoint:
|
|
1018
|
+
instruction["architecture_assessment"] = build_architecture_assessment_instruction(
|
|
1019
|
+
root, action
|
|
1020
|
+
)
|
|
1021
|
+
return instruction
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def require_architecture_instruction(instruction: dict) -> dict | None:
|
|
1025
|
+
assessment_instruction = instruction.get("architecture_assessment")
|
|
1026
|
+
if assessment_instruction is None:
|
|
1027
|
+
# 0.10.0-beta.5 之前已冻结的指令继续按旧契约完成,避免升级中断在途任务。
|
|
1028
|
+
return None
|
|
1029
|
+
if not isinstance(assessment_instruction, dict):
|
|
1030
|
+
raise StateError("Memory instruction has an invalid architecture assessment contract.")
|
|
1031
|
+
return assessment_instruction
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def validate_architecture_asset_changed(
|
|
1035
|
+
baseline: dict,
|
|
1036
|
+
current: dict,
|
|
1037
|
+
label: str,
|
|
1038
|
+
) -> None:
|
|
1039
|
+
if not current.get("exists") or not current.get("non_empty") or not current.get("sha256"):
|
|
1040
|
+
raise StateError(f"Architecture {label} must exist and be non-empty after this action.")
|
|
1041
|
+
if baseline.get("sha256") == current.get("sha256"):
|
|
1042
|
+
raise StateError(f"Architecture {label} did not change after this action.")
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def validate_architecture_assets_unchanged(root: Path, instruction: dict) -> None:
|
|
1046
|
+
for key, relative_path in (
|
|
1047
|
+
("abstract", ARCHITECTURE_ABSTRACT_PATH),
|
|
1048
|
+
("changelog", ARCHITECTURE_CHANGELOG_PATH),
|
|
1049
|
+
):
|
|
1050
|
+
baseline = instruction.get(key)
|
|
1051
|
+
if not isinstance(baseline, dict):
|
|
1052
|
+
raise StateError(f"Architecture assessment is missing the {key} baseline.")
|
|
1053
|
+
if architecture_asset_baseline(root, relative_path) != baseline:
|
|
1054
|
+
raise StateError(f"Architecture asset changed during a no-op assessment: {relative_path}")
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def validate_architecture_action_result(
|
|
1058
|
+
root: Path,
|
|
1059
|
+
instruction: dict,
|
|
1060
|
+
action: str,
|
|
1061
|
+
) -> tuple[dict, dict]:
|
|
1062
|
+
abstract_before = instruction.get("abstract")
|
|
1063
|
+
changelog_before = instruction.get("changelog")
|
|
1064
|
+
if not isinstance(abstract_before, dict) or not isinstance(changelog_before, dict):
|
|
1065
|
+
raise StateError("Architecture assessment is missing frozen asset baselines.")
|
|
1066
|
+
abstract = architecture_asset_baseline(root, ARCHITECTURE_ABSTRACT_PATH)
|
|
1067
|
+
changelog = architecture_asset_baseline(root, ARCHITECTURE_CHANGELOG_PATH)
|
|
1068
|
+
if action == "no-op":
|
|
1069
|
+
validate_architecture_assets_unchanged(root, instruction)
|
|
1070
|
+
elif action == "backfill":
|
|
1071
|
+
if abstract_before.get("non_empty") is True:
|
|
1072
|
+
raise StateError(
|
|
1073
|
+
"Architecture backfill is allowed only when ABSTRACT.md was missing or empty."
|
|
1074
|
+
)
|
|
1075
|
+
validate_architecture_asset_changed(abstract_before, abstract, "ABSTRACT.md")
|
|
1076
|
+
validate_architecture_asset_changed(changelog_before, changelog, "CHANGELOG.md")
|
|
1077
|
+
elif action == "update":
|
|
1078
|
+
if abstract_before.get("non_empty") is not True:
|
|
1079
|
+
raise StateError("Architecture update requires an existing ABSTRACT.md baseline.")
|
|
1080
|
+
validate_architecture_asset_changed(abstract_before, abstract, "ABSTRACT.md")
|
|
1081
|
+
validate_architecture_asset_changed(changelog_before, changelog, "CHANGELOG.md")
|
|
1082
|
+
else:
|
|
1083
|
+
raise StateError(f"Unknown architecture assessment action: {action}")
|
|
1084
|
+
return abstract, changelog
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def allowed_architecture_evidence(progress: dict, instruction: dict) -> set[str]:
|
|
1088
|
+
allowed_evidence = set(instruction.get("candidate_files") or [])
|
|
1089
|
+
if not allowed_evidence:
|
|
1090
|
+
checkpoint_file = progress.get("short_memory_file")
|
|
1091
|
+
if isinstance(checkpoint_file, str):
|
|
1092
|
+
allowed_evidence.add(checkpoint_file)
|
|
1093
|
+
return allowed_evidence
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def record_architecture_assessment(
|
|
1097
|
+
root: Path,
|
|
1098
|
+
action: str,
|
|
1099
|
+
reason: str,
|
|
1100
|
+
evidence: list[str],
|
|
1101
|
+
affected_sections: list[str],
|
|
1102
|
+
agent: str,
|
|
1103
|
+
task_id: str | None = None,
|
|
1104
|
+
session_file: str | Path | None = None,
|
|
1105
|
+
) -> dict:
|
|
1106
|
+
if action not in ARCHITECTURE_ACTIONS:
|
|
1107
|
+
raise StateError(f"Unknown architecture assessment action: {action}")
|
|
1108
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
1109
|
+
if task.get("status") != "MEMORY":
|
|
1110
|
+
raise StateError("Architecture assessment is only available during MEMORY.")
|
|
1111
|
+
progress = task.get("memory_progress")
|
|
1112
|
+
if not isinstance(progress, dict) or progress.get("short_memory_written") is not True:
|
|
1113
|
+
raise StateError("Short memory must be recorded before architecture assessment.")
|
|
1114
|
+
instruction = progress.get("instruction")
|
|
1115
|
+
if not isinstance(instruction, dict):
|
|
1116
|
+
raise StateError("Request the authoritative memory instruction before architecture assessment.")
|
|
1117
|
+
validate_recorded_short_memory(root, resolved_task_id, progress)
|
|
1118
|
+
for candidate_file in instruction.get("candidate_files") or []:
|
|
1119
|
+
if not resolve_short_memory_path(root, candidate_file).is_file():
|
|
1120
|
+
raise StateError(
|
|
1121
|
+
"Keep every frozen distillation candidate until architecture assessment succeeds: "
|
|
1122
|
+
f"{candidate_file}"
|
|
1123
|
+
)
|
|
1124
|
+
assessment_instruction = require_architecture_instruction(instruction)
|
|
1125
|
+
if assessment_instruction is None:
|
|
1126
|
+
raise StateError("Legacy memory instructions do not require an architecture assessment.")
|
|
1127
|
+
if assessment_instruction.get("required") is not True:
|
|
1128
|
+
raise StateError("Architecture assessment is not required for this memory instruction.")
|
|
1129
|
+
allowed_actions = assessment_instruction.get("allowed_actions")
|
|
1130
|
+
if not isinstance(allowed_actions, list) or action not in allowed_actions:
|
|
1131
|
+
raise StateError(
|
|
1132
|
+
f"Architecture action {action} is not allowed for trigger "
|
|
1133
|
+
f"{assessment_instruction.get('trigger')}."
|
|
1134
|
+
)
|
|
1135
|
+
normalized_reason = reason.strip()
|
|
1136
|
+
normalized_evidence = list(dict.fromkeys(item.strip() for item in evidence if item.strip()))
|
|
1137
|
+
normalized_sections = list(
|
|
1138
|
+
dict.fromkeys(item.strip() for item in affected_sections if item.strip())
|
|
1139
|
+
)
|
|
1140
|
+
if not normalized_reason:
|
|
1141
|
+
raise StateError("Architecture assessment requires a non-empty reason.")
|
|
1142
|
+
if not normalized_evidence:
|
|
1143
|
+
raise StateError("Architecture assessment requires at least one frozen memory evidence file.")
|
|
1144
|
+
allowed_evidence = allowed_architecture_evidence(progress, instruction)
|
|
1145
|
+
invalid_evidence = [item for item in normalized_evidence if item not in allowed_evidence]
|
|
1146
|
+
if invalid_evidence:
|
|
1147
|
+
raise StateError(
|
|
1148
|
+
"Architecture assessment evidence must come from the frozen memory set: "
|
|
1149
|
+
+ ", ".join(invalid_evidence)
|
|
1150
|
+
)
|
|
1151
|
+
if action in {"backfill", "update"} and not normalized_sections:
|
|
1152
|
+
raise StateError("Architecture backfill/update requires at least one affected section.")
|
|
1153
|
+
if action == "no-op" and normalized_sections:
|
|
1154
|
+
raise StateError("Architecture no-op must not declare affected sections.")
|
|
1155
|
+
|
|
1156
|
+
abstract, changelog = validate_architecture_action_result(
|
|
1157
|
+
root, assessment_instruction, action
|
|
1158
|
+
)
|
|
1159
|
+
|
|
1160
|
+
assessment = {
|
|
1161
|
+
"action": action,
|
|
1162
|
+
"trigger": assessment_instruction.get("trigger"),
|
|
1163
|
+
"reason": normalized_reason,
|
|
1164
|
+
"evidence": normalized_evidence,
|
|
1165
|
+
"affected_sections": normalized_sections,
|
|
1166
|
+
"abstract_sha256": abstract.get("sha256"),
|
|
1167
|
+
"changelog_sha256": changelog.get("sha256"),
|
|
1168
|
+
"recorded_at": now_iso(),
|
|
1169
|
+
"recorded_by": agent,
|
|
1170
|
+
}
|
|
1171
|
+
progress["architecture_assessment"] = assessment
|
|
1172
|
+
progress["updated_at"] = now_iso()
|
|
1173
|
+
task["memory_progress"] = progress
|
|
1174
|
+
task["last_agent"] = agent
|
|
1175
|
+
write_task(root, resolved_task_id, task)
|
|
1176
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
1177
|
+
snapshot["memory"] = instruction
|
|
1178
|
+
snapshot["architecture_assessment"] = assessment
|
|
1179
|
+
snapshot["action"] = "memory-architecture-assessment"
|
|
1180
|
+
return snapshot
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def validate_recorded_architecture_assessment(root: Path, progress: dict, instruction: dict) -> None:
|
|
1184
|
+
assessment_instruction = require_architecture_instruction(instruction)
|
|
1185
|
+
if assessment_instruction is None:
|
|
1186
|
+
return
|
|
1187
|
+
if assessment_instruction.get("required") is not True:
|
|
1188
|
+
validate_architecture_assets_unchanged(root, assessment_instruction)
|
|
1189
|
+
if progress.get("architecture_assessment") is not None:
|
|
1190
|
+
raise StateError("Unexpected architecture assessment for a no-op memory instruction.")
|
|
1191
|
+
return
|
|
1192
|
+
assessment = progress.get("architecture_assessment")
|
|
1193
|
+
if not isinstance(assessment, dict):
|
|
1194
|
+
raise StateError("Complete the required architecture assessment before MEMORY completion.")
|
|
1195
|
+
action = assessment.get("action")
|
|
1196
|
+
allowed_actions = assessment_instruction.get("allowed_actions")
|
|
1197
|
+
if not isinstance(allowed_actions, list) or action not in allowed_actions:
|
|
1198
|
+
raise StateError("Recorded architecture assessment has an invalid action.")
|
|
1199
|
+
if assessment.get("trigger") != assessment_instruction.get("trigger"):
|
|
1200
|
+
raise StateError("Recorded architecture assessment trigger does not match its instruction.")
|
|
1201
|
+
reason = assessment.get("reason")
|
|
1202
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
1203
|
+
raise StateError("Recorded architecture assessment is missing its reason.")
|
|
1204
|
+
evidence = assessment.get("evidence")
|
|
1205
|
+
if not isinstance(evidence, list) or not evidence or not all(
|
|
1206
|
+
isinstance(item, str) for item in evidence
|
|
1207
|
+
):
|
|
1208
|
+
raise StateError("Recorded architecture assessment has invalid evidence.")
|
|
1209
|
+
if any(item not in allowed_architecture_evidence(progress, instruction) for item in evidence):
|
|
1210
|
+
raise StateError("Recorded architecture assessment evidence is outside the frozen set.")
|
|
1211
|
+
affected_sections = assessment.get("affected_sections")
|
|
1212
|
+
if not isinstance(affected_sections, list) or not all(
|
|
1213
|
+
isinstance(item, str) and item.strip() for item in affected_sections
|
|
1214
|
+
):
|
|
1215
|
+
raise StateError("Recorded architecture assessment has invalid affected sections.")
|
|
1216
|
+
if action == "no-op" and affected_sections:
|
|
1217
|
+
raise StateError("Recorded architecture no-op must not declare affected sections.")
|
|
1218
|
+
if action in {"backfill", "update"} and not affected_sections:
|
|
1219
|
+
raise StateError("Recorded architecture backfill/update requires affected sections.")
|
|
1220
|
+
abstract, changelog = validate_architecture_action_result(
|
|
1221
|
+
root, assessment_instruction, action
|
|
1222
|
+
)
|
|
1223
|
+
if assessment.get("abstract_sha256") != abstract.get("sha256"):
|
|
1224
|
+
raise StateError("ABSTRACT.md changed after the architecture assessment was recorded.")
|
|
1225
|
+
if assessment.get("changelog_sha256") != changelog.get("sha256"):
|
|
1226
|
+
raise StateError("Architecture CHANGELOG.md changed after the assessment was recorded.")
|
|
649
1227
|
|
|
650
1228
|
|
|
651
1229
|
def validate_distillation_file_sets(root: Path, instruction: dict) -> None:
|
|
@@ -670,10 +1248,18 @@ def normalize_legacy_stage(stage: object) -> object:
|
|
|
670
1248
|
|
|
671
1249
|
|
|
672
1250
|
def normalize_legacy_task(task: dict) -> bool:
|
|
673
|
-
"""Normalize
|
|
1251
|
+
"""Normalize legacy task state without touching artifacts outside task.json."""
|
|
674
1252
|
legacy_status = str(task.get("status") or "")
|
|
675
1253
|
changed = False
|
|
676
1254
|
|
|
1255
|
+
for field in ("created_by", "last_agent"):
|
|
1256
|
+
normalized_agent = canonical_agent_identity(
|
|
1257
|
+
task.get(field), allow_legacy_display=True
|
|
1258
|
+
)
|
|
1259
|
+
if normalized_agent is not None and normalized_agent != task.get(field):
|
|
1260
|
+
task[field] = normalized_agent
|
|
1261
|
+
changed = True
|
|
1262
|
+
|
|
677
1263
|
if legacy_status in LEGACY_STAGE_MAP:
|
|
678
1264
|
task["status"] = LEGACY_STAGE_MAP[legacy_status]
|
|
679
1265
|
changed = True
|
|
@@ -689,6 +1275,12 @@ def normalize_legacy_task(task: dict) -> bool:
|
|
|
689
1275
|
if mapped_stage != entry.get("stage"):
|
|
690
1276
|
entry["stage"] = mapped_stage
|
|
691
1277
|
changed = True
|
|
1278
|
+
normalized_agent = canonical_agent_identity(
|
|
1279
|
+
entry.get("agent"), allow_legacy_display=True
|
|
1280
|
+
)
|
|
1281
|
+
if normalized_agent is not None and normalized_agent != entry.get("agent"):
|
|
1282
|
+
entry["agent"] = normalized_agent
|
|
1283
|
+
changed = True
|
|
692
1284
|
if normalized_history and normalized_history[-1].get("stage") == entry.get("stage"):
|
|
693
1285
|
changed = True
|
|
694
1286
|
continue
|
|
@@ -727,7 +1319,28 @@ def normalize_legacy_task(task: dict) -> bool:
|
|
|
727
1319
|
|
|
728
1320
|
def write_json(path: Path, data: dict) -> None:
|
|
729
1321
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
730
|
-
|
|
1322
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
1323
|
+
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
|
|
1324
|
+
)
|
|
1325
|
+
temporary_path = Path(temporary_name)
|
|
1326
|
+
try:
|
|
1327
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
|
|
1328
|
+
handle.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
1329
|
+
handle.flush()
|
|
1330
|
+
os.fsync(handle.fileno())
|
|
1331
|
+
os.replace(temporary_path, path)
|
|
1332
|
+
try:
|
|
1333
|
+
directory_descriptor = os.open(path.parent, os.O_RDONLY)
|
|
1334
|
+
try:
|
|
1335
|
+
os.fsync(directory_descriptor)
|
|
1336
|
+
finally:
|
|
1337
|
+
os.close(directory_descriptor)
|
|
1338
|
+
except OSError:
|
|
1339
|
+
# Some platforms do not allow opening directories; file replacement is still atomic.
|
|
1340
|
+
pass
|
|
1341
|
+
finally:
|
|
1342
|
+
if temporary_path.exists():
|
|
1343
|
+
temporary_path.unlink()
|
|
731
1344
|
|
|
732
1345
|
|
|
733
1346
|
def acquire_legacy_state_lock(root: Path) -> Path | None:
|
|
@@ -782,7 +1395,12 @@ def migrate_legacy_state(root: Path, agent: str) -> dict | None:
|
|
|
782
1395
|
if "stage_history" not in task or not task["stage_history"]:
|
|
783
1396
|
task["stage_history"] = old_state.get("stage_history", [])
|
|
784
1397
|
if "last_agent" not in task or not task["last_agent"]:
|
|
785
|
-
task["last_agent"] =
|
|
1398
|
+
task["last_agent"] = (
|
|
1399
|
+
canonical_agent_identity(
|
|
1400
|
+
old_state.get("last_agent"), allow_legacy_display=True
|
|
1401
|
+
)
|
|
1402
|
+
or agent
|
|
1403
|
+
)
|
|
786
1404
|
if old_state.get("confirmed_by_user"):
|
|
787
1405
|
task["confirmed_by_user"] = True
|
|
788
1406
|
if old_state.get("test_strategy_confirmed"):
|
|
@@ -858,7 +1476,8 @@ def clear_session_pointer(session: dict, agent: str | None = None) -> None:
|
|
|
858
1476
|
|
|
859
1477
|
|
|
860
1478
|
def load_session(root: Path, session_file: str | Path | None = None) -> dict | None:
|
|
861
|
-
|
|
1479
|
+
session = load_json(resolve_session_path(root, session_file))
|
|
1480
|
+
return session if isinstance(session, dict) else None
|
|
862
1481
|
|
|
863
1482
|
|
|
864
1483
|
def write_session(root: Path, session: dict, session_file: str | Path | None = None) -> None:
|
|
@@ -921,7 +1540,7 @@ def ensure_hook_session(
|
|
|
921
1540
|
)
|
|
922
1541
|
|
|
923
1542
|
if session is None:
|
|
924
|
-
|
|
1543
|
+
clean_session_runtime(root, reserve_slots=1)
|
|
925
1544
|
session = migrate_legacy_pid_session(root, session_path, identity, resolved_ppid)
|
|
926
1545
|
if session is None:
|
|
927
1546
|
session = load_session(root, session_path)
|
|
@@ -944,36 +1563,124 @@ def ensure_hook_session(
|
|
|
944
1563
|
|
|
945
1564
|
def clean_stale_sessions(
|
|
946
1565
|
root: Path,
|
|
947
|
-
threshold_hours: int =
|
|
1566
|
+
threshold_hours: int | None = None,
|
|
1567
|
+
idle_threshold_hours: int = SESSION_IDLE_RETENTION_HOURS,
|
|
1568
|
+
attached_threshold_hours: int = SESSION_ATTACHED_RETENTION_HOURS,
|
|
1569
|
+
max_sessions: int = MAX_SESSION_FILES,
|
|
1570
|
+
reserve_slots: int = 0,
|
|
948
1571
|
) -> int:
|
|
949
1572
|
sessions_dir = root / ".easy-coding" / "sessions"
|
|
950
1573
|
if not sessions_dir.is_dir():
|
|
951
1574
|
return 0
|
|
952
1575
|
|
|
953
1576
|
now = datetime.now(timezone.utc)
|
|
954
|
-
|
|
955
|
-
|
|
1577
|
+
if threshold_hours is not None:
|
|
1578
|
+
idle_threshold_hours = threshold_hours
|
|
1579
|
+
attached_threshold_hours = threshold_hours
|
|
1580
|
+
candidates: list[tuple[Path, str, dict, datetime]] = []
|
|
956
1581
|
for entry in sessions_dir.iterdir():
|
|
957
|
-
if entry.suffix != ".json":
|
|
1582
|
+
if not entry.is_file() or entry.suffix != ".json":
|
|
958
1583
|
continue
|
|
959
1584
|
try:
|
|
960
|
-
|
|
961
|
-
|
|
1585
|
+
content = entry.read_text(encoding="utf-8")
|
|
1586
|
+
try:
|
|
1587
|
+
session = json.loads(content)
|
|
1588
|
+
except json.JSONDecodeError:
|
|
1589
|
+
session = {}
|
|
1590
|
+
if not isinstance(session, dict):
|
|
1591
|
+
session = {}
|
|
1592
|
+
activity_value = session.get("last_active_at") or session.get("created_at")
|
|
1593
|
+
try:
|
|
1594
|
+
if not isinstance(activity_value, str):
|
|
1595
|
+
raise ValueError
|
|
1596
|
+
last_active = datetime.fromisoformat(activity_value)
|
|
1597
|
+
if last_active.tzinfo is None:
|
|
1598
|
+
last_active = last_active.replace(tzinfo=timezone.utc)
|
|
1599
|
+
except (ValueError, TypeError):
|
|
1600
|
+
last_active = datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc)
|
|
1601
|
+
candidates.append((entry, content, session, last_active))
|
|
1602
|
+
except OSError:
|
|
1603
|
+
continue
|
|
1604
|
+
|
|
1605
|
+
removed: set[Path] = set()
|
|
1606
|
+
for entry, content, session, last_active in candidates:
|
|
1607
|
+
retention_hours = (
|
|
1608
|
+
attached_threshold_hours if session.get("current_task") else idle_threshold_hours
|
|
1609
|
+
)
|
|
1610
|
+
age_hours = (now - last_active).total_seconds() / 3600
|
|
1611
|
+
if age_hours <= retention_hours:
|
|
1612
|
+
continue
|
|
1613
|
+
if unlink_session_if_unchanged(entry, content):
|
|
1614
|
+
removed.add(entry)
|
|
1615
|
+
|
|
1616
|
+
allowed_existing = max(0, max_sessions - reserve_slots)
|
|
1617
|
+
remaining = sorted(
|
|
1618
|
+
(candidate for candidate in candidates if candidate[0] not in removed),
|
|
1619
|
+
key=lambda candidate: candidate[3],
|
|
1620
|
+
)
|
|
1621
|
+
overflow = max(0, len(remaining) - allowed_existing)
|
|
1622
|
+
for entry, content, _session, _last_active in remaining[:overflow]:
|
|
1623
|
+
if unlink_session_if_unchanged(entry, content):
|
|
1624
|
+
removed.add(entry)
|
|
1625
|
+
return len(removed)
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
def unlink_session_if_unchanged(entry: Path, expected_content: str) -> bool:
|
|
1629
|
+
try:
|
|
1630
|
+
if entry.read_text(encoding="utf-8") != expected_content:
|
|
1631
|
+
return False
|
|
1632
|
+
entry.unlink()
|
|
1633
|
+
return True
|
|
1634
|
+
except OSError:
|
|
1635
|
+
# GC 采用尽力清理;锁定、并发移除等失败文件留到后续新会话再次处理。
|
|
1636
|
+
return False
|
|
1637
|
+
|
|
1638
|
+
|
|
1639
|
+
def clean_orphan_acceptance_snapshots(root: Path) -> int:
|
|
1640
|
+
acceptance_dir = root / ".easy-coding" / "sessions" / "acceptance"
|
|
1641
|
+
if not acceptance_dir.is_dir():
|
|
1642
|
+
return 0
|
|
1643
|
+
|
|
1644
|
+
cleaned = 0
|
|
1645
|
+
for entry in acceptance_dir.iterdir():
|
|
1646
|
+
if not entry.is_file() or entry.suffix != ".json":
|
|
1647
|
+
continue
|
|
1648
|
+
task_path = root / ".easy-coding" / "tasks" / entry.stem / "task.json"
|
|
1649
|
+
if task_path.is_file():
|
|
1650
|
+
try:
|
|
1651
|
+
task = json.loads(task_path.read_text(encoding="utf-8"))
|
|
1652
|
+
except (OSError, json.JSONDecodeError):
|
|
962
1653
|
continue
|
|
963
|
-
|
|
964
|
-
last_active = datetime.fromisoformat(str(activity_value))
|
|
965
|
-
if last_active.tzinfo is None:
|
|
966
|
-
last_active = last_active.replace(tzinfo=timezone.utc)
|
|
967
|
-
age_hours = (now - last_active).total_seconds() / 3600
|
|
968
|
-
if age_hours <= threshold_hours:
|
|
1654
|
+
if not isinstance(task, dict):
|
|
969
1655
|
continue
|
|
1656
|
+
else:
|
|
1657
|
+
task = None
|
|
1658
|
+
|
|
1659
|
+
checkpoint = task.get("verification_checkpoint") if task is not None else None
|
|
1660
|
+
snapshot_file = checkpoint.get("snapshot_file") if isinstance(checkpoint, dict) else None
|
|
1661
|
+
referenced = bool(
|
|
1662
|
+
isinstance(snapshot_file, str)
|
|
1663
|
+
and (root / snapshot_file).resolve() == entry.resolve()
|
|
1664
|
+
)
|
|
1665
|
+
terminal = task is not None and task.get("status") in TERMINAL_STATUSES
|
|
1666
|
+
if task is not None and referenced and not terminal:
|
|
1667
|
+
continue
|
|
1668
|
+
try:
|
|
970
1669
|
entry.unlink()
|
|
971
1670
|
cleaned += 1
|
|
972
|
-
except
|
|
1671
|
+
except OSError:
|
|
1672
|
+
# 验收快照清理失败不能阻断新逻辑会话启动。
|
|
973
1673
|
continue
|
|
974
1674
|
return cleaned
|
|
975
1675
|
|
|
976
1676
|
|
|
1677
|
+
def clean_session_runtime(root: Path, reserve_slots: int = 0) -> dict:
|
|
1678
|
+
return {
|
|
1679
|
+
"sessions_removed": clean_stale_sessions(root, reserve_slots=reserve_slots),
|
|
1680
|
+
"acceptance_snapshots_removed": clean_orphan_acceptance_snapshots(root),
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
|
|
977
1684
|
def task_json_path(root: Path, task_id: str) -> Path:
|
|
978
1685
|
assert_safe_task_id(task_id)
|
|
979
1686
|
return root / ".easy-coding" / "tasks" / task_id / "task.json"
|
|
@@ -999,6 +1706,8 @@ def append_execution_record(root: Path, task_id: str, record: dict) -> None:
|
|
|
999
1706
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1000
1707
|
with path.open("a", encoding="utf-8") as handle:
|
|
1001
1708
|
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
1709
|
+
handle.flush()
|
|
1710
|
+
os.fsync(handle.fileno())
|
|
1002
1711
|
|
|
1003
1712
|
|
|
1004
1713
|
def is_non_empty_string(value: object) -> bool:
|
|
@@ -1071,7 +1780,7 @@ def is_valid_execution_plan(
|
|
|
1071
1780
|
has_empty_file_scope = True
|
|
1072
1781
|
if not is_string_list(unit.get("depends_on")):
|
|
1073
1782
|
return False
|
|
1074
|
-
for optional_list in ("rules_sections", "abstract_modules"):
|
|
1783
|
+
for optional_list in ("rules_sections", "abstract_modules", "local_baseline"):
|
|
1075
1784
|
if optional_list in unit and not is_string_list(unit.get(optional_list)):
|
|
1076
1785
|
return False
|
|
1077
1786
|
if require_unit_contracts:
|
|
@@ -1145,25 +1854,58 @@ def stored_spec_path(root: Path, task: dict) -> Path:
|
|
|
1145
1854
|
source = task.get("spec_source")
|
|
1146
1855
|
if not isinstance(source, dict) or not is_non_empty_string(source.get("path")):
|
|
1147
1856
|
raise StateError("Spec-backed task is missing spec_source.path.")
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1857
|
+
path_mode = source.get("path_mode")
|
|
1858
|
+
raw_path = Path(str(source["path"])).expanduser()
|
|
1859
|
+
if path_mode is None:
|
|
1860
|
+
path_mode = "absolute" if raw_path.is_absolute() else "project-relative"
|
|
1861
|
+
if path_mode not in {"project-relative", "absolute"}:
|
|
1862
|
+
raise StateError("Spec-backed task has an invalid spec_source.path_mode.")
|
|
1863
|
+
if path_mode == "absolute" and not raw_path.is_absolute():
|
|
1864
|
+
raise StateError("Absolute Spec binding must store an absolute path.")
|
|
1865
|
+
if path_mode == "project-relative" and raw_path.is_absolute():
|
|
1866
|
+
raise StateError("Project-relative Spec binding must not store an absolute path.")
|
|
1867
|
+
resolved = (raw_path if path_mode == "absolute" else root / raw_path).resolve()
|
|
1868
|
+
if path_mode == "project-relative":
|
|
1869
|
+
try:
|
|
1870
|
+
resolved.relative_to(root.resolve())
|
|
1871
|
+
except ValueError as exc:
|
|
1872
|
+
raise StateError("Project-relative Spec source escapes the project root.") from exc
|
|
1873
|
+
if not resolved.is_file():
|
|
1874
|
+
raise StateError(
|
|
1875
|
+
"Canonical Spec source is unavailable; run rebind-spec-source with an explicit path."
|
|
1876
|
+
)
|
|
1155
1877
|
return resolved
|
|
1156
1878
|
|
|
1157
1879
|
|
|
1880
|
+
def legacy_source_digest_matches(
|
|
1881
|
+
spec_path: Path, legacy_sha256: object, current_source_sha256: object
|
|
1882
|
+
) -> bool:
|
|
1883
|
+
if not is_non_empty_string(legacy_sha256):
|
|
1884
|
+
return False
|
|
1885
|
+
if legacy_sha256 == current_source_sha256:
|
|
1886
|
+
return True
|
|
1887
|
+
try:
|
|
1888
|
+
design_text, execution = split_execution_region(
|
|
1889
|
+
spec_path.read_text(encoding="utf-8")
|
|
1890
|
+
)
|
|
1891
|
+
except (OSError, UnicodeError, ValueError):
|
|
1892
|
+
return False
|
|
1893
|
+
if execution is None:
|
|
1894
|
+
return False
|
|
1895
|
+
design_document_sha256 = hashlib.sha256(design_text.encode("utf-8")).hexdigest()
|
|
1896
|
+
return legacy_sha256 == design_document_sha256
|
|
1897
|
+
|
|
1898
|
+
|
|
1158
1899
|
def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
|
|
1159
1900
|
source = task.get("spec_source")
|
|
1160
1901
|
selected = task.get("selected_spec_tasks")
|
|
1161
1902
|
repo_paths = task.get("repo_paths")
|
|
1162
1903
|
if not isinstance(source, dict) or not is_string_list(selected, allow_empty=False):
|
|
1163
1904
|
raise StateError("Spec-backed task source and selected task metadata are incomplete.")
|
|
1905
|
+
spec_path = stored_spec_path(root, task)
|
|
1164
1906
|
try:
|
|
1165
1907
|
inspection = inspect_spec(
|
|
1166
|
-
|
|
1908
|
+
spec_path,
|
|
1167
1909
|
root,
|
|
1168
1910
|
repo_paths if isinstance(repo_paths, dict) else {},
|
|
1169
1911
|
selected,
|
|
@@ -1179,6 +1921,10 @@ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
|
|
|
1179
1921
|
selection = select_tasks(inspection, selected, satisfied)
|
|
1180
1922
|
except EasyDevSpecError as exc:
|
|
1181
1923
|
raise StateError(f"Canonical Spec validation failed: {exc}") from exc
|
|
1924
|
+
if not isinstance(inspection.get("execution"), dict):
|
|
1925
|
+
raise StateError(
|
|
1926
|
+
"Canonical Spec shared execution is not initialized; run initialize-spec-execution."
|
|
1927
|
+
)
|
|
1182
1928
|
stored_dependencies = task.get("spec_dependency_evidence")
|
|
1183
1929
|
if not isinstance(stored_dependencies, list):
|
|
1184
1930
|
raise StateError("Spec-backed task dependency metadata is incomplete.")
|
|
@@ -1191,30 +1937,52 @@ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
|
|
|
1191
1937
|
for record in stored_dependencies
|
|
1192
1938
|
if isinstance(record, dict)
|
|
1193
1939
|
}
|
|
1194
|
-
if (
|
|
1195
|
-
len(stored_by_edge) != len(stored_dependencies)
|
|
1196
|
-
or set(stored_by_edge) != set(expected_by_edge)
|
|
1197
|
-
):
|
|
1940
|
+
if len(stored_by_edge) != len(stored_dependencies) or set(stored_by_edge) != set(expected_by_edge):
|
|
1198
1941
|
raise StateError("Canonical Spec dependency metadata no longer matches source selection.")
|
|
1942
|
+
refreshed_dependencies: list[dict] = []
|
|
1199
1943
|
for edge, expected in expected_by_edge.items():
|
|
1200
1944
|
stored = stored_by_edge[edge]
|
|
1201
|
-
for field in ("dependency_type", "required_evidence"
|
|
1945
|
+
for field in ("dependency_type", "required_evidence"):
|
|
1202
1946
|
if stored.get(field) != expected.get(field):
|
|
1203
|
-
raise StateError(
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1947
|
+
raise StateError("Canonical Spec dependency metadata no longer matches source selection.")
|
|
1948
|
+
refreshed = dict(stored)
|
|
1949
|
+
for field in (
|
|
1950
|
+
"status",
|
|
1951
|
+
"shared_status",
|
|
1952
|
+
"dependency_task_status",
|
|
1953
|
+
"basis",
|
|
1954
|
+
):
|
|
1955
|
+
if expected.get(field) is None:
|
|
1956
|
+
refreshed.pop(field, None)
|
|
1957
|
+
else:
|
|
1958
|
+
refreshed[field] = expected.get(field)
|
|
1959
|
+
if expected.get("evidence"):
|
|
1960
|
+
refreshed["evidence"] = expected.get("evidence")
|
|
1961
|
+
refreshed_dependencies.append(refreshed)
|
|
1210
1962
|
if source.get("schema") != inspection.get("schema"):
|
|
1211
1963
|
raise StateError("Canonical Spec schema no longer matches task.json.")
|
|
1212
1964
|
if source.get("spec_id") != inspection.get("spec_id"):
|
|
1213
1965
|
raise StateError("Canonical Spec ID no longer matches task.json.")
|
|
1214
1966
|
if source.get("revision") != inspection.get("revision"):
|
|
1215
|
-
raise StateError("Canonical Spec revision
|
|
1216
|
-
|
|
1217
|
-
|
|
1967
|
+
raise StateError("Canonical Spec design revision changed; return the task to ANALYSIS.")
|
|
1968
|
+
stored_design_sha256 = source.get("design_sha256")
|
|
1969
|
+
if stored_design_sha256 is None:
|
|
1970
|
+
if not legacy_source_digest_matches(
|
|
1971
|
+
spec_path, source.get("sha256"), inspection.get("source_sha256")
|
|
1972
|
+
):
|
|
1973
|
+
raise StateError(
|
|
1974
|
+
"Legacy Canonical Spec digest changed before migration; rebind or recreate the task."
|
|
1975
|
+
)
|
|
1976
|
+
stored_design_sha256 = inspection.get("design_sha256")
|
|
1977
|
+
if stored_design_sha256 != inspection.get("design_sha256"):
|
|
1978
|
+
raise StateError("Canonical Spec static design changed; return the task to ANALYSIS.")
|
|
1979
|
+
stored_execution_revision = source.get("execution_revision")
|
|
1980
|
+
current_execution_revision = inspection.get("execution_revision")
|
|
1981
|
+
if isinstance(stored_execution_revision, int) and isinstance(current_execution_revision, int):
|
|
1982
|
+
if current_execution_revision < stored_execution_revision:
|
|
1983
|
+
raise StateError(
|
|
1984
|
+
"Canonical Spec execution revision moved backwards; restore the latest shared Spec."
|
|
1985
|
+
)
|
|
1218
1986
|
selected_repo_ids = set(selection["selected_repo_ids"])
|
|
1219
1987
|
stored_bindings = task.get("spec_repositories")
|
|
1220
1988
|
if not isinstance(stored_bindings, list):
|
|
@@ -1242,6 +2010,18 @@ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
|
|
|
1242
2010
|
for field in ("repo_id", "name", "path", "baseline_commit"):
|
|
1243
2011
|
if stored.get(field) != current.get(field):
|
|
1244
2012
|
raise StateError("Canonical Spec repository bindings no longer match task.json.")
|
|
2013
|
+
source.update(
|
|
2014
|
+
{
|
|
2015
|
+
"path_mode": source.get("path_mode")
|
|
2016
|
+
or ("absolute" if Path(str(source.get("path"))).is_absolute() else "project-relative"),
|
|
2017
|
+
"design_sha256": inspection.get("design_sha256"),
|
|
2018
|
+
"document_sha256": inspection.get("document_sha256"),
|
|
2019
|
+
"execution_revision": inspection.get("execution_revision"),
|
|
2020
|
+
}
|
|
2021
|
+
)
|
|
2022
|
+
source.pop("sha256", None)
|
|
2023
|
+
task["spec_source"] = source
|
|
2024
|
+
task["spec_dependency_evidence"] = refreshed_dependencies
|
|
1245
2025
|
return inspection, selection
|
|
1246
2026
|
|
|
1247
2027
|
|
|
@@ -1500,6 +2280,8 @@ def has_valid_execution_plan(root: Path, task_id: str) -> bool:
|
|
|
1500
2280
|
return False
|
|
1501
2281
|
if isinstance(record, dict) and record.get("type") == "plan":
|
|
1502
2282
|
latest_plan = record
|
|
2283
|
+
elif isinstance(record, dict) and record.get("type") == "spec-design-sync":
|
|
2284
|
+
latest_plan = None
|
|
1503
2285
|
except OSError:
|
|
1504
2286
|
return False
|
|
1505
2287
|
task = load_task(root, task_id)
|
|
@@ -1539,6 +2321,8 @@ def latest_execution_plan(root: Path, task_id: str) -> dict | None:
|
|
|
1539
2321
|
for record in execution_records(root, task_id):
|
|
1540
2322
|
if record.get("type") == "plan":
|
|
1541
2323
|
latest = record
|
|
2324
|
+
elif record.get("type") == "spec-design-sync":
|
|
2325
|
+
latest = None
|
|
1542
2326
|
if latest is None or not is_valid_execution_plan(latest, allow_empty_files=True):
|
|
1543
2327
|
return None
|
|
1544
2328
|
return latest
|
|
@@ -1686,25 +2470,61 @@ def task_repository_roots(root: Path, task: dict | None, plan: dict) -> list[Pat
|
|
|
1686
2470
|
]
|
|
1687
2471
|
|
|
1688
2472
|
|
|
1689
|
-
def
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
if not is_non_empty_string(
|
|
1701
|
-
raise StateError(
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
2473
|
+
def workflow_plan_repository_roots(root: Path, task: dict, plan: dict) -> list[Path]:
|
|
2474
|
+
"""Resolve only repositories that own files in the current execution plan."""
|
|
2475
|
+
repositories: set[Path] = set()
|
|
2476
|
+
repo_paths = task.get("repo_paths")
|
|
2477
|
+
canonical = isinstance(task.get("spec_source"), dict)
|
|
2478
|
+
|
|
2479
|
+
for unit in plan.get("units", []):
|
|
2480
|
+
if not isinstance(unit, dict):
|
|
2481
|
+
continue
|
|
2482
|
+
if canonical:
|
|
2483
|
+
repo_id = unit.get("repo_id")
|
|
2484
|
+
if not is_non_empty_string(repo_id) or not isinstance(repo_paths, dict):
|
|
2485
|
+
raise StateError("Canonical workflow Unit is missing its repository binding.")
|
|
2486
|
+
raw_repo_path = repo_paths.get(str(repo_id))
|
|
2487
|
+
if not is_non_empty_string(raw_repo_path):
|
|
2488
|
+
raise StateError(f"Canonical workflow repository path is missing: {repo_id}")
|
|
2489
|
+
candidate = Path(str(raw_repo_path))
|
|
2490
|
+
resolved = (candidate if candidate.is_absolute() else root / candidate).resolve()
|
|
2491
|
+
repository = git_repository_root(resolved)
|
|
2492
|
+
if repository is None or repository.resolve() != resolved:
|
|
2493
|
+
raise StateError(f"Canonical workflow repository binding is not a Git root: {repo_id}")
|
|
2494
|
+
repositories.add(repository.resolve())
|
|
2495
|
+
continue
|
|
2496
|
+
|
|
2497
|
+
for file_name in unit.get("files", []):
|
|
2498
|
+
if not is_non_empty_string(file_name):
|
|
2499
|
+
continue
|
|
2500
|
+
candidate = Path(str(file_name))
|
|
2501
|
+
resolved = (candidate if candidate.is_absolute() else root / candidate).resolve()
|
|
2502
|
+
repository = git_repository_root(resolved)
|
|
2503
|
+
if repository is not None:
|
|
2504
|
+
repositories.add(repository.resolve())
|
|
2505
|
+
|
|
2506
|
+
return sorted(repositories, key=lambda item: item.as_posix())
|
|
2507
|
+
|
|
2508
|
+
|
|
2509
|
+
def tdd_repositories(root: Path, task: dict, plan: dict) -> dict[str, Path]:
|
|
2510
|
+
if isinstance(task.get("spec_source"), dict):
|
|
2511
|
+
repo_paths = task.get("repo_paths")
|
|
2512
|
+
if not isinstance(repo_paths, dict):
|
|
2513
|
+
raise StateError("TDD Canonical task is missing repository bindings.")
|
|
2514
|
+
repositories: dict[str, Path] = {}
|
|
2515
|
+
for unit in plan.get("units", []):
|
|
2516
|
+
if not isinstance(unit, dict) or not is_non_empty_string(unit.get("repo_id")):
|
|
2517
|
+
raise StateError("TDD Canonical unit is missing repo_id.")
|
|
2518
|
+
repo_id = str(unit["repo_id"])
|
|
2519
|
+
raw_path = repo_paths.get(repo_id)
|
|
2520
|
+
if not is_non_empty_string(raw_path):
|
|
2521
|
+
raise StateError(f"TDD repository path is missing: {repo_id}")
|
|
2522
|
+
candidate = Path(str(raw_path))
|
|
2523
|
+
resolved = (candidate if candidate.is_absolute() else root / candidate).resolve()
|
|
2524
|
+
repository = git_repository_root(resolved)
|
|
2525
|
+
if repository is None or repository.resolve() != resolved:
|
|
2526
|
+
raise StateError(f"TDD repository binding is not a Git root: {repo_id}")
|
|
2527
|
+
repositories[repo_id] = repository
|
|
1708
2528
|
return repositories
|
|
1709
2529
|
|
|
1710
2530
|
repositories = task_repository_roots(root, task, plan)
|
|
@@ -1994,10 +2814,16 @@ def implementation_fingerprint(root: Path, task_id: str) -> str:
|
|
|
1994
2814
|
digest.update(b"\0")
|
|
1995
2815
|
if task and isinstance(task.get("spec_source"), dict):
|
|
1996
2816
|
digest.update(b"canonical-spec\0")
|
|
2817
|
+
source = task.get("spec_source") or {}
|
|
1997
2818
|
digest.update(
|
|
1998
2819
|
json.dumps(
|
|
1999
2820
|
{
|
|
2000
|
-
"source":
|
|
2821
|
+
"source": {
|
|
2822
|
+
"schema": source.get("schema"),
|
|
2823
|
+
"spec_id": source.get("spec_id"),
|
|
2824
|
+
"revision": source.get("revision"),
|
|
2825
|
+
"design_sha256": source.get("design_sha256"),
|
|
2826
|
+
},
|
|
2001
2827
|
"selected_tasks": task.get("selected_spec_tasks"),
|
|
2002
2828
|
},
|
|
2003
2829
|
ensure_ascii=False,
|
|
@@ -2102,6 +2928,665 @@ def evidence_fingerprints(root: Path, task_id: str) -> dict[str, str]:
|
|
|
2102
2928
|
}
|
|
2103
2929
|
|
|
2104
2930
|
|
|
2931
|
+
def acceptance_snapshot_path(root: Path, task_id: str) -> Path:
|
|
2932
|
+
assert_safe_task_id(task_id)
|
|
2933
|
+
return root / ".easy-coding" / "sessions" / "acceptance" / f"{task_id}.json"
|
|
2934
|
+
|
|
2935
|
+
|
|
2936
|
+
def canonical_json_sha256(value: object) -> str:
|
|
2937
|
+
payload = json.dumps(
|
|
2938
|
+
value,
|
|
2939
|
+
ensure_ascii=False,
|
|
2940
|
+
sort_keys=True,
|
|
2941
|
+
separators=(",", ":"),
|
|
2942
|
+
).encode("utf-8")
|
|
2943
|
+
return hashlib.sha256(payload).hexdigest()
|
|
2944
|
+
|
|
2945
|
+
|
|
2946
|
+
def verification_contract_fingerprint(root: Path, task_id: str, task: dict) -> str:
|
|
2947
|
+
plan = latest_execution_plan(root, task_id)
|
|
2948
|
+
if plan is None:
|
|
2949
|
+
raise StateError("Cannot fingerprint verification contract without a valid plan.")
|
|
2950
|
+
source = task.get("spec_source") if isinstance(task.get("spec_source"), dict) else {}
|
|
2951
|
+
contract = {
|
|
2952
|
+
"workflow_mode": task.get("workflow_mode"),
|
|
2953
|
+
"tdd_enabled": task.get("tdd_enabled"),
|
|
2954
|
+
"tdd_coverage_threshold": task.get("tdd_coverage_threshold"),
|
|
2955
|
+
"tdd_baselines": task.get("tdd_baselines"),
|
|
2956
|
+
"plan": plan,
|
|
2957
|
+
"canonical": {
|
|
2958
|
+
"schema": source.get("schema"),
|
|
2959
|
+
"spec_id": source.get("spec_id"),
|
|
2960
|
+
"revision": source.get("revision"),
|
|
2961
|
+
"design_sha256": source.get("design_sha256"),
|
|
2962
|
+
"selected_tasks": task.get("selected_spec_tasks"),
|
|
2963
|
+
"repository_bindings": task.get("spec_repositories"),
|
|
2964
|
+
"repo_paths": task.get("repo_paths"),
|
|
2965
|
+
}
|
|
2966
|
+
if source
|
|
2967
|
+
else None,
|
|
2968
|
+
}
|
|
2969
|
+
return canonical_json_sha256(contract)
|
|
2970
|
+
|
|
2971
|
+
|
|
2972
|
+
def acceptance_repository_entries(repository: Path, scopes: list[Path]) -> list[dict]:
|
|
2973
|
+
pathspecs = repository_scope_pathspecs(repository, scopes)
|
|
2974
|
+
index_entries = git_index_entries(repository, pathspecs)
|
|
2975
|
+
listed = run_git(
|
|
2976
|
+
repository,
|
|
2977
|
+
"ls-files",
|
|
2978
|
+
"--cached",
|
|
2979
|
+
"--others",
|
|
2980
|
+
"--exclude-standard",
|
|
2981
|
+
"-z",
|
|
2982
|
+
"--",
|
|
2983
|
+
*pathspecs,
|
|
2984
|
+
)
|
|
2985
|
+
modified = run_git(
|
|
2986
|
+
repository,
|
|
2987
|
+
"diff-files",
|
|
2988
|
+
"--name-only",
|
|
2989
|
+
"-z",
|
|
2990
|
+
"--ignore-submodules=none",
|
|
2991
|
+
"--",
|
|
2992
|
+
*pathspecs,
|
|
2993
|
+
)
|
|
2994
|
+
if listed is None or listed.returncode != 0 or modified is None or modified.returncode != 0:
|
|
2995
|
+
raise StateError(f"Cannot capture verification snapshot for {repository}.")
|
|
2996
|
+
modified_paths = set(filter(None, modified.stdout.split(b"\0")))
|
|
2997
|
+
raw_paths = set(filter(None, listed.stdout.split(b"\0"))) | set(index_entries)
|
|
2998
|
+
entries: list[dict] = []
|
|
2999
|
+
for raw_path in sorted(raw_paths):
|
|
3000
|
+
relative_name = os.fsdecode(raw_path)
|
|
3001
|
+
if is_easy_coding_state_path(repository, relative_name, scopes):
|
|
3002
|
+
continue
|
|
3003
|
+
candidate = repository / relative_name
|
|
3004
|
+
index_entry = index_entries.get(raw_path)
|
|
3005
|
+
if index_entry is not None and index_entry[0] == b"160000":
|
|
3006
|
+
entries.append(
|
|
3007
|
+
{
|
|
3008
|
+
"path": relative_name,
|
|
3009
|
+
"exists": True,
|
|
3010
|
+
"mode": "160000",
|
|
3011
|
+
"git_oid": index_entry[1].decode("ascii", errors="replace"),
|
|
3012
|
+
"sha256": hashlib.sha256(index_entry[1]).hexdigest(),
|
|
3013
|
+
}
|
|
3014
|
+
)
|
|
3015
|
+
continue
|
|
3016
|
+
exists = candidate.exists() or candidate.is_symlink()
|
|
3017
|
+
if not exists:
|
|
3018
|
+
entries.append(
|
|
3019
|
+
{
|
|
3020
|
+
"path": relative_name,
|
|
3021
|
+
"exists": False,
|
|
3022
|
+
"mode": None,
|
|
3023
|
+
"sha256": None,
|
|
3024
|
+
}
|
|
3025
|
+
)
|
|
3026
|
+
continue
|
|
3027
|
+
try:
|
|
3028
|
+
content = (
|
|
3029
|
+
os.fsencode(os.readlink(candidate))
|
|
3030
|
+
if candidate.is_symlink()
|
|
3031
|
+
else candidate.read_bytes()
|
|
3032
|
+
)
|
|
3033
|
+
except OSError as exc:
|
|
3034
|
+
raise StateError(f"Cannot read verification snapshot file: {relative_name}") from exc
|
|
3035
|
+
mode = worktree_git_mode(candidate).decode("ascii", errors="replace")
|
|
3036
|
+
entry = {
|
|
3037
|
+
"path": relative_name,
|
|
3038
|
+
"exists": True,
|
|
3039
|
+
"mode": mode,
|
|
3040
|
+
"sha256": hashlib.sha256(content).hexdigest(),
|
|
3041
|
+
}
|
|
3042
|
+
if index_entry is not None and raw_path not in modified_paths:
|
|
3043
|
+
entry["git_oid"] = index_entry[1].decode("ascii", errors="replace")
|
|
3044
|
+
else:
|
|
3045
|
+
# 仅无法从 Git object 还原的工作区内容进入被忽略的临时快照。
|
|
3046
|
+
entry["content_b64"] = base64.b64encode(content).decode("ascii")
|
|
3047
|
+
entries.append(entry)
|
|
3048
|
+
return entries
|
|
3049
|
+
|
|
3050
|
+
|
|
3051
|
+
def acceptance_filesystem_repositories(
|
|
3052
|
+
root: Path,
|
|
3053
|
+
plan: dict,
|
|
3054
|
+
git_scopes: list[tuple[Path, list[Path]]],
|
|
3055
|
+
) -> list[dict]:
|
|
3056
|
+
files_by_root: dict[Path, set[Path]] = {}
|
|
3057
|
+
for unit in plan.get("units", []):
|
|
3058
|
+
if not isinstance(unit, dict):
|
|
3059
|
+
continue
|
|
3060
|
+
for file_name in unit.get("files", []):
|
|
3061
|
+
if not is_non_empty_string(file_name):
|
|
3062
|
+
continue
|
|
3063
|
+
raw_path = Path(str(file_name))
|
|
3064
|
+
base = root.resolve()
|
|
3065
|
+
candidate = raw_path if raw_path.is_absolute() else base / raw_path
|
|
3066
|
+
resolved = candidate.resolve()
|
|
3067
|
+
if not raw_path.is_absolute() and not is_path_within(resolved, base):
|
|
3068
|
+
raise StateError(f"Execution plan file escapes project: {file_name}")
|
|
3069
|
+
if any(
|
|
3070
|
+
is_path_within(resolved, scope)
|
|
3071
|
+
for _repository, scopes in git_scopes
|
|
3072
|
+
for scope in scopes
|
|
3073
|
+
):
|
|
3074
|
+
continue
|
|
3075
|
+
snapshot_root = resolved.parent if raw_path.is_absolute() else base
|
|
3076
|
+
files_by_root.setdefault(snapshot_root, set()).add(resolved)
|
|
3077
|
+
|
|
3078
|
+
repositories: list[dict] = []
|
|
3079
|
+
for snapshot_root, files in sorted(
|
|
3080
|
+
files_by_root.items(), key=lambda item: item[0].as_posix()
|
|
3081
|
+
):
|
|
3082
|
+
entries = []
|
|
3083
|
+
for candidate in sorted(files, key=lambda item: item.as_posix()):
|
|
3084
|
+
relative_name = candidate.relative_to(snapshot_root).as_posix()
|
|
3085
|
+
exists = candidate.exists() or candidate.is_symlink()
|
|
3086
|
+
if not exists:
|
|
3087
|
+
entries.append(
|
|
3088
|
+
{
|
|
3089
|
+
"path": relative_name,
|
|
3090
|
+
"exists": False,
|
|
3091
|
+
"mode": None,
|
|
3092
|
+
"sha256": None,
|
|
3093
|
+
}
|
|
3094
|
+
)
|
|
3095
|
+
continue
|
|
3096
|
+
try:
|
|
3097
|
+
content = (
|
|
3098
|
+
os.fsencode(os.readlink(candidate))
|
|
3099
|
+
if candidate.is_symlink()
|
|
3100
|
+
else candidate.read_bytes()
|
|
3101
|
+
)
|
|
3102
|
+
except OSError as exc:
|
|
3103
|
+
raise StateError(
|
|
3104
|
+
f"Cannot read verification snapshot file: {candidate}"
|
|
3105
|
+
) from exc
|
|
3106
|
+
entries.append(
|
|
3107
|
+
{
|
|
3108
|
+
"path": relative_name,
|
|
3109
|
+
"exists": True,
|
|
3110
|
+
"mode": worktree_git_mode(candidate).decode("ascii", errors="replace"),
|
|
3111
|
+
"sha256": hashlib.sha256(content).hexdigest(),
|
|
3112
|
+
"content_b64": base64.b64encode(content).decode("ascii"),
|
|
3113
|
+
}
|
|
3114
|
+
)
|
|
3115
|
+
repositories.append(
|
|
3116
|
+
{
|
|
3117
|
+
"root": str(snapshot_root),
|
|
3118
|
+
"display": display_path(root, snapshot_root),
|
|
3119
|
+
"scopes": [],
|
|
3120
|
+
"entries": entries,
|
|
3121
|
+
}
|
|
3122
|
+
)
|
|
3123
|
+
return repositories
|
|
3124
|
+
|
|
3125
|
+
|
|
3126
|
+
def build_acceptance_snapshot(root: Path, task_id: str, task: dict) -> dict:
|
|
3127
|
+
plan = latest_execution_plan(root, task_id)
|
|
3128
|
+
if plan is None:
|
|
3129
|
+
raise StateError("Cannot capture verification snapshot without a valid plan.")
|
|
3130
|
+
fingerprints = evidence_fingerprints(root, task_id)
|
|
3131
|
+
repository_scopes = task_repository_scopes(root, task, plan)
|
|
3132
|
+
repositories = []
|
|
3133
|
+
for repository, scopes in repository_scopes:
|
|
3134
|
+
repositories.append(
|
|
3135
|
+
{
|
|
3136
|
+
"root": str(repository.resolve()),
|
|
3137
|
+
"display": display_path(root, repository.resolve()),
|
|
3138
|
+
"scopes": [
|
|
3139
|
+
scope.relative_to(repository.resolve()).as_posix() for scope in scopes
|
|
3140
|
+
],
|
|
3141
|
+
"entries": acceptance_repository_entries(repository.resolve(), scopes),
|
|
3142
|
+
}
|
|
3143
|
+
)
|
|
3144
|
+
repositories.extend(acceptance_filesystem_repositories(root, plan, repository_scopes))
|
|
3145
|
+
return {
|
|
3146
|
+
"schema": ACCEPTANCE_SNAPSHOT_SCHEMA,
|
|
3147
|
+
**fingerprints,
|
|
3148
|
+
"contract_fingerprint": verification_contract_fingerprint(root, task_id, task),
|
|
3149
|
+
"repositories": repositories,
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
|
|
3153
|
+
def load_acceptance_snapshot(root: Path, task: dict) -> dict:
|
|
3154
|
+
checkpoint = task.get("verification_checkpoint")
|
|
3155
|
+
if not isinstance(checkpoint, dict):
|
|
3156
|
+
raise StateError("VERIFICATION has no frozen acceptance checkpoint.")
|
|
3157
|
+
raw_path = checkpoint.get("snapshot_file")
|
|
3158
|
+
if not is_non_empty_string(raw_path):
|
|
3159
|
+
raise StateError("Verification checkpoint has no snapshot file.")
|
|
3160
|
+
candidate = (root / str(raw_path)).resolve()
|
|
3161
|
+
sessions_root = (root / ".easy-coding" / "sessions").resolve()
|
|
3162
|
+
if not is_path_within(candidate, sessions_root):
|
|
3163
|
+
raise StateError("Verification checkpoint snapshot escapes .easy-coding/sessions.")
|
|
3164
|
+
snapshot = load_json(candidate)
|
|
3165
|
+
if not isinstance(snapshot, dict) or snapshot.get("schema") != ACCEPTANCE_SNAPSHOT_SCHEMA:
|
|
3166
|
+
raise StateError("Verification checkpoint snapshot is missing or invalid.")
|
|
3167
|
+
if canonical_json_sha256(snapshot) != checkpoint.get("snapshot_sha256"):
|
|
3168
|
+
raise StateError("Verification checkpoint snapshot fingerprint changed.")
|
|
3169
|
+
if (
|
|
3170
|
+
snapshot.get("implementation_fingerprint")
|
|
3171
|
+
!= checkpoint.get("implementation_fingerprint")
|
|
3172
|
+
or snapshot.get("config_fingerprint") != checkpoint.get("config_fingerprint")
|
|
3173
|
+
or snapshot.get("contract_fingerprint") != checkpoint.get("contract_fingerprint")
|
|
3174
|
+
):
|
|
3175
|
+
raise StateError("Verification checkpoint metadata does not match its snapshot.")
|
|
3176
|
+
return snapshot
|
|
3177
|
+
|
|
3178
|
+
|
|
3179
|
+
def snapshot_entry_content(repository: Path, entry: dict | None) -> bytes | None:
|
|
3180
|
+
if not isinstance(entry, dict) or entry.get("exists") is not True:
|
|
3181
|
+
return None
|
|
3182
|
+
encoded = entry.get("content_b64")
|
|
3183
|
+
if isinstance(encoded, str):
|
|
3184
|
+
try:
|
|
3185
|
+
return base64.b64decode(encoded, validate=True)
|
|
3186
|
+
except ValueError as exc:
|
|
3187
|
+
raise StateError("Verification checkpoint contains invalid file content.") from exc
|
|
3188
|
+
object_id = entry.get("git_oid")
|
|
3189
|
+
if not is_non_empty_string(object_id):
|
|
3190
|
+
return None
|
|
3191
|
+
if entry.get("mode") == "160000":
|
|
3192
|
+
return str(object_id).encode("ascii", errors="replace")
|
|
3193
|
+
result = run_git(repository, "cat-file", "blob", str(object_id))
|
|
3194
|
+
if result is None or result.returncode != 0:
|
|
3195
|
+
raise StateError(f"Cannot restore verification checkpoint Git object: {object_id}")
|
|
3196
|
+
return result.stdout
|
|
3197
|
+
|
|
3198
|
+
|
|
3199
|
+
def acceptance_snapshot_entries(snapshot: dict) -> dict[tuple[str, str], tuple[Path, dict]]:
|
|
3200
|
+
entries: dict[tuple[str, str], tuple[Path, dict]] = {}
|
|
3201
|
+
for repository in snapshot.get("repositories", []):
|
|
3202
|
+
if not isinstance(repository, dict) or not is_non_empty_string(repository.get("root")):
|
|
3203
|
+
continue
|
|
3204
|
+
repository_root = Path(str(repository["root"]))
|
|
3205
|
+
for entry in repository.get("entries", []):
|
|
3206
|
+
if isinstance(entry, dict) and is_non_empty_string(entry.get("path")):
|
|
3207
|
+
entries[(str(repository_root), str(entry["path"]))] = (repository_root, entry)
|
|
3208
|
+
return entries
|
|
3209
|
+
|
|
3210
|
+
|
|
3211
|
+
def acceptance_change_patch(
|
|
3212
|
+
path_name: str,
|
|
3213
|
+
previous: bytes | None,
|
|
3214
|
+
current: bytes | None,
|
|
3215
|
+
) -> tuple[bool, str]:
|
|
3216
|
+
if (previous is not None and b"\0" in previous) or (current is not None and b"\0" in current):
|
|
3217
|
+
return True, ""
|
|
3218
|
+
try:
|
|
3219
|
+
previous_text = previous.decode("utf-8") if previous is not None else ""
|
|
3220
|
+
current_text = current.decode("utf-8") if current is not None else ""
|
|
3221
|
+
except UnicodeDecodeError:
|
|
3222
|
+
return True, ""
|
|
3223
|
+
patch = "".join(
|
|
3224
|
+
difflib.unified_diff(
|
|
3225
|
+
previous_text.splitlines(keepends=True),
|
|
3226
|
+
current_text.splitlines(keepends=True),
|
|
3227
|
+
fromfile=f"a/{path_name}" if previous is not None else "/dev/null",
|
|
3228
|
+
tofile=f"b/{path_name}" if current is not None else "/dev/null",
|
|
3229
|
+
)
|
|
3230
|
+
)
|
|
3231
|
+
return False, patch
|
|
3232
|
+
|
|
3233
|
+
|
|
3234
|
+
def inspect_acceptance_drift(root: Path, task_id: str, task: dict) -> dict:
|
|
3235
|
+
checkpoint = task.get("verification_checkpoint")
|
|
3236
|
+
baseline = load_acceptance_snapshot(root, task)
|
|
3237
|
+
current = build_acceptance_snapshot(root, task_id, task)
|
|
3238
|
+
baseline_entries = acceptance_snapshot_entries(baseline)
|
|
3239
|
+
current_entries = acceptance_snapshot_entries(current)
|
|
3240
|
+
changes: list[dict] = []
|
|
3241
|
+
digest_changes: list[dict] = []
|
|
3242
|
+
nested_repository_changed = False
|
|
3243
|
+
for key in sorted(set(baseline_entries) | set(current_entries)):
|
|
3244
|
+
previous_repository, previous_entry = baseline_entries.get(key, (Path(key[0]), None))
|
|
3245
|
+
current_repository, current_entry = current_entries.get(key, (Path(key[0]), None))
|
|
3246
|
+
if (
|
|
3247
|
+
isinstance(previous_entry, dict)
|
|
3248
|
+
and isinstance(current_entry, dict)
|
|
3249
|
+
and previous_entry.get("exists") == current_entry.get("exists")
|
|
3250
|
+
and previous_entry.get("mode") == current_entry.get("mode")
|
|
3251
|
+
and previous_entry.get("sha256") == current_entry.get("sha256")
|
|
3252
|
+
):
|
|
3253
|
+
continue
|
|
3254
|
+
repository = current_repository if isinstance(current_entry, dict) else previous_repository
|
|
3255
|
+
previous_content = snapshot_entry_content(previous_repository, previous_entry)
|
|
3256
|
+
current_content = snapshot_entry_content(current_repository, current_entry)
|
|
3257
|
+
binary, patch = acceptance_change_patch(key[1], previous_content, current_content)
|
|
3258
|
+
change_type = (
|
|
3259
|
+
"added"
|
|
3260
|
+
if previous_content is None and current_content is not None
|
|
3261
|
+
else "deleted"
|
|
3262
|
+
if previous_content is not None and current_content is None
|
|
3263
|
+
else "modified"
|
|
3264
|
+
)
|
|
3265
|
+
label = f"{display_path(root, repository)}:{key[1]}"
|
|
3266
|
+
detail = {
|
|
3267
|
+
"file": label,
|
|
3268
|
+
"repository": display_path(root, repository),
|
|
3269
|
+
"path": key[1],
|
|
3270
|
+
"change_type": change_type,
|
|
3271
|
+
"old_mode": previous_entry.get("mode") if isinstance(previous_entry, dict) else None,
|
|
3272
|
+
"new_mode": current_entry.get("mode") if isinstance(current_entry, dict) else None,
|
|
3273
|
+
"old_sha256": previous_entry.get("sha256")
|
|
3274
|
+
if isinstance(previous_entry, dict)
|
|
3275
|
+
else None,
|
|
3276
|
+
"new_sha256": current_entry.get("sha256")
|
|
3277
|
+
if isinstance(current_entry, dict)
|
|
3278
|
+
else None,
|
|
3279
|
+
"binary": binary,
|
|
3280
|
+
"patch": patch,
|
|
3281
|
+
}
|
|
3282
|
+
if detail["old_mode"] == "160000" or detail["new_mode"] == "160000":
|
|
3283
|
+
nested_repository_changed = True
|
|
3284
|
+
changes.append(detail)
|
|
3285
|
+
digest_changes.append(
|
|
3286
|
+
{key_name: value for key_name, value in detail.items() if key_name != "patch"}
|
|
3287
|
+
)
|
|
3288
|
+
current_implementation = str(current["implementation_fingerprint"])
|
|
3289
|
+
baseline_implementation = str(checkpoint["implementation_fingerprint"])
|
|
3290
|
+
config_changed = current.get("config_fingerprint") != checkpoint.get("config_fingerprint")
|
|
3291
|
+
contract_changed = current.get("contract_fingerprint") != checkpoint.get(
|
|
3292
|
+
"contract_fingerprint"
|
|
3293
|
+
)
|
|
3294
|
+
metadata_changed = bool(
|
|
3295
|
+
contract_changed
|
|
3296
|
+
or nested_repository_changed
|
|
3297
|
+
or (current_implementation != baseline_implementation and not changes)
|
|
3298
|
+
)
|
|
3299
|
+
metadata_reasons = [
|
|
3300
|
+
reason
|
|
3301
|
+
for condition, reason in (
|
|
3302
|
+
(contract_changed, "verification-contract-changed"),
|
|
3303
|
+
(nested_repository_changed, "nested-repository-changed"),
|
|
3304
|
+
(
|
|
3305
|
+
current_implementation != baseline_implementation
|
|
3306
|
+
and not changes
|
|
3307
|
+
and not contract_changed,
|
|
3308
|
+
"unclassified-implementation-drift",
|
|
3309
|
+
),
|
|
3310
|
+
)
|
|
3311
|
+
if condition
|
|
3312
|
+
]
|
|
3313
|
+
digest_payload = {
|
|
3314
|
+
"from": baseline_implementation,
|
|
3315
|
+
"to": current_implementation,
|
|
3316
|
+
"config_changed": config_changed,
|
|
3317
|
+
"metadata_changed": metadata_changed,
|
|
3318
|
+
"changes": digest_changes,
|
|
3319
|
+
}
|
|
3320
|
+
return {
|
|
3321
|
+
"status": "drift" if changes or config_changed or metadata_changed else "clean",
|
|
3322
|
+
"from_implementation_fingerprint": baseline_implementation,
|
|
3323
|
+
"implementation_fingerprint": current_implementation,
|
|
3324
|
+
"config_fingerprint": str(current["config_fingerprint"]),
|
|
3325
|
+
"config_changed": config_changed,
|
|
3326
|
+
"metadata_changed": metadata_changed,
|
|
3327
|
+
"metadata_reasons": metadata_reasons,
|
|
3328
|
+
"diff_sha256": canonical_json_sha256(digest_payload),
|
|
3329
|
+
"changed_files": [str(change["file"]) for change in changes],
|
|
3330
|
+
"changes": changes,
|
|
3331
|
+
}
|
|
3332
|
+
|
|
3333
|
+
|
|
3334
|
+
def cleanup_verification_checkpoint(root: Path, task_id: str, task: dict) -> None:
|
|
3335
|
+
checkpoint = task.pop("verification_checkpoint", None)
|
|
3336
|
+
if not isinstance(checkpoint, dict):
|
|
3337
|
+
return
|
|
3338
|
+
raw_path = checkpoint.get("snapshot_file")
|
|
3339
|
+
if not is_non_empty_string(raw_path):
|
|
3340
|
+
return
|
|
3341
|
+
candidate = (root / str(raw_path)).resolve()
|
|
3342
|
+
sessions_root = (root / ".easy-coding" / "sessions").resolve()
|
|
3343
|
+
if not is_path_within(candidate, sessions_root):
|
|
3344
|
+
return
|
|
3345
|
+
try:
|
|
3346
|
+
candidate.unlink()
|
|
3347
|
+
except FileNotFoundError:
|
|
3348
|
+
pass
|
|
3349
|
+
try:
|
|
3350
|
+
candidate.parent.rmdir()
|
|
3351
|
+
except OSError:
|
|
3352
|
+
pass
|
|
3353
|
+
|
|
3354
|
+
|
|
3355
|
+
def record_verification_checkpoint(
|
|
3356
|
+
root: Path,
|
|
3357
|
+
agent: str,
|
|
3358
|
+
task_id: str | None = None,
|
|
3359
|
+
session_file: str | Path | None = None,
|
|
3360
|
+
) -> dict:
|
|
3361
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
3362
|
+
if task.get("status") != "VERIFICATION":
|
|
3363
|
+
raise StateError("Verification checkpoint can only be recorded during VERIFICATION.")
|
|
3364
|
+
if isinstance(task.get("verification_checkpoint"), dict):
|
|
3365
|
+
load_acceptance_snapshot(root, task)
|
|
3366
|
+
result = snapshot_state(root, session_file, session)
|
|
3367
|
+
result["action"] = "verification-checkpoint"
|
|
3368
|
+
result["verification_checkpoint"] = task["verification_checkpoint"]
|
|
3369
|
+
result["checkpoint_unchanged"] = True
|
|
3370
|
+
return result
|
|
3371
|
+
validate_verification_readiness(root, resolved_task_id, task)
|
|
3372
|
+
snapshot = build_acceptance_snapshot(root, resolved_task_id, task)
|
|
3373
|
+
path = acceptance_snapshot_path(root, resolved_task_id)
|
|
3374
|
+
write_json(path, snapshot)
|
|
3375
|
+
task["verification_checkpoint"] = {
|
|
3376
|
+
"schema": ACCEPTANCE_SNAPSHOT_SCHEMA,
|
|
3377
|
+
"implementation_fingerprint": snapshot["implementation_fingerprint"],
|
|
3378
|
+
"config_fingerprint": snapshot["config_fingerprint"],
|
|
3379
|
+
"contract_fingerprint": snapshot["contract_fingerprint"],
|
|
3380
|
+
"snapshot_file": display_path(root, path),
|
|
3381
|
+
"snapshot_sha256": canonical_json_sha256(snapshot),
|
|
3382
|
+
"recorded_at": now_iso(),
|
|
3383
|
+
"recorded_by": agent,
|
|
3384
|
+
}
|
|
3385
|
+
task["last_agent"] = agent
|
|
3386
|
+
write_task(root, resolved_task_id, task)
|
|
3387
|
+
result = snapshot_state(root, session_file, session)
|
|
3388
|
+
result["action"] = "verification-checkpoint"
|
|
3389
|
+
result["verification_checkpoint"] = task["verification_checkpoint"]
|
|
3390
|
+
return result
|
|
3391
|
+
|
|
3392
|
+
|
|
3393
|
+
def latest_acceptance_record(root: Path, task_id: str, task: dict) -> dict | None:
|
|
3394
|
+
latest_implement = max(
|
|
3395
|
+
(
|
|
3396
|
+
str(entry.get("entered_at"))
|
|
3397
|
+
for entry in task.get("stage_history", [])
|
|
3398
|
+
if isinstance(entry, dict)
|
|
3399
|
+
and entry.get("stage") == "IMPLEMENT"
|
|
3400
|
+
and is_non_empty_string(entry.get("entered_at"))
|
|
3401
|
+
),
|
|
3402
|
+
default="",
|
|
3403
|
+
)
|
|
3404
|
+
latest: dict | None = None
|
|
3405
|
+
for record in execution_records(root, task_id):
|
|
3406
|
+
if record.get("type") != "acceptance" or not is_non_empty_string(
|
|
3407
|
+
record.get("timestamp")
|
|
3408
|
+
):
|
|
3409
|
+
continue
|
|
3410
|
+
if latest_implement and str(record["timestamp"]) < latest_implement:
|
|
3411
|
+
continue
|
|
3412
|
+
latest = record
|
|
3413
|
+
return latest
|
|
3414
|
+
|
|
3415
|
+
|
|
3416
|
+
def ensure_verification_checkpoint(
|
|
3417
|
+
root: Path,
|
|
3418
|
+
task_id: str,
|
|
3419
|
+
task: dict,
|
|
3420
|
+
agent: str,
|
|
3421
|
+
session_file: str | Path | None,
|
|
3422
|
+
) -> dict:
|
|
3423
|
+
if isinstance(task.get("verification_checkpoint"), dict):
|
|
3424
|
+
load_acceptance_snapshot(root, task)
|
|
3425
|
+
return task
|
|
3426
|
+
record_verification_checkpoint(root, agent, task_id, session_file)
|
|
3427
|
+
refreshed = load_task(root, task_id)
|
|
3428
|
+
if not isinstance(refreshed, dict):
|
|
3429
|
+
raise StateError(f"Task not found after verification checkpoint: {task_id}")
|
|
3430
|
+
return refreshed
|
|
3431
|
+
|
|
3432
|
+
|
|
3433
|
+
def inspect_transition_drift(
|
|
3434
|
+
root: Path,
|
|
3435
|
+
agent: str,
|
|
3436
|
+
task_id: str | None = None,
|
|
3437
|
+
session_file: str | Path | None = None,
|
|
3438
|
+
) -> dict:
|
|
3439
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
3440
|
+
if task.get("status") != "VERIFICATION":
|
|
3441
|
+
raise StateError("Transition drift can only be inspected during VERIFICATION.")
|
|
3442
|
+
task = ensure_verification_checkpoint(root, resolved_task_id, task, agent, session_file)
|
|
3443
|
+
result = snapshot_state(root, session_file, session)
|
|
3444
|
+
result["acceptance_drift"] = inspect_acceptance_drift(root, resolved_task_id, task)
|
|
3445
|
+
result["action"] = "inspect-transition-drift"
|
|
3446
|
+
return result
|
|
3447
|
+
|
|
3448
|
+
|
|
3449
|
+
def append_transition_acceptance(
|
|
3450
|
+
root: Path,
|
|
3451
|
+
task_id: str,
|
|
3452
|
+
task: dict,
|
|
3453
|
+
agent: str,
|
|
3454
|
+
approval_mode: str,
|
|
3455
|
+
authorization: str,
|
|
3456
|
+
expected_diff_sha256: str | None = None,
|
|
3457
|
+
verification_policy: str | None = None,
|
|
3458
|
+
summary: str | None = None,
|
|
3459
|
+
) -> dict:
|
|
3460
|
+
drift = inspect_acceptance_drift(root, task_id, task)
|
|
3461
|
+
if drift["config_changed"]:
|
|
3462
|
+
raise StateError(
|
|
3463
|
+
"Behavior config changed after verification; rerun verification before MEMORY."
|
|
3464
|
+
)
|
|
3465
|
+
if drift["metadata_changed"]:
|
|
3466
|
+
raise StateError(
|
|
3467
|
+
"Execution plan, workflow, Canonical design, or nested repository state changed "
|
|
3468
|
+
"after verification; return to ANALYSIS or IMPLEMENT instead of accepting it as a code diff."
|
|
3469
|
+
)
|
|
3470
|
+
changed_files = list(drift["changed_files"])
|
|
3471
|
+
if changed_files:
|
|
3472
|
+
if expected_diff_sha256 != drift["diff_sha256"]:
|
|
3473
|
+
raise StateError(
|
|
3474
|
+
"Verified code changed after the acceptance checkpoint. Inspect the exact drift "
|
|
3475
|
+
"and confirm its current diff_sha256 before entering MEMORY."
|
|
3476
|
+
)
|
|
3477
|
+
if verification_policy not in ACCEPTANCE_VERIFICATION_POLICIES:
|
|
3478
|
+
raise StateError(
|
|
3479
|
+
"Accepted code drift requires verification policy carry-forward, targeted, or waived."
|
|
3480
|
+
)
|
|
3481
|
+
review_policy = "user-accepted-without-rereview"
|
|
3482
|
+
else:
|
|
3483
|
+
verification_policy = "current"
|
|
3484
|
+
review_policy = "current"
|
|
3485
|
+
required_targeted_source_tasks = (
|
|
3486
|
+
targeted_source_tasks_for_changes(root, task_id, task, drift["changes"])
|
|
3487
|
+
if verification_policy == "targeted"
|
|
3488
|
+
else []
|
|
3489
|
+
)
|
|
3490
|
+
normalized_summary = (
|
|
3491
|
+
summary.strip()
|
|
3492
|
+
if isinstance(summary, str) and summary.strip()
|
|
3493
|
+
else "User accepted the verified implementation"
|
|
3494
|
+
if authorization == "explicit-user"
|
|
3495
|
+
else f"Approval mode {approval_mode} authorized the verified implementation"
|
|
3496
|
+
)
|
|
3497
|
+
record = {
|
|
3498
|
+
"type": "acceptance",
|
|
3499
|
+
"from_implementation_fingerprint": drift["from_implementation_fingerprint"],
|
|
3500
|
+
"implementation_fingerprint": drift["implementation_fingerprint"],
|
|
3501
|
+
"config_fingerprint": drift["config_fingerprint"],
|
|
3502
|
+
"diff_sha256": drift["diff_sha256"],
|
|
3503
|
+
"changed_files": changed_files,
|
|
3504
|
+
"authorization": authorization,
|
|
3505
|
+
"approval_mode": approval_mode,
|
|
3506
|
+
"review_policy": review_policy,
|
|
3507
|
+
"verification_policy": verification_policy,
|
|
3508
|
+
"required_targeted_source_tasks": required_targeted_source_tasks,
|
|
3509
|
+
"summary": normalized_summary,
|
|
3510
|
+
"recorded_by": agent,
|
|
3511
|
+
"timestamp": now_iso(),
|
|
3512
|
+
}
|
|
3513
|
+
existing = latest_acceptance_record(root, task_id, task)
|
|
3514
|
+
identity_fields = (
|
|
3515
|
+
"from_implementation_fingerprint",
|
|
3516
|
+
"implementation_fingerprint",
|
|
3517
|
+
"config_fingerprint",
|
|
3518
|
+
"diff_sha256",
|
|
3519
|
+
"authorization",
|
|
3520
|
+
"approval_mode",
|
|
3521
|
+
"review_policy",
|
|
3522
|
+
"verification_policy",
|
|
3523
|
+
"required_targeted_source_tasks",
|
|
3524
|
+
"summary",
|
|
3525
|
+
)
|
|
3526
|
+
if not (
|
|
3527
|
+
isinstance(existing, dict)
|
|
3528
|
+
and existing.get("changed_files") == changed_files
|
|
3529
|
+
and all(existing.get(field) == record.get(field) for field in identity_fields)
|
|
3530
|
+
):
|
|
3531
|
+
append_execution_record(root, task_id, record)
|
|
3532
|
+
return record
|
|
3533
|
+
|
|
3534
|
+
|
|
3535
|
+
def targeted_source_tasks_for_changes(
|
|
3536
|
+
root: Path,
|
|
3537
|
+
task_id: str,
|
|
3538
|
+
task: dict,
|
|
3539
|
+
changes: list[dict],
|
|
3540
|
+
) -> list[str]:
|
|
3541
|
+
if not isinstance(task.get("spec_source"), dict):
|
|
3542
|
+
return []
|
|
3543
|
+
plan = latest_execution_plan(root, task_id)
|
|
3544
|
+
repo_paths = task.get("repo_paths")
|
|
3545
|
+
if plan is None or not isinstance(repo_paths, dict):
|
|
3546
|
+
raise StateError("Canonical targeted verification requires a valid repository plan.")
|
|
3547
|
+
|
|
3548
|
+
units_by_repository: dict[str, list[dict]] = {}
|
|
3549
|
+
for unit in plan.get("units", []):
|
|
3550
|
+
if not isinstance(unit, dict) or not is_non_empty_string(unit.get("repo_id")):
|
|
3551
|
+
continue
|
|
3552
|
+
raw_repository = repo_paths.get(str(unit["repo_id"]))
|
|
3553
|
+
if not is_non_empty_string(raw_repository):
|
|
3554
|
+
continue
|
|
3555
|
+
candidate = Path(str(raw_repository))
|
|
3556
|
+
repository = (candidate if candidate.is_absolute() else root / candidate).resolve()
|
|
3557
|
+
units_by_repository.setdefault(display_path(root, repository), []).append(unit)
|
|
3558
|
+
|
|
3559
|
+
impacted: set[str] = set()
|
|
3560
|
+
for change in changes:
|
|
3561
|
+
if not isinstance(change, dict):
|
|
3562
|
+
continue
|
|
3563
|
+
repository_units = units_by_repository.get(str(change.get("repository") or ""), [])
|
|
3564
|
+
if not repository_units:
|
|
3565
|
+
continue
|
|
3566
|
+
changed_path = str(change.get("path") or "")
|
|
3567
|
+
matched_units = [
|
|
3568
|
+
unit
|
|
3569
|
+
for unit in repository_units
|
|
3570
|
+
if any(
|
|
3571
|
+
changed_path == str(file_name)
|
|
3572
|
+
or changed_path.startswith(str(file_name).rstrip("/") + "/")
|
|
3573
|
+
for file_name in unit.get("files", [])
|
|
3574
|
+
if is_non_empty_string(file_name)
|
|
3575
|
+
)
|
|
3576
|
+
]
|
|
3577
|
+
scoped_units = matched_units or repository_units
|
|
3578
|
+
impacted.update(
|
|
3579
|
+
str(unit["source_task_id"])
|
|
3580
|
+
for unit in scoped_units
|
|
3581
|
+
if is_non_empty_string(unit.get("source_task_id"))
|
|
3582
|
+
)
|
|
3583
|
+
if not impacted:
|
|
3584
|
+
raise StateError(
|
|
3585
|
+
"Canonical executable drift could not be mapped to a selected source task."
|
|
3586
|
+
)
|
|
3587
|
+
return sorted(impacted)
|
|
3588
|
+
|
|
3589
|
+
|
|
2105
3590
|
def command_option_value(command: str, option: str) -> str | None:
|
|
2106
3591
|
try:
|
|
2107
3592
|
tokens = shlex.split(command)
|
|
@@ -2133,19 +3618,81 @@ def coverage_command_matches_frozen_contract(
|
|
|
2133
3618
|
)
|
|
2134
3619
|
|
|
2135
3620
|
|
|
2136
|
-
def
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
3621
|
+
def current_acceptance_record(
|
|
3622
|
+
root: Path,
|
|
3623
|
+
task_id: str,
|
|
3624
|
+
task: dict,
|
|
3625
|
+
implementation_fingerprint_value: str,
|
|
3626
|
+
config_fingerprint_value: str,
|
|
3627
|
+
) -> dict | None:
|
|
3628
|
+
record = latest_acceptance_record(root, task_id, task)
|
|
3629
|
+
if not isinstance(record, dict):
|
|
3630
|
+
return None
|
|
3631
|
+
if (
|
|
3632
|
+
record.get("implementation_fingerprint") != implementation_fingerprint_value
|
|
3633
|
+
or record.get("config_fingerprint") != config_fingerprint_value
|
|
3634
|
+
or not is_non_empty_string(record.get("from_implementation_fingerprint"))
|
|
3635
|
+
or record.get("review_policy")
|
|
3636
|
+
not in {"current", "user-accepted-without-rereview"}
|
|
3637
|
+
or record.get("verification_policy")
|
|
3638
|
+
not in {"current", *ACCEPTANCE_VERIFICATION_POLICIES}
|
|
3639
|
+
or not is_string_list(record.get("required_targeted_source_tasks"))
|
|
3640
|
+
):
|
|
3641
|
+
return None
|
|
3642
|
+
return record
|
|
3643
|
+
|
|
3644
|
+
|
|
3645
|
+
def accepted_review_fingerprints(
|
|
3646
|
+
root: Path, task_id: str, task: dict, current_fingerprint: str
|
|
3647
|
+
) -> set[str]:
|
|
3648
|
+
accepted = {current_fingerprint}
|
|
3649
|
+
record = current_acceptance_record(
|
|
3650
|
+
root,
|
|
3651
|
+
task_id,
|
|
3652
|
+
task,
|
|
3653
|
+
current_fingerprint,
|
|
3654
|
+
behavior_config_fingerprint(root, task),
|
|
3655
|
+
)
|
|
3656
|
+
if record and record.get("review_policy") == "user-accepted-without-rereview":
|
|
3657
|
+
accepted.add(str(record["from_implementation_fingerprint"]))
|
|
3658
|
+
return accepted
|
|
3659
|
+
|
|
3660
|
+
|
|
3661
|
+
def accepted_verification_fingerprints(
|
|
3662
|
+
root: Path,
|
|
3663
|
+
task_id: str,
|
|
3664
|
+
task: dict,
|
|
3665
|
+
current_implementation: str,
|
|
3666
|
+
current_config: str,
|
|
3667
|
+
) -> tuple[set[str], dict | None]:
|
|
3668
|
+
record = current_acceptance_record(
|
|
3669
|
+
root,
|
|
3670
|
+
task_id,
|
|
3671
|
+
task,
|
|
3672
|
+
current_implementation,
|
|
3673
|
+
current_config,
|
|
3674
|
+
)
|
|
3675
|
+
if not record or record.get("verification_policy") == "current":
|
|
3676
|
+
return {current_implementation}, record
|
|
3677
|
+
previous = str(record["from_implementation_fingerprint"])
|
|
3678
|
+
if record.get("verification_policy") == "targeted":
|
|
3679
|
+
return {previous, current_implementation}, record
|
|
3680
|
+
return {previous}, record
|
|
3681
|
+
|
|
3682
|
+
|
|
3683
|
+
def validate_spec_implementation_results(root: Path, task_id: str, task: dict) -> None:
|
|
3684
|
+
if not isinstance(task.get("spec_source"), dict):
|
|
3685
|
+
return
|
|
3686
|
+
plan = latest_execution_plan(root, task_id)
|
|
3687
|
+
if plan is None or not is_valid_spec_execution_plan(root, task, plan):
|
|
3688
|
+
raise StateError("Canonical Spec implementation has no valid source-traceable plan.")
|
|
3689
|
+
unit_by_id = {
|
|
3690
|
+
str(unit["id"]): unit for unit in plan.get("units", []) if isinstance(unit, dict)
|
|
3691
|
+
}
|
|
3692
|
+
records = execution_records(root, task_id)
|
|
3693
|
+
latest_plan_index = max(
|
|
3694
|
+
(index for index, record in enumerate(records) if record.get("type") == "plan"),
|
|
3695
|
+
default=-1,
|
|
2149
3696
|
)
|
|
2150
3697
|
lifecycle_by_unit: dict[str, list[dict]] = {unit_id: [] for unit_id in unit_by_id}
|
|
2151
3698
|
for record in records[latest_plan_index + 1 :]:
|
|
@@ -2209,11 +3756,12 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
|
|
|
2209
3756
|
if task.get("workflow_mode_legacy") is True and not is_spec_task:
|
|
2210
3757
|
return
|
|
2211
3758
|
expected = implementation_fingerprint(root, task_id)
|
|
3759
|
+
accepted_fingerprints = accepted_review_fingerprints(root, task_id, task, expected)
|
|
2212
3760
|
latest_by_dimension: dict[str, dict] = {}
|
|
2213
3761
|
for record in execution_records(root, task_id):
|
|
2214
3762
|
if (
|
|
2215
3763
|
record.get("type") == "review"
|
|
2216
|
-
and record.get("implementation_fingerprint")
|
|
3764
|
+
and record.get("implementation_fingerprint") in accepted_fingerprints
|
|
2217
3765
|
and is_non_empty_string(record.get("dimension"))
|
|
2218
3766
|
):
|
|
2219
3767
|
dimension = str(record["dimension"])
|
|
@@ -2328,6 +3876,13 @@ def validate_review_readiness(root: Path, task_id: str, task: dict) -> None:
|
|
|
2328
3876
|
|
|
2329
3877
|
def validate_verification_readiness(root: Path, task_id: str, task: dict) -> None:
|
|
2330
3878
|
fingerprints = evidence_fingerprints(root, task_id)
|
|
3879
|
+
accepted_fingerprints, acceptance = accepted_verification_fingerprints(
|
|
3880
|
+
root,
|
|
3881
|
+
task_id,
|
|
3882
|
+
task,
|
|
3883
|
+
fingerprints["implementation_fingerprint"],
|
|
3884
|
+
fingerprints["config_fingerprint"],
|
|
3885
|
+
)
|
|
2331
3886
|
is_spec_task = isinstance(task.get("spec_source"), dict)
|
|
2332
3887
|
if (
|
|
2333
3888
|
(task.get("workflow_mode_legacy") is not True or is_spec_task)
|
|
@@ -2339,11 +3894,17 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
2339
3894
|
for record in execution_records(root, task_id):
|
|
2340
3895
|
if (
|
|
2341
3896
|
record.get("type") == "verify"
|
|
2342
|
-
and record.get("implementation_fingerprint")
|
|
2343
|
-
== fingerprints["implementation_fingerprint"]
|
|
3897
|
+
and record.get("implementation_fingerprint") in accepted_fingerprints
|
|
2344
3898
|
and record.get("config_fingerprint") == fingerprints["config_fingerprint"]
|
|
2345
3899
|
and is_non_empty_string(record.get("check"))
|
|
2346
3900
|
):
|
|
3901
|
+
if (
|
|
3902
|
+
task.get("tdd_enabled") is True
|
|
3903
|
+
and record.get("check_type") == "coverage"
|
|
3904
|
+
and record.get("coverage_scope") == "gitlab"
|
|
3905
|
+
):
|
|
3906
|
+
# 远程 CI 只作为生成的自动化能力,历史 pending/failed 记录不再参与本地验收。
|
|
3907
|
+
continue
|
|
2347
3908
|
check = str(record["check"])
|
|
2348
3909
|
if task.get("tdd_enabled") is True and record.get("check_type") == "coverage":
|
|
2349
3910
|
check = f"{check}\0{record.get('coverage_scope') or ''}"
|
|
@@ -2411,6 +3972,39 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
2411
3972
|
raise StateError(
|
|
2412
3973
|
"VERIFICATION cannot advance to MEMORY while current verification evidence contains failures."
|
|
2413
3974
|
)
|
|
3975
|
+
if acceptance and acceptance.get("verification_policy") == "targeted":
|
|
3976
|
+
current_records = [
|
|
3977
|
+
record
|
|
3978
|
+
for record in applicable_records
|
|
3979
|
+
if record.get("implementation_fingerprint")
|
|
3980
|
+
== fingerprints["implementation_fingerprint"]
|
|
3981
|
+
]
|
|
3982
|
+
if not current_records or any(record.get("passed") is not True for record in current_records):
|
|
3983
|
+
raise StateError(
|
|
3984
|
+
"Accepted executable drift requires at least one passed targeted verification "
|
|
3985
|
+
"record for the current implementation fingerprint."
|
|
3986
|
+
)
|
|
3987
|
+
if is_spec_task:
|
|
3988
|
+
required_source_tasks = set(acceptance["required_targeted_source_tasks"])
|
|
3989
|
+
current_source_tasks = {
|
|
3990
|
+
str(record.get("source_task_id"))
|
|
3991
|
+
for record in current_records
|
|
3992
|
+
if is_non_empty_string(record.get("source_task_id"))
|
|
3993
|
+
}
|
|
3994
|
+
missing_source_tasks = sorted(required_source_tasks - current_source_tasks)
|
|
3995
|
+
if missing_source_tasks:
|
|
3996
|
+
raise StateError(
|
|
3997
|
+
"Accepted Canonical executable drift requires a passed current-fingerprint "
|
|
3998
|
+
"targeted verification record for affected source tasks: "
|
|
3999
|
+
+ ", ".join(missing_source_tasks)
|
|
4000
|
+
)
|
|
4001
|
+
if str(task.get("type") or "").strip().lower() == TDD_INIT_TASK_TYPE:
|
|
4002
|
+
readiness = tdd_readiness(root)
|
|
4003
|
+
if readiness["status"] != "ready":
|
|
4004
|
+
raise StateError(
|
|
4005
|
+
"TDD initialization cannot advance to MEMORY until readiness passes: "
|
|
4006
|
+
+ "; ".join(str(reason) for reason in readiness["reasons"])
|
|
4007
|
+
)
|
|
2414
4008
|
if task.get("tdd_enabled") is not True and any(
|
|
2415
4009
|
record.get("check_type") == "coverage" for record in latest_by_check.values()
|
|
2416
4010
|
):
|
|
@@ -2418,6 +4012,13 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
2418
4012
|
"Coverage verification evidence is not allowed when the frozen TDD mode is off."
|
|
2419
4013
|
)
|
|
2420
4014
|
if task.get("tdd_enabled") is True:
|
|
4015
|
+
require_tdd_readiness(root)
|
|
4016
|
+
test_records = [
|
|
4017
|
+
record
|
|
4018
|
+
for record in latest_by_check.values()
|
|
4019
|
+
if record.get("check_type") == "test"
|
|
4020
|
+
and record.get("applicable") is not False
|
|
4021
|
+
]
|
|
2421
4022
|
coverage_records = [
|
|
2422
4023
|
record
|
|
2423
4024
|
for record in latest_by_check.values()
|
|
@@ -2428,6 +4029,17 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
2428
4029
|
"TDD verification requires changed-production-line JaCoCo coverage evidence."
|
|
2429
4030
|
)
|
|
2430
4031
|
if is_spec_task:
|
|
4032
|
+
tested_source_tasks = {
|
|
4033
|
+
str(record.get("source_task_id") or "") for record in test_records
|
|
4034
|
+
}
|
|
4035
|
+
missing_test_tasks = sorted(
|
|
4036
|
+
set(task_repositories) - tested_source_tasks
|
|
4037
|
+
)
|
|
4038
|
+
if missing_test_tasks:
|
|
4039
|
+
raise StateError(
|
|
4040
|
+
"TDD Canonical verification requires local unit-test evidence for every selected source task: "
|
|
4041
|
+
+ ", ".join(missing_test_tasks)
|
|
4042
|
+
)
|
|
2431
4043
|
covered_source_tasks = {
|
|
2432
4044
|
str(record.get("source_task_id") or "") for record in coverage_records
|
|
2433
4045
|
}
|
|
@@ -2439,19 +4051,16 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
2439
4051
|
"TDD Canonical verification requires separate coverage evidence for every selected source task: "
|
|
2440
4052
|
+ ", ".join(missing_coverage_tasks)
|
|
2441
4053
|
)
|
|
2442
|
-
|
|
4054
|
+
elif not test_records:
|
|
4055
|
+
raise StateError(
|
|
4056
|
+
"TDD verification requires passed local unit-test evidence."
|
|
4057
|
+
)
|
|
2443
4058
|
for record in coverage_records:
|
|
2444
4059
|
scope = str(record.get("coverage_scope") or "")
|
|
2445
|
-
if scope
|
|
4060
|
+
if scope != "local":
|
|
2446
4061
|
raise StateError(
|
|
2447
|
-
"TDD coverage evidence must identify coverage_scope as local
|
|
4062
|
+
"TDD coverage evidence must identify coverage_scope as local."
|
|
2448
4063
|
)
|
|
2449
|
-
owner = (
|
|
2450
|
-
str(record.get("source_task_id") or "")
|
|
2451
|
-
if is_spec_task
|
|
2452
|
-
else "project"
|
|
2453
|
-
)
|
|
2454
|
-
coverage_scopes_by_owner.setdefault(owner, set()).add(scope)
|
|
2455
4064
|
expected_threshold = task.get("tdd_coverage_threshold")
|
|
2456
4065
|
expected_baselines = task.get("tdd_baselines")
|
|
2457
4066
|
if (
|
|
@@ -2466,18 +4075,6 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
2466
4075
|
coverage = record.get("coverage")
|
|
2467
4076
|
if not isinstance(coverage, dict):
|
|
2468
4077
|
raise StateError("TDD coverage evidence must include the coverage result object.")
|
|
2469
|
-
if record.get("coverage_scope") == "gitlab":
|
|
2470
|
-
ci = record.get("ci")
|
|
2471
|
-
if (
|
|
2472
|
-
not isinstance(ci, dict)
|
|
2473
|
-
or ci.get("provider") != "gitlab"
|
|
2474
|
-
or ci.get("status") != "success"
|
|
2475
|
-
or not is_non_empty_string(ci.get("pipeline_url"))
|
|
2476
|
-
or not is_non_empty_string(ci.get("job_name"))
|
|
2477
|
-
):
|
|
2478
|
-
raise StateError(
|
|
2479
|
-
"GitLab coverage evidence requires a successful pipeline URL and job name."
|
|
2480
|
-
)
|
|
2481
4078
|
total = coverage.get("total_lines")
|
|
2482
4079
|
covered = coverage.get("covered_lines")
|
|
2483
4080
|
percentage = coverage.get("percentage")
|
|
@@ -2532,18 +4129,6 @@ def validate_verification_readiness(root: Path, task_id: str, task: dict) -> Non
|
|
|
2532
4129
|
raise StateError(
|
|
2533
4130
|
f"TDD changed-line coverage must meet the frozen {threshold}% threshold."
|
|
2534
4131
|
)
|
|
2535
|
-
expected_coverage_owners = set(task_repositories) if is_spec_task else {"project"}
|
|
2536
|
-
missing_scopes = [
|
|
2537
|
-
f"{owner}:{scope}"
|
|
2538
|
-
for owner in sorted(expected_coverage_owners)
|
|
2539
|
-
for scope in ("local", "gitlab")
|
|
2540
|
-
if scope not in coverage_scopes_by_owner.get(owner, set())
|
|
2541
|
-
]
|
|
2542
|
-
if missing_scopes:
|
|
2543
|
-
raise StateError(
|
|
2544
|
-
"TDD verification requires both local and successful GitLab coverage gates: "
|
|
2545
|
-
+ ", ".join(missing_scopes)
|
|
2546
|
-
)
|
|
2547
4132
|
if task.get("workflow_mode") == "strict":
|
|
2548
4133
|
if is_spec_task:
|
|
2549
4134
|
check_types_by_repository: dict[str, set[str]] = {
|
|
@@ -2723,19 +4308,35 @@ def validate_read_only_completion(root: Path, task_id: str) -> None:
|
|
|
2723
4308
|
)
|
|
2724
4309
|
|
|
2725
4310
|
|
|
4311
|
+
def markdown_fence_token(line: str) -> tuple[str, int, str] | None:
|
|
4312
|
+
stripped = line.lstrip()
|
|
4313
|
+
if not stripped or stripped[0] not in {"`", "~"}:
|
|
4314
|
+
return None
|
|
4315
|
+
marker = stripped[0]
|
|
4316
|
+
run_length = len(stripped) - len(stripped.lstrip(marker))
|
|
4317
|
+
if run_length < 3:
|
|
4318
|
+
return None
|
|
4319
|
+
return marker, run_length, stripped[run_length:]
|
|
4320
|
+
|
|
4321
|
+
|
|
2726
4322
|
def markdown_headings(content: str) -> list[tuple[int, int, str]]:
|
|
2727
4323
|
headings: list[tuple[int, int, str]] = []
|
|
2728
|
-
fence_marker: str | None = None
|
|
4324
|
+
fence_marker: tuple[str, int] | None = None
|
|
2729
4325
|
for index, line in enumerate(content.splitlines()):
|
|
2730
|
-
|
|
2731
|
-
if
|
|
2732
|
-
marker =
|
|
2733
|
-
if
|
|
2734
|
-
|
|
2735
|
-
|
|
4326
|
+
fence = markdown_fence_token(line)
|
|
4327
|
+
if fence_marker is not None:
|
|
4328
|
+
marker, run_length, remainder = fence or ("", 0, "")
|
|
4329
|
+
if (
|
|
4330
|
+
marker == fence_marker[0]
|
|
4331
|
+
and run_length >= fence_marker[1]
|
|
4332
|
+
and not remainder.strip()
|
|
4333
|
+
):
|
|
2736
4334
|
fence_marker = None
|
|
2737
4335
|
continue
|
|
2738
|
-
if
|
|
4336
|
+
if fence is not None:
|
|
4337
|
+
marker, run_length, remainder = fence
|
|
4338
|
+
if marker != "`" or "`" not in remainder:
|
|
4339
|
+
fence_marker = (marker, run_length)
|
|
2739
4340
|
continue
|
|
2740
4341
|
match = MARKDOWN_HEADING_PATTERN.match(line.strip())
|
|
2741
4342
|
if match:
|
|
@@ -2743,6 +4344,55 @@ def markdown_headings(content: str) -> list[tuple[int, int, str]]:
|
|
|
2743
4344
|
return headings
|
|
2744
4345
|
|
|
2745
4346
|
|
|
4347
|
+
def markdown_section_body(content: str, title: str, level: int = 3) -> str | None:
|
|
4348
|
+
lines = content.splitlines()
|
|
4349
|
+
headings = markdown_headings(content)
|
|
4350
|
+
heading_index = next(
|
|
4351
|
+
(
|
|
4352
|
+
index
|
|
4353
|
+
for index, (_, heading_level, heading_title) in enumerate(headings)
|
|
4354
|
+
if heading_level == level and heading_title == title
|
|
4355
|
+
),
|
|
4356
|
+
None,
|
|
4357
|
+
)
|
|
4358
|
+
if heading_index is None:
|
|
4359
|
+
return None
|
|
4360
|
+
line_index, heading_level, _ = headings[heading_index]
|
|
4361
|
+
next_line_index = len(lines)
|
|
4362
|
+
for candidate_line, candidate_level, _ in headings[heading_index + 1 :]:
|
|
4363
|
+
if candidate_level <= heading_level:
|
|
4364
|
+
next_line_index = candidate_line
|
|
4365
|
+
break
|
|
4366
|
+
return "\n".join(lines[line_index + 1 : next_line_index])
|
|
4367
|
+
|
|
4368
|
+
|
|
4369
|
+
def markdown_standalone_field_values(content: str, pattern: re.Pattern[str]) -> list[str]:
|
|
4370
|
+
values: list[str] = []
|
|
4371
|
+
fence_marker: tuple[str, int] | None = None
|
|
4372
|
+
for line in content.splitlines():
|
|
4373
|
+
fence = markdown_fence_token(line)
|
|
4374
|
+
if fence_marker is not None:
|
|
4375
|
+
marker, run_length, remainder = fence or ("", 0, "")
|
|
4376
|
+
if (
|
|
4377
|
+
marker == fence_marker[0]
|
|
4378
|
+
and run_length >= fence_marker[1]
|
|
4379
|
+
and not remainder.strip()
|
|
4380
|
+
):
|
|
4381
|
+
fence_marker = None
|
|
4382
|
+
continue
|
|
4383
|
+
if fence is not None:
|
|
4384
|
+
marker, run_length, remainder = fence
|
|
4385
|
+
if marker != "`" or "`" not in remainder:
|
|
4386
|
+
fence_marker = (marker, run_length)
|
|
4387
|
+
continue
|
|
4388
|
+
if line.startswith(("\t", " ")):
|
|
4389
|
+
continue
|
|
4390
|
+
match = pattern.fullmatch(line)
|
|
4391
|
+
if match:
|
|
4392
|
+
values.append(match.group(1).strip())
|
|
4393
|
+
return values
|
|
4394
|
+
|
|
4395
|
+
|
|
2746
4396
|
def has_meaningful_markdown_body(content: str) -> bool:
|
|
2747
4397
|
for line in content.splitlines():
|
|
2748
4398
|
stripped = line.strip()
|
|
@@ -2826,7 +4476,7 @@ def validate_analysis_readiness(
|
|
|
2826
4476
|
test_strategy = task_dir / "test-strategy.md"
|
|
2827
4477
|
reasons: list[str] = []
|
|
2828
4478
|
behavior = resolve_behavior(root, session or default_session())
|
|
2829
|
-
tdd_enabled = behavior[8]
|
|
4479
|
+
tdd_enabled = behavior[8] if task_type != TDD_INIT_TASK_TYPE else False
|
|
2830
4480
|
tdd_threshold = behavior[11]
|
|
2831
4481
|
|
|
2832
4482
|
dev_spec_content = ""
|
|
@@ -2859,6 +4509,71 @@ def validate_analysis_readiness(
|
|
|
2859
4509
|
if "[阶段:ANALYSIS]" in dev_spec_content or "### 待用户决策" in dev_spec_content:
|
|
2860
4510
|
reasons.append("dev-spec.md contains forbidden analysis-only sections")
|
|
2861
4511
|
|
|
4512
|
+
decision_headings = [
|
|
4513
|
+
heading
|
|
4514
|
+
for heading in markdown_headings(dev_spec_content)
|
|
4515
|
+
if heading[1] == 3 and heading[2] == "决策闭环"
|
|
4516
|
+
]
|
|
4517
|
+
if len(decision_headings) != 1:
|
|
4518
|
+
reasons.append(
|
|
4519
|
+
"dev-spec.md must contain exactly one `### 决策闭环` section; "
|
|
4520
|
+
f"found {len(decision_headings)}"
|
|
4521
|
+
)
|
|
4522
|
+
decision_section = markdown_section_body(dev_spec_content, "决策闭环") or ""
|
|
4523
|
+
all_decision_statuses = [
|
|
4524
|
+
value.lower()
|
|
4525
|
+
for value in markdown_standalone_field_values(
|
|
4526
|
+
dev_spec_content, DECISION_STATUS_PATTERN
|
|
4527
|
+
)
|
|
4528
|
+
]
|
|
4529
|
+
section_decision_statuses = [
|
|
4530
|
+
value.lower()
|
|
4531
|
+
for value in markdown_standalone_field_values(
|
|
4532
|
+
decision_section, DECISION_STATUS_PATTERN
|
|
4533
|
+
)
|
|
4534
|
+
]
|
|
4535
|
+
if not all_decision_statuses:
|
|
4536
|
+
reasons.append(
|
|
4537
|
+
"dev-spec.md is missing the decision closure marker `decision_status: closed`; "
|
|
4538
|
+
"resume ec-analysis, resolve material questions, and record the conclusions first"
|
|
4539
|
+
)
|
|
4540
|
+
elif len(all_decision_statuses) != 1:
|
|
4541
|
+
reasons.append(
|
|
4542
|
+
"dev-spec.md must contain exactly one decision_status marker; "
|
|
4543
|
+
f"found {len(all_decision_statuses)}"
|
|
4544
|
+
)
|
|
4545
|
+
elif len(section_decision_statuses) != 1:
|
|
4546
|
+
reasons.append(
|
|
4547
|
+
"dev-spec.md decision_status marker must be inside the `### 决策闭环` section"
|
|
4548
|
+
)
|
|
4549
|
+
elif section_decision_statuses[0] != "closed":
|
|
4550
|
+
reasons.append(
|
|
4551
|
+
"dev-spec.md has unresolved material decisions: "
|
|
4552
|
+
f"decision_status is {section_decision_statuses[0]!r}, expected 'closed'"
|
|
4553
|
+
)
|
|
4554
|
+
decision_conclusions = markdown_standalone_field_values(
|
|
4555
|
+
decision_section, DECISION_CONCLUSIONS_PATTERN
|
|
4556
|
+
)
|
|
4557
|
+
decision_evidence = markdown_standalone_field_values(
|
|
4558
|
+
decision_section, DECISION_EVIDENCE_PATTERN
|
|
4559
|
+
)
|
|
4560
|
+
for field_name, values in (
|
|
4561
|
+
("已解决问题与结论", decision_conclusions),
|
|
4562
|
+
("确认依据", decision_evidence),
|
|
4563
|
+
):
|
|
4564
|
+
if len(values) != 1:
|
|
4565
|
+
reasons.append(
|
|
4566
|
+
"dev-spec.md decision closure must contain exactly one non-empty "
|
|
4567
|
+
f"`{field_name}` field; found {len(values)}"
|
|
4568
|
+
)
|
|
4569
|
+
elif UNRESOLVED_DECISION_VALUE_PATTERN.fullmatch(
|
|
4570
|
+
re.sub(r"[`*_]", "", values[0]).strip()
|
|
4571
|
+
):
|
|
4572
|
+
reasons.append(
|
|
4573
|
+
"dev-spec.md has unresolved decision closure evidence: "
|
|
4574
|
+
f"`{field_name}` is {values[0]!r}"
|
|
4575
|
+
)
|
|
4576
|
+
|
|
2862
4577
|
if not skeleton.exists():
|
|
2863
4578
|
reasons.append("dev-spec skeleton template is missing")
|
|
2864
4579
|
else:
|
|
@@ -2875,6 +4590,12 @@ def validate_analysis_readiness(
|
|
|
2875
4590
|
if not plan_is_valid:
|
|
2876
4591
|
reasons.append("execution.jsonl has no valid plan record")
|
|
2877
4592
|
if tdd_enabled and not is_read_only_task:
|
|
4593
|
+
readiness = tdd_readiness(root)
|
|
4594
|
+
if readiness["status"] != "ready":
|
|
4595
|
+
reasons.append(
|
|
4596
|
+
"TDD infrastructure is not ready; run ec-tdd-init first: "
|
|
4597
|
+
+ "; ".join(str(reason) for reason in readiness["reasons"])
|
|
4598
|
+
)
|
|
2878
4599
|
plan = latest_execution_plan(root, task_id) or {}
|
|
2879
4600
|
if re.search(
|
|
2880
4601
|
r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
|
|
@@ -2902,7 +4623,13 @@ def validate_analysis_readiness(
|
|
|
2902
4623
|
strategy_content = test_strategy.read_text(encoding="utf-8")
|
|
2903
4624
|
except OSError:
|
|
2904
4625
|
strategy_content = ""
|
|
2905
|
-
required_tdd_markers = [
|
|
4626
|
+
required_tdd_markers = [
|
|
4627
|
+
"TDD",
|
|
4628
|
+
"JaCoCo",
|
|
4629
|
+
"baseline",
|
|
4630
|
+
"local_test_gate: required",
|
|
4631
|
+
"remote_ci_acceptance: non-blocking",
|
|
4632
|
+
]
|
|
2906
4633
|
missing_tdd_markers = [
|
|
2907
4634
|
marker for marker in required_tdd_markers if marker.lower() not in strategy_content.lower()
|
|
2908
4635
|
]
|
|
@@ -2924,6 +4651,32 @@ def validate_analysis_readiness(
|
|
|
2924
4651
|
dev_spec_content, strategy_content, baselines
|
|
2925
4652
|
)
|
|
2926
4653
|
)
|
|
4654
|
+
elif task_type == TDD_INIT_TASK_TYPE:
|
|
4655
|
+
try:
|
|
4656
|
+
strategy_content = test_strategy.read_text(encoding="utf-8")
|
|
4657
|
+
except OSError:
|
|
4658
|
+
strategy_content = ""
|
|
4659
|
+
required_init_markers = [
|
|
4660
|
+
"JaCoCo",
|
|
4661
|
+
"GitLab",
|
|
4662
|
+
"changed production lines",
|
|
4663
|
+
"historical coverage required: no",
|
|
4664
|
+
"easy_coding_tdd_readiness.py",
|
|
4665
|
+
]
|
|
4666
|
+
missing_init_markers = [
|
|
4667
|
+
marker
|
|
4668
|
+
for marker in required_init_markers
|
|
4669
|
+
if marker.lower() not in strategy_content.lower()
|
|
4670
|
+
]
|
|
4671
|
+
if missing_init_markers:
|
|
4672
|
+
reasons.append(
|
|
4673
|
+
"TDD initialization strategy is missing: "
|
|
4674
|
+
+ ", ".join(missing_init_markers)
|
|
4675
|
+
)
|
|
4676
|
+
if re.search(
|
|
4677
|
+
r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
|
|
4678
|
+
):
|
|
4679
|
+
reasons.append("tdd-init must keep TDD off and omit the TDD Mode section")
|
|
2927
4680
|
elif not is_read_only_task:
|
|
2928
4681
|
if re.search(
|
|
2929
4682
|
r"^###\s+TDD Mode\s*$", dev_spec_content, re.MULTILINE | re.IGNORECASE
|
|
@@ -3103,6 +4856,28 @@ def latest_handoff_record(root: Path, task_id: str) -> dict | None:
|
|
|
3103
4856
|
return latest
|
|
3104
4857
|
|
|
3105
4858
|
|
|
4859
|
+
def pending_handoff_record(root: Path, task_id: str) -> dict | None:
|
|
4860
|
+
path = execution_log_path(root, task_id)
|
|
4861
|
+
if not path.exists():
|
|
4862
|
+
return None
|
|
4863
|
+
latest_coordination: dict | None = None
|
|
4864
|
+
try:
|
|
4865
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
4866
|
+
if not line.strip():
|
|
4867
|
+
continue
|
|
4868
|
+
try:
|
|
4869
|
+
record = json.loads(line)
|
|
4870
|
+
except json.JSONDecodeError:
|
|
4871
|
+
continue
|
|
4872
|
+
if isinstance(record, dict) and record.get("type") in {"handoff", "claim"}:
|
|
4873
|
+
latest_coordination = record
|
|
4874
|
+
except OSError:
|
|
4875
|
+
return None
|
|
4876
|
+
if latest_coordination and latest_coordination.get("type") == "handoff":
|
|
4877
|
+
return latest_coordination
|
|
4878
|
+
return None
|
|
4879
|
+
|
|
4880
|
+
|
|
3106
4881
|
def assert_safe_task_id(task_id: str) -> None:
|
|
3107
4882
|
path = Path(task_id)
|
|
3108
4883
|
if not task_id or path.is_absolute() or "/" in task_id or "\\" in task_id or ".." in path.parts:
|
|
@@ -3140,6 +4915,7 @@ def spec_task_summary(task: dict | None) -> dict | None:
|
|
|
3140
4915
|
"selected_spec_tasks": task.get("selected_spec_tasks", []),
|
|
3141
4916
|
"repositories": task.get("spec_repositories", []),
|
|
3142
4917
|
"pending_dependencies": pending_dependencies,
|
|
4918
|
+
"writeback": task.get("spec_writeback_progress"),
|
|
3143
4919
|
}
|
|
3144
4920
|
|
|
3145
4921
|
|
|
@@ -3254,12 +5030,25 @@ def snapshot_state(
|
|
|
3254
5030
|
and status not in {"ANALYSIS", "INIT"}
|
|
3255
5031
|
and isinstance(task_tdd_enabled, bool)
|
|
3256
5032
|
)
|
|
3257
|
-
|
|
5033
|
+
is_tdd_init = bool(
|
|
5034
|
+
task and str(task.get("type") or "").strip().lower() == TDD_INIT_TASK_TYPE
|
|
5035
|
+
)
|
|
5036
|
+
displayed_tdd_enabled = (
|
|
5037
|
+
False if is_tdd_init else task_tdd_enabled if frozen_tdd else effective_tdd_enabled
|
|
5038
|
+
)
|
|
3258
5039
|
displayed_tdd_threshold = (
|
|
3259
5040
|
task_tdd_coverage_threshold
|
|
3260
5041
|
if frozen_tdd and isinstance(task_tdd_coverage_threshold, int)
|
|
3261
5042
|
else effective_tdd_coverage_threshold
|
|
3262
5043
|
)
|
|
5044
|
+
should_check_readiness = bool(
|
|
5045
|
+
effective_tdd_enabled or task_tdd_enabled is True or is_tdd_init
|
|
5046
|
+
)
|
|
5047
|
+
readiness = (
|
|
5048
|
+
tdd_readiness(root)
|
|
5049
|
+
if should_check_readiness
|
|
5050
|
+
else {"status": "not_checked", "reasons": []}
|
|
5051
|
+
)
|
|
3263
5052
|
|
|
3264
5053
|
return {
|
|
3265
5054
|
"session_file": display_path(root, session_path),
|
|
@@ -3291,6 +5080,8 @@ def snapshot_state(
|
|
|
3291
5080
|
"task_tdd_baselines": task.get("tdd_baselines") if task else None,
|
|
3292
5081
|
"displayed_tdd_enabled": displayed_tdd_enabled,
|
|
3293
5082
|
"displayed_tdd_coverage_threshold": displayed_tdd_threshold,
|
|
5083
|
+
"tdd_readiness_status": readiness["status"],
|
|
5084
|
+
"tdd_readiness_reasons": readiness["reasons"],
|
|
3294
5085
|
"spec_summary": spec_task_summary(task),
|
|
3295
5086
|
# Compatibility output aliases for pre-0.9 clients.
|
|
3296
5087
|
"project_confirm_mode": project_approval_mode,
|
|
@@ -3316,9 +5107,10 @@ def build_status_line(
|
|
|
3316
5107
|
if task_id:
|
|
3317
5108
|
status = str(state["status"])
|
|
3318
5109
|
line = f"{status_brand} · `{task_id}` · `{status}`"
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
5110
|
+
handoff = pending_handoff_record(root, str(task_id))
|
|
5111
|
+
handoff_from = handoff.get("from") if handoff else None
|
|
5112
|
+
if agent and handoff_from and not agents_equivalent(handoff_from, agent):
|
|
5113
|
+
line += f" · Handoff -> `{handoff_from}`"
|
|
3322
5114
|
if state["is_terminal"] or state["task_missing"]:
|
|
3323
5115
|
line += f" · {HELP_SUFFIX}"
|
|
3324
5116
|
return line
|
|
@@ -3365,9 +5157,10 @@ def build_machine_breadcrumbs(
|
|
|
3365
5157
|
lines.append(f"[current-task:{task_id}]")
|
|
3366
5158
|
if state["task_missing"]:
|
|
3367
5159
|
lines.append(f"[easy-coding:current-task-missing:{task_id}]")
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
5160
|
+
handoff = pending_handoff_record(root, str(task_id))
|
|
5161
|
+
handoff_from = handoff.get("from") if handoff else None
|
|
5162
|
+
if agent and handoff_from and not agents_equivalent(handoff_from, agent):
|
|
5163
|
+
lines.append(f"[easy-coding:handoff-from:{handoff_from}]")
|
|
3371
5164
|
pending = state.get("pending_transition")
|
|
3372
5165
|
if isinstance(pending, dict):
|
|
3373
5166
|
source = str(pending.get("from") or stage)
|
|
@@ -3385,6 +5178,11 @@ def build_machine_breadcrumbs(
|
|
|
3385
5178
|
lines.append(
|
|
3386
5179
|
"[easy-coding:lite-review-bypass-required:IMPLEMENT->REVIEW]"
|
|
3387
5180
|
)
|
|
5181
|
+
elif pending.get("confirmation_override") == "evidence-drift":
|
|
5182
|
+
lines.append(
|
|
5183
|
+
"[easy-coding:acceptance-drift-confirmation-required]"
|
|
5184
|
+
)
|
|
5185
|
+
lines.append("[easy-coding:transition-confirmation-required]")
|
|
3388
5186
|
elif is_automatic_transition(
|
|
3389
5187
|
source,
|
|
3390
5188
|
target,
|
|
@@ -3674,6 +5472,8 @@ def set_session_tdd(
|
|
|
3674
5472
|
threshold: int | None = None,
|
|
3675
5473
|
session_file: str | Path | None = None,
|
|
3676
5474
|
) -> dict:
|
|
5475
|
+
if enabled:
|
|
5476
|
+
require_tdd_readiness(root)
|
|
3677
5477
|
session = ensure_session(root, session_file)
|
|
3678
5478
|
materialize_legacy_session_behavior(session)
|
|
3679
5479
|
session["tdd_enabled"] = enabled
|
|
@@ -3789,11 +5589,21 @@ def claim_task(root: Path, task_id: str, agent: str, session_file: str | Path |
|
|
|
3789
5589
|
session["last_agent"] = agent
|
|
3790
5590
|
write_session(root, session, session_file)
|
|
3791
5591
|
|
|
5592
|
+
claim = {
|
|
5593
|
+
"type": "claim",
|
|
5594
|
+
"agent": agent,
|
|
5595
|
+
"previous_agent": previous_agent,
|
|
5596
|
+
"action": action,
|
|
5597
|
+
"timestamp": now_iso(),
|
|
5598
|
+
}
|
|
5599
|
+
append_execution_record(root, task_id, claim)
|
|
5600
|
+
|
|
3792
5601
|
snapshot = snapshot_state(root, session_file, session)
|
|
3793
5602
|
snapshot["task_id"] = task_id
|
|
3794
5603
|
snapshot["action"] = action
|
|
3795
5604
|
snapshot["previous_agent"] = previous_agent
|
|
3796
5605
|
snapshot["latest_handoff"] = latest_handoff
|
|
5606
|
+
snapshot["claim"] = claim
|
|
3797
5607
|
return snapshot
|
|
3798
5608
|
|
|
3799
5609
|
|
|
@@ -3836,82 +5646,1441 @@ def create_task(
|
|
|
3836
5646
|
return {"task_id": task_id, "task": task}
|
|
3837
5647
|
|
|
3838
5648
|
|
|
3839
|
-
def
|
|
3840
|
-
|
|
5649
|
+
def create_task_from_spec(
|
|
5650
|
+
root: Path,
|
|
5651
|
+
spec_path: str,
|
|
5652
|
+
spec_task_ids: list[str],
|
|
5653
|
+
task_id: str,
|
|
5654
|
+
task_type: str,
|
|
5655
|
+
title: str,
|
|
5656
|
+
repo_paths: dict[str, str],
|
|
5657
|
+
dependency_evidence: dict[str, str],
|
|
5658
|
+
agent: str,
|
|
5659
|
+
set_current: bool = True,
|
|
5660
|
+
session_file: str | Path | None = None,
|
|
5661
|
+
) -> dict:
|
|
5662
|
+
raw_spec_path = Path(spec_path).expanduser()
|
|
5663
|
+
resolved_spec_path = (
|
|
5664
|
+
raw_spec_path.resolve()
|
|
5665
|
+
if raw_spec_path.is_absolute()
|
|
5666
|
+
else (root / raw_spec_path).resolve()
|
|
5667
|
+
)
|
|
5668
|
+
if not resolved_spec_path.is_file():
|
|
5669
|
+
raise StateError("Canonical Spec path must be an explicitly selected UTF-8 file.")
|
|
3841
5670
|
try:
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
5671
|
+
inspection = inspect_spec(
|
|
5672
|
+
resolved_spec_path,
|
|
5673
|
+
root,
|
|
5674
|
+
repo_paths,
|
|
5675
|
+
spec_task_ids,
|
|
5676
|
+
)
|
|
5677
|
+
selection = select_tasks(inspection, spec_task_ids, dependency_evidence)
|
|
5678
|
+
except EasyDevSpecError as exc:
|
|
5679
|
+
raise StateError(f"Cannot create task from Canonical Spec: {exc}") from exc
|
|
5680
|
+
|
|
5681
|
+
selected_repo_ids = set(selection["selected_repo_ids"])
|
|
5682
|
+
bindings = [
|
|
5683
|
+
binding
|
|
5684
|
+
for binding in inspection["repository_bindings"]
|
|
5685
|
+
if binding.get("repo_id") in selected_repo_ids
|
|
5686
|
+
]
|
|
5687
|
+
if len(bindings) != len(selected_repo_ids):
|
|
5688
|
+
raise StateError("Canonical Spec repository bindings do not cover every selected task.")
|
|
5689
|
+
stored_repo_paths = {
|
|
5690
|
+
str(binding["repo_id"]): str(binding["path"])
|
|
5691
|
+
for binding in bindings
|
|
5692
|
+
}
|
|
5693
|
+
if not isinstance(inspection.get("execution"), dict):
|
|
5694
|
+
raise StateError(
|
|
5695
|
+
"Canonical Spec shared execution is not initialized; run initialize-spec-execution first."
|
|
5696
|
+
)
|
|
5697
|
+
try:
|
|
5698
|
+
source_path = resolved_spec_path.relative_to(root.resolve()).as_posix()
|
|
5699
|
+
path_mode = "project-relative"
|
|
5700
|
+
except ValueError:
|
|
5701
|
+
source_path = str(resolved_spec_path)
|
|
5702
|
+
path_mode = "absolute"
|
|
5703
|
+
fields = {
|
|
5704
|
+
"repos": list(selection["selected_repo_ids"]),
|
|
5705
|
+
"repo_paths": stored_repo_paths,
|
|
5706
|
+
"spec_source": {
|
|
5707
|
+
"schema": inspection["schema"],
|
|
5708
|
+
"spec_id": inspection["spec_id"],
|
|
5709
|
+
"revision": inspection["revision"],
|
|
5710
|
+
"path": source_path,
|
|
5711
|
+
"path_mode": path_mode,
|
|
5712
|
+
"design_sha256": inspection["design_sha256"],
|
|
5713
|
+
"document_sha256": inspection["document_sha256"],
|
|
5714
|
+
"execution_revision": inspection["execution_revision"],
|
|
5715
|
+
},
|
|
5716
|
+
"selected_spec_tasks": selection["selected_task_ids"],
|
|
5717
|
+
"spec_repositories": bindings,
|
|
5718
|
+
"spec_dependency_evidence": selection["dependency_records"],
|
|
5719
|
+
"spec_writeback_progress": {
|
|
5720
|
+
"last_execution_revision": inspection["execution_revision"],
|
|
5721
|
+
"status": "ok",
|
|
5722
|
+
"updated_at": now_iso(),
|
|
5723
|
+
},
|
|
5724
|
+
}
|
|
5725
|
+
return create_task(
|
|
5726
|
+
root,
|
|
5727
|
+
task_id,
|
|
5728
|
+
task_type,
|
|
5729
|
+
title,
|
|
5730
|
+
agent,
|
|
5731
|
+
set_current,
|
|
5732
|
+
session_file,
|
|
5733
|
+
fields,
|
|
5734
|
+
)
|
|
5735
|
+
|
|
5736
|
+
|
|
5737
|
+
SPEC_WRITEBACK_APP = "easy-coding"
|
|
5738
|
+
|
|
5739
|
+
|
|
5740
|
+
def spec_writeback_agent(agent: str) -> str:
|
|
5741
|
+
normalized = canonical_agent_identity(agent)
|
|
5742
|
+
if normalized is None:
|
|
5743
|
+
raise StateError("Canonical Spec attribution requires a canonical workflow agent identity.")
|
|
5744
|
+
display_name = {
|
|
5745
|
+
"claude-code": "Claude Code",
|
|
5746
|
+
"codex": "Codex",
|
|
5747
|
+
"qoder": "Qoder",
|
|
5748
|
+
"unknown": "Unknown Agent",
|
|
5749
|
+
}.get(normalized, normalized)
|
|
5750
|
+
return f"{display_name} with Easy Coding"
|
|
5751
|
+
|
|
5752
|
+
|
|
5753
|
+
def initialize_spec_execution_state(root: Path, spec_path: str) -> dict:
|
|
5754
|
+
raw_path = Path(spec_path).expanduser()
|
|
5755
|
+
resolved = raw_path.resolve() if raw_path.is_absolute() else (root / raw_path).resolve()
|
|
5756
|
+
if not resolved.is_file():
|
|
5757
|
+
raise StateError("Canonical Spec path must identify an explicit UTF-8 file.")
|
|
5758
|
+
try:
|
|
5759
|
+
execution = initialize_execution(resolved)
|
|
5760
|
+
details = show_execution(resolved)
|
|
5761
|
+
except (ExecutionStateError, ExecutionConflictError) as exc:
|
|
5762
|
+
raise StateError(f"Cannot initialize Canonical Spec execution: {exc}") from exc
|
|
5763
|
+
return {
|
|
5764
|
+
"action": "initialize-spec-execution",
|
|
5765
|
+
"spec": str(resolved),
|
|
5766
|
+
"design_sha256": details["design_sha256"],
|
|
5767
|
+
"document_sha256": details["document_sha256"],
|
|
5768
|
+
"execution_revision": execution["execution_revision"],
|
|
5769
|
+
}
|
|
5770
|
+
|
|
5771
|
+
|
|
5772
|
+
def _spec_event(execution: dict, idempotency_key: str) -> dict:
|
|
5773
|
+
matches = [
|
|
5774
|
+
event
|
|
5775
|
+
for event in execution.get("events", [])
|
|
5776
|
+
if isinstance(event, dict) and event.get("idempotency_key") == idempotency_key
|
|
5777
|
+
]
|
|
5778
|
+
if len(matches) != 1:
|
|
5779
|
+
raise StateError("Shared Spec writeback did not expose one matching idempotent event.")
|
|
5780
|
+
return matches[0]
|
|
5781
|
+
|
|
5782
|
+
|
|
5783
|
+
def _writeback_progress(task: dict) -> dict:
|
|
5784
|
+
progress = task.get("spec_writeback_progress")
|
|
5785
|
+
if not isinstance(progress, dict):
|
|
5786
|
+
progress = {}
|
|
5787
|
+
task["spec_writeback_progress"] = progress
|
|
5788
|
+
return progress
|
|
5789
|
+
|
|
5790
|
+
|
|
5791
|
+
def _is_idempotency_key_conflict(exc: ExecutionConflictError) -> bool:
|
|
5792
|
+
return str(exc).startswith("幂等键已被不同事件使用")
|
|
5793
|
+
|
|
5794
|
+
|
|
5795
|
+
def _execute_spec_writeback(
|
|
5796
|
+
root: Path,
|
|
5797
|
+
harness_task_id: str,
|
|
5798
|
+
task: dict,
|
|
5799
|
+
action: dict,
|
|
5800
|
+
idempotency_key: str,
|
|
5801
|
+
invoke,
|
|
5802
|
+
) -> dict:
|
|
5803
|
+
inspection, _ = inspect_task_spec(root, task)
|
|
5804
|
+
source = task["spec_source"]
|
|
5805
|
+
progress = _writeback_progress(task)
|
|
5806
|
+
serialized_action = json.dumps(action, ensure_ascii=False, sort_keys=True)
|
|
5807
|
+
existing_pending = progress.get("pending_action")
|
|
5808
|
+
if isinstance(existing_pending, str) and existing_pending.strip():
|
|
5809
|
+
try:
|
|
5810
|
+
existing_action = json.loads(existing_pending)
|
|
5811
|
+
except json.JSONDecodeError as exc:
|
|
5812
|
+
raise StateError("Pending Canonical Spec writeback metadata is invalid JSON.") from exc
|
|
5813
|
+
if existing_action != action:
|
|
5814
|
+
raise StateError(
|
|
5815
|
+
"A different Canonical Spec writeback is pending; run "
|
|
5816
|
+
"reconcile-spec-execution before starting another action."
|
|
5817
|
+
)
|
|
5818
|
+
progress.update(
|
|
5819
|
+
{
|
|
5820
|
+
"last_execution_revision": source["execution_revision"],
|
|
5821
|
+
"pending_action": serialized_action,
|
|
5822
|
+
"status": "pending",
|
|
5823
|
+
"updated_at": now_iso(),
|
|
5824
|
+
}
|
|
5825
|
+
)
|
|
5826
|
+
write_task(root, harness_task_id, task)
|
|
5827
|
+
|
|
5828
|
+
def call_writer(current_inspection: dict) -> dict:
|
|
5829
|
+
return invoke(
|
|
5830
|
+
str(current_inspection["design_sha256"]),
|
|
5831
|
+
int(current_inspection["execution_revision"]),
|
|
5832
|
+
)
|
|
5833
|
+
|
|
5834
|
+
try:
|
|
5835
|
+
execution = call_writer(inspection)
|
|
5836
|
+
except ExecutionConflictError:
|
|
5837
|
+
try:
|
|
5838
|
+
refreshed = inspect_spec(
|
|
5839
|
+
stored_spec_path(root, task),
|
|
5840
|
+
root,
|
|
5841
|
+
task.get("repo_paths") if isinstance(task.get("repo_paths"), dict) else {},
|
|
5842
|
+
task.get("selected_spec_tasks") or [],
|
|
5843
|
+
)
|
|
5844
|
+
except EasyDevSpecError as exc:
|
|
5845
|
+
progress["status"] = "error"
|
|
5846
|
+
progress["updated_at"] = now_iso()
|
|
5847
|
+
progress.pop("pending_action", None)
|
|
5848
|
+
write_task(root, harness_task_id, task)
|
|
5849
|
+
raise StateError(f"Cannot refresh Canonical Spec after CAS conflict: {exc}") from exc
|
|
5850
|
+
if refreshed.get("design_sha256") != source.get("design_sha256"):
|
|
5851
|
+
progress["status"] = "error"
|
|
5852
|
+
progress["updated_at"] = now_iso()
|
|
5853
|
+
progress.pop("pending_action", None)
|
|
5854
|
+
write_task(root, harness_task_id, task)
|
|
5855
|
+
raise StateError("Canonical Spec design changed during writeback; return to ANALYSIS.")
|
|
5856
|
+
if int(refreshed.get("execution_revision", -1)) < int(source["execution_revision"]):
|
|
5857
|
+
progress["status"] = "conflict"
|
|
5858
|
+
progress["updated_at"] = now_iso()
|
|
5859
|
+
write_task(root, harness_task_id, task)
|
|
5860
|
+
raise StateError("Canonical Spec execution revision moved backwards during writeback.")
|
|
5861
|
+
try:
|
|
5862
|
+
execution = call_writer(refreshed)
|
|
5863
|
+
except (ExecutionStateError, ExecutionConflictError) as exc:
|
|
5864
|
+
terminal_conflict = isinstance(
|
|
5865
|
+
exc, ExecutionConflictError
|
|
5866
|
+
) and _is_idempotency_key_conflict(exc)
|
|
5867
|
+
progress["status"] = "error" if terminal_conflict else "conflict"
|
|
5868
|
+
progress["updated_at"] = now_iso()
|
|
5869
|
+
if terminal_conflict:
|
|
5870
|
+
progress.pop("pending_action", None)
|
|
5871
|
+
write_task(root, harness_task_id, task)
|
|
5872
|
+
raise StateError(f"Canonical Spec CAS retry failed: {exc}") from exc
|
|
5873
|
+
except ExecutionStateError as exc:
|
|
5874
|
+
progress["status"] = "error"
|
|
5875
|
+
progress["updated_at"] = now_iso()
|
|
5876
|
+
progress.pop("pending_action", None)
|
|
5877
|
+
write_task(root, harness_task_id, task)
|
|
5878
|
+
raise StateError(f"Canonical Spec writeback failed: {exc}") from exc
|
|
5879
|
+
|
|
5880
|
+
event = _spec_event(execution, idempotency_key)
|
|
5881
|
+
try:
|
|
5882
|
+
details = show_execution(stored_spec_path(root, task))
|
|
5883
|
+
except ExecutionStateError as exc:
|
|
5884
|
+
raise StateError(f"Canonical Spec writeback cannot be verified: {exc}") from exc
|
|
5885
|
+
source.update(
|
|
5886
|
+
{
|
|
5887
|
+
"revision": details["design_revision"],
|
|
5888
|
+
"design_sha256": details["design_sha256"],
|
|
5889
|
+
"document_sha256": details["document_sha256"],
|
|
5890
|
+
"execution_revision": execution["execution_revision"],
|
|
5891
|
+
}
|
|
5892
|
+
)
|
|
5893
|
+
inspect_task_spec(root, task)
|
|
5894
|
+
progress.update(
|
|
5895
|
+
{
|
|
5896
|
+
"last_execution_revision": execution["execution_revision"],
|
|
5897
|
+
"last_event_id": event["event_id"],
|
|
5898
|
+
"last_idempotency_key": idempotency_key,
|
|
5899
|
+
"status": "ok",
|
|
5900
|
+
"updated_at": now_iso(),
|
|
5901
|
+
}
|
|
5902
|
+
)
|
|
5903
|
+
progress.pop("pending_action", None)
|
|
5904
|
+
acknowledgment = {
|
|
5905
|
+
"type": "spec-writeback",
|
|
5906
|
+
"action": action,
|
|
5907
|
+
"event_id": event["event_id"],
|
|
5908
|
+
"execution_revision": execution["execution_revision"],
|
|
5909
|
+
"idempotency_key": idempotency_key,
|
|
5910
|
+
"timestamp": now_iso(),
|
|
5911
|
+
}
|
|
5912
|
+
already_acknowledged = any(
|
|
5913
|
+
record.get("type") == "spec-writeback"
|
|
5914
|
+
and record.get("idempotency_key") == idempotency_key
|
|
5915
|
+
for record in execution_records(root, harness_task_id)
|
|
5916
|
+
)
|
|
5917
|
+
if not already_acknowledged:
|
|
5918
|
+
append_execution_record(root, harness_task_id, acknowledgment)
|
|
5919
|
+
write_task(root, harness_task_id, task)
|
|
5920
|
+
return acknowledgment
|
|
5921
|
+
|
|
5922
|
+
|
|
5923
|
+
def writeback_spec_task(
|
|
5924
|
+
root: Path,
|
|
5925
|
+
source_task_id: str,
|
|
5926
|
+
status_value: str,
|
|
5927
|
+
summary: str,
|
|
5928
|
+
evidence: list[dict],
|
|
5929
|
+
idempotency_key: str,
|
|
5930
|
+
agent: str,
|
|
5931
|
+
task_id: str | None = None,
|
|
5932
|
+
session_file: str | Path | None = None,
|
|
5933
|
+
) -> dict:
|
|
5934
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
5935
|
+
if source_task_id not in set(task.get("selected_spec_tasks") or []):
|
|
5936
|
+
raise StateError("Canonical source task is outside the Harness task selection.")
|
|
5937
|
+
action = {
|
|
5938
|
+
"kind": "task",
|
|
5939
|
+
"source_task_id": source_task_id,
|
|
5940
|
+
"status": status_value,
|
|
5941
|
+
"summary": summary,
|
|
5942
|
+
"evidence": evidence,
|
|
5943
|
+
"idempotency_key": idempotency_key,
|
|
5944
|
+
"agent": agent,
|
|
5945
|
+
}
|
|
5946
|
+
acknowledgment = _execute_spec_writeback(
|
|
5947
|
+
root,
|
|
5948
|
+
resolved_task_id,
|
|
5949
|
+
task,
|
|
5950
|
+
action,
|
|
5951
|
+
idempotency_key,
|
|
5952
|
+
lambda design_digest, execution_revision: record_task_status(
|
|
5953
|
+
stored_spec_path(root, task),
|
|
5954
|
+
source_task_id,
|
|
5955
|
+
status_value,
|
|
5956
|
+
summary,
|
|
5957
|
+
SPEC_WRITEBACK_APP,
|
|
5958
|
+
spec_writeback_agent(agent),
|
|
5959
|
+
design_digest,
|
|
5960
|
+
execution_revision,
|
|
5961
|
+
evidence=evidence,
|
|
5962
|
+
run_id=resolved_task_id,
|
|
5963
|
+
idempotency_key=idempotency_key,
|
|
5964
|
+
),
|
|
5965
|
+
)
|
|
5966
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
5967
|
+
snapshot["spec_writeback"] = acknowledgment
|
|
5968
|
+
snapshot["action"] = "writeback-spec-task"
|
|
5969
|
+
return snapshot
|
|
5970
|
+
|
|
5971
|
+
|
|
5972
|
+
def writeback_spec_step(
|
|
5973
|
+
root: Path,
|
|
5974
|
+
source_task_id: str,
|
|
5975
|
+
step_id: str,
|
|
5976
|
+
status_value: str,
|
|
5977
|
+
summary: str,
|
|
5978
|
+
evidence: list[dict],
|
|
5979
|
+
idempotency_key: str,
|
|
5980
|
+
agent: str,
|
|
5981
|
+
task_id: str | None = None,
|
|
5982
|
+
session_file: str | Path | None = None,
|
|
5983
|
+
) -> dict:
|
|
5984
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
5985
|
+
if source_task_id not in set(task.get("selected_spec_tasks") or []):
|
|
5986
|
+
raise StateError("Canonical source task is outside the Harness task selection.")
|
|
5987
|
+
action = {
|
|
5988
|
+
"kind": "step",
|
|
5989
|
+
"source_task_id": source_task_id,
|
|
5990
|
+
"step_id": step_id,
|
|
5991
|
+
"status": status_value,
|
|
5992
|
+
"summary": summary,
|
|
5993
|
+
"evidence": evidence,
|
|
5994
|
+
"idempotency_key": idempotency_key,
|
|
5995
|
+
"agent": agent,
|
|
5996
|
+
}
|
|
5997
|
+
acknowledgment = _execute_spec_writeback(
|
|
5998
|
+
root,
|
|
5999
|
+
resolved_task_id,
|
|
6000
|
+
task,
|
|
6001
|
+
action,
|
|
6002
|
+
idempotency_key,
|
|
6003
|
+
lambda design_digest, execution_revision: record_step_status(
|
|
6004
|
+
stored_spec_path(root, task),
|
|
6005
|
+
source_task_id,
|
|
6006
|
+
step_id,
|
|
6007
|
+
status_value,
|
|
6008
|
+
summary,
|
|
6009
|
+
SPEC_WRITEBACK_APP,
|
|
6010
|
+
spec_writeback_agent(agent),
|
|
6011
|
+
design_digest,
|
|
6012
|
+
execution_revision,
|
|
6013
|
+
evidence=evidence,
|
|
6014
|
+
run_id=resolved_task_id,
|
|
6015
|
+
idempotency_key=idempotency_key,
|
|
6016
|
+
),
|
|
6017
|
+
)
|
|
6018
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
6019
|
+
snapshot["spec_writeback"] = acknowledgment
|
|
6020
|
+
snapshot["action"] = "writeback-spec-step"
|
|
6021
|
+
return snapshot
|
|
6022
|
+
|
|
6023
|
+
|
|
6024
|
+
def writeback_spec_dependency(
|
|
6025
|
+
root: Path,
|
|
6026
|
+
source_task_id: str,
|
|
6027
|
+
dependency_task_id: str,
|
|
6028
|
+
status_value: str,
|
|
6029
|
+
summary: str,
|
|
6030
|
+
evidence: list[dict],
|
|
6031
|
+
idempotency_key: str,
|
|
6032
|
+
agent: str,
|
|
6033
|
+
task_id: str | None = None,
|
|
6034
|
+
session_file: str | Path | None = None,
|
|
6035
|
+
) -> dict:
|
|
6036
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
6037
|
+
if source_task_id not in set(task.get("selected_spec_tasks") or []):
|
|
6038
|
+
raise StateError("Canonical source task is outside the Harness task selection.")
|
|
6039
|
+
action = {
|
|
6040
|
+
"kind": "dependency",
|
|
6041
|
+
"source_task_id": source_task_id,
|
|
6042
|
+
"dependency_task_id": dependency_task_id,
|
|
6043
|
+
"status": status_value,
|
|
6044
|
+
"summary": summary,
|
|
6045
|
+
"evidence": evidence,
|
|
6046
|
+
"idempotency_key": idempotency_key,
|
|
6047
|
+
"agent": agent,
|
|
6048
|
+
}
|
|
6049
|
+
acknowledgment = _execute_spec_writeback(
|
|
6050
|
+
root,
|
|
6051
|
+
resolved_task_id,
|
|
6052
|
+
task,
|
|
6053
|
+
action,
|
|
6054
|
+
idempotency_key,
|
|
6055
|
+
lambda design_digest, execution_revision: record_dependency_status(
|
|
6056
|
+
stored_spec_path(root, task),
|
|
6057
|
+
source_task_id,
|
|
6058
|
+
dependency_task_id,
|
|
6059
|
+
status_value,
|
|
6060
|
+
summary,
|
|
6061
|
+
SPEC_WRITEBACK_APP,
|
|
6062
|
+
spec_writeback_agent(agent),
|
|
6063
|
+
design_digest,
|
|
6064
|
+
execution_revision,
|
|
6065
|
+
evidence=evidence,
|
|
6066
|
+
run_id=resolved_task_id,
|
|
6067
|
+
idempotency_key=idempotency_key,
|
|
6068
|
+
),
|
|
6069
|
+
)
|
|
6070
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
6071
|
+
snapshot["spec_writeback"] = acknowledgment
|
|
6072
|
+
snapshot["action"] = "writeback-spec-dependency"
|
|
6073
|
+
return snapshot
|
|
6074
|
+
|
|
6075
|
+
|
|
6076
|
+
def rebind_spec_source(
|
|
6077
|
+
root: Path,
|
|
6078
|
+
spec_path: str,
|
|
6079
|
+
agent: str,
|
|
6080
|
+
task_id: str | None = None,
|
|
6081
|
+
session_file: str | Path | None = None,
|
|
6082
|
+
) -> dict:
|
|
6083
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
6084
|
+
source = task.get("spec_source")
|
|
6085
|
+
if not isinstance(source, dict):
|
|
6086
|
+
raise StateError("Current task is not backed by a Canonical Spec.")
|
|
6087
|
+
raw_path = Path(spec_path).expanduser()
|
|
6088
|
+
resolved = raw_path.resolve() if raw_path.is_absolute() else (root / raw_path).resolve()
|
|
6089
|
+
try:
|
|
6090
|
+
inspection = inspect_spec(
|
|
6091
|
+
resolved,
|
|
6092
|
+
root,
|
|
6093
|
+
task.get("repo_paths") if isinstance(task.get("repo_paths"), dict) else {},
|
|
6094
|
+
task.get("selected_spec_tasks") or [],
|
|
6095
|
+
)
|
|
6096
|
+
except EasyDevSpecError as exc:
|
|
6097
|
+
raise StateError(f"Cannot rebind Canonical Spec: {exc}") from exc
|
|
6098
|
+
for field in ("schema", "spec_id", "revision", "design_sha256"):
|
|
6099
|
+
expected = source.get(field)
|
|
6100
|
+
if field == "design_sha256" and expected is None and source.get("sha256") == inspection.get("source_sha256"):
|
|
6101
|
+
expected = inspection.get("design_sha256")
|
|
6102
|
+
if expected != inspection.get(field):
|
|
6103
|
+
raise StateError(f"Rebind rejected because Canonical Spec {field} does not match.")
|
|
6104
|
+
previous_execution_revision = source.get("execution_revision", 0)
|
|
6105
|
+
if int(inspection.get("execution_revision", -1)) < int(previous_execution_revision):
|
|
6106
|
+
raise StateError("Rebind rejected because Canonical execution revision moved backwards.")
|
|
6107
|
+
try:
|
|
6108
|
+
source_path = resolved.relative_to(root.resolve()).as_posix()
|
|
6109
|
+
path_mode = "project-relative"
|
|
6110
|
+
except ValueError:
|
|
6111
|
+
source_path = str(resolved)
|
|
6112
|
+
path_mode = "absolute"
|
|
6113
|
+
source.update({"path": source_path, "path_mode": path_mode})
|
|
6114
|
+
inspect_task_spec(root, task)
|
|
6115
|
+
task["last_agent"] = agent
|
|
6116
|
+
write_task(root, resolved_task_id, task)
|
|
6117
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
6118
|
+
snapshot["action"] = "rebind-spec-source"
|
|
6119
|
+
return snapshot
|
|
6120
|
+
|
|
6121
|
+
|
|
6122
|
+
def reconcile_local_result_evidence(
|
|
6123
|
+
root: Path,
|
|
6124
|
+
resolved_task_id: str,
|
|
6125
|
+
task: dict,
|
|
6126
|
+
agent: str,
|
|
6127
|
+
session_file: str | Path | None,
|
|
6128
|
+
) -> tuple[int, list[str]]:
|
|
6129
|
+
plan = latest_execution_plan(root, resolved_task_id)
|
|
6130
|
+
if not isinstance(plan, dict):
|
|
6131
|
+
return 0, []
|
|
6132
|
+
inspection, selection = inspect_task_spec(root, task)
|
|
6133
|
+
snapshots = _selected_execution_snapshots(inspection, task)
|
|
6134
|
+
units = {
|
|
6135
|
+
str(unit.get("id")): unit
|
|
6136
|
+
for unit in plan.get("units", [])
|
|
6137
|
+
if isinstance(unit, dict) and is_non_empty_string(unit.get("id"))
|
|
6138
|
+
}
|
|
6139
|
+
records = execution_records(root, resolved_task_id)
|
|
6140
|
+
last_plan_index = max(
|
|
6141
|
+
(index for index, record in enumerate(records) if record.get("type") == "plan"),
|
|
6142
|
+
default=-1,
|
|
6143
|
+
)
|
|
6144
|
+
lifecycle_by_unit: dict[str, list[tuple[int, dict]]] = {
|
|
6145
|
+
unit_id: [] for unit_id in units
|
|
6146
|
+
}
|
|
6147
|
+
for record_index, record in enumerate(records[last_plan_index + 1 :], last_plan_index + 1):
|
|
6148
|
+
unit_id = str(record.get("unit_id") or "")
|
|
6149
|
+
if record.get("type") in {"dispatch", "result"} and unit_id in lifecycle_by_unit:
|
|
6150
|
+
lifecycle_by_unit[unit_id].append((record_index, record))
|
|
6151
|
+
latest_results = {
|
|
6152
|
+
unit_id: lifecycle[-1]
|
|
6153
|
+
for unit_id, lifecycle in lifecycle_by_unit.items()
|
|
6154
|
+
if lifecycle and lifecycle[-1][1].get("type") == "result"
|
|
6155
|
+
}
|
|
6156
|
+
step_by_id = {
|
|
6157
|
+
str(step.get("step_id")): step
|
|
6158
|
+
for step in selection.get("selected_steps", [])
|
|
6159
|
+
if isinstance(step, dict)
|
|
6160
|
+
}
|
|
6161
|
+
test_by_id = {
|
|
6162
|
+
str(test.get("test_id")): test
|
|
6163
|
+
for test in selection.get("selected_tests", [])
|
|
6164
|
+
if isinstance(test, dict)
|
|
6165
|
+
}
|
|
6166
|
+
reconciled = 0
|
|
6167
|
+
unresolved: list[str] = []
|
|
6168
|
+
for unit_id, (result_index, result) in latest_results.items():
|
|
6169
|
+
unit = units.get(unit_id)
|
|
6170
|
+
if not unit:
|
|
6171
|
+
continue
|
|
6172
|
+
lifecycle = lifecycle_by_unit.get(unit_id, [])
|
|
6173
|
+
if len(lifecycle) < 2 or lifecycle[-2][1].get("type") != "dispatch":
|
|
6174
|
+
unresolved.append(f"{unit_id}:missing-matching-dispatch")
|
|
6175
|
+
continue
|
|
6176
|
+
dispatch_index, dispatch = lifecycle[-2]
|
|
6177
|
+
source_task_id = str(unit.get("source_task_id") or "")
|
|
6178
|
+
source_steps = [str(value) for value in unit.get("source_step_ids", [])]
|
|
6179
|
+
if source_task_id not in snapshots or not source_steps:
|
|
6180
|
+
continue
|
|
6181
|
+
if (
|
|
6182
|
+
dispatch.get("source_task_id") != source_task_id
|
|
6183
|
+
or dispatch.get("repo_id") != unit.get("repo_id")
|
|
6184
|
+
or result.get("source_task_id") != source_task_id
|
|
6185
|
+
or result.get("repo_id") != unit.get("repo_id")
|
|
6186
|
+
or not isinstance(result.get("changed_files"), list)
|
|
6187
|
+
or not set(result.get("changed_files", [])).issubset(set(unit.get("files", [])))
|
|
6188
|
+
or not is_non_empty_string(result.get("summary"))
|
|
6189
|
+
):
|
|
6190
|
+
unresolved.append(f"{unit_id}:source-ownership-mismatch")
|
|
6191
|
+
continue
|
|
6192
|
+
current_status = snapshots[source_task_id].get("status")
|
|
6193
|
+
if current_status != "in_progress":
|
|
6194
|
+
unresolved.append(
|
|
6195
|
+
f"{unit_id}:shared-task-status={current_status or 'missing'}"
|
|
6196
|
+
)
|
|
6197
|
+
continue
|
|
6198
|
+
attempt_id, attempt_completed_steps = _shared_attempt_projection(
|
|
6199
|
+
inspection, source_task_id
|
|
6200
|
+
)
|
|
6201
|
+
if not attempt_id:
|
|
6202
|
+
unresolved.append(f"{unit_id}:missing-in-progress-attempt")
|
|
6203
|
+
continue
|
|
6204
|
+
attempt_ack_index = max(
|
|
6205
|
+
(
|
|
6206
|
+
index
|
|
6207
|
+
for index, record in enumerate(records)
|
|
6208
|
+
if record.get("type") == "spec-writeback"
|
|
6209
|
+
and record.get("event_id") == attempt_id
|
|
6210
|
+
and isinstance(record.get("action"), dict)
|
|
6211
|
+
and record["action"].get("kind") == "task"
|
|
6212
|
+
and record["action"].get("source_task_id") == source_task_id
|
|
6213
|
+
and record["action"].get("status") == "in_progress"
|
|
6214
|
+
),
|
|
6215
|
+
default=-1,
|
|
6216
|
+
)
|
|
6217
|
+
if attempt_ack_index < 0:
|
|
6218
|
+
unresolved.append(f"{unit_id}:missing-in-progress-acknowledgment")
|
|
6219
|
+
continue
|
|
6220
|
+
if dispatch_index <= attempt_ack_index or result_index <= attempt_ack_index:
|
|
6221
|
+
unresolved.append(f"{unit_id}:no-result-for-current-attempt")
|
|
6222
|
+
continue
|
|
6223
|
+
result_status = result.get("status")
|
|
6224
|
+
successful = (
|
|
6225
|
+
result_status == "completed"
|
|
6226
|
+
and result.get("issues") == []
|
|
6227
|
+
and result.get("needs_attention") == []
|
|
6228
|
+
)
|
|
6229
|
+
failed = result_status == "failed"
|
|
6230
|
+
if not successful and not failed:
|
|
6231
|
+
unresolved.append(f"{unit_id}:invalid-result-status-or-issues")
|
|
6232
|
+
continue
|
|
6233
|
+
if failed:
|
|
6234
|
+
if len(source_steps) != 1:
|
|
6235
|
+
unresolved.append(f"{unit_id}:ambiguous-failed-source-step")
|
|
6236
|
+
continue
|
|
6237
|
+
step_id = source_steps[0]
|
|
6238
|
+
key = f"{resolved_task_id}:{unit_id}:{step_id}:{attempt_id}:result-failed"
|
|
6239
|
+
writeback_spec_step(
|
|
6240
|
+
root,
|
|
6241
|
+
source_task_id,
|
|
6242
|
+
step_id,
|
|
6243
|
+
"failed",
|
|
6244
|
+
str(result.get("summary") or f"Unit {unit_id} failed"),
|
|
6245
|
+
[
|
|
6246
|
+
{
|
|
6247
|
+
"kind": "result",
|
|
6248
|
+
"status": "failed",
|
|
6249
|
+
"ref": f"execution.jsonl#unit={unit_id}",
|
|
6250
|
+
}
|
|
6251
|
+
],
|
|
6252
|
+
key,
|
|
6253
|
+
agent,
|
|
6254
|
+
resolved_task_id,
|
|
6255
|
+
session_file,
|
|
6256
|
+
)
|
|
6257
|
+
reconciled += 1
|
|
6258
|
+
unresolved.extend(
|
|
6259
|
+
f"{unit_id}:{remaining_step}:blocked-after-unit-failure"
|
|
6260
|
+
for remaining_step in source_steps[1:]
|
|
6261
|
+
)
|
|
6262
|
+
task = load_task(root, resolved_task_id) or task
|
|
6263
|
+
inspection, selection = inspect_task_spec(root, task)
|
|
6264
|
+
snapshots = _selected_execution_snapshots(inspection, task)
|
|
6265
|
+
continue
|
|
6266
|
+
passed_commands = {
|
|
6267
|
+
str(check.get("command"))
|
|
6268
|
+
for check in result.get("checks", [])
|
|
6269
|
+
if isinstance(check, dict)
|
|
6270
|
+
and check.get("passed") is True
|
|
6271
|
+
and is_non_empty_string(check.get("command"))
|
|
6272
|
+
}
|
|
6273
|
+
missing_unit_commands = sorted(set(unit.get("test_commands", [])) - passed_commands)
|
|
6274
|
+
if missing_unit_commands:
|
|
6275
|
+
unresolved.append(
|
|
6276
|
+
f"{unit_id}:missing-passed-command=" + ",".join(missing_unit_commands)
|
|
6277
|
+
)
|
|
6278
|
+
continue
|
|
6279
|
+
pending_steps = list(dict.fromkeys(source_steps))
|
|
6280
|
+
while pending_steps:
|
|
6281
|
+
ready_step_id = next(
|
|
6282
|
+
(
|
|
6283
|
+
step_id
|
|
6284
|
+
for step_id in pending_steps
|
|
6285
|
+
if step_id in attempt_completed_steps
|
|
6286
|
+
or set((step_by_id.get(step_id) or {}).get("depends_on_step_ids", []))
|
|
6287
|
+
.issubset(attempt_completed_steps)
|
|
6288
|
+
),
|
|
6289
|
+
None,
|
|
6290
|
+
)
|
|
6291
|
+
if ready_step_id is None:
|
|
6292
|
+
unresolved.extend(
|
|
6293
|
+
f"{unit_id}:{step_id}:dependency-pending" for step_id in pending_steps
|
|
6294
|
+
)
|
|
6295
|
+
break
|
|
6296
|
+
step_id = ready_step_id
|
|
6297
|
+
pending_steps.remove(step_id)
|
|
6298
|
+
if step_id in attempt_completed_steps:
|
|
6299
|
+
continue
|
|
6300
|
+
step = step_by_id.get(step_id)
|
|
6301
|
+
if not step:
|
|
6302
|
+
unresolved.append(f"{unit_id}:{step_id}:missing-step")
|
|
6303
|
+
continue
|
|
6304
|
+
tests = [test_by_id.get(str(test_id)) for test_id in step.get("test_ids", [])]
|
|
6305
|
+
if any(not isinstance(test, dict) for test in tests):
|
|
6306
|
+
unresolved.append(f"{unit_id}:{step_id}:missing-test")
|
|
6307
|
+
continue
|
|
6308
|
+
missing_commands = [
|
|
6309
|
+
str(test.get("command"))
|
|
6310
|
+
for test in tests
|
|
6311
|
+
if str(test.get("command")) not in passed_commands
|
|
6312
|
+
]
|
|
6313
|
+
if missing_commands:
|
|
6314
|
+
unresolved.append(
|
|
6315
|
+
f"{unit_id}:{step_id}:missing-passed-command=" + ",".join(missing_commands)
|
|
6316
|
+
)
|
|
6317
|
+
continue
|
|
6318
|
+
evidence = [
|
|
6319
|
+
{
|
|
6320
|
+
"kind": "test",
|
|
6321
|
+
"status": "passed",
|
|
6322
|
+
"ref": f"execution.jsonl#unit={unit_id};command={test.get('command')}",
|
|
6323
|
+
"test_id": str(test.get("test_id")),
|
|
6324
|
+
}
|
|
6325
|
+
for test in tests
|
|
6326
|
+
]
|
|
6327
|
+
key = f"{resolved_task_id}:{unit_id}:{step_id}:{attempt_id}:result-completed"
|
|
6328
|
+
writeback_spec_step(
|
|
6329
|
+
root,
|
|
6330
|
+
source_task_id,
|
|
6331
|
+
step_id,
|
|
6332
|
+
"completed",
|
|
6333
|
+
str(result.get("summary") or f"Unit {unit_id} completed"),
|
|
6334
|
+
evidence,
|
|
6335
|
+
key,
|
|
6336
|
+
agent,
|
|
6337
|
+
resolved_task_id,
|
|
6338
|
+
session_file,
|
|
6339
|
+
)
|
|
6340
|
+
reconciled += 1
|
|
6341
|
+
task = load_task(root, resolved_task_id) or task
|
|
6342
|
+
inspection, selection = inspect_task_spec(root, task)
|
|
6343
|
+
snapshots = _selected_execution_snapshots(inspection, task)
|
|
6344
|
+
_, attempt_completed_steps = _shared_attempt_projection(
|
|
6345
|
+
inspection, source_task_id
|
|
6346
|
+
)
|
|
6347
|
+
task = load_task(root, resolved_task_id) or task
|
|
6348
|
+
inspection, _ = inspect_task_spec(root, task)
|
|
6349
|
+
snapshots = _selected_execution_snapshots(inspection, task)
|
|
6350
|
+
selected_tasks = {
|
|
6351
|
+
str(item.get("task_id")): item
|
|
6352
|
+
for item in selection.get("selected_tasks", [])
|
|
6353
|
+
if isinstance(item, dict)
|
|
6354
|
+
}
|
|
6355
|
+
for source_task_id, snapshot in snapshots.items():
|
|
6356
|
+
if snapshot.get("status") != "in_progress":
|
|
6357
|
+
continue
|
|
6358
|
+
expected_steps = set(selected_tasks.get(source_task_id, {}).get("step_ids", []))
|
|
6359
|
+
attempt_id, attempt_completed_steps = _shared_attempt_projection(
|
|
6360
|
+
inspection, source_task_id
|
|
6361
|
+
)
|
|
6362
|
+
if attempt_id and expected_steps and attempt_completed_steps == expected_steps:
|
|
6363
|
+
key = (
|
|
6364
|
+
f"{resolved_task_id}:{source_task_id}:{attempt_id}:"
|
|
6365
|
+
"implemented-from-results"
|
|
6366
|
+
)
|
|
6367
|
+
writeback_spec_task(
|
|
6368
|
+
root,
|
|
6369
|
+
source_task_id,
|
|
6370
|
+
"implemented",
|
|
6371
|
+
"All Canonical Steps have passed local implementation evidence",
|
|
6372
|
+
[],
|
|
6373
|
+
key,
|
|
6374
|
+
agent,
|
|
6375
|
+
resolved_task_id,
|
|
6376
|
+
session_file,
|
|
6377
|
+
)
|
|
6378
|
+
reconciled += 1
|
|
6379
|
+
return reconciled, unresolved
|
|
6380
|
+
|
|
6381
|
+
|
|
6382
|
+
def reconcile_spec_execution(
|
|
6383
|
+
root: Path,
|
|
6384
|
+
agent: str,
|
|
6385
|
+
task_id: str | None = None,
|
|
6386
|
+
session_file: str | Path | None = None,
|
|
6387
|
+
) -> dict:
|
|
6388
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
6389
|
+
progress = _writeback_progress(task)
|
|
6390
|
+
pending = progress.get("pending_action")
|
|
6391
|
+
if not isinstance(pending, str) or not pending.strip():
|
|
6392
|
+
reconciled, unresolved = reconcile_local_result_evidence(
|
|
6393
|
+
root,
|
|
6394
|
+
resolved_task_id,
|
|
6395
|
+
task,
|
|
6396
|
+
agent,
|
|
6397
|
+
session_file,
|
|
6398
|
+
)
|
|
6399
|
+
task = load_task(root, resolved_task_id) or task
|
|
6400
|
+
inspect_task_spec(root, task)
|
|
6401
|
+
progress.update(
|
|
6402
|
+
{
|
|
6403
|
+
"last_execution_revision": task["spec_source"]["execution_revision"],
|
|
6404
|
+
"status": "ok",
|
|
6405
|
+
"updated_at": now_iso(),
|
|
6406
|
+
}
|
|
6407
|
+
)
|
|
6408
|
+
write_task(root, resolved_task_id, task)
|
|
6409
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
6410
|
+
snapshot["action"] = "reconcile-spec-execution"
|
|
6411
|
+
snapshot["reconciled"] = reconciled > 0
|
|
6412
|
+
snapshot["reconciled_actions"] = reconciled
|
|
6413
|
+
snapshot["unresolved_local_evidence"] = unresolved
|
|
6414
|
+
return snapshot
|
|
6415
|
+
try:
|
|
6416
|
+
action = json.loads(pending)
|
|
6417
|
+
except json.JSONDecodeError as exc:
|
|
6418
|
+
raise StateError("Pending Canonical Spec writeback metadata is invalid JSON.") from exc
|
|
6419
|
+
kind = action.get("kind")
|
|
6420
|
+
if kind == "sync-design":
|
|
6421
|
+
affected_task_ids = action.get("affected_task_ids")
|
|
6422
|
+
if not is_string_list(affected_task_ids):
|
|
6423
|
+
raise StateError("Pending Canonical Spec design sync has invalid affected tasks.")
|
|
6424
|
+
result = sync_spec_design_state(
|
|
6425
|
+
root,
|
|
6426
|
+
affected_task_ids,
|
|
6427
|
+
str(action.get("summary") or "Reconciled Canonical Spec design sync"),
|
|
6428
|
+
str(action.get("idempotency_key") or ""),
|
|
6429
|
+
str(action.get("agent") or agent),
|
|
6430
|
+
resolved_task_id,
|
|
6431
|
+
session_file,
|
|
6432
|
+
)
|
|
6433
|
+
result["action"] = "reconcile-spec-execution"
|
|
6434
|
+
result["reconciled"] = True
|
|
6435
|
+
return result
|
|
6436
|
+
try:
|
|
6437
|
+
design_text, _ = split_execution_region(
|
|
6438
|
+
stored_spec_path(root, task).read_text(encoding="utf-8")
|
|
6439
|
+
)
|
|
6440
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
6441
|
+
raise StateError(f"Cannot inspect pending Canonical Spec writeback: {exc}") from exc
|
|
6442
|
+
current_design_sha256 = hashlib.sha256(design_text.encode("utf-8")).hexdigest()
|
|
6443
|
+
source = task.get("spec_source")
|
|
6444
|
+
if not isinstance(source, dict):
|
|
6445
|
+
raise StateError("Current task is not backed by a Canonical Spec.")
|
|
6446
|
+
if current_design_sha256 != source.get("design_sha256"):
|
|
6447
|
+
# 旧设计上的进度事件不能重放到新设计;清除单槽 pending,允许后续 sync-design。
|
|
6448
|
+
progress["status"] = "error"
|
|
6449
|
+
progress["updated_at"] = now_iso()
|
|
6450
|
+
progress.pop("pending_action", None)
|
|
6451
|
+
write_task(root, resolved_task_id, task)
|
|
6452
|
+
raise StateError(
|
|
6453
|
+
"Pending Canonical Spec writeback belongs to an obsolete design and was "
|
|
6454
|
+
"discarded; return to ANALYSIS and run sync-spec-design."
|
|
6455
|
+
)
|
|
6456
|
+
common = {
|
|
6457
|
+
"root": root,
|
|
6458
|
+
"summary": str(action.get("summary") or "Reconciled shared Spec writeback"),
|
|
6459
|
+
"evidence": action.get("evidence") if isinstance(action.get("evidence"), list) else [],
|
|
6460
|
+
"idempotency_key": str(action.get("idempotency_key") or ""),
|
|
6461
|
+
"agent": str(action.get("agent") or agent),
|
|
6462
|
+
"task_id": resolved_task_id,
|
|
6463
|
+
"session_file": session_file,
|
|
6464
|
+
}
|
|
6465
|
+
if not common["idempotency_key"]:
|
|
6466
|
+
raise StateError("Pending Canonical Spec writeback has no idempotency key.")
|
|
6467
|
+
if kind == "task":
|
|
6468
|
+
result = writeback_spec_task(
|
|
6469
|
+
source_task_id=str(action.get("source_task_id") or ""),
|
|
6470
|
+
status_value=str(action.get("status") or ""),
|
|
6471
|
+
**common,
|
|
6472
|
+
)
|
|
6473
|
+
elif kind == "step":
|
|
6474
|
+
result = writeback_spec_step(
|
|
6475
|
+
source_task_id=str(action.get("source_task_id") or ""),
|
|
6476
|
+
step_id=str(action.get("step_id") or ""),
|
|
6477
|
+
status_value=str(action.get("status") or ""),
|
|
6478
|
+
**common,
|
|
6479
|
+
)
|
|
6480
|
+
elif kind == "dependency":
|
|
6481
|
+
result = writeback_spec_dependency(
|
|
6482
|
+
source_task_id=str(action.get("source_task_id") or ""),
|
|
6483
|
+
dependency_task_id=str(action.get("dependency_task_id") or ""),
|
|
6484
|
+
status_value=str(action.get("status") or ""),
|
|
6485
|
+
**common,
|
|
6486
|
+
)
|
|
6487
|
+
else:
|
|
6488
|
+
raise StateError("Pending Canonical Spec writeback kind is unsupported.")
|
|
6489
|
+
result["action"] = "reconcile-spec-execution"
|
|
6490
|
+
result["reconciled"] = True
|
|
6491
|
+
return result
|
|
6492
|
+
|
|
6493
|
+
|
|
6494
|
+
def sync_spec_design_state(
|
|
6495
|
+
root: Path,
|
|
6496
|
+
affected_task_ids: list[str],
|
|
6497
|
+
summary: str,
|
|
6498
|
+
idempotency_key: str,
|
|
6499
|
+
agent: str,
|
|
6500
|
+
task_id: str | None = None,
|
|
6501
|
+
session_file: str | Path | None = None,
|
|
6502
|
+
) -> dict:
|
|
6503
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
6504
|
+
source = task.get("spec_source")
|
|
6505
|
+
if not isinstance(source, dict):
|
|
6506
|
+
raise StateError("Current task is not backed by a Canonical Spec.")
|
|
6507
|
+
spec_path = stored_spec_path(root, task)
|
|
6508
|
+
requested_task_ids = sorted(set(affected_task_ids))
|
|
6509
|
+
|
|
6510
|
+
def current_execution_envelope() -> dict:
|
|
6511
|
+
try:
|
|
6512
|
+
from easy_dev_spec_protocol import split_execution_region
|
|
6513
|
+
|
|
6514
|
+
_, execution = split_execution_region(spec_path.read_text(encoding="utf-8"))
|
|
6515
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
6516
|
+
raise StateError(f"Cannot inspect pre-sync Canonical execution state: {exc}") from exc
|
|
6517
|
+
if not isinstance(execution, dict):
|
|
6518
|
+
raise StateError("Canonical Spec shared execution is missing before sync-design.")
|
|
6519
|
+
if execution.get("design_sha256") != source.get("design_sha256"):
|
|
6520
|
+
matching_events = [
|
|
6521
|
+
event
|
|
6522
|
+
for event in execution.get("events", [])
|
|
6523
|
+
if isinstance(event, dict)
|
|
6524
|
+
and event.get("type") == "spec_revised"
|
|
6525
|
+
and event.get("idempotency_key") == idempotency_key
|
|
6526
|
+
and event.get("requested_task_ids") == requested_task_ids
|
|
6527
|
+
and event.get("run_id") == resolved_task_id
|
|
6528
|
+
]
|
|
6529
|
+
if len(matching_events) != 1:
|
|
6530
|
+
raise StateError(
|
|
6531
|
+
"Canonical Spec execution baseline no longer matches the bound design."
|
|
6532
|
+
)
|
|
6533
|
+
return execution
|
|
6534
|
+
|
|
6535
|
+
current_revision = int(current_execution_envelope().get("execution_revision", -1))
|
|
6536
|
+
progress = _writeback_progress(task)
|
|
6537
|
+
pending_action = {
|
|
6538
|
+
"kind": "sync-design",
|
|
6539
|
+
"affected_task_ids": requested_task_ids,
|
|
6540
|
+
"summary": summary,
|
|
6541
|
+
"idempotency_key": idempotency_key,
|
|
6542
|
+
"agent": agent,
|
|
6543
|
+
}
|
|
6544
|
+
serialized_pending_action = json.dumps(
|
|
6545
|
+
pending_action, ensure_ascii=False, sort_keys=True
|
|
6546
|
+
)
|
|
6547
|
+
existing_pending = progress.get("pending_action")
|
|
6548
|
+
if isinstance(existing_pending, str) and existing_pending.strip():
|
|
6549
|
+
try:
|
|
6550
|
+
existing_action = json.loads(existing_pending)
|
|
6551
|
+
except json.JSONDecodeError as exc:
|
|
6552
|
+
raise StateError("Pending Canonical Spec writeback metadata is invalid JSON.") from exc
|
|
6553
|
+
if existing_action != pending_action:
|
|
6554
|
+
raise StateError(
|
|
6555
|
+
"A different Canonical Spec writeback is pending; run "
|
|
6556
|
+
"reconcile-spec-execution before sync-design."
|
|
6557
|
+
)
|
|
6558
|
+
progress.update(
|
|
6559
|
+
{
|
|
6560
|
+
"last_execution_revision": current_revision,
|
|
6561
|
+
"pending_action": serialized_pending_action,
|
|
6562
|
+
"status": "pending",
|
|
6563
|
+
"updated_at": now_iso(),
|
|
6564
|
+
}
|
|
6565
|
+
)
|
|
6566
|
+
write_task(root, resolved_task_id, task)
|
|
6567
|
+
|
|
6568
|
+
def invoke_sync(execution_revision: int) -> dict:
|
|
6569
|
+
return sync_design(
|
|
6570
|
+
spec_path,
|
|
6571
|
+
requested_task_ids,
|
|
6572
|
+
summary,
|
|
6573
|
+
SPEC_WRITEBACK_APP,
|
|
6574
|
+
spec_writeback_agent(agent),
|
|
6575
|
+
str(source.get("design_sha256")),
|
|
6576
|
+
execution_revision,
|
|
6577
|
+
run_id=resolved_task_id,
|
|
6578
|
+
idempotency_key=idempotency_key,
|
|
6579
|
+
)
|
|
6580
|
+
|
|
6581
|
+
try:
|
|
6582
|
+
execution = invoke_sync(current_revision)
|
|
6583
|
+
except ExecutionConflictError:
|
|
6584
|
+
try:
|
|
6585
|
+
execution = invoke_sync(
|
|
6586
|
+
int(current_execution_envelope().get("execution_revision", -1))
|
|
6587
|
+
)
|
|
6588
|
+
except ExecutionStateError as exc:
|
|
6589
|
+
progress["status"] = "error"
|
|
6590
|
+
progress["updated_at"] = now_iso()
|
|
6591
|
+
progress.pop("pending_action", None)
|
|
6592
|
+
write_task(root, resolved_task_id, task)
|
|
6593
|
+
raise StateError(f"Cannot synchronize Canonical Spec design: {exc}") from exc
|
|
6594
|
+
except ExecutionConflictError as exc:
|
|
6595
|
+
terminal_conflict = _is_idempotency_key_conflict(exc)
|
|
6596
|
+
progress["status"] = "error" if terminal_conflict else "conflict"
|
|
6597
|
+
progress["updated_at"] = now_iso()
|
|
6598
|
+
if terminal_conflict:
|
|
6599
|
+
progress.pop("pending_action", None)
|
|
6600
|
+
write_task(root, resolved_task_id, task)
|
|
6601
|
+
raise StateError(f"Cannot synchronize Canonical Spec design after CAS retry: {exc}") from exc
|
|
6602
|
+
except ExecutionStateError as exc:
|
|
6603
|
+
progress["status"] = "error"
|
|
6604
|
+
progress["updated_at"] = now_iso()
|
|
6605
|
+
progress.pop("pending_action", None)
|
|
6606
|
+
write_task(root, resolved_task_id, task)
|
|
6607
|
+
raise StateError(f"Cannot synchronize Canonical Spec design: {exc}") from exc
|
|
6608
|
+
try:
|
|
6609
|
+
details = show_execution(spec_path)
|
|
6610
|
+
inspection = inspect_spec(
|
|
6611
|
+
spec_path,
|
|
6612
|
+
root,
|
|
6613
|
+
task.get("repo_paths") if isinstance(task.get("repo_paths"), dict) else {},
|
|
6614
|
+
task.get("selected_spec_tasks") or [],
|
|
6615
|
+
)
|
|
6616
|
+
except (ExecutionStateError, EasyDevSpecError) as exc:
|
|
6617
|
+
raise StateError(f"Cannot synchronize Canonical Spec design: {exc}") from exc
|
|
6618
|
+
if inspection.get("spec_id") != source.get("spec_id"):
|
|
6619
|
+
raise StateError("Synchronized Canonical Spec identity changed unexpectedly.")
|
|
6620
|
+
binding_was_synchronized = (
|
|
6621
|
+
source.get("revision") == inspection.get("revision")
|
|
6622
|
+
and source.get("design_sha256") == inspection.get("design_sha256")
|
|
6623
|
+
)
|
|
6624
|
+
source.update(
|
|
6625
|
+
{
|
|
6626
|
+
"revision": inspection["revision"],
|
|
6627
|
+
"design_sha256": inspection["design_sha256"],
|
|
6628
|
+
"document_sha256": inspection["document_sha256"],
|
|
6629
|
+
"execution_revision": execution["execution_revision"],
|
|
6630
|
+
}
|
|
6631
|
+
)
|
|
6632
|
+
event = _spec_event(execution, idempotency_key)
|
|
6633
|
+
if not binding_was_synchronized:
|
|
6634
|
+
reset_task_ids = set(event.get("task_ids", []))
|
|
6635
|
+
refreshed_dependencies: list[dict] = []
|
|
6636
|
+
for dependency in task.get("spec_dependency_evidence", []):
|
|
6637
|
+
if not isinstance(dependency, dict):
|
|
6638
|
+
continue
|
|
6639
|
+
refreshed = dict(dependency)
|
|
6640
|
+
if refreshed.get("source_task_id") in reset_task_ids:
|
|
6641
|
+
refreshed["status"] = "pending"
|
|
6642
|
+
refreshed["shared_status"] = "pending"
|
|
6643
|
+
for field in ("evidence", "satisfied_at", "satisfied_by"):
|
|
6644
|
+
refreshed.pop(field, None)
|
|
6645
|
+
refreshed_dependencies.append(refreshed)
|
|
6646
|
+
task["spec_dependency_evidence"] = refreshed_dependencies
|
|
6647
|
+
inspect_task_spec(root, task)
|
|
6648
|
+
progress.update(
|
|
6649
|
+
{
|
|
6650
|
+
"last_execution_revision": execution["execution_revision"],
|
|
6651
|
+
"last_event_id": event["event_id"],
|
|
6652
|
+
"last_idempotency_key": idempotency_key,
|
|
6653
|
+
"status": "ok",
|
|
6654
|
+
"updated_at": now_iso(),
|
|
6655
|
+
}
|
|
6656
|
+
)
|
|
6657
|
+
progress.pop("pending_action", None)
|
|
6658
|
+
if task.get("status") not in {"INIT", "ANALYSIS"}:
|
|
6659
|
+
cleanup_verification_checkpoint(root, resolved_task_id, task)
|
|
6660
|
+
task["status"] = "ANALYSIS"
|
|
6661
|
+
append_stage_history(task, "ANALYSIS", agent)
|
|
6662
|
+
task.pop("pending_transition", None)
|
|
6663
|
+
task["last_agent"] = agent
|
|
6664
|
+
already_acknowledged = any(
|
|
6665
|
+
record.get("type") == "spec-design-sync"
|
|
6666
|
+
and record.get("idempotency_key") == idempotency_key
|
|
6667
|
+
for record in execution_records(root, resolved_task_id)
|
|
6668
|
+
)
|
|
6669
|
+
if not already_acknowledged:
|
|
6670
|
+
append_execution_record(
|
|
6671
|
+
root,
|
|
6672
|
+
resolved_task_id,
|
|
6673
|
+
{
|
|
6674
|
+
"type": "spec-design-sync",
|
|
6675
|
+
"affected_task_ids": requested_task_ids,
|
|
6676
|
+
"event_id": event["event_id"],
|
|
6677
|
+
"design_sha256": details["design_sha256"],
|
|
6678
|
+
"execution_revision": execution["execution_revision"],
|
|
6679
|
+
"idempotency_key": idempotency_key,
|
|
6680
|
+
"timestamp": now_iso(),
|
|
6681
|
+
},
|
|
6682
|
+
)
|
|
6683
|
+
write_task(root, resolved_task_id, task)
|
|
6684
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
6685
|
+
snapshot["action"] = "sync-spec-design"
|
|
6686
|
+
return snapshot
|
|
6687
|
+
|
|
6688
|
+
|
|
6689
|
+
def _selected_execution_snapshots(inspection: dict, task: dict) -> dict[str, dict]:
|
|
6690
|
+
selected = set(task.get("selected_spec_tasks") or [])
|
|
6691
|
+
execution = inspection.get("execution")
|
|
6692
|
+
if not isinstance(execution, dict):
|
|
6693
|
+
raise StateError("Canonical Spec shared execution is unavailable.")
|
|
6694
|
+
return {
|
|
6695
|
+
str(snapshot.get("task_id")): snapshot
|
|
6696
|
+
for snapshot in execution.get("tasks", [])
|
|
6697
|
+
if isinstance(snapshot, dict) and snapshot.get("task_id") in selected
|
|
6698
|
+
}
|
|
6699
|
+
|
|
6700
|
+
|
|
6701
|
+
def _shared_attempt_projection(
|
|
6702
|
+
inspection: dict, source_task_id: str
|
|
6703
|
+
) -> tuple[str | None, set[str]]:
|
|
6704
|
+
execution = inspection.get("execution")
|
|
6705
|
+
if not isinstance(execution, dict):
|
|
6706
|
+
return None, set()
|
|
6707
|
+
events = [event for event in execution.get("events", []) if isinstance(event, dict)]
|
|
6708
|
+
start_index = next(
|
|
6709
|
+
(
|
|
6710
|
+
index
|
|
6711
|
+
for index in range(len(events) - 1, -1, -1)
|
|
6712
|
+
if events[index].get("type") == "task_status_changed"
|
|
6713
|
+
and events[index].get("task_id") == source_task_id
|
|
6714
|
+
and events[index].get("to_status") == "in_progress"
|
|
6715
|
+
),
|
|
6716
|
+
None,
|
|
6717
|
+
)
|
|
6718
|
+
if start_index is None:
|
|
6719
|
+
return None, set()
|
|
6720
|
+
start_event = events[start_index]
|
|
6721
|
+
completed_steps: set[str] = set()
|
|
6722
|
+
for event in events[start_index + 1 :]:
|
|
6723
|
+
if event.get("type") != "step_status_changed" or event.get("task_id") != source_task_id:
|
|
6724
|
+
continue
|
|
6725
|
+
step_id = str(event.get("step_id") or "")
|
|
6726
|
+
if not step_id:
|
|
6727
|
+
continue
|
|
6728
|
+
if event.get("step_status") == "completed":
|
|
6729
|
+
completed_steps.add(step_id)
|
|
6730
|
+
elif event.get("step_status") == "failed":
|
|
6731
|
+
completed_steps.discard(step_id)
|
|
6732
|
+
return str(start_event.get("event_id") or "") or None, completed_steps
|
|
6733
|
+
|
|
6734
|
+
|
|
6735
|
+
def _snapshot_dependencies_ready(snapshot: dict, all_snapshots: dict[str, dict]) -> bool:
|
|
6736
|
+
for dependency in snapshot.get("dependencies", []):
|
|
6737
|
+
if not isinstance(dependency, dict) or dependency.get("type") not in {"hard", "contract"}:
|
|
6738
|
+
continue
|
|
6739
|
+
if dependency.get("status") == "satisfied":
|
|
6740
|
+
continue
|
|
6741
|
+
if dependency.get("type") == "hard" and all_snapshots.get(
|
|
6742
|
+
str(dependency.get("task_id")), {}
|
|
6743
|
+
).get("status") == "completed":
|
|
6744
|
+
continue
|
|
6745
|
+
return False
|
|
6746
|
+
return True
|
|
6747
|
+
|
|
6748
|
+
|
|
6749
|
+
def writeback_ready_tasks_for_implement(
|
|
6750
|
+
root: Path,
|
|
6751
|
+
harness_task_id: str,
|
|
6752
|
+
task: dict,
|
|
6753
|
+
agent: str,
|
|
6754
|
+
restart_statuses: set[str] | None = None,
|
|
6755
|
+
) -> None:
|
|
6756
|
+
inspection, _ = inspect_task_spec(root, task)
|
|
6757
|
+
implement_attempt = 1 + sum(
|
|
6758
|
+
1
|
|
6759
|
+
for entry in task.get("stage_history", [])
|
|
6760
|
+
if isinstance(entry, dict) and entry.get("stage") == "IMPLEMENT"
|
|
6761
|
+
)
|
|
6762
|
+
all_snapshots = {
|
|
6763
|
+
str(snapshot.get("task_id")): snapshot
|
|
6764
|
+
for snapshot in inspection["execution"].get("tasks", [])
|
|
6765
|
+
if isinstance(snapshot, dict)
|
|
6766
|
+
}
|
|
6767
|
+
selected_snapshots = _selected_execution_snapshots(inspection, task)
|
|
6768
|
+
for source_task_id in task.get("selected_spec_tasks") or []:
|
|
6769
|
+
snapshot = selected_snapshots.get(str(source_task_id))
|
|
6770
|
+
if not snapshot or snapshot.get("status") == "in_progress":
|
|
6771
|
+
continue
|
|
6772
|
+
if restart_statuses is not None and snapshot.get("status") not in restart_statuses:
|
|
6773
|
+
continue
|
|
6774
|
+
if not _snapshot_dependencies_ready(snapshot, all_snapshots):
|
|
6775
|
+
continue
|
|
6776
|
+
key = (
|
|
6777
|
+
f"{harness_task_id}:{source_task_id}:enter-implement:"
|
|
6778
|
+
f"{task['spec_source']['revision']}:attempt-{implement_attempt}"
|
|
6779
|
+
)
|
|
6780
|
+
action = {
|
|
6781
|
+
"kind": "task",
|
|
6782
|
+
"source_task_id": source_task_id,
|
|
6783
|
+
"status": "in_progress",
|
|
6784
|
+
"summary": "Harness entered IMPLEMENT for a dependency-ready Canonical task",
|
|
6785
|
+
"evidence": [],
|
|
6786
|
+
"idempotency_key": key,
|
|
6787
|
+
"agent": agent,
|
|
6788
|
+
}
|
|
6789
|
+
_execute_spec_writeback(
|
|
6790
|
+
root,
|
|
6791
|
+
harness_task_id,
|
|
6792
|
+
task,
|
|
6793
|
+
action,
|
|
6794
|
+
key,
|
|
6795
|
+
lambda design_digest, execution_revision, source_task_id=source_task_id: record_task_status(
|
|
6796
|
+
stored_spec_path(root, task),
|
|
6797
|
+
str(source_task_id),
|
|
6798
|
+
"in_progress",
|
|
6799
|
+
"Harness entered IMPLEMENT for a dependency-ready Canonical task",
|
|
6800
|
+
SPEC_WRITEBACK_APP,
|
|
6801
|
+
spec_writeback_agent(agent),
|
|
6802
|
+
design_digest,
|
|
6803
|
+
execution_revision,
|
|
6804
|
+
run_id=harness_task_id,
|
|
6805
|
+
idempotency_key=key,
|
|
6806
|
+
),
|
|
6807
|
+
)
|
|
6808
|
+
|
|
6809
|
+
|
|
6810
|
+
def require_shared_task_statuses(root: Path, task: dict, allowed: set[str]) -> None:
|
|
6811
|
+
inspection, _ = inspect_task_spec(root, task)
|
|
6812
|
+
snapshots = _selected_execution_snapshots(inspection, task)
|
|
6813
|
+
invalid = [
|
|
6814
|
+
f"{task_id}:{snapshots.get(str(task_id), {}).get('status', 'missing')}"
|
|
6815
|
+
for task_id in task.get("selected_spec_tasks") or []
|
|
6816
|
+
if snapshots.get(str(task_id), {}).get("status") not in allowed
|
|
6817
|
+
]
|
|
6818
|
+
if invalid:
|
|
6819
|
+
raise StateError(
|
|
6820
|
+
"Canonical Spec writeback is incomplete for selected tasks: " + ", ".join(invalid)
|
|
6821
|
+
)
|
|
6822
|
+
|
|
6823
|
+
|
|
6824
|
+
def effective_verification_records(root: Path, task_id: str, task: dict) -> list[dict]:
|
|
6825
|
+
fingerprints = evidence_fingerprints(root, task_id)
|
|
6826
|
+
accepted_fingerprints, _ = accepted_verification_fingerprints(
|
|
6827
|
+
root,
|
|
6828
|
+
task_id,
|
|
6829
|
+
task,
|
|
6830
|
+
fingerprints["implementation_fingerprint"],
|
|
6831
|
+
fingerprints["config_fingerprint"],
|
|
6832
|
+
)
|
|
6833
|
+
latest: dict[tuple[str, str, str], dict] = {}
|
|
6834
|
+
for record in execution_records(root, task_id):
|
|
6835
|
+
if (
|
|
6836
|
+
record.get("type") != "verify"
|
|
6837
|
+
or record.get("implementation_fingerprint") not in accepted_fingerprints
|
|
6838
|
+
or record.get("config_fingerprint") != fingerprints["config_fingerprint"]
|
|
6839
|
+
or record.get("applicable") is False
|
|
6840
|
+
):
|
|
6841
|
+
continue
|
|
6842
|
+
key = (
|
|
6843
|
+
str(record.get("source_task_id") or ""),
|
|
6844
|
+
str(record.get("repo_id") or ""),
|
|
6845
|
+
str(record.get("command") or record.get("check") or ""),
|
|
6846
|
+
)
|
|
6847
|
+
latest[key] = record
|
|
6848
|
+
return list(latest.values())
|
|
6849
|
+
|
|
6850
|
+
|
|
6851
|
+
def acceptance_spec_evidence(acceptance: dict) -> dict:
|
|
6852
|
+
digest = str(acceptance.get("diff_sha256") or "")
|
|
6853
|
+
reference = (
|
|
6854
|
+
"execution.jsonl#acceptance="
|
|
6855
|
+
+ digest
|
|
6856
|
+
+ ";authorization="
|
|
6857
|
+
+ str(acceptance.get("authorization") or "")
|
|
6858
|
+
+ ";approval_mode="
|
|
6859
|
+
+ str(acceptance.get("approval_mode") or "")
|
|
6860
|
+
+ ";review_policy="
|
|
6861
|
+
+ str(acceptance.get("review_policy") or "")
|
|
6862
|
+
+ ";verification_policy="
|
|
6863
|
+
+ str(acceptance.get("verification_policy") or "")
|
|
6864
|
+
+ ";targeted_source_tasks="
|
|
6865
|
+
+ ",".join(str(value) for value in acceptance.get("required_targeted_source_tasks", []))
|
|
6866
|
+
)
|
|
6867
|
+
return {
|
|
6868
|
+
"kind": "acceptance",
|
|
6869
|
+
"status": "recorded",
|
|
6870
|
+
"ref": reference,
|
|
6871
|
+
"sha256": digest,
|
|
6872
|
+
}
|
|
6873
|
+
|
|
6874
|
+
|
|
6875
|
+
def writeback_verified_tasks(
|
|
6876
|
+
root: Path,
|
|
6877
|
+
harness_task_id: str,
|
|
6878
|
+
task: dict,
|
|
6879
|
+
agent: str,
|
|
6880
|
+
session_file: str | Path | None = None,
|
|
6881
|
+
) -> None:
|
|
6882
|
+
inspection, selection = inspect_task_spec(root, task)
|
|
6883
|
+
snapshots = _selected_execution_snapshots(inspection, task)
|
|
6884
|
+
acceptance = latest_acceptance_record(root, harness_task_id, task)
|
|
6885
|
+
if not isinstance(acceptance, dict):
|
|
6886
|
+
raise StateError("Canonical verification writeback requires an acceptance record.")
|
|
6887
|
+
verification_records = effective_verification_records(root, harness_task_id, task)
|
|
6888
|
+
selected_tasks = {
|
|
6889
|
+
str(item.get("task_id")): item
|
|
6890
|
+
for item in selection.get("selected_tasks", [])
|
|
6891
|
+
if isinstance(item, dict)
|
|
6892
|
+
}
|
|
6893
|
+
tests_by_task: dict[str, list[dict]] = {}
|
|
6894
|
+
for test in selection.get("selected_tests", []):
|
|
6895
|
+
if isinstance(test, dict):
|
|
6896
|
+
tests_by_task.setdefault(str(test.get("task_id")), []).append(test)
|
|
6897
|
+
for source_task_id in task.get("selected_spec_tasks") or []:
|
|
6898
|
+
source_task_id = str(source_task_id)
|
|
6899
|
+
status_value = snapshots.get(source_task_id, {}).get("status")
|
|
6900
|
+
if status_value in {"verified", "completed"}:
|
|
6901
|
+
continue
|
|
6902
|
+
if status_value != "implemented":
|
|
6903
|
+
raise StateError(
|
|
6904
|
+
f"Canonical task {source_task_id} must remain implemented until MEMORY entry is applied."
|
|
6905
|
+
)
|
|
6906
|
+
repo_id = str(selected_tasks.get(source_task_id, {}).get("repo_id") or "")
|
|
6907
|
+
evidence = []
|
|
6908
|
+
for test in tests_by_task.get(source_task_id, []):
|
|
6909
|
+
command = str(test.get("command") or "")
|
|
6910
|
+
matching = next(
|
|
6911
|
+
(
|
|
6912
|
+
record
|
|
6913
|
+
for record in verification_records
|
|
6914
|
+
if record.get("passed") is True
|
|
6915
|
+
and str(record.get("source_task_id") or "") == source_task_id
|
|
6916
|
+
and str(record.get("repo_id") or "") == repo_id
|
|
6917
|
+
and str(record.get("command") or "") == command
|
|
6918
|
+
),
|
|
6919
|
+
None,
|
|
6920
|
+
)
|
|
6921
|
+
if matching is None:
|
|
6922
|
+
raise StateError(
|
|
6923
|
+
f"Canonical Test {test.get('test_id')} has no accepted verification command: {command}"
|
|
6924
|
+
)
|
|
6925
|
+
evidence.append(
|
|
6926
|
+
{
|
|
6927
|
+
"kind": "test",
|
|
6928
|
+
"status": "passed",
|
|
6929
|
+
"ref": f"execution.jsonl#verify;command={command}",
|
|
6930
|
+
"test_id": str(test.get("test_id")),
|
|
6931
|
+
}
|
|
6932
|
+
)
|
|
6933
|
+
evidence.append(acceptance_spec_evidence(acceptance))
|
|
6934
|
+
acceptance_key = str(acceptance.get("diff_sha256") or "")[:16]
|
|
6935
|
+
key = (
|
|
6936
|
+
f"{harness_task_id}:{source_task_id}:verified-after-acceptance:"
|
|
6937
|
+
f"{task['spec_source']['revision']}:{acceptance_key}"
|
|
6938
|
+
)
|
|
6939
|
+
writeback_spec_task(
|
|
6940
|
+
root,
|
|
6941
|
+
source_task_id,
|
|
6942
|
+
"verified",
|
|
6943
|
+
"Harness verification was accepted when the MEMORY boundary was applied",
|
|
6944
|
+
evidence,
|
|
6945
|
+
key,
|
|
6946
|
+
agent,
|
|
6947
|
+
harness_task_id,
|
|
6948
|
+
session_file,
|
|
6949
|
+
)
|
|
6950
|
+
|
|
6951
|
+
|
|
6952
|
+
def writeback_completed_tasks(
|
|
6953
|
+
root: Path,
|
|
6954
|
+
harness_task_id: str,
|
|
6955
|
+
task: dict,
|
|
6956
|
+
agent: str,
|
|
6957
|
+
) -> None:
|
|
6958
|
+
inspection, _ = inspect_task_spec(root, task)
|
|
6959
|
+
snapshots = _selected_execution_snapshots(inspection, task)
|
|
6960
|
+
acceptance = latest_acceptance_record(root, harness_task_id, task)
|
|
6961
|
+
acceptance_evidence = (
|
|
6962
|
+
[acceptance_spec_evidence(acceptance)] if isinstance(acceptance, dict) else []
|
|
6963
|
+
)
|
|
6964
|
+
for source_task_id in task.get("selected_spec_tasks") or []:
|
|
6965
|
+
status_value = snapshots.get(str(source_task_id), {}).get("status")
|
|
6966
|
+
if status_value == "completed":
|
|
6967
|
+
continue
|
|
6968
|
+
if status_value != "verified":
|
|
6969
|
+
raise StateError(
|
|
6970
|
+
f"Canonical task {source_task_id} must be verified before Harness COMPLETE."
|
|
6971
|
+
)
|
|
6972
|
+
acceptance_key = (
|
|
6973
|
+
str(acceptance.get("diff_sha256") or "")[:16]
|
|
6974
|
+
if isinstance(acceptance, dict)
|
|
6975
|
+
else "legacy"
|
|
6976
|
+
)
|
|
6977
|
+
key = (
|
|
6978
|
+
f"{harness_task_id}:{source_task_id}:complete:"
|
|
6979
|
+
f"{task['spec_source']['revision']}:{acceptance_key}"
|
|
6980
|
+
)
|
|
6981
|
+
action = {
|
|
6982
|
+
"kind": "task",
|
|
6983
|
+
"source_task_id": source_task_id,
|
|
6984
|
+
"status": "completed",
|
|
6985
|
+
"summary": "Harness MEMORY completed and the Canonical task is complete",
|
|
6986
|
+
"evidence": acceptance_evidence,
|
|
6987
|
+
"idempotency_key": key,
|
|
6988
|
+
"agent": agent,
|
|
6989
|
+
}
|
|
6990
|
+
_execute_spec_writeback(
|
|
6991
|
+
root,
|
|
6992
|
+
harness_task_id,
|
|
6993
|
+
task,
|
|
6994
|
+
action,
|
|
6995
|
+
key,
|
|
6996
|
+
lambda design_digest, execution_revision, source_task_id=source_task_id: record_task_status(
|
|
6997
|
+
stored_spec_path(root, task),
|
|
6998
|
+
str(source_task_id),
|
|
6999
|
+
"completed",
|
|
7000
|
+
"Harness MEMORY completed and the Canonical task is complete",
|
|
7001
|
+
SPEC_WRITEBACK_APP,
|
|
7002
|
+
spec_writeback_agent(agent),
|
|
7003
|
+
design_digest,
|
|
7004
|
+
execution_revision,
|
|
7005
|
+
evidence=acceptance_evidence,
|
|
7006
|
+
run_id=harness_task_id,
|
|
7007
|
+
idempotency_key=key,
|
|
7008
|
+
),
|
|
7009
|
+
)
|
|
3846
7010
|
|
|
3847
7011
|
|
|
3848
|
-
def
|
|
7012
|
+
def cancel_shared_tasks(
|
|
3849
7013
|
root: Path,
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
task_type: str,
|
|
3854
|
-
title: str,
|
|
3855
|
-
repo_paths: dict[str, str],
|
|
3856
|
-
dependency_evidence: dict[str, str],
|
|
7014
|
+
harness_task_id: str,
|
|
7015
|
+
task: dict,
|
|
7016
|
+
reason: str,
|
|
3857
7017
|
agent: str,
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
7018
|
+
) -> None:
|
|
7019
|
+
inspection, _ = inspect_task_spec(root, task)
|
|
7020
|
+
snapshots = _selected_execution_snapshots(inspection, task)
|
|
7021
|
+
for source_task_id in task.get("selected_spec_tasks") or []:
|
|
7022
|
+
current = snapshots.get(str(source_task_id), {}).get("status")
|
|
7023
|
+
if current in {"completed", "cancelled"}:
|
|
7024
|
+
continue
|
|
7025
|
+
if current in {"implemented", "verified"}:
|
|
7026
|
+
blocked_key = f"{harness_task_id}:{source_task_id}:close-blocked"
|
|
7027
|
+
blocked_action = {
|
|
7028
|
+
"kind": "task",
|
|
7029
|
+
"source_task_id": source_task_id,
|
|
7030
|
+
"status": "blocked",
|
|
7031
|
+
"summary": reason,
|
|
7032
|
+
"evidence": [],
|
|
7033
|
+
"idempotency_key": blocked_key,
|
|
7034
|
+
"agent": agent,
|
|
7035
|
+
}
|
|
7036
|
+
_execute_spec_writeback(
|
|
7037
|
+
root,
|
|
7038
|
+
harness_task_id,
|
|
7039
|
+
task,
|
|
7040
|
+
blocked_action,
|
|
7041
|
+
blocked_key,
|
|
7042
|
+
lambda design_digest, execution_revision, source_task_id=source_task_id: record_task_status(
|
|
7043
|
+
stored_spec_path(root, task),
|
|
7044
|
+
str(source_task_id),
|
|
7045
|
+
"blocked",
|
|
7046
|
+
reason,
|
|
7047
|
+
SPEC_WRITEBACK_APP,
|
|
7048
|
+
spec_writeback_agent(agent),
|
|
7049
|
+
design_digest,
|
|
7050
|
+
execution_revision,
|
|
7051
|
+
run_id=harness_task_id,
|
|
7052
|
+
idempotency_key=blocked_key,
|
|
7053
|
+
),
|
|
7054
|
+
)
|
|
7055
|
+
cancel_key = f"{harness_task_id}:{source_task_id}:cancel"
|
|
7056
|
+
cancel_action = {
|
|
7057
|
+
"kind": "task",
|
|
7058
|
+
"source_task_id": source_task_id,
|
|
7059
|
+
"status": "cancelled",
|
|
7060
|
+
"summary": reason,
|
|
7061
|
+
"evidence": [],
|
|
7062
|
+
"idempotency_key": cancel_key,
|
|
7063
|
+
"agent": agent,
|
|
7064
|
+
}
|
|
7065
|
+
_execute_spec_writeback(
|
|
3870
7066
|
root,
|
|
3871
|
-
|
|
3872
|
-
|
|
7067
|
+
harness_task_id,
|
|
7068
|
+
task,
|
|
7069
|
+
cancel_action,
|
|
7070
|
+
cancel_key,
|
|
7071
|
+
lambda design_digest, execution_revision, source_task_id=source_task_id: record_task_status(
|
|
7072
|
+
stored_spec_path(root, task),
|
|
7073
|
+
str(source_task_id),
|
|
7074
|
+
"cancelled",
|
|
7075
|
+
reason,
|
|
7076
|
+
SPEC_WRITEBACK_APP,
|
|
7077
|
+
spec_writeback_agent(agent),
|
|
7078
|
+
design_digest,
|
|
7079
|
+
execution_revision,
|
|
7080
|
+
run_id=harness_task_id,
|
|
7081
|
+
idempotency_key=cancel_key,
|
|
7082
|
+
),
|
|
3873
7083
|
)
|
|
3874
|
-
selection = select_tasks(inspection, spec_task_ids, dependency_evidence)
|
|
3875
|
-
except EasyDevSpecError as exc:
|
|
3876
|
-
raise StateError(f"Cannot create task from Canonical Spec: {exc}") from exc
|
|
3877
|
-
|
|
3878
|
-
selected_repo_ids = set(selection["selected_repo_ids"])
|
|
3879
|
-
bindings = [
|
|
3880
|
-
binding
|
|
3881
|
-
for binding in inspection["repository_bindings"]
|
|
3882
|
-
if binding.get("repo_id") in selected_repo_ids
|
|
3883
|
-
]
|
|
3884
|
-
if len(bindings) != len(selected_repo_ids):
|
|
3885
|
-
raise StateError("Canonical Spec repository bindings do not cover every selected task.")
|
|
3886
|
-
stored_repo_paths = {
|
|
3887
|
-
str(binding["repo_id"]): str(binding["path"])
|
|
3888
|
-
for binding in bindings
|
|
3889
|
-
}
|
|
3890
|
-
source_path = resolved_spec_path.relative_to(root.resolve()).as_posix()
|
|
3891
|
-
fields = {
|
|
3892
|
-
"repos": list(selection["selected_repo_ids"]),
|
|
3893
|
-
"repo_paths": stored_repo_paths,
|
|
3894
|
-
"spec_source": {
|
|
3895
|
-
"schema": inspection["schema"],
|
|
3896
|
-
"spec_id": inspection["spec_id"],
|
|
3897
|
-
"revision": inspection["revision"],
|
|
3898
|
-
"path": source_path,
|
|
3899
|
-
"sha256": inspection["source_sha256"],
|
|
3900
|
-
},
|
|
3901
|
-
"selected_spec_tasks": selection["selected_task_ids"],
|
|
3902
|
-
"spec_repositories": bindings,
|
|
3903
|
-
"spec_dependency_evidence": selection["dependency_records"],
|
|
3904
|
-
}
|
|
3905
|
-
return create_task(
|
|
3906
|
-
root,
|
|
3907
|
-
task_id,
|
|
3908
|
-
task_type,
|
|
3909
|
-
title,
|
|
3910
|
-
agent,
|
|
3911
|
-
set_current,
|
|
3912
|
-
session_file,
|
|
3913
|
-
fields,
|
|
3914
|
-
)
|
|
3915
7084
|
|
|
3916
7085
|
|
|
3917
7086
|
def satisfy_spec_dependency(
|
|
@@ -3953,7 +7122,42 @@ def satisfy_spec_dependency(
|
|
|
3953
7122
|
record["satisfied_at"] = now_iso()
|
|
3954
7123
|
record["satisfied_by"] = agent
|
|
3955
7124
|
task["last_agent"] = agent
|
|
3956
|
-
|
|
7125
|
+
evidence_digest = hashlib.sha256(evidence.strip().encode("utf-8")).hexdigest()[:16]
|
|
7126
|
+
idempotency_key = (
|
|
7127
|
+
f"{resolved_task_id}:{record.get('source_task_id')}:{dependency_task_id}:"
|
|
7128
|
+
f"dependency-satisfied:revision-{task['spec_source']['revision']}:{evidence_digest}"
|
|
7129
|
+
)
|
|
7130
|
+
action = {
|
|
7131
|
+
"kind": "dependency",
|
|
7132
|
+
"source_task_id": str(record.get("source_task_id")),
|
|
7133
|
+
"dependency_task_id": dependency_task_id,
|
|
7134
|
+
"status": "satisfied",
|
|
7135
|
+
"summary": evidence.strip(),
|
|
7136
|
+
"evidence": [{"kind": "dependency", "status": "passed", "ref": evidence.strip()}],
|
|
7137
|
+
"idempotency_key": idempotency_key,
|
|
7138
|
+
"agent": agent,
|
|
7139
|
+
}
|
|
7140
|
+
_execute_spec_writeback(
|
|
7141
|
+
root,
|
|
7142
|
+
resolved_task_id,
|
|
7143
|
+
task,
|
|
7144
|
+
action,
|
|
7145
|
+
idempotency_key,
|
|
7146
|
+
lambda design_digest, execution_revision: record_dependency_status(
|
|
7147
|
+
stored_spec_path(root, task),
|
|
7148
|
+
str(record.get("source_task_id")),
|
|
7149
|
+
dependency_task_id,
|
|
7150
|
+
"satisfied",
|
|
7151
|
+
evidence.strip(),
|
|
7152
|
+
SPEC_WRITEBACK_APP,
|
|
7153
|
+
spec_writeback_agent(agent),
|
|
7154
|
+
design_digest,
|
|
7155
|
+
execution_revision,
|
|
7156
|
+
evidence=[{"kind": "dependency", "status": "passed", "ref": evidence.strip()}],
|
|
7157
|
+
run_id=resolved_task_id,
|
|
7158
|
+
idempotency_key=idempotency_key,
|
|
7159
|
+
),
|
|
7160
|
+
)
|
|
3957
7161
|
snapshot = snapshot_state(root, session_file, session)
|
|
3958
7162
|
snapshot["action"] = "satisfy-spec-dependency"
|
|
3959
7163
|
return snapshot
|
|
@@ -4040,54 +7244,65 @@ def calculate_workflow_floor(root: Path, task_id: str) -> tuple[str, list[str]]:
|
|
|
4040
7244
|
if not plan:
|
|
4041
7245
|
raise StateError("Cannot calculate workflow floor without a valid execution plan.")
|
|
4042
7246
|
units = [unit for unit in plan.get("units", []) if isinstance(unit, dict)]
|
|
7247
|
+
missing_local_baseline = [
|
|
7248
|
+
str(unit.get("id") or "<unknown>")
|
|
7249
|
+
for unit in units
|
|
7250
|
+
if not is_string_list(unit.get("local_baseline"), allow_empty=False)
|
|
7251
|
+
]
|
|
7252
|
+
if missing_local_baseline:
|
|
7253
|
+
raise StateError(
|
|
7254
|
+
"Workflow plan Units must record a non-empty local_baseline: "
|
|
7255
|
+
+ ", ".join(missing_local_baseline)
|
|
7256
|
+
)
|
|
4043
7257
|
files = {
|
|
4044
7258
|
str(file_name)
|
|
4045
7259
|
for unit in units
|
|
4046
7260
|
for file_name in unit.get("files", [])
|
|
4047
7261
|
if is_non_empty_string(file_name)
|
|
4048
7262
|
}
|
|
4049
|
-
repositories =
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
7263
|
+
repositories = workflow_plan_repository_roots(root, task, plan)
|
|
7264
|
+
ignored_values = {"none", "no", "n/a", "无", "无风险"}
|
|
7265
|
+
risk_values = [
|
|
7266
|
+
str(item)
|
|
7267
|
+
for unit in units
|
|
7268
|
+
for item in unit.get("risks", [])
|
|
7269
|
+
if is_non_empty_string(item) and str(item).strip().lower() not in ignored_values
|
|
7270
|
+
]
|
|
7271
|
+
contract_values = [
|
|
7272
|
+
str(item)
|
|
7273
|
+
for unit in units
|
|
7274
|
+
for item in unit.get("contracts", [])
|
|
7275
|
+
if is_non_empty_string(item) and str(item).strip().lower() not in ignored_values
|
|
7276
|
+
]
|
|
7277
|
+
risk_text = NEGATED_HIGH_WORKFLOW_RISK_PATTERN.sub("", " ".join(risk_values))
|
|
7278
|
+
high_risk = bool(HIGH_WORKFLOW_RISK_PATTERN.search(risk_text))
|
|
7279
|
+
|
|
7280
|
+
complexity_reasons: list[str] = []
|
|
7281
|
+
if len(repositories) > 1:
|
|
7282
|
+
complexity_reasons.append("cross-repository-change")
|
|
7283
|
+
if len(units) >= 4 or len(files) >= 10:
|
|
7284
|
+
complexity_reasons.append("broad-change-scope")
|
|
7285
|
+
if WIDE_WORKFLOW_CONTRACT_PATTERN.search(" ".join(contract_values)):
|
|
7286
|
+
complexity_reasons.append("wide-contract-impact")
|
|
7287
|
+
if high_risk and complexity_reasons:
|
|
7288
|
+
return "strict", [
|
|
7289
|
+
"compound-high-risk-and-complexity",
|
|
7290
|
+
"explicit-high-risk-signal",
|
|
7291
|
+
*complexity_reasons,
|
|
4070
7292
|
]
|
|
4071
|
-
)
|
|
4072
|
-
strict_reasons: list[str] = []
|
|
4073
|
-
if repo_count > 1:
|
|
4074
|
-
strict_reasons.append("cross-repository-scope")
|
|
4075
|
-
if len(units) >= 4 or len(files) >= 8:
|
|
4076
|
-
strict_reasons.append("broad-change-scope")
|
|
4077
|
-
if STRICT_WORKFLOW_RISK_PATTERN.search(risk_text):
|
|
4078
|
-
strict_reasons.append("high-risk-contract-or-domain")
|
|
4079
|
-
if strict_reasons:
|
|
4080
|
-
return "strict", strict_reasons
|
|
4081
7293
|
|
|
4082
7294
|
standard_reasons: list[str] = []
|
|
7295
|
+
if high_risk:
|
|
7296
|
+
standard_reasons.append("bounded-high-risk-change")
|
|
7297
|
+
standard_reasons.extend(complexity_reasons)
|
|
4083
7298
|
if len(units) > 1:
|
|
4084
7299
|
standard_reasons.append("multiple-units")
|
|
4085
|
-
if len(files)
|
|
7300
|
+
if len(files) > 5:
|
|
4086
7301
|
standard_reasons.append("multi-file-impact")
|
|
4087
7302
|
if plan.get("strategy") == "parallel":
|
|
4088
7303
|
standard_reasons.append("parallel-execution")
|
|
4089
7304
|
if standard_reasons:
|
|
4090
|
-
return "standard", standard_reasons
|
|
7305
|
+
return "standard", list(dict.fromkeys(standard_reasons))
|
|
4091
7306
|
return "fast", ["single-bounded-unit"]
|
|
4092
7307
|
|
|
4093
7308
|
|
|
@@ -4139,9 +7354,14 @@ def freeze_tdd_mode(
|
|
|
4139
7354
|
) -> None:
|
|
4140
7355
|
behavior = resolve_behavior(root, session)
|
|
4141
7356
|
task_type = str(task.get("type") or "").strip().lower()
|
|
4142
|
-
task["tdd_enabled"] =
|
|
7357
|
+
task["tdd_enabled"] = (
|
|
7358
|
+
behavior[8]
|
|
7359
|
+
if task_type not in NO_CODE_TASK_TYPES | {TDD_INIT_TASK_TYPE}
|
|
7360
|
+
else False
|
|
7361
|
+
)
|
|
4143
7362
|
task["tdd_coverage_threshold"] = behavior[11]
|
|
4144
7363
|
if task["tdd_enabled"] is True:
|
|
7364
|
+
require_tdd_readiness(root)
|
|
4145
7365
|
plan = latest_execution_plan(root, task_id)
|
|
4146
7366
|
if plan is None:
|
|
4147
7367
|
raise StateError("Cannot freeze TDD baseline without a valid execution plan.")
|
|
@@ -4242,8 +7462,22 @@ def request_transition(
|
|
|
4242
7462
|
)
|
|
4243
7463
|
if previous == "REVIEW" and stage == "VERIFICATION":
|
|
4244
7464
|
validate_review_readiness(root, resolved_task_id, task)
|
|
7465
|
+
acceptance_drift: dict | None = None
|
|
4245
7466
|
if previous == "VERIFICATION" and stage == "MEMORY":
|
|
4246
|
-
|
|
7467
|
+
task = ensure_verification_checkpoint(
|
|
7468
|
+
root, resolved_task_id, task, agent, session_file
|
|
7469
|
+
)
|
|
7470
|
+
acceptance_drift = inspect_acceptance_drift(root, resolved_task_id, task)
|
|
7471
|
+
if acceptance_drift["config_changed"]:
|
|
7472
|
+
raise StateError(
|
|
7473
|
+
"Behavior config changed after verification; rerun verification before MEMORY."
|
|
7474
|
+
)
|
|
7475
|
+
if acceptance_drift["metadata_changed"]:
|
|
7476
|
+
raise StateError(
|
|
7477
|
+
"Non-code verification metadata changed; return to ANALYSIS or IMPLEMENT."
|
|
7478
|
+
)
|
|
7479
|
+
if acceptance_drift["status"] == "clean":
|
|
7480
|
+
validate_verification_readiness(root, resolved_task_id, task)
|
|
4247
7481
|
existing = task.get("pending_transition")
|
|
4248
7482
|
if isinstance(existing, dict):
|
|
4249
7483
|
if existing.get("from") != previous or existing.get("to") != stage:
|
|
@@ -4263,6 +7497,8 @@ def request_transition(
|
|
|
4263
7497
|
|
|
4264
7498
|
snapshot = snapshot_state(root, session_file, session)
|
|
4265
7499
|
snapshot["action"] = "request-transition"
|
|
7500
|
+
if acceptance_drift is not None:
|
|
7501
|
+
snapshot["acceptance_drift"] = acceptance_drift
|
|
4266
7502
|
return snapshot
|
|
4267
7503
|
|
|
4268
7504
|
|
|
@@ -4289,14 +7525,31 @@ def apply_transition(
|
|
|
4289
7525
|
if task.get("workflow_mode_legacy") is not True:
|
|
4290
7526
|
freeze_workflow_mode(root, session, resolved_task_id, task, agent)
|
|
4291
7527
|
freeze_tdd_mode(root, session, resolved_task_id, task, agent)
|
|
7528
|
+
if stage == "IMPLEMENT" and previous != "IMPLEMENT":
|
|
7529
|
+
if isinstance(task.get("spec_source"), dict):
|
|
7530
|
+
writeback_ready_tasks_for_implement(
|
|
7531
|
+
root,
|
|
7532
|
+
resolved_task_id,
|
|
7533
|
+
task,
|
|
7534
|
+
agent,
|
|
7535
|
+
{"blocked"} if previous in {"REVIEW", "VERIFICATION"} else None,
|
|
7536
|
+
)
|
|
4292
7537
|
if previous == "REVIEW" and stage == "VERIFICATION":
|
|
4293
7538
|
validate_review_readiness(root, resolved_task_id, task)
|
|
4294
7539
|
if previous == "VERIFICATION" and stage == "MEMORY":
|
|
4295
7540
|
validate_verification_readiness(root, resolved_task_id, task)
|
|
7541
|
+
if isinstance(task.get("spec_source"), dict):
|
|
7542
|
+
writeback_verified_tasks(
|
|
7543
|
+
root, resolved_task_id, task, agent, session_file
|
|
7544
|
+
)
|
|
7545
|
+
task = load_task(root, resolved_task_id) or task
|
|
7546
|
+
require_shared_task_statuses(root, task, {"verified", "completed"})
|
|
4296
7547
|
if previous == "MEMORY" and stage == "COMPLETE":
|
|
4297
7548
|
progress = task.get("memory_progress")
|
|
4298
7549
|
if not isinstance(progress, dict) or progress.get("completed") is not True:
|
|
4299
7550
|
raise StateError("MEMORY cannot advance to COMPLETE before memory processing completes.")
|
|
7551
|
+
if isinstance(task.get("spec_source"), dict):
|
|
7552
|
+
writeback_completed_tasks(root, resolved_task_id, task, agent)
|
|
4300
7553
|
if (previous, stage) == READ_ONLY_COMPLETION_TRANSITION:
|
|
4301
7554
|
validate_read_only_completion(root, resolved_task_id)
|
|
4302
7555
|
if previous != stage:
|
|
@@ -4311,6 +7564,8 @@ def apply_transition(
|
|
|
4311
7564
|
task.pop("workflow_mode_legacy_direct_edge", None)
|
|
4312
7565
|
if stage in {"ANALYSIS", "IMPLEMENT", "MEMORY", "COMPLETE", "CLOSED"}:
|
|
4313
7566
|
task.pop("workflow_mode_legacy_review_bypass_fingerprint", None)
|
|
7567
|
+
if stage in {"ANALYSIS", "IMPLEMENT", "MEMORY", "COMPLETE", "CLOSED"}:
|
|
7568
|
+
cleanup_verification_checkpoint(root, resolved_task_id, task)
|
|
4314
7569
|
task.pop("pending_transition", None)
|
|
4315
7570
|
if stage == "MEMORY" and previous != stage:
|
|
4316
7571
|
task["memory_progress"] = {}
|
|
@@ -4335,7 +7590,7 @@ def auto_transition(
|
|
|
4335
7590
|
task_id: str | None = None,
|
|
4336
7591
|
session_file: str | Path | None = None,
|
|
4337
7592
|
) -> dict:
|
|
4338
|
-
session,
|
|
7593
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
4339
7594
|
previous = str(task.get("status") or "idle")
|
|
4340
7595
|
task_type = str(task.get("type") or "")
|
|
4341
7596
|
approval_mode = resolve_approval_mode(root, session)[2]
|
|
@@ -4352,6 +7607,43 @@ def auto_transition(
|
|
|
4352
7607
|
"A different transition is already pending. Cancel it before automatic transition."
|
|
4353
7608
|
)
|
|
4354
7609
|
|
|
7610
|
+
if previous == "VERIFICATION" and stage == "MEMORY":
|
|
7611
|
+
task = ensure_verification_checkpoint(
|
|
7612
|
+
root, resolved_task_id, task, agent, session_file
|
|
7613
|
+
)
|
|
7614
|
+
drift = inspect_acceptance_drift(root, resolved_task_id, task)
|
|
7615
|
+
if drift["config_changed"]:
|
|
7616
|
+
raise StateError(
|
|
7617
|
+
"Behavior config changed after verification; rerun verification before MEMORY."
|
|
7618
|
+
)
|
|
7619
|
+
if drift["metadata_changed"]:
|
|
7620
|
+
raise StateError(
|
|
7621
|
+
"Non-code verification metadata changed; return to ANALYSIS or IMPLEMENT."
|
|
7622
|
+
)
|
|
7623
|
+
if drift["changed_files"]:
|
|
7624
|
+
task["pending_transition"] = {
|
|
7625
|
+
"from": previous,
|
|
7626
|
+
"to": stage,
|
|
7627
|
+
"requested_at": now_iso(),
|
|
7628
|
+
"requested_by": agent,
|
|
7629
|
+
"reason": "verification checkpoint drift requires exact user acceptance",
|
|
7630
|
+
"confirmation_override": "evidence-drift",
|
|
7631
|
+
}
|
|
7632
|
+
task["last_agent"] = agent
|
|
7633
|
+
write_task(root, resolved_task_id, task)
|
|
7634
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
7635
|
+
snapshot["action"] = "acceptance-drift"
|
|
7636
|
+
snapshot["acceptance_drift"] = drift
|
|
7637
|
+
return snapshot
|
|
7638
|
+
append_transition_acceptance(
|
|
7639
|
+
root,
|
|
7640
|
+
resolved_task_id,
|
|
7641
|
+
task,
|
|
7642
|
+
agent,
|
|
7643
|
+
approval_mode,
|
|
7644
|
+
"approval-policy",
|
|
7645
|
+
)
|
|
7646
|
+
|
|
4355
7647
|
snapshot = apply_transition(root, stage, agent, task_id, session_file)
|
|
4356
7648
|
snapshot["action"] = "auto-transition"
|
|
4357
7649
|
snapshot["automatic_transition"] = {"from": previous, "to": stage}
|
|
@@ -4364,8 +7656,11 @@ def confirm_transition(
|
|
|
4364
7656
|
stage: str | None = None,
|
|
4365
7657
|
task_id: str | None = None,
|
|
4366
7658
|
session_file: str | Path | None = None,
|
|
7659
|
+
expected_diff_sha256: str | None = None,
|
|
7660
|
+
verification_policy: str | None = None,
|
|
7661
|
+
decision_summary: str | None = None,
|
|
4367
7662
|
) -> dict:
|
|
4368
|
-
session,
|
|
7663
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
4369
7664
|
pending = task.get("pending_transition")
|
|
4370
7665
|
if not isinstance(pending, dict):
|
|
4371
7666
|
raise StateError("No transition is pending user confirmation.")
|
|
@@ -4380,12 +7675,29 @@ def confirm_transition(
|
|
|
4380
7675
|
)
|
|
4381
7676
|
if stage and stage != target:
|
|
4382
7677
|
raise StateError(f"Pending transition targets {target}, not {stage}.")
|
|
4383
|
-
|
|
7678
|
+
drift_override = pending.get("confirmation_override") == "evidence-drift"
|
|
7679
|
+
if is_automatic_transition(source, target, task_type, approval_mode) and not drift_override:
|
|
4384
7680
|
raise StateError(
|
|
4385
7681
|
f"Transition {source} -> {target} is automatic in {approval_mode} mode; "
|
|
4386
7682
|
"use auto-transition instead."
|
|
4387
7683
|
)
|
|
4388
7684
|
|
|
7685
|
+
if source == "VERIFICATION" and target == "MEMORY":
|
|
7686
|
+
task = ensure_verification_checkpoint(
|
|
7687
|
+
root, resolved_task_id, task, agent, session_file
|
|
7688
|
+
)
|
|
7689
|
+
append_transition_acceptance(
|
|
7690
|
+
root,
|
|
7691
|
+
resolved_task_id,
|
|
7692
|
+
task,
|
|
7693
|
+
agent,
|
|
7694
|
+
approval_mode,
|
|
7695
|
+
"explicit-user",
|
|
7696
|
+
expected_diff_sha256,
|
|
7697
|
+
verification_policy,
|
|
7698
|
+
decision_summary,
|
|
7699
|
+
)
|
|
7700
|
+
|
|
4389
7701
|
snapshot = apply_transition(root, target, agent, task_id, session_file)
|
|
4390
7702
|
snapshot["action"] = "confirm-transition"
|
|
4391
7703
|
snapshot["confirmed_transition"] = {"from": source, "to": target}
|
|
@@ -4426,6 +7738,46 @@ def memory_short_complete(
|
|
|
4426
7738
|
memory_file.strip(),
|
|
4427
7739
|
require_current_id=True,
|
|
4428
7740
|
)
|
|
7741
|
+
acceptance = latest_acceptance_record(root, resolved_task_id, task)
|
|
7742
|
+
if isinstance(acceptance, dict) and acceptance.get("changed_files"):
|
|
7743
|
+
try:
|
|
7744
|
+
memory_text = resolved_memory_path.read_text(encoding="utf-8")
|
|
7745
|
+
except (OSError, UnicodeError) as exc:
|
|
7746
|
+
raise StateError(f"Cannot read short-memory file: {resolved_memory_path}") from exc
|
|
7747
|
+
required_decision_fields = {
|
|
7748
|
+
"diff_sha256": str(acceptance.get("diff_sha256") or ""),
|
|
7749
|
+
"authorization": str(acceptance.get("authorization") or ""),
|
|
7750
|
+
"approval_mode": str(acceptance.get("approval_mode") or ""),
|
|
7751
|
+
"review_policy": str(acceptance.get("review_policy") or ""),
|
|
7752
|
+
"verification_policy": str(acceptance.get("verification_policy") or ""),
|
|
7753
|
+
"summary": str(acceptance.get("summary") or ""),
|
|
7754
|
+
}
|
|
7755
|
+
missing_decision_fields = [
|
|
7756
|
+
field_name
|
|
7757
|
+
for field_name, value in required_decision_fields.items()
|
|
7758
|
+
if not value or value not in memory_text
|
|
7759
|
+
]
|
|
7760
|
+
missing_changed_files = [
|
|
7761
|
+
str(file_name)
|
|
7762
|
+
for file_name in acceptance.get("changed_files", [])
|
|
7763
|
+
if not is_non_empty_string(file_name) or str(file_name) not in memory_text
|
|
7764
|
+
]
|
|
7765
|
+
missing_targeted_tasks = [
|
|
7766
|
+
str(source_task_id)
|
|
7767
|
+
for source_task_id in acceptance.get("required_targeted_source_tasks", [])
|
|
7768
|
+
if not is_non_empty_string(source_task_id)
|
|
7769
|
+
or str(source_task_id) not in memory_text
|
|
7770
|
+
]
|
|
7771
|
+
if missing_decision_fields or missing_changed_files or missing_targeted_tasks:
|
|
7772
|
+
missing_labels = [
|
|
7773
|
+
*missing_decision_fields,
|
|
7774
|
+
*(f"changed_file:{file_name}" for file_name in missing_changed_files),
|
|
7775
|
+
*(f"targeted_source_task:{task_name}" for task_name in missing_targeted_tasks),
|
|
7776
|
+
]
|
|
7777
|
+
raise StateError(
|
|
7778
|
+
"Short memory must record the complete accepted post-verification decision; "
|
|
7779
|
+
"missing: " + ", ".join(missing_labels)
|
|
7780
|
+
)
|
|
4429
7781
|
progress = task.get("memory_progress")
|
|
4430
7782
|
if not isinstance(progress, dict):
|
|
4431
7783
|
progress = {}
|
|
@@ -4523,6 +7875,7 @@ def memory_complete(
|
|
|
4523
7875
|
action == "distill" and instruction.get("checkpoint_disposition") == "candidate"
|
|
4524
7876
|
),
|
|
4525
7877
|
)
|
|
7878
|
+
validate_recorded_architecture_assessment(root, progress, instruction)
|
|
4526
7879
|
if action == "distill":
|
|
4527
7880
|
validate_distillation_file_sets(root, instruction)
|
|
4528
7881
|
progress["long_memory_action"] = action
|
|
@@ -4550,7 +7903,10 @@ def close_current_task(
|
|
|
4550
7903
|
task = load_task(root, str(task_id))
|
|
4551
7904
|
if task is None:
|
|
4552
7905
|
raise StateError(f"Task not found: {task_id}")
|
|
7906
|
+
if isinstance(task.get("spec_source"), dict) and task.get("status") not in TERMINAL_STATUSES:
|
|
7907
|
+
cancel_shared_tasks(root, str(task_id), task, reason, agent)
|
|
4553
7908
|
if task.get("status") != "CLOSED":
|
|
7909
|
+
cleanup_verification_checkpoint(root, str(task_id), task)
|
|
4554
7910
|
task["status"] = "CLOSED"
|
|
4555
7911
|
append_stage_history(task, "CLOSED", agent)
|
|
4556
7912
|
task.pop("pending_transition", None)
|
|
@@ -4651,6 +8007,19 @@ def parse_mapping_args(values: list[str], label: str) -> dict[str, str]:
|
|
|
4651
8007
|
return mappings
|
|
4652
8008
|
|
|
4653
8009
|
|
|
8010
|
+
def parse_evidence_args(values: list[str]) -> list[dict]:
|
|
8011
|
+
evidence: list[dict] = []
|
|
8012
|
+
for value in values:
|
|
8013
|
+
try:
|
|
8014
|
+
parsed = json.loads(value)
|
|
8015
|
+
except json.JSONDecodeError as exc:
|
|
8016
|
+
raise StateError(f"--evidence must be a JSON object: {exc}") from exc
|
|
8017
|
+
if not isinstance(parsed, dict):
|
|
8018
|
+
raise StateError("--evidence must be a JSON object.")
|
|
8019
|
+
evidence.append(parsed)
|
|
8020
|
+
return evidence
|
|
8021
|
+
|
|
8022
|
+
|
|
4654
8023
|
def main() -> int:
|
|
4655
8024
|
configure_stdio()
|
|
4656
8025
|
common = argparse.ArgumentParser(add_help=False)
|
|
@@ -4667,6 +8036,11 @@ def main() -> int:
|
|
|
4667
8036
|
inspect_spec_parser = subcommands.add_parser("inspect-dev-spec", parents=[common])
|
|
4668
8037
|
inspect_spec_parser.add_argument("--spec", required=True)
|
|
4669
8038
|
inspect_spec_parser.add_argument("--repo-path", action="append", default=[])
|
|
8039
|
+
inspect_spec_parser.add_argument("--spec-task", action="append", default=[])
|
|
8040
|
+
inspect_spec_parser.add_argument("--manifest-only", action="store_true")
|
|
8041
|
+
|
|
8042
|
+
initialize_spec = subcommands.add_parser("initialize-spec-execution", parents=[common])
|
|
8043
|
+
initialize_spec.add_argument("--spec", required=True)
|
|
4670
8044
|
|
|
4671
8045
|
select_spec_scope = subcommands.add_parser("select-dev-spec-scope", parents=[common])
|
|
4672
8046
|
select_spec_scope.add_argument("--spec", required=True)
|
|
@@ -4685,11 +8059,71 @@ def main() -> int:
|
|
|
4685
8059
|
create_from_spec.add_argument("--task-id", required=True)
|
|
4686
8060
|
create_from_spec.add_argument("--type", required=True)
|
|
4687
8061
|
create_from_spec.add_argument("--title", required=True)
|
|
4688
|
-
create_from_spec.add_argument("--repo-path",
|
|
8062
|
+
create_from_spec.add_argument("--repo-path", action="append", default=[])
|
|
4689
8063
|
create_from_spec.add_argument("--dependency-evidence", action="append", default=[])
|
|
4690
8064
|
create_from_spec.add_argument("--agent", required=True)
|
|
4691
8065
|
create_from_spec.add_argument("--no-set-current", action="store_true")
|
|
4692
8066
|
|
|
8067
|
+
rebind_spec = subcommands.add_parser("rebind-spec-source", parents=[common])
|
|
8068
|
+
rebind_spec.add_argument("--spec", required=True)
|
|
8069
|
+
rebind_spec.add_argument("--agent", required=True)
|
|
8070
|
+
rebind_spec.add_argument("--task-id")
|
|
8071
|
+
|
|
8072
|
+
writeback_task = subcommands.add_parser("writeback-spec-task", parents=[common])
|
|
8073
|
+
writeback_task.add_argument("--spec-task", required=True)
|
|
8074
|
+
writeback_task.add_argument(
|
|
8075
|
+
"--status",
|
|
8076
|
+
required=True,
|
|
8077
|
+
choices=[
|
|
8078
|
+
"in_progress",
|
|
8079
|
+
"blocked",
|
|
8080
|
+
"implemented",
|
|
8081
|
+
"verified",
|
|
8082
|
+
"completed",
|
|
8083
|
+
"cancelled",
|
|
8084
|
+
],
|
|
8085
|
+
)
|
|
8086
|
+
writeback_task.add_argument("--summary", required=True)
|
|
8087
|
+
writeback_task.add_argument("--evidence", action="append", default=[])
|
|
8088
|
+
writeback_task.add_argument("--idempotency-key", required=True)
|
|
8089
|
+
writeback_task.add_argument("--agent", required=True)
|
|
8090
|
+
writeback_task.add_argument("--task-id")
|
|
8091
|
+
|
|
8092
|
+
writeback_step = subcommands.add_parser("writeback-spec-step", parents=[common])
|
|
8093
|
+
writeback_step.add_argument("--spec-task", required=True)
|
|
8094
|
+
writeback_step.add_argument("--step", required=True)
|
|
8095
|
+
writeback_step.add_argument("--status", required=True, choices=["completed", "failed"])
|
|
8096
|
+
writeback_step.add_argument("--summary", required=True)
|
|
8097
|
+
writeback_step.add_argument("--evidence", action="append", default=[])
|
|
8098
|
+
writeback_step.add_argument("--idempotency-key", required=True)
|
|
8099
|
+
writeback_step.add_argument("--agent", required=True)
|
|
8100
|
+
writeback_step.add_argument("--task-id")
|
|
8101
|
+
|
|
8102
|
+
writeback_dependency = subcommands.add_parser(
|
|
8103
|
+
"writeback-spec-dependency", parents=[common]
|
|
8104
|
+
)
|
|
8105
|
+
writeback_dependency.add_argument("--source-task", required=True)
|
|
8106
|
+
writeback_dependency.add_argument("--dependency-task", required=True)
|
|
8107
|
+
writeback_dependency.add_argument(
|
|
8108
|
+
"--status", required=True, choices=["pending", "satisfied"]
|
|
8109
|
+
)
|
|
8110
|
+
writeback_dependency.add_argument("--summary", required=True)
|
|
8111
|
+
writeback_dependency.add_argument("--evidence", action="append", default=[])
|
|
8112
|
+
writeback_dependency.add_argument("--idempotency-key", required=True)
|
|
8113
|
+
writeback_dependency.add_argument("--agent", required=True)
|
|
8114
|
+
writeback_dependency.add_argument("--task-id")
|
|
8115
|
+
|
|
8116
|
+
sync_spec = subcommands.add_parser("sync-spec-design", parents=[common])
|
|
8117
|
+
sync_spec.add_argument("--affected-task", action="append", default=[])
|
|
8118
|
+
sync_spec.add_argument("--summary", required=True)
|
|
8119
|
+
sync_spec.add_argument("--idempotency-key", required=True)
|
|
8120
|
+
sync_spec.add_argument("--agent", required=True)
|
|
8121
|
+
sync_spec.add_argument("--task-id")
|
|
8122
|
+
|
|
8123
|
+
reconcile_spec = subcommands.add_parser("reconcile-spec-execution", parents=[common])
|
|
8124
|
+
reconcile_spec.add_argument("--agent", required=True)
|
|
8125
|
+
reconcile_spec.add_argument("--task-id")
|
|
8126
|
+
|
|
4693
8127
|
set_current = subcommands.add_parser("set-current", parents=[common])
|
|
4694
8128
|
set_current.add_argument("--task-id", required=True)
|
|
4695
8129
|
set_current.add_argument("--agent", required=True)
|
|
@@ -4766,6 +8200,18 @@ def main() -> int:
|
|
|
4766
8200
|
fingerprints_parser.add_argument("--agent", required=True)
|
|
4767
8201
|
fingerprints_parser.add_argument("--task-id")
|
|
4768
8202
|
|
|
8203
|
+
verification_checkpoint_parser = subcommands.add_parser(
|
|
8204
|
+
"verification-checkpoint", parents=[common]
|
|
8205
|
+
)
|
|
8206
|
+
verification_checkpoint_parser.add_argument("--agent", required=True)
|
|
8207
|
+
verification_checkpoint_parser.add_argument("--task-id")
|
|
8208
|
+
|
|
8209
|
+
inspect_transition_drift_parser = subcommands.add_parser(
|
|
8210
|
+
"inspect-transition-drift", parents=[common]
|
|
8211
|
+
)
|
|
8212
|
+
inspect_transition_drift_parser.add_argument("--agent", required=True)
|
|
8213
|
+
inspect_transition_drift_parser.add_argument("--task-id")
|
|
8214
|
+
|
|
4769
8215
|
disable_harness_parser = subcommands.add_parser("disable-harness", parents=[common])
|
|
4770
8216
|
disable_harness_parser.add_argument("--agent", required=True)
|
|
4771
8217
|
|
|
@@ -4791,6 +8237,11 @@ def main() -> int:
|
|
|
4791
8237
|
confirm_transition_parser.add_argument("--stage")
|
|
4792
8238
|
confirm_transition_parser.add_argument("--agent", required=True)
|
|
4793
8239
|
confirm_transition_parser.add_argument("--task-id")
|
|
8240
|
+
confirm_transition_parser.add_argument("--diff-sha256")
|
|
8241
|
+
confirm_transition_parser.add_argument(
|
|
8242
|
+
"--verification-policy", choices=sorted(ACCEPTANCE_VERIFICATION_POLICIES)
|
|
8243
|
+
)
|
|
8244
|
+
confirm_transition_parser.add_argument("--decision-summary")
|
|
4794
8245
|
|
|
4795
8246
|
auto_transition_parser = subcommands.add_parser("auto-transition", parents=[common])
|
|
4796
8247
|
auto_transition_parser.add_argument("--stage", required=True)
|
|
@@ -4802,6 +8253,11 @@ def main() -> int:
|
|
|
4802
8253
|
transition.add_argument("--stage")
|
|
4803
8254
|
transition.add_argument("--agent", required=True)
|
|
4804
8255
|
transition.add_argument("--task-id")
|
|
8256
|
+
transition.add_argument("--diff-sha256")
|
|
8257
|
+
transition.add_argument(
|
|
8258
|
+
"--verification-policy", choices=sorted(ACCEPTANCE_VERIFICATION_POLICIES)
|
|
8259
|
+
)
|
|
8260
|
+
transition.add_argument("--decision-summary")
|
|
4805
8261
|
|
|
4806
8262
|
cancel_transition_parser = subcommands.add_parser("cancel-transition", parents=[common])
|
|
4807
8263
|
cancel_transition_parser.add_argument("--agent", required=True)
|
|
@@ -4819,6 +8275,18 @@ def main() -> int:
|
|
|
4819
8275
|
memory_instruction_parser.add_argument("--agent")
|
|
4820
8276
|
memory_instruction_parser.add_argument("--task-id")
|
|
4821
8277
|
|
|
8278
|
+
memory_architecture_parser = subcommands.add_parser(
|
|
8279
|
+
"memory-architecture-assessment", parents=[common]
|
|
8280
|
+
)
|
|
8281
|
+
memory_architecture_parser.add_argument(
|
|
8282
|
+
"--action", required=True, choices=sorted(ARCHITECTURE_ACTIONS)
|
|
8283
|
+
)
|
|
8284
|
+
memory_architecture_parser.add_argument("--reason", required=True)
|
|
8285
|
+
memory_architecture_parser.add_argument("--evidence", action="append", default=[])
|
|
8286
|
+
memory_architecture_parser.add_argument("--affected-section", action="append", default=[])
|
|
8287
|
+
memory_architecture_parser.add_argument("--agent", required=True)
|
|
8288
|
+
memory_architecture_parser.add_argument("--task-id")
|
|
8289
|
+
|
|
4822
8290
|
memory_complete_parser = subcommands.add_parser("memory-complete", parents=[common])
|
|
4823
8291
|
memory_complete_parser.add_argument("--action", required=True, choices=["no-op", "distill"])
|
|
4824
8292
|
memory_complete_parser.add_argument("--agent", required=True)
|
|
@@ -4849,9 +8317,8 @@ def main() -> int:
|
|
|
4849
8317
|
root = resolve_root(getattr(args, "cwd", None))
|
|
4850
8318
|
session_file = getattr(args, "session_file", None)
|
|
4851
8319
|
command = args.command or "snapshot"
|
|
4852
|
-
agent =
|
|
4853
|
-
|
|
4854
|
-
)
|
|
8320
|
+
agent = resolve_state_agent(getattr(args, "agent", None))
|
|
8321
|
+
validate_session_agent(agent, session_file)
|
|
4855
8322
|
session_agent = normalize_session_agent(agent)
|
|
4856
8323
|
visible_agent = None if agent == "unknown" else agent
|
|
4857
8324
|
if session_file is None and command == "project-init-complete":
|
|
@@ -4860,6 +8327,7 @@ def main() -> int:
|
|
|
4860
8327
|
)
|
|
4861
8328
|
if session_file is None and command not in {
|
|
4862
8329
|
"inspect-dev-spec",
|
|
8330
|
+
"initialize-spec-execution",
|
|
4863
8331
|
"select-dev-spec-scope",
|
|
4864
8332
|
"list-tasks",
|
|
4865
8333
|
"memory-new-id",
|
|
@@ -4872,18 +8340,26 @@ def main() -> int:
|
|
|
4872
8340
|
if command == "snapshot":
|
|
4873
8341
|
emit(snapshot_state(root, session_file))
|
|
4874
8342
|
elif command == "inspect-dev-spec":
|
|
4875
|
-
spec_path = Path(args.spec)
|
|
4876
|
-
|
|
4877
|
-
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
|
|
8343
|
+
spec_path = Path(args.spec).expanduser()
|
|
8344
|
+
if args.manifest_only and args.spec_task:
|
|
8345
|
+
raise StateError("--manifest-only cannot be combined with --spec-task")
|
|
8346
|
+
resolved_spec = spec_path if spec_path.is_absolute() else root / spec_path
|
|
8347
|
+
repo_paths = parse_mapping_args(args.repo_path, "--repo-path")
|
|
8348
|
+
inspection = (
|
|
8349
|
+
inspect_manifest(resolved_spec, root, repo_paths)
|
|
8350
|
+
if args.manifest_only
|
|
8351
|
+
else inspect_spec(
|
|
8352
|
+
resolved_spec,
|
|
8353
|
+
root,
|
|
8354
|
+
repo_paths,
|
|
8355
|
+
args.spec_task or None,
|
|
4883
8356
|
)
|
|
4884
8357
|
)
|
|
8358
|
+
emit(inspection_summary(inspection))
|
|
8359
|
+
elif command == "initialize-spec-execution":
|
|
8360
|
+
emit(initialize_spec_execution_state(root, args.spec))
|
|
4885
8361
|
elif command == "select-dev-spec-scope":
|
|
4886
|
-
spec_path = Path(args.spec)
|
|
8362
|
+
spec_path = Path(args.spec).expanduser()
|
|
4887
8363
|
emit(
|
|
4888
8364
|
select_consumption_scopes(
|
|
4889
8365
|
spec_path if spec_path.is_absolute() else root / spec_path,
|
|
@@ -4934,6 +8410,100 @@ def main() -> int:
|
|
|
4934
8410
|
session_file,
|
|
4935
8411
|
)
|
|
4936
8412
|
)
|
|
8413
|
+
elif command == "rebind-spec-source":
|
|
8414
|
+
emit(
|
|
8415
|
+
attach_status_context(
|
|
8416
|
+
root,
|
|
8417
|
+
rebind_spec_source(root, args.spec, agent, args.task_id, session_file),
|
|
8418
|
+
agent,
|
|
8419
|
+
session_file,
|
|
8420
|
+
)
|
|
8421
|
+
)
|
|
8422
|
+
elif command == "writeback-spec-task":
|
|
8423
|
+
emit(
|
|
8424
|
+
attach_status_context(
|
|
8425
|
+
root,
|
|
8426
|
+
writeback_spec_task(
|
|
8427
|
+
root,
|
|
8428
|
+
args.spec_task,
|
|
8429
|
+
args.status,
|
|
8430
|
+
args.summary,
|
|
8431
|
+
parse_evidence_args(args.evidence),
|
|
8432
|
+
args.idempotency_key,
|
|
8433
|
+
agent,
|
|
8434
|
+
args.task_id,
|
|
8435
|
+
session_file,
|
|
8436
|
+
),
|
|
8437
|
+
agent,
|
|
8438
|
+
session_file,
|
|
8439
|
+
)
|
|
8440
|
+
)
|
|
8441
|
+
elif command == "writeback-spec-step":
|
|
8442
|
+
emit(
|
|
8443
|
+
attach_status_context(
|
|
8444
|
+
root,
|
|
8445
|
+
writeback_spec_step(
|
|
8446
|
+
root,
|
|
8447
|
+
args.spec_task,
|
|
8448
|
+
args.step,
|
|
8449
|
+
args.status,
|
|
8450
|
+
args.summary,
|
|
8451
|
+
parse_evidence_args(args.evidence),
|
|
8452
|
+
args.idempotency_key,
|
|
8453
|
+
agent,
|
|
8454
|
+
args.task_id,
|
|
8455
|
+
session_file,
|
|
8456
|
+
),
|
|
8457
|
+
agent,
|
|
8458
|
+
session_file,
|
|
8459
|
+
)
|
|
8460
|
+
)
|
|
8461
|
+
elif command == "writeback-spec-dependency":
|
|
8462
|
+
emit(
|
|
8463
|
+
attach_status_context(
|
|
8464
|
+
root,
|
|
8465
|
+
writeback_spec_dependency(
|
|
8466
|
+
root,
|
|
8467
|
+
args.source_task,
|
|
8468
|
+
args.dependency_task,
|
|
8469
|
+
args.status,
|
|
8470
|
+
args.summary,
|
|
8471
|
+
parse_evidence_args(args.evidence),
|
|
8472
|
+
args.idempotency_key,
|
|
8473
|
+
agent,
|
|
8474
|
+
args.task_id,
|
|
8475
|
+
session_file,
|
|
8476
|
+
),
|
|
8477
|
+
agent,
|
|
8478
|
+
session_file,
|
|
8479
|
+
)
|
|
8480
|
+
)
|
|
8481
|
+
elif command == "sync-spec-design":
|
|
8482
|
+
emit(
|
|
8483
|
+
attach_status_context(
|
|
8484
|
+
root,
|
|
8485
|
+
sync_spec_design_state(
|
|
8486
|
+
root,
|
|
8487
|
+
args.affected_task,
|
|
8488
|
+
args.summary,
|
|
8489
|
+
args.idempotency_key,
|
|
8490
|
+
agent,
|
|
8491
|
+
args.task_id,
|
|
8492
|
+
session_file,
|
|
8493
|
+
),
|
|
8494
|
+
agent,
|
|
8495
|
+
session_file,
|
|
8496
|
+
)
|
|
8497
|
+
)
|
|
8498
|
+
elif command == "reconcile-spec-execution":
|
|
8499
|
+
emit(
|
|
8500
|
+
attach_status_context(
|
|
8501
|
+
root,
|
|
8502
|
+
reconcile_spec_execution(root, agent, args.task_id, session_file),
|
|
8503
|
+
agent,
|
|
8504
|
+
session_file,
|
|
8505
|
+
)
|
|
8506
|
+
)
|
|
4937
8507
|
elif command == "set-current":
|
|
4938
8508
|
emit(
|
|
4939
8509
|
attach_status_context(
|
|
@@ -5095,6 +8665,28 @@ def main() -> int:
|
|
|
5095
8665
|
session_file,
|
|
5096
8666
|
)
|
|
5097
8667
|
)
|
|
8668
|
+
elif command == "verification-checkpoint":
|
|
8669
|
+
emit(
|
|
8670
|
+
attach_status_context(
|
|
8671
|
+
root,
|
|
8672
|
+
record_verification_checkpoint(
|
|
8673
|
+
root, agent, args.task_id, session_file
|
|
8674
|
+
),
|
|
8675
|
+
agent,
|
|
8676
|
+
session_file,
|
|
8677
|
+
)
|
|
8678
|
+
)
|
|
8679
|
+
elif command == "inspect-transition-drift":
|
|
8680
|
+
emit(
|
|
8681
|
+
attach_status_context(
|
|
8682
|
+
root,
|
|
8683
|
+
inspect_transition_drift(
|
|
8684
|
+
root, agent, args.task_id, session_file
|
|
8685
|
+
),
|
|
8686
|
+
agent,
|
|
8687
|
+
session_file,
|
|
8688
|
+
)
|
|
8689
|
+
)
|
|
5098
8690
|
elif command == "disable-harness":
|
|
5099
8691
|
emit(
|
|
5100
8692
|
attach_status_context(
|
|
@@ -5151,7 +8743,16 @@ def main() -> int:
|
|
|
5151
8743
|
emit(
|
|
5152
8744
|
attach_status_context(
|
|
5153
8745
|
root,
|
|
5154
|
-
confirm_transition(
|
|
8746
|
+
confirm_transition(
|
|
8747
|
+
root,
|
|
8748
|
+
agent,
|
|
8749
|
+
args.stage,
|
|
8750
|
+
args.task_id,
|
|
8751
|
+
session_file,
|
|
8752
|
+
args.diff_sha256,
|
|
8753
|
+
args.verification_policy,
|
|
8754
|
+
args.decision_summary,
|
|
8755
|
+
),
|
|
5155
8756
|
agent,
|
|
5156
8757
|
session_file,
|
|
5157
8758
|
)
|
|
@@ -5200,6 +8801,24 @@ def main() -> int:
|
|
|
5200
8801
|
session_file,
|
|
5201
8802
|
)
|
|
5202
8803
|
)
|
|
8804
|
+
elif command == "memory-architecture-assessment":
|
|
8805
|
+
emit(
|
|
8806
|
+
attach_status_context(
|
|
8807
|
+
root,
|
|
8808
|
+
record_architecture_assessment(
|
|
8809
|
+
root,
|
|
8810
|
+
args.action,
|
|
8811
|
+
args.reason,
|
|
8812
|
+
args.evidence,
|
|
8813
|
+
args.affected_section,
|
|
8814
|
+
agent,
|
|
8815
|
+
args.task_id,
|
|
8816
|
+
session_file,
|
|
8817
|
+
),
|
|
8818
|
+
agent,
|
|
8819
|
+
session_file,
|
|
8820
|
+
)
|
|
8821
|
+
)
|
|
5203
8822
|
elif command == "memory-complete":
|
|
5204
8823
|
emit(
|
|
5205
8824
|
attach_status_context(
|