easy-coding-harness 1.0.0 → 1.0.1-beta.0

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.
@@ -539,11 +539,7 @@ def read_project_behavior(root: Path) -> tuple[str, str, bool, int]:
539
539
  "expected adaptive, fast, standard, or strict."
540
540
  )
541
541
  if schema_version >= 4:
542
- tdd_enabled = (
543
- parse_yaml_bool(behavior.get("tdd_enabled"), "behavior.tdd_enabled")
544
- if schema_version >= 5
545
- else DEFAULT_TDD_ENABLED
546
- )
542
+ tdd_enabled = parse_yaml_bool(behavior.get("tdd_enabled"), "behavior.tdd_enabled")
547
543
  tdd_threshold = parse_tdd_threshold(
548
544
  behavior.get("tdd_coverage_threshold", DEFAULT_TDD_COVERAGE_THRESHOLD),
549
545
  "behavior.tdd_coverage_threshold",
@@ -610,17 +606,17 @@ def tdd_ci_contract_reasons(contents: list[str]) -> list[str]:
610
606
  return reasons
611
607
 
612
608
 
613
- def tdd_readiness(root: Path) -> dict[str, object]:
609
+ def tdd_readiness(root: Path, include_ci: bool = False) -> dict[str, object]:
614
610
  receipt = root / TDD_READINESS_PATH
615
611
  if not receipt.is_file():
616
612
  return {"status": "needs_init", "reasons": ["TDD readiness receipt is missing"]}
617
613
  try:
618
614
  manifest = json.loads(receipt.read_text(encoding="utf-8"))
619
615
  except (OSError, UnicodeError, json.JSONDecodeError):
620
- return {"status": "needs_init", "reasons": ["TDD readiness receipt is invalid"]}
616
+ return {"status": "needs_repair", "reasons": ["TDD readiness receipt is invalid"]}
621
617
  if not isinstance(manifest, dict):
622
618
  return {
623
- "status": "needs_init",
619
+ "status": "needs_repair",
624
620
  "reasons": ["TDD readiness receipt must be a JSON object"],
625
621
  }
626
622
 
@@ -650,9 +646,10 @@ def tdd_readiness(root: Path) -> dict[str, object]:
650
646
 
651
647
  contents: dict[str, list[str]] = {
652
648
  "build_files": [],
653
- "ci_files": [],
654
649
  "tool_files": [],
655
650
  }
651
+ if include_ci:
652
+ contents["ci_files"] = []
656
653
  for field in contents:
657
654
  records = manifest.get(field)
658
655
  if not isinstance(records, list) or not records:
@@ -663,11 +660,8 @@ def tdd_readiness(root: Path) -> dict[str, object]:
663
660
  reasons.append(f"{field} contains an invalid record")
664
661
  continue
665
662
  file_name = record.get("path")
666
- expected = record.get("sha256")
667
- if not is_non_empty_string(file_name) or not re.fullmatch(
668
- r"[a-f0-9]{64}", str(expected or "")
669
- ):
670
- reasons.append(f"{field} contains an invalid path or SHA-256")
663
+ if not is_non_empty_string(file_name):
664
+ reasons.append(f"{field} contains an invalid path")
671
665
  continue
672
666
  candidate = Path(str(file_name))
673
667
  if candidate.is_absolute():
@@ -678,8 +672,6 @@ def tdd_readiness(root: Path) -> dict[str, object]:
678
672
  resolved.relative_to(root.resolve())
679
673
  payload = resolved.read_bytes()
680
674
  contents[field].append(payload.decode("utf-8"))
681
- if hashlib.sha256(payload).hexdigest() != expected:
682
- reasons.append(f"readiness file changed: {file_name}")
683
675
  except (OSError, UnicodeError, ValueError):
684
676
  reasons.append(f"readiness file is missing or unreadable: {file_name}")
685
677
 
@@ -698,8 +690,6 @@ def tdd_readiness(root: Path) -> dict[str, object]:
698
690
  } if isinstance(manifest_ci_files, list) else set()
699
691
  if not build_paths.intersection(JAVA_BUILD_FILE_NAMES):
700
692
  reasons.append("build_files must include a Maven or Gradle Java build file")
701
- if not ci_paths.intersection(GITLAB_CI_ENTRY_FILES):
702
- reasons.append("ci_files must include the project-root GitLab CI entry file")
703
693
  tool_paths = {
704
694
  str(item.get("path", "")).replace("\\", "/")
705
695
  for item in manifest_tool_files
@@ -707,11 +697,14 @@ def tdd_readiness(root: Path) -> dict[str, object]:
707
697
  } if isinstance(manifest_tool_files, list) else set()
708
698
  if COVERAGE_TOOL_PATH not in tool_paths:
709
699
  reasons.append(f"tool_files must include {COVERAGE_TOOL_PATH}")
710
- if not any("jacoco" in content.lower() for content in contents["build_files"]):
711
- reasons.append("build files do not configure JaCoCo")
712
- reasons.extend(tdd_ci_contract_reasons(contents["ci_files"]))
700
+ if include_ci:
701
+ if not ci_paths.intersection(GITLAB_CI_ENTRY_FILES):
702
+ reasons.append("ci_files must include the project-root GitLab CI entry file")
703
+ if not any("jacoco" in content.lower() for content in contents["build_files"]):
704
+ reasons.append("build files do not configure JaCoCo")
705
+ reasons.extend(tdd_ci_contract_reasons(contents["ci_files"]))
713
706
  return {
714
- "status": "ready" if not reasons else "needs_init",
707
+ "status": "ready" if not reasons else "needs_repair",
715
708
  "reasons": list(dict.fromkeys(reasons)),
716
709
  }
717
710
 
@@ -720,7 +713,8 @@ def require_tdd_readiness(root: Path) -> None:
720
713
  readiness = tdd_readiness(root)
721
714
  if readiness["status"] != "ready":
722
715
  reasons = "; ".join(str(reason) for reason in readiness["reasons"])
723
- raise StateError(f"TDD cannot be enabled before ec-tdd-init succeeds: {reasons}")
716
+ action = "Run ec-tdd-init first" if readiness["status"] == "needs_init" else "Repair TDD readiness"
717
+ raise StateError(f"{action}: {reasons}. TDD settings are unchanged.")
724
718
 
725
719
 
726
720
  def resolve_behavior(
@@ -1121,6 +1115,7 @@ def record_architecture_assessment(
1121
1115
  if action not in ARCHITECTURE_ACTIONS:
1122
1116
  raise StateError(f"Unknown architecture assessment action: {action}")
1123
1117
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
1118
+ require_spec_context(root, task, agent, session_file)
1124
1119
  if task.get("status") != "MEMORY":
1125
1120
  raise StateError("Architecture assessment is only available during MEMORY.")
1126
1121
  progress = task.get("memory_progress")
@@ -2050,7 +2045,9 @@ def legacy_source_digest_matches(
2050
2045
  return legacy_sha256 == design_document_sha256
2051
2046
 
2052
2047
 
2053
- def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
2048
+ def inspect_task_spec(
2049
+ root: Path, task: dict, *, allow_pending_hard_dependencies: bool = False,
2050
+ ) -> tuple[dict, dict]:
2054
2051
  source = task.get("spec_source")
2055
2052
  selected = task.get("selected_spec_tasks")
2056
2053
  repo_paths = task.get("repo_paths")
@@ -2072,7 +2069,10 @@ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
2072
2069
  and is_non_empty_string(record.get("task_id"))
2073
2070
  and is_non_empty_string(record.get("evidence"))
2074
2071
  }
2075
- selection = select_tasks(inspection, selected, satisfied)
2072
+ selection = select_tasks(
2073
+ inspection, selected, satisfied,
2074
+ allow_pending_hard_dependencies=allow_pending_hard_dependencies,
2075
+ )
2076
2076
  except EasyDevSpecError as exc:
2077
2077
  raise StateError(f"Canonical Spec validation failed: {exc}") from exc
2078
2078
  if not isinstance(inspection.get("execution"), dict):
@@ -2179,6 +2179,117 @@ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
2179
2179
  return inspection, selection
2180
2180
 
2181
2181
 
2182
+ def restore_spec_context(
2183
+ root: Path, task_id: str, task: dict, agent: str,
2184
+ session_file: str | Path | None = None,
2185
+ ) -> dict | None:
2186
+ if not isinstance(task.get("spec_source"), dict):
2187
+ return None
2188
+ task.pop("spec_context", None)
2189
+ try:
2190
+ inspection, _ = inspect_task_spec(root, task, allow_pending_hard_dependencies=True)
2191
+ context = select_consumption_scopes(
2192
+ stored_spec_path(root, task), root, task["selected_spec_tasks"]
2193
+ )
2194
+ if context.get("design_sha256") != inspection.get("design_sha256"):
2195
+ raise StateError("Canonical Spec changed while restoring context; retry resume-spec-context.")
2196
+ if isinstance(task.get("spec_change"), dict):
2197
+ raise StateError("Confirmed Spec change is pending; update the bound source and run sync-spec-design.")
2198
+ task["spec_context"] = {
2199
+ "session_file": resolve_session_path(root, session_file).relative_to(root.resolve()).as_posix(),
2200
+ "agent": normalize_session_agent(agent),
2201
+ "spec_id": inspection["spec_id"],
2202
+ "revision": inspection["revision"],
2203
+ "design_sha256": inspection["design_sha256"],
2204
+ "selected_spec_tasks": task["selected_spec_tasks"],
2205
+ "loaded_at": now_iso(),
2206
+ }
2207
+ result = {"status": "ready", "consumption": context}
2208
+ except (StateError, EasyDevSpecError, OSError, UnicodeError) as exc:
2209
+ # 接手仍可成功,修复来源与同步状态后必须重新加载,不能沿用旧会话的消费记录。
2210
+ result = {"status": "blocked", "reason": str(exc), "source": task["spec_source"]}
2211
+ write_task(root, task_id, task)
2212
+ return result
2213
+
2214
+
2215
+ def resume_spec_context(
2216
+ root: Path, agent: str, task_id: str | None = None,
2217
+ session_file: str | Path | None = None,
2218
+ ) -> dict:
2219
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
2220
+ if not isinstance(task.get("spec_source"), dict):
2221
+ raise StateError("Current task is not backed by a Canonical Spec.")
2222
+ context = restore_spec_context(root, resolved_task_id, task, agent, session_file)
2223
+ snapshot = snapshot_state(root, session_file, session)
2224
+ snapshot.update({"action": "resume-spec-context", "spec_context": context})
2225
+ return snapshot
2226
+
2227
+
2228
+ def require_spec_context(
2229
+ root: Path, task: dict, agent: str, session_file: str | Path | None = None,
2230
+ *, allow_pending_hard_dependencies: bool = False,
2231
+ ) -> None:
2232
+ if not isinstance(task.get("spec_source"), dict):
2233
+ return
2234
+ if isinstance(task.get("spec_change"), dict):
2235
+ raise StateError("Confirmed Spec change is pending; update the bound source and run sync-spec-design.")
2236
+ inspection, _ = inspect_task_spec(
2237
+ root, task, allow_pending_hard_dependencies=allow_pending_hard_dependencies,
2238
+ )
2239
+ receipt = task.get("spec_context")
2240
+ expected = {
2241
+ "session_file": resolve_session_path(root, session_file).relative_to(root.resolve()).as_posix(),
2242
+ "agent": normalize_session_agent(agent),
2243
+ "spec_id": inspection["spec_id"],
2244
+ "revision": inspection["revision"],
2245
+ "design_sha256": inspection["design_sha256"],
2246
+ "selected_spec_tasks": task["selected_spec_tasks"],
2247
+ }
2248
+ if not isinstance(receipt, dict) or any(receipt.get(key) != value for key, value in expected.items()):
2249
+ raise StateError("Current session must consume the bound Canonical Spec via resume-spec-context before advancing.")
2250
+
2251
+
2252
+ def begin_spec_change(
2253
+ root: Path, affected_task_ids: list[str], summary: str, agent: str,
2254
+ task_id: str | None = None, session_file: str | Path | None = None,
2255
+ ) -> dict:
2256
+ session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
2257
+ if task.get("status") in TERMINAL_STATUSES:
2258
+ raise StateError("Cannot change the design of a terminal task.")
2259
+ selected = task.get("selected_spec_tasks") or []
2260
+ affected = sorted(set(affected_task_ids))
2261
+ if not affected or not set(affected).issubset(selected) or not is_non_empty_string(summary):
2262
+ raise StateError("Spec change requires selected affected task IDs and a confirmed summary.")
2263
+ existing = task.get("spec_change")
2264
+ if isinstance(existing, dict):
2265
+ if existing.get("affected_task_ids") != affected or existing.get("summary") != summary.strip():
2266
+ raise StateError("A different confirmed Spec change is pending; finish its synchronization first.")
2267
+ else:
2268
+ inspect_task_spec(root, task, allow_pending_hard_dependencies=True)
2269
+ if _writeback_progress(task).get("pending_action"):
2270
+ raise StateError("Reconcile pending Spec writeback before beginning a design change.")
2271
+ cancel_active_quality_attempt(root, resolved_task_id, task, agent, summary, "manual-return")
2272
+ task = load_task(root, resolved_task_id) or task
2273
+ cleanup_verification_checkpoint(root, resolved_task_id, task)
2274
+ task["spec_change"] = {
2275
+ "summary": summary.strip(), "affected_task_ids": affected,
2276
+ "spec_id": task["spec_source"]["spec_id"],
2277
+ "revision": task["spec_source"]["revision"],
2278
+ "design_sha256": task["spec_source"]["design_sha256"],
2279
+ "confirmed_by": agent, "confirmed_at": now_iso(),
2280
+ }
2281
+ task.pop("spec_context", None)
2282
+ task.pop("pending_transition", None)
2283
+ if task.get("status") != "ANALYSIS":
2284
+ task["status"] = "ANALYSIS"
2285
+ append_stage_history(task, "ANALYSIS", agent)
2286
+ task["last_agent"] = agent
2287
+ write_task(root, resolved_task_id, task)
2288
+ snapshot = snapshot_state(root, session_file, session)
2289
+ snapshot["action"] = "begin-spec-change"
2290
+ return snapshot
2291
+
2292
+
2182
2293
  def is_valid_spec_execution_plan(root: Path, task: dict, plan: object) -> bool:
2183
2294
  if not isinstance(plan, dict):
2184
2295
  return False
@@ -2931,6 +3042,40 @@ def update_git_worktree_fingerprint(
2931
3042
  )
2932
3043
 
2933
3044
 
3045
+ def tdd_infrastructure_fingerprint(repositories: set[Path]) -> str:
3046
+ # 初始化快照不参与就绪判断;当前输入同时约束验收和跨仓证据继承。
3047
+ digest = hashlib.sha256()
3048
+ for repository in sorted(repositories, key=str):
3049
+ receipt = repository / TDD_READINESS_PATH
3050
+ digest.update(str(repository).encode("utf-8") + b"\0")
3051
+ try:
3052
+ receipt_payload = receipt.read_bytes()
3053
+ manifest = json.loads(receipt_payload)
3054
+ digest.update(receipt_payload)
3055
+ except (OSError, UnicodeError, json.JSONDecodeError):
3056
+ digest.update(b"<missing-or-invalid-readiness>")
3057
+ continue
3058
+ if not isinstance(manifest, dict):
3059
+ continue
3060
+ for field in ("build_files", "tool_files"):
3061
+ records = manifest.get(field)
3062
+ for record in records if isinstance(records, list) else []:
3063
+ if not isinstance(record, dict) or not is_non_empty_string(record.get("path")):
3064
+ continue
3065
+ candidate = (repository / str(record["path"])).resolve()
3066
+ try:
3067
+ candidate.relative_to(repository.resolve())
3068
+ except ValueError as error:
3069
+ raise StateError("TDD evidence file escapes repository.") from error
3070
+ digest.update(f"{field}:{record['path']}".encode("utf-8") + b"\0")
3071
+ try:
3072
+ digest.update(candidate.read_bytes())
3073
+ except OSError:
3074
+ digest.update(b"<missing>")
3075
+ digest.update(b"\0")
3076
+ return digest.hexdigest()
3077
+
3078
+
2934
3079
  def implementation_fingerprint(root: Path, task_id: str) -> str:
2935
3080
  plan = latest_execution_plan(root, task_id)
2936
3081
  if not plan:
@@ -2953,6 +3098,9 @@ def implementation_fingerprint(root: Path, task_id: str) -> str:
2953
3098
  ).encode("utf-8")
2954
3099
  )
2955
3100
  digest.update(b"\0")
3101
+ digest.update(tdd_infrastructure_fingerprint(
3102
+ {root.resolve(), *task_repository_roots(root, task, plan)}
3103
+ ).encode("ascii"))
2956
3104
  digest.update(b"execution-plan\0")
2957
3105
  digest.update(
2958
3106
  json.dumps(
@@ -3046,6 +3194,8 @@ def canonical_repository_fingerprints(
3046
3194
  base = root / base
3047
3195
  base = base.resolve()
3048
3196
  digest = hashlib.sha256()
3197
+ if task.get("tdd_enabled") is True:
3198
+ digest.update(tdd_infrastructure_fingerprint({root.resolve(), base}).encode("ascii"))
3049
3199
  units = [
3050
3200
  unit
3051
3201
  for unit in plan.get("units", [])
@@ -3911,6 +4061,7 @@ def finalize_quality_decision(
3911
4061
  session_file: str | Path | None = None,
3912
4062
  ) -> dict:
3913
4063
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
4064
+ require_spec_context(root, task, agent, session_file)
3914
4065
  if task.get("status") != "QUALITY":
3915
4066
  raise StateError("A QUALITY decision can only be finalized during QUALITY.")
3916
4067
  record = finalize_quality_attempt(
@@ -4064,6 +4215,9 @@ def verification_contract_fingerprint(root: Path, task_id: str, task: dict) -> s
4064
4215
  "tdd_enabled": task.get("tdd_enabled"),
4065
4216
  "tdd_coverage_threshold": task.get("tdd_coverage_threshold"),
4066
4217
  "tdd_baselines": task.get("tdd_baselines"),
4218
+ **({"tdd_infrastructure": tdd_infrastructure_fingerprint(
4219
+ {root.resolve(), *task_repository_roots(root, task, plan)}
4220
+ )} if task.get("tdd_enabled") is True else {}),
4067
4221
  "plan": plan,
4068
4222
  "canonical": {
4069
4223
  "schema": source.get("schema"),
@@ -4471,6 +4625,7 @@ def record_verification_checkpoint(
4471
4625
  session_file: str | Path | None = None,
4472
4626
  ) -> dict:
4473
4627
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
4628
+ require_spec_context(root, task, agent, session_file)
4474
4629
  if task.get("status") != "QUALITY":
4475
4630
  raise StateError("Quality checkpoint can only be recorded during QUALITY.")
4476
4631
  if isinstance(task.get("quality_checkpoint"), dict):
@@ -4567,6 +4722,7 @@ def inspect_transition_drift(
4567
4722
  session_file: str | Path | None = None,
4568
4723
  ) -> dict:
4569
4724
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
4725
+ require_spec_context(root, task, agent, session_file)
4570
4726
  if task.get("status") != "QUALITY":
4571
4727
  raise StateError("Transition drift can only be inspected during QUALITY.")
4572
4728
  task = ensure_verification_checkpoint(root, resolved_task_id, task, agent, session_file)
@@ -5152,7 +5308,7 @@ def validate_verification_readiness(
5152
5308
  + ", ".join(missing_source_tasks)
5153
5309
  )
5154
5310
  if str(task.get("type") or "").strip().lower() == TDD_INIT_TASK_TYPE:
5155
- readiness = tdd_readiness(root)
5311
+ readiness = tdd_readiness(root, include_ci=True)
5156
5312
  if readiness["status"] != "ready":
5157
5313
  raise StateError(
5158
5314
  "TDD initialization cannot advance to MEMORY until readiness passes: "
@@ -6376,7 +6532,8 @@ def validate_analysis_readiness(
6376
6532
  readiness = tdd_readiness(root)
6377
6533
  if readiness["status"] != "ready":
6378
6534
  reasons.append(
6379
- "TDD infrastructure is not ready; run ec-tdd-init first: "
6535
+ ("TDD infrastructure is not ready; run ec-tdd-init first: "
6536
+ if readiness["status"] == "needs_init" else "Repair TDD readiness: ")
6380
6537
  + "; ".join(str(reason) for reason in readiness["reasons"])
6381
6538
  )
6382
6539
  plan = latest_execution_plan(root, task_id) or {}
@@ -6695,6 +6852,8 @@ def spec_task_summary(task: dict | None) -> dict | None:
6695
6852
  "repositories": task.get("spec_repositories", []),
6696
6853
  "pending_dependencies": pending_dependencies,
6697
6854
  "writeback": task.get("spec_writeback_progress"),
6855
+ "context": task.get("spec_context"),
6856
+ "pending_change": task.get("spec_change"),
6698
6857
  }
6699
6858
 
6700
6859
 
@@ -6939,6 +7098,14 @@ def build_machine_breadcrumbs(
6939
7098
 
6940
7099
  if task_id:
6941
7100
  lines.append(f"[current-task:{task_id}]")
7101
+ if task and isinstance(task.get("spec_source"), dict):
7102
+ source = task["spec_source"]
7103
+ lines.append(f"[easy-coding:spec:{source.get('spec_id')}:revision:{source.get('revision')}]")
7104
+ lines.append("[easy-coding:spec-context:resume-spec-context-on-session-resume]")
7105
+ if task.get("spec_change"):
7106
+ lines.append("[easy-coding:spec-change:pending-sync-spec-design]")
7107
+ if (task.get("spec_writeback_progress") or {}).get("pending_action"):
7108
+ lines.append("[easy-coding:spec-writeback:reconcile-spec-execution-required]")
6942
7109
  if state["task_missing"]:
6943
7110
  lines.append(f"[easy-coding:current-task-missing:{task_id}]")
6944
7111
  handoff = pending_handoff_record(root, str(task_id))
@@ -7116,7 +7283,11 @@ def set_current_task(root: Path, task_id: str, agent: str, session_file: str | P
7116
7283
  session["last_seen_stage"] = str(task.get("status") or "PENDING")
7117
7284
  session["last_agent"] = agent
7118
7285
  write_session(root, session, session_file)
7119
- return snapshot_state(root, session_file, session)
7286
+ context = restore_spec_context(root, task_id, task, agent, session_file)
7287
+ snapshot = snapshot_state(root, session_file, session)
7288
+ if context is not None:
7289
+ snapshot["spec_context"] = context
7290
+ return snapshot
7120
7291
 
7121
7292
 
7122
7293
  def clear_current_task(root: Path, agent: str, session_file: str | Path | None = None) -> dict:
@@ -7721,7 +7892,10 @@ def claim_task(root: Path, task_id: str, agent: str, session_file: str | Path |
7721
7892
  }
7722
7893
  append_execution_record(root, task_id, claim)
7723
7894
 
7895
+ context = restore_spec_context(root, task_id, task, agent, session_file)
7724
7896
  snapshot = snapshot_state(root, session_file, session)
7897
+ if context is not None:
7898
+ snapshot["spec_context"] = context
7725
7899
  snapshot["task_id"] = task_id
7726
7900
  snapshot["action"] = action
7727
7901
  snapshot["previous_agent"] = previous_agent
@@ -7771,7 +7945,8 @@ def create_task(
7771
7945
  write_task(root, task_id, task)
7772
7946
  if set_current:
7773
7947
  return set_current_task(root, task_id, agent, session_file)
7774
- return {"task_id": task_id, "task": task}
7948
+ context = restore_spec_context(root, task_id, task, agent, session_file)
7949
+ return {"task_id": task_id, "task": task, **({"spec_context": context} if context else {})}
7775
7950
 
7776
7951
 
7777
7952
  def create_task_from_spec(
@@ -7927,8 +8102,13 @@ def _execute_spec_writeback(
7927
8102
  action: dict,
7928
8103
  idempotency_key: str,
7929
8104
  invoke,
8105
+ *, allow_pending_hard_dependencies: bool = False,
7930
8106
  ) -> dict:
7931
- inspection, _ = inspect_task_spec(root, task)
8107
+ inspection, _ = inspect_task_spec(
8108
+ root, task, allow_pending_hard_dependencies=(
8109
+ allow_pending_hard_dependencies or action.get("kind") == "dependency"
8110
+ ),
8111
+ )
7932
8112
  source = task["spec_source"]
7933
8113
  progress = _writeback_progress(task)
7934
8114
  serialized_action = json.dumps(action, ensure_ascii=False, sort_keys=True)
@@ -8018,7 +8198,11 @@ def _execute_spec_writeback(
8018
8198
  "execution_revision": execution["execution_revision"],
8019
8199
  }
8020
8200
  )
8021
- inspect_task_spec(root, task)
8201
+ inspect_task_spec(
8202
+ root, task, allow_pending_hard_dependencies=(
8203
+ allow_pending_hard_dependencies or action.get("kind") == "dependency"
8204
+ ),
8205
+ )
8022
8206
  progress.update(
8023
8207
  {
8024
8208
  "last_execution_revision": execution["execution_revision"],
@@ -8058,8 +8242,11 @@ def writeback_spec_task(
8058
8242
  agent: str,
8059
8243
  task_id: str | None = None,
8060
8244
  session_file: str | Path | None = None,
8245
+ *, event_agent: str | None = None,
8061
8246
  ) -> dict:
8062
8247
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
8248
+ require_spec_context(root, task, agent, session_file)
8249
+ writer_agent = event_agent or agent
8063
8250
  if source_task_id not in set(task.get("selected_spec_tasks") or []):
8064
8251
  raise StateError("Canonical source task is outside the Harness task selection.")
8065
8252
  action = {
@@ -8069,7 +8256,7 @@ def writeback_spec_task(
8069
8256
  "summary": summary,
8070
8257
  "evidence": evidence,
8071
8258
  "idempotency_key": idempotency_key,
8072
- "agent": agent,
8259
+ "agent": writer_agent,
8073
8260
  }
8074
8261
  acknowledgment = _execute_spec_writeback(
8075
8262
  root,
@@ -8083,7 +8270,7 @@ def writeback_spec_task(
8083
8270
  status_value,
8084
8271
  summary,
8085
8272
  SPEC_WRITEBACK_APP,
8086
- spec_writeback_agent(agent),
8273
+ spec_writeback_agent(writer_agent),
8087
8274
  design_digest,
8088
8275
  execution_revision,
8089
8276
  evidence=evidence,
@@ -8108,8 +8295,11 @@ def writeback_spec_step(
8108
8295
  agent: str,
8109
8296
  task_id: str | None = None,
8110
8297
  session_file: str | Path | None = None,
8298
+ *, event_agent: str | None = None,
8111
8299
  ) -> dict:
8112
8300
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
8301
+ require_spec_context(root, task, agent, session_file)
8302
+ writer_agent = event_agent or agent
8113
8303
  if source_task_id not in set(task.get("selected_spec_tasks") or []):
8114
8304
  raise StateError("Canonical source task is outside the Harness task selection.")
8115
8305
  action = {
@@ -8120,7 +8310,7 @@ def writeback_spec_step(
8120
8310
  "summary": summary,
8121
8311
  "evidence": evidence,
8122
8312
  "idempotency_key": idempotency_key,
8123
- "agent": agent,
8313
+ "agent": writer_agent,
8124
8314
  }
8125
8315
  acknowledgment = _execute_spec_writeback(
8126
8316
  root,
@@ -8135,7 +8325,7 @@ def writeback_spec_step(
8135
8325
  status_value,
8136
8326
  summary,
8137
8327
  SPEC_WRITEBACK_APP,
8138
- spec_writeback_agent(agent),
8328
+ spec_writeback_agent(writer_agent),
8139
8329
  design_digest,
8140
8330
  execution_revision,
8141
8331
  evidence=evidence,
@@ -8160,8 +8350,11 @@ def writeback_spec_dependency(
8160
8350
  agent: str,
8161
8351
  task_id: str | None = None,
8162
8352
  session_file: str | Path | None = None,
8353
+ *, event_agent: str | None = None,
8163
8354
  ) -> dict:
8164
8355
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
8356
+ require_spec_context(root, task, agent, session_file, allow_pending_hard_dependencies=True)
8357
+ writer_agent = event_agent or agent
8165
8358
  if source_task_id not in set(task.get("selected_spec_tasks") or []):
8166
8359
  raise StateError("Canonical source task is outside the Harness task selection.")
8167
8360
  action = {
@@ -8172,7 +8365,7 @@ def writeback_spec_dependency(
8172
8365
  "summary": summary,
8173
8366
  "evidence": evidence,
8174
8367
  "idempotency_key": idempotency_key,
8175
- "agent": agent,
8368
+ "agent": writer_agent,
8176
8369
  }
8177
8370
  acknowledgment = _execute_spec_writeback(
8178
8371
  root,
@@ -8187,7 +8380,7 @@ def writeback_spec_dependency(
8187
8380
  status_value,
8188
8381
  summary,
8189
8382
  SPEC_WRITEBACK_APP,
8190
- spec_writeback_agent(agent),
8383
+ spec_writeback_agent(writer_agent),
8191
8384
  design_digest,
8192
8385
  execution_revision,
8193
8386
  evidence=evidence,
@@ -8239,7 +8432,7 @@ def rebind_spec_source(
8239
8432
  source_path = str(resolved)
8240
8433
  path_mode = "absolute"
8241
8434
  source.update({"path": source_path, "path_mode": path_mode})
8242
- inspect_task_spec(root, task)
8435
+ inspect_task_spec(root, task, allow_pending_hard_dependencies=True)
8243
8436
  task["last_agent"] = agent
8244
8437
  write_task(root, resolved_task_id, task)
8245
8438
  snapshot = snapshot_state(root, session_file, session)
@@ -8254,6 +8447,7 @@ def reconcile_local_result_evidence(
8254
8447
  agent: str,
8255
8448
  session_file: str | Path | None,
8256
8449
  ) -> tuple[int, list[str]]:
8450
+ require_spec_context(root, task, agent, session_file)
8257
8451
  plan = latest_execution_plan(root, resolved_task_id)
8258
8452
  if not isinstance(plan, dict):
8259
8453
  return 0, []
@@ -8554,9 +8748,10 @@ def reconcile_spec_execution(
8554
8748
  affected_task_ids,
8555
8749
  str(action.get("summary") or "Reconciled Canonical Spec design sync"),
8556
8750
  str(action.get("idempotency_key") or ""),
8557
- str(action.get("agent") or agent),
8751
+ agent,
8558
8752
  resolved_task_id,
8559
8753
  session_file,
8754
+ event_agent=str(action.get("agent") or agent),
8560
8755
  )
8561
8756
  result["action"] = "reconcile-spec-execution"
8562
8757
  result["reconciled"] = True
@@ -8586,12 +8781,34 @@ def reconcile_spec_execution(
8586
8781
  "summary": str(action.get("summary") or "Reconciled shared Spec writeback"),
8587
8782
  "evidence": action.get("evidence") if isinstance(action.get("evidence"), list) else [],
8588
8783
  "idempotency_key": str(action.get("idempotency_key") or ""),
8589
- "agent": str(action.get("agent") or agent),
8784
+ "agent": agent,
8785
+ "event_agent": str(action.get("agent") or agent),
8590
8786
  "task_id": resolved_task_id,
8591
8787
  "session_file": session_file,
8592
8788
  }
8593
8789
  if not common["idempotency_key"]:
8594
8790
  raise StateError("Pending Canonical Spec writeback has no idempotency key.")
8791
+ cancellation_suffix = {"blocked": "close-blocked", "cancelled": "cancel"}.get(action.get("status"))
8792
+ source_task_id = str(action.get("source_task_id") or "")
8793
+ if (
8794
+ kind == "task" and cancellation_suffix
8795
+ and source_task_id in (task.get("selected_spec_tasks") or [])
8796
+ and common["idempotency_key"] == f"{resolved_task_id}:{source_task_id}:{cancellation_suffix}"
8797
+ ):
8798
+ # 只恢复已经登记的取消链;沿用原动作,不能借恢复入口新增实施进度。
8799
+ acknowledgment = _execute_spec_writeback(
8800
+ root, resolved_task_id, task, action, common["idempotency_key"],
8801
+ lambda design_digest, execution_revision: record_task_status(
8802
+ stored_spec_path(root, task), source_task_id, str(action["status"]),
8803
+ common["summary"], SPEC_WRITEBACK_APP, spec_writeback_agent(common["event_agent"]),
8804
+ design_digest, execution_revision, evidence=common["evidence"],
8805
+ run_id=resolved_task_id, idempotency_key=common["idempotency_key"],
8806
+ ),
8807
+ allow_pending_hard_dependencies=True,
8808
+ )
8809
+ result = snapshot_state(root, session_file, session)
8810
+ result.update({"action": "reconcile-spec-execution", "reconciled": True, "spec_writeback": acknowledgment})
8811
+ return result
8595
8812
  if kind == "task":
8596
8813
  result = writeback_spec_task(
8597
8814
  source_task_id=str(action.get("source_task_id") or ""),
@@ -8627,6 +8844,7 @@ def sync_spec_design_state(
8627
8844
  agent: str,
8628
8845
  task_id: str | None = None,
8629
8846
  session_file: str | Path | None = None,
8847
+ event_agent: str | None = None,
8630
8848
  ) -> dict:
8631
8849
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
8632
8850
  source = task.get("spec_source")
@@ -8634,6 +8852,21 @@ def sync_spec_design_state(
8634
8852
  raise StateError("Current task is not backed by a Canonical Spec.")
8635
8853
  spec_path = stored_spec_path(root, task)
8636
8854
  requested_task_ids = sorted(set(affected_task_ids))
8855
+ writer_agent = event_agent or agent
8856
+ change = task.get("spec_change")
8857
+ if isinstance(change, dict) and change.get("affected_task_ids") != requested_task_ids:
8858
+ raise StateError("Design sync must include exactly the confirmed affected tasks.")
8859
+ if isinstance(change, dict) and any(
8860
+ change.get(field) != source.get(field) for field in ("spec_id", "revision", "design_sha256")
8861
+ ):
8862
+ raise StateError("Confirmed Spec change baseline no longer matches the bound design.")
8863
+
8864
+ def validate_change_event(event: dict) -> None:
8865
+ if isinstance(change, dict) and (
8866
+ event.get("from_design_revision") != change.get("revision")
8867
+ or event.get("to_design_revision") != int(change["revision"]) + 1
8868
+ ):
8869
+ raise StateError("Old design sync cannot resolve the current confirmed Spec change.")
8637
8870
 
8638
8871
  def current_execution_envelope() -> dict:
8639
8872
  try:
@@ -8644,6 +8877,9 @@ def sync_spec_design_state(
8644
8877
  raise StateError(f"Cannot inspect pre-sync Canonical execution state: {exc}") from exc
8645
8878
  if not isinstance(execution, dict):
8646
8879
  raise StateError("Canonical Spec shared execution is missing before sync-design.")
8880
+ for event in execution.get("events", []):
8881
+ if isinstance(event, dict) and event.get("idempotency_key") == idempotency_key:
8882
+ validate_change_event(event)
8647
8883
  if execution.get("design_sha256") != source.get("design_sha256"):
8648
8884
  matching_events = [
8649
8885
  event
@@ -8667,7 +8903,7 @@ def sync_spec_design_state(
8667
8903
  "affected_task_ids": requested_task_ids,
8668
8904
  "summary": summary,
8669
8905
  "idempotency_key": idempotency_key,
8670
- "agent": agent,
8906
+ "agent": writer_agent,
8671
8907
  }
8672
8908
  serialized_pending_action = json.dumps(
8673
8909
  pending_action, ensure_ascii=False, sort_keys=True
@@ -8699,7 +8935,7 @@ def sync_spec_design_state(
8699
8935
  requested_task_ids,
8700
8936
  summary,
8701
8937
  SPEC_WRITEBACK_APP,
8702
- spec_writeback_agent(agent),
8938
+ spec_writeback_agent(writer_agent),
8703
8939
  str(source.get("design_sha256")),
8704
8940
  execution_revision,
8705
8941
  run_id=resolved_task_id,
@@ -8745,6 +8981,10 @@ def sync_spec_design_state(
8745
8981
  raise StateError(f"Cannot synchronize Canonical Spec design: {exc}") from exc
8746
8982
  if inspection.get("spec_id") != source.get("spec_id"):
8747
8983
  raise StateError("Synchronized Canonical Spec identity changed unexpectedly.")
8984
+ event = _spec_event(execution, idempotency_key)
8985
+ validate_change_event(event)
8986
+ if isinstance(change, dict) and inspection.get("revision") != int(change["revision"]) + 1:
8987
+ raise StateError("Synchronized design does not match the confirmed Spec change revision.")
8748
8988
  binding_was_synchronized = (
8749
8989
  source.get("revision") == inspection.get("revision")
8750
8990
  and source.get("design_sha256") == inspection.get("design_sha256")
@@ -8757,22 +8997,32 @@ def sync_spec_design_state(
8757
8997
  "execution_revision": execution["execution_revision"],
8758
8998
  }
8759
8999
  )
8760
- event = _spec_event(execution, idempotency_key)
8761
9000
  if not binding_was_synchronized:
8762
9001
  reset_task_ids = set(event.get("task_ids", []))
8763
- refreshed_dependencies: list[dict] = []
9002
+ new_dependencies = {
9003
+ (item["source_task_id"], item["task_id"]): item
9004
+ for item in inspection["dependency_edges"]
9005
+ }
9006
+ preserved_evidence: dict[str, str] = {}
8764
9007
  for dependency in task.get("spec_dependency_evidence", []):
8765
9008
  if not isinstance(dependency, dict):
8766
9009
  continue
8767
- refreshed = dict(dependency)
8768
- if refreshed.get("source_task_id") in reset_task_ids:
8769
- refreshed["status"] = "pending"
8770
- refreshed["shared_status"] = "pending"
8771
- for field in ("evidence", "satisfied_at", "satisfied_by"):
8772
- refreshed.pop(field, None)
8773
- refreshed_dependencies.append(refreshed)
8774
- task["spec_dependency_evidence"] = refreshed_dependencies
8775
- inspect_task_spec(root, task)
9010
+ edge = (dependency.get("source_task_id"), dependency.get("task_id"))
9011
+ current = new_dependencies.get(edge)
9012
+ if (
9013
+ edge[0] not in reset_task_ids and edge[1] not in reset_task_ids
9014
+ and current is not None
9015
+ and dependency.get("dependency_type") == current.get("dependency_type")
9016
+ and dependency.get("required_evidence") == current.get("required_evidence")
9017
+ and dependency.get("status") == "satisfied"
9018
+ and is_non_empty_string(dependency.get("evidence"))
9019
+ ):
9020
+ preserved_evidence[f"{edge[0]}->{edge[1]}"] = str(dependency["evidence"])
9021
+ task["spec_dependency_evidence"] = select_tasks(
9022
+ inspection, task["selected_spec_tasks"], preserved_evidence,
9023
+ allow_pending_hard_dependencies=True,
9024
+ )["dependency_records"]
9025
+ inspect_task_spec(root, task, allow_pending_hard_dependencies=True)
8776
9026
  progress.update(
8777
9027
  {
8778
9028
  "last_execution_revision": execution["execution_revision"],
@@ -8783,6 +9033,8 @@ def sync_spec_design_state(
8783
9033
  }
8784
9034
  )
8785
9035
  progress.pop("pending_action", None)
9036
+ task.pop("spec_change", None)
9037
+ task.pop("spec_context", None)
8786
9038
  if task.get("status") not in {"INIT", "ANALYSIS"}:
8787
9039
  cleanup_verification_checkpoint(root, resolved_task_id, task)
8788
9040
  task["status"] = "ANALYSIS"
@@ -9147,7 +9399,7 @@ def cancel_shared_tasks(
9147
9399
  reason: str,
9148
9400
  agent: str,
9149
9401
  ) -> None:
9150
- inspection, _ = inspect_task_spec(root, task)
9402
+ inspection, _ = inspect_task_spec(root, task, allow_pending_hard_dependencies=True)
9151
9403
  snapshots = _selected_execution_snapshots(inspection, task)
9152
9404
  for source_task_id in task.get("selected_spec_tasks") or []:
9153
9405
  current = snapshots.get(str(source_task_id), {}).get("status")
@@ -9182,6 +9434,7 @@ def cancel_shared_tasks(
9182
9434
  run_id=harness_task_id,
9183
9435
  idempotency_key=blocked_key,
9184
9436
  ),
9437
+ allow_pending_hard_dependencies=True,
9185
9438
  )
9186
9439
  cancel_key = f"{harness_task_id}:{source_task_id}:cancel"
9187
9440
  cancel_action = {
@@ -9211,6 +9464,7 @@ def cancel_shared_tasks(
9211
9464
  run_id=harness_task_id,
9212
9465
  idempotency_key=cancel_key,
9213
9466
  ),
9467
+ allow_pending_hard_dependencies=True,
9214
9468
  )
9215
9469
 
9216
9470
 
@@ -9228,7 +9482,7 @@ def satisfy_spec_dependency(
9228
9482
  raise StateError("Spec dependency evidence cannot change after MEMORY begins.")
9229
9483
  if not is_non_empty_string(evidence):
9230
9484
  raise StateError("Spec dependency evidence must be non-empty.")
9231
- inspect_task_spec(root, task)
9485
+ require_spec_context(root, task, agent, session_file, allow_pending_hard_dependencies=True)
9232
9486
  records = task.get("spec_dependency_evidence")
9233
9487
  if not isinstance(records, list):
9234
9488
  raise StateError("Current task is not backed by Canonical Spec dependency metadata.")
@@ -9563,6 +9817,8 @@ def request_transition(
9563
9817
  if stage not in VALID_TRANSITIONS:
9564
9818
  raise StateError(f"Unknown stage: {stage}")
9565
9819
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
9820
+ if stage in {"IMPLEMENT", "QUALITY", "MEMORY", "COMPLETE"}:
9821
+ require_spec_context(root, task, agent, session_file)
9566
9822
  previous = str(task.get("status") or "idle")
9567
9823
  task_type = str(task.get("type") or "")
9568
9824
  approval_mode = resolve_approval_mode(root, session)[2]
@@ -9663,6 +9919,8 @@ def apply_transition(
9663
9919
  if stage not in VALID_TRANSITIONS:
9664
9920
  raise StateError(f"Unknown stage: {stage}")
9665
9921
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
9922
+ if stage in {"IMPLEMENT", "QUALITY", "MEMORY", "COMPLETE"}:
9923
+ require_spec_context(root, task, agent, session_file)
9666
9924
 
9667
9925
  previous = str(task.get("status") or "idle")
9668
9926
  task_type = str(task.get("type") or "")
@@ -9779,6 +10037,8 @@ def auto_transition(
9779
10037
  session_file: str | Path | None = None,
9780
10038
  ) -> dict:
9781
10039
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
10040
+ if stage in {"IMPLEMENT", "QUALITY", "MEMORY", "COMPLETE"}:
10041
+ require_spec_context(root, task, agent, session_file)
9782
10042
  previous = str(task.get("status") or "idle")
9783
10043
  task_type = str(task.get("type") or "")
9784
10044
  approval_mode = resolve_approval_mode(root, session)[2]
@@ -9857,6 +10117,8 @@ def confirm_transition(
9857
10117
  approval_mode = resolve_approval_mode(root, session)[2]
9858
10118
  source = str(pending.get("from") or "")
9859
10119
  target = str(pending.get("to") or "")
10120
+ if target in {"IMPLEMENT", "QUALITY", "MEMORY", "COMPLETE"}:
10121
+ require_spec_context(root, task, agent, session_file)
9860
10122
  if source != previous:
9861
10123
  raise StateError(
9862
10124
  f"Pending transition source {source or 'missing'} does not match current stage {previous}."
@@ -9930,6 +10192,7 @@ def memory_short_complete(
9930
10192
  session_file: str | Path | None = None,
9931
10193
  ) -> dict:
9932
10194
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
10195
+ require_spec_context(root, task, agent, session_file)
9933
10196
  if task.get("status") != "MEMORY":
9934
10197
  raise StateError("Short-memory progress can only be recorded during MEMORY.")
9935
10198
  if not memory_file.strip():
@@ -10057,6 +10320,7 @@ def memory_complete(
10057
10320
  if action not in {"no-op", "distill"}:
10058
10321
  raise StateError(f"Unknown memory action: {action}")
10059
10322
  session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
10323
+ require_spec_context(root, task, agent, session_file)
10060
10324
  if task.get("status") != "MEMORY":
10061
10325
  raise StateError("Memory completion can only be recorded during MEMORY.")
10062
10326
  progress = task.get("memory_progress")
@@ -10286,6 +10550,16 @@ def main() -> int:
10286
10550
  rebind_spec.add_argument("--agent", required=True)
10287
10551
  rebind_spec.add_argument("--task-id")
10288
10552
 
10553
+ resume_spec = subcommands.add_parser("resume-spec-context", parents=[common])
10554
+ resume_spec.add_argument("--agent", required=True)
10555
+ resume_spec.add_argument("--task-id")
10556
+
10557
+ begin_change = subcommands.add_parser("begin-spec-change", parents=[common])
10558
+ begin_change.add_argument("--affected-task", action="append", required=True)
10559
+ begin_change.add_argument("--summary", required=True)
10560
+ begin_change.add_argument("--agent", required=True)
10561
+ begin_change.add_argument("--task-id")
10562
+
10289
10563
  writeback_task = subcommands.add_parser("writeback-spec-task", parents=[common])
10290
10564
  writeback_task.add_argument("--spec-task", required=True)
10291
10565
  writeback_task.add_argument(
@@ -10614,6 +10888,9 @@ def main() -> int:
10614
10888
  command_lock = acquire_session_command_lock(
10615
10889
  root, resolve_session_path(root, session_file)
10616
10890
  )
10891
+ if command == "evidence-fingerprints":
10892
+ _, _, current = resolve_current_task(root, getattr(args, "task_id", None), session_file)
10893
+ require_spec_context(root, current, agent, session_file)
10617
10894
  if command == "snapshot":
10618
10895
  emit(snapshot_state(root, session_file))
10619
10896
  elif command == "inspect-dev-spec":
@@ -10696,6 +10973,15 @@ def main() -> int:
10696
10973
  session_file,
10697
10974
  )
10698
10975
  )
10976
+ elif command == "resume-spec-context":
10977
+ emit(attach_status_context(
10978
+ root, resume_spec_context(root, agent, args.task_id, session_file), agent, session_file
10979
+ ))
10980
+ elif command == "begin-spec-change":
10981
+ emit(attach_status_context(
10982
+ root, begin_spec_change(root, args.affected_task, args.summary, agent, args.task_id, session_file),
10983
+ agent, session_file,
10984
+ ))
10699
10985
  elif command == "writeback-spec-task":
10700
10986
  emit(
10701
10987
  attach_status_context(