easy-coding-harness 1.0.1 → 1.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +17 -15
- package/dist/cli.js +209 -195
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/templates/claude/agents/ec-implementer.md +4 -0
- package/templates/claude/agents/ec-reviewer.md +6 -0
- package/templates/codex/agents/ec-implementer.toml +4 -0
- package/templates/codex/agents/ec-reviewer.toml +6 -0
- package/templates/common/bundled-skills/ec-init/SKILL.md +2 -2
- package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +6 -6
- package/templates/common/skills/ec-analysis/SKILL.md +56 -104
- package/templates/common/skills/ec-config/SKILL.md +37 -47
- package/templates/common/skills/ec-implementing/SKILL.md +26 -10
- package/templates/common/skills/ec-lite/SKILL.md +1 -1
- package/templates/common/skills/ec-memory/SKILL.md +3 -4
- package/templates/common/skills/ec-quality/SKILL.md +46 -25
- package/templates/common/skills/ec-task-management/SKILL.md +2 -2
- package/templates/common/skills/ec-tdd-init/SKILL.md +7 -7
- package/templates/common/skills/ec-workflow/SKILL.md +24 -22
- package/templates/main-constraint/AGENTS.md.tpl +23 -16
- package/templates/main-constraint/CLAUDE.md.tpl +23 -16
- package/templates/qoder/agents/ec-implementer.md +4 -0
- package/templates/qoder/agents/ec-reviewer.md +6 -0
- package/templates/runtime/tools/easy_coding_java_coverage.py +5 -5
- package/templates/shared-hooks/easy_coding_inputs.py +305 -0
- package/templates/shared-hooks/easy_coding_state.py +495 -461
- package/templates/shared-hooks/inject-subagent-context.py +3 -3
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Scoped evidence inputs. Caches live for one state operation, never across edits."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import xml.etree.ElementTree as ET
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from contextvars import ContextVar
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_operation = ContextVar("evidence_operation", default=None)
|
|
19
|
+
BUILD_FILES = ("pom.xml", "package.json", "package-lock.json", "pnpm-lock.yaml",
|
|
20
|
+
"yarn.lock", "tsconfig.json", "build.gradle", "build.gradle.kts",
|
|
21
|
+
"settings.gradle", "gradle.properties", ".gitattributes", ".npmrc",
|
|
22
|
+
"vitest.config.ts", "jest.config.js", "biome.json", "pytest.ini", "pyproject.toml")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@contextmanager
|
|
26
|
+
def evidence_operation():
|
|
27
|
+
token = _operation.set({})
|
|
28
|
+
try:
|
|
29
|
+
yield
|
|
30
|
+
finally:
|
|
31
|
+
_operation.reset(token)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def memo(key, compute):
|
|
35
|
+
cache = _operation.get()
|
|
36
|
+
if cache is None:
|
|
37
|
+
return compute()
|
|
38
|
+
if key not in cache:
|
|
39
|
+
cache[key] = compute()
|
|
40
|
+
return cache[key]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def digest(value):
|
|
44
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False,
|
|
45
|
+
separators=(",", ":")).encode()).hexdigest()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def git(root, *args):
|
|
49
|
+
result = subprocess.run(["git", "-C", str(root), *args], stdout=subprocess.PIPE,
|
|
50
|
+
stderr=subprocess.PIPE, check=False)
|
|
51
|
+
if result.returncode:
|
|
52
|
+
raise ValueError(result.stderr.decode("utf-8", "replace").strip())
|
|
53
|
+
return result.stdout
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RepositoryInputs:
|
|
57
|
+
def __init__(self, root):
|
|
58
|
+
self.root = root
|
|
59
|
+
self.content = {}
|
|
60
|
+
self.index = {}
|
|
61
|
+
self.unmerged = set()
|
|
62
|
+
try:
|
|
63
|
+
raw = git(root, "ls-files", "--stage", "-z")
|
|
64
|
+
except ValueError:
|
|
65
|
+
# Non-Git fixtures and newly initialized projects use the explicit input paths.
|
|
66
|
+
self.paths = set()
|
|
67
|
+
self.dirty = set()
|
|
68
|
+
self.is_git = False
|
|
69
|
+
return
|
|
70
|
+
self.is_git = True
|
|
71
|
+
for entry in raw.split(b"\0"):
|
|
72
|
+
if not entry:
|
|
73
|
+
continue
|
|
74
|
+
metadata, name = entry.split(b"\t", 1)
|
|
75
|
+
mode, oid, stage = metadata.split()
|
|
76
|
+
if stage != b"0":
|
|
77
|
+
self.unmerged.add(os.fsdecode(name))
|
|
78
|
+
self.index[os.fsdecode(name)] = (mode.decode(), oid.decode())
|
|
79
|
+
self.dirty = set(map(os.fsdecode, filter(None, git(
|
|
80
|
+
root, "diff-files", "--relative", "--name-only", "-z").split(b"\0"))))
|
|
81
|
+
untracked = set(map(os.fsdecode, filter(None, git(
|
|
82
|
+
root, "ls-files", "--others", "--exclude-standard", "-z").split(b"\0"))))
|
|
83
|
+
self.paths = set(self.index) | untracked
|
|
84
|
+
self.dirty.update(untracked)
|
|
85
|
+
|
|
86
|
+
def capture(self, scopes, production_only=False):
|
|
87
|
+
names = set()
|
|
88
|
+
for scope in scopes:
|
|
89
|
+
path = self.root / scope
|
|
90
|
+
if path.is_dir():
|
|
91
|
+
names.update(n for n in self.paths if n.startswith(scope.rstrip("/") + "/"))
|
|
92
|
+
if not self.is_git:
|
|
93
|
+
names.update(p.relative_to(self.root).as_posix() for p in path.rglob("*")
|
|
94
|
+
if p.is_file() and "__pycache__" not in p.parts)
|
|
95
|
+
else:
|
|
96
|
+
names.add(scope)
|
|
97
|
+
if production_only:
|
|
98
|
+
names = {n for n in names if not is_test(n)}
|
|
99
|
+
if names & self.unmerged:
|
|
100
|
+
raise ValueError("Resolve unmerged check inputs: " + ", ".join(sorted(names & self.unmerged)))
|
|
101
|
+
pending = []
|
|
102
|
+
for name in sorted(names):
|
|
103
|
+
if name in self.content:
|
|
104
|
+
continue
|
|
105
|
+
path = self.root / name
|
|
106
|
+
if name in self.index and name not in self.dirty:
|
|
107
|
+
mode, oid = self.index[name]
|
|
108
|
+
if mode == "160000":
|
|
109
|
+
raise ValueError("Declare the checked-out submodule as an input repository: " + name)
|
|
110
|
+
self.content[name] = [mode, oid]
|
|
111
|
+
elif path.is_symlink():
|
|
112
|
+
content = os.fsencode(os.readlink(path))
|
|
113
|
+
algorithm = "sha256" if any(len(oid) == 64 for _, oid in self.index.values()) else "sha1"
|
|
114
|
+
oid = hashlib.new(algorithm, b"blob " + str(len(content)).encode() + b"\0" + content).hexdigest()
|
|
115
|
+
self.content[name] = ["120000", oid]
|
|
116
|
+
elif path.is_file():
|
|
117
|
+
pending.append(name)
|
|
118
|
+
else:
|
|
119
|
+
self.content[name] = None
|
|
120
|
+
if pending:
|
|
121
|
+
# Batch Git's filtered blob hashing; no subprocess per changed file.
|
|
122
|
+
if self.is_git:
|
|
123
|
+
values = git(self.root, "hash-object", "--", *pending).decode().splitlines()
|
|
124
|
+
else:
|
|
125
|
+
values = [hashlib.sha256((self.root / n).read_bytes()).hexdigest() for n in pending]
|
|
126
|
+
for name, oid in zip(pending, values):
|
|
127
|
+
executable = (self.root / name).stat().st_mode & stat.S_IXUSR
|
|
128
|
+
self.content[name] = ["100755" if executable else "100644", oid]
|
|
129
|
+
return {name: self.content[name] for name in sorted(names)}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def is_test(path):
|
|
133
|
+
return any(p in {"test", "tests", "__tests__"} for p in Path(path).parts) or is_test_case(path)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def is_test_case(path):
|
|
137
|
+
name = Path(path).name
|
|
138
|
+
return bool(re.search(r"(?:Test|Tests|IT)\.java$|\.(?:test|spec)\.[cm]?[jt]sx?$|^test_.*\.py$", name))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def command_tokens(command):
|
|
142
|
+
tokens = shlex.split(command)
|
|
143
|
+
if tokens and tokens[0] == "env":
|
|
144
|
+
tokens = tokens[1:]
|
|
145
|
+
while tokens and re.match(r"^[A-Za-z_][A-Za-z_0-9]*=", tokens[0]):
|
|
146
|
+
tokens = tokens[1:]
|
|
147
|
+
return tokens
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def toolchain_identity(command):
|
|
151
|
+
tokens = command_tokens(command)
|
|
152
|
+
executable = tokens[0] if tokens else ""
|
|
153
|
+
resolved = shutil.which(executable) if executable else None
|
|
154
|
+
metadata = Path(resolved).stat() if resolved else None
|
|
155
|
+
return {
|
|
156
|
+
"executable": resolved,
|
|
157
|
+
"binary": [metadata.st_size, metadata.st_mtime_ns] if metadata else None,
|
|
158
|
+
"python": sys.version,
|
|
159
|
+
"environment": {key: os.environ.get(key) for key in (
|
|
160
|
+
"JAVA_HOME", "JAVA_TOOL_OPTIONS", "MAVEN_OPTS", "NODE_OPTIONS", "PATH", "GRADLE_USER_HOME"
|
|
161
|
+
)},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def maven_modules(root):
|
|
166
|
+
modules = {}
|
|
167
|
+
def visit(directory):
|
|
168
|
+
pom = directory / "pom.xml"
|
|
169
|
+
if not pom.is_file():
|
|
170
|
+
return
|
|
171
|
+
try:
|
|
172
|
+
tree = ET.fromstring(pom.read_bytes())
|
|
173
|
+
except ET.ParseError:
|
|
174
|
+
# The POM bytes still bind the check; Maven reports malformed build input.
|
|
175
|
+
modules[directory] = (None, [])
|
|
176
|
+
return
|
|
177
|
+
ns = "{http://maven.apache.org/POM/4.0.0}" if tree.tag.startswith("{") else ""
|
|
178
|
+
artifact = tree.findtext(ns + "artifactId")
|
|
179
|
+
dependencies = [d.findtext(ns + "artifactId") for d in tree.findall(
|
|
180
|
+
f"{ns}dependencies/{ns}dependency")]
|
|
181
|
+
modules[directory] = (artifact, dependencies)
|
|
182
|
+
for child in tree.findall(f"{ns}modules/{ns}module"):
|
|
183
|
+
visit((directory / child.text).resolve())
|
|
184
|
+
visit(root)
|
|
185
|
+
return modules
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def input_spec(root, task, plan, check):
|
|
189
|
+
units = plan.get("units", [])
|
|
190
|
+
by_id = {u["id"]: u for u in units}
|
|
191
|
+
selected = [u for u in units if
|
|
192
|
+
(not check.get("unit_id") or u["id"] == check["unit_id"]) and
|
|
193
|
+
(not check.get("source_task_id") or u.get("source_task_id") == check["source_task_id"])]
|
|
194
|
+
if not selected:
|
|
195
|
+
raise ValueError("Check must belong to an existing implementation Unit.")
|
|
196
|
+
owners = list(selected)
|
|
197
|
+
for unit in selected:
|
|
198
|
+
for dep in unit.get("depends_on", []):
|
|
199
|
+
if dep in by_id and by_id[dep] not in selected:
|
|
200
|
+
selected.append(by_id[dep])
|
|
201
|
+
command = check.get("command", "")
|
|
202
|
+
tokens = command_tokens(command)
|
|
203
|
+
executable = Path(tokens[0]).name if tokens else ""
|
|
204
|
+
builds_module = check.get("type") == "verify" and executable in {
|
|
205
|
+
"mvn", "mvnw", "gradle", "gradlew", "npm", "npx", "pnpm", "yarn", "tsc"
|
|
206
|
+
}
|
|
207
|
+
repositories = {}
|
|
208
|
+
for unit in selected:
|
|
209
|
+
repo_id = unit.get("repo_id") or "current"
|
|
210
|
+
base = Path(task.get("repo_paths", {}).get(repo_id, root))
|
|
211
|
+
base = (base if base.is_absolute() else root / base).resolve()
|
|
212
|
+
paths = repositories.setdefault(str(base), set())
|
|
213
|
+
for name in [*unit.get("files", []), *unit.get("input_files", [])]:
|
|
214
|
+
absolute = Path(name) if Path(name).is_absolute() else base / name
|
|
215
|
+
relative = absolute.relative_to(base).as_posix()
|
|
216
|
+
if ".." in Path(relative).parts:
|
|
217
|
+
raise ValueError("Input path escapes repository: " + name)
|
|
218
|
+
paths.add(relative)
|
|
219
|
+
directory = absolute if absolute.is_dir() else absolute.parent
|
|
220
|
+
while directory != base and not any((directory / marker).is_file() for marker in ("pom.xml", "package.json", "build.gradle", "build.gradle.kts")):
|
|
221
|
+
directory = directory.parent
|
|
222
|
+
modules = memo(("maven", str(base)), lambda: maven_modules(base))
|
|
223
|
+
involved = {directory}
|
|
224
|
+
# A reactor-wide command consumes every module, even when attributed to one Unit.
|
|
225
|
+
if executable in {"mvn", "mvnw"}:
|
|
226
|
+
selector = next((t.split("=", 1)[1] for t in tokens if t.startswith("--projects=")), None)
|
|
227
|
+
for flag in ("-pl", "--projects"):
|
|
228
|
+
if flag in tokens and tokens.index(flag) + 1 < len(tokens):
|
|
229
|
+
selector = tokens[tokens.index(flag) + 1]
|
|
230
|
+
if selector is None:
|
|
231
|
+
involved.update(modules)
|
|
232
|
+
else:
|
|
233
|
+
selected_modules = set(selector.split(","))
|
|
234
|
+
involved.update(m for m, (artifact, _) in modules.items()
|
|
235
|
+
if m.relative_to(base).as_posix() in selected_modules
|
|
236
|
+
or f":{artifact}" in selected_modules)
|
|
237
|
+
pending = list(involved)
|
|
238
|
+
for module in pending:
|
|
239
|
+
for dep in modules.get(module, (None, []))[1]:
|
|
240
|
+
for candidate, (artifact, _) in modules.items():
|
|
241
|
+
if artifact == dep and candidate not in involved:
|
|
242
|
+
involved.add(candidate)
|
|
243
|
+
pending.append(candidate)
|
|
244
|
+
for module in involved:
|
|
245
|
+
for folder in ("src/main", "src" if not (module / "pom.xml").exists() else "src/test"):
|
|
246
|
+
if (builds_module or "input_files" not in unit) and (module / folder).is_dir():
|
|
247
|
+
paths.add((module / folder).relative_to(base).as_posix())
|
|
248
|
+
for parent in [module, *module.parents]:
|
|
249
|
+
if not parent.is_relative_to(base):
|
|
250
|
+
break
|
|
251
|
+
for filename in BUILD_FILES:
|
|
252
|
+
if (parent / filename).is_file():
|
|
253
|
+
paths.add((parent / filename).relative_to(base).as_posix())
|
|
254
|
+
for config in (".mvn", "gradle", "test" if builds_module else "test/fixtures",
|
|
255
|
+
"tests" if builds_module else "tests/fixtures"):
|
|
256
|
+
if (base / config).is_dir():
|
|
257
|
+
paths.add(config)
|
|
258
|
+
if task.get("unit_test_mode") in {"ut", "tdd"}:
|
|
259
|
+
for directory in {str(root.resolve()), *repositories}:
|
|
260
|
+
manifest = Path(directory) / ".easy-coding/tdd/readiness.json"
|
|
261
|
+
if manifest.is_file():
|
|
262
|
+
readiness = json.loads(manifest.read_text())
|
|
263
|
+
paths = repositories.setdefault(directory, set())
|
|
264
|
+
for field in ("build_files", "tool_files"):
|
|
265
|
+
paths.update(record["path"] for record in readiness.get(field, []))
|
|
266
|
+
return {
|
|
267
|
+
"schema": 1,
|
|
268
|
+
"repositories": {r: sorted(paths) for r, paths in sorted(repositories.items())},
|
|
269
|
+
"production_only": check.get("review_scope") == "production",
|
|
270
|
+
"contract": [{"id": u["id"], "contracts": u.get("contracts", []),
|
|
271
|
+
"acceptance_criteria": u.get("acceptance_criteria", [])} for u in owners]
|
|
272
|
+
if check.get("type") == "review" else [],
|
|
273
|
+
"command": shlex.split(command),
|
|
274
|
+
"environment": check.get("environment", {}),
|
|
275
|
+
"toolchain": toolchain_identity(command) if check.get("type") == "verify" else {},
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def capture(spec):
|
|
280
|
+
inputs = {}
|
|
281
|
+
for root, scopes in spec["repositories"].items():
|
|
282
|
+
repository = memo(("repository", root), lambda: RepositoryInputs(Path(root)))
|
|
283
|
+
inputs[root] = repository.capture(scopes, spec["production_only"])
|
|
284
|
+
return {"spec": spec, "files": inputs, "signature": digest([spec, inputs])}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def changed_inputs(before, after):
|
|
288
|
+
changes = []
|
|
289
|
+
for repo in sorted(set(before["files"]) | set(after["files"])):
|
|
290
|
+
old, new = before["files"].get(repo, {}), after["files"].get(repo, {})
|
|
291
|
+
changes.extend(f"{repo}:{p}" for p in sorted(set(old) | set(new)) if old.get(p) != new.get(p))
|
|
292
|
+
if before["spec"] != after["spec"]:
|
|
293
|
+
changes.append("check command, contract, configuration, or input scope changed")
|
|
294
|
+
return changes
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def command_covers(executed, required):
|
|
298
|
+
"""A grouped Maven test command proves each selector; other arguments stay exact."""
|
|
299
|
+
actual, expected = shlex.split(executed), shlex.split(required)
|
|
300
|
+
a = [t for t in actual if t.startswith("-Dtest=")]
|
|
301
|
+
b = [t for t in expected if t.startswith("-Dtest=")]
|
|
302
|
+
if len(a) != 1 or len(b) != 1:
|
|
303
|
+
return actual == expected
|
|
304
|
+
return ([t for t in actual if t not in a] == [t for t in expected if t not in b]
|
|
305
|
+
and set(b[0][7:].split(",")) <= set(a[0][7:].split(",")))
|