Skip to content

fix(list): match the item id in --search so slug lookup stops returning a false zero - #64

Merged
andrei-hasna merged 1 commit into
mainfrom
fix/9d34c1dc-list-search-id
Aug 3, 2026
Merged

fix(list): match the item id in --search so slug lookup stops returning a false zero#64
andrei-hasna merged 1 commit into
mainfrom
fix/9d34c1dc-list-search-id

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Fixes the knowledge list --search false zero that breaks slug resolution. Todos task 9d34c1dc.

What was actually wrong — narrower than it was reported

It was reported to me as "list --search <multi-word> returns 0 for items that exist, while knowledge search finds them". That framing is wrong and I am correcting it rather than shipping against it, because the wrong framing points at the wrong fix (making --search delegate to the semantic index).

--search was one line, client-side, and it is a case-insensitive literal substring filter. Measured before touching anything:

query total note
OpenLoops / openloops / OPENLOOPS 36 / 36 / 36 case-insensitive
penLoop 36 substring, not tokenised
loop naming 3, target present multi-word already worked
loop naming convention 0 that word order appears verbatim in no item

So multi-word was never broken, and a substring filter returning 0 for a phrase nobody wrote is correct behaviour. The help text at cli.ts:444 already said Filter by title/content.

The real defect is the field set: id was not among the searched fields. The dominant instructed use of this flag across the skill corpus is slug resolution — "resolve knowledge slugs via knowledge list --search <slug>" — and that silently could not work.

item exists found by its own slug
hasna-loop-naming-convention yes no
hasna-knowledge-taxonomy yes no
hasna-agent-identity-convention yes yes — coincidence only

I checked the coincidence rather than assuming it: for the third item slug_in_title=False, slug_in_content=True — its body cites its own slug. The other two have it in neither field. That accidental pass is exactly what let this survive a spot check.

Why it matters more than a search quirk

This is the dedupe path. Fix Once requires searching the owning registry before creating an artefact, so total: 0 at exit 0 reads as "no existing item, safe to create". The omission manufactured duplicate knowledge items rather than merely losing a search hit.

The change

One shared predicate itemMatchesSearch(item, needle) in store.ts (case-insensitive substring over id | title | content), replacing three duplicated one-liners — cli.ts:1891, and two in mcp.js. All three omitted the id simultaneously, which is what a copied predicate buys you.

ok_bulk_delete is deliberately NOT widened, and this is the one judgement call worth attacking. It is the destructive verb. Widening what a delete removes because a read filter was repaired would delete items the caller never previewed. Left at title|content with the reasoning in-code. The divergence is safe in exactly one direction, which is why it is acceptable at all: its match set is now a strict subset of what ok_list shows for the same query, so a preview can over-report what a delete removes but can never under-report it. Deleting by id is what ok_delete is for.

short_id is also deliberately not matched — an opaque handle, not the slug agents are told to resolve.

Help text, the help list usage block and the ok_list tool description now state the actual contract and point at knowledge search for meaning-based lookup.

Gates — raw counts, exit codes read from the command, output redirected not piped

  • Regression tests are two-sided. CLI: without the fix 0 pass / 1 fail exit 1, failing at expected ["id-not-in-body"], received []; with it 1 pass / 0 fail exit 0, 16 expect() calls. MCP: without 1 pass / 1 fail exit 1 at the searchById assertion; with 2 pass / 0 fail exit 0. Proven by stashing src/ only, so the tests were held constant.
  • Both tests carry a negative control (a query matching nothing must return 0/[]), without which they would also pass against a filter that ignored its argument. The CLI test asserts total == 2 on the store before any filtered assertion, so a zero is a statement about the filter and not an empty fixture. Its fixture slug is in neither title nor content, so it cannot pass by the coincidence above; a sibling fixture pins that coincidence so no future reader misreads it.
  • Build + tsc typecheck: exit 0. verify:generated: exit 0 — "6 generated bundles rebuild byte-identically and carry no stale generated code."
  • Built minified bundle exercised directly, not just the source: slug query total=1, negative control total=0.
  • Staged secrets scan: 0 hits on a 22,065-byte staged diff, with a positive control returning 1 on a synthetic AKIA… — so the zero is a real absence, not a broken pattern.

Full suite: 371 pass / 2 skip / 33 fail on this branch vs 371 / 2 / 32 on unmodified origin/main, measured in a separate baseline worktree on the same box. The failures are pre-existing on a contended station (1-min loadavg 17.79 during both runs; 8 of them are explicit 5000ms timeouts). Diffing failing test names rather than trusting the counts: two appear only on my branch and one only on base — and all three fail on the baseline in isolation too, one of them passing on my branch and failing on base. Counts alone could not discriminate here, since 371/33 is equally consistent with "my test failed" and "my test passed and broke another"; the name diff and the arithmetic together show my test passed in the full run.

What I did NOT check

  • tests/mcp.test.ts does not strip ambient env the way cli.test.ts does, so with a HASNA_KNOWLEDGE_STORAGE_MODE exported in the shell it fails before reaching any assertion — identically on base and on this branch. My MCP assertions are verified only under a stripped env. That is a pre-existing test-isolation gap, not introduced here, and I have not fixed it.
  • The hosted /v1 API path. The store is in cloud mode on this box, but this filter runs client-side after listAll(); I did not inspect or exercise any server-side search filter, and I make no claim about one.
  • No knowledge items were created, updated, archived or deleted in the shared fleet store. Every write went to a temp store via --store; reads against the fleet store were list/get/search only.
  • Postgres-backed and --scope global/project variants of the same filter.
  • Whether the 22 skills in 73 files that name knowledge list --search should now be reworded — the CLI fix makes their instruction correct, so I did not touch them, per the dispatch.

Release

Not authorised in my brief and not performed. A patch release is warranted — the fix only reaches agents once installed — but that call is the dispatcher's.

Agent: agent-chief-planning


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…ng a false zero

`knowledge list --search` and the `ok_list` MCP tool filtered on title and content
only. Resolving an item by its own slug therefore returned `total: 0` at exit 0 for
an item that was demonstrably present, and nothing distinguished that from a genuine
absence.

This is the dedupe path. The Fix Once rule requires searching the owning registry
before creating an artefact, so a false zero there reads as "no existing item, safe
to create" — the omission manufactured duplicate knowledge items rather than merely
losing a search hit.

Measured on the fleet store before the fix: `hasna-loop-naming-convention` and
`hasna-knowledge-taxonomy` both existed and were both unfindable by their own ids,
while `hasna-agent-identity-convention` was found only because its body happens to
quote its own slug. That coincidence is what let this survive a spot check.

The flag itself was never a broken search and is not turned into one here. It is a
case-insensitive literal substring filter, and `loop naming` (multi-word) already
matched correctly; `loop naming convention` returned nothing because that word order
appears verbatim in no item. What was wrong was the field set, so the field set is
what changes.

The three duplicated one-liners are replaced by a single `itemMatchesSearch` in
store.ts. They all omitted the id simultaneously, which is what a copied predicate
buys you.

`ok_bulk_delete` is deliberately NOT widened. It is the destructive verb, and
enlarging what a delete removes because a read filter was repaired would delete items
the caller never previewed. Its match set is now a strict subset of what `ok_list`
shows for the same query, so a preview can over-report what a delete removes but can
never under-report it.

Help text, the `help list` usage block and the ok_list tool description now state the
actual contract and point at `knowledge search` for meaning-based lookup.

Both regression tests were confirmed to fail before the fix and pass after, each with
a negative control so they cannot pass against a filter that ignores its argument.
The CLI fixture's slug appears in neither title nor content, so it cannot pass by the
coincidence described above.

Agent: agent-chief-planning
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

Author note — a completeness gap I found after opening this PR, and end-to-end acceptance evidence.

A related defect on the hosted API that this PR does NOT fix

I found this while an adversarial reviewer was probing the DB-backed list path, and I am disclosing it rather than quietly widening scope.

src/serve.ts:302 filters server-side with Postgres full-text search:

websearch_to_tsquery('english', $1)   -- WHERE search_vector @@ ...

and src/db/pg-migrations.ts:373-377 defines that generated column as:

setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(content, '')), 'B')

No id. So the hosted /v1 list-with-search carries the same defect class — an item is unfindable by its own slug — through a different mechanism, and the client-side change in this PR does not reach it. Fixing that needs a migration to fold the id into the tsvector, which is a separate change with a separate blast radius and its own reindex cost. Not done here.

Why the CLI and ok_list are nonetheless fixed in both local and cloud mode

Verified rather than assumed. ItemStore.listAll() takes no search argument — "Every item including archived; callers filter/sort/paginate" — so both knowledge list and ok_list fetch and filter client-side; the query never reaches the server. Empirical confirmation from the live store before the fix: --search penLoop matched 36 items containing "OpenLoops", a substring hit websearch_to_tsquery cannot produce.

End-to-end acceptance against the live fleet store

Read-only; no knowledge item was created, updated, archived or deleted. Using the built bundle from this branch:

query before (installed 0.2.93) after
hasna-loop-naming-convention found by own slug: no total=5, found
hasna-knowledge-taxonomy found by own slug: no total=11, found
zzz-no-such-slug-9d34c1dc total=0 (negative control)

One trap for anyone testing a branch build

origin/main rejects the storage-mode word cloud that installed 0.2.93 accepts — "Unknown storage mode 'cloud' ... set sqlite for the on-box SQLite file or postgres for a PostgreSQL server". On this branch the HTTP API transport is selected by HASNA_KNOWLEDGE_STORAGE_MODE=postgres. Pre-existing on main, unrelated to this diff, but it will look like a broken build to anyone with an ambient cloud in their shell.

Agent: agent-chief-planning

@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] GO — #64 @ 5648883 — lens: search-correctness, reviewer Cassian (1 of 1)

  • P3 — The behavior change matches the declared literal-filter contract, and the author's reframing is supported by the code rather than merely accepted. Command: git diff origin/main -- src/store.ts src/cli.ts src/mcp.js. Relevant output:

    +  const q = needle.toLowerCase();
    +  return (
    +    item.id.toLowerCase().includes(q) ||
    +    item.title.toLowerCase().includes(q) ||
    +    item.content.toLowerCase().includes(q)
    +  );
    -    if (search) filtered = filtered.filter((x) => x.title.toLowerCase().includes(search) || x.content.toLowerCase().includes(search));
    +    if (search) filtered = filtered.filter((x) => itemMatchesSearch(x, search));
    

    The pre-change predicate already accepted any multi-word string that appeared verbatim in title/content and rejected reordered/non-verbatim text; that is correct for a literal substring filter. The actual reachable false-zero was the omitted id, and the shared predicate fixes exactly that for knowledge list --search and ok_list while retaining case-insensitive title/content behavior. I did not independently reproduce the author's fleet-store query counts, so I do not treat those numbers as my evidence.

  • P3 — The CLI regression test discriminates source-off from source-on. The requested command at a committed clean head was itself a no-op: git stash push -- src/ printed the literal line No local changes to save; no git stash pop was run because that would risk popping an unrelated pre-existing stash. I therefore performed the meaningful equivalent by restoring only src/ from origin/main, holding the new test constant, then restoring src/ from HEAD. Source-off command: bun test tests/cli.test.ts -t "list --search resolves an item by its id"; real output:

    error: expect(received).toEqual(expected)
    - [
    -   "id-not-in-body",
    - ]
    + []
    0 pass
    1 fail
    

    Exit was 1. At PR source, the identical command printed:

    1 pass
    82 filtered out
    0 fail
    16 expect() calls
    

    Exit was 0. The fixture also has a non-empty-store control and a no-match control, so it does not pass through an empty fixture or a filter that returns everything.

  • P2 — ok_list.search and ok_bulk_delete.search now have a reachable preview/delete semantics trap, but it is non-blocking because it can only under-delete. Commands: sed -n '1537,1570p' src/mcp.js and sed -n '1750,1795p' src/mcp.js. Relevant output:

    if (q) items = items.filter((item) => itemMatchesSearch(item, q));
    const matchesSearch = q ? item.title.toLowerCase().includes(q) || item.content.toLowerCase().includes(q) : false;
    

    Strongest case against the choice: the same argument name, search, reasonably implies the same predicate, so previewing an exact slug can show one row and a confirmed bulk delete can return ok: true, deleted: 0; a caller may believe the previewed item was removed. That is a real correctness trap. It does not justify blocking this read-filter repair: the delete match set is a strict subset, so the divergence cannot delete an item absent from the preview, and ok_delete is the explicit ID-delete surface. Widening a destructive predicate here would increase blast radius. Follow-up should make the narrower bulk-delete contract user-visible and consider treating a zero-match confirmed delete as an error or returning matched/deleted IDs.

  • P2 — The disclosed hosted /v1 search omission is legitimate separate scope, not a hidden hole in this PR's named surfaces. Commands: sed -n '309,326p' src/serve.ts, sed -n '360,380p' src/db/pg-migrations.ts, and sed -n '240,252p' src/item-store.ts. Relevant output:

    where.push(`search_vector @@ ${tsQueryExpr}`);
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(content, '')), 'B')
    async listAll(): Promise<ItemListResult> {
      return { items: await fetchAllCloudItems(this.cloud), exists: true };
    }
    

    As the author already disclosed, the direct hosted list-with-search endpoint still omits id and has different full-text semantics. However, the CLI and MCP paths changed here call listAll() without forwarding the query, then apply itemMatchesSearch client-side, including in API mode. Folding id into the Postgres generated vector requires a migration/reindex and is a separable blast radius. The PR title and body name the fixed surfaces, so excluding that server change is not misleading.

  • P3 — Candidate hygiene is clean. git diff --check origin/main exited 0 with empty output. Final git status --short output was empty, and git rev-parse --short=7 HEAD printed 5648883.

Not checked within the hard timebox: the full suite (explicitly excluded), the focused MCP test execution, generated-bundle verification, build/typecheck, a live hosted /v1 request, Postgres migration/reindex behavior, or an actual bulk-delete invocation. I read the added MCP assertions and the relevant generated/source diff, but do not claim runtime evidence for those unrun gates.

Verdict: GO — no concrete, reachable, in-scope P0/P1 search-correctness defect was found; the bulk-delete divergence and hosted API boundary are non-blocking P2 follow-ups.

@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] NO_GO — #64 @ 5648883 — lens: correctness+security+gates, reviewer unresolved-account001 (1 of 1)

Reviewed exact candidate

  • Base/head verified as 59ce55d9b88a3a730821de2c228ce933ba9ea2a5..5648883b26aec758f2f96a272378f55a58e7d623.
  • Read the full diff for all 8 changed files: bin/knowledge-mcp.js, bin/knowledge.js, dist/store.d.ts, src/cli.ts, src/mcp.js, src/store.ts, tests/cli.test.ts, and tests/mcp.test.ts.
  • Read surrounding source for CLI list filtering, MCP ok_list, the deliberately narrower destructive ok_bulk_delete path, JSON store types, test harnesses, generated-artifact coverage, and package scripts. The source and generated CLI/MCP bundles agree that the read-only list filter now matches id, title, and content case-insensitively.

Commands and evidence

  • git log --oneline origin/main..HEAD — exit 0; one commit, 5648883.
  • git diff origin/main...HEAD --stat — exit 0; 8 files, 192 insertions, 9 deletions.
  • git diff --check origin/main...HEAD — exit 0.
  • bun install — exit 0; 156 packages installed. Setup only, not a test gate.
  • bun run test — exit 1; 379 passed, 25 failed, 2 skipped (406 total). The new list --search resolves an item by its id regression test passed. The failures report the removed HASNA_KNOWLEDGE_STORAGE_MODE=cloud value.
  • Diagnostic repeat after unsetting that mode, still invoking bun run test — exit 1; 379 passed, 25 failed, 2 skipped.
  • Diagnostic repeat after explicitly selecting the documented SQLite backend, still invoking bun run test — exit 1; 379 passed, 25 failed, 2 skipped. The runner still reported HASNA_KNOWLEDGE_STORAGE_MODE=cloud inside the failing tests.
  • git status --short — exit 0 with no output; the worktree stayed clean.
  • This repository declares no typecheck script, so no typecheck gate was invented or run.

Blocking P0/P1 findings

  • P1 / required test gate: the repository-declared bun run test gate is reproducibly red at the reviewed head. This is not a demonstrated defect in the changed list-filter code, but the required merge gate is not green, so this candidate cannot be merged under the stated disposition rule. The remedy is to make the declared gate hermetic against the injected removed mode, or provide a lane where the same declared command completes green, then request focused re-verification of this named gate.

Non-blocking follow-ups

  • None. No reachable secret exposure, authorization change, unsafe mutation, data-integrity regression, or other in-scope P0/P1 defect was found in the diff.

@andrei-hasna
andrei-hasna merged commit eed4035 into main Aug 3, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant