Commit Graph

13 Commits

Author SHA1 Message Date
Edward Langley 4ca3a47b41 kb: refusal no longer offers committing the loss; skill and kb-tool.md describe the guard (improvise-za97)
The kb skill (both copies) now describes the HEAD comparison, the ledger,
exit code 3 and the recovery. kb-tool.md records the ih3k/yea8 arguments and
corrects the residue paragraph: .gitignore hides the leftovers, and there are
six, not one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:17:17 -07:00
Edward Langley 5a79f7385b kb-test: exercise the git-path ledger and HEAD baseline in a throwaway repo
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:10:13 -07:00
Edward Langley d90c45c14e kb: a ledger excuses only the kb file its write published (improvise-yea8)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:09:17 -07:00
Edward Langley b3a41bca57 kb: count keys, so one of a pair sharing a key cannot vanish (improvise-ih3k)
WIP: cases 23-24 added; 24 (improvise-yea8) still fails.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:06:34 -07:00
Edward Langley a55b3c7147 kb: refuse a write that leaves the kb short of HEAD; publish by rename
scripts/kb add wrote onto a working-tree kb.pl that had lost 276 of its 801
clauses and reported success, because kb_consistent checks consistency, not
completeness (improvise-usus). Every write now runs kb-merge.pl `complete`
on the candidate before kb_consistent. It refuses unless every clause HEAD
has is still present by key, or its removal was recorded by `kb rm` in a
per-worktree ledger in the git dir, which prunes itself on every write.

The candidate is now built beside kb.pl, so publishing it is a rename(2)
rather than a possible cross-device copy that a kill could truncate.

Exit codes: 0 done, 1 refused, 2 usage, 3 HEAD's kb.pl unreadable. kb-test.sh
now builds its fixtures from HEAD and adds cases 18-22. Argument in
context/code/kb-tool.md, evidence and rivals in context/test-evidence/tooling.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 20:46:03 -07:00
Edward Langley 7945221eed fix(kb): two comments claimed things the code does not do (improvise-9d5k)
kb-test case 10 said it was where same_key is discriminated. Reverting same_key
to unification leaves case 10 PASSING and fails the wildcard rm and case 17
instead -- a rule keys on its whole clause, so the `:-` term cannot unify with
probe_fact(a) and conflicts never fires there. Re-demonstrated by injection
before rewriting the comment. The site is guarded; the explanation was wrong.

replace's refusal said it would add the rules "beside the ones already there",
which is false when the predicate has none. The behaviour was always right and
fail-safe; only that clause of the sentence was wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:09:10 -07:00
Edward Langley cd094b6dfb fix(kb): the dj1l cases discriminate, and a replaced rule cannot silently become an add
Five findings from the second review round, and the first is this PR's own
standard applied to its own P1.

**The two fixes improvise-dj1l names had no discriminating test.** Reverting
`has_key` to unification, or dropping `canon_key` from the duplicate gate, left
kb-test at 27 passed. Case 11 was carried by `check_total` refusing with a
DIFFERENT message, and case 10 by the other half of the fix. Both now assert
which refusal fired (`said()`), plus two cases aimed at each half: a rm key with
a variable in a non-final position must be refused as ABSENT rather than
matching everything, and a keyed FACT whose key holds a variable must ADD
without excluding the siblings it would unify with. Each proved by reverting
exactly one fix and watching exactly that case fail.

**Keying a rule on its whole clause made `replace` on an edited rule silently
become `add`** -- both arms live, the stale one still firing, and `check_total`
cannot see it because `NO + NF` is correct arithmetic for an add. Reachable on
the 15 `kb_error/1` arms and the `kb_text/2` rules, so editing the check
improvise-2f97 adds is the first thing to hit it. `check_rule_replace` refuses
and names the route (rm, then add); the negative arm -- replace of a rule
present verbatim -- is tested, or the refusal would just be "replace never
works on rules".

**The `decision(` arm was dead code.** `cited_name` took the first field, and
the repo spells `decision(<ticket>, <name>)`, whose ticket id fails
`name_shaped/1` on the hyphen -- so that marker could never fire for a real
citation. A second `cited_name` clause takes the second field. All three
markers are now exercised.

Minor, from the same round: `_JS_TEST` no longer accepts `test.only`, which
disables every other test in its file, with a case asserting a heading cannot
be satisfied by one; and `node-test-test.sh`'s last case no longer duplicates
an earlier one.

Filed rather than fixed: improvise-w8ih, extracting `web/assemble.sh`'s gate so
its own absence is visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 17:01:33 -07:00
Edward Langley 50de0a8fd2 fix(kb): keys are compared as variants, and a cited clause cannot be silently retired (improvise-dj1l, improvise-2f97)
dj1l (P1, found by review of PR #68 after it merged). kb-merge.pl matched
clause keys by UNIFICATION, which both lost and corrupted clauses.
check_self_consistent compared keys with ==, but findall hands back fresh
variables, so two clauses with variant heads were never == and passed the
duplicate gate; foldl_merge then matched by unification and the second removed
the first. Reproduced: 536 before, 2 in delta, 537 after. Worse, adding
`probe_fact(X) :- doctrine(X,_).` over a kb holding probe_fact(a) and
probe_fact(b) deleted probe_fact(a) AND wrote `probe_fact(a):-doctrine(a,A)` --
the match bound X, so the clause written was not the clause supplied. The
round-trip verify cannot see that: it compares the file against an intended
list already corrupted by the same unification.

same_key/2 (copy_term + numbervars) in has_key, conflicts and absent;
canonicalize before duplicate detection; a RULE keys on the whole clause, since
one head shape with different bodies is ordinary Prolog; and check_total/2
asserts the arithmetic the tool already PRINTED beside the delta size -- "2 in
delta, 537 after" was visible in its own output twice and nothing compared them.

2f97. kb_error(dangling_citation/3): a name cited inside another clause's text
must resolve as a doctrine/2, invariant/2 or decision/3, so retiring a clause
can no longer dangle every clause naming it. The token rule is narrow on
purpose -- a name-shaped token directly inside doctrine(, invariant( or
decision( -- so prose cannot false-positive. kb_error/1 is a gate predicate,
so this was written with KB_ALLOW_GATE=1 deliberately: the refusal exists so a
write cannot disarm its own gate, and an intentional change to the gate is
supposed to be a greppable act rather than an impossible one.

Seven cases added to kb-test.sh, each proved by injection: two variant-headed
rules both land; a non-ground rule deletes nothing and is written as supplied;
a duplicate key is still refused; an injected dropping fold is caught by the
total; a cited clause cannot be retired; and the citation arm fires on a name
that resolves to nothing without firing on one that resolves. scripts/kb gains
a KB_MERGE seam so a broken merge can be injected. One pre-existing case moved
off doctrine(bug_first), which the new arm correctly refuses to retire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:31:02 -07:00
Edward Langley fc6d229dda kb: design against the model before implementing, and fix the key rule it exposed
AGENTS.md has always said Prolog is a design tool for reasoning about tickets;
it was the least-used line in the file, because kb.pl was only ever written
retrospectively. `kb check --with <design.pl>` merges proposed facts into a
throwaway candidate and runs kb_consistent over the union, so a design can be
refused before any code exists. Nothing is written either way.

The model already derives rather than records -- illegal_dependency, may_use,
depends_transitively, portable, snapshot_safe, view_state_admits,
record_obligation, and eleven kb_error classes. Three proposals exercised it in
one sitting: one added dependency edge produced SIX layering violations through
the transitive closure; a proposal to declare decision/3 functional was refused,
correctly, because improvise-907 carries two decisions; and a mis-ordered
layer/2 argument was named rather than accepted.

Trying to model a design found a real defect in the tool. Keys were the functor
plus all arguments but the last, which is right for doctrine(Name,Text) and
wrong for any RELATION: crate_depends_on(A,B) keyed on A, so a crate depending
on two crates read as one key twice and was unaddable. It cannot be read off the
KB's functional/1 -- that keys an arity-3 predicate on its first argument, right
for crate/3 and wrong for decision/3 -- so key_arity/2 is a table, and anything
unlisted keys on the whole head, which is the safe direction.

kb-test.sh is 20 cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:16:31 -07:00
Edward Langley eaaf9d935a kb: a write cannot disarm its own gate, and cannot silently lose another's
Second review pass on PR #68. All three findings real; the first two reproduced
by injection before fixing.

The candidate is validated by CONSULTING it, so the thing being checked got to
influence the check. Three ways through, all demonstrated:

- `:- halt(0).` as a delta -- fixed last round by refusing directives
- REMOVING kb_consistent: it then throws existence_error, and tpl exits 0 when a
  goal throws before reaching halt, so the gate passed by not existing. The
  error was printed and the tool still reported success.
- REPLACING it with the fact `kb_consistent.`, which passes forever after.

CHECK_GOAL now catches, so a throw exits non-zero, and writes touching the gate
predicates are refused unless KB_ALLOW_GATE makes it deliberate. That list is
hand-maintained and a new gate predicate is unprotected until named -- stated
rather than hidden.

Writes were read/validate/mv with nothing in between, so two agents -- ordinary
here, waves run in parallel -- would both read the same file, both validate, and
the later mv would discard the earlier addition. The lost-update class this tool
exists to remove, reintroduced in the tool. Closed by stamping content at read
time and refusing to commit if it moved, plus an mkdir lock. The stamp is the
part that matters; the lock only makes the collision rare.

AGENTS.md's top-level instruction still said to save facts through the MCP. The
wave section had been fixed; the authoritative line had not.

kb-test.sh is 17 cases. The race is deterministic via KB_TEST_RACE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:11:24 -07:00
Edward Langley 661ca76e6d kb: close the directive bypass, and package both tools as skills
Review on PR #68 found two real holes, both reproduced by injection before
fixing and both now regression-tested:

- A DIRECTIVE is callable, so the callable check let one through -- and the
  candidate is validated by CONSULTING it, so a delta of `:- halt(0).` exited
  the check with success before kb_consistent ran. The gate passed by never
  running, and the directive landed in kb.pl. Directives and queries are now
  refused by shape.
- Keys were checked only against the EXISTING file, so two clauses sharing a
  previously-unused key clashed with nothing and the merge dropped the first --
  add discarding part of its own payload. Visible in the tool's own output ("2
  in delta" against one clause added) and nothing compared those numbers.

Also from the review: nothing ran kb-test.sh. A suite that gates the only writer
of kb.pl but is invoked by no workflow is doctrine(an_advisory_gate_is_not_a_gate)
in the PR that adds it. Both suites now run in the kb job.

AGENTS.md now leads with the rule instead of describing the retired procedure
first -- the review read against the earlier head, but it was half right at the
later one too: a skimming reader followed the batching paragraph.

Both tools are packaged as skills, because the failure is not that the scripts
are missing, it is that agents do not know they exist and write their own. Three
hand-rolled PR watchers in one session all reported green while wrong, which is
what scripts/pr-watch and its eight fixture cases exist to stop. One of those
cases -- a head that moves mid-wait -- was untestable until a subshell bug in the
fixture seam was fixed: fetch ran inside "$(...)", so its counter never advanced
and every poll re-read the first fixture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:02:57 -07:00
Edward Langley 805bae7a15 kb: a clause can be retired, and the docs stop naming the MCP
Three gaps in the tool as first written, each found by asking what it could not
do rather than by running it:

- There was no way to REMOVE a clause. Every correction could add or overwrite,
  so a fact that went false could only be rewritten, never dropped. `kb rm`
  takes keys and refuses when any is absent -- a typo'd key would otherwise
  report success and leave the clause in place.
- `doctrine(kb_is_maintained_through_the_mcp)` is retired for
  kb_is_maintained_by_scripts_kb. The NAME was the claim that went false, which
  is the repair opfs_is_run_but_bindings_is_not needed for the same reason. The
  replacement records why nobody should go back, and that READING through the
  MCP is unaffected.
- AGENTS.md said kb.pl was "MCP-maintained, whole-predicate replacement" and
  told agents to strip host_rpc shims after each save. Both described the
  retired mechanism. This is a_fact_stated_twice_is_changed_by_grep landing
  inside the PR that depends on that doctrine being true.

Also fixes a printed value that was an unevaluated expression: `rm` reported
"530-529 removed" rather than 1.

kb-test.sh is 11 cases now, both new ones proved by injection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 15:58:34 -07:00
Edward Langley 3405fc4def kb: retire the Prolog MCP for a tool that costs the clause, not the predicate
loadProgram REPLACES a whole predicate, so adding one clause meant re-sending
every existing clause of it through an agent's context window. Measured on the
wave-3 batch: ~113KB read and ~113KB written to deliver ~25KB of new text. That
sets the behaviour -- writing becomes a thing you batch and do rarely -- and the
batch is where a short payload silently drops the rest of a predicate, which has
cost this repo 22 decisions and 19 doctrines once already.

scripts/kb reads clauses as DATA and never consults them, so the cost of adding
a clause is the clause. Canonical form is read_term in, portray_clause out --
measured, not designed: all 495 existing facts round-trip byte-identically and
the form is a fixed point in one pass. Rules move to one line, which is why the
reformat lands here rather than earlier: the MCP would re-expand them on its
next save and the two would thrash.

trealla moves into the devShell. Reaching it through `nix shell` per invocation
took the tool's first real use past a two-minute timeout for a write that takes
0.42s with tpl on PATH.

Four guards, each added because injecting its fault showed the tool accepting
what it should refuse. None was found by reading:

- an unparseable delta dropped trealla into its REPL, which HANGS with a
  terminal and EXITS 0 without one -- a refusal that reads as acceptance
- `-g main,halt` exits 0 on a throw AND on a failure, because a goal that fails
  never reaches the halt beside it; halt(2) alone exits 2, so the default is the
  hazard. The goal now guarantees a halt on every path (0/2/1)
- `42.` parses, is not callable, and landed as a fact whose head is a number;
  kb_consistent has no opinion about stray terms
- fmt discarded the merge's exit code and would have committed a failed
  canonicalization

Plus the two that were designed in: the result is read back and required to be
the clauses intended (variant equality -- == compares variable identity and
reports every rule as different), and kb_consistent runs on the CANDIDATE, so a
bad delta leaves the knowledge base untouched rather than needing a revert.

scripts/kb-test.sh proves all nine by injection. context/code/kb-tool.md carries
the argument, including what it does not reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 15:54:39 -07:00