Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .agents/.gitattributes
Original file line number Diff line number Diff line change
@@ -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
14 changes: 12 additions & 2 deletions .agents/PROTOCOL_RULES.md
Original file line number Diff line number Diff line change
@@ -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`.

Expand Down Expand Up @@ -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/<actor>/<agent>/handoff.md`) — strict, always overwritten

```markdown
Expand Down Expand Up @@ -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

Expand Down
18 changes: 17 additions & 1 deletion .agents/modules/git-substrate.md
Original file line number Diff line number Diff line change
@@ -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.).

Expand Down Expand Up @@ -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:
Expand Down
7 changes: 5 additions & 2 deletions .agents/scripts/.pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
191 changes: 187 additions & 4 deletions .agents/scripts/test_validate_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down Expand Up @@ -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
# --------------------------------------------------------------------------
Expand Down Expand Up @@ -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]:
Expand All @@ -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")
Expand All @@ -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",
Expand Down
Loading
Loading