Borrowing it
Nothing to install: this file belongs to Parslee-ai/neo. Take a copy, put it at the same path in your own repository, and replace the rules that are about this project with yours.
curl -O https://raw.githubusercontent.com/Parslee-ai/neo/main/CLAUDE.mdgit clone --depth 1 https://github.com/Parslee-ai/neoWrote this? Show the measurements
A badge with what this costs and how it scanned, read live from this page, so it follows the numbers instead of freezing them. Markdown for a README, HTML for a documentation site or a project page.
[](https://agentmods.dev/instructions/parslee-ai/neo/claude-md)<a href="https://agentmods.dev/instructions/parslee-ai/neo/claude-md"><img src="https://agentmods.dev/badge/instructions/parslee-ai/neo/claude-md/github.svg" alt="Measured on agentmods" height="20"></a>Or the 80×15 button, for a site that already has a row of RSS and ATOM ones. Only the verdict fits; the numbers stay here.
<a href="https://agentmods.dev/instructions/parslee-ai/neo/claude-md"><img src="https://agentmods.dev/badge/instructions/parslee-ai/neo/claude-md.svg" alt="Reviewed on agentmods" width="80" height="20"></a>What it costs to keep this loaded
Counted locally with the o200k_base tokenizer, which is exact for GPT models; Claude uses its own tokenizer and its counts differ. Treat this as one consistent yardstick across the catalogue rather than a bill. Prices are per million input tokens.
| Model | Per session | Once invoked |
|---|---|---|
| Fable 5.1 | $0.32333 | $0.32333 |
| Opus 5 | $0.16167 | $0.16167 |
| Sonnet 5 | $0.06467 | $0.06467 |
| Haiku 4.5 | $0.03233 | $0.03233 |
Grade A, and why
neo CLAUDE.md scanned grade A with 0 findings against 26 rules in 11 categories — prompt injection, anti-refusal, data exfiltration, privilege escalation, supply chain, agent snooping, system-prompt leakage, SSRF and excessive agency — measured 4d ago.
A static scan of the body, not an audit. Every finding is printed with the line that produced it so you can judge whether it matters here. A mod is markdown that instructs an agent; that is exactly why what it instructs is worth reading.
Nothing flagged
None of the 26 patterns this scan looks for appear in this file: no shell pipes, no recursive deletes, no credential paths, no hidden text, no instruction-override or anti-refusal phrasing, no agent-config snooping. That is not a guarantee, it is the absence of the things that are checkable.
How it starts
The opening of the file, as written. The whole thing — 1,679 lines — stays where its author put it; the contents beside it link to each section on GitHub.
Project: Neo - Semantic Reasoning Helper
Quick Context
- Purpose: Read-only reasoning helper for CLI tools using MapCoder/CodeSim-style multi-agent reasoning with semantic memory
- Tech Stack: Python 3.10+, fastembed (Jina Code v2, 768d), faiss-cpu (legacy pattern matching), Anthropic/OpenAI/Google LMs
- Installation:
pip install -e ".[dev]"for development
Code Style
- Import convention: stdlib → third-party → local, specific imports
- Naming: PascalCase classes, snake_case functions, UPPER_SNAKE constants, _private methods
- Error handling: Try/except with specific exceptions, logger warnings, graceful fallbacks
- Testing: test_*.py pattern, pytest framework
- Type hints: Extensive with Optional, list[], dict[]
- Docstrings: Triple quotes, brief description first
Project Rules
- Keep implementations simple first, enhance iteratively
- Test all changes before committing
- Use 3-5 minute timeout when executing
neocommands - Semantic memory: Local embeddings (Jina 768-dim) preferred over OpenAI (1536-dim)
- Memory hygiene:
- Per-scope valid-fact caps (
SCOPE_LIMITSinstore.py): global=200, org=100, project=500, session=50. Enforced per loaded scope set (project+org+global); invalidated facts persist as tombstones untilpurge_dead_factsruns. - Supersession at cosine ≥ 0.85 (
SUPERSESSION_THRESHOLD,store.py:59); pre-write dedup is canonical-signature equality, not cosine (memory.generalize).SYNTHESIS_SIMILARITYwas a separate constant that happened to equal 0.85 and gated REVIEW clustering; it went away withsynthesize_reviews(see below). The only 0.85 left instore.pyis supersession.memory.issueskeeps its ownCLUSTER_SIMILARITY = 0.85, tunable independently. - Episode-derived promotion correlation (
store._episode_signature/_global_signature): a candidate promotes to a durable fact only when ≥2 verified-accepted episodes share a correlation signature AND those episodes span ≥2 distinctrepository_revisions (_supporting_episodes_span_distinct_revisions). The revision requirement is what "independent" actually means here: distinct episode ids alone is no test at all, since every invocation mints a fresh one, so one operator applying the same patch twice minutes apart at the same HEAD promoted a durable PATTERN whose content was wrong (reproduced live). A session-id fallback for non-git projects was written and REMOVED — do not reintroduce it.LearningEpisode.session_idis a per-episodeuuid4()that nothing ever assigns from a real session (121 episodes on a live ledger → 121 distinct ids), so "≥2 distinct sessions" was the very gate being replaced, renamed; and since acceptance detection is entirely git-based, a genuinely non-git project can never record an ACCEPTED outcome and could not reach the fallback anyway. Its only reachable trigger was a transientrev-parsefailure, which made BOTH failing promote while ONE failing blocked — load-dependent non-determinism in the durable memory path. It now fails closed on a blank revision. Two accepted costs, documented on the predicate: the revision is captured when the episode BEGINS (HEAD when advice was asked for, not the commit the fix landed in), and applying one lesson across several files in a single sitting records ONE revision and promotes nothing (40% of revision-bearing episodes share a HEAD with another). Keying on the acceptance-carrying sha — already walked by_get_changed_files_since— is the obvious improvement. That signature is keyed on the candidate SUBJECT, never the body — the body is the LM's run-varying Reasoning/Suggestion prose, and including it (the oldgeneralize(subject+body)) gave two acceptances of one task different signatures so promotion never fired (a live drill measured this). The subject isf"{task_type}: {prompt[:50]} [{file_path}] [fp:{hash}]"; the[fp:<hash>]is a structural fingerprint of the suggested change (engine._suggestion_fingerprint= sha256 of the AST-shaped_extract_code_skeletoncalled withnormalize_names=True, sodef:<name>becomes baredef, Python-only, "" for unparseable code → key degrades to subject-only). The name-normalization is load-bearing: the skeleton keepsdef:<name>by default because it doubles as readable metadata on the fact, but hashing the name made the identical fix toread_textandread_bodytwo different signatures, so a genuinely recurring lesson could never reach the two acceptances promotion requires. A live drill against realneoruns measured four git-verified acceptances and zero promotions; normalizing promoted on the next pair. It MUST happen inside the extractor, before the 500-char truncation —def:<name>is the only unbounded-length token, so long identifiers are exactly what pushes a skeleton past the cut, and post-hoc string stripping still left identical shapes hashing differently. The rule for what survives is bounded vocabulary: the ~11 structural keywords, the 6-name method whitelist and the 9-name constructor whitelist all carry shape, whiledef:<name>was the only free user-chosen identifier (there is noClassDefhandler, no arg names, no bareNameloads) — which is what makes normalizing it both necessary and sufficient. Honest limit: post-normalization skeletons are coarse — the drill's fix hashes todef return, a guard-clause fix todef if-stmt return— so the fingerprint discriminates far less than "diff shape" suggests, and at the path-agnostic GLOBAL tier correlation is close to prompt-prefix plus a near-constant token. The real anti-collision protection is the double git-verified acceptance plus the kind gate, not the fingerprint;test_structurally_distinct_fixes_do_not_collidepins what it still buys._split_fingerprintpulls the fp OUT beforegeneralize(whose_HASH_REwould collapse the hex to<hash>) and appends it RAW after a\x1fseparator, so two episodes correlate only when prompt-prefix AND diff-shape agree — read "diff-shape" with the coarseness caveat above. Two tiers: PROJECT uses_episode_signature(path-bearing, thoughgeneralizecollapses deep multi-segment paths so it only discriminates SHALLOW[file.py]names); cross-project GLOBAL uses_global_signature(path-agnostic — strips all bracket qualifiers to match the bracket-stripped text_mint_global_factstores, so the same lesson correlates across repos). The signature is frozen intoFact.canonical_signatureat mint; all rollback/dedup/teardown sites use the frozen value only (never recompute a global fact's transformed subject). Deliberate invariant: single-project rollback keys on the path-bearing project signature and therefore can NEVER hard-retract a path-agnostic global fact — global teardown requires cross-project contradiction and is owned byreconcile_cross_project_promotions. Rollback-resolve is fingerprint-precise (a differently-shaped correction won't hard-retract; soft confidence demotion still applies). Footgun:_episode_signature/_global_signature(episode correlation, subject+fp) are distinct from_canonical_signature(pre-write exact-twin dedup, which DOES include body+kind+scope) — one keystroke apart, do not conflate. - Candidate KIND gate + task-type classification (
models.classify_task_type, enginekind_map): a candidate promotes only when its kind ispattern. Kind derives from the task type —algorithm/bugfix→pattern(promotable);feature→decision,refactor→architecture,explanation→review, and the unknown-type default →review(all non-promotable, by design — auto- minting durable decisions/architecture/prose is riskier than patterns). The CLI used to hardcodetask_type=FEATUREfor every plain-text prompt, so nothing interactive could ever promote; it now callsclassify_task_type(prompt)— deterministic keyword scoring (no LLM), highest distinct-match count wins, ties break by_TASK_TYPE_PRIORITYwhich is ordered so ALL non-promotable kinds precede the two promotable ones (ties FAIL SAFE to non-promotable; EXPLANATION must stay ahead of BUGFIX/ALGORITHM). No signal →FEATURE. JSON callers' explicittask_typestill wins; only an omitted one is classified. Known limitation (contained by the double-acceptance + fingerprint promotion gate, not the kind map): a lone/multi promotable-noun in feature/explanation prose with no competing feature/refactor verb can win by score and raise eligibility (e.g. "improve the errors page" → BUGFIX; "summarize the performance of this algorithm" → ALGORITHM) — it still can't mint a durable fact without two independent git-verified acceptances of a matching diff shape.classify_task_type(prompt, error_trace=None)also takes an optionalerror_trace(wired on the JSON path): a supplied traceback adds_FAILURE_TRACE_WEIGHT(=2) to BUGFIX — strong but overridable (a 3-signal dominant intent still wins; a signal-less/empty prompt + trace → BUGFIX). The BUGFIX failure-symptom patterns are DERIVED from the sharedexecution_context.FAILURE_SIGNAL_KEYWORDS(error/fail/exception/crash) so this classifier and_infer_intentcan't drift;_infer_goalkeeps its own timeout-inclusive set by design. (If a third module needs the lexicon, extract it to a neutralneo.lexiconthen.) The derived\bcrash(?:…)?\bis boundary-closed, so compound terms likecrashloopno longer match — a deliberate precision/recall trade in the fail-safe direction. - REVIEW → PATTERN synthesis has been REMOVED (
synthesize_reviewsand its cluster/watermark/Hebbian machinery). It ran for four months and minted 114 facts, none of them a PATTERN — the PATTERN branch required agroup_key == "outcome:accepted"and a census found 0 accepted-tagged REVIEWs (against 97independent, 3046history), becausedetect_implicit_feedbackboosts the linked fact or supports an episode candidate and both return before the fallback that would carry the tag. Meanwhile it re-consumed its own summaries as fresh evidence (68 synthesized vs 29 raw), and every run multiplied the whole corpus by 0.97. A live census also showed zero ≥3-member clusters at cosine 0.85 across all 1152 valid REVIEWs, so it could not fire on real data anyway. The git-verified episode ledger (_promote_repeatedly_supported_candidate, ≥2 verified acceptances spanning ≥2 revisions) is the learning path that remains — do not reintroduce an unverified similarity-clustered route beside it. Facts minted before the removal keep theirsynthesizedtag and prune immunity; nothing mints it now.SUPERSESSION_THRESHOLD(0.85) and canonical-signature dedup are untouched — they were always separate from the deletedSYNTHESIS_SIMILARITY. - Candidate verifiability gate (
memory.outcomes.suggestion_is_verifiable, applied inengine._store_reasoning): a candidate is minted with its task-type kind only when a downstream git diff could ever confirm it — the path must be one attribution could name AND there must be code/diff text to compare against. Otherwise it is downgraded to non-promotablereview. Promotion is gated on git-verified acceptance, so an unverifiable suggestion was previously mintedpatternand then sat pending forever. Measured live: only 23 of 65 recorded suggestions were verifiable at all; the rest are advisory prompts where the model answers with a topical pseudo-path (/review/commit-<sha>.md) instead of an edit target. Footgun: this predicate MUST resolve paths vianormalize_suggestion_path— the same helperOutcomeTracker._normalize_pathdelegates to. An independent copy missed bare-leading-slash paths (/src/foo.js, which normalize to repo-relative) and wrongly rejected two genuinely promotable candidates. A not-yet-existing path still qualifies when its parent dir is inside the repo — proposing a NEW file is legitimate and shows up ingit logonce committed. Known limit: a bare-slash name at the repo ROOT (/NO_CODE_PLANNING_ONLYvs/README.md) is structurally indistinguishable from a real new file and is admitted. That asymmetry is deliberate —kindis frozen at mint, so under-admitting permanently kills a real lesson, while over-admitting only leaves a candidate pending.neo memory learning-statsbuckets recorded suggestions FOUR ways (subcommands._classify_suggestion):verifiable,advisory,unattributable(the bug signal) androot_unavailable. The fourth exists because every resolution test runs against the LIVE filesystem, so a recordedcodebase_rootthat no longer exists — mostly deleted Claude Code agent worktrees — makes them all meaningless; counted as unattributable those buried the one number the report exists to make actionable. Ordering rule: only the empty-input test is decidable from the arguments alone, so the root check runs immediately after it and every filesystem-dependent branch runs below. Placing the prose-suffix/sentinel/docs/branches above it looked string-decidable and was not — under a dead root all three degrade to "does not exist" and silently return advisory, which under-reported the integrity signal by 43% (8 against 14 real) and inflatedmeasurableby the difference. Verdicts (ACTIVE / UNMEASURABLE / STARVED / IDLE) are computed overmeasurable = total - root_unavailable, never against a content bucket: the first version comparedroot_unavailable > verifiable, so a single dead root suppressed STARVED entirely and claimed "most recorded suggestions" off a count of one. An integrity note discloses the unmeasured share in EVERY branch, ACTIVE included. The bug signal is keyed on_SOURCE_SUFFIXES, not on a leading slash — slash-keying split/review/x.jsonfromreview/x.jsonon LM formatting alone and buried two real defects (a model-emitted<placeholder>segment and a new module in a new directory). This classifier is reporting-only and deliberately stricter thansuggestion_is_verifiable, which stays untouched: its bias toward admitting a doubtful path is correct becausekindis frozen at mint, while over-admitting in a report costs only an inaccurate number. Measured: the prose-suffix-before-verifiable ordering correctedverifiablefrom 21 to 10, 11 of which were invented review docs (/ARCHITECTURAL_REVIEW.md) that the plausible-new-file rule had granted because their parent IS the repo root. - Probation: new non-curated facts enter with a
probationtag and a 3-day stale window (vs 7/14); promoted automatically on access_count ≥2 or success_count >0. - Independent-outcome facts capped at 5/session (
MAX_INDEPENDENT_OUTCOMESinoutcomes.py) and 50/project (MAX_INDEPENDENT_FACTSinstore.py). - Invalidation choke point:
_invalidate(fact, *, cascade=True)is the single path that setsis_valid=False. It strips the 768-dim embedding AND the bulk text (body,context_text,retrieval_text) at the transition (_strip_tombstone_text; measured 11,421 tombstones holding 24.7 MB, of whichbodyalone was 15.8 MB).subject/tagsstay for audit andmetadatastays becausepurge_dead_factsages tombstones offmetadata.last_accessedand readsinvalidation_reason— dropping those would strand tombstones forever.episode_contextis deliberately NOT stripped: it is a structuredEpisodeContextwith its ownto_dict, and blanking it to""breaks serialization (caught against a copy of a real store). Safe because nothing reads a tombstone's text: dedup skips invalid facts (_exact_canonical_match), merge-on-save returns early for them (_reconcile_fact), retrieval and clustering pre-filteris_valid. The older wording said only the embedding — a tombstone is never retrieved/deduped/clustered (all such paths pre-filteris_valid) but is retained up to 30 days for supersession/audit, so its embedding (~24 KB/fact) is immediate dead weight; stripping at the source keeps bloat from accumulating between sweeps. All six FactStore invalidation sites route through it (eviction, prune, demote,_cap_independent_factswithcascade=False,_supersede,_synthesize_cluster);superseded_by/event_time_endstay at the call site. Safe because invalidation is terminal (merge-on-save returns OURS when we hold it invalid); no current command re-embeds existing facts (--regenerate-embeddingstargets the legacy ReasoningMemory cache), so the strip is one-way in practice. prune_stale_facts→demote_unhelpful_facts→purge_dead_facts→strip_tombstone_embeddingsrun on every cold start (inFactStore.initialize), each takingsave=Falseso the chain flushes one merge-on-save instead of four.strip_tombstone_embeddingsis now a backfill — it only catches tombstones minted off the_invalidatepath (an ingester superseding a fact; a peer process's still-embedded copy reconciled in) plus any legacy pre-strip rows; it self-heals across processes since every cold start /detect_implicit_feedbackre-runs it. For on-demand compaction of tombstone bloat in a specific project's fact file, useneo memory prune [--all] [--dry-run](neo/subcommands.py:_compact_fact_file— at the package root, not undermemory/); it both drops 30-day-cold invalid rows and strips embeddings off the retained (<30-day) tombstones (reportsremoved+stripped), under the sharedscope_file_lockso it can't clobber a concurrent observer/request-pathsave().neo memory replay-feedback [--all] [--dry-run] [--include-legacy-fallback] [--limit N]re-processes linked session outcomes (ACCEPTED/MODIFIED/UNVERIFIED) to update the linked facts' confidence +success_count— a manual re-run of the implicit-feedback pass, for after a memory-loop fix (store.replay_linked_feedback).--dry-runreports what would change without mutating;--include-legacy-fallbackalso inspects legacysession_*.jsonfiles (may re-replay already-processed sessions). Only touches linked, non-independent outcomes.- Diagnostics (read-only, flag-and-propose):
neo memory issues [--since 14d] [--min-cluster 3] [--suggest-rules] [--json]surfaces recurring frictions mined from transcript history (Claude Code / Codex / CAR) as ranked, evidence-cited issues (missing-tool/absent-guardrail/vague-rule);--suggest-rulesadds a bounded LM call per issue to draft a preventive rule.neo memory rules [--json] [--no-conflicts]flags drift between AGENTS.md / CLAUDE.md / GEMINI.md (gaps + LM-judged conflicts).neo memory audit [--json] [--no-conflicts]inspects an AI tool's memory files (Claude Codememory/*.md) for malformed entries, near-duplicates, conflicts, and MEMORY.md index drift.neo memory import [--dry-run]ingests a peer tool's memory files into neo's store as REVIEW facts on probation (trust-first;imported:claude-memorytag, content-hash watermark for idempotency). (neo/memory/issues.py,neo/memory/rulesync.py,neo/memory/memaudit.py,neo/memory/memimport.py)neo memory citation-stats [--since 7d] [--json]summarizes thecitation_survivalmetric from~/.neo/metrics.jsonl— retrieved/included/used counts plus the per-signal split (by_marker/by_self_report/by_overlap) showing WHICH detector earns the retrieved-fact use-credit. Use it to decide whether the reliable structured self-report carries the reinforcement path or the softer subject-overlap heuristic is doing the work (and thus whether to keep/tune/drop overlap). Read-only, no LM call (subcommands._handle_citation_stats).neo memory learning-stats [--since 7d] [--json]is the promote-side pulse: it reads the episode ledger (~/.neo/episodes, no LM, no fact-store scan) and reports episodes, final outcomes, candidate statuses (durable / supported_once / contradicted / rejected_by_verification / …), and learning actions (promotions, rollbacks, demotions, reinforcements incl. cited-fact credit) from the ledger mutations. Scoped to the INTERACTIVE / attributed path: an IDLE reading means the accept-driven loop is quiet (suggestions not accepted downstream), NOT that neo isn't learning — the background promote engine (observer transcript/GitHub-PR mining) mints facts with no episode footprint and is deliberately not counted here. Together with citation-stats it forms an "is it learning?" dashboard (subcommands._handle_learning_stats).issuesreuses the ingester'sTranscriptSourceepisodes but never admits facts or touches thetranscript_watermark_*watermark — decoupled from fact admission and idempotent (find_issues). Gate mirrors the old synthesis discipline (≥min_clustermembers, ≥2 sessions, ≥2 frictional, verbatim evidence); clusters atissues.CLUSTER_SIMILARITYvia the sharedmath_utils.cluster_by_similarity. Seedocs/solutions/conversation-mined-issues.mdanddocs/solutions/rule-file-sync.md.
- Per-scope valid-fact caps (
- Transcript sources (
memory.transcript, theTranscriptSourceProtocol): theTranscriptIngestermines lessons from four sources by default —ClaudeCodeSource(~/.claude/projects/**/*.jsonl),CodexSource(~/.codex/sessions/**/rollout-*.jsonl),CarSource(~/.car/sessions/*.json), andGitHubPRSource(merged PRs + review threads via theghCLI). A source may declare optionalfact_kind/extra_tagstrust overrides that the ingester'sadmitreads (default = today's PATTERN/FAILURE +transcript-derivedtag).GitHubPRSource: PROJECT-scoped (owner/repo derived from the git remote, so PR facts co-scope with that repo's transcript facts under the sameproject_id); enters facts as REVIEW on probation (imported:github-prtag) — trust-first, and NOT promoted by recurrence (only an independent git-verified acceptance ever mints PATTERN). Mine-once (watermark keyed on PR number, bounded); maps title+body→ask, reviews/comments/inline-thread comments→assistant_text,CHANGES_REQUESTED→errors; filters bot authors; skips PRs with no human discussion. Throttled to oneghfetch per repo per_GH_PR_FETCH_INTERVAL(3600s) so the all-projects sweep keeps near-zero work on unchanged repos. Self-disables (returns[]) when the remote isn't github.com orghis absent — no env flag. Known limits (deferred): merged-only, no PR-diff ingestion (discussion text only), no historical backfill beyond the_GH_PR_PAGE(=25)-most-recently-updated window, GitHub Enterprise hosts and fork-origin upstreams not handled. - Domain tags (
Fact.domain,memory.models.SUGGESTED_DOMAINS): optional free-form area tag orthogonal toFactKind—code-style,testing,git,debugging,workflow,security,file-patterns,architecture,performanceare the suggested vocabulary, but any string is valid.retrieve_relevant(..., domain=...)filters by exact match;domain=Nonereturns all facts including unset ones. - Pending sessions are RETAINED, not cleared (
outcomes.collect_outcomes). Pendingness is per SUGGESTED PATH, not per session — this is the whole subtlety._get_changed_files_sincereturns every file changed anywhere in the repo, so the first version'sif not changed_files: pending.append(...)only retained anything in a repo with no commits AND a spotless working tree since the suggestion. One unrelated dirty file dropped the session and lost the acceptance exactly as before the fix; every test passed because they all ran on a pristine tree, the one state neo is never invoked in._unresolved_suggestionsnow keeps a reduced record holding only the suggestions still outstanding — git-trackable paths git hasn't touched yet. Resolved paths are removed so they can't re-emit, and review-only paths are removed because their weak UNVERIFIED already fired (retaining them re-emitted it every invocation, growingVerificationEvidenceper episode without bound). Anything pastPENDING_SESSION_TTL_SECONDS(14d) is dropped so the log stays bounded._get_working_tree_changesis hoisted OUT of the per-session loop: it's timestamp-independent, and re-forking it per retained session measured 0.88s of puregitforking at 40 pending sessions, on the request hot path, growing linearly. The session log is merge-on-write, not last-writer-wins._rewrite_session_logtakesstore.scope_file_lock, then RE-READS the log under it and preserves every record whose_session_keyis absent from_last_loaded_keys— i.e. appended by a peer since our read. Locking the write alone was NOT enough and the loss was reproduced: a second neo process saving between one process's read and itsos.replacewas erased without trace.save_sessiontakes the same lock for its append. Merge-on-write rather than a wider lock on purpose — holding the lock across the git and LM work incollect_outcomes/replay_linked_feedbackwould let a slow reasoning run block another process's save.replay_linked_feedbackmust useconsume_sessions_keeping_pending(), never a wholesale delete — it is the documented repair command for a broken memory loop, so nuking the log there destroyed every pending suggestion the moment you ran it._clear_session_logis deleted; do not reintroduce it. This used to clear the WHOLE log whenever any session existed — so any neo invocation between "neo suggests X" and "user applies X" silently destroyed the pending suggestion. The multi-session read incollect_outcomesexists to prevent exactly that loss and the unconditional clear defeated it one level up. Measured: 30d of real traffic = 108 episodes, 58 stuck atsuggested_pending_downstream_outcome, zeroacceptedoutcomes ever — so the promote path (needs 2 git-verified acceptances) could never fire no matter how correct its own gates were. The July drill only worked because the operator applied the diff with no intervening run. Retention rewrites the log (_rewrite_session_log, temp +os.replace) rather than appending, or one suggestion would accrue a copy per invocation and bump its fact once per copy._is_non_git_trackableis shared by the weak-acceptance detector and the retention rule so they can't disagree about what's still worth waiting on. Test note:outcomes.SESSIONS_DIRis resolved at IMPORT time fromPath.home(). conftest now re-points it (and every other import-time home constant) at the fake home per test — see the home-isolation entry below — but tests sharing a fixedproject_idstill read each other's session logs within a run, so scope by a unique id. - Outcomes (
memory.outcomes+store.detect_implicit_feedback): ACCEPTED/MODIFIED act on the linked original fact when present — confidence +0.2 / −0.2 (both ±arch_mod); ACCEPTED also bumpssuccess_countand sets effectiveness "better", MODIFIED sets "worse". UNVERIFIED mutates nothing: absence of verification is not success, so the evidence is preserved in the learning episode (candidate status →unverified) and neither confidence norsuccess_countmoves — the live path andreplay_linked_feedbackshare that invariant (store.py:2148,store.py:2301). MODIFIED also writes a REVIEW at confidence 0.4; ACCEPTED falls back to a REVIEW (suggestion_confidence + 0.1) when no link is found; UNVERIFIED never creates a REVIEW. INDEPENDENT writes a REVIEW at confidence 0.2. Footgun: if you add a newOutcomeType, update bothoutcomes.pyandstore.detect_implicit_feedback. - "The linked original fact" means the DURABLE fact the suggestion re-applied,
and the link nearly died in silence.
suggestion_fact_ids(file_path → fact_id) is the only input to the ACCEPTED reinforcement branch and toreplay_linked_feedback. It is built byengine._build_suggestion_fact_idsfrom the fact_store_reasoningreturns — and when episodes replaced immediate fact-writing (412a174, 2026-07-18) that function started returningNoneon every path (all threereturns; the legacy branch too). The builder returns{}for aNonefact, so the map was unconditionally empty from that commit on. Nothing raised: the ACCEPTED branch just fell through to the candidate path,reinforce_legacy_factbecame unreachable, andneo memory replay-feedback— documented right here as the repair command for a broken memory loop — became a no-op that reports success. Measured on a live install: 51 sessions through 2026-06-19 carried one link per suggestion, then 9 consecutive sessions from 2026-07-19 carried zero, and no fact'ssuccess_countmoved again in 90 days (learning-statsreinforcements = 0). Across 6,613 valid facts in every project, zero have ever reachedsuccess_count >= 3, sofind_contributablehas never once returned a fact andneo contributehas never been reachable. That absence had a SECOND, independent cause, and restoring the link alone would not have lifted it — the cited-credit path reached nosave()at all, so the one mechanism that can carry a fact past the 2 that promotion writes was discarding its own work. See "A credited fact must reach a SAVE" below; read the two together before concluding the contribution gate is fixed. The fix does NOT restore per-suggestion fact minting — that is the unverified flood412a174existed to stop. It resolves the link through the episode candidate instead:find_durable_fact_for_candidate(subject)matches the candidate's_episode_signatureagainst the FROZENcanonical_signature, which is written only by the two promotion paths, so a hit means "this exact lesson is already durable" and this suggestion is an application of it. A candidate with no durable fact yet contributes no link — its early acceptances are evidence toward promotion, which the candidate path already owns. Footgun: match the frozen field, never a recomputed signature, and never let an empty target match the empty default — every unpromoted fact would answer for every candidate. - A credited fact must reach a SAVE, and the cited-credit path had none.
detect_implicit_feedbackcredits two different populations: linked ORIGINAL facts (a suggestion re-applying a durable fact), which movelinked_count; and CITED retrieved facts, credited by_apply_used_fact_feedback, which bumpsuccess_counton the live Fact objects and record an episode mutation and nothing else. The single save wasif linked_count: self.save(), so a run that credited a cited fact with no linked original fact never wrote the store and the credit died with the process — the normal case, sincesuggestion_fact_idsis empty unless the candidate already resolves to a durable fact. The episode ledger still recorded the mutation, solearning-statsreported reinforcements the store never received; the reporter and the data disagreed and the REPORTER was the honest one. Measured live: 0 of 88 valid GLOBAL facts had ever reachedsuccess_count > 0while 64 carried a non-zeroaccess_count(global facts are almost never the linked original, so theirs were the credits always dropped), and PROJECT facts topped out at exactly 2 — the value promotion writes from its two supporting episodes — so no fact among 6,613 ever cleared thesuccess_count >= 3contribution bar andneo contributewas mechanically unreachable, not merely starved. Gate is nowif linked_count or touched_fact_ids. Footgun — two neighbours save for their own reasons and will mask this. The no-link ACCEPTED fallback callsadd_fact, which saves; and the janitor chain underif outcomes:ends inif changed: self.save(). The credit survived whenever either happened to fire, which is what made the bug load-dependent. The real modern path reaches neither: an ACCEPTED outcome carrying an episode candidate takes the candidate branch andcontinues past the fallback, and a FIRST acceptance promotes nothing.test_cited_fact_credit_survives_the_processtherefore setscandidate_id, pins the janitor to "changed nothing", stubs promotion toNone, and RELOADS FROM DISK — all four load-bearing; the first two cuts of that test passed against the broken code, once viaadd_factand once via the janitor. Every other test inTestRetrievedFactAttributionasserts the mutated in-memory object and never reloads, which is how a suite thorough about attribution stayed silent about persistence. Any new credit path needs its own save, and a test that reads the fact back off disk. - A re-accepted durable pattern is reinforced in place, not re-minted.
_promote_repeatedly_supported_candidatelooks for an existing valid PROJECT fact at the target signature before callingadd_fact, and on a hit folds in the new supporting episodes, raisessuccess_countto the support count and addsREACCEPTANCE_BOOST(0.05).add_fact's pre-write dedup cannot catch this: its signature includes the BODY, which is one episode's run-varying LM prose, so a third acceptance worded differently wrote a SECOND durable fact for the same lesson.collapse_duplicate_signature_factsheals that after the fact but keeps the richest by supporting-episode count — the NEW fact, at its freshly capped mint confidence — silently discarding whatever the original had earned. This is also the only way past the mint cap: promotion mints atmin(0.75, 0.4 + 0.1·n), so without an in-place boost a repeatedly-verified pattern could never reach the 0.8 contribution bar no matter how many acceptances it collected. durableis a terminal candidate status.detect_implicit_feedbackcalls_record_attributed_episode_outcomea SECOND time right after a successful promotion, to record the mutation. That method used to assign the ACCEPTED status unconditionally, so it immediately walked the just-writtendurableback tosupported_once— leaving every promoted candidate readingsupported_oncebeside a populatedpromoted_fact_id, andlearning-statsunder-reporting the one number it exists to report. Promotion is not the only writer of that field, so the guard lives in the status loop, not the caller.- The protection boost is bounded by evidence, because it runs per PROCESS
START.
demote_unhelpful_facts(cold-start chain) addsPROTECTION_BOOSTto any fact withsuccess_count > 0and hit rate ≥PROTECTION_HIT_RATE. Unbounded, that compounds once per neo invocation, so confidence measured how often the process started rather than how often the fact was right — measured on a live store, the 93 facts with any success at all averaged 0.968 confidence and 57 sat at exactly 1.00, several of them throwaway drill prompts holding a single success against 40-odd accesses. The boost now stops atmin(PROTECTION_CEILING_MAX, PROTECTION_CEILING_BASE + PROTECTION_CEILING_PER_SUCCESS · success_count). Verified outcomes may still carry a fact above that line; the comparison is strictly>so protection can never claw back confidence it did not grant. This is why the contribution banner had it backwards: confidence was the half satisfied almost accidentally, and successes the half nothing could move. - Community contribution gates are single-sourced in
store.py(CONTRIBUTION_MIN_CONFIDENCE/CONTRIBUTION_MIN_SUCCESSES/CONTRIBUTION_EXCLUDED_TAGS), and eligibility is split in two:is_contribution_candidateholds the PERMANENT disqualifiers (kind is CONSTRAINT, or provenance is a seed/community/history feed), while the two numeric thresholds are the only part a fact can grow out of. The split exists so a caller reporting why a fact is not contributable can name only the gate that binds —subcommands._describe_contribution_gap. The status banner used to print a fixed "need 0.8 confidence + 3 successes" at facts already sitting at 1.00, which is this repo's own rule about never blaming a cap for an absence it did not cause, broken in the one line a user reads most. It also filteredneardifferently fromfind_contributable, so it could advertise facts that were never contributable at all. - Retrieval: `rank_score = recall_decay(sim)·confidence + success_bonus·effectiveness_f
- provenance_bonus
.memory.models.rank_scoreis the single source of truth — if you change the formula, auditContextAssembler._score_factstoo. Cosine is batched viamath_utils.batched_cosine. Hybrid: 0.7·dense + 0.3·BM25; half the result slots ranked byrank_score, half by raw cosine. CONSTRAINT/ARCHITECTURE/DECISION and theseed/community/synthesizedtags bypass decay. Branching prompts (CHAIN/SPLIT) get per-branch retrieval viamemory.query_routing`; each surfaced EPISODE pulls up to 2 peer episodes from the same session.
- provenance_bonus
- Local storage: per-scope JSON files in
~/.neo/facts/with inline embeddings. Fine while any single scope file stays under ~10k facts; revisit the backend past that.project_idisSHA256[:16]of the normalized git remote URL (scope._compute_project_id) so the same repo on different clones / worktrees / machines hashes to the same ID. Falls back to a path hash for repos without a remote. Legacy path-hashed fact and watermark files are renamed in place onFactStoreinit (store._migrate_legacy_project_id_files). - Context assembly four-layer model is from Beyond Conversation: A State-Based Context
Architecture for Enterprise AI Agents (Liotta, 2025); the
ContextAssemblertoken-budget enforcement is ported from Memgine: A Deterministic Memory Engine for Stateful AI Agents (Liotta, 2026). Both PDFs: state-based-context-architecture and memgine-deterministic-memory-engine. Both are evaluated by StateBench. Changes to layer ordering, the 2/3 constraint cap, or the inline(changed from: X)annotation should preserve the validated 95.8% decision-accuracy contract (GPT-5.2 on the v1.0 development split). Seedocs/solutions/token-budget-enforcement.md. - Delivery-cap sweep (
docs/delivery-cap-sweep-2026-08-30.md;rank_mine_eval.py --max-files N).--max-files(default 30) had never been measured, and could not be until it was fixed:calculate_adaptive_limitreturned its three broad-prompt buckets VERBATIM, so--max-files 5on a vague prompt delivered 15, and sweeping below 25 moved nothing for any prompt that was not highly specific. The default of 30 is well chosen and must not be cut to 10. Rescored over everything the model receives: recall(delivered) rises monotonically — neo 0.523 / 0.654 / 0.776 / 0.813 / 0.869 and m365dotnet 0.571 / 0.652 / 0.714 / 0.741 / 0.750 at caps 5/10/20/30/50. Going 10 -> 30 buys +24% relative recall on neo and +14% on m365dotnet for +50% context bytes; 30 -> 50 buys +7% / +1% for a further +28% bytes AND degrades the top of the list (MRR 0.763 -> 0.752, R@1 0.403 -> 0.377), so extra files dilute the ranking they extend. 30 sits where returns flatten without that dilution. Footgun, and the reason a first pass concluded the opposite: R@10 and MRR are structurally blind to ranks 11+, so a correct answer delivered at rank 15 cannot move either number at any cap. They read flat from cap 10 to 50 because they COULD NOT VARY, and the first analysis published "the knee is ~10, the default costs 15-50% for nothing" in v0.52.0's changelog and release notes before this was caught. Measured properly, truth sits at rank 11-30 for 15% of neo's answer files and 11% of m365dotnet's, so a cap of 10 would drop 23 of 107 and 14 of 112 respectively (concretely:src/neo/cli.pyat rank 14,project_index.pyat rank 12). A delivery question needs recall over the DELIVERED set, never recall@10 — using a top-k metric to size a cap larger than k measures nothing about the files the cap admits. Byte cost is not monotonic in file count — cap 5 spends MORE than cap 10 (142 KB vs 131 KB) while delivering less, because--max-bytesis apportioned across whatever is admitted. Below 10 there is real loss on both metrics. - Effectiveness evidence (
docs/effectiveness-evidence-2026-08-30.md,tools/rank_baseline_eval.py). Absolute R@k figures have no comparator and cannot answer "is neo effective". The baseline harness scores four naive rankers over the IDENTICAL candidate set, queries and ground truthrank_mine_eval.pyused, so only the ranking rule differs. The load-bearing baseline isgrep— prompt-token counts over file CONTENT — because the mining leak (a file holds the commit's terms because that commit put them there) helps any content-reading ranker and does nothing forsize/recency. Comparing only against content-blind baselines would let the leak masquerade as ranker quality. Measured, MRR vs grep: neo 0.778/0.584 (1.3×), aieweb 0.729/0.511 (1.4×), m365dotnet 0.536/0.219 (2.4×); pooled over 150 cases 81 better / 37 worse / 32 tied, sign p = 6.3e-05. The margin GROWS with repo size (vs random 6× → 32× → 43×) because naive heuristics collapse as the candidate pool grows —sizeruns MRR 0.443 on neo's 99 files and 0.098 on m365dotnet's 2,882. The comparison is conservative toward neo: neo is scored on the ~30 files it would actually deliver while every baseline ranks the full set (99 / 480 / 2,882). Footgun:randommust be seeded fromhashlib, nothash()— the latter is salted per process and did not reproduce; andrecencyis mtime-based, so editing this harness (which lives in the repo it measures) moves its own score. Both are floor comparators; the deterministic ones reproduce byte-identically across runs. The answer link is measured too, in a constructed pre-fix repo (git-mined cases cannot show it — the fix is already at HEAD, so neo correctly proposes nothing): three planted bugs, each with a failing test defining "fixed", run with the buggy file in context vs--excluded. 3/3 patches applied and passed with the file, 0/3 without. The mechanism is narrower than it looks and is recorded as such: arm B's fix LOGIC was right (for one bug byte-identical to the passing arm) and the patch was rejected because the surrounding context lines were hallucinated. Retrieval's measured contribution there is groundedness, not reasoning — on textbook defects priors supply the fix either way. A second experiment closes that gap: two conventions defined only in a scratch repo (aConfigErrorwhosecodekwarg is required, a key rule stripping anacme::prefix), with the CONVENTION file excluded in arm B while the file to edit stays in context, so only project knowledge varies. 2/2 pass with the convention visible, 0/2 without — and the failure mode is REFUSAL, not a wrong guess: neo names the file it needs and says the convention "cannot be verified". Instructive contrast with the textbook case, where priors gave it false confidence and it patched against hallucinated context. Footgun that invalidated the first run: the tests defining the conventions lived IN the repo and were retrieved at score 2.52, so the no-context arm read the answer out of the answer key and "passed". A benchmark whose answer key is inside the corpus measures nothing — the tests now live outside the repository. What this does NOT show: how often real tasks turn on project-specific knowledge (n=2 is an existence proof, not a rate), and the learning loop delivering value in practice — on a live install that loop is starved, 2 accepted outcomes in 208 episodes and both from drills. - Learning-loop benchmark (
memory/evaluation.py,benchmarks/learning_loop_v1.json,neo memory evaluate-learning).acceptedis a correctness verdict and nothing else — never gate it on wall-clock time. Everything it enforces is reproducible on any machine: twelve scenarios, four safety rates that evaluate to exactly0.0, the primary-metric comparison against the memory-disabled baseline, and a zero model-call assertion. Latency is a property of the hardware, so it lives in a separateperformance_budgetblock and surfaces asperformance_within_budget/performance_notes, advisory, with no effect on the exit code. It used to sit insafety_thresholds: a GitHub runner recorded 592.44ms against the 500ms limit with every scenario passing and every rate at zero, while the same commit ran at ~53ms locally — an 11× spread with no code difference, so no threshold can be both sensitive enough to catch a regression and loose enough to survive a shared runner. Retuning the number only moves the coin-flip. Worse,acceptedis the benchmark's published verdict, so a timing wobble invalidated a correctness claim, and the failure presented asassert report.accepted is Truewith a 9,000-character repr — it reads as "my change broke the learning benchmark" and costs a real detour to rule out. Corpus schema 2 moved the key; schema 1 still loads and its budget is read through the fallback in_check_performance_budget, because--corpuslets a caller supply their own file. Footgun: keeping the two verdicts apart is the whole fix, and the way to undo it is onefailures.extend(performance_notes)—test_no_latency_text_leaks_into_acceptance_failuresexists for exactly that edit, since the two obvious tests either side of it stay green when it happens (#183). The gate came back once already, as a TEST rather than a threshold.test_within_budget_run_reports_cleancalledrun_learning_evaluationagainst the REAL 500ms budget and assertedperformance_within_budget is True— i.e. it asserted the ambient machine is fast, which is the identical coin-flip one level up. It failed CI at 534.80ms on a commit measured at ~50ms locally (three runs each on branch and main, pinnedPYTHONPATH, ≤1ms apart — so not the change under review), withaccepted=True,acceptance_failures=[], all twelve scenarios passing and every safety rate 0.0. A correctness PR was red for a reason that had nothing to do with correctness, which is the whole defect #183 named. Every OTHER test in that class is deterministic because_over_budgetforceslatency_ms_max = 0.001, something no machine can meet; the clean-report case now goes through_within_budget(1e6 ms), something no machine can exceed. Rule: no test in this class may depend on how fast the machine running it is — pin the budget in whichever direction the case needs, and never mock the clock (a patched timer tests the patch). - A2UI memory inspector (
neo.a2ui): a per-project A2UI v0.9 surface (neo-<project_id8>) registered with the runningcar-serverdaemon so any conformant renderer (CarHost.app, future webviews) can inspect neo's state live. Two tabs: Observer (status badge, pid, last cycle, recent cycles list, Kick/Stop buttons) and Memory (valid fact count, by kind, by scope, probation count). Updates pushed by the observer process at the end of each sweep cycle — the same FactStore load powers both tabs, so the inspector adds zero hot-path cost. Kick/Stop buttons emita2ui.actionnotifications which the observer dispatches tokick_observer/stop_observer— closes the loop with CAR's supervisor. Footgun: Python'scar_runtime.a2ui_*helpers are in-process only; reaching the daemon's shared store (which renderers subscribe to) requires speaking JSON-RPC over its WebSocket.neo.a2ui.DaemonClientis that bridge. Activation: auto when127.0.0.1:9100is reachable; silent no-op otherwise. Addswebsockets>=12.0to the[car]extra. - Async transcript-mining observer (
memory.observer): a single global background process (CAR agentneo-observer, daemon--daemon --all) that sweeps all discovered projects each cycle — round-robin/budgeted (max_projects_per_cycle, default 25; watermark- AND mtime-gated so unchanged projects do near-zero work (the watermark alone gates only admission: sources still parsed every transcript each cycle, measured at 298 MB for one project, which is what drove multi-GB observer RSS._unchanged_sincenow skips files untouched since the watermark file's mtime minus a 1h skew margin; every error path falls back to parsing, because a wrong skip loses learning silently)) — running transcript mining per project. (It also ransynthesize_reviewsuntil that subsystem was removed.) Two roots can share aproject_id(two clones of one remote, e.g.flyx/fms+flyx/fms2), meaning one fact file and one pid-keyed watermark; the sweep keeps a per-cyclestore_cacheso such a project loads and synthesizes once, while transcript ingest still runs per root — attribution is by cwd and the second clone has sessions the first would never see. Not opt-in:maybe_autostart_observer()(called fromcli.main) auto-registers it whenevercar-serveris reachable; opt out withNEO_OBSERVER_AUTOSTART=0. No CAR → one-time hint, then silent. Footgun — that export belongs in~/.zshenv, never~/.zshrc. The gate is a plainos.getenv(observer.py), read by whichever process runsneo, and most of those are NOT interactive shells: an editor plugin, a CI step, a git hook, an agent tool call. zsh sources.zshrcfor interactive shells only, so an export there leaves every programmatic invocation autostarting the observer while the terminal prints0and looks correct. Measured directly: with it in.zshrc,zsh -c,zsh -lcand a non-interactive tool call all read empty; onlyzsh -icread0. Verify without-i— that flag forces the single mode that works, so the obvious check passes for the wrong reason and confirms nothing. Projects are discovered from~/.claude/projects/*(decoded roots), minus container roots (observer._is_container_root): a decoded root that holds another discovered root and is not itself a repo. Claude Code mints a transcript dir for whatever cwd a session ran in, so ad-hoc sessions from/,$HOMEor~/gitcreated pseudo-projects that were ancestors of every real one — 26.3s of a ~40s cycle, andCodexSource's cwd-prefix attribution then claimed the machine's entire Codex history under them (/matched everything because"/".rstrip("/")is""). The.gitclause only ever rescues an ancestor (a monorepo is a real project); it is never a blanket requirement, since many real roots here have no repo. Nested real roots are disambiguated byCodexSource(peer_roots=…)— deepest root wins. Known limits: the inventory is Claude Code's, so a project only ever opened in another tool is invisible; comparisons are case-sensitive on a case-insensitive filesystem. On bootstrap/start, legacy per-project agents (neo-observer-<id12>, the old model) are stopped +agents_removed. Hard dep: car-runtime ≥ 0.18.0 (pin floor 0.27.0) + a runningcar-server— CAR's supervisor owns spawn / restart-on-failure / clean SIGTERM. Logs at~/.car/logs/neo-observer.{stdout,stderr}.log. Lifecycle/status/orphan-check all operate on the single global agent. (A2UI per-project inspector is skipped in global mode.) RSS bounding: the daemon re-execs itself everyNEO_OBSERVER_RECYCLE_CYCLEScycles (default 48, ~4h; 0 disables). Its RSS is peak working set plus CPython arena fragmentation — each sweep deserializes a multi-MB fact file (79% of whose rows are tombstones retained by the 30-day policy) and the allocator never returns those arenas. Nothing leaks; RSS drifts to the high-water mark and stays (measured 0.5–0.7 GB). Embeddings are NOT the cost (1.9 MB in memory; they dominate the file on disk only, as JSON text). Re-exec, not exit-and-be-restarted: the CAR spec isrestart: "on_failure"withmax_restarts: 10, so a clean exit(0) would never restart and forcing a non-zero exit would exhaust the budget after ten recycles. Footgun: the single-instance lock MUST be released before the exec. Carrying the fd across looks safer butflockis owned by the open file description, so the inherited fd keeps the file locked and the new image can never take it — it exits as "contended" and the machine is left with no observer. That failure was measured directly. The floor is one cycle's working set (~380 MB), so recycling caps drift, not baseline. Lifecycle:neo memory observer {start|stop|status|kick}—kickmaps toagents_restartsince CAR has no signal-passthrough primitive. Status surfaces CAR's raw state verbatim (running|stopped|starting|backoff|errored) so restart-loops are diagnosable, and also flags orphaned observer processes — aneo.memory.observer --daemonreparented to init/launchd (ppid==1, or no live parent on Windows) by a dead prior car-server, which CAR's supervised view can't see (observer._find_orphan_observers; theorphansfield + aWARNING). Orphans are now auto-reaped, not just reported:_reap_orphan_observersSIGTERMs them (re-checking each pid's cmdline right before the signal to defend against pid reuse) and is wired intostart/stop/autostart and the daemon's own startup. A second guarantee backs it up — the daemon holds a cross-process single-instance lock (_SingleInstanceLock,fcntl/msvcrton~/.neo/observer.lock) for its lifetime, so two observers can never run a sweep at once even in the handoff window; a contended daemon exits 0 (benign no-op, no CAR backoff). If a straggler ignores SIGTERM past the_LOCK_ESCALATE_AFTERgrace, the daemon escalates to SIGKILL so the kernel frees the lock — safe becauseFactStore._save_fileis atomic (temp +os.replace), so a hard kill can only leave a stray.tmp, never a torn fact file. (This is belt-and-suspenders:store.save()already serializes writers with its own per-scope flock, so the orphan was never a corruption bug — just doubled LM spend and mining.) Tunables:NEO_OBSERVER_INTERVAL_SECONDS(default 300),NEO_OBSERVER_COOLDOWN(default 60, per-process). Footgun: the interpreter path (sys.executable) must not live under a world-writable directory (/tmp,/private/tmp,/var/tmp,/dev/shm) — the CAR daemon rejects such commands as a security measure. Use a venv under$HOMEor a system install. - Observability: retrieve / add_fact / lm_call / overseer_tick events land in
~/.neo/metrics.jsonl. Gated byNEO_PROFILE:off(no emit),minimal(lm_call only),standard(default, all events),strict(reserved for future verbose events; currently == standard).NEO_METRICS=offis a legacy hard kill-switch that overridesNEO_PROFILE. The log rotates tometrics.jsonl.1at 32 MB (one generation retained); the size is sampled every 500th write so the steady state stays onewriteper event. Readers (memory citation-stats,memory learning-stats) window by--sinceand read only the active file — a--sinceolder than the last rotation silently sees less history. Sessions and watermarks live in~/.neo/sessions/. - Disk hygiene:
FactStorereaps abandoned*.tmpatomic-write files older than 24h from~/.neo/factson cold start (_reap_stale_temp_files). A save that is SIGKILLed betweenmkstempandos.replacestrands its temp file —_save_fileunlinks on exception, but SIGKILL runs no handler, and the observer's own lock-escalation path SIGKILLs stragglers by design. 88 MB had accumulated this way with nothing to sweep it. scope._get_git_remote_urlis memoized per root (clear_remote_url_cacheresets it, called once per observer cycle). Resolving one project's identity asked git for the same remote 3× — ~100 forks/cycle, ~26k/week. Staleness is bounded to one cycle because a stale hit would write facts under the wrongproject_id.- Measuring retrieval changes: pin
PYTHONPATH, and measure the REAL pipeline. Two traps, both hit during the BM25 work, both producing confident wrong numbers. (1) The venv installs neo EDITABLE againstsrc/, so running.venv/bin/python -m neo.clifrom a git worktree of another commit executes THIS tree's code against that tree's files — a "baseline" run that is not the baseline. Every A/B needsPYTHONPATH=<that tree>/src, andrank_mine_evalnow REFUSES to run unless the tree it was handed is the treeimport neoactually resolves to — mandatory is not the same as effective, since the editable.pthsilently catches a typo'd path and measures the working checkout twice. Measured on the superseded harness generation: it made main look like MRR 0.613 against a real figure of 0.082 (the current harness puts main at 0.304 — different instrument, same trap). (2) Callingscore_candidatedirectly measures the FIRST-PASS ranking only;gather_contextthen re-ranks withpi_boost + hist_boost + _symbol_scoreand applies an adaptive limit and a byte budget. A first-pass harness overstated R@10 by 0.14 against the real CLI. Validate any in-process replica against--dry-runoutput before trusting a sweep. - Debugging:
neo --dry-run "your query"runs the real engine — file selection, fact retrieval, constraints, four-layer assembly — and prints the exact messages that would go to the provider, then exits without making the LLM call. Faster iteration on context-gatherer and retrieval changes than waiting for an inference round trip. Use it before believing any claim about what Neo "saw" — the two defects below were both invisible from the outside and presented as the model being unhelpful. This bullet described the tool's intent for a long time and not its behaviour: the flag used to exit incli.mainbefore the engine was constructed, so three of the four things listed above never ran and the output was the file list alone. The Execution Envelope, retrieved facts, and the REPOSITORY CONTEXT block with its truncation markers — the #178 work, whose entire point is that a cut be visible — were all uninspectable through the tool built for inspecting them. An instrument that under-reports sends the operator to the wrong knob, which is the same failure as a cap that blames itself for an absence it did not cause. The prompt is recorded, never rebuilt (neo.dry_run.RecordingLMis a realLMAdapterinstalled in the engine's ownself.lmslot), because a renderer that walked the context dict would be a second implementation of the seven prompt builders, free to drift the moment one changed — the duplicated-rule shape that putEXCLUDED_DIR_NAMESin two places. What it shows is the adapter's INPUT, not the wire payload: Anthropic hoistssysteminto a separate kwarg, Google remaps roles, Ollama flattens, CAR addsintent_json, and no provider is resolved at all because the flag deliberately requires no credentials. The output says so rather than claiming exactness it cannot have.DryRunCompletederives fromBaseException, notException:_process_guardedconverts anything itsexcept Exceptioncatches into aFAILEDlifecycle event, and reporting a dry run as a crash would be one more way of misdescribing the run. An ordinary exception is NOT a safe substitute —_deliberatehas its ownexcept Exceptionthat would swallow it and silently fall back to the fast path. The panel is forced OFF underdry_run. This is correctness, not tidiness:_build_car_role_factorycallscreate_adapter("car", model=m)per role and usesself.lmonly as the fallback, soRecordingLMnever intercepts it. Withcar-serverreachable — the normal setup here, since the observer autostarts off it — a novel prompt under--dry-runran the full panel against real models, spent real money, never raisedDryRunComplete, and printed ordinary output. Measured: 4 real adapters built. It is also the honest scope, since the panel's later prompts are built from earlier model responses and cannot be shown without making the calls the flag exists to avoid. A dry run does not modify the fact store, which is narrower than "mutates nothing" and is the claim that survives measurement. The old implementation got it for free by never constructing aFactStore;FactStore.initializerunsprune_stale_facts→demote_unhelpful_facts→purge_dead_factsand then saves, anddemote_unhelpful_factslowers confidence and invalidates facts — so reaching the engine at all meant a "read-only" inspection was aging the store it inspected.FactStore(read_only=True)makessave()a no-op at the single write choke point, which a new caller cannot forget;dry_runalso skipsdetect_implicit_feedbackand_complete_learning_episode. Retrieval still marks facts accessed in memory, andmetrics.jsonlstill records the run — deliberately: the two events it writes (execution_context_resolved,retrieve) are read by neithercitation-stats(which filterscitation_survival) norlearning-stats(which reads the episode ledger), so observability costs nothing. That argument was measured on the fast path and briefly untrue on another. VERIFY mode reasons without an LM call, soprocess()returns NORMALLY and never raisesDryRunComplete;_complete_learning_episodethen wrote an episode file AND acitation_survivalmetric — the exact two surfaces the sentence above claims are untouched. Measured on a clean HOME: 1 episode, 1citation_survival. Gating that call is what makes the claim true; do not narrow the gate to the recorded-call path. Under--jsonthe report is the single stdout document ({dry_run, calls, note}— a second schema, discriminated bydry_run: true, with noorchestratorkey;test_host_adapter_parity.pydoes not know about it) and a terminalphase_completed(reasoning)+completedpair is emitted. Writing prose to stderr broke both--jsoninvariants at once: zero documents on stdout, and every source line beginning with{became a counterfeit event. Both dry-run exits route throughcli._report_dry_run— there are two, the recorded call and the normal return, and the second shipped without the--jsonhandling the first had. - Project index (
index/project_index.py,index/language_parser.py; full notes indocs/tree-sitter-setup.md). Three invariants, each of which was violated and each of which produced an index that could not answer a question about its own repository:- Budgets are apportioned, never sliced.
_select_filesgroups eligible files by language and hands each a share of--max-filesproportional to repo composition, floor of one slot per language (_allocate_slots); then_cap_chunksround-robinsMAX_CHUNKS_PER_REPOacross FILES so each keeps a chunk before any keeps a second. Both exist because a list slice is not a ranking: globbing**/*.pybefore**/*.csand slicing gave a .NET repo of 4,272 C# files an index of 83 Python files (95 from an in-repo worktree) and zero C#, exit 0. Fixing only the file cut leftchunks[:1000]re-creating it one function later — chunks arrive grouped by file and files by language, so the slice kept 1000 C# chunks and dropped every other language, with 37 of the 100 selected files contributing nothing. And the order WITHIN a language is source-before-tests, then depth, then alphabetical — the test key being the load-bearing one. Depth alone is a centrality proxy that INVERTS on the conventional Python layout: withsrc/<pkg>/…besidetests/…every test sits at depth 2 and every source file at depth 3, so the whole test tree sorted ahead of the whole source tree and--max-fileswas spent before one source file was examined. Measured here: 131 Python files at depth 2, and the catalog came out 100% tests while the build reported success — the cap had genuinely bound and the report was truthful about that, but nothing said the files it kept were all tests (#213). This module already treats "tests outrank the source they test" as a failure mode —_embed_chunksembeds a structured summary rather than the raw body precisely because assertion strings carry a query's keywords verbatim — but that mitigation runs at EMBEDDING time and so never got a chance; selection had already spent the budget. Tests are DEMOTED, not excluded: they fill the slots source does not need, so a test-only repo still indexes.is_test_pathis IMPORTED fromcontext_gatherer, never restated — a second copy of that rule would agree with the first only by coincidence, and its careful cases (testdata/andtesting/are ordinary source;Foo.Tests/is not) are exactly what a re-implementation loses. - Exclusion is two layers and
bin/build/out/target/dist/vendorbelong to neither by name. Both layers, and the walk that applies them, live inneo/eligibility.py— the ONE eligibility module, consumed bycontext_gatherer,ProjectIndexandarchitecture_metricsalike.DEFAULT_IGNORE_PATTERNScovers what repos forget to ignore (.worktrees,.claude/worktrees,node_modules,obj, virtualenvs);load_ignore_patternslayers the repo's own root.gitignore/.ignoreon top, so a repo's!negationcan re-include what a default excluded.should_ignoreonly tests the path handed to it, so ancestor directories must be handled separately —walkdoes that by PRUNING an ignored directory instead of descending, which is git's own rule and the reason a!beneath an excluded directory does not fire. Matching is exact and case-sensitive, against directory components only. The ambiguous names stay out because each is real source somewhere (src/bin/main.rs, vendored trees; 254 tracked files underbin/+vendor/across three local repos) and the asymmetry is one-sided: over-excluding hides code permanently, over-including only spends slots. Three more exclusion classes are NOT gitignore, and conflating them sends an operator to the wrong file:WalkPolicyknobs (symlink rejection, the gatherer's 512 KBMAX_FILE_BYTESceiling, extension and per-language glob filters) are per-consumer policy; nested.gitignorefiles are not read, which under-excludes and is the accepted limit; and git applies ignore rules only to files it does not already TRACK, so a file added before a rule was written stays tracked while the walk still skips it (fourspecs/*.mdon this checkout — recorded, deliberately not fixed in a pure refactor, since closing it means agit ls-filesfork on the warm path). Two tests hold the line:test_eligibility_single_source.pyAST-scanssrc/and fails on a second definition, a secondos.walkor a second exclusion list (detected by CONTENT — three sentinel directory names in one literal — because a copy always renames the variable);test_eligibility_differential.pydiffs the walk againstgit check-ignoreover a fixture corpus AND this checkout, and fails on any tracked file skipped without an ignore rule accounting for it. Both are markedinvariants, so they run in the Guard-invariant battery on every PR. Footgun: the index'sexcludedcount is now excluded PATHS SEEN (excluded_dirs+excluded_files), not files under an excluded directory. A pruned subtree is one path; the walk does not descend and therefore does not know how many files are inside. The old "200 paths" number was only available because the old code globbed the whole repo and filtered afterwards — i.e. it walked every worktree copy in order to count what it was about to discard. - A cap that fired must be reported.
selection_reportcarries eligible / selected / excluded / duplicates / chunk counts and the CLI prints them.truncatedmeans THE CAP BOUND US and isexamined < eligible— whether any candidate went unlooked-at, which the per-language iterators already know. Both cheaper predicates name the wrong knob:selected < eligiblemade dedup print "2 of 7 eligible files (capped at --max-files=1000)", andselected >= max_filesdid the same whenever--max-fileslanded exactly on the unique-file count. Raising a cap that never bound fixes nothing. Separately, round-robin only represents every file while chunk slots ≥ files;MAX_CHUNKS_PER_REPOis fixed at 1000 while--max-filesis not, sofiles_with_chunksreports the shortfall —truncatedis False there, because the FILE cap genuinely was not the constraint. And the corollary the first version of this report broke: never blame a cap for an absence it did not cause. A selected file is missing from the index for one of TWO unrelated reasons, and the console must not guess between them. Either it produced no chunks at all — no function, class, interface or struct for the grammar to match, as in an empty__init__.py, an enum-only.cs, a type-alias-only.ts— which no cap setting changes and which therefore gets a bare statement with NO remedy attached; or the cap took every chunk it produced, which gets the cap named andlower --max-files.files_producing_chunksis measured BEFORE_cap_chunksprecisely so the two stay separable; subtracting the post-capfiles_with_chunksfromselectedconflates them and is what printed "the 1000-chunk cap is below the 25 files selected" for a build that kept 559 of 559 chunks, withchunks_cappedFalse on the same report. It fired on this repo, on any repo with an__init__.py. A report that invents a cause is worse than the silence it replaced, because silence at least does not send the operator to the wrong knob. Query footgun (language_parser.py; incident detail in the tree-sitter doc's "Why queries break silently"): a query that fails to compile is indistinguishable from one that matches nothing —_get_queryreturns None andparse_filemoves on; edge failures log at DEBUG. Four were broken at once, so TS interfaces and ALL C# inheritance edges were absent from every index since they shipped. Three rules follow. Compile results are cached INCLUDING failures (the uncached retry warned per query per file — 9,699 lines in one run). C# bases need all four ofidentifier,generic_name, andqualified_namewith either as itsname:field — across class/interface/record/struct — since a narrower pattern compiles fine and silently drops: Repository<Order>,: System.Exceptionandinterface IX : IY. That query is GENERATED from_CS_BASE_TYPESover the four declaration kinds rather than written out four times: the hand-copied version is what leftinterface_declarationuncovered, and each widening since has had to be applied everywhere at once or not at all. Andtest_every_chunk_query_compiles/test_every_edge_query_compilesprove compilation ONLY, so a new query still needs its own behavioural assertion.
- Budgets are apportioned, never sliced.
- Persistent eligibility walk (
index/walk_cache.py,eligibility.DirectoryListing). The #208 walk is kept on disk too, in the same.neo/, because once the content index stopped rebuilding it became the largest single item in a warm call: 4.64 s on m365dotnet to re-derive that 9,348 files are eligible, on every invocation, in a repository that had not changed (#210). Warm walk now 0.16 s; the canonical M2 battery went 15.63 s → 8.57 s median with byte-identical selection on all six prompts and byte-identicalrank_mine_evalon all three flagships.- The cost is the pattern matching, not the filesystem, and that decides the
whole design. Measured before the cache existed, on m365dotnet: the full
walk 6.85 s; the same traversal with the per-FILE ignore test removed
0.80 s;
statover all 9,378 admitted files 0.10 s. So 6.05 s of a 6.85 s walk isshould_ignore(11,219 calls), and caching the syscalls would have saved almost nothing. What is stored per directory is therefore the VERDICTS — which subdirectories survive, which filenames survive, and the exclusion counts — with the directory's stamps saying whether they hold. - Directory mtime is the right key for exactly one reason: on every POSIX filesystem it moves when an entry is created, deleted or renamed inside that directory, and does NOT move when the content of a file inside it changes. An edit can change what a file SAYS; it can never change whether it is eligible.
- mtime alone is not enough, because mtime is forgeable.
touch -r,tar -x,rsync -aand every snapshot restore write a directory's mtime back to a recorded value, so a restore that adds or deletes a file can land on exactly the mtime the cache holds and be reportedwarm— reproduced with two lines ofos.utime, found by the fresh-verifier pass.ctime_nsis stored beside it: the inode change time moves on any metadata change, no API restores it, and it arrives in the samestat. On Windowsst_ctimeis a CREATION time and hence constant, which makes the extra comparison a no-op there rather than a false invalidation. - Sizes and mtimes are never remembered. They come fresh from the
statthe walk owes its callers anyway (0.10 s), because the content index uses them as ITS freshness stamp — serving a remembered mtime would make an edited file look unedited and turn one cache's staleness into another's.TestStampsAreNeverCachedpins it, mutation-verified. - A
.gitignoreedit invalidates by CONTENT, not by any timestamp. No directory's mtime moves when a pattern file is edited, so every stored verdict looks current while every one of them may now be wrong. The signature hashes the effective pattern list (shared defaults + the repo's own.gitignore/.ignore), plus aMATCHER_VERSIONfor the case the patterns are identical andshould_ignoreis not. Cost of an edit: one full walk, then warm again. os.walk(followlinks=False)had been doing work no one had named. The traversal now recurses by hand, one directory at a time, so that flag protects only the single read it is passed — a link to an ancestor became an infinite descent and a link to/walked the machine. The refusal to descend into a symlinked DIRECTORY is restated explicitly and is not gated byskip_symlinks, which is about whether a symlinked FILE is delivered.- A directory modified within
RACY_WINDOW_NS(1 s) of being read is not trusted. Timestamp granularity is not always finer than the interval between two events — HFS+ stamps whole seconds — so a directory read at E with mtime M can be modified afterwards into the same bucket wheneverE - Mis under one tick. Git carries the same guard under the name "racily clean". Its test had to pre-create.neo/to mean anything: writing the cache creates that directory, which moves the repository ROOT's mtime, so the call after a first-ever call re-lists the root whatever the guard does — the first cut of that test passed with the guard deleted. extra_ignoresis never served from a cache and never writes one. A caller's patterns are appended after the repo's own and gitignore is last-match-wins, so a!negationthere can re-include what a stored verdict excluded — the stored verdict is not a stale answer, it is an answer to a different question. Nothing in Neo takes that path today (--excludeis applied after the walk); the guard exists so a future caller gets a correct answer rather than a fast wrong one. Reported as modebypassed.- JSON, not SQLite — the opposite conclusion from the same question. Every directory is validated on every call, so the file is read whole every time, which is what JSON is for and what the semantic catalog beside it already uses. 507 KB and ~10 ms for m365dotnet. The neighbouring content index went to SQLite because a query touches ten terms of a few hundred thousand; the access shape decides, not the size.
- The verdicts are the IGNORE layer only, so one cache serves every consumer:
--exts/match_globs/max_file_bytes/skip_symlinksare applied on top, per call.--indexandarchitecture_metrics.compute(which runs on the outcome-detection path of every real invocation) go through the same cache, so--indexwarms the walk as well as the catalog. - Degradations are loud and none is fatal: a corrupt, truncated, malformed or
foreign-signature cache is discarded with a warning and the walk runs in
full; an unwritable
.neo/costs a warning and nothing else. A malformed ENTRY discards the whole file rather than the entry, because half a cache is half a repository, silently.--dry-runnames which of cold / rebuilt / incremental (N of M directories) / warm / bypassed happened, in the selected-files block and in the--jsonpayload'swalk_cachekey. A cold walk announces itself BEFORE it starts, since a first call on a large repository is seconds of otherwise-silent work.
- The cost is the pattern matching, not the filesystem, and that decides the
whole design. Measured before the cache existed, on m365dotnet: the full
walk 6.85 s; the same traversal with the per-FILE ignore test removed
0.80 s;
- Persistent content index (
index/content_index.py,index/freshness.py). The BM25 corpus above is built once and kept on disk, in the repository's own.neo/beside the semantic catalog, not re-derived per call. Rebuilding it per invocation was the entire cost of a Neo call on a large repo: the canonical M2 battery on m365dotnet (9,348 eligible files) measured a 53.47 s median wall and 1.95 GB peak RSS, against 18.50 s / 1.45 GB with the store (#195). Selection is UNCHANGED — byte-identical selected files and rank order on all six battery prompts, and byte-identicalrank_mine_evalMRR / R@k on all three flagships (neo 0.712, aieweb 0.728, m365dotnet 0.669, 50 cases each).- SQLite, not the catalog's JSON, and the access shape is the reason. The catalog is read whole (every embedding participates in every query); a keyword query touches ten terms out of a few hundred thousand, so a parse-whole format would spend the warm budget deserializing postings nothing asked for. Index artifacts only — postings, doc lengths, hashes, a tokenizer/schema signature — never a file body; delivery still reads whole files fresh from disk. Cost is disk: 109 MB for m365dotnet, 5 MB for neo.
- The cheap stamp decides what to HASH, never that a file changed. size +
mtime come free from the walk's existing
stat(EligiblePath.mtime_ns); only a content-hash mismatch counts as a change, sotouchre-stamps and re-tokenizes nothing and is reported astouched, separately fromchanged.UNRECORDED = -1disables the cheap path for a store that persists hashes alone — and it is checked EXPLICITLY, because the first cut stamped both the store and the candidate with the sentinel and-1 == -1reported every changed file as unchanged. - Eligibility arrives from the #208 walker and is never recomputed here —
test_the_module_does_not_walk_the_filesystemfails on anos.walk/globin the module. A file the walker stops admitting (newly gitignored, deleted) has its postings dropped on the same pass, so a stale index cannot answer with an excluded file. Correspondingly the gatherer now walks ONCE, unfiltered, and applies--exts/--include/--excludeto the result: the index is a property of the REPOSITORY, and letting one--exts pycall prune it would force the next call to rebuild.--excludeno longer prunes a directory subtree (it excludes every file under it viashould_ignore, which matches a non-final component); the names that make pruning matter are in the walker's shared default list. - Parity is enforced, not asserted.
K1/B/IDF are imported fromneo.memory.bm25and the document is the same expressionFileIndexused, soTestParitycan score one corpus both ways and compare to floating point. Query-term MULTIPLICITY is preserved — deduping the query before hitting the postings table is a silent ranking change, since the scorer this replaces iterated the token LIST. A filtered call gets filtered statistics:scores(prompt, candidates)takes N, df and avgdl fromcandidates, not from the whole repository. Repo-global stats were the first cut and are the better IR design in the abstract; they are also a re-rank, and a fresh-verifier pass caught it — unflagged runs matched main exactly while--exts pychanged all 25 selected lines. Now byte-identical under--exts,--excludeand--includeas well. - Degradations are loud and none is fatal, and the except-clause ORDER is the
whole mechanism:
sqlite3.OperationalErroris a SUBCLASS ofDatabaseError, soexcept DatabaseErrorwritten first catches "database is locked" and runs the corruption handler — whichos.unlinks a perfectly good store. Reproduced: the index was deleted AND the peer's committed transaction went into an unlinked inode and vanished with no error anywhere. Two Neo invocations in one repo is ordinary (an editor plugin and a shell), not exotic. So: locked / read-only / full → serve this call from memory viaFileIndexand say so; corrupt (aDatabaseErrorthat is NOT anOperationalError) → delete and rebuild, detected while OPENING because the memory fallback is permanent and a store that merely failed to open would pin that repo to the full per-call rebuild forever; tokenizer/schema bump → wipe and rebuild (no per-file hash can see it — the files did not change, the tokenizer did).cold/rebuiltare reported separately though both read everything: only one means something went wrong, and the corruption flag is CONSUMED, or a reused instance rebuilds on every refresh forever. - A warm call opens no write transaction. Rewriting the unchanged signature unconditionally made every invocation a writer, so two ordinary overlapping calls contended on the steady-state path rather than only during a rebuild — which is what made the clause-order bug reachable in normal use.
- A file that cannot be hashed keeps its path tokens.
chmod 000used to delete it from the corpus permanently, while the per-call index still ranked it on its name (its content simply read as empty). Permission was withdrawn from the CONTENT; the name is still a real name in the repository. The empty hash is safe as a comparison value because stamps are keyed by path —"" == ""is only ever asked of one file against its own previous state. - The vocabulary is not preloaded to answer a query. Reading every
termsrow to resolve ten of them cost a few hundred thousand rows on the warm path this module exists to make cheap; term ids resolve per query, and the full map is loaded only when writing. - Cold build is bounded and ANNOUNCED before it starts, with progress every 250
files — 122 s for m365dotnet's 9,348, 2.3 s for neo's 307.
--dry-runnames which of cold / rebuilt / incremental (N files) / warm / memory happened, in the selected-files block and in the--jsonpayload'scontent_indexkey (--jsonimplies--quiet, so the stderr note is suppressed on exactly the path a machine consumer reads). - M2's 500 MB target is NOT reachable from file selection and never was.
Warm profile on m365dotnet: imports 1.2 s, eligibility walk 4.6 s, content
index refresh 0.4 s + scores 0.1 s,
_history_boost2.9 s and +1.26 GB. The RSS is the FactStore (152 MB of JSON with 768-dim embeddings inflating to ~1.3 GB of Python objects) — Goal 1's 1.43 GB baseline was never the gatherer's. What remains of the warm wall-clock is the walker and the memory system, in that order.
- Context selection (
context_gatherer): files are ranked by BM25 over their CONTENT (neo.file_retrieval), not by their path. Until 2026-08 they were: the scorer took(rel_path, size, prompt_tokens, git_recent, entry_points)and the file was first opened after selection, only to chunk what had already been chosen. Every other defect in that scorer followed from having no content signal, and the dominant term wasscore -= 0.01 * size_kb, uncapped, against a realistic positive signal of +0.6 to +2.1 — so a file with one keyword hit was unrankable above 60 KB.src/neo/memory/store.pyscored 0.000 and ranked 200th of 284 for "fix the fact store supersession threshold", because it is 162 KB. Ground truth ran 31–177 KB against a corpus median of 10 KB: central files are large because they are central. That had already been noticed once and patched with a seven-name stem whitelist, which rescuedengine.py(−0.13) and leftstore.py(−1.62) — a 12× disparity decided by whether someone had thought of the name. The sign was wrong, not the magnitude: BugLocator's rVSM (ICSE 2012) ranks larger files higher for this exact task, and BM25'sbhandles the concern with bounded, corpus-derived length normalization. Measured end-to-end over cases mined from git history (commit subject = query, changed non-test files = ground truth), R@10 / MRR: neo 0.301→0.742 / 0.304→0.771, car 0.180→0.472 / 0.162→0.425, quip 0.174→0.696 / 0.158→0.643, atCONTENT_WEIGHT = 3.0.tools/rank_mine_eval.pyis the harness — nottools/rank_eval.py, which is a different instrument (12 hand-labelled prompts, this repo, recall@k, no MRR) and was named here in error while the real one went uncommitted, leaving the figures unreproducible. Quoting a number from one under the other's name is howcargot into the record twice at 0.969 and 0.507; keep the generation attached. An earlier generation of these same figures read neo 0.078→0.603 / 0.082→0.655 — superseded, and not comparable, because the harness that produced it no longer exists to re-run. Measure with--no-git, which is the default. The scorer's recency signal readsgit status --porcelainplus the last 50 commits and holds PATHS, not commits — so a case mined below the window whose truth file was touched again inside it is still handed its own answer key, as is every file dirty in the tree you are measuring from.--skip-recentdoes NOT fix this and a first version of that docstring wrongly said it did.neo --dry-run --no-gitgatesgit_recentand nothing else (_history_boostand the rest of the re-rank stay live), which is whattools/rank_eval.pyhad been doing all along.--with-gitmeasures the full pipeline and then reportscontaminated_casesper run: with it on, 48 of 50 neo cases are contaminated, and the figures move by ≤0.03 — the leak is real but was never what carried the result. The re-rank is LOAD-BEARING, not redundant (pi_boost+_symbol_score+hist_boost, applied after the first-pass score). Disabling it on the real CLI takes R@1 from 0.344 to 0.044 and MRR from 0.646 to 0.261 — a SUPERSEDED-generation ablation (pre---no-git, uncommitted harness), so read those four numbers against each other and never against the table above. An earlier version of this note claimed the opposite, from a weight sweep that landed everywhere in 0.66–0.68 — measured at k=10, where every configuration is flat, and through an in-process replica that omits the byte budget and adaptive limit, i.e. exactly the stages that make the re-rank matter. Both errors are the onestools/rank_mine_eval.pywarns about in its own docstring (rank_eval.pywas cited here and carries neither warning). Per channel,hist_boostcontributes nothing measurable (identical results with it disabled) while_symbol_scorecarries most of the effect. RRF fusion with the dense channel LOSES to BM25 alone (0.596 best-weighted vs 0.693, also superseded-generation and unstamped when first recorded) — dense returns ~25 files against BM25's ~180 and is half as accurate. Treat that as provisional: it was measured at k=10 before the same cutoff problem was understood, and the docstring deferring it to a chunk-allocation fix is stale because that fix landed in the same branch. Re-measure at k=3/k=5 before relying on it. Four filename-tuning fixes were separately measured and rejected; the filename is not the evidence, the file is. A path named in the prompt is still pinned (EXPLICIT_PATH_BOOST=10.0, chosen to exceed every organic signal combined — content caps at +3.0 (CONTENT_WEIGHT), the re-rank boosts at +1.0 and +1.2). Without it a spelled-out path competed on generic filename-token overlap and lost:src/neo/subcommands.pyranked 163rd of 296 on a prompt naming it, below its own test file, because an 86KB file took a heavy size penalty. The file never reached context, so the model correctly refused to patch code it had not seen and emitted NO diff — and a suggestion with no diff text can never be git-verified, which is a major reason only ~32% of suggestions were verifiable.matches_explicit_pathtests containment in BOTH directions (an absolute path — what tracebacks, IDE copy-path and Neo's own output emit — must match the repo-relative candidate) and anchors on/so baresubcommands.pyhitssrc/neo/subcommands.pybut NOTtests/test_subcommands.py. A named path that matched nothing emits a WARNING; silence there is indistinguishable from "no path mentioned".select_chunksranks windows by per-file document frequency and matched-token length, not file order or match count. It used to takematching_idxs[:5]— the first five matching lines in FILE ORDER — and since matching is a substring test andextract_prompt_tokensemits every 3+ character word,"in"matchedint/using/pointand virtually every line qualified. "First five matches" therefore meant lines 1-5 for any prompt against any large file: every large file contributed its import block, twice, as overlapping near-duplicates that consumed both slots ofMAX_CHUNKS_PER_FILE. A length cutoff for "discriminative" is INVERTED on real prompts (English stopwords are long, identifiers are short — it keepsdoes/hereand dropsdb/fs/os), hence document frequency (DISCRIMINATIVE_MAX_LINE_FRACTION=0.25). Length weighting matters because match COUNT let a keyword-bearing module docstring tie with the function body and win on the file-order tie-break. Window merging is bounded byMAX_MERGED_WINDOW_LINES: unbounded chain-merging measured 8.3× overmax_chunk_bytes, and because the caller admits a chunk all-or-nothing, an oversized chunk that no longer fits the global budget is DROPPED — the original bug returning through a different door._fit_to_budgetshrinks outward from the window's best line so truncation can never discard the line that earned the window. - One retrieval front door (
context_gatherer.gather_context): every invocation goes through ONE pipeline with one priority order — (1) paths the prompt named, PINNED; (2)--include, pinned per ruling 1 with the scan continuing; (3) keyword BM25 over the persistent content index; (4) the embedding catalog, re-ranking and supplementing (3) whenever it exists.gather_context_semantic— a second gather function with its own candidate list, its own budget arithmetic and no idea what the prompt had named — is deleted;--semanticis now a HINT carried onGatherConfig.semanticthat raises the catalog's weight (SEMANTIC_WEIGHT1.0 →SEMANTIC_HINT_WEIGHT=CONTENT_WEIGHT= 3.0) and its retrieval depth (SEMANTIC_HINT_DEPTH= 3×). Which retrieval strategy you asked for must not decide whether a guarantee applies, and it did: the semantic lane ignored prompt-named paths entirely. Stage 1 is a pin, not a boost.EXPLICIT_PATH_BOOSTmakes a named path rank first, which is a weaker claim than "present" — the file could still be windowed into a fragment, and a prompt naming more files than the adaptive limit admits lost the last-named ones.resolve_explicit_pathsnow pins them on the same terms--includeuses. The boost STAYS on the ranking: a file can only be pinned if the walk found it, so the boost covers the candidates the pin pool never held (an--exts-narrowed list). The pin block cannot spend the whole ceiling (PIN_BUDGET_SHARE= 0.5). Ruling 1 is "the named files AND keep scanning", and funding pins to the last byte satisfies the first clause by deleting the second. Measured on the M2 battery after #214 merged: the prompt namingsrc/Parslee.M365.Api/Program.cs(442,867 bytes) pinned it, spent 299,959 of the 300,000-byte default, and delivered one file — the whole context was that file — where pre-#214 main delivered 22 and the fix delivers 30. The reserve binds only when pins would take more than half; with nothing else eligible it would fund nothing, so it does not apply and the pin arrives whole. The held-back cut is marked and announced, which the ruling permits ("whole, or with an explicit marker"). Each budget cap is charged for what IT removed.dropped_by_file_capis counted before the byte cap cuts anddropped_by_byte_capafter, and both are reported when both bind. Deriving the file cap's verdict from the full candidate list made the run printpinned files filled the file budget (--max-files=30)with 29 of 30 slots free and the BYTE ceiling holding the count at one — raising the named knob provably changed nothing, which is this repo's own rule about never blaming a cap for an absence it did not cause, broken inside the goal whose subject is selection truthfulness. In the scan-delivered-nothing branch the byte cap is tested FIRST, because it is applied second and therefore holds the margin. Delivery is one entry per file, read whole from disk. Chunking survives only as a RANKING internal — it chooses WHICH region of an over-budget file arrives, never how many entries a file contributes.--max-bytesis apportioned max-min fair (text_budget.apportion) across the selection rather than spent greedily in rank order, so a file's SIZE no longer decides how many other files reach the model;MIN_FILE_SHARE_BYTES(512) is the floor below which the ceiling reduces the file COUNT instead, and says by how many. Measured branch vs main atd5adcbc, 50 git-mined cases × 3 flagships: MRR and R@k byte-identical in every cell, while mean distinct files delivered per query went 18.4→28.9 (neo), 22.6→28.7 (aieweb), 22.1→29.3 (m365dotnet) — 0 files lost, 1,190 gained, a strict superset in 150 of 150 cases. The metrics do not move because the gain sits below rank 10. On the M2 battery, within-prompt repeat entries went 45 → 0: #197's "chunks counted as files" is now impossible rather than merely reported. M2 median wall 9.10 s → 8.97 s, peak RSS +0.24% — inside main's own 7.6–10.4 s spread. Full tables:docs/goal8-front-door-measurements-2026-08-13.md. What the flagship M1 numbers CANNOT tell you: none of neo, aieweb or m365dotnet has a.neo/index.json, so stage 4 returns{}on all 300 of those runs and is inert in both arms. Read "no regression" as measured and "no concept-shaped win" as unmeasured-here, not as absent. Stage 4 is measured separately and the result is about robustness, not quality. With a catalog built on neo, the OLD--semanticlane scored MRR 0.000 / R@10 0.000 on all 50 cases — it returned 1,224 files and every one was a test file, because it readindex.retrieve()+ MMR with none of the pipeline's judgement (no test demotion, no BM25, no pin) and becauseneo --indexhad built a catalog of 99 files that are 100% tests. That second half is a real upstream defect, #213:ProjectIndex._select_filesranks shallowest-path-first, so onsrc/<pkg>/…tests/…every test is depth 2 and every source file depth 3 — 105 Python files at depth 2 here, first non-test at rank 102,--max-filesdefault 100. Through the front door the same broken catalog yields 0.705 / 0.708, because it is one channel of four. #213 is now FIXED — selection ranks source before tests (see index invariant 1) and a rebuild of this repo's catalog went from 82% tests / 52 files to 100% source / 94 files. The deferred re-measurement has been RUN across all three flagships, and the honest answer is NULL — the semantic lane's value is a function of CATALOG COVERAGE, not of the weight.tools/rank_mine_eval.py, 50 git-mined cases per repo,--no-git, clean trees, 0 failed cases, each against a freshly built post-#213 catalog (100% source in all three, across Python, TS/TSX and C#).--semanticagainst flag-off, paired by case:
What this file has done since we first saw it
Hashed on every crawl. A supply-chain change to an agent config is a question of when, not whether, so the history is kept rather than the latest state alone.
- 4d ago Changed · +18 lines · +415 tokens per session 1816324a0790
- 8d ago First seen · 1,661 lines · 31,918 tokens per session scan A 46167dc6b002
neo CLAUDE.md is an instructions file published in the GitHub repository Parslee-ai/neo (16 stars, last pushed yesterday), licensed Apache-2.0. It adds 32,333 tokens to every session, about $0.1617 per session on Opus 5. A static security scan graded it A with 0 findings. No closer match exists in the catalogue, so it is treated as the original; first seen 2026-08-30.
Other instructions, from other repositories
next.js AGENTS.md
AGENTS.md instructions for vercel/next.js, covering next.js development guide, codebase structure, monorepo overview, core package: packages/next and other important packages.
codex AGENTS.md
AGENTS.md instructions for openai/codex, covering rust/codex-rs, the codex-core crate, code review rules, crate api surface and model visible context.
vscode buildNext.instructions.md
Working notes and architecture documentation for the new esbuild-based build system in build/next. Use when making changes to the new build pipeline (transpile/bundle commands, NLS plugin, source-map handling, resource copying, or self-hosting watch tasks).
vscode oss-third-party-notices.instructions.md
Instructions for microsoft/vscode, covering vs code oss third-party-notices pipeline, architecture, pipeline flow in ci, applying the notice (cutover) and fallback chain (never fail the build).
langchain AGENTS.md
AGENTS.md instructions for langchain-ai/langchain, covering global development guidelines for the langchain monorepo, corridor security analysis, project architecture and context, monorepo structure and development tools & commands.
spec-kit AGENTS.md
AGENTS.md instructions for github/spec-kit, covering agents.md, about spec kit and specify, quickstart — add a new integration in 5 steps, integration architecture and integrationmanifest — file tracking.