refactor(.agent): host-agent review protocol + structural fixes - #3
Merged
Conversation
added 10 commits
April 17, 2026 02:32
Shifts the reasoning boundary: Python handles mechanical filing
(cluster, extract, stage, prefilter, decay, archive). The host agent
(Claude Code, Codex, Windsurf, ...) handles validation via CLI tools
using the LLM it already has. The brain no longer needs its own
ANTHROPIC_API_KEY.
Bug fixes
- promote.graduate_validated batch-unsoundness (fixed then the function
was removed; replaced by tools/graduate.py which handles one candidate
at a time against a fresh LESSONS.md read).
- hooks/on_failure.py: rewrite-flag off-by-one (was firing on 4th
failure; now 3rd, as FAILURE_THRESHOLD=3 implies).
- hooks/post_execution.py + on_failure.py: ornamental `contradicts`
field removed from episodic writes (never populated on raw events).
Restructure
- memory/auto_dream.py: staging-only. No validation, no graduation,
no git commit. Safe to run on Stop hooks or cron.
- memory/validate.py: gutted to heuristic prefilter (length + exact
duplicate). No LLM calls. No more Anthropic SDK coupling.
- memory/review_state.py (new): candidate lifecycle (staged /
provisional / accepted / rejected / superseded) + append-only
decision log. rejection_count + reopen history surface recurring
churn instead of looking fresh.
- memory/render_lessons.py (new): semantic/lessons.jsonl is the
source of truth; LESSONS.md is rendered. User content above the
`## Auto-promoted entries will be appended below` sentinel is
preserved across renders.
- memory/cluster.py (new): Jaccard content clustering + deterministic
extractive pattern (canonical highest-salience episode as claim).
Replaces action-prefix clustering.
- harness/context_budget.py: query-aware retrieval (salience x
relevance). Loads AGENTS.md, DECISIONS.md, REVIEW_QUEUE.md per
the stated read order (previously silently skipped).
- harness/llm.py (new): _call_model factored out of conductor.
Used only by the standalone conductor path; memory/ no longer
imports it.
- harness/text.py (new): shared STOPWORDS, word_set, jaccard.
- harness/hooks/_provenance.py (new): source metadata for
episodic entries (confidence, source{skill, run_id, commit_sha},
evidence_ids).
- tools/list_candidates.py, graduate.py, reject.py, reopen.py (new):
host-agent CLI. Graduation requires --rationale so silent
rubber-stamp is structurally impossible.
Host-agent review protocol
- auto_dream stages candidates + writes memory/working/REVIEW_QUEUE.md
- Host agent checks the queue at session start
- list_candidates.py to see pending (priority = cluster_size *
canonical_salience * age_factor)
- graduate.py <id> --rationale "..." or reject.py <id> --reason "..."
- Review is batched so cross-candidate contradictions surface
- Heuristic prefilter catches obvious junk; subjective judgment is
the host agent's, not the brain's
Addresses Codex review of the earlier draft
- Batch-unsound graduate_validated: removed; per-call graduate.py reads
fresh LESSONS.md each time.
- Unattended reasoning + git commits on Stop hook: stripped to
staging-only.
- Provider coupling in validate.py: removed.
- Fragile candidate state: durable lifecycle + decision log.
- AGENTS.md / DECISIONS.md not loaded despite stated contract: now
loaded.
- Off-by-one rewrite trigger: fixed.
- Anecdote-as-claim extraction: unchanged; mitigated by host-agent
review. (Upgrading extraction is Phase 4 work.)
Not pushed (stays per-user)
- memory/personal/PREFERENCES.md
- memory/working/WORKSPACE.md
- memory/working/REVIEW_QUEUE.md (regenerated by auto_dream)
- memory/episodic/AGENT_LEARNINGS.jsonl
- memory/candidates/
- memory/semantic/lessons.jsonl (grows from graduations)
- [P1] write_candidates now looks up prior state across all lifecycle subdirs (staged/rejected/graduated), not just the staged location. A rejected candidate re-detected by clustering is moved back to staged with rejection_count and decision log preserved; the stale rejected/<slug>.json is removed so the id never exists in two states at once. A graduated candidate is skipped entirely — the lesson already lives in lessons.jsonl. - [P2] staged_at is preserved across re-staging. candidate_priority() and the "oldest staged" backlog signal in REVIEW_QUEUE.md both depend on staged_at representing the true first-staged time; overwriting it to now() made recurring candidates never age and stop surfacing as stale work. - [P2] run_dream_cycle now refreshes REVIEW_QUEUE.md even when the episodic log is empty. build_context loads the queue into every host session, so a stale/missing file silently hides pending review items. The early-return path now writes the queue first and reports pending count in its log line. Smoke tests verify: - rejected → re-detected: rejection_count=1, staged_at preserved, decisions log shows [staged, rejected, staged], old rejected/ copy cleared. - graduated → re-detected: write_candidates returns 0 (skipped); graduated/ file intact. - empty episodic with prior pending: REVIEW_QUEUE.md regenerated with correct Pending count + Oldest staged timestamp.
- [P1] render_lessons now migrates legacy auto-promoted bullets below the sentinel into lessons.jsonl on first run. Without this, an installation upgrading from the old markdown-only format would have its previously-promoted lessons silently erased the first time graduate.py (or any path that calls render_lessons) ran with an empty lessons.jsonl. Migrated entries land with status='legacy'. Idempotent: subsequent renders don't re-import the same bullet. - [P2] extract_pattern now boosts canonical_salience by cluster_size via salience_score's existing recurrence multiplier (capped at 3). Previously canonical_salience was computed from a single episode and ignored recurrence, so a cluster of 2 entries with pain=7 importance=7 scored 4.9 and failed the 7.0 promotion threshold. Now scores 9.8 — matching the old recurrence-aware behavior. Repetition is the learning signal the pipeline is designed around. - [P2] _extract_lesson_lines now strips the `[PROVISIONAL]` prefix (and `~~strikethrough~~` markers for superseded lessons) before returning claim text for duplicate detection. Previously a candidate with claim "foo" would not match an existing "[PROVISIONAL] foo" line, so the same provisional lesson could be graduated repeatedly under new candidate ids.
- [P2] write_candidates now distinguishes accepted vs provisional in the graduated/ subdir. Fully accepted lessons stay terminal (the pattern will never be re-surfaced as a candidate), but provisional ones are probationary by design — if the same pattern recurs, the host agent should see it again for evidence accumulation or supersession. Previously any slug in graduated/ was skipped regardless of status, making the provisional workflow terminal. - [P2] review_state.mark_graduated / mark_rejected / mark_reopened now call write_review_queue_summary on the standard working/ path after every transition. build_context loads the queue into every host session; without this refresh, a manual graduate.py would leave the reviewed item showing as pending until the next dream cycle. Uses a convention-based default path derived from candidates_dir, so callers don't need to pass it explicitly. - [P3] write_candidates now skips re-staging of rejected candidates whose last decision was by reviewer "heuristic_prefilter". Those rejections are deterministic (length / exact duplicate vs LESSONS.md), so re-staging them every dream cycle just to have the prefilter reject them again does nothing but churn rejection_count. Human rejections still re-surface — the recurrence IS the signal the reviewer wants to see.
The round-2 "strip [PROVISIONAL] prefix before duplicate check" fix
was defensive against a different-slug-same-claim risk. Round 4
pointed out the root cause: slugs were md5(name), and name depends
on cluster membership, so the same pattern could get a new slug
when one supporting episode joined or left. That's the actual bug
to fix; once ids are stable, the marker-stripping hack becomes
harmful (it blocks provisional lessons from ever re-entering review).
- [P2] extract_pattern now returns a stable `id` derived from
md5(normalize(claim)). Adding or removing a supporting episode
no longer changes the id, so lifecycle history (rejection_count,
graduated/provisional status, decision log) carries forward.
promote._slug prefers pattern["id"]; falls back to md5(key) for
safety on older data.
- [P1] _extract_lesson_lines now SKIPS provisional and superseded
lessons in the duplicate check rather than stripping their
markers. Provisional lessons are probationary by design: a
recurring provisional pattern must reach the host agent so
evidence can accumulate. Superseded ("~~...~~") lessons are
historical, not current; a new claim matching one is a
legitimate revival.
With stable ids, the round-2 scenario (same claim under a new
slug creating a duplicate provisional) is structurally impossible,
so the marker-strip hack is no longer needed.
- [P1] _extract_lesson_lines now reads the status= field from each rendered bullet's HTML annotation. Only status="accepted" counts as terminal; legacy (migrated from pre-restructure LESSONS.md) and provisional lessons can now recur through the duplicate gate for re-review or supersession. Visual-marker check is kept as a fallback for unannotated bullets. - [P1] render_lessons now dedupes lessons.jsonl by id before rendering (keeps the latest entry per id). Without this, a provisional→accepted state transition would render both rows as separate bullets with the same claim. The jsonl stays append-only for audit; the rendered markdown shows the current state. - [P2] write_candidates now cleans up the graduated/ copy when it re-stages a provisional lesson. Previously the same slug could live in both staged and graduated at once, making list_candidates --status graduated show stale data.
- [P1] Candidate re-stage gate now keys off actual change signals,
not reviewer identity. mark_rejected and mark_graduated stamp the
most recent decision with {evidence_snapshot, lessons_sha}.
write_candidates compares the current cluster's evidence_ids +
current LESSONS.md hash against the stamp and re-stages only if
something material shifted. This fixes two failure modes:
(a) human-rejected candidates no longer churn every dream cycle
when the same evidence keeps producing the same cluster, and
(b) heuristic-rejected candidates are no longer permanently
suppressed — if the accepted lesson that triggered the duplicate
rejection is later superseded or removed, the pattern comes back
for review.
- [P2] context_budget._top_lessons now filters by status, so only
accepted lessons reach the host agent's system prompt.
Previously superseded (~~...~~), [PROVISIONAL], and legacy
bullets could be injected as active guidance, letting the agent
act on stale or probationary memory. Uses the same status=
annotation parsing as validate._extract_lesson_lines (kept in
sync by comment; duplication preferred over cross-layer import
between harness/ and memory/).
- [P1] Pattern id now derives from normalized claim + conditions
(sorted shared tokens). Claim-only id collided for generic
canonical text ("the test failed" in db context vs api context
would overwrite). Conditions usually stay stable across cluster
membership changes, so lifecycle continuity is preserved in the
common case while genuinely-different clusters get distinct ids.
- [P1] graduate.py is now atomic in the right direction: append to
lessons.jsonl + render LESSONS.md FIRST, then move the candidate
file to graduated/. Previously the move happened before the
semantic writes; an interruption in the middle left the
candidate terminal-filed but the lesson unlogged, which was a
silent data-loss path. Append-only jsonl + dedupe-by-id at
render time mean a retry after crash produces one rendered
bullet, not two.
- [P2] Re-stage gate for heuristic-rejected candidates now
compares the SPECIFIC lesson(s) that blocked them, not the
whole-file LESSONS.md hash. heuristic_prefilter stamps
duplicate_claims on the rejection decision; write_candidates
checks whether any of those claims still exist as terminal
lessons. Unrelated graduations no longer trigger re-staging +
auto-rejection + rejection_count inflation. validate's
_extract_lesson_lines is renamed extract_lesson_lines so
promote can reuse the status-aware terminal filter.
- [P1] graduate._lesson_id now keys off candidate.id (claim+ conditions, stable) instead of md5(claim). Two distinct candidates with the same canonical claim (e.g., "the test failed" in db vs api context) used to produce the same lesson_id; dedupe_by_id would silently drop the earlier accepted lesson from LESSONS.md. - [P2] context_budget._top_lessons now returns "" when no accepted lessons exist, instead of falling back to raw markdown. The fallback leaked exactly the provisional/ legacy/superseded content the status filter is designed to block — agent could act on stale memory right after migration or during pure-provisional states. - [P2] content_cluster is now actually single-linkage. The old loop appended to the FIRST matching cluster but never merged other clusters the new entry also connected to, so input order [A, C, B] where A~B and B~C but A⊄C produced [[A,B], [C]] instead of one 3-item cluster. Recurrence counts and promotion thresholds were order-dependent as a result. Fix: find ALL clusters the new entry connects to, absorb them into one.
- [P2] Provisional supersessions no longer blank the active lesson. _build_auto_section now applies the superseded_by map only when the superseder is status="accepted". A provisional --supersedes was silently making the old accepted lesson render as ~~strikethrough~~ while the replacement was still probationary, so retrieval skipped both — the topic went dark. - [P2] graduate.py --supersedes now excludes the superseded lesson from the exact-duplicate check. Replacing a lesson with structurally better content (updated conditions, fresh evidence, revised status) but the same claim wording is the whole point of supersession; the heuristic was blocking it with exact_duplicate_of_*. - [P2] Re-stage gate now only counts NEW evidence (new_evidence minus prev_evidence), not set inequality. Decay archives old low-salience episodes every dream cycle; without this fix, routine evidence shrinkage would flip evidence_changed=True even when nothing new arrived, recreating churn for patterns whose only change was decay.
codejunkie99
pushed a commit
to hovhannest/agentic-stack
that referenced
this pull request
Apr 23, 2026
Cross-model review (Claude + Codex adversarial) flagged 4 issues that would either lose user data on Windows or silently degrade episodic memory quality. 1. install.ps1:157-159 — DATA LOSS on Windows re-install. `Remove-Item -LiteralPath $skillsDst -Recurse -Force` on PowerShell 5.1 (default Windows shell) traverses INTO a symlink target and deletes its contents before removing the link. A second `install.ps1 pi` run would wipe .agent/skills/. Detect ReparsePoint via Get-Item.Attributes BEFORE Remove-Item; use .NET Directory.Delete($path, false) on links so only the link is removed, never the target. 2. adapters/pi/memory-hook.ts — no subprocess timeout, hangs Pi. `await runHook(payload)` is awaited inside Pi's tool_result handler; a stuck Python child blocks Pi's event loop forever. Add 3s default timeout (overridable via $AGENT_HOOK_TIMEOUT_MS), kill the child on timeout, surface `timeout` as a separate result kind. 3. adapters/pi/memory-hook.ts — stderr was dropped, failures undiagnosable. Switch stdio to capture stderr (bounded to 4KB to avoid memory blowup on a wedged hook) and surface the first line in the failure notification. 4. .agent/harness/hooks/pi_post_tool.py — Pi sends tool_input with camelCase keys (filePath, oldString, newString), but the shared cc.* helpers (action_label, reflection, importance) expect Claude Code's snake_case keys. Without normalization, every Edit/Write logged by Pi degraded to "edit: ?" / "Edited ?" with empty detail. Add a Pi→canonical input key map applied in _normalize_input(). 5. .agent/harness/hooks/pi_post_tool.py — fail-open on malformed payload was logging bogus "Unknown success" entries (Codex's High codejunkie99#1). If Pi ever changes the event shape or sends invalid JSON, episodic memory got polluted with noise instead of a real signal. Add a _emit_malformed() path that records an explicit `hook:malformed_payload` failure entry with a 200-char excerpt of the offending payload — visible in AGENT_LEARNINGS.jsonl as a real error, not noise. Smoke-tested: - empty payload → `hook:malformed_payload | empty payload` - malformed JSON → `hook:malformed_payload | json decode error: ...` - Pi camelCase Edit (filePath/oldString/newString) → produces correct `edit: /tmp/x.txt` action label and `Edited /tmp/x.txt: replaced 'a' with 'b'` reflection (was: `edit: ?` / `Edited ?`) - well-formed bash success → unchanged behavior Codex's concurrent-write concern (Codex codejunkie99#3) and the rsync-style sync for .pi/skills (Codex codejunkie99#5) are NOT addressed here — they apply to pre-existing infrastructure (post_execution.py write semantics, pi's existing symlink path) and are scoped as separate follow-ups.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ANTHROPIC_API_KEY.contradictsfield on episodic writes.lessons.jsonlas the source of truth (LESSONS.md rendered from it), and a host-agent CLI (list_candidates,graduate,reject,reopen). Graduation requires--rationaleso silent rubber-stamping is structurally impossible.What changes for the user
auto_dream.pyon the Stop hook is now safe — it only stages candidates and writes a summary; no unattended reasoning, no git commits.memory/working/REVIEW_QUEUE.mdsurfaces the review backlog into every session.python .agent/tools/list_candidates.py→graduate.py <id> --rationale "..."orreject.py <id> --reason "...".LESSONS.mdcontent above the## Auto-promoted entries will be appended belowsentinel is preserved across renders — the template's seed lessons and preamble survive first graduation.Codex findings addressed
graduate_validatedwas batch-unsound (frozenexistingsnapshot) — function removed;tools/graduate.pyhandles one candidate against a freshLESSONS.mdread.auto_dreamdid unattended reasoning + git commits on the Stop hook — stripped to staging-only.validate.py— LLM dependency removed; heuristic prefilter only.review_state.py,rejection_countsurfaces churn.AGENTS.md/DECISIONS.mdnot loaded despite stated contract —context_budget.build_contextnow loads them +REVIEW_QUEUE.md.hooks/on_failure.py:52— fires on Nth failure instead of (N+1)th.Files (19 changed)
New (11):
harness/hooks/_provenance.py,harness/llm.py,harness/text.py,memory/validate.py,memory/cluster.py,memory/review_state.py,memory/render_lessons.py,tools/list_candidates.py,tools/graduate.py,tools/reject.py,tools/reopen.pyModified (8):
AGENTS.md,harness/conductor.py,harness/context_budget.py,harness/hooks/post_execution.py,harness/hooks/on_failure.py,memory/auto_dream.py,memory/promote.py,tools/memory_reflect.pyNot pushed (stays per-user)
memory/personal/PREFERENCES.md(customizable template)memory/working/WORKSPACE.md+REVIEW_QUEUE.md(session state)memory/episodic/AGENT_LEARNINGS.jsonl(per-machine log)memory/candidates/(transient)memory/semantic/lessons.jsonl(grows from user graduations)Test plan
existing), off-by-one fires rewrite flag on 3rd failure withFAILED 3 TIMES,contradictsfield absent from episodic entries.claim_too_shortandexact_duplicate.review_statelifecycle: stage → graduate (accepted + rationale recorded), reject (rejection_count++), reopen (history preserved including rejection + reopen decisions).render_lessonsmarker preservation: hand-curated preamble + seed bullets survive re-renders; sentinel present exactly once; fresh install creates a minimal header + sentinel.render_lessonsidempotent across repeated calls.--help.context_budget.build_contextloadsAGENTS.md+ review queue content (~2k tokens with the test query).auto_dreamimports cleanly with new wiring;graduate_validatedremoved frompromote.python .agent/memory/auto_dream.pyagainst real episodic data and confirm staging +REVIEW_QUEUE.mdoutput.