Skip to content
Merged
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
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,59 @@ All notable changes to `role-x` are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.4.2] — 2026-05-29

Activates the previously-inert `context_loaders.entities` wire. The field was
validated (`REQUIRED_CONTEXT_KEYS`) and templated since v0.2.0, but the intake
formatter only ever rendered `context_loaders.files` — so entities a lens
declared never reached the agent's working context. This is the *load* half of
the persona substrate (workspace spec
`docs/specs/2026-05-28-persona-substrate-architecture.html`, Phase 2): persona
constraint entities can now ride every turn, regardless of agent discipline.

### Added

- **`context_loaders.entities` now surfaced in intake output.** For each
workspace-relative entity path a lens declares, intake resolves the file,
parses its frontmatter `core_claim`, and emits a one-liner under a new block:

```
Knowledge-graph constraints to honor (core_claim):
- Default deploy target is Railway; suggest AWS only on explicit ask. · [research/entities/persona/railway-deploy-default.md]
```

Hybrid load path (per spec §6): the compact `core_claim` index rides every
turn; `kg` loads full entity bodies on demand for depth.

- **`_confined_entity_path()` + `_entity_core_claim()` + `_safe_inline()`
helpers** — resolve an entity path (confined to the workspace) and read its
`core_claim`, collapsed to a single length-capped line. Hardened across three
P20 cross-review rounds for the every-turn path: absolute paths are rejected
(entity paths are workspace-relative by contract), and `../` / symlink
escapes are skipped entirely (never read, never surfaced); oversized files
are skipped; non-mapping frontmatter is rejected; the displayed provenance is
run through `_safe_inline()` (strips control characters, newlines, and square
brackets, collapses whitespace, caps length) so a crafted entry can't break
the `[...]` wrapper or inject a standalone directive line; and any error
degrades to the bare path — intake still exits 0 (the never-fail-the-turn
invariant). Entity entries dedup on the cleaned path string, so an `#anchor`
suffix no longer double-renders a claim. Requires the CI floor of Python
3.11+ (`Path.is_relative_to`).

### Changed

- `_format_intake_context(selection)` → `_format_intake_context(selection,
workspace=None)`. Backward-compatible: when `workspace` is omitted, entities
render as bare paths; when no lens declares entities the block is absent and
output is byte-identical to v0.4.1.

### Tests

- Added happy-path, missing-file, and empty-entities tests, plus hardening
tests from two P20 cross-review rounds: non-mapping frontmatter,
multiline-claim collapse, out-of-workspace path confinement (`../` and
absolute), and newline / bracket / control-char sanitization. Full suite: 47 passing.

## [0.4.1] — 2026-05-14

Closes the meta-progression gap. The per-prompt routing was wired in v0.2.0;
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ echo '{"prompt": "implement rust cargo tokio async support", "session_id": "manu
| CLAUDE_PROJECT_DIR=$PWD ~/.agents/skills/role-x/scripts/role-x-intake-hook.sh
```

Expected output: lens selected, mode decided, quality_bar surfaced, event appended to events.jsonl.
Expected output: lens selected, mode decided, quality_bar surfaced, any `context_loaders.entities` core_claim constraints surfaced, event appended to events.jsonl.

### Event schema

Expand Down
2 changes: 1 addition & 1 deletion references/lens-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Each lens lives at `roles/<name>.md` and consists of YAML frontmatter
| `signals.branch_patterns` | list of glob patterns | current-branch name patterns |
| `signals.linear_labels` | list of strings | optional Linear ticket labels |
| `context_loaders.files` | list of strings | workspace-relative file paths to surface in working context |
| `context_loaders.entities` | list of strings | KG entity page paths |
| `context_loaders.entities` | list of strings | workspace-relative KG entity page paths; intake surfaces each entity's `core_claim` one-liner in working context (v0.4.2) |
| `context_loaders.skills` | list of strings | skill identifiers flagged as "in scope" |
| `context_loaders.glob_hints` | list of glob patterns | globs to surface as "likely relevant" |
| `default_mode` | enum | `augment` / `rewrite` / `decompose` |
Expand Down
90 changes: 87 additions & 3 deletions scripts/role-x.py
Original file line number Diff line number Diff line change
Expand Up @@ -596,8 +596,70 @@ def _emit_event(
pass # never fail the hook


def _format_intake_context(selection: dict) -> str:
"""Render the selection as a markdown block that becomes agent context."""
def _confined_entity_path(rel_path: str, workspace: Path | None) -> Path | None:
"""Resolve a workspace-relative entity path, confined to the workspace.

Entries are workspace-relative paths to entity markdown files
(e.g. ``research/entities/persona/railway-deploy-default.md``), matching the
README lens schema. Returns the resolved ``Path``, or ``None`` when the
workspace is unknown or the path escapes it (``../`` / absolute / symlink
escape). Never raises — the intake hook must never fail the user's turn.
"""
if workspace is None:
return None
try:
clean = rel_path.split("#", 1)[0].strip() # tolerate an optional #anchor
if not clean or Path(clean).is_absolute():
return None # empty, or absolute (entity paths are workspace-relative)
ws = workspace.resolve()
path = (ws / clean).resolve() # resolve() follows symlinks, so escapes are caught
return path if path.is_relative_to(ws) else None
except Exception:
return None


def _entity_core_claim(path: Path | None) -> str | None:
"""Read a confined entity file's ``core_claim``, collapsed to one capped line.

Returns ``None`` for a missing / oversized / non-mapping / claim-less file.
Never raises (the every-turn intake hook must not fail): oversized files are
skipped, non-mapping frontmatter is rejected, and any error degrades to
``None`` so the caller renders the bare provenance path instead.
"""
if path is None:
return None
try:
if not path.is_file() or path.stat().st_size > 256 * 1024:
return None # missing, non-file, or pathologically large
fm = parse_frontmatter(path.read_text(encoding="utf-8"))
if not isinstance(fm, dict):
return None # frontmatter that isn't a mapping has no core_claim
claim = fm.get("core_claim")
if isinstance(claim, str) and claim.strip():
return " ".join(claim.split())[:240] # single line, length-capped
except Exception:
return None # never fail the user's turn
return None


def _safe_inline(text: str) -> str:
"""Collapse a string to a single safe inline token for the every-turn block.

Strips control characters, newlines, and square brackets (so provenance
can't break the ``[...]`` wrapper or inject a standalone directive line),
collapses whitespace, and caps length. Used to render entity provenance.
"""
cleaned = "".join(c if (c.isprintable() and c not in "[]") else " " for c in text)
return " ".join(cleaned.split())[:200]


def _format_intake_context(selection: dict, workspace: Path | None = None) -> str:
"""Render the selection as a markdown block that becomes agent context.

``workspace`` (when provided) lets the entities loader resolve each
``context_loaders.entities`` path to its ``core_claim`` one-liner, so the
constraints ride every turn. When ``None``, entities render as bare paths.
"""
lenses_selected = selection["lenses_selected"]
extension_chain = selection["lenses_extended"]
mode = selection["mode"]
Expand All @@ -623,9 +685,11 @@ def _format_intake_context(selection: dict) -> str:
# Compose quality_bar across the extension chain (child overrides parent)
quality_bar: list[str] = []
context_files: list[str] = []
context_entities: list[str] = []
suggestions: list[dict] = []
seen_bar: set[str] = set()
seen_files: set[str] = set()
seen_entities: set[str] = set()
for name in reversed(extension_chain): # parent first, child overrides
lens = registry.get(name)
if not lens:
Expand All @@ -639,6 +703,15 @@ def _format_intake_context(selection: dict) -> str:
if isinstance(f, str) and f not in seen_files:
context_files.append(f)
seen_files.add(f)
for ent in (loaders.get("entities") or []):
if not isinstance(ent, str):
continue
key = ent.split("#", 1)[0].strip() # dedup on the resolved entity path
if key.startswith("./"):
key = key[2:]
if key and key not in seen_entities:
context_entities.append(key)
seen_entities.add(key)
for sug in lens.get("prompt_improvement_patterns") or []:
if isinstance(sug, dict):
suggestions.append(sug)
Expand All @@ -651,6 +724,17 @@ def _format_intake_context(selection: dict) -> str:
lines.append("Context files to surface:")
for f in context_files:
lines.append(f" - {f}")
entity_lines: list[str] = []
for ent in context_entities:
path = _confined_entity_path(ent, workspace)
if workspace is not None and path is None:
continue # path escapes the workspace — never surface it
display = _safe_inline(ent) # sanitize provenance for the every-turn block
claim = _entity_core_claim(path)
entity_lines.append(f" - {claim} · [{display}]" if claim else f" - {display}")
if entity_lines:
lines.append("Knowledge-graph constraints to honor (core_claim):")
lines.extend(entity_lines)
if suggestions and mode != "augment":
lines.append("Prompt-improvement suggestions (optional):")
for sug in suggestions:
Expand Down Expand Up @@ -750,7 +834,7 @@ def cmd_intake(args: argparse.Namespace) -> int:
# v0.4.1: attach authoring nudge for _meta-only domain-rich prompts
selection["authoring_nudge"] = _build_authoring_nudge(prompt, selection)
_emit_event(session_id, prompt, selection)
print(_format_intake_context(selection))
print(_format_intake_context(selection, workspace=workspace))
return 0


Expand Down
Loading
Loading