From 35ae62ccea2694118966ba5f700b0fa4aa854226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Sun, 30 Aug 2026 01:41:23 +0800 Subject: [PATCH 1/9] fix(knowledge): enforce pinned vector runtime --- scripts/ks.py | 95 +++++++++++++++++ tests/test_keep_summarizing_query.py | 147 +++++++++++++++++++++++++++ 2 files changed, 242 insertions(+) diff --git a/scripts/ks.py b/scripts/ks.py index 381772a..b467974 100755 --- a/scripts/ks.py +++ b/scripts/ks.py @@ -12,9 +12,100 @@ from __future__ import annotations import importlib.util +import os +import shutil import sys +import tomllib +from collections.abc import Mapping, Sequence +from importlib import metadata as importlib_metadata from pathlib import Path +RUNTIME_DEPENDENCIES = ( + ("yaml", "pyyaml"), + ("sqlite_vec", "sqlite-vec"), + ("fastembed", "fastembed"), +) +UV_REEXEC_ENV = "WORK_BUNDLE_KS_UV_REEXEC" + + +def _declared_runtime_versions() -> dict[str, str]: + metadata_lines: list[str] = [] + inside_script_metadata = False + for line in Path(__file__).read_text(encoding="utf-8").splitlines(): + if line == "# /// script": + inside_script_metadata = True + continue + if inside_script_metadata and line == "# ///": + break + if inside_script_metadata: + metadata_lines.append(line.removeprefix("#").lstrip()) + + metadata = tomllib.loads("\n".join(metadata_lines)) + versions: dict[str, str] = {} + for dependency in metadata.get("dependencies", []): + distribution, separator, version = dependency.partition("==") + if separator: + versions[distribution.lower().replace("_", "-")] = version + return versions + + +def _missing_runtime_dependencies() -> list[str]: + declared_versions = _declared_runtime_versions() + invalid: list[str] = [] + for module_name, distribution_name in RUNTIME_DEPENDENCIES: + if importlib.util.find_spec(module_name) is None: + invalid.append(module_name) + continue + expected_version = declared_versions.get(distribution_name) + try: + actual_version = importlib_metadata.version(distribution_name) + except importlib_metadata.PackageNotFoundError: + invalid.append(f"{module_name} ({distribution_name} distribution missing)") + continue + if expected_version is None: + invalid.append(f"{module_name} ({distribution_name} is not pinned)") + elif actual_version != expected_version: + invalid.append( + f"{module_name} ({distribution_name} {actual_version} != {expected_version})" + ) + return invalid + + +def _ensure_managed_runtime( + *, + argv: Sequence[str] | None = None, + environ: Mapping[str, str] | None = None, +) -> tuple[bool, str | None]: + missing = _missing_runtime_dependencies() + if not missing: + return True, None + + current_environment = dict(os.environ if environ is None else environ) + missing_list = ", ".join(missing) + if current_environment.get(UV_REEXEC_ENV) == "1": + return ( + False, + "KS_RUNTIME_DEPENDENCY_UNAVAILABLE: uv could not hydrate the declared " + f"runtime dependencies: {missing_list}", + ) + + uv_path = shutil.which("uv") + if uv_path is None: + return ( + False, + "KS_RUNTIME_DEPENDENCY_UNAVAILABLE: missing runtime dependencies " + f"({missing_list}); install uv and retry this command", + ) + + current_argv = list(sys.argv if argv is None else argv) + current_environment[UV_REEXEC_ENV] = "1" + os.execve( + uv_path, + [uv_path, "run", str(Path(__file__).resolve()), *current_argv[1:]], + current_environment, + ) + raise RuntimeError("uv runtime re-exec returned unexpectedly") + def _load_main(): module_path = Path(__file__).resolve().parent / "keep-summarizing" / "dispatcher.py" @@ -28,6 +119,10 @@ def _load_main(): def main() -> int: + ready, error = _ensure_managed_runtime() + if not ready: + print(error, file=sys.stderr) + return 2 return int(_load_main()()) diff --git a/tests/test_keep_summarizing_query.py b/tests/test_keep_summarizing_query.py index dc52dcf..26fb6ea 100644 --- a/tests/test_keep_summarizing_query.py +++ b/tests/test_keep_summarizing_query.py @@ -3,6 +3,8 @@ import argparse import importlib import json +import os +import subprocess import sys from pathlib import Path @@ -30,6 +32,16 @@ def load_keep_summarizing_modules() -> tuple[object, object]: return indexes_module, query_module +def load_ks_entrypoint() -> object: + module_path = REPO_ROOT / "scripts" / "ks.py" + spec = importlib.util.spec_from_file_location("keep_summarizing_entrypoint", module_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Unable to load keep-summarizing entrypoint: {module_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + indexes, query = load_keep_summarizing_modules() LIFECYCLE_FIXTURES = [ @@ -661,6 +673,141 @@ def test_hybrid_retrieval_contract_runtime_declares_pinned_uv_dependencies() -> assert '"fastembed==0.8.0"' in source +def test_ks_entrypoint_reexecutes_with_uv_when_runtime_dependencies_are_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + entrypoint = load_ks_entrypoint() + invocation: dict[str, object] = {} + + def capture_execve(executable: str, argv: list[str], environ: dict[str, str]) -> None: + invocation.update(executable=executable, argv=argv, environ=environ) + raise RuntimeError("execve intercepted") + + monkeypatch.setattr(entrypoint, "_missing_runtime_dependencies", lambda: ["sqlite_vec"]) + monkeypatch.setattr(entrypoint.shutil, "which", lambda _command: "/opt/homebrew/bin/uv") + monkeypatch.setattr(entrypoint.os, "execve", capture_execve) + + with pytest.raises(RuntimeError, match="execve intercepted"): + entrypoint._ensure_managed_runtime( + argv=["scripts/ks.py", "index", "--project", "work-bundle"], + environ={"PATH": "/opt/homebrew/bin"}, + ) + + assert invocation["executable"] == "/opt/homebrew/bin/uv" + assert invocation["argv"] == [ + "/opt/homebrew/bin/uv", + "run", + str((REPO_ROOT / "scripts" / "ks.py").resolve()), + "index", + "--project", + "work-bundle", + ] + assert invocation["environ"][entrypoint.UV_REEXEC_ENV] == "1" + + +def test_ks_entrypoint_reports_actionable_error_when_uv_is_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + entrypoint = load_ks_entrypoint() + monkeypatch.setattr(entrypoint, "_missing_runtime_dependencies", lambda: ["sqlite_vec", "fastembed"]) + monkeypatch.setattr(entrypoint.shutil, "which", lambda _command: None) + + ready, error = entrypoint._ensure_managed_runtime(argv=["scripts/ks.py", "query"], environ={}) + + assert ready is False + assert error is not None + assert "KS_RUNTIME_DEPENDENCY_UNAVAILABLE" in error + assert "sqlite_vec, fastembed" in error + assert "install uv" in error.lower() + + +def test_ks_entrypoint_uses_current_runtime_when_dependencies_are_available( + monkeypatch: pytest.MonkeyPatch, +) -> None: + entrypoint = load_ks_entrypoint() + monkeypatch.setattr(entrypoint, "_missing_runtime_dependencies", lambda: []) + + assert entrypoint._ensure_managed_runtime(argv=["scripts/ks.py", "query"], environ={}) == (True, None) + + +def test_ks_entrypoint_reexecutes_when_an_installed_distribution_version_is_wrong( + monkeypatch: pytest.MonkeyPatch, +) -> None: + entrypoint = load_ks_entrypoint() + expected = entrypoint._declared_runtime_versions() + installed = dict(expected) + installed["sqlite-vec"] = "0.1.8" + + monkeypatch.setattr(entrypoint.importlib.util, "find_spec", lambda _module: object()) + monkeypatch.setattr(entrypoint.importlib_metadata, "version", lambda name: installed[name]) + + assert entrypoint._missing_runtime_dependencies() == [ + "sqlite_vec (sqlite-vec 0.1.8 != 0.1.9)" + ] + + +def test_ks_entrypoint_reentry_marker_fails_typed_without_recursing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + entrypoint = load_ks_entrypoint() + monkeypatch.setattr(entrypoint, "_missing_runtime_dependencies", lambda: ["sqlite_vec"]) + monkeypatch.setattr( + entrypoint.shutil, + "which", + lambda _command: pytest.fail("marked re-entry must not look up uv again"), + ) + + ready, error = entrypoint._ensure_managed_runtime( + argv=["scripts/ks.py", "query"], + environ={entrypoint.UV_REEXEC_ENV: "1"}, + ) + + assert ready is False + assert error is not None + assert "KS_RUNTIME_DEPENDENCY_UNAVAILABLE" in error + assert "sqlite_vec" in error + + +def test_ks_entrypoint_preserves_uv_native_prelaunch_failure( + tmp_path: Path, +) -> None: + fake_runtime = tmp_path / "runtime" + fake_runtime.mkdir() + for module_name, distribution_name in ( + ("yaml", "PyYAML"), + ("sqlite_vec", "sqlite-vec"), + ("fastembed", "fastembed"), + ): + (fake_runtime / f"{module_name}.py").write_text("", encoding="utf-8") + metadata_dir = fake_runtime / f"{distribution_name.replace('-', '_')}-0.0.dist-info" + metadata_dir.mkdir() + (metadata_dir / "METADATA").write_text( + f"Metadata-Version: 2.1\nName: {distribution_name}\nVersion: 0.0\n", + encoding="utf-8", + ) + + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + fake_uv = fake_bin / "uv" + fake_uv.write_text("#!/bin/sh\necho UV_NATIVE_PRELAUNCH_FAILURE >&2\nexit 73\n", encoding="utf-8") + fake_uv.chmod(0o755) + environment = dict(os.environ) + environment["PATH"] = f"{fake_bin}{os.pathsep}{environment.get('PATH', '')}" + environment["PYTHONPATH"] = str(fake_runtime) + + result = subprocess.run( + [sys.executable, str(REPO_ROOT / "scripts" / "ks.py"), "--help"], + check=False, + capture_output=True, + text=True, + env=environment, + ) + + assert result.returncode == 73 + assert "UV_NATIVE_PRELAUNCH_FAILURE" in result.stderr + assert "KS_RUNTIME_DEPENDENCY_UNAVAILABLE" not in result.stderr + + @pytest.mark.parametrize( ("field", "value"), [ From ce8f1aa44ce42e6aacf95cbf378d720ea651dffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Sun, 30 Aug 2026 01:55:26 +0800 Subject: [PATCH 2/9] fix(orchestration): repair G1 helper contracts --- scripts/orchestration/core.py | 2 +- scripts/orchestration/dispatcher.py | 1 + scripts/orchestration/execution_context.py | 13 +++- scripts/orchestration/plans.py | 28 +++++++- tests/test_orchestration_execution_context.py | 67 +++++++++++++++++++ .../test_orchestration_workflow_contracts.py | 32 +++++++++ 6 files changed, 138 insertions(+), 5 deletions(-) diff --git a/scripts/orchestration/core.py b/scripts/orchestration/core.py index 9b22e2d..2942c1a 100644 --- a/scripts/orchestration/core.py +++ b/scripts/orchestration/core.py @@ -12,7 +12,7 @@ from pathlib import Path -SPEC_STATUSES = {"draft", "active", "implemented", "reviewed", "superseded", "archived"} +SPEC_STATUSES = {"draft", "active", "verified", "implemented", "reviewed", "superseded", "archived"} PLAN_STATUSES = {"Planned", "In progress", "Completed", "Deprecated", "On Hold"} HANDOFF_STATUSES = {"active", "reviewed", "archived", "superseded"} HANDOFF_TYPES = {"orchestration", "executor-result"} diff --git a/scripts/orchestration/dispatcher.py b/scripts/orchestration/dispatcher.py index 399dd5c..ad4f096 100644 --- a/scripts/orchestration/dispatcher.py +++ b/scripts/orchestration/dispatcher.py @@ -91,6 +91,7 @@ def build_parser() -> argparse.ArgumentParser: set_plan.add_argument("--id", required=True) set_plan.add_argument("--status", required=True) set_plan.add_argument("--kind", choices=["plan", "phase", "task"]) + set_plan.add_argument("--plan-id") set_plan.add_argument("--handoff") set_plan.set_defaults(func=cmd_set_plan_status) archive_plan = sub.add_parser("archive-plan", parents=[parent]) diff --git a/scripts/orchestration/execution_context.py b/scripts/orchestration/execution_context.py index 20bae24..81b478e 100644 --- a/scripts/orchestration/execution_context.py +++ b/scripts/orchestration/execution_context.py @@ -1607,6 +1607,16 @@ def _task_context(args: argparse.Namespace) -> tuple[Path, Path, dict[str, Any], return root, task_path, task_data, task_body, records, source_paths +def _contains_resolved_source_record(value: Any, record: str) -> bool: + if isinstance(value, str): + return record in value + if isinstance(value, dict): + return any(_contains_resolved_source_record(item, record) for item in value.values()) + if isinstance(value, list): + return any(_contains_resolved_source_record(item, record) for item in value) + return False + + def _compile_task_brief(args: argparse.Namespace) -> tuple[Path, dict[str, Any]]: root, task_path, task, task_body, records, source_paths = _task_context(args) task_id = _artifact_id(task, "id", task_path) @@ -1700,9 +1710,8 @@ def _compile_task_brief(args: argparse.Namespace) -> tuple[Path, dict[str, Any]] "review_required": review_required, } } - serialized_brief = json.dumps(brief, ensure_ascii=False) for identifier in source_ids: - if records[identifier] not in serialized_brief: + if not _contains_resolved_source_record(brief, records[identifier]): raise SystemExit( f"Source ID {identifier} from {', '.join(path.as_posix() for path in source_paths)} " "is not allocated to a resolved task-brief field" diff --git a/scripts/orchestration/plans.py b/scripts/orchestration/plans.py index a585f8b..069e42f 100644 --- a/scripts/orchestration/plans.py +++ b/scripts/orchestration/plans.py @@ -9,7 +9,9 @@ unique_explicit_handoff_plan_id, validate_executor_result_for_task, _compile_task_brief, + _parse_scalar, ) +from handoffs import _read_compact_yaml_metadata from repository_preflight import capture_repository_evidence, task_caused_paths from specs import load_index, replace_front_matter_value @@ -59,6 +61,14 @@ def _plan_executor_handoffs(args: argparse.Namespace, plan_id: str) -> list[dict for path in sorted(handoff_root.glob("*/*")): if not path.is_file() or path.suffix not in {".yaml", ".yml"}: continue + compact = _read_compact_yaml_metadata(path) + if isinstance(compact.get("related"), str): + related = _parse_scalar(str(compact["related"])) + if isinstance(related, dict): + compact["related"] = related + compact_plan_id = unique_explicit_handoff_plan_id(compact) + if compact_plan_id is not None and compact_plan_id != plan_id: + continue handoff = read_structured_artifact(path) if unique_explicit_handoff_plan_id(handoff) != plan_id: continue @@ -522,11 +532,25 @@ def cmd_set_plan_status(args: argparse.Namespace) -> None: if args.status not in PLAN_STATUSES: raise SystemExit(f"Invalid plan status: {args.status}") rows = index_plans(args) - matches = [row for row in rows if row.get("id") == args.id and (not args.kind or row.get("type") == args.kind)] + kind = getattr(args, "kind", None) + plan_id = getattr(args, "plan_id", None) + matches = [ + row + for row in rows + if row.get("id") == args.id + and (not kind or row.get("type") == kind) + and (not plan_id or row.get("plan_id") == plan_id) + ] if not matches: raise SystemExit(f"Plan artifact not found: {args.id}") if len(matches) > 1: - raise SystemExit(f"Multiple plan artifacts match {args.id}; pass --kind plan|phase|task") + selectors = [] + if not kind: + selectors.append("--kind plan|phase|task") + if not plan_id: + selectors.append("--plan-id PLAN_ID") + guidance = f"; pass {' and '.join(selectors)}" if selectors else "; supplied selectors remain ambiguous" + raise SystemExit(f"Multiple plan artifacts match {args.id}{guidance}") row = matches[0] path = artifact_path_from_row(row, args) if args.status == "Completed" and row.get("type") == "task": diff --git a/tests/test_orchestration_execution_context.py b/tests/test_orchestration_execution_context.py index b185a12..3fcaed0 100644 --- a/tests/test_orchestration_execution_context.py +++ b/tests/test_orchestration_execution_context.py @@ -116,6 +116,73 @@ def workspace(tmp_path: Path) -> tuple[Path, Path, Path]: return root, spec, task +def test_set_spec_status_verified_compiles_task_brief(tmp_path: Path) -> None: + from specs import cmd_set_spec_status + + root, spec, task = workspace(tmp_path) + spec.write_text( + spec.read_text(encoding="utf-8").replace("status: verified\n", "status: draft\n"), + encoding="utf-8", + ) + + cmd_set_spec_status( + argparse.Namespace(project_root=str(root), workspace_root=None, id="spec-001", status="verified") + ) + target = build_task_brief(args(root, task)) + + assert target.is_file() + assert "status: verified" in spec.read_text(encoding="utf-8") + + +def test_build_task_brief_accepts_quoted_source_prose(tmp_path: Path) -> None: + root, spec, task = workspace(tmp_path) + spec.write_text( + spec.read_text(encoding="utf-8").replace( + "Never write outside the assigned files.", + 'Never write outside the "quoted" assigned files.', + ), + encoding="utf-8", + ) + + target = build_task_brief(args(root, task)) + + assert target.is_file() + brief, _ = execution_context._read_structured(target) + assert any( + 'Never write outside the "quoted" assigned files.' in constraint + for constraint in brief["task_brief"]["constraints"] + ) + + +def test_set_plan_status_uses_plan_id_to_disambiguate(tmp_path: Path) -> None: + from plans import cmd_set_plan_status + + root, _, task = workspace(tmp_path) + second = root / ".work-bundle/orchestration/plan/active/plan-002/phase-001/task-004.md" + second.parent.mkdir(parents=True) + second.write_text( + task.read_text(encoding="utf-8").replace("plan_id: plan-001\n", "plan_id: plan-002\n"), + encoding="utf-8", + ) + + cmd_set_plan_status( + argparse.Namespace( + project_root=str(root), + workspace_root=None, + id="task-004", + plan_id="plan-002", + status="In progress", + kind="task", + handoff=None, + ) + ) + + first_data, _ = execution_context._read_structured(task) + second_data, _ = execution_context._read_structured(second) + assert first_data.get("status") != "In progress" + assert second_data["status"] == "In progress" + + def args(root: Path, task: Path, **overrides: object) -> argparse.Namespace: values: dict[str, object] = { "project_root": str(root), diff --git a/tests/test_orchestration_workflow_contracts.py b/tests/test_orchestration_workflow_contracts.py index 7f3daa7..4c6b8ec 100644 --- a/tests/test_orchestration_workflow_contracts.py +++ b/tests/test_orchestration_workflow_contracts.py @@ -600,6 +600,38 @@ def test_archive_plan_ignores_foreign_plan_handoff_with_colliding_task_id(tmp_pa assert (tmp_path / ".work-bundle/orchestration/plan/active/plan-A.md").is_file() +def test_archive_plan_skips_unrelated_unparseable_executor_yaml(tmp_path: Path) -> None: + from plans import cmd_archive_plan + + _write_archive_plan(tmp_path, "plan-A") + _write_archive_plan(tmp_path, "plan-B") + handoff_root = tmp_path / ".work-bundle/orchestration/handoff/executor/active" + handoff_root.mkdir(parents=True, exist_ok=True) + (handoff_root / "foreign-block.yaml").write_text( + "id: foreign\n" + "type: executor-result\n" + "related:\n" + " plan: plan-A\n" + " task: task-001\n" + "summary: >\n" + " unsupported folded scalar\n", + encoding="utf-8", + ) + (handoff_root / "foreign-inline.yaml").write_text( + "id: foreign-inline\n" + "type: executor-result\n" + "related: {plan: plan-A, task: task-001}\n" + "summary: >\n" + " unsupported folded scalar\n", + encoding="utf-8", + ) + + cmd_archive_plan(argparse.Namespace(project_root=str(tmp_path), id="plan-B")) + + assert (tmp_path / ".work-bundle/orchestration/plan/archived/plan-B.md").is_file() + assert (tmp_path / ".work-bundle/orchestration/plan/active/plan-A.md").is_file() + + def test_archive_plan_ignores_task_only_handoff_with_ambiguous_task_id(tmp_path: Path) -> None: from plans import cmd_archive_plan From d930790a11f88cced0e101707934fd65ab3f17fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Sun, 30 Aug 2026 02:13:26 +0800 Subject: [PATCH 3/9] fix(toolkit): repair G2 and G3 root contracts --- .../assets/orchestration/contract/plan-v1.md | 4 ++-- scripts/orchestration/repository_preflight.py | 9 ++++++++- scripts/work-bundle/violations.py | 2 +- .../test_orchestration_repository_preflight.py | 8 ++++++++ tests/test_orchestration_workflow_contracts.py | 7 +++++++ tests/test_work_bundle_violation_evidence.py | 17 ++++++++++++++--- 6 files changed, 40 insertions(+), 7 deletions(-) diff --git a/references/assets/orchestration/contract/plan-v1.md b/references/assets/orchestration/contract/plan-v1.md index 38e1a01..637e495 100644 --- a/references/assets/orchestration/contract/plan-v1.md +++ b/references/assets/orchestration/contract/plan-v1.md @@ -157,5 +157,5 @@ If any generated artifact drifts from the source specification, omits required s ## 10. Related Specifications / Further Reading -- [Related specification](.work-bundle/orchestration/spec/active/...) -- [Carried durable-knowledge context, if any, from the source specification](.work-bundle/orchestration/spec/active/...) +- Related specification: `.work-bundle/orchestration/spec/active/...` +- Carried durable-knowledge context, if any: source specification front matter diff --git a/scripts/orchestration/repository_preflight.py b/scripts/orchestration/repository_preflight.py index 8915d35..bdf2eec 100644 --- a/scripts/orchestration/repository_preflight.py +++ b/scripts/orchestration/repository_preflight.py @@ -636,7 +636,14 @@ def repository_preflight( def _load_baselines(path: str | None) -> dict[str, list[str]]: if not path: return {} - value = json.loads(Path(path).read_text(encoding="utf-8")) + try: + raw = Path(path).read_text(encoding="utf-8") + except OSError as error: + raise SystemExit(f"Accepted baseline file could not be read: {path}: {error}") from error + try: + value = json.loads(raw) + except json.JSONDecodeError as error: + raise SystemExit(f"Accepted baseline must contain valid JSON: {path}: {error.msg}") from error if not isinstance(value, dict) or not all(isinstance(item, list) for item in value.values()): raise SystemExit("Accepted baseline must be a JSON object mapping repository paths to change lists.") return {str(Path(key).resolve()): [str(change) for change in changes] for key, changes in value.items()} diff --git a/scripts/work-bundle/violations.py b/scripts/work-bundle/violations.py index 3eeae54..4da0fdc 100644 --- a/scripts/work-bundle/violations.py +++ b/scripts/work-bundle/violations.py @@ -9,7 +9,7 @@ from core import out, read, work_bundle_config_root, write -REFERENCE_PATH = Path('references/wb-violation-evidence.yaml') +REFERENCE_PATH = Path(__file__).resolve().parents[2] / 'references' / 'wb-violation-evidence.yaml' class ViolationError(Exception): diff --git a/tests/test_orchestration_repository_preflight.py b/tests/test_orchestration_repository_preflight.py index 9e4eb76..1461557 100644 --- a/tests/test_orchestration_repository_preflight.py +++ b/tests/test_orchestration_repository_preflight.py @@ -37,6 +37,14 @@ def repository(tmp_path: Path, name: str = "repo") -> Path: return path +def test_malformed_accepted_baseline_is_typed(tmp_path: Path) -> None: + malformed = tmp_path / "baseline.json" + malformed.write_text("{not-json", encoding="utf-8") + + with pytest.raises(SystemExit, match="Accepted baseline.*valid JSON"): + preflight_module._load_baselines(str(malformed)) + + def write_project_metadata(project: Path, repo: Path, *, branch: str = "main", commit: str | None = None) -> None: head = commit if commit is not None else git(repo, "rev-parse", "HEAD") (project / ".work-bundle").mkdir(parents=True, exist_ok=True) diff --git a/tests/test_orchestration_workflow_contracts.py b/tests/test_orchestration_workflow_contracts.py index 4c6b8ec..0a6b63b 100644 --- a/tests/test_orchestration_workflow_contracts.py +++ b/tests/test_orchestration_workflow_contracts.py @@ -1583,6 +1583,13 @@ def test_overlapping_writes_are_not_parallelizable() -> None: assert "unsafe parallelization is explicitly blocked by dependency or scope evidence" in plan +def test_plan_contract_has_no_placeholder_markdown_links() -> None: + plan = read("references/assets/orchestration/contract/plan-v1.md") + + assert "](.work-bundle/orchestration/spec/active/...)" not in plan + assert "`.work-bundle/orchestration/spec/active/...`" in plan + + def test_dev_create_task_plan_tests_omit_heavy_orchestration_requirements() -> None: skill = read("skills/dev-create-task-plan/SKILL.md") assert "Do not import executor-result, `Completed`, review package, archive helper, or heavy Knowledge Base Update closure" in skill diff --git a/tests/test_work_bundle_violation_evidence.py b/tests/test_work_bundle_violation_evidence.py index 16b94c5..2c35d09 100644 --- a/tests/test_work_bundle_violation_evidence.py +++ b/tests/test_work_bundle_violation_evidence.py @@ -69,6 +69,17 @@ def test_violation_ensure_store_creates_directories(tmp_path: Path) -> None: assert not (Path.home() / ".work-bundle" / "violation" / "active" / "__pytest_marker__").exists() +def test_violation_catalog_is_cwd_independent(tmp_path: Path) -> None: + external_cwd = tmp_path / "external-cwd" + external_cwd.mkdir() + + result = run_wb(tmp_path, "violation-build-index", cwd=external_cwd) + + assert result.returncode == 0, result.stdout + result.stderr + assert "active:" in result.stdout + assert "archived:" in result.stdout + + def test_violation_create_evidence_writes_active_record_with_supplied_evidence_only(tmp_path: Path) -> None: cwd = prepare_cwd(tmp_path) supplied = cwd / "visible.txt" @@ -274,7 +285,7 @@ def test_violation_dispatcher_routes_all_commands_and_command_help(tmp_path: Pat assert f"usage: wb.py {command}" in result.stdout -def test_violation_catalog_is_read_from_reference(tmp_path: Path) -> None: +def test_violation_catalog_ignores_cwd_shadow(tmp_path: Path) -> None: custom_catalog = CATALOG.replace(" - p3\n", "") cwd = prepare_cwd(tmp_path, custom_catalog) @@ -296,9 +307,9 @@ def test_violation_catalog_is_read_from_reference(tmp_path: Path) -> None: cwd=cwd, ) - assert result.returncode == 1 + assert result.returncode == 0 payload = json.loads(result.stdout) - assert "invalid severity: p3" in payload["error"] + assert payload["status"] == "ok" def test_violation_evidence_rule_remains_minimal_storage_after_evaluation() -> None: From 9a38267bf16161e97d614cd2b82a3e95d41fcc7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Sun, 30 Aug 2026 02:24:38 +0800 Subject: [PATCH 4/9] fix(toolkit): repair G4 doctor contracts --- scripts/work-bundle/control_plane.py | 24 ++++++-- scripts/work-bundle/project.py | 12 +++- scripts/work-bundle/workspace_resources.py | 4 +- skills/wb-initialize-project/SKILL.md | 10 ++-- tests/test_control_plane_v4.py | 14 +++++ tests/test_dev_skill_contracts.py | 12 ++++ tests/test_project_initialization.py | 68 +++++++++++++++++++++- 7 files changed, 131 insertions(+), 13 deletions(-) diff --git a/scripts/work-bundle/control_plane.py b/scripts/work-bundle/control_plane.py index 4008637..7a366f0 100644 --- a/scripts/work-bundle/control_plane.py +++ b/scripts/work-bundle/control_plane.py @@ -1763,7 +1763,12 @@ def _materialize_workspace_root(remote: str, workspace_root: Path, default_branc def _attach( - workspace_root: Path, materialize: str, repository_paths: dict[str, Path], apply: bool + workspace_root: Path, + materialize: str, + repository_paths: dict[str, Path], + apply: bool, + *, + create_script_index: bool = True, ) -> tuple[dict[str, object], int]: metadata_path = workspace_root / ".work-bundle/project.yaml" text = read(metadata_path) @@ -1943,7 +1948,10 @@ def rollback_attach() -> None: raise ControlPlaneError("WB_CONTROL_PLANE_TRANSACTION_FAILED") from exc if apply: try: - changed = ensure_workspace_resources(workspace_root) + changed = ensure_workspace_resources( + workspace_root, + create_script_index=create_script_index, + ) for relative in (".work-bundle/git", ".work-bundle/runtime", ".work-bundle/orchestration/execution-state"): path = workspace_root / relative if not path.exists(): @@ -1987,7 +1995,9 @@ def rollback_attach() -> None: final_failures: list[str] = [] if apply: final_failures.extend(_portable_failures(read(metadata_path))) - required_resources = ("script/index.yaml", "credentials/credentials.yaml", "AGENTS.md") + required_resources = ["credentials/credentials.yaml", "AGENTS.md"] + if create_script_index: + required_resources.insert(0, "script/index.yaml") final_failures.extend( f"WB_CONTROL_PLANE_RESOURCE_MISSING:{relative}" for relative in required_resources @@ -2108,7 +2118,13 @@ def cmd_doctor_workspace(args: list[str], *, command_name: str = "doctor-workspa missing_required.append(issue) if parsed.repair and not portable_failures: try: - result, code = _attach(workspace_root, "none", {}, True) + result, code = _attach( + workspace_root, + "none", + {}, + True, + create_script_index=False, + ) except ControlPlaneError as exc: local_failures.append(exc.code) result, code = {"status": "issues-found"}, 1 diff --git a/scripts/work-bundle/project.py b/scripts/work-bundle/project.py index 1dcc71b..987979f 100644 --- a/scripts/work-bundle/project.py +++ b/scripts/work-bundle/project.py @@ -927,7 +927,9 @@ def sync_agents_managed_section(project_root: Path, dry_run: bool = False, force warnings.append('multiple-managed-sections-consolidated') agents_changed = next_text != existing - metadata_changed = agents_status != 'unchanged' or _metadata_agents_checksum(metadata_path) != checksum + if existing and not agents_changed: + agents_status = 'unchanged' + metadata_changed = _metadata_agents_checksum(metadata_path) != checksum if agents_changed: changed_files.append(str(agents_path)) if metadata_changed: @@ -1278,10 +1280,18 @@ def repair_project(project_root: Path, force: bool = False, return_details: bool _yaml_scalar(current_metadata, 'metadata_version') == '3' and _yaml_scalar(current_metadata, 'workspace_mode') == 'multi-repository' ): + registry_entry_data, _ = find_registry_entry(project_root) changed = ensure_project_layout(project_root) changed.extend(ensure_workspace_resources(project_root)) agents_result = sync_agents_managed_section(project_root, force=force) changed.extend(str(path) for path in agents_result.get('changed_files', [])) + if registry_entry_data is not None: + metadata_changed, refreshed_path, _ = sync_project_metadata_from_registry_entry( + registry_entry_data, + fallback_root=project_root, + ) + if metadata_changed: + changed.append(str(refreshed_path)) changed = sorted(set(changed)) if return_details: return changed, agents_result diff --git a/scripts/work-bundle/workspace_resources.py b/scripts/work-bundle/workspace_resources.py index db3b6fc..74435c4 100644 --- a/scripts/work-bundle/workspace_resources.py +++ b/scripts/work-bundle/workspace_resources.py @@ -43,11 +43,11 @@ CREDENTIAL_TEMPLATE = 'version: 1\ncredentials: []\n' -def ensure_workspace_resources(workspace_root: Path) -> list[str]: +def ensure_workspace_resources(workspace_root: Path, *, create_script_index: bool = True) -> list[str]: changed: list[str] = [] script_index = workspace_root / 'script' / 'index.yaml' credential_file = workspace_root / 'credentials' / 'credentials.yaml' - if not script_index.exists(): + if create_script_index and not script_index.exists(): script_index.parent.mkdir(parents=True, exist_ok=True) script_index.write_text(SCRIPT_INDEX_TEMPLATE, encoding='utf-8') changed.append(str(script_index)) diff --git a/skills/wb-initialize-project/SKILL.md b/skills/wb-initialize-project/SKILL.md index f7f7533..f192e5c 100644 --- a/skills/wb-initialize-project/SKILL.md +++ b/skills/wb-initialize-project/SKILL.md @@ -32,11 +32,11 @@ Invoke project lifecycle behavior only through `python3 scripts/wb.py` dispatche | Mode | Command | |---|---| -| Initialize | `init-project --mode [--workspace-root ] [--project-root ] [--name ] [--force] [--dry-run] [--disable-work-bundle-git] [--create-project-skill-override]` | -| Doctor | `doctor-project [--workspace-root ] [--project-root ] [--repair] [--force]` | +| Initialize | `init-project --mode [--name ] [--force] [--dry-run] [--disable-work-bundle-git] [--create-project-skill-override]` | +| Doctor | `doctor-project [--repair] [--force]` | | Inspect only | `show-project [--workspace-root | --project-root ]` | -| Strict validate | `validate-project [--workspace-root ] [--project-root ] [--dry-run]` | -| Register only | `register-project [--workspace-root ] [--project-root ] [--name ]` | +| Strict validate | `validate-project [--dry-run]` | +| Register only | `register-project [--name ]` | | Inspect metadata migration | `migrate-project [--name ] --dry-run` | | Apply metadata migration | `migrate-project [--name ] [--force] [--accepted-proposal-id ] --apply` | | Inspect portable-control migration | `migrate-control-plane [--repository-remote =] --dry-run` | @@ -52,7 +52,7 @@ Invoke project lifecycle behavior only through `python3 scripts/wb.py` dispatche `initialize-project` remains a compatibility alias for `init-project`; prefer `init-project` in new instructions. -Existing command names and `--project-root` remain supported for single-repository projects. New creation must reject a missing or contradictory mode/root combination rather than silently infer topology. Single-repository mode is current and fully supported, not legacy or transitional. +The explicit `--workspace-root` and `--project-root` selectors remain available only on commands whose live help lists them, such as `show-project` and project-scoped `set-prefer-subagent`. New creation must reject a missing or contradictory mode/root combination rather than silently infer topology. Single-repository mode is current and fully supported, not legacy or transitional. **Portable v4 migration guardrails:** before `migrate-control-plane`, load every applicable rule body in full, including project context, registry authority, lifecycle, repository boundary, security exclusion, and violation routing. Do not sample those rules by keyword. Resolve canonical remotes from explicit `--repository-remote` input, registry locator authority, and the live origin chain. When authoritative network remotes conflict, stop and ask the user which remote is canonical; rerun the exact dry-run with `--repository-remote` after the decision. During this workflow, do not edit the project registry directly and do not change an external repository's Git config. `show-project`, `validate-project`, and `doctor-project` route metadata-version-4 workspaces to v4 control-plane validation; repair must never rewrite portable v4 metadata into v3 shape. diff --git a/tests/test_control_plane_v4.py b/tests/test_control_plane_v4.py index 0da6b4e..8029c9f 100644 --- a/tests/test_control_plane_v4.py +++ b/tests/test_control_plane_v4.py @@ -1161,6 +1161,20 @@ def test_doctor_repair_preserves_existing_and_unknown_local_binding_fields(tmp_p assert str(checkout.resolve()) in after +def test_doctor_workspace_repair_does_not_create_script_index(tmp_path: Path) -> None: + config = config_root(tmp_path / "config-root") + workspace, _, _ = make_v3_workspace(tmp_path / "fixture") + migrate(config, workspace) + script_index = workspace / "script/index.yaml" + script_index.unlink(missing_ok=True) + + repaired = run_wb(config, "doctor-workspace", str(workspace), "--repair") + + assert repaired.returncode == 0, repaired.stdout + repaired.stderr + assert not script_index.exists() + assert (workspace / "credentials/credentials.yaml").is_file() + + def test_attach_rejects_second_active_materialization_on_same_device(tmp_path: Path) -> None: config = config_root(tmp_path / "config-root") workspace_a, _, _ = make_v3_workspace(tmp_path / "fixture") diff --git a/tests/test_dev_skill_contracts.py b/tests/test_dev_skill_contracts.py index d8f5906..726a854 100644 --- a/tests/test_dev_skill_contracts.py +++ b/tests/test_dev_skill_contracts.py @@ -11,6 +11,18 @@ def skill_text(name: str) -> str: return (REPO_ROOT / "skills" / name / "SKILL.md").read_text(encoding="utf-8") +def test_wb_initialize_skill_matches_live_cli() -> None: + text = skill_text("wb-initialize-project") + + assert "`init-project --mode [--workspace-root" not in text + assert "`doctor-project [--workspace-root" not in text + assert "`validate-project [--workspace-root" not in text + assert "`init-project --mode [--name ]" in text + assert "`doctor-project [--repair] [--force]`" in text + assert "`validate-project [--dry-run]`" in text + assert "Existing command names and `--project-root` remain supported for single-repository projects." not in text + + def test_semantic_convergence_contract_is_bounded_and_reports_compact_result() -> None: text = skill_text("dev-semantic-convergence") diff --git a/tests/test_project_initialization.py b/tests/test_project_initialization.py index 52e1191..eb18541 100644 --- a/tests/test_project_initialization.py +++ b/tests/test_project_initialization.py @@ -634,6 +634,48 @@ def test_doctor_repair_does_not_restore_legacy_root_rule_index(tmp_path: Path) - assert not legacy_rule_index.exists() +def test_doctor_repair_creates_missing_project_rule_index(tmp_path: Path) -> None: + config_root, project = _init_fixture_project(tmp_path) + current_rule_index = project / ".work-bundle/rules/index.yaml" + current_rule_index.unlink() + + repaired = run_wb(config_root, "doctor-project", str(project), "--repair") + + assert repaired.returncode == 0, repaired.stdout + repaired.stderr + assert current_rule_index.read_text(encoding="utf-8") == "rules: []\n" + assert not (project / "rules/index.yaml").exists() + + +def test_agents_force_checksum_only_reports_unchanged(tmp_path: Path) -> None: + config_root, project = _init_fixture_project(tmp_path) + agents_path = project / "AGENTS.md" + metadata_path = project / ".work-bundle/project.yaml" + agents_before = agents_path.read_bytes() + metadata_lines = metadata_path.read_text(encoding="utf-8").splitlines() + checksum_index = next( + index for index, line in enumerate(metadata_lines) if "template_checksum_sha256:" in line + ) + metadata_lines[checksum_index] = ' template_checksum_sha256: "stale"' + metadata_path.write_text("\n".join(metadata_lines) + "\n", encoding="utf-8") + + refreshed = run_wb( + config_root, + "init-project", + str(project), + "--mode", + "single-repository", + "--name", + "demo", + "--force", + ) + + assert refreshed.returncode == 0, refreshed.stdout + refreshed.stderr + data = json.loads(refreshed.stdout) + assert data["agents_status"] == "unchanged" + assert data["agents_sync"]["changed_files"] == [str(metadata_path)] + assert agents_path.read_bytes() == agents_before + + def test_init_force_overwrites_init_managed_templates_only(tmp_path: Path) -> None: config_root, project = _init_fixture_project(tmp_path) agents_path = project / "AGENTS.md" @@ -655,7 +697,7 @@ def test_init_force_overwrites_init_managed_templates_only(tmp_path: Path) -> No assert str(agents_path) in force_data["changed_files"] assert force_data["agents_status"] == "updated" assert force_data["agents_sync"]["template_checksum_sha256"] - assert force_data["agents_sync"]["changed_files"] == [str(project / ".work-bundle/project.yaml"), str(agents_path)] + assert force_data["agents_sync"]["changed_files"] == [str(agents_path)] assert agents_path.read_text(encoding="utf-8").startswith(custom_agents.rstrip() + "\n\n") assert role_path.read_text(encoding="utf-8") == custom_role @@ -1285,6 +1327,30 @@ def test_doctor_repair_refreshes_all_registered_checkout_baselines(tmp_path: Pat assert repair_data["project_source_repositories"][0]["checkout_role"] == "truth" +def test_doctor_force_refreshes_v3_member_observations(tmp_path: Path) -> None: + config_root, project = _init_fixture_project(tmp_path) + metadata_path = project / ".work-bundle/project.yaml" + metadata_text = metadata_path.read_text(encoding="utf-8") + metadata_text = metadata_text.replace( + "workspace_mode: single-repository", "workspace_mode: multi-repository" + ) + metadata_lines = metadata_text.splitlines() + baseline_index = next( + index for index, line in enumerate(metadata_lines) if line.strip().startswith("observed_head:") + ) + metadata_lines[baseline_index] = f" observed_head: {'0' * 40}" + metadata_path.write_text("\n".join(metadata_lines) + "\n", encoding="utf-8") + (project / "README.md").write_text("# Advanced member\n", encoding="utf-8") + git(project, "add", "README.md") + git(project, "commit", "-m", "chore: advance member") + actual_head = git(project, "rev-parse", "HEAD") + + repaired = run_wb(config_root, "doctor-project", str(project), "--repair", "--force") + + assert repaired.returncode == 0, repaired.stdout + repaired.stderr + assert f"observed_head: {actual_head}" in metadata_path.read_text(encoding="utf-8") + + def test_migrate_project_upgrades_v1_metadata_and_preserves_unknown_fields(tmp_path: Path) -> None: config_root, project = _init_fixture_project(tmp_path) metadata_path = project / ".work-bundle/project.yaml" From 80fd43d4c3122ffb0119ea4a37c6a9683f724dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Sun, 30 Aug 2026 02:35:15 +0800 Subject: [PATCH 5/9] fix(toolkit): repair G5 and G6 contracts --- scripts/work-bundle/control_plane.py | 30 ++++++++++++++++ scripts/work-bundle/project.py | 11 +++--- tests/test_control_plane_v4.py | 43 ++++++++++++++++++++++ tests/test_project_initialization.py | 54 ++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 4 deletions(-) diff --git a/scripts/work-bundle/control_plane.py b/scripts/work-bundle/control_plane.py index 7a366f0..5ceb67a 100644 --- a/scripts/work-bundle/control_plane.py +++ b/scripts/work-bundle/control_plane.py @@ -1083,6 +1083,26 @@ def _publish_evidence(control: Path, payload: dict[str, object]) -> Path: return path +def _control_plane_gitlink_paths(control: Path) -> list[str]: + paths = { + str(marker.parent.relative_to(control)).replace("\\", "/") + for marker in control.rglob(".git") + if marker.parent != control and (marker.is_dir() or marker.is_file()) + } + if (control / ".git").exists(): + result = subprocess.run( + ["git", "-C", str(control), "ls-files", "--stage", "-z"], + check=False, + capture_output=True, + ) + if result.returncode == 0: + for entry in result.stdout.split(b"\0"): + if not entry.startswith(b"160000 ") or b"\t" not in entry: + continue + paths.add(entry.split(b"\t", 1)[1].decode("utf-8", errors="surrogateescape")) + return sorted(paths) + + def cmd_publish_control_plane(args: list[str]) -> int: parser = argparse.ArgumentParser(prog="wb.py publish-control-plane") parser.add_argument("workspace_root") @@ -1109,6 +1129,16 @@ def cmd_publish_control_plane(args: list[str]) -> int: if not remote: out({"command": "publish-control-plane", "status": "issues-found", "failure_code": "WB_CONTROL_PLANE_REMOTE_REQUIRED", "changed_files": []}) return 1 + gitlink_paths = _control_plane_gitlink_paths(control) + if gitlink_paths: + out({ + "command": "publish-control-plane", + "status": "issues-found", + "failure_code": "WB_CONTROL_PLANE_GITLINK_FORBIDDEN", + "gitlink_paths": gitlink_paths, + "changed_files": [], + }) + return 1 if parsed.dry_run: out({"command": "publish-control-plane", "status": "passed", "dry_run": True, "remote": remote, "changed_files": [], "git_actions": ["init", "configure-origin", "commit", "push"]}) return 0 diff --git a/scripts/work-bundle/project.py b/scripts/work-bundle/project.py index 987979f..acbe427 100644 --- a/scripts/work-bundle/project.py +++ b/scripts/work-bundle/project.py @@ -1895,7 +1895,7 @@ def assess_legacy_topology( ) registry_sources = registry_sources if isinstance(registry_sources, list) else [] - def identities(sources: object) -> list[dict[str, str]]: + def identities(sources: object, identity_kind: str) -> list[dict[str, str]]: result: list[dict[str, str]] = [] if not isinstance(sources, list): return result @@ -1906,11 +1906,14 @@ def identities(sources: object) -> list[dict[str, str]]: result.append({ 'id': str(source.get('id') or ''), 'path': str(Path(str(raw_path)).expanduser().resolve()) if raw_path else '', + 'kind': identity_kind, }) return result - metadata_identities = identities(metadata_sources) - registry_identities = identities([*registry_sources, *(registry_origin_data or [])]) + metadata_identities = identities(metadata_sources, 'member') + registry_member_identities = identities(registry_sources, 'member') + registry_origin_identities = identities(registry_origin_data or [], 'origin') + registry_identities = [*registry_member_identities, *registry_origin_identities] registry_identities = [ dict(identity) for identity in { @@ -1920,7 +1923,7 @@ def identities(sources: object) -> list[dict[str, str]]: ] conflicts: list[str] = [] by_id: dict[str, set[str]] = {} - for identity in [*metadata_identities, *registry_identities]: + for identity in [*metadata_identities, *registry_member_identities]: if identity['id'] and identity['path']: by_id.setdefault(identity['id'], set()).add(identity['path']) for source_id, paths in sorted(by_id.items()): diff --git a/tests/test_control_plane_v4.py b/tests/test_control_plane_v4.py index 8029c9f..20ef73a 100644 --- a/tests/test_control_plane_v4.py +++ b/tests/test_control_plane_v4.py @@ -1550,6 +1550,49 @@ def test_publish_existing_dirty_control_plane_fails_closed_without_mutation(tmp_ assert evidence["recovery_required"] is True +def test_publish_rejects_nested_gitlink_before_staging(tmp_path: Path) -> None: + config = config_root(tmp_path / "config-root") + source_remote, _, _ = make_remote(tmp_path / "source-fixture", "source") + workspace = tmp_path / "workspace" + assert run_wb( + config, + "init-workspace", + str(workspace), + "--slug", + "demo", + "--repository", + f"source-main={source_remote}", + "--apply", + ).returncode == 0 + control = workspace / ".work-bundle" + nested = control / "knowledge/notes/nested-repository" + nested.mkdir(parents=True) + git(nested, "init", "-q", "-b", "main") + git(nested, "config", "user.email", "test@example.com") + git(nested, "config", "user.name", "Test") + (nested / "README.md").write_text("nested\n", encoding="utf-8") + git(nested, "add", "README.md") + git(nested, "commit", "-q", "-m", "nested") + remote = tmp_path / "control.git" + subprocess.run(["git", "init", "--bare", "-q", str(remote)], check=True) + + failed = run_wb( + config, + "publish-control-plane", + str(workspace), + "--remote", + str(remote), + "--apply", + ) + + assert failed.returncode == 1 + data = json.loads(failed.stdout) + assert data["failure_code"] == "WB_CONTROL_PLANE_GITLINK_FORBIDDEN" + assert data["gitlink_paths"] == ["knowledge/notes/nested-repository"] + assert not (control / ".git").exists() + assert (nested / ".git").is_dir() + + def test_publish_failed_git_snapshot_fails_closed_without_mutation(tmp_path: Path) -> None: config = config_root(tmp_path / "config-root") source_remote, _, _ = make_remote(tmp_path / "source-fixture", "source") diff --git a/tests/test_project_initialization.py b/tests/test_project_initialization.py index eb18541..bd850ec 100644 --- a/tests/test_project_initialization.py +++ b/tests/test_project_initialization.py @@ -1440,6 +1440,60 @@ def test_migrate_project_routes_registry_multi_source_to_workspace_migration(tmp assert metadata_path.read_bytes() == before +def test_migrate_project_accepts_same_id_origin_and_member_paths(tmp_path: Path) -> None: + config_root, project = _init_fixture_project(tmp_path) + origin = tmp_path / "origin-locator" + origin.mkdir() + registry_path = config_root / "registry" / "projects.yaml" + registry_path.write_text( + "\n".join( + [ + "projects:", + " - slug: demo", + " name: demo", + f" work_bundle_root: {project.resolve() / '.work-bundle'}", + f" knowledge_root: {project.resolve() / '.work-bundle' / 'knowledge'}", + " aliases: []", + " repository_origins:", + " - id: demo-main", + f" origin_path: {origin.resolve()}", + " git_repository: true", + " source_repositories:", + " - id: demo-main", + f" path: {project.resolve()}", + " work_dir: true", + ' remote: ""', + " git_repository: true", + " status: active", + " updated_at: 2026-01-01", + "", + ] + ), + encoding="utf-8", + ) + metadata_path = project / ".work-bundle/project.yaml" + metadata_path.write_text( + "\n".join( + [ + "metadata_version: 1", + "authority: canonical", + f"project_root: {project.resolve()}", + "industry: legacy", + "", + ] + ), + encoding="utf-8", + ) + + migrated = run_wb(config_root, "migrate-project", str(project), "--dry-run", "--name", "demo") + + assert migrated.returncode == 1 + data = json.loads(migrated.stdout) + assert data["mode"] == "multi-repository-migration-required" + assert data["topology_assessment"]["conflicts"] == [] + assert data["topology_assessment"]["required_command"] == "migrate-to-multi-repository" + + def _seed_committed_repository(path: Path) -> None: path.mkdir() git(path, "init", "-q", "-b", "main") From f3445ffee8e178c9b70bb27ec845c7bb76e4a2b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Sun, 30 Aug 2026 02:41:30 +0800 Subject: [PATCH 6/9] fix(orchestration): align doctor review anchors --- scripts/orchestration/doctor.py | 3 ++- tests/test_orchestration_skill_rule_boundary.py | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/orchestration/doctor.py b/scripts/orchestration/doctor.py index 4398be7..7e2e669 100644 --- a/scripts/orchestration/doctor.py +++ b/scripts/orchestration/doctor.py @@ -218,7 +218,8 @@ def cmd_doctor(args: argparse.Namespace) -> None: [ "Disposable task briefs, review packages, and lightweight development plans", "build-task-brief", - "independent dev-code-review", + "optional task review", + "acceptance_review.required: true", "A task becomes `Completed` only when", "Final workflow audit", ], diff --git a/tests/test_orchestration_skill_rule_boundary.py b/tests/test_orchestration_skill_rule_boundary.py index 4806b99..ea57ea6 100644 --- a/tests/test_orchestration_skill_rule_boundary.py +++ b/tests/test_orchestration_skill_rule_boundary.py @@ -170,6 +170,14 @@ def test_workflow_makes_task_review_optional_on_the_chain() -> None: assert "-> independent dev-code-review" not in text +def test_orchestration_doctor_uses_optional_review_anchors() -> None: + text = (REPO_ROOT / "scripts/orchestration/doctor.py").read_text(encoding="utf-8") + + assert '"optional task review"' in text + assert '"acceptance_review.required: true"' in text + assert '"independent dev-code-review"' not in text + + def test_doctor_execute_path_requires_validate_not_universal_review() -> None: text = read("scripts/orchestration/doctor.py") start = text.index('skill_root / "orch-execute-plan" / "SKILL.md"') From 48c50b97595755242750b8ac089aa549ed174bbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Sun, 30 Aug 2026 09:42:47 +0800 Subject: [PATCH 7/9] test(session-start): expect truthful AGENTS status --- tests/test_session_start_hook.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_session_start_hook.py b/tests/test_session_start_hook.py index c74a70b..ef9feb9 100644 --- a/tests/test_session_start_hook.py +++ b/tests/test_session_start_hook.py @@ -164,7 +164,7 @@ def test_session_start_repairs_stale_metadata_without_rewriting_agents(tmp_path: result = run_wb(config_root, "session-start", "--project-root", str(project), "--json") assert result.returncode == 0, result.stdout + result.stderr data = json.loads(result.stdout) - assert data["agents_status"] == "updated" + assert data["agents_status"] == "unchanged" assert data["changed_files"] == [str(metadata_path)] assert agents_path.read_text(encoding="utf-8") == agents_before assert data["project_agents_checksum"].startswith("sha256:") From d418123753e22ea029cc6b07fc638ed80b7b2921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Sun, 30 Aug 2026 10:12:06 +0800 Subject: [PATCH 8/9] fix(wor-59): close review contract gaps --- rules/orchestration/orch-review-completion.md | 6 ++++ scripts/orchestration/dispatcher.py | 5 ++- scripts/work-bundle/core.py | 2 +- skills/orch-review-plan/SKILL.md | 10 ++++++ skills/wb-initialize-project/SKILL.md | 2 +- tests/test_dev_skill_contracts.py | 5 +-- ...test_orchestration_repository_preflight.py | 18 ++++++++++ .../test_orchestration_skill_rule_boundary.py | 34 +++++++++++++++++++ 8 files changed, 77 insertions(+), 5 deletions(-) diff --git a/rules/orchestration/orch-review-completion.md b/rules/orchestration/orch-review-completion.md index 4ddcd44..5620cea 100644 --- a/rules/orchestration/orch-review-completion.md +++ b/rules/orchestration/orch-review-completion.md @@ -32,6 +32,12 @@ Keep final review focused on whether the WorkBundle workflow completed correctly - Require the execution-evidence-driven final Knowledge Base Update disposition to be `completed` or `not-needed` before archive; archive remains blocked while promoted closure lacks validated keep-summarizing return evidence. - Create or require plan repair only for a decomposition defect, and specification repair only for a requirement, design, or authority defect. - Complete allowed commit, applicable CodeGraph sync, metadata update, archive, and index refresh only after all gates allow finalization. +- When a post-execution runtime or UI defect is classified, or the accepted specification or plan explicitly claims runtime acceptance of a user-visible invariant, require a `RuntimeVerificationClassificationV1` before archive. Evaluate the original user request and accepted specification before the plan, task acceptance criteria, executor handoffs, produced commits, and execution-introduced behavior. +- Require `RuntimeVerificationClassificationV1` to carry `classification`, `invariant_trace`, `negative_evidence`, and `owning_repair`. Accepted classes are `execution_introduced_bug`, `implementation_gap`, `new_feature`, and `uncovered_fixture`. +- For an accepted-invariant `execution_introduced_bug` or `implementation_gap`, require `invariant_trace` to connect original requirement, specification invariant, owning plan task or acceptance criterion, changed commit, materialization, and runtime or UI proof. Passing component or unit tests alone is insufficient for this triggered runtime claim; do not impose a universal browser or UI gate when neither trigger applies. +- Permit `new_feature` or `uncovered_fixture` with an empty `invariant_trace` only when `negative_evidence` proves no matching original user request or accepted specification invariant and no plan, handoff, or produced-commit contradiction. +- Route `owning_repair` to the first broken artifact: task or acceptance criterion present plus implementation miss means task repair and re-review; accepted specification present plus plan omission means plan repair and resume from the owning step; original-request invariant omitted or contradicted by the accepted specification means specification repair. Only after those cases are excluded may a residual class stand. +- Keep classification agent-owned and evidence-linked. A helper may require and structurally validate the record but must not decide the semantic class. ## Must Not diff --git a/scripts/orchestration/dispatcher.py b/scripts/orchestration/dispatcher.py index ad4f096..66ef208 100644 --- a/scripts/orchestration/dispatcher.py +++ b/scripts/orchestration/dispatcher.py @@ -33,7 +33,10 @@ def build_parser() -> argparse.ArgumentParser: repository_preflight.add_argument("--task-file", action="append", default=[]) repository_preflight.add_argument("--reference", action="append", default=[]) repository_preflight.add_argument("--repository", action="append", default=[]) - repository_preflight.add_argument("--accepted-baseline") + repository_preflight.add_argument( + "--accepted-baseline", + help="JSON file containing accepted repository baselines that reconcile observed branch or commit drift", + ) repository_preflight.set_defaults(func=cmd_repository_preflight) task_brief = sub.add_parser("build-task-brief", parents=[parent]) task_brief.add_argument("--task", required=True) diff --git a/scripts/work-bundle/core.py b/scripts/work-bundle/core.py index a45155e..064dfa0 100644 --- a/scripts/work-bundle/core.py +++ b/scripts/work-bundle/core.py @@ -34,7 +34,7 @@ RULES = ['repository-boundary', 'lifecycle-authority', 'skill-registry', 'domain-profile', 'doctor-readonly', 'runtime-artifact-format', 'security-exclusion'] CLI_HELP_EPILOG = '''Canonical consolidated command surface: - init-project --mode + init-project --mode [--workspace-root ] show-project [--workspace-root | --project-root ] validate-project --dry-run doctor-project [--repair] [--force] diff --git a/skills/orch-review-plan/SKILL.md b/skills/orch-review-plan/SKILL.md index cd7c5c3..02b3549 100644 --- a/skills/orch-review-plan/SKILL.md +++ b/skills/orch-review-plan/SKILL.md @@ -29,6 +29,16 @@ Verify: - approved `ks-*` return evidence exists when durable knowledge was required; - allowed commit, applicable CodeGraph sync, metadata update, archive, and index refresh completed or are explicitly not applicable. +## Runtime verification classification + +When a runtime or UI defect is reported after execution, or an accepted specification or plan explicitly claims runtime acceptance of a user-visible invariant, record a `RuntimeVerificationClassificationV1` before archive or residual feature routing. Review the authority chain in order: original user request and accepted specification; compiled plan and task acceptance criteria; executor handoffs and produced commits; then execution-introduced behavior. + +The record contains `classification`, `invariant_trace`, `negative_evidence`, and `owning_repair`. `classification` is one of `execution_introduced_bug`, `implementation_gap`, `new_feature`, or `uncovered_fixture`. For `execution_introduced_bug` and `implementation_gap` tied to an accepted invariant, `invariant_trace` must connect original requirement, specification invariant, owning plan task or acceptance criterion, changed commit, materialization, and runtime or UI proof. Passing component or unit tests alone is insufficient for this triggered runtime claim. This is not a universal browser or UI gate for plans without either trigger. + +`new_feature` or `uncovered_fixture` may have an empty `invariant_trace` only when `negative_evidence` records no matching original request or accepted specification invariant and no contradiction in the plan, handoff, or produced commit. Route `owning_repair` to the first broken artifact: an invariant already present in the task or acceptance criterion requires task repair and re-review; a specification invariant omitted from plan decomposition requires plan repair and resume from the owning step; an original-request invariant omitted or contradicted by the specification requires specification repair. Only after those routes are excluded may a residual class stand. + +Classification remains agent-owned and evidence-linked. A helper may require the record and validate its structure, but must not decide the semantic class. This audit must not expand into a broad source-quality reread or create another implementation-review agent. + Use project files only for bounded identity and finalization evidence. Do not broadly inspect source to decide code quality, redo task review, reread implementation for code quality, repair source/tests, or start another implementation-review agent for plan-level acceptance. ## Typed routing diff --git a/skills/wb-initialize-project/SKILL.md b/skills/wb-initialize-project/SKILL.md index f192e5c..ebd214a 100644 --- a/skills/wb-initialize-project/SKILL.md +++ b/skills/wb-initialize-project/SKILL.md @@ -32,7 +32,7 @@ Invoke project lifecycle behavior only through `python3 scripts/wb.py` dispatche | Mode | Command | |---|---| -| Initialize | `init-project --mode [--name ] [--force] [--dry-run] [--disable-work-bundle-git] [--create-project-skill-override]` | +| Initialize | `init-project --mode [--workspace-root ] [--name ] [--force] [--dry-run] [--disable-work-bundle-git] [--create-project-skill-override]` | | Doctor | `doctor-project [--repair] [--force]` | | Inspect only | `show-project [--workspace-root | --project-root ]` | | Strict validate | `validate-project [--dry-run]` | diff --git a/tests/test_dev_skill_contracts.py b/tests/test_dev_skill_contracts.py index 726a854..0138a2e 100644 --- a/tests/test_dev_skill_contracts.py +++ b/tests/test_dev_skill_contracts.py @@ -13,11 +13,12 @@ def skill_text(name: str) -> str: def test_wb_initialize_skill_matches_live_cli() -> None: text = skill_text("wb-initialize-project") + epilog = (REPO_ROOT / "scripts" / "work-bundle" / "core.py").read_text(encoding="utf-8") - assert "`init-project --mode [--workspace-root" not in text + assert "`init-project --mode [--workspace-root ]" in text + assert "init-project --mode [--workspace-root ]" in epilog assert "`doctor-project [--workspace-root" not in text assert "`validate-project [--workspace-root" not in text - assert "`init-project --mode [--name ]" in text assert "`doctor-project [--repair] [--force]`" in text assert "`validate-project [--dry-run]`" in text assert "Existing command names and `--project-root` remain supported for single-repository projects." not in text diff --git a/tests/test_orchestration_repository_preflight.py b/tests/test_orchestration_repository_preflight.py index 1461557..3fc1375 100644 --- a/tests/test_orchestration_repository_preflight.py +++ b/tests/test_orchestration_repository_preflight.py @@ -512,3 +512,21 @@ def test_cli_outputs_machine_usable_json(tmp_path: Path) -> None: payload = json.loads(result.stdout) assert payload["repository_preflight"]["status"] == "passed" assert payload["repository_preflight"]["repositories"][0]["status"] == "clean" + + +def test_repository_preflight_help_describes_accepted_baseline_contract() -> None: + result = subprocess.run( + [ + sys.executable, + str(REPO_ROOT / "scripts" / "orch.py"), + "repository-preflight", + "--help", + ], + check=True, + capture_output=True, + text=True, + ) + + assert "--accepted-baseline" in result.stdout + assert "JSON file" in result.stdout + assert "accepted repository baselines" in result.stdout diff --git a/tests/test_orchestration_skill_rule_boundary.py b/tests/test_orchestration_skill_rule_boundary.py index ea57ea6..41fd95e 100644 --- a/tests/test_orchestration_skill_rule_boundary.py +++ b/tests/test_orchestration_skill_rule_boundary.py @@ -154,10 +154,44 @@ def test_final_review_is_workflow_audit_not_code_review() -> None: "accepted `update`, `supersede`, or `reclassify`", "rejected task dispositions", "archive remains blocked", + "RuntimeVerificationClassificationV1", + "invariant_trace", + "negative_evidence", + "owning_repair", + "execution_introduced_bug", + "implementation_gap", + "new_feature", + "uncovered_fixture", ]: assert token in text +def test_runtime_verification_classification_contract_routes_the_first_broken_artifact() -> None: + for relative in [ + "skills/orch-review-plan/SKILL.md", + "rules/orchestration/orch-review-completion.md", + ]: + text = read(relative) + for token in [ + "RuntimeVerificationClassificationV1", + "original user request", + "accepted specification", + "invariant_trace", + "negative_evidence", + "execution_introduced_bug", + "implementation_gap", + "new_feature", + "uncovered_fixture", + "owning_repair", + "task repair", + "plan repair", + "specification repair", + ]: + assert token in text, f"{relative}: {token}" + assert "unit tests alone" in text + assert "must not decide the semantic class" in text + + def test_workflow_makes_task_review_optional_on_the_chain() -> None: text = read("references/assets/orchestration/workflow.md") for token in [ From 99b8315af004231fa9f56117022c8f4512699511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Sun, 30 Aug 2026 10:25:31 +0800 Subject: [PATCH 9/9] fix(wor-59): complete runtime review contract --- rules/orchestration/orch-review-completion.md | 3 ++- skills/orch-review-plan/SKILL.md | 4 +++- tests/test_orchestration_skill_rule_boundary.py | 4 ++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/rules/orchestration/orch-review-completion.md b/rules/orchestration/orch-review-completion.md index 5620cea..7208d16 100644 --- a/rules/orchestration/orch-review-completion.md +++ b/rules/orchestration/orch-review-completion.md @@ -34,10 +34,11 @@ Keep final review focused on whether the WorkBundle workflow completed correctly - Complete allowed commit, applicable CodeGraph sync, metadata update, archive, and index refresh only after all gates allow finalization. - When a post-execution runtime or UI defect is classified, or the accepted specification or plan explicitly claims runtime acceptance of a user-visible invariant, require a `RuntimeVerificationClassificationV1` before archive. Evaluate the original user request and accepted specification before the plan, task acceptance criteria, executor handoffs, produced commits, and execution-introduced behavior. - Require `RuntimeVerificationClassificationV1` to carry `classification`, `invariant_trace`, `negative_evidence`, and `owning_repair`. Accepted classes are `execution_introduced_bug`, `implementation_gap`, `new_feature`, and `uncovered_fixture`. -- For an accepted-invariant `execution_introduced_bug` or `implementation_gap`, require `invariant_trace` to connect original requirement, specification invariant, owning plan task or acceptance criterion, changed commit, materialization, and runtime or UI proof. Passing component or unit tests alone is insufficient for this triggered runtime claim; do not impose a universal browser or UI gate when neither trigger applies. +- For an accepted-invariant `execution_introduced_bug` or `implementation_gap`, require `invariant_trace` to connect original requirement, specification invariant, owning plan task or acceptance criterion, changed commit, materialization, presentation, and runtime or UI proof. Passing component or unit tests alone is insufficient for this triggered runtime claim; do not impose a universal browser or UI gate when neither trigger applies. - Permit `new_feature` or `uncovered_fixture` with an empty `invariant_trace` only when `negative_evidence` proves no matching original user request or accepted specification invariant and no plan, handoff, or produced-commit contradiction. - Route `owning_repair` to the first broken artifact: task or acceptance criterion present plus implementation miss means task repair and re-review; accepted specification present plus plan omission means plan repair and resume from the owning step; original-request invariant omitted or contradicted by the accepted specification means specification repair. Only after those cases are excluded may a residual class stand. - Keep classification agent-owned and evidence-linked. A helper may require and structurally validate the record but must not decide the semantic class. +- Keep same-scope specification-owned handling authoritative for a first-observed classification defect. Persist separate WorkBundle violation evidence only after `wb-violation-evaluation` classifies the finding as work-bundle-scoped or mixed and same-scope specification-owned handling no longer applies. ## Must Not diff --git a/skills/orch-review-plan/SKILL.md b/skills/orch-review-plan/SKILL.md index 02b3549..4e0b05f 100644 --- a/skills/orch-review-plan/SKILL.md +++ b/skills/orch-review-plan/SKILL.md @@ -33,12 +33,14 @@ Verify: When a runtime or UI defect is reported after execution, or an accepted specification or plan explicitly claims runtime acceptance of a user-visible invariant, record a `RuntimeVerificationClassificationV1` before archive or residual feature routing. Review the authority chain in order: original user request and accepted specification; compiled plan and task acceptance criteria; executor handoffs and produced commits; then execution-introduced behavior. -The record contains `classification`, `invariant_trace`, `negative_evidence`, and `owning_repair`. `classification` is one of `execution_introduced_bug`, `implementation_gap`, `new_feature`, or `uncovered_fixture`. For `execution_introduced_bug` and `implementation_gap` tied to an accepted invariant, `invariant_trace` must connect original requirement, specification invariant, owning plan task or acceptance criterion, changed commit, materialization, and runtime or UI proof. Passing component or unit tests alone is insufficient for this triggered runtime claim. This is not a universal browser or UI gate for plans without either trigger. +The record contains `classification`, `invariant_trace`, `negative_evidence`, and `owning_repair`. `classification` is one of `execution_introduced_bug`, `implementation_gap`, `new_feature`, or `uncovered_fixture`. For `execution_introduced_bug` and `implementation_gap` tied to an accepted invariant, `invariant_trace` must connect original requirement, specification invariant, owning plan task or acceptance criterion, changed commit, materialization, presentation, and runtime or UI proof. Passing component or unit tests alone is insufficient for this triggered runtime claim. This is not a universal browser or UI gate for plans without either trigger. `new_feature` or `uncovered_fixture` may have an empty `invariant_trace` only when `negative_evidence` records no matching original request or accepted specification invariant and no contradiction in the plan, handoff, or produced commit. Route `owning_repair` to the first broken artifact: an invariant already present in the task or acceptance criterion requires task repair and re-review; a specification invariant omitted from plan decomposition requires plan repair and resume from the owning step; an original-request invariant omitted or contradicted by the specification requires specification repair. Only after those routes are excluded may a residual class stand. Classification remains agent-owned and evidence-linked. A helper may require the record and validate its structure, but must not decide the semantic class. This audit must not expand into a broad source-quality reread or create another implementation-review agent. +Keep same-scope specification-owned handling authoritative for a first-observed classification defect. Persist separate WorkBundle violation evidence only after `wb-violation-evaluation` classifies the finding as work-bundle-scoped or mixed and same-scope specification-owned handling no longer applies. + Use project files only for bounded identity and finalization evidence. Do not broadly inspect source to decide code quality, redo task review, reread implementation for code quality, repair source/tests, or start another implementation-review agent for plan-level acceptance. ## Typed routing diff --git a/tests/test_orchestration_skill_rule_boundary.py b/tests/test_orchestration_skill_rule_boundary.py index 41fd95e..a4b595d 100644 --- a/tests/test_orchestration_skill_rule_boundary.py +++ b/tests/test_orchestration_skill_rule_boundary.py @@ -183,6 +183,10 @@ def test_runtime_verification_classification_contract_routes_the_first_broken_ar "new_feature", "uncovered_fixture", "owning_repair", + "presentation", + "wb-violation-evaluation", + "work-bundle-scoped or mixed", + "same-scope specification-owned", "task repair", "plan repair", "specification repair",