From 1d1c6d2cd5450f0854fccf891a71446bce233217 Mon Sep 17 00:00:00 2001 From: Michael Gilardi <11632652+mgilardi@users.noreply.github.com> Date: Mon, 9 Nov 2026 02:58:11 -0700 Subject: [PATCH] Add the intake reader and close test gaps found in the first audit Other tools can now hand Theseus items through data/intake/.jsonl, and only the ones the user accepts join the list (decided 2026-11-08). The reader isn't wired into the workflows yet; that RULES.md change waits for the owner's yes. The first test audit (docs/test-audit.md) found three always-covered promises without tests: the install guard that protects local engine changes, read-only access to personal databases, and the new intake. Added 14 tests for them. All 134 engine tests and ci-checks.sh pass locally. Not tested: a real console file or a sync that uses it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CwdHcRvts6qgU3EZBfNtvY --- docs/DECISIONS.md | 1 + docs/test-audit.md | 35 +++++++++ engine/tools/intake.py | 153 ++++++++++++++++++++++++++++++++++++ tests/test_engine.py | 172 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 361 insertions(+) create mode 100644 docs/test-audit.md create mode 100644 engine/tools/intake.py diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 3683335..a7a1c05 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -2,6 +2,7 @@ Choices the owner has made, newest first, with the date each was made. Later work follows them without asking again. Bigger design choices have their own notes in `docs/adr`, and plans live in `docs/ROADMAP.md`. +- **2026-11-08 · Intake from other tools needs the user's yes.** The owner's words: "Build it; yes before joining". Another tool (first, a console app with action items from meeting notes) appends JSON Lines to `data/intake/.jsonl` and never edits the list or the Notes. `engine/tools/intake.py` reads it; new items are offered to the user and only accepted ones join the master list. Not the Notes inbox, because that is the user's own words. Wiring it into the workflows (RULES.md) waits for the owner's yes in a session. - **2026-11-07 · Copyright.** `LICENSE` reads "Copyright (c) 2026 EXTANT APE". In prose the org is written EXTANT APE; GitHub URLs keep `ExtantApe`. The leak check allows that exact line through `scripts/leak-allow.txt`. - **2026-11-07 · Name every way data leaves the Mac.** The manifesto lists each one: Claude Code, connectors you turn on, Apple's own sync, and a wall display if you connect one. Anything new that sends data off the Mac is named there first. - **2026-11-07 · iPhone plans** (not started; details in `docs/ROADMAP.md`). The encrypted backup uses a recovery key you keep, and Theseus suggests saving it in Apple's Passwords. First run suggests iCloud backup, and staying offline is a choice. The backup covers `config.json`, `profile.md` and `data/`. The lockdown button hides and locks, replaces what the Today and Master List notes show, and warns when FileVault is off. diff --git a/docs/test-audit.md b/docs/test-audit.md new file mode 100644 index 0000000..a13bc70 --- /dev/null +++ b/docs/test-audit.md @@ -0,0 +1,35 @@ +# Test audit + +How the tests line up with what Theseus promises. Redo it after every feature or fix, and at least every 14 days while the repo has commits. + +## 2026-11-09 · first audit + +What exists: `tests/test_engine.py` (134 tests, stdlib, run by `scripts/ci-checks.sh`), `apps/desktop/src/*.test.ts` (22, `node --test`), 2 Rust tests in the desktop app, the desktop and wall end-to-end runs in CI (`apps/desktop/e2e/ui.mjs`, `apps/wall/e2e.mjs`), and `apps/wall/sim/test_connection.py`. CI also runs the leak check, syntax checks for every script and tool, `swiftc -typecheck` on `ek.swift`, and the shared-file check. + +### Always covered (security, data loss, privacy, fail-closed checks) + +| Promise | Proved by | +| --- | --- | +| Nothing is sent without the user; connectors can read but not send, delete or change things in unattended runs | `Guard` (send_guard: sends and deletes refused, one bad action refuses the batch, empty batch blocked, fails closed, works from any folder, settings run the guard on every connector, unattended runs can sync but not send) | +| The iMessage channel answers only the user's own self-chat | `test_the_imessage_channel_can_answer_you`, `…_cannot_reach_anyone_else`, `…_needs_your_handles` | +| Personal databases (Messages, Mail, Things) are only read, through a copy | **new** `PersonalDataReadOnly`: every live database is opened `mode=ro`; no reader runs SQL that writes or can send; a snapshot leaves the source byte for byte | +| No copies of personal data stay behind | `test_a_run_leaves_no_copies_of_personal_databases_behind`, `test_a_run_clears_its_hidden_temp_exports` | +| Secrets are off limits; what runs read is data, not instructions | `test_secrets_are_off_limits_everywhere`, `test_runs_are_told_what_they_read_is_data`, `test_both_hosts_keep_runs_off_the_web_and_out_of_settings` | +| The wall display sends only what the user chose, and the Pi only accepts it | `WallDisplay` and `WallReceive` (only the list is read, notes and ids stay behind, Done and Communications never appear, titles off sends no titles, a bad list sends nothing, the key can do nothing but deliver or clear, host identity changes are never accepted) | +| The master list is never lost or half written | app-edits batches (`test_a_change_survives_a_run_that_never_finished`, `test_a_batch_from_a_dead_run_comes_back`), `test_state_is_never_half_written`, desktop `master.test.ts` round trips, undo refusing when the list changed | +| An install never overwrites the user's local engine changes | **new** `LocalChangesGuard`: a patch the source lacks stops the install, a missing patch file stops it, an applied patch lets it through, entries without a patch don't block, the session note says the file is data | +| Items other tools hand Theseus join the list only with the user's yes | **new** `Intake`: only open items for the user are offered, a non-date due is never used, nothing joins without a decision and each is decided once, a later done line reaches its task once, the tool's file is never written | + +### Gaps added in this audit +- `engine/tools/local_changes.py` had no tests, though `guard` is the check that keeps an install from overwriting local changes. Added `LocalChangesGuard` (5 tests). +- Read-only access to personal databases was true in the code but unproven. Added `PersonalDataReadOnly` (3 tests). +- `engine/tools/intake.py` is new (the Console intake, decided 2026-11-08). Added with `Intake` (6 tests). It isn't wired into the workflows yet. + +### Gaps skipped, and why +- `alarms.py` (reads a file a Shortcut writes; no writes, nothing sent) and `things_export.py` (one-time migration). Their read-only side is covered by `PersonalDataReadOnly`; parsing details aren't worth a test until they change. +- `ek.swift` beyond the type check: it needs real Reminders and Calendar access, so it can't run in CI. Its writes go through the send guard's rules and the workflows' logging. +- `mail.py` and `imsg.py` output formatting: covered where it matters (search windows, contacts fallback, seen marks); the rest is presentation. +- Framework internals of the desktop app (Tauri, Svelte) and trivial getters. + +### Next audit +By 2026-11-23, or with the next feature or fix, whichever comes first. Check then: the intake wiring once the workflows use it. diff --git a/engine/tools/intake.py b/engine/tools/intake.py new file mode 100644 index 0000000..ad3d459 --- /dev/null +++ b/engine/tools/intake.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +""" +intake.py: items other tools hand Theseus (data/intake/.jsonl). Nothing joins the master list without the user's yes. + +Each tool only appends, one JSON object per line: + {"id": "console-…", "created": "", "title": "…", "owner_is_me": true, + "meeting": {"title": "…", "date": "YYYY-MM-DD"}, + "context": "…", "due": "YYYY-MM-DD", "category": "#…", "link": "…"} (last four optional) +A later line with the same id and "status": "done" or "cancelled" updates the item. + + pending new items waiting for the user's yes, numbered, plus lines that were skipped and why + accept ID --task tNNNN the user said yes and the item is now that master task + decline ID the user said no + updates accepted items whose tool later marked them done or cancelled, not yet carried to the list + applied ID that update has been carried to the list + +Decisions live in data/intake-state.json. This tool never writes to the tools' files. +""" +import datetime as dt +import json +import os +import re +import sys +from pathlib import Path + +WS = Path(os.environ.get("THESEUS_HOME") or Path(__file__).resolve().parents[2]) +INTAKE = WS / "data/intake" +STATE = WS / "data/intake-state.json" +REQUIRED = ("id", "created", "title", "owner_is_me", "meeting") +DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def load_state(): + try: + return json.loads(STATE.read_text()) + except (OSError, ValueError): + return {"items": {}} + + +def save_state(state): + tmp = STATE.with_suffix(".tmp") + tmp.write_text(json.dumps(state, indent=1, ensure_ascii=False) + "\n") + os.replace(tmp, STATE) + + +def read_items(): + """Fold every tool's lines by id: the first full line is the item, later status lines update it.""" + items, skipped = {}, [] + for path in sorted(INTAKE.glob("*.jsonl")) if INTAKE.exists() else []: + source = path.stem + for n, line in enumerate(path.read_text().splitlines(), 1): + if not line.strip(): + continue + where = f"{path.name}:{n}" + try: + obj = json.loads(line) + except ValueError: + skipped.append({"line": where, "why": "not JSON"}) + continue + if not isinstance(obj, dict) or not isinstance(obj.get("id"), str) or not obj["id"]: + skipped.append({"line": where, "why": "no id"}) + continue + key = f"{source}:{obj['id']}" + status = obj.get("status") + if status in ("done", "cancelled"): + if key in items: + items[key]["status"] = status + items[key]["status_line"] = where + else: + skipped.append({"line": where, "why": f"{status} for an id not seen yet"}) + continue + if status is not None: + skipped.append({"line": where, "why": f"unknown status {status!r}"}) + continue + if key in items: + continue # same id again: the first one stands + missing = [f for f in REQUIRED if f not in obj or obj[f] in (None, "")] + if missing: + skipped.append({"line": where, "why": "missing " + ", ".join(missing)}) + continue + if obj["owner_is_me"] is not True: + continue # someone else's action item + if obj.get("due") and not DATE.match(str(obj["due"])): + obj = {**obj, "due_raw": obj["due"], "due": None} + items[key] = {"key": key, "source": source, "line": where, "status": "open", + **{k: obj.get(k) for k in ("id", "created", "title", "context", "due", "due_raw", + "category", "meeting", "link")}} + return items, skipped + + +def pending(): + items, skipped = read_items() + decided = load_state()["items"] + new = [i for k, i in items.items() if k not in decided and i["status"] == "open"] + new.sort(key=lambda i: str(i["created"])) + for n, i in enumerate(new, 1): + i["n"] = n + print(json.dumps({"pending": new, "skipped": skipped}, indent=1, ensure_ascii=False)) + + +def decide(key, decision, task=None): + items, _ = read_items() + if key not in items: + sys.exit(f"no intake item {key!r} (use the key from `pending`, e.g. console:console-123)") + state = load_state() + if key in state["items"]: + sys.exit(f"{key} was already {state['items'][key]['decision']}") + entry = {"decision": decision, "at": dt.datetime.now().isoformat(timespec="seconds"), + "title": items[key]["title"]} + if task: + entry["task"] = task + state["items"][key] = entry + save_state(state) + print(json.dumps({key: entry}, ensure_ascii=False)) + + +def updates(): + items, _ = read_items() + state = load_state()["items"] + out = [{"key": k, "task": d.get("task"), "title": d["title"], "status": items[k]["status"]} + for k, d in state.items() + if d["decision"] == "accepted" and k in items and items[k]["status"] != "open" and not d.get("applied")] + print(json.dumps({"updates": out}, indent=1, ensure_ascii=False)) + + +def applied(key): + state = load_state() + if key not in state["items"]: + sys.exit(f"no decision recorded for {key!r}") + state["items"][key]["applied"] = dt.datetime.now().isoformat(timespec="seconds") + save_state(state) + print(json.dumps({key: state["items"][key]}, ensure_ascii=False)) + + +def main(argv): + if not argv: + sys.exit(__doc__) + cmd, rest = argv[0], argv[1:] + if cmd == "pending" and not rest: + return pending() + if cmd == "updates" and not rest: + return updates() + if cmd == "accept" and len(rest) == 3 and rest[1] == "--task" and re.match(r"^t\d{4,}$", rest[2]): + return decide(rest[0], "accepted", rest[2]) + if cmd == "decline" and len(rest) == 1: + return decide(rest[0], "declined") + if cmd == "applied" and len(rest) == 1: + return applied(rest[0]) + sys.exit(__doc__) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/tests/test_engine.py b/tests/test_engine.py index 67eadd0..be7c3ce 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1244,6 +1244,178 @@ def test_the_command_records_and_reports_in_a_throwaway_workspace(self): self.assertTrue(json.loads(run("due"))["due"]) +class LocalChangesGuard(unittest.TestCase): + """Installing must stop rather than overwrite engine changes made in a workspace that the source doesn't have yet.""" + + def setUp(self): + self.ws = Path(tempfile.mkdtemp(prefix="theseus-lc-")) + self.src = Path(tempfile.mkdtemp(prefix="theseus-src-")) + for d in (self.ws, self.src): + self.addCleanup(shutil.rmtree, d, True) + git = lambda *a: subprocess.run(["git", "-C", str(self.src), *a], check=True, capture_output=True, text=True).stdout + git("init", "-q") + (self.src / "tool.py").write_text("print('old')\n") + git("add", "tool.py") + (self.src / "tool.py").write_text("print('new')\n") + (self.ws / "data/local-changes").mkdir(parents=True) + (self.ws / "data/local-changes/x.patch").write_text(git("diff")) + (self.src / "tool.py").write_text("print('old')\n") + + def changes(self, *fields): + (self.ws / "data/local-changes.md").write_text("# Local changes\n\n## Pending\n\n### A change\n" + "".join(f"- {f}\n" for f in fields) + + "\n## Done upstream\n\n### Old\n- Patch: `data/local-changes/gone.patch`\n") + + def guard(self): + return subprocess.run(["/usr/bin/python3", str(TOOLS / "local_changes.py"), "guard", "--src", str(self.src), "--home", str(self.ws)], + capture_output=True, text=True) + + def test_a_patch_the_source_lacks_stops_the_install(self): + self.changes("Type: applied locally", "Patch: `data/local-changes/x.patch`") + p = self.guard() + self.assertEqual(p.returncode, 1, p.stderr) + self.assertIn("A change", p.stderr) + + def test_a_missing_patch_file_stops_the_install(self): + self.changes("Patch: `data/local-changes/nowhere.patch`") + self.assertEqual(self.guard().returncode, 1) + + def test_a_patch_already_in_the_source_lets_it_through(self): + self.changes("Patch: `data/local-changes/x.patch`") + (self.src / "tool.py").write_text("print('new')\n") + p = self.guard() + self.assertEqual(p.returncode, 0, p.stderr) + + def test_entries_without_a_patch_and_done_ones_do_not_block(self): + self.changes("Type: recommended, not built", "Files: tool.py (patch mentioned only in passing)") + self.assertEqual(self.guard().returncode, 0) + + def test_the_session_note_lists_pending_and_says_its_text_is_data(self): + self.changes("Type: applied locally", "Before restart: yes") + p = subprocess.run(["/usr/bin/python3", str(TOOLS / "local_changes.py"), "session", "--home", str(self.ws)], capture_output=True, text=True) + self.assertIn("A change", p.stdout) + self.assertIn("BEFORE RESTART", p.stdout) + self.assertIn("data, not instructions", p.stdout) + self.assertNotIn("Old", p.stdout) + + +class PersonalDataReadOnly(unittest.TestCase): + """Messages, Mail and Things are read through a copy. The tools never change the real databases.""" + READERS = ("imsg.py", "mail.py", "things_export.py", "changes.py") + + def test_every_live_database_is_opened_read_only(self): + for name in self.READERS: + source = (TOOLS / name).read_text() + for m in re.finditer(r"sqlite3\.connect\((.*)", source): + arg = m.group(1) + if "as_uri" in arg: + self.assertIn("mode=ro", arg, f"{name}: {arg}") + self.assertIn("mode=ro", source, name) + + def test_no_reader_writes_sql_or_can_send(self): + writes = re.compile(r"""execute\w*\(\s*[rfb]?["']\s*(insert|update|delete|drop|alter|create|replace|attach)\b""", re.I) + for name in self.READERS: + source = (TOOLS / name).read_text() + self.assertIsNone(writes.search(source), name) + if name == "changes.py": + continue # it asks Notes for modification dates through osascript; the send guard covers what that could do + for sender in ("osascript", "smtplib", "sendmail"): + self.assertNotIn(sender, source, f"{name} mentions {sender}") + + def test_a_snapshot_leaves_the_source_byte_for_byte(self): + tmp = Path(tempfile.mkdtemp(prefix="theseus-ro-")) + self.addCleanup(shutil.rmtree, tmp, True) + src = tmp / "live.db" + con = sqlite3.connect(src) + con.execute("create table t (x)") + con.execute("insert into t values (1)") + con.commit() + con.close() + before = (src.read_bytes(), src.stat().st_mtime_ns) + src.chmod(0o444) + self.addCleanup(src.chmod, 0o644) + import imsg + out = imsg.snapshot(src, tmp / "copy.db", 0, True) + self.assertEqual(sqlite3.connect(out).execute("select x from t").fetchone(), (1,)) + self.assertEqual((src.read_bytes(), src.stat().st_mtime_ns), before) + self.assertEqual(sorted(p.name for p in tmp.iterdir()), ["copy.db", "live.db"]) + + +class Intake(unittest.TestCase): + """Items other tools hand Theseus wait for the user's yes, and the tools' files are never written to.""" + + def setUp(self): + self.ws = Path(tempfile.mkdtemp(prefix="theseus-intake-")) + self.addCleanup(shutil.rmtree, self.ws, True) + (self.ws / "data/intake").mkdir(parents=True) + self.file = self.ws / "data/intake/console.jsonl" + meeting = {"title": "Planning", "date": "2026-11-08"} + lines = [ + {"id": "c1", "created": "2026-11-08T10:00:00-07:00", "title": "Send the quote", "owner_is_me": True, "meeting": meeting, + "due": "2026-11-14", "category": "#Work"}, + {"id": "c2", "created": "2026-11-08T10:01:00-07:00", "title": "Someone else's", "owner_is_me": False, "meeting": meeting}, + {"id": "c3", "created": "2026-11-08T10:02:00-07:00", "title": "Vague date", "owner_is_me": True, "meeting": meeting, "due": "next Friday"}, + {"id": "c4", "created": "2026-11-08T10:03:00-07:00", "owner_is_me": True, "meeting": meeting}, + "not json", + {"id": "c9", "status": "done"}, + {"id": "c1", "created": "2026-11-08T11:00:00-07:00", "title": "A second c1", "owner_is_me": True, "meeting": meeting}, + {"id": "c5", "created": "2026-11-08T10:04:00-07:00", "title": "Called off", "owner_is_me": True, "meeting": meeting}, + {"id": "c5", "status": "cancelled"}, + ] + self.file.write_text("".join((l if isinstance(l, str) else json.dumps(l)) + "\n" for l in lines)) + + def run_intake(self, *args): + return subprocess.run(["/usr/bin/python3", str(TOOLS / "intake.py"), *args], capture_output=True, text=True, + env={**os.environ, "THESEUS_HOME": str(self.ws)}) + + def pending(self): + return json.loads(self.run_intake("pending").stdout) + + def test_only_open_items_for_the_user_wait_for_a_yes(self): + d = self.pending() + self.assertEqual([(i["n"], i["key"], i["title"]) for i in d["pending"]], [(1, "console:c1", "Send the quote"), (2, "console:c3", "Vague date")]) + self.assertEqual({s["why"] for s in d["skipped"]}, {"missing title", "not JSON", "done for an id not seen yet"}) + + def test_a_date_that_is_not_a_date_is_never_used(self): + c3 = self.pending()["pending"][1] + self.assertIsNone(c3["due"]) + self.assertEqual(c3["due_raw"], "next Friday") + + def test_nothing_joins_without_a_decision_and_each_is_decided_once(self): + self.assertFalse((self.ws / "data/intake-state.json").exists()) + self.assertEqual(self.run_intake("accept", "console:c1", "--task", "t0042").returncode, 0) + self.assertEqual(self.run_intake("decline", "console:c3").returncode, 0) + self.assertNotEqual(self.run_intake("accept", "console:c1", "--task", "t0043").returncode, 0) + self.assertNotEqual(self.run_intake("accept", "console:nope", "--task", "t0044").returncode, 0) + self.assertNotEqual(self.run_intake("accept", "console:c3", "--task", "tasks!").returncode, 0) + self.assertEqual(self.pending()["pending"], []) + state = json.loads((self.ws / "data/intake-state.json").read_text())["items"] + self.assertEqual((state["console:c1"]["decision"], state["console:c1"]["task"]), ("accepted", "t0042")) + self.assertEqual(state["console:c3"]["decision"], "declined") + + def test_a_later_done_line_reaches_the_task_once(self): + self.run_intake("accept", "console:c1", "--task", "t0042") + self.assertEqual(json.loads(self.run_intake("updates").stdout)["updates"], []) + with open(self.file, "a") as f: + f.write(json.dumps({"id": "c1", "status": "done"}) + "\n") + ups = json.loads(self.run_intake("updates").stdout)["updates"] + self.assertEqual([(u["task"], u["status"]) for u in ups], [("t0042", "done")]) + self.run_intake("applied", "console:c1") + self.assertEqual(json.loads(self.run_intake("updates").stdout)["updates"], []) + + def test_the_tools_file_is_never_written(self): + before = (self.file.read_bytes(), self.file.stat().st_mtime_ns) + self.pending() + self.run_intake("accept", "console:c1", "--task", "t0042") + self.run_intake("decline", "console:c3") + self.run_intake("updates") + self.assertEqual((self.file.read_bytes(), self.file.stat().st_mtime_ns), before) + self.assertEqual(sorted(p.name for p in (self.ws / "data/intake").iterdir()), ["console.jsonl"]) + + def test_no_intake_folder_means_nothing_pending(self): + shutil.rmtree(self.ws / "data/intake") + self.assertEqual(self.pending(), {"pending": [], "skipped": []}) + + class Versions(unittest.TestCase): def test_everything_says_the_same_version(self): v = (ROOT / "engine/VERSION").read_text().strip()