easy-coding-harness 0.10.0-beta.4 → 0.10.0-beta.6
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 +46 -0
- package/README.md +20 -12
- package/package.json +1 -1
- package/templates/common/bundled-skills/ec-init/SKILL.md +3 -1
- package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +15 -7
- package/templates/common/skills/ec-analysis/SKILL.md +37 -8
- package/templates/common/skills/ec-git/SKILL.md +7 -1
- package/templates/common/skills/ec-implementing/SKILL.md +16 -1
- package/templates/common/skills/ec-memory/SKILL.md +65 -3
- package/templates/common/skills/ec-reviewing/SKILL.md +6 -0
- 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-verification/SKILL.md +11 -1
- package/templates/common/skills/ec-workflow/SKILL.md +45 -15
- package/templates/main-constraint/AGENTS.md.tpl +12 -0
- package/templates/main-constraint/CLAUDE.md.tpl +12 -0
- package/templates/runtime/templates/dev-spec-skeleton.md +3 -1
- package/templates/shared-hooks/easy_coding_state.py +1997 -113
- 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
|
@@ -11,7 +11,9 @@ from typing import Any, Iterable
|
|
|
11
11
|
from easy_dev_spec_protocol import SCHEMA, select_scope, validate_spec
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
UPSTREAM_PROTOCOL_COMMIT = "
|
|
14
|
+
UPSTREAM_PROTOCOL_COMMIT = "8239a5befae08b41da43b7cfbf41acf07e487d04"
|
|
15
|
+
UPSTREAM_PROTOCOL_SHA256 = "a6016f04b4ce18794038ebcdbcab6e400a8a08aa2929a3e777c2b35ee3f7e7a1"
|
|
16
|
+
UPSTREAM_EXECUTION_WRITER_SHA256 = "17f03314adce341269e2689aa41bb7bb29c236979be530a373fef58fe88a2524"
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
class EasyDevSpecError(ValueError):
|
|
@@ -21,12 +23,17 @@ class EasyDevSpecError(ValueError):
|
|
|
21
23
|
def _read_spec(
|
|
22
24
|
path: Path,
|
|
23
25
|
require_ready: bool = False,
|
|
24
|
-
|
|
26
|
+
require_execution: bool = False,
|
|
27
|
+
) -> tuple[str, dict[str, Any], dict[str, str], Any]:
|
|
25
28
|
if not path.is_file():
|
|
26
29
|
raise EasyDevSpecError(f"Spec file does not exist: {path}")
|
|
27
30
|
try:
|
|
28
31
|
text = path.read_text(encoding="utf-8")
|
|
29
|
-
report = validate_spec(
|
|
32
|
+
report = validate_spec(
|
|
33
|
+
text,
|
|
34
|
+
require_ready=require_ready,
|
|
35
|
+
require_execution=require_execution,
|
|
36
|
+
)
|
|
30
37
|
except (OSError, UnicodeError) as exc:
|
|
31
38
|
raise EasyDevSpecError(f"Cannot read Spec as UTF-8: {path}: {exc}") from exc
|
|
32
39
|
if report.protocol == "legacy":
|
|
@@ -40,7 +47,7 @@ def _read_spec(
|
|
|
40
47
|
section_id: section.content
|
|
41
48
|
for section_id, section in report.sections.items()
|
|
42
49
|
}
|
|
43
|
-
return text, report.manifest, sections
|
|
50
|
+
return text, report.manifest, sections, report
|
|
44
51
|
|
|
45
52
|
|
|
46
53
|
def normalize_remote(remote: str) -> str:
|
|
@@ -121,31 +128,300 @@ def portable_path(root: Path, path: Path) -> str:
|
|
|
121
128
|
)
|
|
122
129
|
|
|
123
130
|
|
|
131
|
+
def _path_hint_status(root: Path, repository: dict[str, Any], repository_path: Path) -> str:
|
|
132
|
+
hint = Path(str(repository.get("path_hint") or ""))
|
|
133
|
+
if not str(hint):
|
|
134
|
+
return "missing"
|
|
135
|
+
resolved_hint = (hint if hint.is_absolute() else root / hint).resolve()
|
|
136
|
+
return "matched" if resolved_hint == repository_path.resolve() else "different"
|
|
137
|
+
|
|
138
|
+
|
|
124
139
|
def _candidate_repository_paths(
|
|
125
140
|
root: Path,
|
|
126
141
|
repository: dict[str, Any],
|
|
127
142
|
explicit_paths: dict[str, str],
|
|
128
|
-
) -> list[Path]:
|
|
143
|
+
) -> list[tuple[str, Path]]:
|
|
129
144
|
repo_id = str(repository["repo_id"])
|
|
130
|
-
candidates: list[Path] = []
|
|
145
|
+
candidates: list[tuple[str, Path]] = []
|
|
131
146
|
explicit = explicit_paths.get(repo_id)
|
|
132
147
|
if explicit:
|
|
133
148
|
path = Path(explicit)
|
|
134
149
|
# An explicit binding is authoritative. Falling back to path_hint/root would make a
|
|
135
150
|
# mistyped --repo-path appear valid while silently binding a different checkout.
|
|
136
|
-
return [(path if path.is_absolute() else root / path).resolve()]
|
|
151
|
+
return [("explicit", (path if path.is_absolute() else root / path).resolve())]
|
|
152
|
+
candidates.append(("current-root", root))
|
|
137
153
|
hint = Path(str(repository.get("path_hint") or ""))
|
|
138
154
|
if str(hint):
|
|
139
|
-
candidates.append(hint if hint.is_absolute() else root / hint)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
for candidate in candidates:
|
|
155
|
+
candidates.append(("path-hint", hint if hint.is_absolute() else root / hint))
|
|
156
|
+
unique: list[tuple[str, Path]] = []
|
|
157
|
+
seen: set[Path] = set()
|
|
158
|
+
for binding_source, candidate in candidates:
|
|
143
159
|
resolved = candidate.resolve()
|
|
144
|
-
if resolved not in
|
|
145
|
-
unique.append(resolved)
|
|
160
|
+
if resolved not in seen:
|
|
161
|
+
unique.append((binding_source, resolved))
|
|
162
|
+
seen.add(resolved)
|
|
146
163
|
return unique
|
|
147
164
|
|
|
148
165
|
|
|
166
|
+
def _resolve_repository_binding(
|
|
167
|
+
root: Path,
|
|
168
|
+
repository: dict[str, Any],
|
|
169
|
+
explicit_paths: dict[str, str],
|
|
170
|
+
) -> dict[str, Any] | None:
|
|
171
|
+
expected_remotes = {
|
|
172
|
+
normalize_remote(remote) for remote in repository["remote_urls"]
|
|
173
|
+
}
|
|
174
|
+
# 显式路径保持权威;否则当前 worktree 一旦通过 remote 验证就立即返回,不再探测旧 path_hint。
|
|
175
|
+
for binding_source, repository_path in _candidate_repository_paths(
|
|
176
|
+
root, repository, explicit_paths
|
|
177
|
+
):
|
|
178
|
+
if not repository_path.is_dir():
|
|
179
|
+
continue
|
|
180
|
+
if not expected_remotes.intersection(repository_remotes(repository_path)):
|
|
181
|
+
continue
|
|
182
|
+
return {
|
|
183
|
+
"path": repository_path,
|
|
184
|
+
"binding_source": binding_source,
|
|
185
|
+
"path_hint_status": _path_hint_status(root, repository, repository_path),
|
|
186
|
+
}
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _current_repository_match(
|
|
191
|
+
root: Path,
|
|
192
|
+
repositories: list[dict[str, Any]],
|
|
193
|
+
explicit_paths: dict[str, str],
|
|
194
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
195
|
+
resolved_root = root.resolve()
|
|
196
|
+
repository_by_id = {
|
|
197
|
+
str(repository["repo_id"]): repository for repository in repositories
|
|
198
|
+
}
|
|
199
|
+
if len(explicit_paths) > 1:
|
|
200
|
+
raise EasyDevSpecError(
|
|
201
|
+
"Manifest routing accepts at most one --repo-path for the current repository"
|
|
202
|
+
)
|
|
203
|
+
if explicit_paths:
|
|
204
|
+
repo_id, raw_path = next(iter(explicit_paths.items()))
|
|
205
|
+
repository = repository_by_id.get(repo_id)
|
|
206
|
+
if repository is None:
|
|
207
|
+
raise EasyDevSpecError(f"Unknown Canonical repository: {repo_id}")
|
|
208
|
+
path = Path(raw_path)
|
|
209
|
+
resolved_path = (path if path.is_absolute() else root / path).resolve()
|
|
210
|
+
if resolved_path != resolved_root:
|
|
211
|
+
raise EasyDevSpecError(
|
|
212
|
+
"Manifest routing --repo-path must identify the current Git worktree"
|
|
213
|
+
)
|
|
214
|
+
binding = _resolve_repository_binding(root, repository, explicit_paths)
|
|
215
|
+
if binding is None:
|
|
216
|
+
raise EasyDevSpecError(
|
|
217
|
+
f"Current worktree does not match Canonical repository {repo_id}"
|
|
218
|
+
)
|
|
219
|
+
return repository, binding
|
|
220
|
+
|
|
221
|
+
local_remotes = set(repository_remotes(resolved_root))
|
|
222
|
+
if not local_remotes:
|
|
223
|
+
raise EasyDevSpecError(
|
|
224
|
+
"Current worktree has no Git remote; configure a matching remote before "
|
|
225
|
+
"Canonical manifest routing"
|
|
226
|
+
)
|
|
227
|
+
matches = [
|
|
228
|
+
repository
|
|
229
|
+
for repository in repositories
|
|
230
|
+
if local_remotes.intersection(
|
|
231
|
+
normalize_remote(remote) for remote in repository["remote_urls"]
|
|
232
|
+
)
|
|
233
|
+
]
|
|
234
|
+
if len(matches) != 1:
|
|
235
|
+
candidates = ", ".join(str(item["repo_id"]) for item in matches) or "none"
|
|
236
|
+
raise EasyDevSpecError(
|
|
237
|
+
"Current worktree must match exactly one Canonical repository; "
|
|
238
|
+
f"matched: {candidates}. Pass --repo-path <repo-id>=<current-worktree>."
|
|
239
|
+
)
|
|
240
|
+
repository = matches[0]
|
|
241
|
+
return repository, {
|
|
242
|
+
"path": resolved_root,
|
|
243
|
+
"binding_source": "current-root-remote",
|
|
244
|
+
"path_hint_status": _path_hint_status(root, repository, resolved_root),
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _dependency_execution_summary(
|
|
249
|
+
dependency: dict[str, Any],
|
|
250
|
+
source_snapshot: dict[str, Any],
|
|
251
|
+
execution_by_task: dict[str, dict[str, Any]],
|
|
252
|
+
manifest_status: str,
|
|
253
|
+
execution_available: bool,
|
|
254
|
+
) -> dict[str, Any]:
|
|
255
|
+
dependency_id = str(dependency["task_id"])
|
|
256
|
+
shared_dependency = next(
|
|
257
|
+
(
|
|
258
|
+
item
|
|
259
|
+
for item in source_snapshot.get("dependencies", [])
|
|
260
|
+
if isinstance(item, dict) and item.get("task_id") == dependency_id
|
|
261
|
+
),
|
|
262
|
+
None,
|
|
263
|
+
)
|
|
264
|
+
shared_status = (
|
|
265
|
+
shared_dependency.get("status")
|
|
266
|
+
if execution_available and isinstance(shared_dependency, dict)
|
|
267
|
+
else "pending"
|
|
268
|
+
if execution_available
|
|
269
|
+
else None
|
|
270
|
+
)
|
|
271
|
+
dependency_task_status = (
|
|
272
|
+
execution_by_task.get(dependency_id, {}).get("status", "not_started")
|
|
273
|
+
if execution_available
|
|
274
|
+
else None
|
|
275
|
+
)
|
|
276
|
+
dependency_type = str(dependency.get("type"))
|
|
277
|
+
if dependency_type == "contract":
|
|
278
|
+
status = "satisfied" if manifest_status == "READY" else "pending"
|
|
279
|
+
basis = "design-ready" if status == "satisfied" else "design-not-ready"
|
|
280
|
+
elif shared_status == "satisfied":
|
|
281
|
+
status = "satisfied"
|
|
282
|
+
basis = "recorded-evidence"
|
|
283
|
+
elif dependency_type == "hard" and dependency_task_status == "completed":
|
|
284
|
+
status = "satisfied"
|
|
285
|
+
basis = "dependency-task-completed"
|
|
286
|
+
else:
|
|
287
|
+
status = "pending"
|
|
288
|
+
basis = "pending-integration" if dependency_type == "integration" else "pending"
|
|
289
|
+
return {
|
|
290
|
+
"shared_status": shared_status,
|
|
291
|
+
"dependency_task_status": dependency_task_status,
|
|
292
|
+
"status": status,
|
|
293
|
+
"basis": basis,
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def inspect_manifest(
|
|
298
|
+
spec_path: Path,
|
|
299
|
+
root: Path,
|
|
300
|
+
repo_paths: dict[str, str] | None = None,
|
|
301
|
+
) -> dict[str, Any]:
|
|
302
|
+
"""Return the current-repository task catalog without resolving unrelated checkouts."""
|
|
303
|
+
|
|
304
|
+
resolved_spec_path = spec_path.resolve()
|
|
305
|
+
if not resolved_spec_path.is_file():
|
|
306
|
+
raise EasyDevSpecError(f"Spec file does not exist: {resolved_spec_path}")
|
|
307
|
+
try:
|
|
308
|
+
text = resolved_spec_path.read_text(encoding="utf-8")
|
|
309
|
+
report = validate_spec(text)
|
|
310
|
+
except (OSError, UnicodeError) as exc:
|
|
311
|
+
raise EasyDevSpecError(
|
|
312
|
+
f"Cannot read Spec as UTF-8: {resolved_spec_path}: {exc}"
|
|
313
|
+
) from exc
|
|
314
|
+
if report.protocol == "legacy":
|
|
315
|
+
return {
|
|
316
|
+
"inspection_mode": "manifest-only",
|
|
317
|
+
"protocol": "legacy",
|
|
318
|
+
"source_path": str(resolved_spec_path),
|
|
319
|
+
"source_sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(),
|
|
320
|
+
"design_sha256": report.design_sha256,
|
|
321
|
+
"document_sha256": report.document_sha256,
|
|
322
|
+
"execution_revision": None,
|
|
323
|
+
"execution": None,
|
|
324
|
+
"selection_required": False,
|
|
325
|
+
"selected_task_ids": [],
|
|
326
|
+
"task_catalog": [],
|
|
327
|
+
"matched_repo_paths": {},
|
|
328
|
+
"unresolved_repositories": [],
|
|
329
|
+
"baseline_status": {},
|
|
330
|
+
"repository_bindings": [],
|
|
331
|
+
}
|
|
332
|
+
if report.protocol != "canonical-v1" or report.manifest is None or not report.ok:
|
|
333
|
+
details = "; ".join(
|
|
334
|
+
f"{issue.code}: {issue.message}" for issue in report.issues
|
|
335
|
+
) or "unknown validation failure"
|
|
336
|
+
raise EasyDevSpecError(f"Canonical Spec validation failed: {details}")
|
|
337
|
+
manifest = report.manifest
|
|
338
|
+
repositories = manifest["repositories"]
|
|
339
|
+
tasks = manifest["tasks"]
|
|
340
|
+
repository, binding = _current_repository_match(
|
|
341
|
+
root, repositories, repo_paths or {}
|
|
342
|
+
)
|
|
343
|
+
current_repo_id = str(repository["repo_id"])
|
|
344
|
+
repository_names = {
|
|
345
|
+
str(item["repo_id"]): str(item["name"]) for item in repositories
|
|
346
|
+
}
|
|
347
|
+
execution = report.execution
|
|
348
|
+
execution_by_task = {
|
|
349
|
+
str(snapshot.get("task_id")): snapshot
|
|
350
|
+
for snapshot in execution.get("tasks", [])
|
|
351
|
+
if isinstance(execution, dict)
|
|
352
|
+
and isinstance(snapshot, dict)
|
|
353
|
+
and isinstance(snapshot.get("task_id"), str)
|
|
354
|
+
} if isinstance(execution, dict) else {}
|
|
355
|
+
task_catalog: list[dict[str, Any]] = []
|
|
356
|
+
for task in tasks:
|
|
357
|
+
task_id = str(task["task_id"])
|
|
358
|
+
source_snapshot = execution_by_task.get(task_id, {})
|
|
359
|
+
dependencies = [
|
|
360
|
+
{
|
|
361
|
+
**dependency,
|
|
362
|
+
**_dependency_execution_summary(
|
|
363
|
+
dependency,
|
|
364
|
+
source_snapshot,
|
|
365
|
+
execution_by_task,
|
|
366
|
+
str(manifest["status"]),
|
|
367
|
+
isinstance(execution, dict),
|
|
368
|
+
),
|
|
369
|
+
}
|
|
370
|
+
for dependency in task.get("depends_on", [])
|
|
371
|
+
]
|
|
372
|
+
task_catalog.append(
|
|
373
|
+
{
|
|
374
|
+
"task_id": task_id,
|
|
375
|
+
"repo_id": task["repo_id"],
|
|
376
|
+
"repository_name": repository_names[str(task["repo_id"])],
|
|
377
|
+
"title": task["title"],
|
|
378
|
+
"status": task["status"],
|
|
379
|
+
"execution_status": (
|
|
380
|
+
execution_by_task.get(task_id, {}).get("status", "not_started")
|
|
381
|
+
if isinstance(execution, dict)
|
|
382
|
+
else None
|
|
383
|
+
),
|
|
384
|
+
"depends_on": dependencies,
|
|
385
|
+
"baseline_status": "not-inspected",
|
|
386
|
+
}
|
|
387
|
+
)
|
|
388
|
+
try:
|
|
389
|
+
source_path = portable_path(root, resolved_spec_path)
|
|
390
|
+
except EasyDevSpecError:
|
|
391
|
+
source_path = str(resolved_spec_path)
|
|
392
|
+
return {
|
|
393
|
+
"inspection_mode": "manifest-only",
|
|
394
|
+
"protocol": "canonical-v1",
|
|
395
|
+
"schema": SCHEMA,
|
|
396
|
+
"spec_id": manifest["spec_id"],
|
|
397
|
+
"revision": manifest["revision"],
|
|
398
|
+
"status": manifest["status"],
|
|
399
|
+
"title": manifest["title"],
|
|
400
|
+
"source_path": source_path,
|
|
401
|
+
"source_sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(),
|
|
402
|
+
"design_sha256": report.design_sha256,
|
|
403
|
+
"document_sha256": report.document_sha256,
|
|
404
|
+
"execution_revision": (
|
|
405
|
+
execution.get("execution_revision") if isinstance(execution, dict) else None
|
|
406
|
+
),
|
|
407
|
+
"execution": execution,
|
|
408
|
+
"repository_match": {
|
|
409
|
+
"repo_id": current_repo_id,
|
|
410
|
+
"name": repository["name"],
|
|
411
|
+
"path": portable_path(root, binding["path"]),
|
|
412
|
+
"binding_source": binding["binding_source"],
|
|
413
|
+
"path_hint_status": binding["path_hint_status"],
|
|
414
|
+
},
|
|
415
|
+
"task_catalog": task_catalog,
|
|
416
|
+
"selection_required": True,
|
|
417
|
+
"selected_task_ids": [],
|
|
418
|
+
"matched_repo_paths": {},
|
|
419
|
+
"unresolved_repositories": [],
|
|
420
|
+
"baseline_status": {},
|
|
421
|
+
"repository_bindings": [],
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
|
|
149
425
|
def inspect_spec(
|
|
150
426
|
spec_path: Path,
|
|
151
427
|
root: Path,
|
|
@@ -153,7 +429,7 @@ def inspect_spec(
|
|
|
153
429
|
selected_task_ids: Iterable[str] | None = None,
|
|
154
430
|
) -> dict[str, Any]:
|
|
155
431
|
resolved_spec_path = spec_path.resolve()
|
|
156
|
-
text, manifest, sections = _read_spec(resolved_spec_path)
|
|
432
|
+
text, manifest, sections, report = _read_spec(resolved_spec_path)
|
|
157
433
|
repositories = manifest["repositories"]
|
|
158
434
|
tasks = manifest["tasks"]
|
|
159
435
|
changes = manifest["changes"]
|
|
@@ -161,6 +437,14 @@ def inspect_spec(
|
|
|
161
437
|
task_by_id = {task["task_id"]: task for task in tasks}
|
|
162
438
|
explicit_paths = repo_paths or {}
|
|
163
439
|
selected_task_set = set(selected_task_ids or [])
|
|
440
|
+
unknown_tasks = selected_task_set - set(task_by_id)
|
|
441
|
+
if unknown_tasks:
|
|
442
|
+
raise EasyDevSpecError(
|
|
443
|
+
"Unknown Canonical Spec tasks: " + ", ".join(sorted(unknown_tasks))
|
|
444
|
+
)
|
|
445
|
+
selected_repo_ids = {
|
|
446
|
+
str(task_by_id[task_id]["repo_id"]) for task_id in selected_task_set
|
|
447
|
+
}
|
|
164
448
|
matched_repo_paths: dict[str, str] = {}
|
|
165
449
|
unresolved_repositories: list[str] = []
|
|
166
450
|
baseline_status: dict[str, str] = {}
|
|
@@ -168,18 +452,15 @@ def inspect_spec(
|
|
|
168
452
|
|
|
169
453
|
for repository in repositories:
|
|
170
454
|
repo_id = str(repository["repo_id"])
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
]
|
|
177
|
-
matches = list(dict.fromkeys(matches))
|
|
178
|
-
if len(matches) != 1:
|
|
455
|
+
# task 已选定后只解析其所属仓库,未选仓库不得制造路径或 baseline 阻塞。
|
|
456
|
+
if selected_repo_ids and repo_id not in selected_repo_ids:
|
|
457
|
+
continue
|
|
458
|
+
binding = _resolve_repository_binding(root, repository, explicit_paths)
|
|
459
|
+
if binding is None:
|
|
179
460
|
unresolved_repositories.append(repo_id)
|
|
180
461
|
baseline_status[repo_id] = "baseline-unavailable"
|
|
181
462
|
continue
|
|
182
|
-
repository_path =
|
|
463
|
+
repository_path = binding["path"]
|
|
183
464
|
selected_paths = [
|
|
184
465
|
str(change["path"])
|
|
185
466
|
for change in changes
|
|
@@ -207,6 +488,8 @@ def inspect_spec(
|
|
|
207
488
|
"path": stored_path,
|
|
208
489
|
"baseline_commit": repository["baseline"]["commit"],
|
|
209
490
|
"baseline_status": status,
|
|
491
|
+
"binding_source": binding["binding_source"],
|
|
492
|
+
"path_hint_status": binding["path_hint_status"],
|
|
210
493
|
}
|
|
211
494
|
)
|
|
212
495
|
|
|
@@ -223,9 +506,11 @@ def inspect_spec(
|
|
|
223
506
|
try:
|
|
224
507
|
source_path = portable_path(root, resolved_spec_path)
|
|
225
508
|
except EasyDevSpecError:
|
|
226
|
-
#
|
|
509
|
+
# Explicit project-external input remains readable and is stored as an absolute locator.
|
|
227
510
|
source_path = str(resolved_spec_path)
|
|
511
|
+
execution = report.execution
|
|
228
512
|
return {
|
|
513
|
+
"inspection_mode": "selected" if selected_task_set else "full",
|
|
229
514
|
"protocol": "canonical-v1",
|
|
230
515
|
"schema": SCHEMA,
|
|
231
516
|
"spec_id": manifest["spec_id"],
|
|
@@ -234,6 +519,12 @@ def inspect_spec(
|
|
|
234
519
|
"title": manifest["title"],
|
|
235
520
|
"source_path": source_path,
|
|
236
521
|
"source_sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(),
|
|
522
|
+
"design_sha256": report.design_sha256,
|
|
523
|
+
"document_sha256": report.document_sha256,
|
|
524
|
+
"execution_revision": (
|
|
525
|
+
execution.get("execution_revision") if isinstance(execution, dict) else None
|
|
526
|
+
),
|
|
527
|
+
"execution": execution,
|
|
237
528
|
"repositories": repositories,
|
|
238
529
|
"tasks": tasks,
|
|
239
530
|
"changes": changes,
|
|
@@ -241,6 +532,7 @@ def inspect_spec(
|
|
|
241
532
|
"tests": manifest["tests"],
|
|
242
533
|
"contracts": manifest["contracts"],
|
|
243
534
|
"dependency_edges": dependency_edges,
|
|
535
|
+
"selected_task_ids": sorted(selected_task_set),
|
|
244
536
|
"matched_repo_paths": matched_repo_paths,
|
|
245
537
|
"unresolved_repositories": unresolved_repositories,
|
|
246
538
|
"baseline_status": baseline_status,
|
|
@@ -256,7 +548,11 @@ def select_consumption_scopes(
|
|
|
256
548
|
"""Return final-protocol consumption closures grouped by repository."""
|
|
257
549
|
|
|
258
550
|
resolved_spec_path = spec_path.resolve()
|
|
259
|
-
text, manifest, _ = _read_spec(
|
|
551
|
+
text, manifest, _, report = _read_spec(
|
|
552
|
+
resolved_spec_path,
|
|
553
|
+
require_ready=True,
|
|
554
|
+
require_execution=True,
|
|
555
|
+
)
|
|
260
556
|
selected_ids = list(dict.fromkeys(selected_task_ids))
|
|
261
557
|
if not selected_ids:
|
|
262
558
|
raise EasyDevSpecError("At least one Canonical Spec task must be selected")
|
|
@@ -306,6 +602,9 @@ def select_consumption_scopes(
|
|
|
306
602
|
"status": manifest["status"],
|
|
307
603
|
"source_path": source_path,
|
|
308
604
|
"source_sha256": source_sha256,
|
|
605
|
+
"design_sha256": report.design_sha256,
|
|
606
|
+
"document_sha256": report.document_sha256,
|
|
607
|
+
"execution_revision": report.execution.get("execution_revision"),
|
|
309
608
|
"selected_task_ids": selected_ids,
|
|
310
609
|
"scopes": scopes,
|
|
311
610
|
}
|
|
@@ -331,6 +630,14 @@ def select_tasks(
|
|
|
331
630
|
|
|
332
631
|
selected_set = set(selected_ids)
|
|
333
632
|
evidence_by_dependency = dependency_evidence or {}
|
|
633
|
+
execution = inspection.get("execution")
|
|
634
|
+
execution_by_task = {
|
|
635
|
+
str(snapshot.get("task_id")): snapshot
|
|
636
|
+
for snapshot in execution.get("tasks", [])
|
|
637
|
+
if isinstance(execution, dict)
|
|
638
|
+
and isinstance(snapshot, dict)
|
|
639
|
+
and isinstance(snapshot.get("task_id"), str)
|
|
640
|
+
} if isinstance(execution, dict) else {}
|
|
334
641
|
dependency_target_counts: dict[str, int] = {}
|
|
335
642
|
for source_task_id in selected_ids:
|
|
336
643
|
for dependency in task_by_id[source_task_id].get("depends_on", []):
|
|
@@ -346,15 +653,32 @@ def select_tasks(
|
|
|
346
653
|
evidence = evidence_by_dependency.get(edge_key)
|
|
347
654
|
if evidence is None and dependency_target_counts[dependency_id] == 1:
|
|
348
655
|
evidence = evidence_by_dependency.get(dependency_id)
|
|
656
|
+
source_snapshot = execution_by_task.get(source_task_id, {})
|
|
657
|
+
execution_fact = _dependency_execution_summary(
|
|
658
|
+
dependency,
|
|
659
|
+
source_snapshot,
|
|
660
|
+
execution_by_task,
|
|
661
|
+
str(inspection.get("status")),
|
|
662
|
+
isinstance(execution, dict),
|
|
663
|
+
)
|
|
664
|
+
shared_satisfied = execution_fact["shared_status"] == "satisfied"
|
|
665
|
+
dependency_completed = (
|
|
666
|
+
execution_fact["dependency_task_status"] == "completed"
|
|
667
|
+
)
|
|
668
|
+
basis = execution_fact.get("basis")
|
|
349
669
|
if dependency_type == "hard":
|
|
350
|
-
satisfied =
|
|
351
|
-
if
|
|
670
|
+
satisfied = shared_satisfied or dependency_completed or bool(evidence)
|
|
671
|
+
if execution_fact["status"] != "satisfied" and evidence:
|
|
672
|
+
basis = "manual-evidence"
|
|
673
|
+
if dependency_id not in selected_set and not satisfied:
|
|
352
674
|
missing_hard.append(f"{source_task_id}->{dependency_id}")
|
|
353
675
|
elif dependency_type == "contract":
|
|
354
|
-
satisfied =
|
|
676
|
+
satisfied = execution_fact["status"] == "satisfied"
|
|
355
677
|
evidence = evidence or "canonical-spec-ready-contract"
|
|
356
678
|
else:
|
|
357
|
-
satisfied = bool(evidence)
|
|
679
|
+
satisfied = shared_satisfied or bool(evidence)
|
|
680
|
+
if execution_fact["status"] != "satisfied" and evidence:
|
|
681
|
+
basis = "manual-evidence"
|
|
358
682
|
dependency_records.append(
|
|
359
683
|
{
|
|
360
684
|
"source_task_id": source_task_id,
|
|
@@ -362,6 +686,11 @@ def select_tasks(
|
|
|
362
686
|
"dependency_type": dependency_type,
|
|
363
687
|
"required_evidence": dependency["required_evidence"],
|
|
364
688
|
"status": "satisfied" if satisfied else "pending",
|
|
689
|
+
"shared_status": execution_fact["shared_status"],
|
|
690
|
+
"dependency_task_status": execution_fact[
|
|
691
|
+
"dependency_task_status"
|
|
692
|
+
],
|
|
693
|
+
"basis": basis,
|
|
365
694
|
**({"evidence": evidence} if evidence else {}),
|
|
366
695
|
}
|
|
367
696
|
)
|
|
@@ -391,6 +720,12 @@ def select_tasks(
|
|
|
391
720
|
test for test in inspection["tests"] if test.get("task_id") in selected_set
|
|
392
721
|
],
|
|
393
722
|
"dependency_records": dependency_records,
|
|
723
|
+
"execution_revision": inspection.get("execution_revision"),
|
|
724
|
+
"execution_tasks": [
|
|
725
|
+
execution_by_task[task_id]
|
|
726
|
+
for task_id in selected_ids
|
|
727
|
+
if task_id in execution_by_task
|
|
728
|
+
],
|
|
394
729
|
}
|
|
395
730
|
|
|
396
731
|
|
|
@@ -398,6 +733,7 @@ def inspection_summary(inspection: dict[str, Any]) -> dict[str, Any]:
|
|
|
398
733
|
"""Return the discovery surface without unrelated implementation routing objects."""
|
|
399
734
|
|
|
400
735
|
keys = (
|
|
736
|
+
"inspection_mode",
|
|
401
737
|
"protocol",
|
|
402
738
|
"schema",
|
|
403
739
|
"spec_id",
|
|
@@ -406,12 +742,90 @@ def inspection_summary(inspection: dict[str, Any]) -> dict[str, Any]:
|
|
|
406
742
|
"title",
|
|
407
743
|
"source_path",
|
|
408
744
|
"source_sha256",
|
|
745
|
+
"design_sha256",
|
|
746
|
+
"document_sha256",
|
|
747
|
+
"execution_revision",
|
|
409
748
|
"repositories",
|
|
410
749
|
"tasks",
|
|
411
750
|
"dependency_edges",
|
|
751
|
+
"selected_task_ids",
|
|
752
|
+
"repository_match",
|
|
753
|
+
"task_catalog",
|
|
754
|
+
"selection_required",
|
|
412
755
|
"matched_repo_paths",
|
|
413
756
|
"unresolved_repositories",
|
|
414
757
|
"baseline_status",
|
|
415
758
|
"repository_bindings",
|
|
416
759
|
)
|
|
417
|
-
|
|
760
|
+
summary = {key: inspection[key] for key in keys if key in inspection}
|
|
761
|
+
execution = inspection.get("execution")
|
|
762
|
+
relevant_execution_task_ids: set[str] | None = None
|
|
763
|
+
if inspection.get("inspection_mode") == "selected":
|
|
764
|
+
selected_task_ids = set(inspection.get("selected_task_ids", []))
|
|
765
|
+
selected_tasks = [
|
|
766
|
+
task
|
|
767
|
+
for task in inspection.get("tasks", [])
|
|
768
|
+
if str(task.get("task_id")) in selected_task_ids
|
|
769
|
+
]
|
|
770
|
+
selected_repo_ids = {str(task.get("repo_id")) for task in selected_tasks}
|
|
771
|
+
execution_by_task = {
|
|
772
|
+
str(task.get("task_id")): task
|
|
773
|
+
for task in execution.get("tasks", [])
|
|
774
|
+
if isinstance(execution, dict)
|
|
775
|
+
and isinstance(task, dict)
|
|
776
|
+
and isinstance(task.get("task_id"), str)
|
|
777
|
+
} if isinstance(execution, dict) else {}
|
|
778
|
+
selected_edges: list[dict[str, Any]] = []
|
|
779
|
+
for edge in inspection.get("dependency_edges", []):
|
|
780
|
+
source_task_id = str(edge.get("source_task_id"))
|
|
781
|
+
if source_task_id not in selected_task_ids:
|
|
782
|
+
continue
|
|
783
|
+
selected_edges.append(
|
|
784
|
+
{
|
|
785
|
+
**edge,
|
|
786
|
+
**_dependency_execution_summary(
|
|
787
|
+
{
|
|
788
|
+
"task_id": edge.get("task_id"),
|
|
789
|
+
"type": edge.get("dependency_type"),
|
|
790
|
+
},
|
|
791
|
+
execution_by_task.get(source_task_id, {}),
|
|
792
|
+
execution_by_task,
|
|
793
|
+
str(inspection.get("status")),
|
|
794
|
+
isinstance(execution, dict),
|
|
795
|
+
),
|
|
796
|
+
}
|
|
797
|
+
)
|
|
798
|
+
summary["repositories"] = [
|
|
799
|
+
repository
|
|
800
|
+
for repository in inspection.get("repositories", [])
|
|
801
|
+
if str(repository.get("repo_id")) in selected_repo_ids
|
|
802
|
+
]
|
|
803
|
+
summary["tasks"] = selected_tasks
|
|
804
|
+
summary["dependency_edges"] = selected_edges
|
|
805
|
+
relevant_execution_task_ids = selected_task_ids | {
|
|
806
|
+
str(edge.get("task_id")) for edge in selected_edges
|
|
807
|
+
}
|
|
808
|
+
if isinstance(execution, dict):
|
|
809
|
+
execution_keys = [
|
|
810
|
+
"schema",
|
|
811
|
+
"spec_id",
|
|
812
|
+
"design_revision",
|
|
813
|
+
"design_sha256",
|
|
814
|
+
"execution_revision",
|
|
815
|
+
"updated_at",
|
|
816
|
+
]
|
|
817
|
+
if inspection.get("inspection_mode") != "manifest-only":
|
|
818
|
+
execution_keys.append("tasks")
|
|
819
|
+
summary["execution"] = {
|
|
820
|
+
key: execution.get(key)
|
|
821
|
+
for key in execution_keys
|
|
822
|
+
}
|
|
823
|
+
if relevant_execution_task_ids is not None:
|
|
824
|
+
summary["execution"]["tasks"] = [
|
|
825
|
+
task
|
|
826
|
+
for task in execution.get("tasks", [])
|
|
827
|
+
if str(task.get("task_id")) in relevant_execution_task_ids
|
|
828
|
+
]
|
|
829
|
+
else:
|
|
830
|
+
summary["execution"] = None
|
|
831
|
+
return summary
|