diff --git a/.console/log.md b/.console/log.md index 74408eb..06e21b1 100644 --- a/.console/log.md +++ b/.console/log.md @@ -1,4 +1,20 @@ # Log +## 2026-07-16 — feat: D3 P0-B — self-describing citation instruction in the injected cold block + +The injected cold block now teaches the reading agent the citation protocol +itself, so NO per-consumer prompt changes are needed (the protocol travels with +the data). route.py `build_context` appends one closing instruction line +(`COLD_CITATION_NOTE`) to the cold section — `(cite: if you act on a [slug] +item above, add the git trailer "Context-Used: " to that commit)` — and +ONLY when at least one real slug-bearing cold line surfaced (gated on +`cold_slugs`, so a hypothetical note-only block gets no instruction; a +warm-only block never does). Rendered-only: appended to the block string after +telemetry assembly, so `cold_surfaced` and `cold_slugs` are byte-identical to +before, and the line does not start with `[` so `_cold_slug_from_line` returns +None for it. cold.py `surface_cold` stays pure. Two new tests (presence + last +line + telemetry unchanged; absence on warm-only). Full suite 434 pass; ruff +clean. + ## 2026-07-16 — feat: D3 P1 fail-closed CI-status resolver (sha → tests_green) New standalone module `context_engine/ci_status.py`: `resolve_ci_status(owner, diff --git a/src/context_lifecycle/context_engine/route.py b/src/context_lifecycle/context_engine/route.py index 6a087d5..bfc911e 100644 --- a/src/context_lifecycle/context_engine/route.py +++ b/src/context_lifecycle/context_engine/route.py @@ -44,6 +44,17 @@ # so all breadth tuning lives in one config surface alongside max_docs_per_edit. COLD_SURFACE_CAP = 5 +# One-line citation protocol note appended to the cold block (D3 attribution +# scheme A): the instruction travels WITH the injected data, so every consumer +# learns the ``Context-Used:`` trailer protocol without per-consumer prompt +# changes. Must NOT start with ``[`` (so `_cold_slug_from_line` returns None +# for it) and is appended only to the rendered block — never to `cold_lines` — +# so the `cold_surfaced` count and `cold_slugs` telemetry are unaffected. +COLD_CITATION_NOTE = ( + "(cite: if you act on a [slug] item above, add the git trailer " + '"Context-Used: " to that commit)' +) + @dataclass(frozen=True) class Route: @@ -432,6 +443,14 @@ def build_context(target: str, root: Path) -> str: cold_section = ( "\n\nRelated cold topics (pull on demand):\n" + "\n".join(cold_lines) ) + # Self-describing block (D3 P0-B): when at least one REAL cold item is + # injected (a slug-bearing line, not just the truncation note), close + # the block with the citation instruction so the acting agent knows the + # `Context-Used:` trailer protocol. Rendered-only: appended after the + # telemetry above was assembled, so it never counts as a surfaced line + # and never appears in cold_slugs. + if cold_slugs: + cold_section += "\n" + COLD_CITATION_NOTE if not blocks: # No injectable warm docs. Emit any diagnostics + cold section. diff --git a/tests/test_context_router.py b/tests/test_context_router.py index 2ce9263..a40aadf 100644 --- a/tests/test_context_router.py +++ b/tests/test_context_router.py @@ -403,6 +403,54 @@ def test_injection_event_omits_truncation_note_from_cold_slugs(tmp_path): assert all("more cold topic" not in s for s in slugs) +def test_cold_block_is_self_describing(tmp_path): + # D3 P0-B: when real cold items surface, the block closes with ONE + # instruction line teaching the acting agent the `Context-Used:` trailer + # protocol — the protocol travels with the data, not per-consumer prompts. + import json as _json + + ctx = tmp_path / ".context" + kn = ctx / "knowledge" + kn.mkdir(parents=True) + (kn / "projection.md").write_text(_COLD_ITEM) + + block = route.build_context("src/x/thing.py", tmp_path) + + # Exactly one instruction line, it names the trailer key, and it is the + # LAST line of the block (after every surfaced item line). + assert block.count(route.COLD_CITATION_NOTE) == 1 + assert "Context-Used:" in route.COLD_CITATION_NOTE + assert block.splitlines()[-1] == route.COLD_CITATION_NOTE + assert "[projection]" in block # the item line it refers to is above it + + # The instruction is rendered-only: not a slug line, not counted, not in + # the cold_slugs telemetry. + assert route._cold_slug_from_line(route.COLD_CITATION_NOTE) is None + log = ctx / "sessions" / ".telemetry" / "injection.jsonl" + event = _json.loads(log.read_text().splitlines()[-1]) + assert event["cold_surfaced"] == 1 + assert [rec["slug"] for rec in event["cold_slugs"]] == ["projection"] + + +def test_no_citation_instruction_without_cold_items(tmp_path): + # Warm-only injection (no cold items surfaced): the instruction line must + # NOT appear — it describes the cold block only. + ctx = tmp_path / ".context" + ctx.mkdir() + (ctx / "routes.yaml").write_text( + 'engine_compat: ">=0.2 <0.3"\n' + 'routes:\n - match: "src/loader.py"\n' + ' inject: ["docs/inject/loader.md"]\n priority: 10\n' + ) + doc = tmp_path / "docs" / "inject" + doc.mkdir(parents=True) + (doc / "loader.md").write_text("## Inject\n- Fail-closed.\n") + + block = route.build_context("src/loader.py", tmp_path) + assert "Fail-closed." in block + assert "Context-Used" not in block + + def test_telemetry_failure_never_breaks_router(tmp_path, monkeypatch): ctx = tmp_path / ".context" ctx.mkdir()