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.
@@ -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 = "7eb9b64cdb4c8c338c5871c3c759526f2c78fb8e"
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
- ) -> tuple[str, dict[str, Any], dict[str, str]]:
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(text, require_ready=require_ready)
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
- candidates.append(root)
141
- unique: list[Path] = []
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 unique:
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
- expected_remotes = {normalize_remote(remote) for remote in repository["remote_urls"]}
172
- matches = [
173
- candidate
174
- for candidate in _candidate_repository_paths(root, repository, explicit_paths)
175
- if candidate.is_dir() and expected_remotes.intersection(repository_remotes(candidate))
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 = matches[0]
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
- # inspect 是纯只读命令,允许展示项目外输入;创建任务时仍会拒绝项目外来源。
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(resolved_spec_path, require_ready=True)
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 = dependency_id in selected_set or bool(evidence)
351
- if not satisfied:
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 = True
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
- return {key: inspection[key] for key in keys}
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