Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
259fa3a
feat(delivery): add dev-only Jinja template_renderer engine (ST-001)
azalio May 30, 2026
fa3f4a4
feat(delivery): Claude templates_src + dual-dest renderer map (ST-002)
azalio May 30, 2026
106c8db
feat(delivery): Codex templates_src + codex dest-map resolver (ST-003)
azalio May 30, 2026
84bad79
build(render): add make render-templates + ship templates_src jinja (…
azalio May 30, 2026
e6976d2
test(render): golden-file test_template_render + delete test_template…
azalio May 30, 2026
ad4f07e
ci(render): add make check-render render-diff gate (ST-006)
azalio May 30, 2026
bbecf7e
feat(ST-007): C1 GATE — delete sync-templates, repoint all refs to re…
azalio May 30, 2026
10835f9
test(init): add INV-6 import-graph guard (ST-008)
azalio May 30, 2026
315ef84
feat(copier): fence-aware managed_file_copier merge (ST-010)
azalio May 30, 2026
2429fe8
feat(C2/ST-011): inject map:start/map:end fences into all templates_s…
azalio May 31, 2026
91fb041
test(copier): del unused start_tok parametrize param (surfaced Pylanc…
azalio May 31, 2026
71ab33f
refactor(C2): remove fences from templates_src + regenerate trees
azalio May 31, 2026
b55d520
fix(C2): regenerate fence-free trees (71ab33f left them stale)
azalio May 31, 2026
e036c8b
feat(copier): add fenced= mode to copy_managed_file (watched vs overw…
azalio May 31, 2026
7746263
feat(C2/ST-012): wire install through copy_managed_file (watched vs o…
azalio May 31, 2026
f6a36dd
test(C2): regen fence-free golden fixtures + update map-tools contract
azalio May 31, 2026
f816403
docs(learned): record C2 fence/copier patterns from map-efficient
azalio Jun 1, 2026
c81c987
fix(render): make check-render non-destructive — stop reverting uncom…
azalio Jun 1, 2026
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
2 changes: 1 addition & 1 deletion .claude/hooks/end-of-turn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ fi
# PYTHONDONTWRITEBYTECODE, since emitting bytecode is `py_compile`'s entire
# job. Touching any .py under .map/scripts/ or src/mapify_cli/templates/ then
# leaves a tracked __pycache__/ that the template-hygiene gate
# (tests/test_template_sync.py) rejects.
# (tests/test_template_render.py) rejects.
if command -v python3 &>/dev/null; then
for file in $CHANGED_FILES; do
if [[ "$file" == *.py ]] && [[ -f "$file" ]]; then
Expand Down
49 changes: 25 additions & 24 deletions .claude/rules/learned/architecture-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,20 +48,19 @@
return create_codex_files(project_path) # handles .map/scripts/ internally
```

- **Dual-Copy Template-Sync Testability Invariant** (2026-05-27): When a project ships a template copy of runtime code (e.g., `src/mapify_cli/templates/map/scripts/`) that is ALSO the copy imported by pytest, code changes in the dev copy (`.map/scripts/`) are invisible to the test suite until an explicit sync command (`make sync-templates`) is run. Document this as a named invariant and enforce it mechanically: run sync before tests, or add a CI step that diffs the two copies and fails on divergence. Without the documented invariant, developers iterate on the dev copy, run tests, see failures, and spend time debugging the wrong copy. [workflow: map-efficient]
- **Single-Source Render Testability Invariant** (2026-05-27, updated 2026-05-31): When a project generates multiple output trees (`.claude/`, `.codex/`, `src/mapify_cli/templates/`, `.agents/skills/`) from a single `.jinja` source tree (`src/mapify_cli/templates_src/`), changes to a `.jinja` source are invisible to all generated consumers until `make render-templates` is run. Document this as a named invariant and enforce it mechanically: always run `make render-templates` before tests (or before commit), and wire `make check-render` into CI to fail on stale generated trees. Without the invariant, developers edit a source file, run tests, see failures, and spend time debugging the generated copies that still hold the old content. [workflow: map-efficient]
```bash
# WRONG — edit dev copy, run tests, observe mysterious failures:
vim .map/scripts/map_step_runner.py
pytest tests/test_map_step_runner.py # imports from templates/ — sees OLD code!

# CORRECT — sync first, then test:
vim .map/scripts/map_step_runner.py
make sync-templates # mirrors dev -> templates/
pytest tests/test_map_step_runner.py # now sees the updated copy

# CI enforcement: add diff gate to Makefile check target:
# diff -q .map/scripts/map_step_runner.py \
# src/mapify_cli/templates/map/scripts/map_step_runner.py
# WRONG — edit .jinja source, run tests, observe mysterious failures:
vim src/mapify_cli/templates_src/CLAUDE.md.jinja
pytest tests/test_template_render.py # generated .claude/CLAUDE.md is still OLD!

# CORRECT — render first, then test:
vim src/mapify_cli/templates_src/CLAUDE.md.jinja
make render-templates # propagates .jinja -> all generated trees
pytest tests/test_template_render.py # now sees the updated copies

# CI enforcement (already wired into `make check` via check-render target):
make check-render # renders + git diff --exit-code; fails on any stale output
```

- **Single-Source Schema Dict with Derived Consumer Lists** (2026-05-27): When multiple consumers (monitor, predictor, evaluator, retry-prompt builder) each need the required fields for a shared agent output format, define ONE module-level dict as the authority and derive ALL per-consumer field lists from it via comprehension. Never let consumers maintain their own hardcoded lists — they drift silently. A field added to the schema for monitor is not added to the retry-prompt builder, so the retry prompt asks for a field the retry validator never checks. The dict also serves as the skeleton source for prompt injection. This is the intra-module application of the existing 'Contract-First Inter-Component JSON Schemas' rule. [workflow: map-efficient]
Expand Down Expand Up @@ -147,16 +146,18 @@
"flapping). Check TaskList before re-sending.")
```

- **N-Copy Artifact Parity Requires a Byte-Identical Diff Gate Across All Trees** (2026-05-30): When a file exists in N>2 locations that must stay identical (e.g., `workflow-gate.py` in `.claude/hooks/`, `.codex/hooks/`, and their two `src/mapify_cli/templates/` mirrors), a named sync step alone is insufficient — any one copy drifts silently if the developer edits only the most obvious dev tree. This repo has TWO dev trees (`.claude` + `.codex`) that each feed a templates mirror, so a single hook is 4 copies. Editing only `.claude` leaves `.codex` and both mirrors drifted. Enforce parity mechanically: after editing EITHER dev tree run `make sync-templates`, then `diff -q` every copy against the canonical source and fail on any divergence. Generalizes the existing two-copy "Dual-Copy Template-Sync Testability Invariant" to the N-copy case. [workflow: map-efficient]
- **N-Output-Tree Parity Requires a Render Gate, Not Manual Copies** (2026-05-30, updated 2026-05-31): When a file must appear identically in N>2 output locations (e.g., `workflow-gate.py` rendered into `.claude/hooks/`, `.codex/hooks/`, `src/mapify_cli/templates/hooks/`, and `src/mapify_cli/templates/codex/hooks/`), manual copy-paste across trees is fragile — any tree drifts silently if the developer edits only the `.jinja` source without re-rendering, or edits a generated output directly. Correct approach: keep ONE `.jinja` source in `templates_src/`, run `make render-templates` to propagate, and enforce parity via `make check-render` (renders + `git diff --exit-code` over all generated trees). Never edit a generated output directly. Generalizes the "Single-Source Render Testability Invariant" to the N-output-tree case. [workflow: map-efficient]
```bash
# Correct edit workflow for the 4-copy hook:
vim .claude/hooks/workflow-gate.py
cp .claude/hooks/workflow-gate.py .codex/hooks/workflow-gate.py # both dev trees
make sync-templates # mirror -> templates/
# Byte-identical gate (wire into `make check`):
for c in .codex/hooks/workflow-gate.py \
src/mapify_cli/templates/hooks/workflow-gate.py \
src/mapify_cli/templates/codex/hooks/workflow-gate.py; do
diff -q .claude/hooks/workflow-gate.py "$c" || { echo "PARITY FAIL: $c"; exit 1; }
done
# Correct edit workflow for the 4-output hook:
vim src/mapify_cli/templates_src/hooks/workflow-gate.py.jinja # ONE source of truth
make render-templates # propagates to .claude/, .codex/, both templates/ mirrors
make check-render # byte-identical gate (already wired into `make check`)
git add -p # stage only the intentional delta
```

- **Install-Time Marker Double-Application: Source Artifacts Must Not Pre-Contain Installer Output** (2026-05-31): When an install step is responsible for injecting a structural marker (e.g. `map:start`/`map:end` fences, a generated header, a version stamp) into a file at install time, the source artifact the installer consumes must NOT already contain that marker. If the marker is pre-baked into the source (injected into a `.jinja` template or a `templates_src` file) AND the installer also wraps the content, every installed file ends up with TWO marker pairs; a parser expecting exactly one pair sees malformed/duplicate structure, fails, and falls back to a safe-but-wrong default (e.g. treating the whole file as user-owned and silently skipping the managed refresh). Invariant: a transformation that is the installer's responsibility has exactly one application site — the installer. Keep source + generated trees marker-free; the installer adds the marker once at write time. Generalises to any idempotency concern where a transform has two application sites. [workflow: map-efficient]
```python
# WRONG: fence baked into template AND added by copier -> double fence -> parse fallback
# CORRECT: templates_src is fence-free; copier injects exactly once:
wrapped = f"# map:start\n{rendered}\n# map:end\n" if fenced else rendered
```
24 changes: 24 additions & 0 deletions .claude/rules/learned/error-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,27 @@
```

- **In an Agentic Harness, Git State Is Ground Truth — Tool Returns Are Not** (2026-05-30, key insight): When operating through an agentic harness, treat every external dispatch and file mutation as inherently uncertain — Agent calls may QUEUE rather than fail (never retry blindly: see [[never-retry-a-queued-agent-dispatch]]), Edit calls may NOT land (always verify via `git diff`), and Write calls ALWAYS overwrite (check existence first). The harness layer between intent and execution introduces silent queuing, silent no-ops, and silent overwrites that make a tool's return value an unreliable proxy for filesystem state. Before every commit, verify with independent `git`/`grep`/`pytest` rather than trusting an agent's self-report (which can also be replayed/garbled by context compaction). [workflow: map-efficient]

- **Cross-Clone Editable-Install Contamination: Verify Package Source Before Trusting Subprocess Results** (2026-05-31): When a Python project is installed editable (`pip install -e` / `uv sync`) and more than one clone of the repo exists, `uv run <console-script>` (or any subprocess invoking the installed entry point) resolves the package through the editable `.pth` in the active `.venv` — which may point to a DIFFERENT clone than the worktree under edit. The subprocess exits 0 with no import error, but exercises the WRONG code, producing phantom failures (wrong file counts, missing markers, behavior that contradicts your edits). To verify the worktree under edit, import in-process (`sys.path.insert(0,"src")` + call functions directly) or run `python -m pytest` (honours the worktree). Never trust a `uv run <console-script>` subprocess as evidence about local changes. [workflow: map-efficient]
```python
import mapify_cli, os
assert os.getcwd() in mapify_cli.__file__, (
f"Package resolves to {mapify_cli.__file__!r}, not this worktree — "
"check editable .pth in .venv/lib/*/site-packages/")
# Prefer in-process over subprocess for the code you're editing:
# sys.path.insert(0,'src'); from mapify_cli.delivery... import fn; fn(tmp)
# NOT: subprocess.run(['uv','run','mapify','init', str(tmp)]) # may hit wrong clone
```

- **Tangled Multi-Edit Recovery: `git checkout HEAD -- <file>` Then One Complete Write** (2026-05-31): When several sequential Edit calls have left a file internally inconsistent — partial anchors matched the wrong location, edits applied against a stale mental model of the real HEAD shape, or context compaction shifted the agent's understanding — STOP issuing incremental Edits. Each further Edit narrows the search but adds another chance to mis-anchor against the now-diverged content. Recover by: (1) `git checkout HEAD -- <file>` to restore the known-good committed state; (2) Read the file for an accurate model; (3) one full-content Write incorporating all intended changes. Trigger: `git diff` shows structural artifacts (duplicate blocks, orphaned `else`) that were never part of any explicit Edit intent. Distinct from "Truncated Agent Recovery" (prose truncation, git state correct) and "Verify File State via Git After Every Edit" (per-edit check) — this is specifically "file is internally inconsistent; reset to known-good and rewrite whole". [workflow: map-efficient]
```bash
git checkout HEAD -- src/mapify_cli/delivery/managed_file_copier.py # restore baseline
# Read the file (ground truth, not memory), then Write full intended content once.
# Safe because Write now produces exactly the intended delta vs the last commit.
```

- **Harness Flap Output Capture: Redirect to a File and Read It Back; Treat Cancelled Batches as Unknown** (2026-05-31): Under harness flapping, safety-classifier delays, or batched-tool cancellation, inline stdout can arrive garbled, out-of-order, or empty while the call still exits 0 — acting on it yields false verdicts ("no errors" when the tool never ran). Reliable pattern: redirect to a temp file (`cmd > /tmp/out.txt 2>&1`) then Read the file (file I/O bypasses the streaming pipeline). If a batch is cancelled or unreadable, classify as "unknown" and re-derive ground truth from `git diff`/`git status`/`pytest` before any dependent action. Separately: ad-hoc `python3 /tmp/foo.py` can break with stdlib shadowing (e.g. `module 'inspect' has no attribute 'Parameter'`) if `/tmp` holds a same-named module — prefer `python3 - <<'PY' … PY` heredocs run from the repo root with `sys.path.insert(0,"src")`. [workflow: map-efficient]
```bash
python -m mypy src/ > /tmp/mypy.txt 2>&1; echo "EXIT:$?" >> /tmp/mypy.txt
# then Read /tmp/mypy.txt; if empty or no EXIT: marker -> harness flap, re-derive from git
```
15 changes: 15 additions & 0 deletions .claude/rules/learned/implementation-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,18 @@ paths:
and name not in _GENERIC_ENTRYPOINT_NAMES # convention-called entrypoints
)
```

- **Watched-vs-Owned File Categorization via a Single `fenced=` Boolean on the Copy Function** (2026-05-31): When an installer manages files in two lifecycle categories — (A) "watched/fenced": managed region refreshed in place, user content BELOW the fence preserved byte-for-byte on update (INV-5); (B) "owned": fully overwritten on update, timestamped `.bak` on drift, no fence — model the split as ONE per-call boolean `fenced=` on the shared copy function, not two functions or a string enum. One code path, one audit trail, one place to fix fence logic. Callers pass `fenced=True` where the downstream user is expected to extend below the fence (agents, skills, CLAUDE.md), `fenced=False` for fully-owned trees (references, map scripts, hooks). JSON is always `fenced=False` because it has no comment syntax — ownership is signalled by a sentinel root key (in this repo, `_map_managed`) instead. [workflow: map-efficient]
```python
def copy_managed_file(src, dest, version, *, fenced: bool = True): ...
copy_managed_file(s/"CLAUDE.md", d/"CLAUDE.md", version) # watched
copy_managed_file(s/"host-paths.md", d/"host-paths.md", version, fenced=False) # owned
```

- **Preserve Executable Bits After an Atomic Temp-File Writer: chmod 0o755 After Every Managed Write of an Executable** (2026-05-31): A managed copier that writes atomically (write a temp file, then `os.replace()`/`Path.replace()` into place) sets the destination mode from the TEMP file's creation mode — typically `0o644` — discarding the source file's `+x`. Any `.sh` or hook/script `.py` installed via this path silently loses executability; the file is correct but `./script.sh` fails "Permission denied", often not surfacing until an integration test invokes it. Fix: after every managed write of a known-executable file (`.sh`, `hooks/*.py`, `scripts/*`), explicitly re-chmod to `0o755`. Do not rely on `shutil.copy2` or source-mode preservation through the atomic replace — the replace drops source metadata. Mirror the chmod in EVERY caller (map-tools, codex hooks, skill scripts). [workflow: map-efficient]
```python
copy_managed_file(src, dest, version)
if src.suffix in (".sh", ".py") and dest.exists():
dest.chmod(dest.stat().st_mode | 0o755)
# test guard: assert os.access(installed_hook, os.X_OK)
```
4 changes: 2 additions & 2 deletions .claude/skills/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ The development copy under `.claude/skills/` must stay byte-for-byte synced with
Use:

```bash
make sync-templates
pytest tests/test_skills.py tests/test_template_sync.py -v
make render-templates
pytest tests/test_skills.py tests/test_template_render.py -v
```

## Troubleshooting
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,16 @@ jobs:
which mypy > /dev/null 2>&1 && mypy src/ || echo "Mypy not installed, skipping"
which pyright > /dev/null 2>&1 && pyright src/ || echo "Pyright not installed, skipping"

- name: Install uv
run: pip install uv

- name: Render parity check
run: make check-render

- name: Run Codex provider regression checks
run: |
python -m pytest -v \
tests/test_template_sync.py::TestCodexTemplateSynchronization \
tests/test_template_render.py::TestRenderRepoTreesCodex \
tests/test_mapify_cli.py::TestCodexProvider

- name: Run tests
Expand Down
26 changes: 12 additions & 14 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,22 @@
- **Bundled templates (what users get from `mapify init`):** `src/mapify_cli/templates/`
- **Dev templates/config used in this repo:** `.claude/` (keep it in sync with `src/mapify_cli/templates/`)

## Critical invariant: template synchronization
## Critical invariant: template single-source render

If you change anything under `.claude/` that is shipped to users, you MUST copy it to the matching path under `src/mapify_cli/templates/` before finishing.
All shipped templates are generated from `src/mapify_cli/templates_src/**/*.jinja` via `make render-templates`. Never edit the generated trees directly — edit the `.jinja` source and re-render.

Common synced paths:
- `.claude/agents/` → `src/mapify_cli/templates/agents/`
- `.claude/commands/` → `src/mapify_cli/templates/commands/` (custom-command scaffolding only; MAP `/map-*` surfaces live in skills)
- `.claude/skills/` → `src/mapify_cli/templates/skills/`
- `.claude/hooks/` → `src/mapify_cli/templates/hooks/`
- `.claude/references/` → `src/mapify_cli/templates/references/`
- `.claude/settings.json`, `.claude/workflow-rules.json` → `src/mapify_cli/templates/`
Generated trees (do NOT edit directly):
- `src/mapify_cli/templates/**`
- `.claude/**`
- `.codex/**`
- `.agents/skills/**`

Do the sync via a deterministic command (preferred):
- `make sync-templates` (runs `scripts/sync-templates.sh`)
To propagate any change to shipped templates:
- `make render-templates`

Verification:
- Run `pytest tests/test_template_sync.py -v` (enforces agent template sync).
- For other `.claude/` files, use `git diff`/`git status` to ensure the template copy was updated too.
- Run `make check-render` (renders and asserts no diff — enforces generated trees match source).
- Run `pytest tests/test_template_render.py -v` (byte-identity golden render tests).

## Skill catalog invariant

Expand All @@ -34,7 +32,7 @@ When changing shipped skills, keep `.claude/skills/skill-rules.json` and `src/ma
- `hybrid` only when reference guidance ships hooks/scripts or artifact side effects; list `runtimeEffects`.

Validation:
- Run `pytest tests/test_skills.py tests/test_template_sync.py -v`.
- Run `pytest tests/test_skills.py tests/test_template_render.py -v`.
- Run `uv run mapify init <new-temp-path> --no-git --mcp none` and inspect generated `.claude/skills/skill-rules.json` for shipped metadata changes.

## How to work in this repo
Expand Down
Loading
Loading