easy-coding-harness 0.10.0-beta.5 → 0.10.0-beta.7

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.
@@ -128,31 +128,300 @@ def portable_path(root: Path, path: Path) -> str:
128
128
  )
129
129
 
130
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
+
131
139
  def _candidate_repository_paths(
132
140
  root: Path,
133
141
  repository: dict[str, Any],
134
142
  explicit_paths: dict[str, str],
135
- ) -> list[Path]:
143
+ ) -> list[tuple[str, Path]]:
136
144
  repo_id = str(repository["repo_id"])
137
- candidates: list[Path] = []
145
+ candidates: list[tuple[str, Path]] = []
138
146
  explicit = explicit_paths.get(repo_id)
139
147
  if explicit:
140
148
  path = Path(explicit)
141
149
  # An explicit binding is authoritative. Falling back to path_hint/root would make a
142
150
  # mistyped --repo-path appear valid while silently binding a different checkout.
143
- 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))
144
153
  hint = Path(str(repository.get("path_hint") or ""))
145
154
  if str(hint):
146
- candidates.append(hint if hint.is_absolute() else root / hint)
147
- candidates.append(root)
148
- unique: list[Path] = []
149
- 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:
150
159
  resolved = candidate.resolve()
151
- if resolved not in unique:
152
- unique.append(resolved)
160
+ if resolved not in seen:
161
+ unique.append((binding_source, resolved))
162
+ seen.add(resolved)
153
163
  return unique
154
164
 
155
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
+
156
425
  def inspect_spec(
157
426
  spec_path: Path,
158
427
  root: Path,
@@ -168,6 +437,14 @@ def inspect_spec(
168
437
  task_by_id = {task["task_id"]: task for task in tasks}
169
438
  explicit_paths = repo_paths or {}
170
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
+ }
171
448
  matched_repo_paths: dict[str, str] = {}
172
449
  unresolved_repositories: list[str] = []
173
450
  baseline_status: dict[str, str] = {}
@@ -175,18 +452,15 @@ def inspect_spec(
175
452
 
176
453
  for repository in repositories:
177
454
  repo_id = str(repository["repo_id"])
178
- expected_remotes = {normalize_remote(remote) for remote in repository["remote_urls"]}
179
- matches = [
180
- candidate
181
- for candidate in _candidate_repository_paths(root, repository, explicit_paths)
182
- if candidate.is_dir() and expected_remotes.intersection(repository_remotes(candidate))
183
- ]
184
- matches = list(dict.fromkeys(matches))
185
- 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:
186
460
  unresolved_repositories.append(repo_id)
187
461
  baseline_status[repo_id] = "baseline-unavailable"
188
462
  continue
189
- repository_path = matches[0]
463
+ repository_path = binding["path"]
190
464
  selected_paths = [
191
465
  str(change["path"])
192
466
  for change in changes
@@ -214,6 +488,8 @@ def inspect_spec(
214
488
  "path": stored_path,
215
489
  "baseline_commit": repository["baseline"]["commit"],
216
490
  "baseline_status": status,
491
+ "binding_source": binding["binding_source"],
492
+ "path_hint_status": binding["path_hint_status"],
217
493
  }
218
494
  )
219
495
 
@@ -234,6 +510,7 @@ def inspect_spec(
234
510
  source_path = str(resolved_spec_path)
235
511
  execution = report.execution
236
512
  return {
513
+ "inspection_mode": "selected" if selected_task_set else "full",
237
514
  "protocol": "canonical-v1",
238
515
  "schema": SCHEMA,
239
516
  "spec_id": manifest["spec_id"],
@@ -255,6 +532,7 @@ def inspect_spec(
255
532
  "tests": manifest["tests"],
256
533
  "contracts": manifest["contracts"],
257
534
  "dependency_edges": dependency_edges,
535
+ "selected_task_ids": sorted(selected_task_set),
258
536
  "matched_repo_paths": matched_repo_paths,
259
537
  "unresolved_repositories": unresolved_repositories,
260
538
  "baseline_status": baseline_status,
@@ -376,29 +654,31 @@ def select_tasks(
376
654
  if evidence is None and dependency_target_counts[dependency_id] == 1:
377
655
  evidence = evidence_by_dependency.get(dependency_id)
378
656
  source_snapshot = execution_by_task.get(source_task_id, {})
379
- shared_dependency = next(
380
- (
381
- item
382
- for item in source_snapshot.get("dependencies", [])
383
- if isinstance(item, dict) and item.get("task_id") == dependency_id
384
- ),
385
- None,
657
+ execution_fact = _dependency_execution_summary(
658
+ dependency,
659
+ source_snapshot,
660
+ execution_by_task,
661
+ str(inspection.get("status")),
662
+ isinstance(execution, dict),
386
663
  )
387
- dependency_snapshot = execution_by_task.get(dependency_id, {})
388
- shared_satisfied = bool(
389
- isinstance(shared_dependency, dict)
390
- and shared_dependency.get("status") == "satisfied"
664
+ shared_satisfied = execution_fact["shared_status"] == "satisfied"
665
+ dependency_completed = (
666
+ execution_fact["dependency_task_status"] == "completed"
391
667
  )
392
- dependency_completed = dependency_snapshot.get("status") == "completed"
668
+ basis = execution_fact.get("basis")
393
669
  if dependency_type == "hard":
394
670
  satisfied = shared_satisfied or dependency_completed or bool(evidence)
671
+ if execution_fact["status"] != "satisfied" and evidence:
672
+ basis = "manual-evidence"
395
673
  if dependency_id not in selected_set and not satisfied:
396
674
  missing_hard.append(f"{source_task_id}->{dependency_id}")
397
675
  elif dependency_type == "contract":
398
- satisfied = shared_satisfied or inspection.get("status") == "READY"
676
+ satisfied = execution_fact["status"] == "satisfied"
399
677
  evidence = evidence or "canonical-spec-ready-contract"
400
678
  else:
401
679
  satisfied = shared_satisfied or bool(evidence)
680
+ if execution_fact["status"] != "satisfied" and evidence:
681
+ basis = "manual-evidence"
402
682
  dependency_records.append(
403
683
  {
404
684
  "source_task_id": source_task_id,
@@ -406,11 +686,11 @@ def select_tasks(
406
686
  "dependency_type": dependency_type,
407
687
  "required_evidence": dependency["required_evidence"],
408
688
  "status": "satisfied" if satisfied else "pending",
409
- "shared_status": (
410
- shared_dependency.get("status")
411
- if isinstance(shared_dependency, dict)
412
- else "pending"
413
- ),
689
+ "shared_status": execution_fact["shared_status"],
690
+ "dependency_task_status": execution_fact[
691
+ "dependency_task_status"
692
+ ],
693
+ "basis": basis,
414
694
  **({"evidence": evidence} if evidence else {}),
415
695
  }
416
696
  )
@@ -453,6 +733,7 @@ def inspection_summary(inspection: dict[str, Any]) -> dict[str, Any]:
453
733
  """Return the discovery surface without unrelated implementation routing objects."""
454
734
 
455
735
  keys = (
736
+ "inspection_mode",
456
737
  "protocol",
457
738
  "schema",
458
739
  "spec_id",
@@ -467,26 +748,84 @@ def inspection_summary(inspection: dict[str, Any]) -> dict[str, Any]:
467
748
  "repositories",
468
749
  "tasks",
469
750
  "dependency_edges",
751
+ "selected_task_ids",
752
+ "repository_match",
753
+ "task_catalog",
754
+ "selection_required",
470
755
  "matched_repo_paths",
471
756
  "unresolved_repositories",
472
757
  "baseline_status",
473
758
  "repository_bindings",
474
759
  )
475
- summary = {key: inspection[key] for key in keys}
760
+ summary = {key: inspection[key] for key in keys if key in inspection}
476
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
+ }
477
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")
478
819
  summary["execution"] = {
479
820
  key: execution.get(key)
480
- for key in (
481
- "schema",
482
- "spec_id",
483
- "design_revision",
484
- "design_sha256",
485
- "execution_revision",
486
- "updated_at",
487
- "tasks",
488
- )
821
+ for key in execution_keys
489
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
+ ]
490
829
  else:
491
830
  summary["execution"] = None
492
831
  return summary