Foundation for the project-overlay trust mechanism. No callers yet —
commit #2 wires main.lua to use these.
Three new functions:
history._sha256_file(path) -> hex digest or nil
Shells `sha256sum`; parses first whitespace-separated field;
validates 64-hex-char length. nil on any failure (path missing,
binary missing, file unreadable). Caller treats nil as "skip
the trust path" — never crashes.
history.is_trusted(trust_path, project_path, sha256) -> bool
Reads trust_path as JSONL; returns true iff an entry exists
matching BOTH project_path AND sha256. Missing / corrupt /
unreadable trust file -> false (re-prompt). Per-line JSON
decode means partial-write corruption affects at most one line.
history.add_trusted(trust_path, project_path, sha256) -> bool
mkdir -p parent; append JSONL line {path, sha256, ts (ISO)};
chmod 600 the trust file (best-effort; ignore failure). Single
writer per call; append-only.
11 unit cases verified:
- sha256 known value matches manual `sha256sum`
- nil / missing-file -> nil (no crash)
- is_trusted on missing trust file -> false
- add_trusted + is_trusted roundtrip works
- Different sha -> not trusted (content-binding)
- Different path -> not trusted
- Multi-entry trust file: each entry independently checked
- chmod 600 verified via stat
Regression: test_safety 87/87, test_router_model 31/31.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 commit #1 per docs/PHASE4.md §12. Two file changes bundled
because R-B1 (flock for race-free single-writer enforcement) cannot
be deferred — adding it retroactively means reopening the memory
handle.
ffi/libc.lua extensions:
- cdef flock(int fd, int op), open(...), lseek(int, long, int)
- constants LOCK_EX=2, LOCK_NB=4, LOCK_UN=8
- M.flock(fd, op) wrapper returning (true) on success or
(false, errmsg) — errmsg is the strerror text so callers can
surface "Resource temporarily unavailable" cleanly to the user.
history.lua additions (Phase 4 section appended at end):
- M.open_memory(path) -> handle | nil, err
Opens the file via libc.open(2) (need integer fd for flock —
io.open's FILE* doesn't expose it), takes flock(LOCK_EX | LOCK_NB).
Returns "memory.jsonl held by another aish process" on lock-held.
Scans existing content for max id; caches as handle.next_id.
Writes meta header on first creation (no id, ignored at load).
- handle:add(kind, content, tags?, source?) -> id
Assigns next id; appends one JSONL item with auto-timestamp.
kind ∈ {fact, pref, context} enforced via assert.
- handle:forget(target_id)
Appends a tombstone {id, ts, kind:"forget", target}.
- handle:close()
Releases fd (flock auto-released on close).
- M.load_memory(path) -> items_table
Reads all lines, builds forget-target set from kind=="forget"
entries, returns active items as an array sorted by ts desc.
Items without id (meta header) silently dropped. Tombstones with
non-matching targets are no-ops (N3 invariant).
Round-trip test passes:
- open empty file → next_id=1
- add 3 items → ids 1, 2, 3
- forget id 2 (appends tombstone)
- reopen → next_id correctly advances past the tombstone (=5)
- load_memory → 2 active items (id 1 + id 3); tombstone resolved
- lock-held detection: second open while first held → fails with
"memory.jsonl held by another aish process" message
- close releases the lock; reopen after release succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CONCERNs from the Phase 1 review pass:
ffi/curl.lua:
- SSE write_cb body is now pcall-wrapped. A Lua error in on_event (or
in the parse loop itself) is captured into cb_error and surfaced
after curl_easy_perform rather than propagating across the FFI
callback boundary (which LuaJIT documents as process-fatal). The
EOS flush path gets the same shield. Errors return
(nil, "callback: <msg>") from post_sse.
history.lua:
- sh_singlequote() escapes shell metacharacters; the mkdir -p and
ls -1 shell-outs no longer double-quote (where $(...) and $VAR
still expand) — single-quote with embedded-' escaping is the
safe form.
- M.load now returns (turns, meta) instead of (meta, turns). turns
is ALWAYS a table on success, never nil-when-no-header; failure
path is the unambiguous (nil, err). Callers can `if not turns
then` without the previous ambiguity. repl.lua :resume updated
to the new shape.
repl.lua :resume:
- Refuse to resume into a non-empty ctx — silent overwrite was the
Q15 default, but the review surfaced the no-undo / no-warning
failure mode. User must :reset (or :save then re-launch) to
express intent. The current session's on-disk log is unaffected
either way.
NITs:
- ffi/libc.lua READ_BUF: comment noting it's module-shared and
Phase 1 has no reentrant readers; revisit when that changes.
- PHASE1.md §7: \C-x\C-c reservation pinned to Phase 3 ("deferred
from Phase 1 — no consumer here") rather than the previous
dangling "(or here)".
Regression suite verifies:
- history.load new signature on success + failure paths
- shell-quoted history.dir with $ doesn't trip
- aish scripted run: ctx with 2 turns refuses :resume anchor with
a clear status; user must :reset first
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 persistence per PHASE1.md §6.
history.open(path, meta?) -> session | (nil, err)
parent dir auto-created; meta line written iff file is new/empty so
reopening a session doesn't duplicate the header
session:append(turn)
JSON-encoded line, fh:flush after every write (no fsync — Q16
tracks the policy if it ever bites)
session:close()
history.load(path) -> meta, turns | (nil, err)
skips unparseable lines (e.g. partial trailing write from a crash);
distinguishes the meta-header line from role/content turn lines
history.list_sessions(dir) -> [basename, ...]
sorted (ISO 8601 names lex-sort chronologically); no mtime / turn
counts in Phase 1 — that's a Phase 4 :sessions UI concern
Smoke:
- open, append 3 turns, close, list_sessions sees 1 file
- load returns meta (model="fast") and 3 turns in order
- corrupt tail (partial JSON line appended) is silently skipped on load
- reopen with different meta does NOT duplicate the header line
Repl wiring (`:save`, `:resume`, `:sessions`, auto-write on quit) lands
in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- README, .gitignore, CLAUDE.md (project conventions)
- docs/PHASE0.md — full Phase 0 manifest (locked substrate)
- 10 root .lua modules + 4 ffi/ bindings, all stubs raising NotImplemented
with module-scoped responsibilities matching the manifest
- config.lua wired to current dirac/hossenfelder endpoints (qwen-coder-7b
snappy/32k + cloud via OpenRouter through hossenfelder)
File names match docs/PHASE0.md §4 exactly. Module bodies fill in across
later phases; the tree shape is locked.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>