Fix search ranking and --hop, make the evidence rule and CI gates actually check something - #2
Conversation
…creds - bin/facts/crm: prove person<->company/company<->project against ooCRM x corpus SoT (knowledge-mesh-seed.yaml), write 78 facts (root=facts) - tools/crmfacts.py + test_crm_facts.py: parser under unit tests (26 pass) - docs/crm-associations-proof.md: provable graph, mistakes, fixes - oo merge 759->763 resolves duplicate GoldenRatio.Exchange legal entity - bin/db/ssh-tunnel: "$0" self-check + accept-new/BatchMode ssh flags - AGENTS.md: document bin/facts/crm
Move serve/ (module) -> bin/server, tools/ -> bin/tools, replace bin/kb-watch bash with bin/watch Go package; self-executing Go shebangs bin/serve.go and bin/kb/watch.go; Docker + CI + git/import + docs repointed. Multi-stage image builds static serve+watch binaries (no Go runtime in container).
- bin/mail/sync.go: async Go sync engine (8 workers, paginated Gmail via API + OnlyOffice IMAP); Gmail attachments key off body.attachmentId, not MIME partId; ICS sidecars Latin-1->UTF-8 normalized (TestICSToMarkdownNormalizesLatin1) - bin/mail/import: message.json -> markdown; PDFs via pdftotext -layout fast path with docling subprocess fallback for the ~5% textless files - bin/mail/index_mail: fresh-rebuild indexer (repo corpus + mail) avoiding ladybug WAL corruption on bulk-insert into indexed DBs; split from import - bin/kb/index: keep FTS/VECTOR indexes across incremental runs (drop+recreate leaves stale backing tables killing the vector index) - docs: README/PLAN/AGENTS cover the mail pipeline Result: 17,835 messages -> 28,918 info leafs, FTS+HNSW healthy.
- New nested module bin/kbsearch with Go implementation of bin/kb/search - Embedding model (potion-multilingual-128M) served by localhost daemon so repeated CLI calls reuse the loaded model - Bash launcher bin/kb/search builds binary on first run, caches to var/bin/ - Hybrid FTS + vector search (RRF k=60) matching Python kblib behavior - YAML output via port of yamlout.py (ordered keys, same format) - JSON output with proper field order - All flags: --root, --repo, -n, --json, --list-model - Root go.mod reverted to 1.25.0 (kbsearch is isolated nested module) - CI passes: go test ./... and go vet ./... unaffected by kbsearch
- PLAN.md: replace 'curasoft-detective' with 'detective method' - README.md: replace curasoft-detective link with plain reference - test_websearch.py: fix test domain from ticket.curasoft.de to example.com - Rewrote git history with git-filter-repo to remove all traces
QUERY_FTS_INDEX was ordered `ORDER BY score LIMIT $n`, i.e. ascending, so the Go search took the *worst* BM25 matches and fed them into the RRF fusion in reverse rank order. kblib.py has always used `ORDER BY score DESC`. Both Cypher statements now live in named consts next to each other so the FTS (descending BM25) vs vector (ascending cosine distance) asymmetry is visible in one place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
openWithOpts ran `SET STREAM_SANDBOX` on the package-level `conn` while it was still nil — OpenConnection happened afterwards. Any run with KBTEST_EPS set panicked instead of opening the brain. Also: drop the `allow` parameter (never read, hence the rename to openWithSandbox) and close the database on the error paths so a failed extension load doesn't leak the open handle. Not covered by a test: everything in this function needs the native ladybug library, which the nested kbsearch module cannot build offline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
runSearch fused the FTS and vector hits, cut the list to -n, and only then applied --root/--repo. Every matching leaf ranked below the cut was thrown away before the filter ever saw it, so `--root facts` returned nothing as soon as info leafs filled the top N — the deduction order (facts first) was unreachable from the Go CLI. Fusion + filtering now live in rank.go as rankAndFilter(): fuse everything, filter, then truncate. hybrid() takes limit <= 0 for "keep all" and breaks RRF ties by id, so the output no longer depends on map iteration order. rank.go has no cgo dependency; rank_test.go covers filter-before-limit for both filters, the RRF fusion order and the tie determinism. Verified with a stub module (the package itself needs the native ladybug lib to build). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ensureDaemon() was written but never called: every query fell straight through to the in-process fallback and loaded the ~100MB potion matrix again, which is exactly what the daemon exists to avoid. embedQuery now tries the daemon, starts one in the background when nothing answers, and retries once before falling back. The daemon gets its own session (Setsid) so a Ctrl+C in the launching terminal does not kill it with the foreground process group. KBSEARCH_NO_DAEMON=1 opts out for one-shot containers and CI. Verified against a stub-model build of the daemon half: cold run spawns the daemon and answers, the daemon survives the CLI exit (health 200), the next run reuses it, and the opt-out path stays in-process. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ules.py
The evidence rule (D8) lived inline in facts/extract, tangled with docker
subprocess calls and file reads, so nothing could test it and facts/audit
could not re-check it.
factsrules is pure: observations in, Facts out. make_fact() refuses fewer
than 2 *distinct* sources, so a single observation cannot reach the facts
root by accident. Compose candidates are now scoped per container — the flat
{file: services} shape I started with would have paired a container `db`
against an unrelated project's compose.yaml that also declares `db`, which
is not a second source at all.
extract keeps collecting the observations; behaviour is unchanged, verified
by diffing --dry-run --json output of both versions against a fixture ssh
config (byte identical, 2 facts).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`audit self` was three greps over PLAN.md and README.md — and its own
docstring described the `db` mode ("every fact in db has >=2 sources"),
which self never did. The regex `2.source` had an unescaped dot, so it
matched almost anything. It could not go red.
It now checks what it claims to:
- evidence rule: runs factsrules against a fixture with one paired and one
unpaired observation, asserts the single-source one stays out, every fact
carries >=2 distinct sources in the " x " form facts/audit db greps for,
and make_fact still refuses a single source
- tool convention (D14): shebang on line 1 (both `#!` and the Go
`//usr/bin/env go run` form) and a usage line naming the tool
- documented modes: AGENTS.md/PLAN.md must list exactly the modes argparse
accepts, which is now a single MODES constant
- the doc greps stay, with the two-source regex tightened to ">=2 sources"
Against the current tree it reports 5 real problems (bin/md/import has no
usage line; AGENTS.md and PLAN.md both advertise facts|info|stale, which do
not exist). Those are fixed in the next commit, so the gate goes green on
substance rather than by weakening it.
test_facts_audit.py exercises every check against a fixture tree that
violates it and one that satisfies it — a gate that cannot fail is the bug
being fixed here. The evidence rule itself was TDD via test_factsrules.py;
these checker tests were written after the checks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes the 5 problems `audit self` reported: - bin/md/import had a shebang and no usage block (D14); added one, including the exit codes it already returns - AGENTS.md and PLAN.md advertised `facts|info|stale`; the tool has `self` and `db`. Both now say so - docs/design.md described `bin/facts/audit stale` as if it existed; now phrased as planned and marked not implemented - README's diagram called audit "confidence + staleness"; it does neither, it gates the 2-source rule and the tool convention - the audit docstring described `db` behaviour under `self`, and claimed db checks source_rev, which it does not — it checks source, loc, how and confidence audit self is green again on substance: 0 problems, 67 python tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mode check only looked at AGENTS.md and PLAN.md, which is why docs/design.md could advertise `bin/facts/audit stale` unnoticed for as long as it did. It now scans README.md and docs/*.md too, and only inside code spans and fenced blocks — prose like "run bin/facts/audit before pushing" would otherwise be read as a mode named 'before'. Two shapes are checked: a bracket list must be complete, a bare invocation must name a real mode. Verified by appending `bin/facts/audit stale` to docs/design.md: audit self goes red with exactly that finding, green again once reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both gates were `./tool 2>/dev/null || echo "not yet implemented; gate
skipped"`, so any failure was swallowed and the traceback hidden.
kb/eval had in fact never run in CI: the step invoked ./bin/kb/eval with the
system python instead of `uv run`, so it died on `import ladybug` and the ||
branch reported it as "not yet implemented". Even with uv it would have
failed — nothing builds an index in CI and var/ is gitignored.
- both steps now run through `uv run` with no `||` and no `2>/dev/null`
- a `bin/kb/index --rebuild` step builds the repo corpus first, with the HF
model restored from actions/cache (~1GB on a cold cache)
- the control set drops `("eslider devops engineer", "DevOps")`: "devops"
appears nowhere in the repo corpus, that question needs the portfolio
corpus, and it alone kept recall at 0.667 — the gate could not have passed.
Six questions that the repo corpus does answer replace it
- hit_texts_of no longer swallows exceptions; a broken FTS index must fail
loudly instead of masquerading as recall 0.0
- PLAN.md records OQ5: the Go search path is still ungated because
bin/kbsearch needs the native ladybug library, and states that gates are
fail-closed by policy
Verified locally end to end after `rm -rf var`: audit self ok, index 47/47
leafs, recall@5 1.0, 70 python tests, go vet + go test green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ntion
`" x " in source` accepted "bullshit x bullshit2", and counting distinct
strings accepted two compose files as two sources. Two compose files are two
paths but one method, one kind of claim, often one author — corroboration,
not independence.
A Source is now structured: kind (from a fixed taxonomy), method, locator,
origin. Two sources are independent only when kind *and* origin differ, and
every source needs a locator, because evidence you cannot go back and look
at is not evidence. check_independence() returns the reasons; make_fact()
refuses and says which rule broke.
Follow-on effects:
- facts/extract records *where* a doc mentions a host ('README.md:89'), so
loc leads back to the evidence instead of asserting it exists
- repo compose pairings are keyed by file, so the locator names the compose
that actually declares the service
- audit self now feeds five bad pairings through make_fact and fails if any
is accepted — a gate that only accepts is not a gate
- Leaf.source keeps the " x " rendering for output, but it is derived from
the sources and nothing verifies against it any more
Next commit persists the sources as Evidence nodes; until then audit db
still reads the flattened string.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
audit db checked `" x " in source` — a claim about a string, not about
evidence. `"bullshit x bullshit2"` passed it. The string was all there was:
the schema flattened the sources into one free-text field, so nothing better
could be checked.
Evidence is now a node:
(Evidence {id, kind, method, locator, origin, value_hash, observed_at})
-[:SUPPORTS]-> (Leaf)
Evidence is shared, not copied — one observation backing two assertions is
one node with two edges, so "how many independent things did we look at"
stays answerable. kblib.facts_lacking_independence() counts observations,
kinds and origins per facts leaf and is what audit db now reports; the
source string is no longer consulted by any check.
facts/extract and facts/crm both write structured evidence. crm's `loc` used
to be "bin/facts/crm" — the tool answering "where did you see it" with its
own path; it now points at the CRM export key and the yaml org id.
Verified end to end against a real db: extract writes 2 facts, audit db is
clean, then two forged facts are injected and both are caught —
"bullshit x bullshit2" (evidence=0) and the subtle one, two compose files
with 2 evidence nodes and 2 origins but kinds=['declared']. 82 tests pass.
facts/crm could not be run: neither /tmp/opencode/crm/graph.json nor the
knowledge-mesh yaml exists on this machine, so its change is reviewed and
syntax-checked only.
Docs updated (AGENTS, PLAN D8, README, design.md) and PLAN OQ6 records what
is still missing: value_hash is written empty, nothing re-runs a locator, so
`confirmed` still means "was true when observed".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The graph was a bag of leafs. init_schema declared File, FROM_FILE, HAS_VERSION and RUNS_ON, kblib's docstring advertised "Cypher graph hops", and a fresh index produced 47 Leaf nodes, 0 File nodes and 0 edges — every rel table empty. Nothing that reads the graph could have returned anything. upsert_leaf now takes file_path/repo and MERGEs a File node plus a FROM_FILE edge; kb/index and mail/index_mail pass them. neighbours_of() does one hop: the other leafs of the same file, which is the step `--hop N` repeats from each new frontier. After --rebuild: 47 leafs, 12 files, 47 edges. A leaf found by FTS in README.md now walks to its 6 siblings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--hop was documented in six files and implemented in none. Worse, it failed silently: the parser dropped any unrecognised `-flag` and kept its argument, so the documented example `bin/kb/search "what runs on arc-2" --hop 1` searched for "what runs on arc-2 1". Proven by running the old parse loop verbatim. Parsing moved to args.go, free of cgo so it can be tested at all. Unknown flags, non-numeric or out-of-range values and an empty query are now errors with exit 2 and a usage line, instead of being absorbed into the query. expandHops() walks the FROM_FILE edge: each round asks for the neighbours of the previous frontier, skips leafs already seen (so a<->b cannot loop), tags new ones with their depth and adds at most `-n` per round. Hits carry `hop: N` in YAML and JSON; ranked hits stay unmarked. Docs corrected to what is actually walked: design.md and kb-search/SKILL.md claimed `related:` links and vector-neighbours, and no such edges are written. Verification: the package now type-checks as a whole. bin/kbsearch cannot be compiled here (no native ladybug), so I built stub modules for the two cgo dependencies and ran `go vet` plus the tests over every real source file -- which is how the unused `strings` import left behind by this change was caught. The Cypher in hopStmt mirrors kblib.neighbours_of, which is tested against a real database in test_kblib.py; the Go query itself is unrun. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s modes The mode check I added only covered bin/facts/audit, so it would not have caught --hop, and did not. check_documented_commands() now scans AGENTS.md, PLAN.md, README.md, docs/*.md and skills/*/SKILL.md: every bin/... shown inside code must exist, and every flag it is shown with must be declared in its source. Static by design — running each tool with --help would execute tools like facts/crm that have no argparse and no dry-run. Both declaration spellings count, since argparse writes "--rebuild" while Go's flag package writes "out" and still accepts --out. Three false positives were found while triaging and fixed in the checker, not by loosening the rule: `-s bin/tools` is a directory argument, `-n` in `bash -n bin/db/psql-yq` sits before the command, and `///usr/bin/env go run` is not an invocation of `bin/env`. Four real findings, now fixed: - AGENTS.md advertised bin/md/tables and bin/brain/deduce; neither exists. Replaced with bin/kb/stats, and PLAN's layout marks the rest as planned - skills/diataxis-docs documented `--hop` following `related:` links and a `--type` filter; neither exists. Corrected to what the tools do - skills/agent-cost documents bin/agents/cost, which was never vendored. Rather than delete someone's skill, it is declared in EXTERNAL_TOOLS with a reason and printed under `exceptions` on every audit run — an invisible suppression is how --hop survived in six files 96 python tests, audit self and db clean, recall@5 1.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The one finding this PR does not fix — |
eSlider
left a comment
There was a problem hiding this comment.
Verdict: request changes
This PR found that several gates were theatre and replaced them with checks that can actually go red. The FTS ascending-score bug, filter-before-limit, unknown flags being swallowed into the query, and " x " in source were all real. The tests name the failure mode. OQ5/OQ6 are written as open questions instead of papered over. That is the right standard for this repo.
Blocking before merge (AGENTS.md + PLAN D8/D15):
- No absolute
/mnt/paths in committed files or in persisted evidence.bin/facts/crmnow interpolates/mnt/8TB/...and/tmp/opencode/...into Evidence locators/origins. - File identity is path-only. Two repos with
README.mdshare one File node;--hopthen walks across repos. - The ranking/parser tests do not run in CI.
rank.go/args.gosit in the cgo module. The thesis of this PR is that a gate must be able to fail — these tests need a cgo-free package sogo teston ubuntu-latest actually runs them. --hopdrops--root/--repoand treats a DB error as exit 0. Same class of silent failure this PR exists to kill.bin/facts/auditmodule docstring still describes the" x "source check the rest of the PR removed (rule 6: docs reflect behaviour).
Also:
- Drop the "Generated with Claude Code" footer from the PR body. Commits carry
Co-Authored-By: Claude Opus 5; squash-merge can drop those trailers. This repo does not attribute PRs/commits to AI. - CI has not reported on this fork PR yet. First-time contributor workflows often need maintainer approval — please trigger the Checks tab after addressing the above.
Happy to re-review a follow-up that keeps the evidence model and the fail-closed CI. The commit grouping (search / evidence / CI+audit) is already the right split if this needs to land in pieces.
| f"{CRM_GRAPH}:companies_with_persons:{item['crm_key']}", "oo-crm"), | ||
| factsrules.Source( | ||
| "doc", CORPUS_MESH.name, | ||
| f"{CORPUS_MESH}:orgs:{item['org']}", f"file:{CORPUS_MESH}"), |
There was a problem hiding this comment.
Blocking — GitHub safety (AGENTS.md rule 1) and PII.
CORPUS_MESH is still /mnt/8TB/projects/eslider/cv/.... This change now writes that absolute path (and /tmp/opencode/crm/graph.json) into Evidence.locator / Evidence.origin, so a later audit db dump or leaf export leaks a host path into the graph.
The old loc="bin/facts/crm" was the wrong kind of pointer; this is the right kind with the wrong value.
- Resolve both inputs from env (
$PROJECTS_ROOT,$HOME, or a gitignored config path). Do not commit/mnt/,/home/<user>/,/Users/<user>/. - Store locators as repo-relative / logical keys (
knowledge-mesh-seed.yaml:orgs:<id>,oo-crm:companies_with_persons:<key>), not host paths. - The fact text still interpolates CRM person names into
root=factson a public repo. That was pre-existing, but this path now persists it as structured evidence — keep third-party names out of committed fixtures and prefer ids over names in leaf text.
Also: json.load(open(CRM_GRAPH)) is still an unclosed handle.
| Without these edges the graph is a bag of leafs: `--hop` has nothing to | ||
| walk and the File->Commit->Person history has nothing to hang off. | ||
| """ | ||
| fid = sha256_b64(path)[:24] |
There was a problem hiding this comment.
Blocking — File identity ignores repo.
fid = sha256_b64(path)[:24] hashes the path only. repo is stored as a property and then overwritten on MERGE.
The brain indexes multiple corpora (repo markdown, mail, ops). Two README.mds (or the same relative path in mail vs tree) collapse onto one File node. --hop then returns siblings from the other repo — the walk this PR just made real.
Include repo in the id (sha256(f"{repo}:{path}")), and do not MERGE-overwrite f.repo.
Secondary: mtime here is the observation clock from upsert_leaf, not the file's mtime. That will make D10/HAS_VERSION staleness lie. Pass the real mtime (or leave it unset until git/import owns it).
| found = evidence_for(conn, lid) | ||
| kinds = sorted({e["kind"] for e in found if e["kind"]}) | ||
| origins = sorted({e["origin"] for e in found if e["origin"]}) | ||
| if len(found) >= MIN_EVIDENCE and len(kinds) >= MIN_EVIDENCE and len(origins) >= MIN_EVIDENCE: |
There was a problem hiding this comment.
audit db does not enforce locators, unlike make_fact.
check_independence refuses an empty locator. This gate only counts nodes / distinct kinds / distinct origins. A writer that bypasses make_fact (or upsert_evidence(..., locator="")) can store two kind+origin pairs with nowhere to re-check, and audit db stays green.
The PR's own rule: evidence you cannot go back and look at is not evidence. Count locators here too (and reject empty kind/origin rather than dropping them from the set).
Thank you for killing " x " in source — the injected bullshit x bullshit2 case is exactly the test this needed.
| Exit 0 = all checks pass, 1 = audit failures, 2 = could not evaluate. | ||
| `db` loads every Leaf with root=facts and asserts each carries a two-source | ||
| `source` (the " x " pairing), a non-empty `loc` (the "where did you see it" | ||
| pointer), a `how`, and confidence='confirmed'. |
There was a problem hiding this comment.
Docs reflect behaviour (AGENTS.md rule 6).
This docstring still says db asserts a two-source source (the " x " pairing). The function below deliberately stopped doing that — that was the point of the PR.
Readers of the tool file will re-learn the bug you just removed. Update this block to: Evidence nodes, ≥2 observations, ≥2 kinds, ≥2 origins, locator present.
| walked, err := expandHops(results, opt.hops, limit, neighbours) | ||
| if err != nil { | ||
| fmt.Fprintf(os.Stderr, "hop: %v\n", err) | ||
| } |
There was a problem hiding this comment.
Same class of silent failure this PR exists to kill.
- Hop DB errors are printed to stderr, then
results = walkedandrunSearchreturns 0. The user gets a successful YAML document with nohopkey and no non-zero exit. Unknown flags now fail closed (good); this path is still fail-open. rankAndFilterhonours--root/--repo, thenexpandHopsappends every sibling of the file, including the other root.bin/kb/search "q" --root facts --hop 1will mixinfoleafs back in. Deduction is facts first; the filter has to apply to hopped hits too (or be documented as intentionally unfiltered, with a test).
hopStmt itself is fine as a mirror of kblib.neighbours_of (tested in Python). The Go []any bind has never executed — if ladybug rejects the param type, this branch will hit (1) on every --hop. Return non-zero.
| h("i3", "info", "docs/c.md"), | ||
| h("f1", "facts", "docker ps x compose"), | ||
| } | ||
| eq(t, rankAndFilter(fts, nil, "facts", "", 2), "f1") |
There was a problem hiding this comment.
Thank you. This is the test the old hybrid+truncate+filter sequence could not have passed. The comment states the failure mode in one sentence. Same for TestParseHopIsNotSwallowedIntoTheQuery and the RRF tie-break.
Please get these onto CI (cgo-free package — comment on ci.yml). A perfect regression test that never runs is how --hop survived in six docs.
| opt.listModel = true | ||
| default: | ||
| if strings.HasPrefix(arg, "-") { | ||
| return opt, fmt.Errorf("unknown flag %q", arg) |
There was a problem hiding this comment.
Thank you. Unknown flags as an error with exit 2 is the correct fix for "documented --hop 1 searched for query 1". Validating --root / -n / --hop instead of silently keeping the default is the same standard.
| f"sources share one origin ({sorted({s.origin for s in sources})}); " | ||
| "one system of record cannot confirm itself" | ||
| ) | ||
| return problems |
There was a problem hiding this comment.
Thank you. This is the actual D8 rule: kind and origin, locator required, taxonomy closed, make_fact refuses instead of rendering a string that looks like two sources.
The pairing helpers staying pure (no docker, no db) is why audit self can run in CI. The "two compose files are corroboration" refusal is the one that would have been impossible to express with " x " in source.
| except Exception: | ||
| return [] | ||
| # Deliberately unguarded: a broken FTS index must fail the gate loudly, | ||
| # not disguise itself as recall 0.0. |
There was a problem hiding this comment.
Thank you. Unguarded FTS is the right default: a missing index must fail the gate, not report recall 0.0. Control questions that the repo corpus can actually answer were the other half of this fix.
| cmd.Stderr = nil | ||
| // Own session: a Ctrl+C in the terminal that launched the search must not | ||
| // take the daemon down with the foreground process group. | ||
| cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} |
There was a problem hiding this comment.
Thank you for actually calling ensureDaemon and for Setsid so Ctrl+C on the search does not kill the server.
Please also Release() the child after Start (otherwise the CLI holds a zombie until it exits), and serialise the bind: two concurrent searches can both miss /health and both spawn serve on 17830. First wins, second dies after 10s and falls through to a 100MB in-process load — which is the path this change was meant to avoid.
Rebase required — current
|
Started from three bugs in the Go search and ended up in the evidence model,
because each fix uncovered a gate that could not fail.
Search (bin/kbsearch)
QUERY_FTS_INDEXusedORDER BY scorewherekblib has always used
ORDER BY score DESC, so the Go search took theworst BM25 matches and fed them into RRF in reverse rank order.
SET STREAM_SANDBOXran on the package-levelconnbefore
OpenConnection; any run withKBTEST_EPSset panicked.--rootreturned nothing. Results were truncated to-nbefore theroot/repo filters, so every matching leaf below the cut was discarded —
--root factscame back empty whenever info leafs filled the top N. Thededuction order was unreachable from the CLI.
ensureDaemon()existed and was nevercalled, so every query loaded the ~100MB potion matrix in-process — the
thing the daemon exists to avoid. It now auto-starts in its own session,
with
KBSEARCH_NO_DAEMON=1to opt out.--hopexisted only in the docsDocumented in six files, implemented in none, and it failed silently: the
parser dropped unrecognised
-flagsbut kept their arguments, so thedocumented example
bin/kb/search "what runs on arc-2" --hop 1searched for"what runs on arc-2 1".Implementing it surfaced the reason nobody noticed: the graph had no
edges.
init_schemadeclaredFROM_FILE,HAS_VERSIONandRUNS_ON,kblib advertised "Cypher graph hops", and a fresh index produced 47 Leaf
nodes, 0 File nodes and 0 edges.
kb/indexandmail/index_mailnow writeFile nodes and
FROM_FILEedges (47 leafs → 12 files → 47 edges), and--hop Nwalks them, tagging results with the depth they were reached at.Unknown flags are now an error instead of being absorbed into the query.
The evidence rule was never checked
facts/audit dbverified that" x "appeared inLeaf.source. That is aclaim about a string, not about evidence —
"bullshit x bullshit2"passedit. And the string was all there was: the schema flattened the sources into
one free-text field.
kind,method,locator,origin) and twosources count as independent only when kind and origin both differ.
Two compose files are two paths but one kind of claim — corroboration, not
independence. Every source needs a locator, because evidence you cannot go
back and look at is not evidence.
(Evidence)-[:SUPPORTS]->(Leaf), shared rather thancopied, so "how many independent things did we actually look at" stays
answerable.
audit dbcounts observations, kinds and origins; the sourcestring is no longer consulted by any check.
facts/crmused to recordloc="bin/facts/crm"— the tool answering"where did you see it" with its own path. It now points at the CRM export
key and the yaml org id.
Verified by injecting two forged facts into a real database: both are
caught, the blunt one (
evidence=0) and the subtle one (two compose files,2 evidence nodes and 2 origins but
kinds=['declared']).CI gates were fail-open
kb/evalhad in fact never run: the step used the system python insteadof
uv run, died onimport ladybug, and the||branch reported it as"not yet implemented". Even with uv it would have failed — nothing built an
index and
var/is gitignored. Its control set also asked about "DevOps",which appears nowhere in the repo corpus, pinning recall at 0.667.
Both gates now run through
uv runwith no||and no2>/dev/null, anindex is built first (HF model from
actions/cache, ~1GB cold), and thecontrol questions are ones the repo corpus can answer.
audit selfwas three grepsIt checked that PLAN.md contained "recall@5" and that README mentioned
HNSW/BM25 — with
2.sourceas a regex, unescaped dot. It could not go red.It now runs the pairing rule against a fixture, checks the tool convention
(D14), and verifies that every
bin/…shown in a doc code block existsand accepts the flags it is shown with. That last check is what would have
caught
--hop; the narrower version I wrote first did not.Findings it produced, all fixed here:
bin/md/tablesandbin/brain/deduceadvertised in AGENTS.md do not exist,
skills/diataxis-docsdocumented a--typefilter and--hopfollowingrelated:links that were neverimplemented, and
bin/md/importhad no usage block.One decision left to the maintainer
skills/agent-costdocumentsbin/agents/cost, which was never vendoredinto this repo (PLAN D2). Rather than delete a skill, it is declared in
EXTERNAL_TOOLSwith a reason and printed underexceptionson everyaudit selfrun:An invisible suppression is how
--hopsurvived in six files. Vendor thetool or drop the skill — either way the exception should go.
Verification, and its limits
Green: 96 python tests,
audit selfandaudit dbclean,recall@51.0after a rebuild from an empty
var/,go vet+go test ./...on the rootmodule.
Not verified, stated plainly:
bin/kbsearchwas never compiled. It needs the native ladybug library(
lib-ladybug/, gitignored) which was not available. I built stub modulesfor the two cgo dependencies and ran
go vetand the package tests overevery real source file — that is how an unused
stringsimport left bythis change was caught — but the Cypher in
hopStmthas never executed.Its python counterpart
kblib.neighbours_ofis tested against a realdatabase.
bin/facts/crmwas never run: neither/tmp/opencode/crm/graph.jsonnor the knowledge-mesh yaml exists outside its own environment. Reviewed
and syntax-checked only.
bin/kbsearchmodule is still outside CI for the same reason,recorded as OQ5. OQ6 records that
Evidence.value_hashis writtenempty and nothing re-runs a locator, so
confirmedstill means "was truewhen observed" and staleness cannot be detected.
Happy to split this into separate PRs (search / evidence model / CI+audit)
if that reviews better — the commits are already grouped that way.
🤖 Generated with Claude Code