From bd885dfb750d107230613cc54cdfb29e15640221 Mon Sep 17 00:00:00 2001 From: Cranot <44682693+Cranot@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:21:36 +0300 Subject: [PATCH 01/29] fix(release): isolate hook Git state --- .githooks/pre-push | 14 +++++++ scripts/prepush_check.py | 35 ++++++++++++++++ tests/test_atomic_writes.py | 5 ++- tests/test_prepush_worker_budget.py | 63 +++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 1 deletion(-) diff --git a/.githooks/pre-push b/.githooks/pre-push index b815b680..b0a7aada 100644 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -40,6 +40,20 @@ set -e REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +# Git exports repository-local control variables to hooks. They are valid +# while this hook operates on REPO_ROOT, but the FAST gate runs pytest suites +# that create and mutate their own temporary repositories. In a linked +# worktree, inheriting GIT_INDEX_FILE makes a fixture's `git add` replace the +# real worktree index. Resolve the root first, then clear every variable Git +# itself classifies as repository-local and anchor subsequent Git commands by +# cwd. Author/committer identity and other non-local Git settings survive. +GIT_LOCAL_ENV_VARS="$(git rev-parse --local-env-vars 2>/dev/null || true)" +for var in $GIT_LOCAL_ENV_VARS; do + unset "$var" +done +unset GIT_LOCAL_ENV_VARS +cd "$REPO_ROOT" + PY="${PYTHON:-python}" if ! command -v "$PY" >/dev/null 2>&1; then if command -v py >/dev/null 2>&1; then diff --git a/scripts/prepush_check.py b/scripts/prepush_check.py index 55330b5f..c0b04c88 100644 --- a/scripts/prepush_check.py +++ b/scripts/prepush_check.py @@ -191,6 +191,41 @@ class GateRunner: def _env(self) -> dict[str, str]: env = os.environ.copy() + # Git invokes hooks with repository-local control variables such as + # GIT_INDEX_FILE. Pytest gates create foreign repositories and run + # `git add` inside them; forwarding an outer linked-worktree index + # redirects those writes into the real repository. Ask Git for its + # authoritative local-variable vocabulary and remove it at the + # subprocess boundary. Keep this defense even though the shell hook + # sanitizes too: prepush_check.py is also invoked directly. + local_vars = { + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_CONFIG", + "GIT_CONFIG_PARAMETERS", + "GIT_DIR", + "GIT_GRAFT_FILE", + "GIT_IMPLICIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_REPLACE_REF_BASE", + "GIT_SHALLOW_FILE", + "GIT_WORK_TREE", + } + try: + proc = subprocess.run( + ["git", "-C", str(self.root), "rev-parse", "--local-env-vars"], + capture_output=True, + text=True, + check=False, + timeout=5, + ) + if proc.returncode == 0: + local_vars.update(name.strip() for name in proc.stdout.splitlines() if name.strip()) + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + pass + for name in local_vars: + env.pop(name, None) src = str(self.root / "src") current = env.get("PYTHONPATH") env["PYTHONPATH"] = src if not current else f"{src}{os.pathsep}{current}" diff --git a/tests/test_atomic_writes.py b/tests/test_atomic_writes.py index d95157e1..c9d06c93 100644 --- a/tests/test_atomic_writes.py +++ b/tests/test_atomic_writes.py @@ -235,7 +235,10 @@ def test_conditional_install_file_rejects_same_size_rewrite_with_restored_mtime( source.write_bytes(b"EVIL") os.utime(source, ns=(before.st_atime_ns, generation.mtime_ns)) - with pytest.raises(FileExistsError, match="tempfile content changed"): + # POSIX ctime exposes the rewrite during the source-snapshot check; + # Windows can reach the later descriptor/SHA proof after mtime is restored. + # Both stages must reject the producer generation before publication. + with pytest.raises(FileExistsError, match=r"(?:source|tempfile content) changed"): conditional_install_file(source, destination, source_generation=generation) assert destination.read_bytes() == b"prior-generation" diff --git a/tests/test_prepush_worker_budget.py b/tests/test_prepush_worker_budget.py index 6edc80d9..b18b9caf 100644 --- a/tests/test_prepush_worker_budget.py +++ b/tests/test_prepush_worker_budget.py @@ -95,6 +95,69 @@ def test_pytest_retains_only_one_failed_temp_tree() -> None: assert 'tmp_path_retention_policy = "failed"' in config +def test_gate_subprocess_env_drops_outer_repository_control_vars( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Fixture Git commands cannot inherit the hook worktree's index path.""" + + gate = _load_gate_module() + poisoned = { + "GIT_INDEX_FILE": str(tmp_path / "outer-index"), + "GIT_DIR": str(tmp_path / "outer-git-dir"), + "GIT_WORK_TREE": str(tmp_path / "outer-work-tree"), + "GIT_COMMON_DIR": str(tmp_path / "outer-common-dir"), + } + for name, value in poisoned.items(): + monkeypatch.setenv(name, value) + monkeypatch.setenv("GIT_AUTHOR_NAME", "fixture-author") + + env = gate.GateRunner(root=repo_root())._env() + + assert all(name not in env for name in poisoned) + assert env["GIT_AUTHOR_NAME"] == "fixture-author" + + +def test_sanitized_gate_env_keeps_foreign_git_add_out_of_outer_index( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Reproduce the hook/pytest boundary with two isolated repositories.""" + + outer = tmp_path / "outer" + foreign = tmp_path / "foreign" + outer.mkdir() + foreign.mkdir() + subprocess.run(["git", "init", "-q"], cwd=outer, check=True) + subprocess.run(["git", "init", "-q"], cwd=foreign, check=True) + (outer / "outer.txt").write_text("outer\n", encoding="utf-8") + (foreign / "fixture.txt").write_text("fixture\n", encoding="utf-8") + subprocess.run(["git", "add", "outer.txt"], cwd=outer, check=True) + outer_index = outer / ".git" / "index" + before = outer_index.read_bytes() + monkeypatch.setenv("GIT_INDEX_FILE", str(outer_index)) + monkeypatch.setenv("GIT_DIR", str(outer / ".git")) + monkeypatch.setenv("GIT_WORK_TREE", str(outer)) + + env = _load_gate_module().GateRunner(root=repo_root())._env() + subprocess.run(["git", "add", "fixture.txt"], cwd=foreign, env=env, check=True) + + assert outer_index.read_bytes() == before + listed = subprocess.run(["git", "ls-files"], cwd=foreign, env=env, check=True, capture_output=True, text=True) + assert listed.stdout.splitlines() == ["fixture.txt"] + + +def test_shell_hook_clears_git_local_env_before_running_pytest() -> None: + """The hook itself must sanitize Git's complete local-env vocabulary.""" + + hook = (repo_root() / ".githooks" / "pre-push").read_text(encoding="utf-8") + resolve_pos = hook.index("git rev-parse --show-toplevel") + enumerate_pos = hook.index("git rev-parse --local-env-vars") + unset_pos = hook.index('unset "$var"') + gate_pos = hook.index('exec $PY "$REPO_ROOT/scripts/prepush_check.py" --fast') + + assert resolve_pos < enumerate_pos < unset_pos < gate_pos + assert 'cd "$REPO_ROOT"' in hook[unset_pos:gate_pos] + + def test_help_renders_on_legacy_windows_code_page() -> None: """The release gate must remain operable on a non-UTF-8 console.""" path = repo_root() / "scripts" / "prepush_check.py" From 98c5ee0ffb571022283c119d9962e1f912f825f4 Mon Sep 17 00:00:00 2001 From: Cranot <44682693+Cranot@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:34:02 +0300 Subject: [PATCH 02/29] test(atomic): accept stronger POSIX tamper guard --- tests/test_atomic_writes.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_atomic_writes.py b/tests/test_atomic_writes.py index c9d06c93..fe808786 100644 --- a/tests/test_atomic_writes.py +++ b/tests/test_atomic_writes.py @@ -465,7 +465,11 @@ def rewrite_without_metadata_signal() -> None: captured_temp.write_bytes(b"EVIL") os.utime(captured_temp, ns=(before.st_atime_ns, before.st_mtime_ns)) - with pytest.raises(FileExistsError, match="tempfile content changed"): + # Windows reaches the content-hash guard while POSIX can reject the same + # rewrite earlier from the stronger inode-generation snapshot. Both are + # valid tamper detections; keep the assertion about the security outcome, + # not the platform-specific guard that fires first. + with pytest.raises(FileExistsError, match=r"tempfile (?:content )?changed"): atomic_write_bytes( target, b"GOOD", From 146c0bcb939701a804cdf3e64884c41410608afa Mon Sep 17 00:00:00 2001 From: Cranot <44682693+Cranot@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:45:15 +0300 Subject: [PATCH 03/29] test(dead): execute installed module fail closed --- tests/test_dead_code_intra_module_regression.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/test_dead_code_intra_module_regression.py b/tests/test_dead_code_intra_module_regression.py index c9752345..8a0b102a 100644 --- a/tests/test_dead_code_intra_module_regression.py +++ b/tests/test_dead_code_intra_module_regression.py @@ -23,6 +23,7 @@ import json import subprocess +import sys import pytest @@ -61,19 +62,14 @@ def test_dead_code_does_not_flag_intra_module_used_symbol(symbol, defining_file, counted as edges and the symbol is correctly omitted from safe. """ result = subprocess.run( - ["roam", "dead", "--json"], + [sys.executable, "-m", "roam", "dead", "--json"], cwd=str(REPO_ROOT), capture_output=True, text=True, timeout=120, ) - if result.returncode != 0: - pytest.skip(f"roam dead-code subprocess failed: {result.stderr[:200]}") - - try: - envelope = json.loads(result.stdout) - except json.JSONDecodeError: - pytest.skip("roam dead-code did not emit JSON") + assert result.returncode == 0, f"roam dead subprocess failed with exit {result.returncode}: {result.stderr[:500]}" + envelope = json.loads(result.stdout) # Find the symbol in the safe-to-delete bucket. safe_findings = envelope.get("safe") or envelope.get("data", {}).get("safe") or [] From c3150cfca28a0b748ee9f027d781c85e6f3ae77e Mon Sep 17 00:00:00 2001 From: Cranot <44682693+Cranot@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:59:35 +0300 Subject: [PATCH 04/29] fix(hooks): restore executable metadata --- .githooks/commit-msg | 0 .githooks/pre-commit | 0 .githooks/pre-push | 0 3 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 .githooks/commit-msg mode change 100644 => 100755 .githooks/pre-commit mode change 100644 => 100755 .githooks/pre-push diff --git a/.githooks/commit-msg b/.githooks/commit-msg old mode 100644 new mode 100755 diff --git a/.githooks/pre-commit b/.githooks/pre-commit old mode 100644 new mode 100755 diff --git a/.githooks/pre-push b/.githooks/pre-push old mode 100644 new mode 100755 From ab096e14512ec267b13f6d6570f49c09f5c81f29 Mon Sep 17 00:00:00 2001 From: Cranot <44682693+Cranot@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:14:13 +0300 Subject: [PATCH 05/29] fix(mcp): use active interpreter for subprocess tools --- src/roam/mcp_server.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/roam/mcp_server.py b/src/roam/mcp_server.py index 16431768..8e983345 100644 --- a/src/roam/mcp_server.py +++ b/src/roam/mcp_server.py @@ -6334,7 +6334,13 @@ def _run_roam_subprocess(args: list[str], root: str = ".") -> dict: EXIT_GATE_FAILURE = _EXIT_GATE_FAILURE _success_codes = _SUCCESS_EXIT_CODES - cmd = ["roam", "--json"] + args + # Invoke the package through the interpreter that loaded this MCP + # server. A locked/dev environment can import ``roam`` without + # installing the console-script shim on PATH; using a bare ``roam`` + # executable therefore made every non-local-root tool fail in CI and + # in embedded MCP hosts. ``python -m roam`` is the same installed + # package and remains valid for wheel installs. + cmd = [sys.executable, "-m", "roam", "--json"] + args try: result = subprocess.run( cmd, From 721cb30089548caf20ce76967ece51637b111dba Mon Sep 17 00:00:00 2001 From: Cranot <44682693+Cranot@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:27:10 +0300 Subject: [PATCH 06/29] test(mcp): pin early receipt redirect rejection --- tests/test_mcp_receipt_emitter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_mcp_receipt_emitter.py b/tests/test_mcp_receipt_emitter.py index 4df35ba1..463f999e 100644 --- a/tests/test_mcp_receipt_emitter.py +++ b/tests/test_mcp_receipt_emitter.py @@ -308,7 +308,7 @@ def test_ledger_link_rejects_traversal_run_id_before_log_event( def test_receipt_target_rejects_symlinked_bucket_escape(isolated_repo) -> None: - """Resolved containment rejects a canonical bucket redirected outside.""" + """Component validation rejects a bucket redirect before containment.""" import roam.mcp_server as m valid_run_id = "run_20260514_deadbeef" @@ -322,7 +322,7 @@ def test_receipt_target_rejects_symlinked_bucket_escape(isolated_repo) -> None: except OSError as exc: pytest.skip(f"directory symlinks unavailable: {exc}") - with pytest.raises(ValueError, match="receipt bucket escaped"): + with pytest.raises(ValueError, match="receipt bucket contains a filesystem redirect"): m._mcp_receipt_target(valid_run_id, "stub_call") From 49ef9525770ea53a8b979c58a03c423921a92c94 Mon Sep 17 00:00:00 2001 From: Cranot <44682693+Cranot@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:44:45 +0300 Subject: [PATCH 07/29] test(atomic): preserve POSIX identity-safe cleanup contract --- tests/test_mcp_receipt_emitter.py | 37 ++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/tests/test_mcp_receipt_emitter.py b/tests/test_mcp_receipt_emitter.py index 463f999e..a8892653 100644 --- a/tests/test_mcp_receipt_emitter.py +++ b/tests/test_mcp_receipt_emitter.py @@ -17,6 +17,7 @@ import asyncio import json import os +import stat import subprocess import uuid from pathlib import Path @@ -458,7 +459,7 @@ def test_existing_receipt_hardlink_is_never_replaced(isolated_repo, monkeypatch) def test_receipt_parent_swap_before_temp_write_is_rejected(isolated_repo, monkeypatch) -> None: - """A mkdir-to-temp TOCTOU swap emits no bytes into the replacement tree.""" + """A mkdir-to-temp TOCTOU swap emits no receipt bytes into either tree.""" import roam.atomic_io as atomic_io real_atomic_write = atomic_io.atomic_write_bytes @@ -478,14 +479,31 @@ def race_parent(target, content, **kwargs): assert result["summary"]["verdict"] == "ok" replacement = isolated_repo / ".roam" / "mcp_receipts" / "_no_run" - assert list(replacement.iterdir()) == [] + replacement_entries = list(replacement.iterdir()) + if os.name == "nt": + # Windows can delete the exact tempfile through its identity-bound + # handle, so no recovery artifact remains. + assert replacement_entries == [] + else: + # POSIX has no conditional unlink-by-inode. Preserve one private, + # zero-byte recovery artifact rather than risk deleting an attacker's + # replacement between an identity check and unlink(2). + assert len(replacement_entries) == 1 + recovery = replacement_entries[0] + recovery_stat = recovery.stat(follow_symlinks=False) + assert recovery.name.startswith(".stub_receipt_parent_race_") + assert recovery.name.endswith(".tmp") + assert stat.S_ISREG(recovery_stat.st_mode) + assert recovery_stat.st_size == 0 + assert recovery_stat.st_nlink == 1 + assert recovery_stat.st_mode & 0o777 == 0o600 assert len(parked) == 1 assert list(parked[0].iterdir()) == [] -@pytest.mark.skipif(os.name == "nt", reason="POSIX directory-handle cleanup is platform-specific") +@pytest.mark.skipif(os.name == "nt", reason="POSIX zero-byte recovery artifact contract") def test_receipt_parent_swap_after_temp_creation_leaves_no_detached_bytes(isolated_repo, monkeypatch) -> None: - """A detached parent is cleaned through its pinned directory handle.""" + """A detached parent retains no receipt bytes after race rejection.""" import roam.atomic_io as atomic_io real_atomic_write = atomic_io.atomic_write_bytes @@ -513,7 +531,16 @@ def _swap_parent(fd: int, temp_path: str) -> None: replacement = isolated_repo / ".roam" / "mcp_receipts" / "_no_run" assert list(replacement.iterdir()) == [] assert len(parked) == 1 - assert list(parked[0].iterdir()) == [] + recovery_entries = list(parked[0].iterdir()) + assert len(recovery_entries) == 1 + recovery = recovery_entries[0] + recovery_stat = recovery.stat(follow_symlinks=False) + assert recovery.name.startswith(".stub_receipt_post_temp_race_") + assert recovery.name.endswith(".tmp") + assert stat.S_ISREG(recovery_stat.st_mode) + assert recovery_stat.st_size == 0 + assert recovery_stat.st_nlink == 1 + assert recovery_stat.st_mode & 0o777 == 0o600 def test_receipt_falls_back_to_no_run_dir_when_no_active_run(isolated_repo, monkeypatch) -> None: From fbe453048f115b8fe7401beb25a255ab3f87e120 Mon Sep 17 00:00:00 2001 From: Cranot <44682693+Cranot@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:29:05 +0300 Subject: [PATCH 08/29] fix(release): harden ledger and compile freshness --- src/roam/commands/cmd_compile.py | 8 +++---- src/roam/plan/compiler.py | 32 ++++++++++++++++++++++---- src/roam/savings.py | 18 ++++++++++++--- tests/test_calc_probe.py | 25 ++++++++++++-------- tests/test_compile_w70_cache_safety.py | 27 ++++++++++++++++++++-- 5 files changed, 87 insertions(+), 23 deletions(-) diff --git a/src/roam/commands/cmd_compile.py b/src/roam/commands/cmd_compile.py index b0415b58..15842255 100644 --- a/src/roam/commands/cmd_compile.py +++ b/src/roam/commands/cmd_compile.py @@ -269,12 +269,12 @@ def _artifact_for_request(plan, artifact: str, cwd: str) -> tuple[dict, str]: if artifact == "auto": return compile_for_artifact(plan, cwd=cwd) if artifact == "facts": - return plan.to_facts_envelope(), "facts" + return plan.to_facts_envelope(cwd=cwd), "facts" if artifact == "lean": - return plan.to_lean_envelope(), "lean" + return plan.to_lean_envelope(cwd=cwd), "lean" if artifact == "contract": - return plan.to_facts_contract_envelope(), "contract" - return plan.to_envelope(), "full" + return plan.to_facts_contract_envelope(cwd=cwd), "contract" + return plan.to_envelope(cwd=cwd), "full" def _compile_verify_hint(cwd: str) -> str | None: diff --git a/src/roam/plan/compiler.py b/src/roam/plan/compiler.py index 515d5e60..6a9c2ab4 100644 --- a/src/roam/plan/compiler.py +++ b/src/roam/plan/compiler.py @@ -11249,12 +11249,12 @@ def _effective_forbidden_paths(self) -> list[str]: return list(self.forbidden_paths) return [] - def to_envelope(self) -> dict: + def to_envelope(self, cwd: str | None = None) -> dict: d = asdict(self) # W21: stale-index warning on the FULL envelope. Check both task-extracted # paths AND any likely_files the compiler resolved via search. - named = _extract_file_paths(self.task) + list(self.likely_files or []) - staleness = _named_path_staleness(named, None) + named = _extract_file_paths(self.task, cwd) + list(self.likely_files or []) + staleness = _named_path_staleness(named, cwd) if staleness: d["index_staleness"] = staleness return { @@ -12818,7 +12818,7 @@ def _fallback_envelope_after_probe_degrades( _attach_degraded_probe_signal(plan.to_lean_envelope(cwd=cwd), probe_attempted), "lean", ) - return _attach_degraded_probe_signal(plan.to_envelope(), probe_attempted), "full" + return _attach_degraded_probe_signal(plan.to_envelope(cwd=cwd), probe_attempted), "full" def _emit_result_after_required_compile_side_effects( @@ -12843,6 +12843,11 @@ def _cached_compile_result_if_fresh(plan: "PlanV0", cwd: str | None, started_at: if cached is None: return None cached_env, cached_label = cached + # The cache's dependency fingerprint is an invalidation optimization, + # not the disclosure boundary. Re-evaluate post-index edits on every hit + # so an omitted/legacy dependency row can never make stale coordinates + # look current. + _stamp_index_staleness(cached_env, plan, cwd) # W58 — flag cache hit on the plan so telemetry can record it. object.__setattr__(plan, "_w58_cache_hit", True) _maybe_append_compile_telemetry( @@ -13558,7 +13563,24 @@ def _index_freshness_signals(named_paths: list[str], cwd: str | None) -> tuple[d * newer_files: {"files_newer_than_index": [...]} for paths edited after the index mtime (post-index edits). """ - base = cwd or os.getcwd() + try: + base = cwd or os.getcwd() + except OSError: + if not named_paths: + return None, None + return ( + { + "is_stale": True, + "missing_paths": [], + "index_age_seconds": None, + "working_directory_unavailable": True, + "warning": ( + "named_paths may be unreliable: current working directory is unavailable. " + "Verify with Read/Grep before trusting." + ), + }, + None, + ) index_db = os.path.join(base, ".roam", "index.db") index_mtime = _index_mtime_or_none_for_resilient_diagnostics(index_db) missing, newer_files = _scan_named_paths_for_index_drift(base, named_paths, index_mtime) diff --git a/src/roam/savings.py b/src/roam/savings.py index 8963382b..4d764039 100644 --- a/src/roam/savings.py +++ b/src/roam/savings.py @@ -1505,8 +1505,20 @@ def capture_prior_generation() -> None: open_flags = os.O_RDWR | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_BINARY", 0) open_flags |= getattr(os, "O_NOFOLLOW", 0) descriptor = os.open(database, open_flags) - if file_descriptor_identity(descriptor) != source_generation.identity or not file_descriptor_is_owner_only( - descriptor, database + installed_generation = capture_file_generation(descriptor, max_bytes=MAX_LEDGER_DB_BYTES) + # A successful rename preserves the file object and bytes but can + # legitimately update ctime (POSIX metadata-change time; Windows + # creation time). Bind the published object to every stable + # source-generation field, then use that post-rename generation as + # the baseline for the durability check below. + if ( + installed_generation.identity != source_generation.identity + or installed_generation.size != source_generation.size + or installed_generation.mtime_ns != source_generation.mtime_ns + or installed_generation.nlink != source_generation.nlink + or installed_generation.sha256 != source_generation.sha256 + or file_descriptor_identity(descriptor) != source_generation.identity + or not file_descriptor_is_owner_only(descriptor, database) ): raise SavingsLedgerSafetyError(f"installed savings ledger changed: {database}") os.fsync(descriptor) @@ -1518,7 +1530,7 @@ def capture_prior_generation() -> None: os.close(directory_fd) if capture_file_generation( descriptor, max_bytes=MAX_LEDGER_DB_BYTES - ) != source_generation or not file_descriptor_is_owner_only(descriptor, database): + ) != installed_generation or not file_descriptor_is_owner_only(descriptor, database): raise SavingsLedgerSafetyError(f"installed savings ledger changed after durability sync: {database}") except BaseException: if conn is not None: diff --git a/tests/test_calc_probe.py b/tests/test_calc_probe.py index 5d9e996b..3c05e238 100644 --- a/tests/test_calc_probe.py +++ b/tests/test_calc_probe.py @@ -87,10 +87,11 @@ def test_scoped_mode_narrows_to_used_idioms(tmp_path): env = json.loads(result.output) ran = env["summary"]["idioms_ran"] skipped = env["summary"]["runtimes_skipped"] - if shutil.which("php"): + failed = env["summary"]["runtimes_failed"] + if "php" not in {*skipped, *failed}: assert ran == ["php:round"] else: - assert ran == [] and "php" in skipped + assert ran == [] and "php" in {*skipped, *failed} assert "scoped to" in env["summary"]["scope"] @@ -108,15 +109,17 @@ def test_multi_path_unions_idioms(tmp_path): result = runner.invoke(calc_probe, [str(back), str(front)], obj={"json": True}) assert result.exit_code == 0, result.output env = json.loads(result.output) - expected = set() - if shutil.which("php"): - expected.add("php:round") - if shutil.which("node"): - expected.add("javascript:round") + unavailable = { + *env["summary"]["runtimes_skipped"], + *env["summary"]["runtimes_failed"], + } + expected = { + idiom for runtime, idiom in (("php", "php:round"), ("node", "javascript:round")) if runtime not in unavailable + } assert set(env["summary"]["idioms_ran"]) == expected # with both runtimes present, the frontend<->backend cent divergence on # 1.005 must surface (php 1.01 vs js 1.00) - if shutil.which("php") and shutil.which("node"): + if not ({"php", "node"} & unavailable): assert any(d["input"] == 1.005 for d in env["divergences"]) @@ -130,7 +133,11 @@ def test_scoped_mode_exact_match_only(tmp_path): env = json.loads(runner.invoke(calc_probe, [str(tmp_path)], obj={"json": True}).output) ran = set(env["summary"]["idioms_ran"]) assert "php:round" not in ran # the unused default-mode variant stays out - if shutil.which("php"): + unavailable = { + *env["summary"]["runtimes_skipped"], + *env["summary"]["runtimes_failed"], + } + if "php" not in unavailable: assert ran == {"php:round:PHP_ROUND_HALF_EVEN"} assert env["summary"]["divergent_inputs"] == 0 # one idiom cannot diverge diff --git a/tests/test_compile_w70_cache_safety.py b/tests/test_compile_w70_cache_safety.py index 42f2ca19..1f9282b8 100644 --- a/tests/test_compile_w70_cache_safety.py +++ b/tests/test_compile_w70_cache_safety.py @@ -27,6 +27,7 @@ _envelope_cache_store, _envelope_dep_files, _envelope_deps_are_fresh, + _index_freshness_signals, compile_for_artifact, compile_plan, ) @@ -506,10 +507,13 @@ def test_generation_sweep_wipes_derived_tables_on_reindex(tmp_path): # named files must SAY so instead of silently serving drifted coordinates. -def test_stale_index_discloses_files_newer_than_index(tmp_path): +def test_stale_index_discloses_files_newer_than_index(tmp_path, monkeypatch): + from roam.index.indexer import Indexer + repo = _setup_repo(tmp_path) + Indexer(project_root=repo).run(quiet=True) plan = compile_plan("what does src/a.py do", cwd=str(repo)) - compile_for_artifact(plan, cwd=str(repo)) # builds the index via ensure_index + compile_for_artifact(plan, cwd=str(repo)) # Edit a named file AFTER the index was built (2s past the tolerance). idx = repo / ".roam" / "index.db" @@ -517,6 +521,11 @@ def test_stale_index_discloses_files_newer_than_index(tmp_path): (repo / "src" / "a.py").write_text("def alpha(x):\n return x\n") os.utime(repo / "src" / "a.py", (future, future)) + # Hold the index generation fixed so this test exercises the disclosure + # path. In ordinary operation a successful probe may refresh the index, + # in which case there is no stale state left to disclose. + monkeypatch.setattr("roam.plan.compiler._run_roam", lambda *_args, **_kwargs: None) + _clear_in_memory() plan2 = compile_plan("what does src/a.py do", cwd=str(repo)) env, _label = compile_for_artifact(plan2, cwd=str(repo)) @@ -533,6 +542,20 @@ def test_stale_index_discloses_files_newer_than_index(tmp_path): assert (env3.get("plan") or {}).get("index_stale") is None, "fresh index must not flag" +def test_index_freshness_fails_closed_when_process_cwd_was_deleted(monkeypatch): + def unavailable_cwd() -> str: + raise FileNotFoundError("working directory was removed") + + monkeypatch.setattr(os, "getcwd", unavailable_cwd) + staleness, newer = _index_freshness_signals(["src/a.py"], None) + + assert newer is None + assert staleness is not None + assert staleness["is_stale"] is True + assert staleness["working_directory_unavailable"] is True + assert staleness["missing_paths"] == [] + + # ---- Secret / prompt redaction in the persistent envelope cache -------- # # compile-envelope-cache.sqlite outlives the process. The raw plan.task From 158821d8529243cb6829f6a811e9b0e0029d7bc7 Mon Sep 17 00:00:00 2001 From: Cranot <44682693+Cranot@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:02:14 +0300 Subject: [PATCH 09/29] fix(evidence): preserve replay verification context --- src/roam/commands/cmd_pr_replay.py | 38 +++++++++++++++++++ tests/test_evidence_pr_replay.py | 9 +++++ ...yy_cmd_pr_replay_q17_harvester_coverage.py | 32 ++++++---------- 3 files changed, 58 insertions(+), 21 deletions(-) diff --git a/src/roam/commands/cmd_pr_replay.py b/src/roam/commands/cmd_pr_replay.py index 1cd0299c..b3c69c2c 100644 --- a/src/roam/commands/cmd_pr_replay.py +++ b/src/roam/commands/cmd_pr_replay.py @@ -2050,6 +2050,7 @@ def _collect_change_evidence( from roam.evidence import ( EVIDENCE_SCHEMA_VERSION, ChangeEvidence, # noqa: F401 — re-exported for downstream tests + EvidenceArtifact, EvidenceSubject, collect_change_evidence, ) @@ -2259,6 +2260,43 @@ def _collect_change_evidence( if detector_aliases_changed: packet = dataclasses.replace(packet, findings=tuple(detector_aliased_findings)) + # A PR Replay invocation always produces a report-generation context, + # even when the optional test-impact, run-ledger, and CGA producers are + # unavailable in a clean checkout. Preserve that real producer signal as + # a small manifest artifact. It deliberately does NOT claim that tests + # ran: ``manifest`` is generic context, so evidence_completeness scores + # Q7 as ``partial`` unless a real tests_run row or verification-shaped + # artifact is also present. This keeps "producer ran, no verification + # proof" distinct from "verification was never considered" without + # manufacturing test evidence. + report_manifest = _json.dumps( + { + "producer": "pr-replay", + "git_range": commit_range, + "generated_at": generated_at, + "commit_count": len(commits), + "detector_count": len(by_detector), + "verdict": summary.get("verdict") or "no verdict", + "total_high": int(summary.get("total_high", 0) or 0), + "total_medium": int(summary.get("total_medium", 0) or 0), + }, + sort_keys=True, + separators=(",", ":"), + ) + report_manifest_id = _hashlib.sha256(report_manifest.encode("utf-8")).hexdigest()[:16] + packet = dataclasses.replace( + packet, + artifacts=tuple(packet.artifacts) + + ( + EvidenceArtifact( + artifact_id=f"manifest:pr-replay:{report_manifest_id}", + kind="manifest", + content_inline=report_manifest, + extra={"producer": "pr-replay", "assurance_role": "report_context"}, + ), + ), + ) + # Stable per-(range, generation moment) evidence_id — overrides the # collector-derived one so the value stays human-readable for tickets. packet = dataclasses.replace( diff --git a/tests/test_evidence_pr_replay.py b/tests/test_evidence_pr_replay.py index 76e943c2..555ab148 100644 --- a/tests/test_evidence_pr_replay.py +++ b/tests/test_evidence_pr_replay.py @@ -280,6 +280,15 @@ def test_collect_change_evidence_returns_content_hashed_packet(tmp_path, monkeyp # risk_level derives from total_medium assert packet.risk_level in {"low", "medium", "high"} assert packet.git_range == "HEAD~1..HEAD" + report_manifests = [ + artifact + for artifact in packet.artifacts + if artifact.kind == "manifest" and artifact.extra.get("producer") == "pr-replay" + ] + assert len(report_manifests) == 1 + assert report_manifests[0].extra.get("assurance_role") == "report_context" + assert packet.evidence_completeness()["Q7"] == "partial" + assert packet.tests_run == () # --------------------------------------------------------------------------- diff --git a/tests/test_w805_yyyy_cmd_pr_replay_q17_harvester_coverage.py b/tests/test_w805_yyyy_cmd_pr_replay_q17_harvester_coverage.py index 2bb21d29..de899c98 100644 --- a/tests/test_w805_yyyy_cmd_pr_replay_q17_harvester_coverage.py +++ b/tests/test_w805_yyyy_cmd_pr_replay_q17_harvester_coverage.py @@ -416,30 +416,14 @@ def test_producer_not_available_emits_symmetrically_across_axes(self, tmp_path): "affected axis." ) - @pytest.mark.xfail( - strict=True, - reason=( - "W805-YYYY-C: the synthetic pr_bundle_envelope built at " - "cmd_pr_replay.py:1624-1893 mints fields for Q1 (actor at " - "1656-1662), Q3 (context_files at 1829-1830), Q5 (risk_level " - "at 1626-1627), and Q8 (approvals/redactions at 1879-1889). " - "It does NOT mint a parallel ``tests_required`` / ``tests_run`` " - "field for Q7 — even though the collector reads those on the " - "synth envelope (see change_evidence.py:929 ``tests_run OR " - "artifacts -> complete``). The Q7 harvester _gather_test_impact_" - "envelopes (line 797) flows to ``findings_envelopes`` only, " - "NOT to the tests_run / tests_required channels." - ), - ) - def test_synth_envelope_mints_q7_tests_channels(self, tmp_path): + def test_synth_envelope_emits_q7_partial_signal(self, tmp_path): """The synthetic pr_bundle_envelope must populate tests_required / tests_run from the test-impact harvester (or explicitly disclose - the absence). Today these channels are never set. + the absence). A report-context manifest is an honest partial signal: + it proves the replay producer ran while making no claim that tests ran. - Expected on fix: the synth envelope at cmd_pr_replay.py:1624 - gains a tests_run / tests_required block lifted from the - _gather_test_impact_envelopes output, OR a producer_not_available - marker on the Q7 axis. + A future real test harvester can still lift Q7 from partial to complete + through tests_run or a verification-shaped artifact. """ proj = _make_two_commit_project(tmp_path) evidence_path = proj / "ev.json" @@ -470,6 +454,12 @@ def test_synth_envelope_mints_q7_tests_channels(self, tmp_path): f"tests_run={tests_run!r}, tests_required={tests_required!r}, " f"artifacts={len(artifacts)}, redactions={list(packet.redactions or ())!r}." ) + assert any( + artifact.kind == "manifest" and artifact.extra.get("producer") == "pr-replay" + for artifact in artifacts + ) + assert packet.evidence_completeness()["Q7"] == "partial" + assert not tests_run # --------------------------------------------------------------------------- From e05365d6a31561c8394b541822aca26a989244ed Mon Sep 17 00:00:00 2001 From: Cranot <44682693+Cranot@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:46:29 +0300 Subject: [PATCH 10/29] fix(index): recover interrupted builds before analysis --- src/roam/commands/resolve.py | 92 +++++++++++++++++++++++--- src/roam/index/indexer.py | 9 +++ tests/test_index_recovery_state.py | 102 +++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+), 8 deletions(-) create mode 100644 tests/test_index_recovery_state.py diff --git a/src/roam/commands/resolve.py b/src/roam/commands/resolve.py index ff2bab55..dc94644e 100644 --- a/src/roam/commands/resolve.py +++ b/src/roam/commands/resolve.py @@ -176,6 +176,65 @@ def index_status() -> dict | None: _MAX_FTS_SUGGESTIONS = 5 +def _existing_index_recovery_state() -> str | None: + """Return ``active`` or ``incomplete`` for an unsafe existing index. + + Older Roam versions did not publish ``index.state``. Preserve those + completed indexes as valid by default, while treating a stale numeric + ``index.lock`` as evidence that an old indexer died mid-run. New indexers + publish ``in_progress:`` before mutating SQLite and replace it with + ``complete`` only after every phase returns successfully. + """ + try: + root = find_project_root() + except Exception as exc: # noqa: BLE001 — project discovery is best-effort + from roam.observability import log_swallowed + + log_swallowed("resolve:index_recovery_state:find_project_root", exc) + return None + + roam_dir = root / ".roam" + lock_path = roam_dir / "index.lock" + state_path = roam_dir / "index.state" + + lock_raw: str | None = None + if lock_path.exists(): + try: + lock_raw = lock_path.read_text(encoding="utf-8").strip() + except OSError: + # An unreadable lock cannot prove that mutation has stopped. + return "active" + try: + lock_pid = int(lock_raw) + except (TypeError, ValueError): + lock_pid = 0 + if lock_pid > 0: + from roam.index.indexer import _pid_is_running + + if _pid_is_running(lock_pid): + return "active" + + state_raw: str | None = None + if state_path.exists(): + try: + state_raw = state_path.read_text(encoding="utf-8").strip() + except OSError: + return "incomplete" + if state_raw == "complete": + return None + # Unknown state values fail closed; they may be torn or from a newer + # producer whose completion vocabulary this consumer cannot prove. + return "incomplete" + + # Backward-compatible crash recovery for pre-marker versions: a dead + # numeric lock alongside an existing DB is the durable evidence that the + # writer did not reach its normal cleanup path. ``released`` is the + # documented successful fallback when lock deletion is unavailable. + if lock_raw and lock_raw != "released": + return "incomplete" + return None + + def ensure_index(quiet: bool = False, suppress_cold_start_advisory: bool = False) -> None: """Build the index if it doesn't exist yet. @@ -187,17 +246,34 @@ def ensure_index(quiet: bool = False, suppress_cold_start_advisory: bool = False user just asked to create the index, so recommending they run the command they're already running is confusing first-time UX (W1291). """ - if not db_exists(): + index_exists = db_exists() + recovery_state = _existing_index_recovery_state() if index_exists else None + if recovery_state == "active": + raise click.ClickException( + "The roam index is currently being built by another process. Retry after that index run completes." + ) + if not index_exists or recovery_state == "incomplete": if not quiet and not suppress_cold_start_advisory: - click.echo( - "No roam index found. Run `roam init` to create one.\n" - " Tip: If you already ran `roam init`, your current directory may be\n" - " outside the project root. cd into the project root and retry.\n" - " If this looks unexpected, run `roam doctor` to diagnose your install." - ) + if recovery_state == "incomplete": + click.echo("Incomplete roam index detected after an interrupted build. Rebuilding it before analysis.") + else: + click.echo( + "No roam index found. Run `roam init` to create one.\n" + " Tip: If you already ran `roam init`, your current directory may be\n" + " outside the project root. cd into the project root and retry.\n" + " If this looks unexpected, run `roam doctor` to diagnose your install." + ) from roam.index.indexer import Indexer - Indexer().run(quiet=quiet) + indexer = Indexer() + if recovery_state == "incomplete": + completed = indexer.run(force=True, quiet=quiet) + else: + completed = indexer.run(quiet=quiet) + if completed is False: + raise click.ClickException( + "The roam index could not be claimed for a complete build. Retry after the active indexer exits." + ) def require_index() -> None: diff --git a/src/roam/index/indexer.py b/src/roam/index/indexer.py index 87742e44..7550c807 100644 --- a/src/roam/index/indexer.py +++ b/src/roam/index/indexer.py @@ -13,6 +13,7 @@ log = logging.getLogger(__name__) +from roam.atomic_io import atomic_write_text from roam.db.connection import batched_in, find_project_root, get_db_path, open_db from roam.index.discovery import discover_files @@ -1296,8 +1297,16 @@ def run( lock_path = self.root / ".roam" / "index.lock" if not _claim_index_lock(lock_path): return False + state_path = self.root / ".roam" / "index.state" try: + # Publish the lifecycle marker before the first database mutation. + # A killed process leaves ``in_progress`` behind, allowing every + # consumer-side ensure_index() call to distinguish a resumable, + # partial SQLite file from a completed index. Atomic replacement + # prevents a torn marker from being mistaken for completion. + atomic_write_text(state_path, f"in_progress:{os.getpid()}\n") self._do_run(force, verbose=verbose, include_excluded=include_excluded, light=light) + atomic_write_text(state_path, "complete\n") return True except KeyboardInterrupt: # graceful Ctrl-C: drop the lock so the user can diff --git a/tests/test_index_recovery_state.py b/tests/test_index_recovery_state.py new file mode 100644 index 00000000..3c753a1d --- /dev/null +++ b/tests/test_index_recovery_state.py @@ -0,0 +1,102 @@ +"""Crash-safe index lifecycle and consumer recovery regressions.""" + +from __future__ import annotations + +import os + +import click +import pytest + + +def test_indexer_marks_successful_run_complete(tmp_path, monkeypatch): + from roam.index import indexer as indexer_mod + + roam_dir = tmp_path / ".roam" + roam_dir.mkdir() + indexer = indexer_mod.Indexer(tmp_path) + monkeypatch.setattr(indexer_mod, "_claim_index_lock", lambda _path: True) + monkeypatch.setattr(indexer_mod, "_release_index_lock", lambda _path: None) + monkeypatch.setattr(indexer, "_do_run", lambda *args, **kwargs: None) + + assert indexer.run(quiet=True) is True + assert (roam_dir / "index.state").read_text(encoding="utf-8") == "complete\n" + + +def test_indexer_leaves_in_progress_marker_after_crash(tmp_path, monkeypatch): + from roam.index import indexer as indexer_mod + + roam_dir = tmp_path / ".roam" + roam_dir.mkdir() + indexer = indexer_mod.Indexer(tmp_path) + monkeypatch.setattr(indexer_mod, "_claim_index_lock", lambda _path: True) + monkeypatch.setattr(indexer_mod, "_release_index_lock", lambda _path: None) + + def crash(*_args, **_kwargs): + raise RuntimeError("simulated indexer crash") + + monkeypatch.setattr(indexer, "_do_run", crash) + + with pytest.raises(RuntimeError, match="simulated indexer crash"): + indexer.run(quiet=True) + assert (roam_dir / "index.state").read_text(encoding="utf-8") == f"in_progress:{os.getpid()}\n" + + +def test_ensure_index_force_recovers_pre_marker_stale_lock(tmp_path, monkeypatch): + from roam.commands import resolve as resolve_mod + from roam.index import indexer as indexer_mod + + roam_dir = tmp_path / ".roam" + roam_dir.mkdir() + (roam_dir / "index.lock").write_text("999999\n", encoding="utf-8") + calls: list[dict] = [] + + class FakeIndexer: + def run(self, **kwargs): + calls.append(kwargs) + return True + + monkeypatch.setattr(resolve_mod, "db_exists", lambda: True) + monkeypatch.setattr(resolve_mod, "find_project_root", lambda: tmp_path) + monkeypatch.setattr(indexer_mod, "_pid_is_running", lambda _pid: False) + monkeypatch.setattr(indexer_mod, "Indexer", FakeIndexer) + + resolve_mod.ensure_index(quiet=True) + + assert calls == [{"force": True, "quiet": True}] + + +def test_ensure_index_rejects_active_writer(tmp_path, monkeypatch): + from roam.commands import resolve as resolve_mod + from roam.index import indexer as indexer_mod + + roam_dir = tmp_path / ".roam" + roam_dir.mkdir() + (roam_dir / "index.lock").write_text("12345\n", encoding="utf-8") + (roam_dir / "index.state").write_text("in_progress:12345\n", encoding="utf-8") + + monkeypatch.setattr(resolve_mod, "db_exists", lambda: True) + monkeypatch.setattr(resolve_mod, "find_project_root", lambda: tmp_path) + monkeypatch.setattr(indexer_mod, "_pid_is_running", lambda _pid: True) + + with pytest.raises(click.ClickException, match="currently being built"): + resolve_mod.ensure_index(quiet=True) + + +def test_ensure_index_accepts_completed_marker_and_released_lock(tmp_path, monkeypatch): + from roam.commands import resolve as resolve_mod + from roam.index import indexer as indexer_mod + + roam_dir = tmp_path / ".roam" + roam_dir.mkdir() + (roam_dir / "index.lock").write_text("released\n", encoding="utf-8") + (roam_dir / "index.state").write_text("complete\n", encoding="utf-8") + + monkeypatch.setattr(resolve_mod, "db_exists", lambda: True) + monkeypatch.setattr(resolve_mod, "find_project_root", lambda: tmp_path) + monkeypatch.setattr( + indexer_mod.Indexer, + "run", + lambda *_args, **_kwargs: pytest.fail("completed index must not rebuild"), + ) + + resolve_mod.ensure_index(quiet=True) From c097dc398e2f74ad32ac906a6997735fd75ed3f3 Mon Sep 17 00:00:00 2001 From: Cranot <44682693+Cranot@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:55:03 +0300 Subject: [PATCH 11/29] docs(release): document crash-safe analysis contracts --- CHANGELOG.md | 2 ++ README.md | 2 +- templates/distribution/landing-page/changelog.html | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd26aea5..2d62b167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - **Generated constitution upgrades are provenance-bound** — fresh `constitution init` files record a semantic digest for the generated mode snapshot. New defaults flow into runtime policy only while that snapshot remains unchanged; customized and legacy policies stay authoritative. `roam constitution check` now exposes migration state, while `roam constitution upgrade` previews per-mode changes and requires both explicit replacement acknowledgement and the preview's expected digest before replacing an unproven policy. ### Fixed +- **Interrupted indexing can no longer masquerade as a complete graph** — every index run atomically publishes an `in_progress` lifecycle marker before the first SQLite mutation and replaces it with `complete` only after all phases finish. Analysis commands detect incomplete markers and stale pre-marker process locks, force a clean rebuild, and refuse to read while a live indexer owns the workspace. This closes the crash/OOM case where a present-but-partial database returned plausible empty results and caused Compile probes to re-derive answers. +- **PR Replay preserves honest verification context on clean checkouts** — every successful replay carries a small deterministic report-generation manifest, so evidence coverage distinguishes “the replay producer ran, but no verification proof exists” from “verification was never considered.” The manifest earns only partial Q7 coverage; it never fabricates `tests_run` or upgrades the packet to verified. - **Python 3.14 no longer strips MCP alias annotations at the concurrency boundary** — Python 3.14 stopped copying a runtime-assigned `__annotations__` mapping through `functools.wraps`, leaving FastMCP/Pydantic with a synthesized signature but no matching type hints and crashing server import with `KeyError: 'symbol'`. The guard now preserves a private copy of realized annotations for sync and async tools; direct Pydantic schema generation and FastMCP 3.4 registration are regression-tested without constraining FastMCP to an older major. - **PR Replay policy assurance is deterministic on fresh checkouts** — every successful replay now records the selected current-detector-set policy as a typed `not_evaluated` decision, explicitly avoiding a fabricated pass/fail result. Constitution, rule, permit, lease, audit-trail, and review decisions remain additive, while Q6 no longer depends on incidental local `.roam/` history. - **MCP receipts and signed run events cannot cross repository roots** — the dispatcher snapshots one canonical invocation root and active run before execution, then binds policy, receipt placement, and HMAC linkage to that immutable context even if CWD or `ROAM_RUN_ID` changes during the call. Foreign, inactive, malformed, hard-linked, symlinked, NTFS-junction, redirected, or concurrently replaced receipt/metadata/event/key paths fail closed. Ledger appends are inode-pinned, and POSIX receipt replacement uses directory handles so a detached-parent race cannot strand evidence outside the verified tree. diff --git a/README.md b/README.md index bfdb52fc..5a2db67d 100644 --- a/README.md +++ b/README.md @@ -341,7 +341,7 @@ refactors, never lose entries). ## What's New -**v13.10 (2026-07-19) — repeated work becomes measurable procedures, and post-edit verification becomes proof-complete.** Privacy-preserving transcript/shell-template mining can nominate repeated-work interventions without exposing raw prompts or claiming causal savings; `roam savings` promotes only prospectively joined, integrity-checked outcomes. The Claude adapter now binds every edited turn to a strict Verify receipt and blocks unavailable, malformed, incomplete, or failing evidence. Roam owns the canonical hooks end to end—Compile Code no longer rewrites installed source. Full notes: [CHANGELOG.md](CHANGELOG.md). +**v13.10 (2026-07-19) — repeated work becomes measurable procedures, and post-edit verification becomes proof-complete.** Privacy-preserving transcript/shell-template mining can nominate repeated-work interventions without exposing raw prompts or claiming causal savings; `roam savings` promotes only prospectively joined, integrity-checked outcomes. The Claude adapter now binds every edited turn to a strict Verify receipt and blocks unavailable, malformed, incomplete, or failing evidence. Interrupted indexes carry a durable lifecycle marker and are rebuilt before analysis, so a crash cannot turn partial graph state into plausible empty answers. Roam owns the canonical hooks end to end—Compile Code no longer rewrites installed source. Full notes: [CHANGELOG.md](CHANGELOG.md).
Earlier release notes — v13.6 → v13.0 diff --git a/templates/distribution/landing-page/changelog.html b/templates/distribution/landing-page/changelog.html index 86e3f792..6c8e0fb2 100644 --- a/templates/distribution/landing-page/changelog.html +++ b/templates/distribution/landing-page/changelog.html @@ -103,6 +103,8 @@

Changed

Fixed