Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 71 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,72 @@
# Changelog

## Unreleased — documentation corrections to the multi-tag work

Wording-only corrections to claims landed by #34/#35. No behaviour change: the
only non-comment source edit is one added `list --help` line, plus one added test
case (six `expect()` calls, 860 -> 866 in `tests/cli.test.ts`); the removal
semantics, exit codes and messages are untouched.

- The `untag` whole-value-versus-split exclusivity is **per raw `-t` value**, not
"one shape per run". Four sites said per run (`README.md` twice, `src/cli.ts`
twice); repeated `-t` on `untag` still accumulates, so on an item carrying both
shapes `untag -t "a,b,c" -t a` removes 2 and `-t "a,b,c" -t a -t b -t c` removes
4 in a single run. The re-run contract itself is true and unchanged
(`removed: 1`, then `removed: 3`, then exit 1) — only its stated reason was
wrong, and the reason is what a reader would reimplement from. (Repeated `-t` on
`list` narrows instead; that has not changed.)
- Corrected the justification for quoting names in the partial-miss `untag`
message, at three sites (`README.md`, `src/cli.ts`, `tests/cli.test.ts`). The
cited case — `(not found: p, q)` colliding with one tag literally named `p, q` —
is **unreachable**: a comma-bearing value only enters the removal set via the
whole-value branch, which requires the tag to be stored, so it is found by
definition and never appears in `not_found`. Because every entry is comma-free
the `", "` join is injective, so a plain space is a legibility problem, not a
collision. The load-bearing reachable case is whitespace `trim()` does not
strip: `-t $'p\nq'` yields `not_found: ["p\nq"]`, which joined raw would break
the single-line message in two. Now covered by a test assertion.
- Documented an undocumented flag precedence on `list`: when both `--archived` and
`--include-archived` are passed, `--archived` wins (archived items only) in
either order. Documented, not changed.
- Made the consequence of a split-only `list -t` conditional instead of universal,
at three sites (`README.md`, `src/cli.ts`, `tests/cli.test.ts`). All three said
the defect "returns a DIFFERENT item ... at `total: 1` and exit 0" outright. It
only does that when the corpus *also* holds an item carrying the three names
separately; with the glued item alone it returns `total: 0` at exit 0. Measured
by removing the whole-value branch from the `list` predicate and running both
corpora: `total: 0` / no ids with the glued item alone, `total: 1` / the other
item's id once a split-shape item exists. Both outcomes are silent, so the point
stands — but which one occurs is a property of the corpus, not of the query, and
a swap has to be constructed rather than assumed.
- Corrected the `verify:generated` citation under "inventory paths block fix".
`scripts/verify-generated-artifacts.mjs` only (a) `git diff --exit-code`s
`bin/knowledge-mcp.js` and `dist`, and (b) greps four generated files for two
stale Windows-path patterns. It never compares a bundle against its source, so
its exit 0 says the *untouched* artifacts are untouched — measured: it exits 0
with `src/cli.ts` diverged from `bin/knowledge.js`, and exits 0 even with
`bin/knowledge.js` corrupted outright. To check bundle/source sync, rebuild the
bundle to a temp outfile with the `build` script's command and compare against
the committed file after per-line trailing-whitespace stripping (see
`scripts/strip-generated-trailing-whitespace.mjs`), and read the lockfile caveat
below first.

Two pre-existing problems were found while verifying this and are **not** fixed
here, because both need their own change and review:

- **`bun run verify:generated` is red on `main`**, independently of this entry.
The script rebuilds first, and the rebuild does not reproduce the committed
`bin/knowledge-mcp.js` or `dist/index.js`: `package.json` is inlined into the
bundles and gained a `files` entry in #33 that the committed mcp bundle predates,
and the zod codegen in `dist/index.js` has drifted. `bin/knowledge.js` is not
affected (it is `--minify`ed). Only `bun scripts/verify-generated-artifacts.mjs`
on its own exits 0.
- **There is no committed lockfile**, so `bun install --frozen-lockfile` exits 0
while pinning nothing. `@hasna/events` is bundled into `bin/knowledge.js` (it is
not in the `--external` list) and is declared `^0.1.3`, so the rebuild-and-compare
check below is dependency-state-dependent: 0.1.14 reproduces the committed bundle,
0.1.13 does not. Check `node_modules/@hasna/events/package.json` before concluding
a bundle is out of sync.

## 0.2.91

Harden the local JSON item-store against lock corruption and add a sanctioned
Expand Down Expand Up @@ -59,8 +126,10 @@ self_hosted/cloud (api) mode, where it disagreed with `knowledge paths`.
- test(knowledge): `tests/cloud-inventory.test.ts` now asserts `inventory.paths`
equals `service.paths()` for all four path fields, that the `/v1` URL never
appears in the paths block, and that it is reported on `legacy_store.path`.
- Rebuilt generated bundles so shipped artifacts carry the fix and
`verify:generated` passes.
- Rebuilt generated bundles so shipped artifacts carry the fix. `verify:generated`
passing is **not** evidence that a rebuilt bundle matches its source — see
"documentation corrections to the multi-tag work" for what that check does and
does not prove, and for the check that establishes bundle/source sync.

## Unreleased — Search overhaul, Stage 2 (Postgres full-text parity)

Expand Down
81 changes: 66 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ state, or keyword retrieval across active compatibility notes too.
| `-t, --tag <tag>` | Filter by tag; repeatable/comma-separated, an item must match **all** of them |
| `--sort created\|title` | Sort field (default: created) |
| `--desc` | Sort descending |
| `--archived` | Return archived items *only* |
| `--archived` | Return archived items *only*; **wins** over `--include-archived` if both are passed |
| `--include-archived` | Return live **and** archived items (default: live only) |
| `--verbose` | Print the full paginated item objects |
| `--json` | Print the stable machine-readable list response |
Expand All @@ -321,13 +321,20 @@ Each `-t` value matches an item when the item's stored tags contain the **whole
falling back to the split names *only* when no stored tag equals the whole value.

Both rules are right for their own command. `untag` writes, so it must take one shape per
run — that is exactly what makes its documented "re-run to clear the split names"
behaviour true. `list` only reads, so matching both shapes destroys nothing, while
matching only one would hide items from the very query used to find them. Items written
before the multi-tag parse fix can carry a single literal `"a,b,c"` tag, which none of the
split names equals, so a split-only filter does not merely miss the damaged item: it
answers with a *different* item that carries the three names separately, at `total: 1` and
exit 0, giving the operator no signal that the result changed. The union therefore finds
`-t` **value** — that is exactly what makes its documented "re-run to clear the split
names" behaviour true. The exclusivity is per raw `-t` value, **not** per run: repeating `-t`
on `untag` still accumulates, so `untag -t "a,b,c" -t a -t b -t c` removes four tags from an
item carrying both shapes in a single run. (Repeating `-t` on `list` narrows instead — see
the `-t` row above.) `list` only reads, so matching both shapes destroys
nothing, while matching only one would hide items from the very query used to find them.
Items written before the multi-tag parse fix can carry a single literal `"a,b,c"` tag, which
none of the split names equals, so a split-only filter never matches the damaged item — and
what it returns *instead* depends on the rest of the corpus, silently either way. With
nothing else matching it answers `total: 0` at exit 0, an empty result for an item that is
demonstrably there. If some **other** item carries the three names separately, it answers
with *that* item at `total: 1` and exit 0 — a different item from the one asked for, and
nothing in the output says the answer changed. Which of the two you get is a property of the
corpus, not of the query, so neither can be relied on as a signal. The union therefore finds
both shapes in one query:

```bash
Expand All @@ -343,6 +350,10 @@ tag that sits on an archived item. Add `--include-archived` to sweep live and ar
items together, or `--archived` for archived items only — use the former when auditing the
corpus for tag damage rather than reading live knowledge.

If **both** are passed, `--archived` wins and the result is archived items *only* — the
narrower flag, not the wider one, regardless of the order they appear in. So
`--archived --include-archived` is not a way to widen the sweep; drop `--archived` for that.

### inventory
```bash
knowledge inventory [--scope local|global|project] [--limit <n>] [--include-archived] [--verbose] [--json]
Expand Down Expand Up @@ -415,9 +426,25 @@ knowledge untag --id <id> -t "a,b,c"

When an item somehow carries both shapes, the whole-value match wins and the literal
tag is removed first; re-run to clear the split names. That exclusivity is the point: one
shape per run is what makes re-running meaningful, so `untag` deliberately does **not**
match both shapes at once. [`list -t`](#list) does — a union — because a filter has nothing
to remove and suppressing a shape would hide items. Do not "unify" the two rules.
shape per `-t` **value** is what makes re-running meaningful, so a *single* `-t` deliberately
does **not** match both shapes at once. [`list -t`](#list) does — a union — because a filter
has nothing to remove and suppressing a shape would hide items. Do not "unify" the two rules.

The exclusivity is scoped to one `-t` value, **not** to the whole run. Repeated `-t` still
accumulates, so on an item carrying both shapes:

```bash
# each line run against this same starting state, not as a sequence:
# stored tags ["a,b,c","a","b","c"]
knowledge untag --id <id> -t "a,b,c" # removed: 1 (whole value only)
knowledge untag --id <id> -t "a,b,c" -t a # removed: 2
knowledge untag --id <id> -t "a,b,c" -t a -t b -t c # removed: 4 (both shapes, one run)
```

Naming the split names explicitly is a caller's way of asking for both shapes at once. What
the `-t`-level exclusivity guarantees is narrower and is the part the re-run contract rests
on: one `-t "a,b,c"` clears the glued tag and leaves the split names, so re-running the same
command is meaningful (`removed: 1`, then `removed: 3`, then exit 1).

Removing nothing **exits 1**. `removed: 0` at exit 0 with a `Removed tag from <id>`
message is the same class of defect as the original bug — a success signal that
Expand All @@ -429,16 +456,40 @@ succeeds and the unmatched names are reported both in `message` (`Removed 1 tag
invisible to exactly the callers most likely to be reading it.

Tag names are quoted on **both** paths — the stored tags in the failure message, and the
unmatched names in the partial-miss success line. Unquoted, a single glued `"a,b,c"` tag
renders identically to three separate ones, so the message appears to deny a tag that is
plainly listed, and `(not found: p, q)` cannot be told apart from one missing tag literally
named `p, q`:
unmatched names in the partial-miss success line:

```
Error: No matching tag on k_x: "iapp" not in ["iapp,integrations,architecture"]
Removed 1 tag from k_y (not found: "p", "q")
```

On the **failure** path the quoting resolves a real collision: stored tags can contain
commas, so unquoted, a single glued `"a,b,c"` tag renders identically to three separate
ones and the message appears to deny a tag that is plainly listed.

On the **partial-miss** path the justification is different, and it is *not* the comma
case. A `not_found` entry can never contain a comma: a comma-bearing value only enters the
removal set through the whole-value branch, which requires the stored tags to already
contain it — so it is found by definition — and every other entry comes from splitting on
commas. One missing tag literally named `p, q` is therefore **unreachable**, and because
every entry is comma-free the raw `", "` join is *injective*: `["p","q"]` prints `p, q` and
`["p q"]` prints `p q`, which are different strings. A plain space is a **legibility**
problem, not an ambiguity.

The reachable case that does make quoting load-bearing is **whitespace the parser does not
strip**. `trim()` only removes the ends, so a tab or newline inside a name survives:

```
-t $'p\nq' -> not_found: ["p\nq"]
quoted: Removed 1 tag from k_x (not found: "p\nq") <- one line
unquoted: Removed 1 tag from k_x (not found: p
q) <- message split in two
```

Joined raw, that name breaks the single-line message and the tail reads as separate output.
`JSON.stringify` escapes it. Quoting also keeps the two messages symmetric, so a reader does
not have to work out which of the two lines happens to be collision-proof.

### delete
```bash
knowledge delete|rm --id <id> --yes
Expand Down
3 changes: 2 additions & 1 deletion bin/knowledge.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading