From 7a23efb80cea7e6d00d0f6ba62248e5edc0d7593 Mon Sep 17 00:00:00 2001 From: Leonardo Buares Date: Tue, 7 Jul 2026 03:36:03 -0300 Subject: [PATCH] feat(protocol): add append-only integrity invariants and git merge support (#5) --- .agents/.gitattributes | 22 +++ .agents/PROTOCOL_RULES.md | 14 +- .agents/modules/git-substrate.md | 18 ++- .agents/scripts/.pre-commit-hooks.yaml | 7 +- .agents/scripts/test_validate_state.py | 191 ++++++++++++++++++++++++- .agents/scripts/validate_state.py | 147 ++++++++++++++++++- .github/workflows/state-validation.yml | 5 +- .pre-commit-config.yaml | 11 +- CHANGELOG.md | 32 +++++ README.md | 11 ++ cli/README.md | 5 +- cli/scripts/test-pack.mjs | 41 ++++++ cli/src/commands/validate.ts | 22 ++- cli/src/lib/validator.ts | 120 +++++++++++++++- 14 files changed, 621 insertions(+), 25 deletions(-) create mode 100644 .agents/.gitattributes diff --git a/.agents/.gitattributes b/.agents/.gitattributes new file mode 100644 index 0000000..eb43f0a --- /dev/null +++ b/.agents/.gitattributes @@ -0,0 +1,22 @@ +# Lead Protocol - merge attributes for the append-only shared logs. +# +# JOURNAL.md, LESSONS.md and decisions.jsonl are append-at-tail by protocol +# rule (PROTOCOL_RULES.md §P3): every writer only adds lines at the end of +# the file. When two branches both appended, the textually "conflicting" +# region is really two independent tails, and the correct resolution is to +# keep both. `merge=union` tells git to do exactly that instead of writing +# conflict markers into the file. The relative order of two entries appended +# on different branches is arbitrary, which the protocol already tolerates +# (see the concurrency notes in §P3). +# +# Deliberately NOT listed here: +# - sessions/active_sessions.md: rows are REMOVED on session close, and a +# union merge would silently resurrect removed rows (closed sessions +# would reappear as live). Conflicts there must be resolved by hand. +# - local/**: per-pair state is gitignored and never merged. +# +# See modules/git-substrate.md §M-git-7 for the full merge contract. + +JOURNAL.md merge=union +LESSONS.md merge=union +decisions.jsonl merge=union diff --git a/.agents/PROTOCOL_RULES.md b/.agents/PROTOCOL_RULES.md index 614d886..8eebe15 100644 --- a/.agents/PROTOCOL_RULES.md +++ b/.agents/PROTOCOL_RULES.md @@ -1,6 +1,6 @@ # PROTOCOL_RULES.md — Lead Protocol framework rules (generic) -> Version: 2.0.1 | Updated: 2026-06-01 +> Version: 2.1.0 | Updated: 2026-07-07 > Scope: Substrate-agnostic kernel. Opt-in modules live in `modules/` and are activated via `PROJECT_RULES.md §J8`. > This file contains no project-specific content — that lives in `PROJECT_RULES.md`. @@ -105,6 +105,16 @@ Consequences: - `LESSONS.md` has no top-of-file table of contents. Queries go through `grep` over inline tags (`grep -A 10 "tags:.*rate-limit" LESSONS.md`). - `decisions.jsonl` is JSON Lines, one object per line (see *Decisions log* below), not a JSON array — a JSON array cannot be appended to atomically. +#### Integrity invariants *(v2.1.0+)* + +The append-at-tail rule implies three invariants that every writer must uphold, on every substrate: + +1. **Correction is a new entry, never a rewrite.** An erroneous past entry is corrected by appending a new entry that references it and states the correction (in `decisions.jsonl`, a new line whose rationale points at the entry it supersedes; in `JOURNAL.md` / `LESSONS.md`, a new dated entry). Rewriting or deleting past content silently invalidates what other agents already read, and rewrites race against concurrent appends. +2. **Every append ends with a newline.** The file must always end with a final newline. Without it, the next append glues onto the last line; in `decisions.jsonl` that produces two JSON objects on one line, which is structurally invalid JSONL. +3. **Structural corruption blocks new appends.** Unresolved merge conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`), glued JSONL lines, and a duplicated top-of-file header are structural corruption. On finding any of them, fix the structure first (minimal repair, preserving both sides' entries) and log the repair in `decisions.jsonl` before appending anything else. Never build on corrupted state (see *Recovery mode*). + +Enforcement: `scripts/validate_state.py` (and the CLI's `lead-protocol validate`) detect all three violations across `decisions.jsonl`, `JOURNAL.md`, `LESSONS.md`, `sessions/active_sessions.md`, and per-pair handoffs. Projects on a git substrate additionally get merge-level protection (see `modules/git-substrate.md §M-git-7`). + ### Handoff schema (`local///handoff.md`) — strict, always overwritten ```markdown @@ -174,7 +184,7 @@ Why JSONL, not a JSON array: 2. **Cheap line-by-line query.** Agents grep or filter line-by-line without loading the full file — consistent with the demand-load contract in `§P-Access`. 3. **Scales past the point a JSON array becomes an anti-pattern.** A 200-entry JSON array is unreadable without tooling; 200 JSONL lines are trivially filterable. -Never edit past entries. If the file is corrupted (a line is not valid JSON), the recovery agent fixes the structure before appending new entries. +Never edit past entries. To correct an erroneous entry, append a new corrective entry whose rationale references the entry it supersedes (see *Integrity invariants* above). If the file is corrupted (a line is not valid JSON), the recovery agent fixes the structure before appending new entries. ### Commit convention diff --git a/.agents/modules/git-substrate.md b/.agents/modules/git-substrate.md index c850482..a82d468 100644 --- a/.agents/modules/git-substrate.md +++ b/.agents/modules/git-substrate.md @@ -1,6 +1,6 @@ # modules/git-substrate.md — Git / pull-request substrate rules -> Version: 1.2.0 | Updated: 2026-06-01 | Protocol: Lead Protocol v2.0.1+ +> Version: 1.3.0 | Updated: 2026-07-07 | Protocol: Lead Protocol v2.1.0+ > Scope: Opt-in module. Activate via `PROJECT_RULES.md §J8 Active modules: git-substrate`. > Applies to: repositories hosted on a git platform with pull-request support (GitHub, GitLab, Bitbucket, etc.). @@ -66,6 +66,22 @@ All session-close state must be committed on the **feature branch** before the p **Interaction with §M-git-1:** project-layer state files (`JOURNAL.md`, `LESSONS.md`, `decisions.jsonl`) normally allow direct commit without branching. §M-git-6 does not override that — it restricts **when** that direct commit may happen relative to PR lifecycle. When a PR is open for branched work, write your state to the feature branch before merge, not directly to the default branch after. +## §M-git-7 - Merge safety for append-only logs *(v1.3.0+)* + +`JOURNAL.md`, `LESSONS.md`, and `decisions.jsonl` are append-at-tail (`PROTOCOL_RULES §P3`). When two branches both appended entries, git sees two edits at the same location (the tail) and declares a conflict, even though both sides are valid and the correct resolution is simply to keep both. A badly resolved conflict here is the main real-world source of the structural corruption §P3 forbids: leftover `<<<<<<<` markers inside `decisions.jsonl` break every subsequent boot that tails the file. + +**Union merge attribute.** The template ships `.agents/.gitattributes` declaring `merge=union` for the three append-only logs. With it, git resolves the "conflicting" tails by keeping both sides' lines and never writes conflict markers into these files. The relative order of two entries appended on different branches is arbitrary, which the protocol already tolerates for these files (§P3 concurrency notes: worst case is reordered lines, and the file stays structurally valid). + +**Deliberately excluded from union merge:** + +- `sessions/active_sessions.md`: rows are removed on session close, so this file is not append-only. A union merge would silently resurrect removed rows, making closed sessions reappear as live and poisoning the takeover rule. Conflicts here are rare (the file is small and short-lived) and must be resolved by hand. +- `local/**`: per-pair state is gitignored (§M-git-5) and never merged. +- Rules files (`PROTOCOL_RULES.md`, `PROJECT_RULES.md`, modules): edited in place by design; a union merge would concatenate divergent rule text. Normal conflict resolution applies. + +**Post-merge validation.** After any merge (or rebase) that touched files under `.agents/`, run the validator before continuing work: `python .agents/scripts/validate_state.py` or `lead-protocol validate`. It detects leftover conflict markers, a missing final newline, and duplicated top-of-file headers. Treat a failure as blocking (fix the state before any new append, per §P3 *Integrity invariants*). + +**Limitations (when human review is still required):** `merge=union` is line-based and trusts that both sides only appended. If a branch violated §P3 and rewrote earlier lines, union merge can silently combine the rewrite with the original instead of surfacing a conflict. The validator catches structural symptoms, not semantic ones, so a merge that mixes two half-written entries into valid-looking text still needs a human eye. When in doubt, `git log -p` on the state file shows what each side actually changed. + ## Optional tooling that ships with the template These files are included in the template as conveniences for projects whose substrate is `git+github` and that use common Python-ecosystem tooling. They are **opt-in** — deleting them breaks nothing in the kernel or in this module's rules: diff --git a/.agents/scripts/.pre-commit-hooks.yaml b/.agents/scripts/.pre-commit-hooks.yaml index 70828a1..2882dbf 100644 --- a/.agents/scripts/.pre-commit-hooks.yaml +++ b/.agents/scripts/.pre-commit-hooks.yaml @@ -15,10 +15,13 @@ .agents/decisions.jsonl against the JSON Schemas shipped in the template. Pristine templates are skipped; populated handoffs and non-empty decisions logs must conform to handoff.schema.json and decisions.entry.schema.json - respectively. + respectively. Also runs structural integrity checks (unresolved conflict + markers, missing final newline, duplicated top-level header) on the + append-only logs JOURNAL.md and LESSONS.md and on + sessions/active_sessions.md. entry: python .agents/scripts/validate_state.py language: python additional_dependencies: ["jsonschema>=4.0"] pass_filenames: false - files: "^\\.agents/(decisions\\.jsonl|local/[^/]+/[^/]+/handoff\\.md)$" + files: "^\\.agents/(decisions\\.jsonl|JOURNAL\\.md|LESSONS\\.md|sessions/active_sessions\\.md|local/[^/]+/[^/]+/handoff\\.md)$" stages: [pre-commit] diff --git a/.agents/scripts/test_validate_state.py b/.agents/scripts/test_validate_state.py index b2d05c1..ed0d312 100644 --- a/.agents/scripts/test_validate_state.py +++ b/.agents/scripts/test_validate_state.py @@ -18,10 +18,16 @@ from validate_state import ( discover_pair_handoffs, + find_conflict_markers, + find_duplicate_h1, + has_final_newline, + integrity_errors, is_pristine_handoff, parse_handoff_md, + validate_active_sessions, validate_decisions_jsonl, validate_handoff, + validate_markdown_log, ) @@ -240,6 +246,172 @@ def test_multiple_lines_one_bad_reports_only_the_bad_one( assert ":2:" in errors[0] +# -------------------------------------------------------------------------- +# Structural integrity checks (§P3 append-at-tail invariants) +# -------------------------------------------------------------------------- + + +class TestFindConflictMarkers: + def test_clean_text_has_no_markers(self) -> None: + assert find_conflict_markers("# Title\n\nBody text.\n") == [] + + def test_all_four_marker_kinds_detected(self) -> None: + text = ( + "<<<<<<< HEAD\n" + "ours\n" + "||||||| merged common ancestors\n" + "base\n" + "=======\n" + "theirs\n" + ">>>>>>> feature-branch\n" + ) + assert find_conflict_markers(text) == [1, 3, 5, 7] + + def test_marker_must_start_at_column_zero(self) -> None: + assert find_conflict_markers(" <<<<<<< HEAD\n") == [] + + def test_shorter_runs_are_not_markers(self) -> None: + # Six characters is not a git conflict marker. + assert find_conflict_markers("<<<<<<\n======\n>>>>>>\n") == [] + + def test_longer_equals_run_is_not_a_marker(self) -> None: + # A setext underline longer than seven equals must not trigger. + assert find_conflict_markers("========\n") == [] + + +class TestHasFinalNewline: + def test_empty_file_is_fine(self) -> None: + assert has_final_newline("") is True + + def test_trailing_newline_is_fine(self) -> None: + assert has_final_newline('{"a":1}\n') is True + + def test_missing_newline_detected(self) -> None: + assert has_final_newline('{"a":1}') is False + + +class TestFindDuplicateH1: + def test_single_h1_is_fine(self) -> None: + assert find_duplicate_h1("# JOURNAL.md\n\n## 2026-07-07 | entry\n") == [] + + def test_duplicated_h1_reports_second_occurrence(self) -> None: + text = "# JOURNAL.md\n\nbody\n\n# JOURNAL.md\n" + assert find_duplicate_h1(text) == [5] + + def test_h1_inside_fenced_block_ignored(self) -> None: + text = "# LESSONS.md\n\n```\n# example header inside a fence\n```\n" + assert find_duplicate_h1(text) == [] + + def test_h2_entries_do_not_count(self) -> None: + text = "# JOURNAL.md\n\n## 2026-07-06 | a\n\n## 2026-07-07 | b\n" + assert find_duplicate_h1(text) == [] + + +class TestValidateMarkdownLog: + def test_pristine_template_passes(self, tmp_path: Path) -> None: + path = tmp_path / "JOURNAL.md" + path.write_text( + "# JOURNAL.md (Project biography)\n\nBody.\n", encoding="utf-8" + ) + assert validate_markdown_log(path) == [] + + def test_conflict_marker_reported_with_lineno(self, tmp_path: Path) -> None: + path = tmp_path / "LESSONS.md" + path.write_text( + "# LESSONS.md\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> b\n", + encoding="utf-8", + ) + errors = validate_markdown_log(path) + assert any("conflict marker" in e and ":2:" in e for e in errors) + + def test_missing_final_newline_reported(self, tmp_path: Path) -> None: + path = tmp_path / "JOURNAL.md" + path.write_text("# JOURNAL.md\n\n## entry", encoding="utf-8") + errors = validate_markdown_log(path) + assert any("final newline" in e for e in errors) + + def test_duplicated_h1_reported(self, tmp_path: Path) -> None: + path = tmp_path / "JOURNAL.md" + path.write_text("# JOURNAL.md\n\nbody\n\n# JOURNAL.md\n", encoding="utf-8") + errors = validate_markdown_log(path) + assert any("duplicated top-level header" in e for e in errors) + + +class TestValidateActiveSessions: + def test_clean_registry_passes(self, tmp_path: Path) -> None: + path = tmp_path / "active_sessions.md" + path.write_text( + "# active_sessions.md (sessions currently live)\n\n| a |\n", + encoding="utf-8", + ) + assert validate_active_sessions(path) == [] + + def test_conflict_marker_reported(self, tmp_path: Path) -> None: + path = tmp_path / "active_sessions.md" + path.write_text("<<<<<<< HEAD\n| row |\n", encoding="utf-8") + errors = validate_active_sessions(path) + assert any("conflict marker" in e for e in errors) + + def test_missing_final_newline_is_not_an_error_here( + self, tmp_path: Path + ) -> None: + # active_sessions.md is not append-only (rows are removed on close), + # so the final-newline invariant is deliberately not enforced. + path = tmp_path / "active_sessions.md" + path.write_text("# header\n| row |", encoding="utf-8") + assert validate_active_sessions(path) == [] + + +class TestDecisionsIntegrity: + def test_conflict_markers_short_circuit_schema_pass( + self, tmp_path: Path, decisions_entry_schema: dict + ) -> None: + path = tmp_path / "decisions.jsonl" + path.write_text( + '<<<<<<< HEAD\n{"not": "validated"}\n=======\n>>>>>>> b\n', + encoding="utf-8", + ) + errors = validate_decisions_jsonl(decisions_entry_schema, path) + assert errors + assert all("conflict marker" in e for e in errors) + + def test_missing_final_newline_reported_alongside_schema_pass( + self, + tmp_path: Path, + decisions_entry_schema: dict, + valid_decision_entry: dict, + ) -> None: + path = tmp_path / "decisions.jsonl" + path.write_text(json.dumps(valid_decision_entry), encoding="utf-8") + errors = validate_decisions_jsonl(decisions_entry_schema, path) + assert len(errors) == 1 + assert "final newline" in errors[0] + + def test_integrity_errors_helper_composes_all_checks(self) -> None: + text = "# H\n\n# H\n<<<<<<< HEAD\nbody" + errors = integrity_errors( + Path("f.md"), text, check_newline=True, check_h1=True + ) + kinds = "\n".join(errors) + assert "conflict marker" in kinds + assert "final newline" in kinds + assert "duplicated top-level header" in kinds + + +class TestHandoffIntegrity: + def test_conflict_marker_beats_pristine_skip( + self, tmp_path: Path, handoff_schema: dict + ) -> None: + # Even a template-looking handoff is corrupted if markers are present. + path = tmp_path / "handoff.md" + path.write_text( + "<<<<<<< HEAD\n> Version: 1.0 | Updated: YYYY-MM-DD\n", + encoding="utf-8", + ) + errors = validate_handoff(handoff_schema, path) + assert any("conflict marker" in e for e in errors) + + # -------------------------------------------------------------------------- # validate_handoff # -------------------------------------------------------------------------- @@ -348,10 +520,14 @@ class TestPreCommitFileRegex: # double-escapes the backslashes because they sit inside a double-quoted # scalar, so `\.` in regex is written `\\.` on disk). PRE_COMMIT_FILES_REGEX_YAML_LITERAL = ( - r"^\\.agents/(decisions\\.jsonl|local/[^/]+/[^/]+/handoff\\.md)$" + r"^\\.agents/(decisions\\.jsonl|JOURNAL\\.md|LESSONS\\.md" + r"|sessions/active_sessions\\.md|local/[^/]+/[^/]+/handoff\\.md)$" ) # What `re.compile()` sees after the YAML parser un-escapes one layer. - PRE_COMMIT_FILES_REGEX = r"^\.agents/(decisions\.jsonl|local/[^/]+/[^/]+/handoff\.md)$" + PRE_COMMIT_FILES_REGEX = ( + r"^\.agents/(decisions\.jsonl|JOURNAL\.md|LESSONS\.md" + r"|sessions/active_sessions\.md|local/[^/]+/[^/]+/handoff\.md)$" + ) @pytest.fixture def regex(self) -> re.Pattern[str]: @@ -375,6 +551,13 @@ def test_yaml_regex_matches_source_of_truth(self) -> None: def test_decisions_jsonl_matches(self, regex: re.Pattern[str]) -> None: assert regex.match(".agents/decisions.jsonl") + def test_append_only_logs_and_sessions_registry_match( + self, regex: re.Pattern[str] + ) -> None: + assert regex.match(".agents/JOURNAL.md") + assert regex.match(".agents/LESSONS.md") + assert regex.match(".agents/sessions/active_sessions.md") + def test_pair_handoff_matches(self, regex: re.Pattern[str]) -> None: assert regex.match(".agents/local/alice@laptop/claude/handoff.md") assert regex.match(".agents/local/bob@workstation/codex/handoff.md") @@ -397,9 +580,9 @@ def test_too_many_segments_does_not_match(self, regex: re.Pattern[str]) -> None: def test_sibling_files_do_not_match(self, regex: re.Pattern[str]) -> None: # Common near-miss candidates the hook deliberately ignores. for path in [ - ".agents/LESSONS.md", - ".agents/JOURNAL.md", ".agents/AGENTS_MAP.md", + ".agents/PROJECT_RULES.md", + ".agents/sessions/other.md", ".agents/local/alice@laptop/claude/activity.log", ".agents/local/alice@laptop/claude/lessons.md", ".agents/local/alice@laptop/claude/tasks/TASK.md", diff --git a/.agents/scripts/validate_state.py b/.agents/scripts/validate_state.py index 883959b..86231ef 100644 --- a/.agents/scripts/validate_state.py +++ b/.agents/scripts/validate_state.py @@ -9,12 +9,15 @@ pip install pytest jsonschema pytest .agents/scripts/ -v -Without arguments, validates both canonical state locations: +Without arguments, validates every canonical state location that exists: .agents/decisions.jsonl (one object per line) + .agents/JOURNAL.md (append-only log) + .agents/LESSONS.md (append-only log) + .agents/sessions/active_sessions.md (live-session registry) .agents/local///handoff.md (one per pair, walked) With explicit file paths, validates each one. The file type is inferred -from the filename (handoff.md vs decisions.jsonl). +from the filename. Exit codes: 0 — all validations passed @@ -29,6 +32,15 @@ The decisions.jsonl validator processes one line at a time, validating each against decisions.entry.schema.json. Empty lines are skipped; a malformed line fails validation without aborting the rest of the file. + +All state files additionally go through structural integrity checks +(§P3 append-at-tail invariants): + - unresolved merge conflict markers (all state files) + - missing final newline (append-only files: decisions.jsonl, + JOURNAL.md, LESSONS.md), which would make the next append glue + onto the previous line + - duplicated top-level `# ` header (JOURNAL.md, LESSONS.md), the + classic symptom of a badly resolved markdown merge """ from __future__ import annotations @@ -53,6 +65,74 @@ PRISTINE_MARKERS = ("YYYY-MM-DD", "[Your Agent Signature]") +# Git writes conflict markers as exactly seven marker characters at the +# start of a line: `<<<<<<<