diff --git a/docs/APPS.md b/docs/APPS.md index 17d2a7901..8d0a2950a 100644 --- a/docs/APPS.md +++ b/docs/APPS.md @@ -345,6 +345,15 @@ def post_process(result: str, context: dict) -> str | None: return result + "\n\n## Generated by hook" ``` +**Hook idempotency:** Post-hooks that write to shared journal state must be safe to run more than once on the same inputs. `sol dream --refresh` bypasses the "output already exists" early-return in `think/talents.py` and re-executes the talent, which re-fires `post_process` against a fresh LLM result — so any side-effect the hook performs (writing events, appending to a log, updating an index file) will happen again. Pick one of these two patterns: + +- **Natural-key dedup.** Read the existing output, compute a natural key per row (e.g., `(facet, event_day, title, start, end)` for facet events), skip rows already present, and append only the new ones. Use this when the output is append-only history and you want to preserve prior writes from other agents. +- **Atomic replace.** Recompute the full output, write it to a temp file, and rename into place. `atomic_write()` in `think/entities/core.py` is the established helper for text outputs; for JSONL, write the full set of lines to a tempfile and `os.replace()`. Use this when the hook owns the file end-to-end. + +An earlier `write_events_jsonl` hook in `think/hooks.py` opened facet-event logs in `"a"` mode with no dedup and doubled row counts on every `sol dream --refresh` — see the 2026-04-17 layer-violations audit (V6) in the sol pbc internal extro repo (`vpe/workspace/solstone-layer-violations-audit.md`) for the full write-up. + +See `docs/coding-standards.md` L8/L9 for the broader principles. + **Reference implementations:** - System generator templates: `talent/*.md` (files with `schedule` field but no `tools` field) - Extraction hooks: `talent/occurrence.py`, `talent/anticipation.py`