neo: Instructions file for Claude Code

CLAUDE.md

neo CLAUDE.md is an instructions file for Claude Code, Codex from Parslee-ai/neo. It costs 32,333 tokens per session, scanned A, original, Apache-2.0.

A set of repository instructions for Neo, a read-only helper that uses several language-model reasoning steps and stored code-related knowledge.

In plain words
What is it for?
It is for guiding development of Neo's Python code and for explaining how to use its semantic memory, model integrations, and tests.
Why use it?
It gives coding agents clear rules for style, testing, memory, error handling, and command timeouts while working on the project.

Instructions file for Claude CodeCodex

Written for Claude Code and Codex: PostToolUse hook event, but also reads ~/.codex or $CODEX_HOME. Also seen: reads .claude/ paths; mentions CLAUDE.md; mentions subagents.

This is Parslee-ai/neo's own configuration. It tells Claude Code and Codex how to work on neo itself, so it is not a mod to install elsewhere. Copy it as a starting point and replace the rules that are about this project. Everything neo configures →

Reuse

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.

Copy the file
curl -O https://raw.githubusercontent.com/Parslee-ai/neo/main/CLAUDE.md
Clone the repo
git clone --depth 1 https://github.com/Parslee-ai/neo

Made for: Claude Code, Codex.

Wrote 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.

agentmods badge for neo CLAUDE.md

README.md
[![agentmods](https://agentmods.dev/badge/instructions/parslee-ai/neo/claude-md/github.svg)](https://agentmods.dev/instructions/parslee-ai/neo/claude-md)
Your own site
<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.

agentmods 80×15 button for neo CLAUDE.md

Your own site · 80×15
<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>
Per session 32,333 This file is loaded in full into every session.
When invoked 32,333 The same file — it is already loaded in full.
Security scan A 0 findings. A grade says what 26 rules found in the file — not that it is safe.
Origin original No closer match found in the catalogue.
Token cost

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.

ModelPer sessionOnce 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

Measured 4d ago against content hash 1816324a0790, method: parsed. Prices are Anthropic first-party input rates as of 2026-09-08, from the pricing page.

Security

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.

CLAUDE.md · 1,679 lines

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 neo commands
  • Semantic memory: Local embeddings (Jina 768-dim) preferred over OpenAI (1536-dim)
  • Memory hygiene:
    • Per-scope valid-fact caps (SCOPE_LIMITS in store.py): global=200, org=100, project=500, session=50. Enforced per loaded scope set (project+org+global); invalidated facts persist as tombstones until purge_dead_facts runs.
    • Supersession at cosine ≥ 0.85 (SUPERSESSION_THRESHOLD, store.py:59); pre-write dedup is canonical-signature equality, not cosine (memory.generalize). SYNTHESIS_SIMILARITY was a separate constant that happened to equal 0.85 and gated REVIEW clustering; it went away with synthesize_reviews (see below). The only 0.85 left in store.py is supersession. memory.issues keeps its own CLUSTER_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 distinct repository_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_id is a per-episode uuid4() 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 transient rev-parse failure, 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 old generalize(subject+body)) gave two acceptances of one task different signatures so promotion never fired (a live drill measured this). The subject is f"{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_skeleton called with normalize_names=True, so def:<name> becomes bare def, Python-only, "" for unparseable code → key degrades to subject-only). The name-normalization is load-bearing: the skeleton keeps def:<name> by default because it doubles as readable metadata on the fact, but hashing the name made the identical fix to read_text and read_body two different signatures, so a genuinely recurring lesson could never reach the two acceptances promotion requires. A live drill against real neo runs 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, while def:<name> was the only free user-chosen identifier (there is no ClassDef handler, no arg names, no bare Name loads) — which is what makes normalizing it both necessary and sufficient. Honest limit: post-normalization skeletons are coarse — the drill's fix hashes to def return, a guard-clause fix to def 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_collide pins what it still buys. _split_fingerprint pulls the fp OUT before generalize (whose _HASH_RE would collapse the hex to <hash>) and appends it RAW after a \x1f separator, 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, though generalize collapses 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_fact stores, so the same lesson correlates across repos). The signature is frozen into Fact.canonical_signature at 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 by reconcile_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, engine kind_map): a candidate promotes only when its kind is pattern. Kind derives from the task type — algorithm/bugfixpattern (promotable); featuredecision, refactorarchitecture, explanationreview, 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 hardcode task_type=FEATURE for every plain-text prompt, so nothing interactive could ever promote; it now calls classify_task_type(prompt) — deterministic keyword scoring (no LLM), highest distinct-match count wins, ties break by _TASK_TYPE_PRIORITY which 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' explicit task_type still 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 optional error_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 shared execution_context.FAILURE_SIGNAL_KEYWORDS (error/fail/exception/crash) so this classifier and _infer_intent can't drift; _infer_goal keeps its own timeout-inclusive set by design. (If a third module needs the lexicon, extract it to a neutral neo.lexicon then.) The derived \bcrash(?:…)?\b is boundary-closed, so compound terms like crashloop no longer match — a deliberate precision/recall trade in the fail-safe direction.
    • REVIEW → PATTERN synthesis has been REMOVED (synthesize_reviews and its cluster/watermark/Hebbian machinery). It ran for four months and minted 114 facts, none of them a PATTERN — the PATTERN branch required a group_key == "outcome:accepted" and a census found 0 accepted-tagged REVIEWs (against 97 independent, 3046 history), because detect_implicit_feedback boosts 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 their synthesized tag and prune immunity; nothing mints it now. SUPERSESSION_THRESHOLD (0.85) and canonical-signature dedup are untouched — they were always separate from the deleted SYNTHESIS_SIMILARITY.
    • Candidate verifiability gate (memory.outcomes.suggestion_is_verifiable, applied in engine._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-promotable review. Promotion is gated on git-verified acceptance, so an unverifiable suggestion was previously minted pattern and 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 via normalize_suggestion_path — the same helper OutcomeTracker._normalize_path delegates 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 in git log once committed. Known limit: a bare-slash name at the repo ROOT (/NO_CODE_PLANNING_ONLY vs /README.md) is structurally indistinguishable from a real new file and is admitted. That asymmetry is deliberate — kind is frozen at mint, so under-admitting permanently kills a real lesson, while over-admitting only leaves a candidate pending. neo memory learning-stats buckets recorded suggestions FOUR ways (subcommands._classify_suggestion): verifiable, advisory, unattributable (the bug signal) and root_unavailable. The fourth exists because every resolution test runs against the LIVE filesystem, so a recorded codebase_root that 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 inflated measurable by the difference. Verdicts (ACTIVE / UNMEASURABLE / STARVED / IDLE) are computed over measurable = total - root_unavailable, never against a content bucket: the first version compared root_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.json from review/x.json on 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 than suggestion_is_verifiable, which stays untouched: its bias toward admitting a doubtful path is correct because kind is frozen at mint, while over-admitting in a report costs only an inaccurate number. Measured: the prose-suffix-before-verifiable ordering corrected verifiable from 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 probation tag 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_OUTCOMES in outcomes.py) and 50/project (MAX_INDEPENDENT_FACTS in store.py).
    • Invalidation choke point: _invalidate(fact, *, cascade=True) is the single path that sets is_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 which body alone was 15.8 MB). subject/tags stay for audit and metadata stays because purge_dead_facts ages tombstones off metadata.last_accessed and reads invalidation_reason — dropping those would strand tombstones forever. episode_context is deliberately NOT stripped: it is a structured EpisodeContext with its own to_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-filter is_valid. The older wording said only the embedding — a tombstone is never retrieved/deduped/clustered (all such paths pre-filter is_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_facts with cascade=False, _supersede, _synthesize_cluster); superseded_by/event_time_end stay 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-embeddings targets the legacy ReasoningMemory cache), so the strip is one-way in practice.
    • prune_stale_factsdemote_unhelpful_factspurge_dead_factsstrip_tombstone_embeddings run on every cold start (in FactStore.initialize), each taking save=False so the chain flushes one merge-on-save instead of four. strip_tombstone_embeddings is now a backfill — it only catches tombstones minted off the _invalidate path (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_feedback re-runs it. For on-demand compaction of tombstone bloat in a specific project's fact file, use neo memory prune [--all] [--dry-run] (neo/subcommands.py:_compact_fact_file — at the package root, not under memory/); it both drops 30-day-cold invalid rows and strips embeddings off the retained (<30-day) tombstones (reports removed + stripped), under the shared scope_file_lock so it can't clobber a concurrent observer/request-path save().
    • 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-run reports what would change without mutating; --include-legacy-fallback also inspects legacy session_*.json files (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-rules adds 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 Code memory/*.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-memory tag, 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 the citation_survival metric 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).
      • issues reuses the ingester's TranscriptSource episodes but never admits facts or touches the transcript_watermark_* watermark — decoupled from fact admission and idempotent (find_issues). Gate mirrors the old synthesis discipline (≥min_cluster members, ≥2 sessions, ≥2 frictional, verbatim evidence); clusters at issues.CLUSTER_SIMILARITY via the shared math_utils.cluster_by_similarity. See docs/solutions/conversation-mined-issues.md and docs/solutions/rule-file-sync.md.
  • Transcript sources (memory.transcript, the TranscriptSource Protocol): the TranscriptIngester mines lessons from four sources by default — ClaudeCodeSource (~/.claude/projects/**/*.jsonl), CodexSource (~/.codex/sessions/**/rollout-*.jsonl), CarSource (~/.car/sessions/*.json), and GitHubPRSource (merged PRs + review threads via the gh CLI). A source may declare optional fact_kind / extra_tags trust overrides that the ingester's admit reads (default = today's PATTERN/FAILURE + transcript-derived tag). GitHubPRSource: PROJECT-scoped (owner/repo derived from the git remote, so PR facts co-scope with that repo's transcript facts under the same project_id); enters facts as REVIEW on probation (imported:github-pr tag) — 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_REQUESTEDerrors; filters bot authors; skips PRs with no human discussion. Throttled to one gh fetch 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 or gh is 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 to FactKindcode-style, testing, git, debugging, workflow, security, file-patterns, architecture, performance are the suggested vocabulary, but any string is valid. retrieve_relevant(..., domain=...) filters by exact match; domain=None returns 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_since returns every file changed anywhere in the repo, so the first version's if 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_suggestions now 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, growing VerificationEvidence per episode without bound). Anything past PENDING_SESSION_TTL_SECONDS (14d) is dropped so the log stays bounded. _get_working_tree_changes is hoisted OUT of the per-session loop: it's timestamp-independent, and re-forking it per retained session measured 0.88s of pure git forking at 40 pending sessions, on the request hot path, growing linearly. The session log is merge-on-write, not last-writer-wins. _rewrite_session_log takes store.scope_file_lock, then RE-READS the log under it and preserves every record whose _session_key is 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 its os.replace was erased without trace. save_session takes 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 in collect_outcomes/replay_linked_feedback would let a slow reasoning run block another process's save. replay_linked_feedback must use consume_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_log is 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 in collect_outcomes exists to prevent exactly that loss and the unconditional clear defeated it one level up. Measured: 30d of real traffic = 108 episodes, 58 stuck at suggested_pending_downstream_outcome, zero accepted outcomes 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_trackable is 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_DIR is resolved at IMPORT time from Path.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 fixed project_id still 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 bumps success_count and 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 nor success_count moves — the live path and replay_linked_feedback share 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 new OutcomeType, update both outcomes.py and store.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 to replay_linked_feedback. It is built by engine._build_suggestion_fact_ids from the fact _store_reasoning returns — and when episodes replaced immediate fact-writing (412a174, 2026-07-18) that function started returning None on every path (all three returns; the legacy branch too). The builder returns {} for a None fact, so the map was unconditionally empty from that commit on. Nothing raised: the ACCEPTED branch just fell through to the candidate path, reinforce_legacy_fact became unreachable, and neo 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's success_count moved again in 90 days (learning-stats reinforcements = 0). Across 6,613 valid facts in every project, zero have ever reached success_count >= 3, so find_contributable has never once returned a fact and neo contribute has 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 no save() 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 flood 412a174 existed to stop. It resolves the link through the episode candidate instead: find_durable_fact_for_candidate(subject) matches the candidate's _episode_signature against the FROZEN canonical_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_feedback credits two different populations: linked ORIGINAL facts (a suggestion re-applying a durable fact), which move linked_count; and CITED retrieved facts, credited by _apply_used_fact_feedback, which bump success_count on the live Fact objects and record an episode mutation and nothing else. The single save was if 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, since suggestion_fact_ids is empty unless the candidate already resolves to a durable fact. The episode ledger still recorded the mutation, so learning-stats reported 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 reached success_count > 0 while 64 carried a non-zero access_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 the success_count >= 3 contribution bar and neo contribute was mechanically unreachable, not merely starved. Gate is now if linked_count or touched_fact_ids. Footgun — two neighbours save for their own reasons and will mask this. The no-link ACCEPTED fallback calls add_fact, which saves; and the janitor chain under if outcomes: ends in if 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 and continues past the fallback, and a FIRST acceptance promotes nothing. test_cited_fact_credit_survives_the_process therefore sets candidate_id, pins the janitor to "changed nothing", stubs promotion to None, and RELOADS FROM DISK — all four load-bearing; the first two cuts of that test passed against the broken code, once via add_fact and once via the janitor. Every other test in TestRetrievedFactAttribution asserts 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_candidate looks for an existing valid PROJECT fact at the target signature before calling add_fact, and on a hit folds in the new supporting episodes, raises success_count to the support count and adds REACCEPTANCE_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_facts heals 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 at min(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.
  • durable is a terminal candidate status. detect_implicit_feedback calls _record_attributed_episode_outcome a 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-written durable back to supported_once — leaving every promoted candidate reading supported_once beside a populated promoted_fact_id, and learning-stats under-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) adds PROTECTION_BOOST to any fact with success_count > 0 and 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 at min(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_candidate holds 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 filtered near differently from find_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 by rank_score, half by raw cosine. CONSTRAINT/ARCHITECTURE/DECISION and the seed/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.
  • 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_id is SHA256[: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 on FactStore init (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 ContextAssembler token-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). See docs/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_limit returned its three broad-prompt buckets VERBATIM, so --max-files 5 on 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.py at rank 14, project_index.py at 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-bytes is 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 truth rank_mine_eval.py used, so only the ranking rule differs. The load-bearing baseline is grep — 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 for size/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 — size runs 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: random must be seeded from hashlib, not hash() — the latter is salted per process and did not reproduce; and recency is 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 (a ConfigError whose code kwarg is required, a key rule stripping an acme:: 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). accepted is 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 exactly 0.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 separate performance_budget block and surfaces as performance_within_budget / performance_notes, advisory, with no effect on the exit code. It used to sit in safety_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, accepted is the benchmark's published verdict, so a timing wobble invalidated a correctness claim, and the failure presented as assert report.accepted is True with 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 --corpus lets a caller supply their own file. Footgun: keeping the two verdicts apart is the whole fix, and the way to undo it is one failures.extend(performance_notes)test_no_latency_text_leaks_into_acceptance_failures exists 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_clean called run_learning_evaluation against the REAL 500ms budget and asserted performance_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, pinned PYTHONPATH, ≤1ms apart — so not the change under review), with accepted=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_budget forces latency_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 running car-server daemon 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 emit a2ui.action notifications which the observer dispatches to kick_observer / stop_observer — closes the loop with CAR's supervisor. Footgun: Python's car_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.DaemonClient is that bridge. Activation: auto when 127.0.0.1:9100 is reachable; silent no-op otherwise. Adds websockets>=12.0 to the [car] extra.
  • Async transcript-mining observer (memory.observer): a single global background process (CAR agent neo-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_since now 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 ran synthesize_reviews until that subsystem was removed.) Two roots can share a project_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-cycle store_cache so 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 from cli.main) auto-registers it whenever car-server is reachable; opt out with NEO_OBSERVER_AUTOSTART=0. No CAR → one-time hint, then silent. Footgun — that export belongs in ~/.zshenv, never ~/.zshrc. The gate is a plain os.getenv (observer.py), read by whichever process runs neo, and most of those are NOT interactive shells: an editor plugin, a CI step, a git hook, an agent tool call. zsh sources .zshrc for interactive shells only, so an export there leaves every programmatic invocation autostarting the observer while the terminal prints 0 and looks correct. Measured directly: with it in .zshrc, zsh -c, zsh -lc and a non-interactive tool call all read empty; only zsh -ic read 0. 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 /, $HOME or ~/git created pseudo-projects that were ancestors of every real one — 26.3s of a ~40s cycle, and CodexSource's cwd-prefix attribution then claimed the machine's entire Codex history under them (/ matched everything because "/".rstrip("/") is ""). The .git clause 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 by CodexSource(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 running car-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 every NEO_OBSERVER_RECYCLE_CYCLES cycles (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 is restart: "on_failure" with max_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 but flock is 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}kick maps to agents_restart since 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 — a neo.memory.observer --daemon reparented 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; the orphans field + a WARNING). Orphans are now auto-reaped, not just reported: _reap_orphan_observers SIGTERMs them (re-checking each pid's cmdline right before the signal to defend against pid reuse) and is wired into start/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/msvcrt on ~/.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_AFTER grace, the daemon escalates to SIGKILL so the kernel frees the lock — safe because FactStore._save_file is 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 $HOME or a system install.
  • Observability: retrieve / add_fact / lm_call / overseer_tick events land in ~/.neo/metrics.jsonl. Gated by NEO_PROFILE: off (no emit), minimal (lm_call only), standard (default, all events), strict (reserved for future verbose events; currently == standard). NEO_METRICS=off is a legacy hard kill-switch that overrides NEO_PROFILE. The log rotates to metrics.jsonl.1 at 32 MB (one generation retained); the size is sampled every 500th write so the steady state stays one write per event. Readers (memory citation-stats, memory learning-stats) window by --since and read only the active file — a --since older than the last rotation silently sees less history. Sessions and watermarks live in ~/.neo/sessions/.
  • Disk hygiene: FactStore reaps abandoned *.tmp atomic-write files older than 24h from ~/.neo/facts on cold start (_reap_stale_temp_files). A save that is SIGKILLed between mkstemp and os.replace strands its temp file — _save_file unlinks 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_url is memoized per root (clear_remote_url_cache resets 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 wrong project_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 against src/, so running .venv/bin/python -m neo.cli from 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 needs PYTHONPATH=<that tree>/src, and rank_mine_eval now REFUSES to run unless the tree it was handed is the tree import neo actually resolves to — mandatory is not the same as effective, since the editable .pth silently 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) Calling score_candidate directly measures the FIRST-PASS ranking only; gather_context then re-ranks with pi_boost + hist_boost + _symbol_score and 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-run output 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 in cli.main before 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.RecordingLM is a real LMAdapter installed in the engine's own self.lm slot), 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 put EXCLUDED_DIR_NAMES in two places. What it shows is the adapter's INPUT, not the wire payload: Anthropic hoists system into a separate kwarg, Google remaps roles, Ollama flattens, CAR adds intent_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. DryRunComplete derives from BaseException, not Exception: _process_guarded converts anything its except Exception catches into a FAILED lifecycle 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 — _deliberate has its own except Exception that would swallow it and silently fall back to the fast path. The panel is forced OFF under dry_run. This is correctness, not tidiness: _build_car_role_factory calls create_adapter("car", model=m) per role and uses self.lm only as the fallback, so RecordingLM never intercepts it. With car-server reachable — the normal setup here, since the observer autostarts off it — a novel prompt under --dry-run ran the full panel against real models, spent real money, never raised DryRunComplete, 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 a FactStore; FactStore.initialize runs prune_stale_factsdemote_unhelpful_factspurge_dead_facts and then saves, and demote_unhelpful_facts lowers 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) makes save() a no-op at the single write choke point, which a new caller cannot forget; dry_run also skips detect_implicit_feedback and _complete_learning_episode. Retrieval still marks facts accessed in memory, and metrics.jsonl still records the run — deliberately: the two events it writes (execution_context_resolved, retrieve) are read by neither citation-stats (which filters citation_survival) nor learning-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, so process() returns NORMALLY and never raises DryRunComplete; _complete_learning_episode then wrote an episode file AND a citation_survival metric — the exact two surfaces the sentence above claims are untouched. Measured on a clean HOME: 1 episode, 1 citation_survival. Gating that call is what makes the claim true; do not narrow the gate to the recorded-call path. Under --json the report is the single stdout document ({dry_run, calls, note} — a second schema, discriminated by dry_run: true, with no orchestrator key; test_host_adapter_parity.py does not know about it) and a terminal phase_completed(reasoning) + completed pair is emitted. Writing prose to stderr broke both --json invariants at once: zero documents on stdout, and every source line beginning with { became a counterfeit event. Both dry-run exits route through cli._report_dry_run — there are two, the recorded call and the normal return, and the second shipped without the --json handling the first had.
  • Project index (index/project_index.py, index/language_parser.py; full notes in docs/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:
    1. Budgets are apportioned, never sliced. _select_files groups eligible files by language and hands each a share of --max-files proportional to repo composition, floor of one slot per language (_allocate_slots); then _cap_chunks round-robins MAX_CHUNKS_PER_REPO across FILES so each keeps a chunk before any keeps a second. Both exist because a list slice is not a ranking: globbing **/*.py before **/*.cs and 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 left chunks[: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: with src/<pkg>/… beside tests/… 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-files was 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_chunks embeds 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_path is IMPORTED from context_gatherer, never restated — a second copy of that rule would agree with the first only by coincidence, and its careful cases (testdata/ and testing/ are ordinary source; Foo.Tests/ is not) are exactly what a re-implementation loses.
    2. Exclusion is two layers and bin/build/out/target/dist/vendor belong to neither by name. Both layers, and the walk that applies them, live in neo/eligibility.py — the ONE eligibility module, consumed by context_gatherer, ProjectIndex and architecture_metrics alike. DEFAULT_IGNORE_PATTERNS covers what repos forget to ignore (.worktrees, .claude/worktrees, node_modules, obj, virtualenvs); load_ignore_patterns layers the repo's own root .gitignore/.ignore on top, so a repo's !negation can re-include what a default excluded. should_ignore only tests the path handed to it, so ancestor directories must be handled separately — walk does 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 under bin/+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: WalkPolicy knobs (symlink rejection, the gatherer's 512 KB MAX_FILE_BYTES ceiling, extension and per-language glob filters) are per-consumer policy; nested .gitignore files 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 (four specs/*.md on this checkout — recorded, deliberately not fixed in a pure refactor, since closing it means a git ls-files fork on the warm path). Two tests hold the line: test_eligibility_single_source.py AST-scans src/ and fails on a second definition, a second os.walk or a second exclusion list (detected by CONTENT — three sentinel directory names in one literal — because a copy always renames the variable); test_eligibility_differential.py diffs the walk against git check-ignore over a fixture corpus AND this checkout, and fails on any tracked file skipped without an ignore rule accounting for it. Both are marked invariants, so they run in the Guard-invariant battery on every PR. Footgun: the index's excluded count 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.
    3. A cap that fired must be reported. selection_report carries eligible / selected / excluded / duplicates / chunk counts and the CLI prints them. truncated means THE CAP BOUND US and is examined < eligible — whether any candidate went unlooked-at, which the per-language iterators already know. Both cheaper predicates name the wrong knob: selected < eligible made dedup print "2 of 7 eligible files (capped at --max-files=1000)", and selected >= max_files did the same whenever --max-files landed 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_REPO is fixed at 1000 while --max-files is not, so files_with_chunks reports the shortfall — truncated is 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 and lower --max-files. files_producing_chunks is measured BEFORE _cap_chunks precisely so the two stay separable; subtracting the post-cap files_with_chunks from selected conflates them and is what printed "the 1000-chunk cap is below the 25 files selected" for a build that kept 559 of 559 chunks, with chunks_capped False 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_query returns None and parse_file moves 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 of identifier, generic_name, and qualified_name with either as its name: field — across class/interface/record/struct — since a narrower pattern compiles fine and silently drops : Repository<Order>, : System.Exception and interface IX : IY. That query is GENERATED from _CS_BASE_TYPES over the four declaration kinds rather than written out four times: the hand-copied version is what left interface_declaration uncovered, and each widening since has had to be applied everywhere at once or not at all. And test_every_chunk_query_compiles / test_every_edge_query_compiles prove compilation ONLY, so a new query still needs its own behavioural assertion.
  • 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-identical rank_mine_eval on 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; stat over all 9,378 admitted files 0.10 s. So 6.05 s of a 6.85 s walk is should_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 -a and 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 reported warm — reproduced with two lines of os.utime, found by the fresh-verifier pass. ctime_ns is stored beside it: the inode change time moves on any metadata change, no API restores it, and it arrives in the same stat. On Windows st_ctime is 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 stat the 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. TestStampsAreNeverCached pins it, mutation-verified.
    • A .gitignore edit 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 a MATCHER_VERSION for the case the patterns are identical and should_ignore is 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 by skip_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 whenever E - M is 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_ignores is 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 !negation there 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 (--exclude is applied after the walk); the guard exists so a future caller gets a correct answer rather than a fast wrong one. Reported as mode bypassed.
    • 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_symlinks are applied on top, per call. --index and architecture_metrics.compute (which runs on the outcome-detection path of every real invocation) go through the same cache, so --index warms 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-run names which of cold / rebuilt / incremental (N of M directories) / warm / bypassed happened, in the selected-files block and in the --json payload's walk_cache key. A cold walk announces itself BEFORE it starts, since a first call on a large repository is seconds of otherwise-silent work.
  • 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-identical rank_mine_eval MRR / 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, so touch re-stamps and re-tokenizes nothing and is reported as touched, separately from changed. UNRECORDED = -1 disables 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 == -1 reported every changed file as unchanged.
    • Eligibility arrives from the #208 walker and is never recomputed heretest_the_module_does_not_walk_the_filesystem fails on an os.walk/glob in 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/--exclude to the result: the index is a property of the REPOSITORY, and letting one --exts py call prune it would force the next call to rebuild. --exclude no longer prunes a directory subtree (it excludes every file under it via should_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 from neo.memory.bm25 and the document is the same expression FileIndex used, so TestParity can 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 from candidates, 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 py changed all 25 selected lines. Now byte-identical under --exts, --exclude and --include as well.
    • Degradations are loud and none is fatal, and the except-clause ORDER is the whole mechanism: sqlite3.OperationalError is a SUBCLASS of DatabaseError, so except DatabaseError written first catches "database is locked" and runs the corruption handler — which os.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 via FileIndex and say so; corrupt (a DatabaseError that is NOT an OperationalError) → 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/rebuilt are 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 000 used 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 terms row 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-run names which of cold / rebuilt / incremental (N files) / warm / memory happened, in the selected-files block and in the --json payload's content_index key (--json implies --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_boost 2.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 was score -= 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.py scored 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 rescued engine.py (−0.13) and left store.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's b handles 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, at CONTENT_WEIGHT = 3.0. tools/rank_mine_eval.py is the harness — not tools/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 how car got 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 reads git status --porcelain plus 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-recent does NOT fix this and a first version of that docstring wrongly said it did. neo --dry-run --no-git gates git_recent and nothing else (_history_boost and the rest of the re-rank stay live), which is what tools/rank_eval.py had been doing all along. --with-git measures the full pipeline and then reports contaminated_cases per 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 ones tools/rank_mine_eval.py warns about in its own docstring (rank_eval.py was cited here and carries neither warning). Per channel, hist_boost contributes nothing measurable (identical results with it disabled) while _symbol_score carries 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.py ranked 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_path tests 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 bare subcommands.py hits src/neo/subcommands.py but NOT tests/test_subcommands.py. A named path that matched nothing emits a WARNING; silence there is indistinguishable from "no path mentioned". select_chunks ranks windows by per-file document frequency and matched-token length, not file order or match count. It used to take matching_idxs[:5] — the first five matching lines in FILE ORDER — and since matching is a substring test and extract_prompt_tokens emits every 3+ character word, "in" matched int/using/point and 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 of MAX_CHUNKS_PER_FILE. A length cutoff for "discriminative" is INVERTED on real prompts (English stopwords are long, identifiers are short — it keeps does/here and drops db/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 by MAX_MERGED_WINDOW_LINES: unbounded chain-merging measured 8.3× over max_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_budget shrinks 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; --semantic is now a HINT carried on GatherConfig.semantic that raises the catalog's weight (SEMANTIC_WEIGHT 1.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_BOOST makes 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_paths now pins them on the same terms --include uses. 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 naming src/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_cap is counted before the byte cap cuts and dropped_by_byte_cap after, and both are reported when both bind. Deriving the file cap's verdict from the full candidate list made the run print pinned 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-bytes is 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 at d5adcbc, 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 --semantic lane 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 read index.retrieve() + MMR with none of the pipeline's judgement (no test demotion, no BM25, no pin) and because neo --index had built a catalog of 99 files that are 100% tests. That second half is a real upstream defect, #213: ProjectIndex._select_files ranks shallowest-path-first, so on src/<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-files default 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#). --semantic against flag-off, paired by case:

Read the full file on GitHub · 1,679 lines

Changes

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.

  1. 4d ago Changed · +18 lines · +415 tokens per session 1816324a0790
  2. 8d ago First seen · 1,661 lines · 31,918 tokens per session scan A 46167dc6b002

Subscribe to this mod's changes

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.

Related

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.

vercel/next.js · 7,296 tokens

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.

openai/codex · 5,153 tokens

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).

microsoft/vscode · 6,785 tokens

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).

microsoft/vscode · 5,001 tokens

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.

langchain-ai/langchain · 4,469 tokens

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.

github/spec-kit · 7,104 tokens