Files
improvise/scripts/kb
T
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

163 lines
5.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# The knowledge base's one maintenance tool. Replaces the Prolog MCP cycle:
# adding a clause costs the clause, not the whole predicate.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/.." && pwd)"
KB="${KB_FILE:-$repo/prolog-sessions/kb.pl}"
MERGE="$here/kb-merge.pl"
# trealla comes from the dev shell, which direnv puts on PATH. Reaching it
# through `nix shell` per invocation cost more than the work it did: this
# tool's first real use timed out past two minutes on a half-second write.
if ! command -v tpl >/dev/null 2>&1; then
echo "kb: tpl is not on PATH -- run inside the dev shell (direnv allow, or nix develop)" >&2
exit 127
fi
# stdin is closed on every call: an uncaught Prolog error falls back to the
# REPL, which hangs on a terminal and exits 0 without one -- a refusal that
# reads as acceptance. kb-merge.pl catches parse errors; this is the backstop.
tpl() { command tpl "$@" </dev/null; }
# The goal that runs kb-merge.pl. `-g main,halt` is NOT enough: measured on
# trealla 3.10.3 it exits 0 on a throw and on a failure alike, so a crash reads
# as success to `if ! tpl ...`. This propagates both.
MERGE_GOAL="(catch(main,E,(write(user_error,E),nl(user_error),halt(2))) -> halt(0) ; halt(1))"
merge() { tpl -q "$MERGE" -g "$MERGE_GOAL" -- "$@"; }
# kb_consistent is the gate CI runs. Anything that writes runs it on the
# CANDIDATE, before the candidate becomes the knowledge base.
# The goal reaches a halt on EVERY path, for the same reason MERGE_GOAL does: a
# goal that throws never reaches the halt beside it and tpl exits 0. Without the
# catch, a write that REMOVED kb_consistent made the gate pass by not existing.
CHECK_GOAL="(catch(kb_consistent,E,(write(user_error,E),nl(user_error),halt(2))) -> halt(0) ; (kb_report, halt(1)))"
check_file() {
tpl "$1" -g "$CHECK_GOAL" 2>/dev/null
}
# A content stamp of the knowledge base as it was READ. Two agents running this
# concurrently would otherwise both read the same file, both validate, and the
# later mv would discard the earlier addition -- the lost-update class this tool
# exists to avoid, and waves here run agents in parallel.
stamp_of() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1
elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1
else cksum "$1" | tr -d ' '
fi
}
# mkdir is atomic everywhere; flock is absent on macOS. The lock makes a
# collision rare, the stamp makes one that happens anyway LOUD rather than silent.
acquire_lock() {
local tries=0
until mkdir "$KB.lock" 2>/dev/null; do
tries=$((tries + 1))
if [ "$tries" -ge 300 ]; then
echo "kb: could not acquire $KB.lock after 30s; remove it if stale" >&2
exit 1
fi
sleep 0.1
done
trap 'rmdir "$KB.lock" 2>/dev/null || true' EXIT
}
# Test seam: simulate another writer landing between the read and the commit.
race_hook() {
[ -n "${KB_TEST_RACE:-}" ] || return 0
cp -f "$KB_TEST_RACE" "$KB"
}
usage() {
cat >&2 <<'EOF'
usage: kb <command>
check run kb_consistent (what CI runs)
count clauses per keyed predicate
query '<goal>' run a goal against the knowledge base
fmt rewrite in canonical form (idempotent)
add <delta.pl> add clauses; refuses if any key already exists
replace <delta.pl> add clauses, overwriting existing keys
rm <keys.pl> remove clauses by key; refuses if any key is absent
Every writing command verifies the result reads back as the clauses intended
and passes kb_consistent before it replaces the knowledge base.
EOF
exit 2
}
commit() { # $1 = candidate file, $2 = stamp of the kb before it was read
local cand="$1" was="${2:-}"
race_hook
if [ -n "$was" ] && [ "$(stamp_of "$KB")" != "$was" ]; then
rm -f "$cand"
echo "kb: REFUSED -- $KB changed while this write was being prepared." >&2
echo "kb: another writer won; re-run and your delta applies on top." >&2
exit 1
fi
if ! check_file "$cand"; then
rm -f "$cand"
echo "kb: REFUSED -- kb_consistent fails on the result; $KB is unchanged" >&2
exit 1
fi
mv -f "$cand" "$KB"
}
[ $# -ge 1 ] || usage
cmd="$1"; shift
case "$cmd" in
check)
if check_file "$KB"; then echo "kb_consistent: TRUE"; else exit 1; fi
;;
count)
merge count "$KB" 2>/dev/null
;;
query)
[ $# -eq 1 ] || usage
tpl "$KB" -g "($1 -> halt(0) ; (write('false'), nl, halt(1)))" 2>/dev/null
;;
fmt)
acquire_lock
was="$(stamp_of "$KB")"
cand="$(mktemp)"
: > "${cand}.delta"
if ! merge add "$KB" "${cand}.delta" "$cand" >/dev/null 2>&1; then
rm -f "$cand" "${cand}.delta"
echo "kb: REFUSED -- $KB is unchanged" >&2
exit 1
fi
rm -f "${cand}.delta"
commit "$cand" "$was"
echo "kb: canonicalized"
;;
rm)
[ $# -eq 1 ] || usage
acquire_lock
was="$(stamp_of "$KB")"
cand="$(mktemp)"
if ! merge remove "$KB" "$1" "$cand"; then
rm -f "$cand"
echo "kb: REFUSED -- $KB is unchanged" >&2
exit 1
fi
commit "$cand" "$was"
echo "kb: $KB updated"
;;
add|replace)
[ $# -eq 1 ] || usage
acquire_lock
was="$(stamp_of "$KB")"
cand="$(mktemp)"
if ! merge "$cmd" "$KB" "$1" "$cand"; then
rm -f "$cand"
echo "kb: REFUSED -- $KB is unchanged" >&2
exit 1
fi
commit "$cand" "$was"
echo "kb: $KB updated"
;;
*) usage ;;
esac