diff --git a/SKILL.md b/SKILL.md index ab6fa01..aebf1da 100644 --- a/SKILL.md +++ b/SKILL.md @@ -107,9 +107,9 @@ At session end, do four things in order: remember a list called session-corrections with "none" ``` -2. **Sensitivity check before saving.** Before saving to Receipts, scan the contract for `remember a source called` and `remember a claim called` statements. If any quoted content could contain sensitive material — proprietary code, financial data, customer information, medical records, credentials, or internal documents — ask the user: "This contract contains source excerpts that may be sensitive. Save to Receipts, or keep local-only?" If the user chooses local-only, skip the Receipts save, present the final contract as a copyable `limn` block, and note that it can be run locally with `liminate contract.limn --pack references/session_pack.json`. See [docs/TRUST-BOUNDARY.md](docs/TRUST-BOUNDARY.md) for the full data-flow description and [docs/LOCAL-ONLY.md](docs/LOCAL-ONLY.md) for the local-only walkthrough. +2. **Save via the helper.** Call `helper/contract_lifecycle.py save` — it persists the contract locally always, and uploads to Receipts only with a present human's explicit consent. See [`helper/README.md`](helper/README.md) for the CLI. The helper runs the sensitivity scan (`remember a source called` / `remember a claim called`) internally and applies the consent gate as code: unattended, it stays local-only and never sends a credential; attended, it stops at a "needs confirmation" signal until you pass `--consent upload`. When it returns that signal — or whenever the scan flags potentially sensitive material (proprietary code, financial data, customer information, medical records, credentials, internal documents) — ask the user: "This contract may contain sensitive excerpts. Upload to Receipts, or keep local-only?" Re-invoke with `--attended true --consent upload` only if they agree; otherwise the local copy is the record, runnable with `liminate --pack references/session_pack.json`. See [docs/TRUST-BOUNDARY.md](docs/TRUST-BOUNDARY.md) and [docs/LOCAL-ONLY.md](docs/LOCAL-ONLY.md) for the data-flow and local-only walkthroughs. -3. **Save to Receipts and present the permalink.** See [`references/save-procedure.md`](references/save-procedure.md) for the full save protocol — including `parent_id` resolution, the save payload fields, the Tier 2+ direct `curl`, classifier/permission handling, and the Tier 1 / user-run `save_receipt.py` fallback. +3. **Present the result.** The helper prints the local path always and a Receipts permalink only when an upload actually happened. Present whichever it returns. See [`references/save-procedure.md`](references/save-procedure.md) for the Receipts payload reference and the `parent_id`/lineage procedure the helper applies internally. 4. **Close the contract.** After emitting the final contract and the permalink (or the local-only alternative), the contract is closed. Do not emit any further `limn` delta blocks in this conversation. If the user continues talking after session end (follow-up questions, corrections, new tasks), respond normally in prose but do not append to the contract. The contract is a record of the session that ended — not a living document that grows indefinitely. @@ -120,9 +120,84 @@ The skill runs at whatever tier the host supports. Higher tiers add enforcement; | Tier | What's available | Behavior | |------|------------------|----------| | 1 | Conversation only | Emit the contract delta as a `limn` code block in each response. User can copy/paste to run later. | -| 2 | File tools + Liminate installed (`pip install liminate`) | Write the full contract to `~/.claude/contracts/.limn` (the session_id supplied by the SessionStart hook) on open, and rewrite it on every delta. After emitting each delta, run the file through `liminate` and fix parse errors before continuing. See [`references/starting-a-contract.md`](references/starting-a-contract.md) for session persistence & verification. | +| 2 | File tools + Liminate installed (`pip install liminate`) | Resolve the canonical contract path with `helper/contract_lifecycle.py path` (never the repo working tree), write the full contract there on open, and rewrite it on every delta. The helper's `init`/`save` operations also validate the contract through `liminate` for you. See [`references/starting-a-contract.md`](references/starting-a-contract.md) for session persistence & verification. | | 3 | Persistent storage + session pack | Load the session pack (`liminate --pack references/session_pack.json …`). Use `cite` and `verify` from the pack. Persist the contract across sessions so prior decisions inform later ones. | +## Contract lifecycle helper + +Contract-lifecycle correctness — *where* a contract is written, *how* it is +persisted, and *whether* it is uploaded — lives in one host-agnostic +executable, [`helper/contract_lifecycle.py`](helper/contract_lifecycle.py) +(see [`helper/README.md`](helper/README.md)), not in prose the model executes +by hand. It exposes three operations: + +- **`path`** — resolve the canonical contract path (`$LIMINATE_CONTRACTS_DIR` + > `$XDG_DATA_HOME/liminate/contracts` > `$HOME/.liminate/contracts`), never + inside a git working tree. +- **`init`** — create the contract from initial content (sources, decisions, + open questions) or a bare template, validated through the interpreter. +- **`save`** — persist locally always; upload to Receipts only when a human + is present and gives explicit consent. + +The helper is the universal floor: it runs identically on every host and +non-agent caller. Hooks are the silent-invocation layer for hosts that have +them; this SKILL is the discoverability layer for hosts that don't (read it, +call the helper). Every per-host variation degrades safe — no consent signal +means local-only, no session id means the helper generates one, no hook means +you invoke the helper directly. + +### Session-start triggers — one contract, many registrations + +A *trigger* is the thin per-agent front door that invokes the helper at +session start. There is **one trigger contract**; each agent registers it in +its own config format. Supporting a new agent is one small registration +against this contract — never a change to the helper or the trigger script. + +**The trigger contract.** A session-start trigger, in whatever form the host +supports, MUST: + +1. Obtain the `session_id` from the host — or omit it and let the helper + generate (and print) one. +2. Resolve the canonical path: + `python3 /helper/contract_lifecycle.py path --session-id `. +3. Inject the open-contract rule into the agent's context: write the full + contract to that path on open, rewrite it on every Channel-2 delta, and do + **not** create the file unless a contract is genuinely opened (its presence + is the statusline's proof). + +It MUST NOT create the contract file and MUST NOT re-implement path or +directory logic — the helper owns that. + +**The shared trigger script.** `hooks/contract-session-init.sh` implements the +contract. Its I/O is agent-neutral: it reads `session_id` from a stdin JSON +field and emits `hookSpecificOutput.additionalContext` — the shape Claude Code +and Codex both use — so the *same script* backs every hook-capable agent. Only +the registration differs: + +- **Claude Code** — in `~/.claude/settings.json`: + + ```json + "hooks": { "SessionStart": [ { "hooks": [ { "type": "command", "command": "/hooks/contract-session-init.sh" } ] } ] } + ``` + +- **Codex** — in `~/.codex/hooks.json` (or inline `[[hooks.SessionStart]]` in + `~/.codex/config.toml`); a ready example ships at + [`hooks/codex.hooks.json`](hooks/codex.hooks.json): + + ```json + { "hooks": { "SessionStart": [ { "matcher": "startup|resume", "hooks": [ { "type": "command", "command": "/hooks/contract-session-init.sh" } ] } ] } } + ``` + +- **Any other hook-capable agent** registers the same trigger in its own config + format, pointing at the same script — or, if its hook I/O differs from the + `session_id`-in / `additionalContext`-out shape, at a thin shim that adapts + the I/O and still calls the helper. + +**Agents without hooks — the universal fallback.** The instruction file +(`CLAUDE.md`, `AGENTS.md`, or the host's equivalent) directs the agent to run +the helper itself at session start. This SKILL is that discoverability layer. +No hook is required; correctness still holds because the helper is the floor. + ## Vocabulary constraint (critical) Liminate has 58 reserved words (21 verbs, 22 connectives, 8 operators, 3 articles, 3 multi-word reserved, 1 declaration). See `references/vocabulary_quick_reference.md` for the full list. The contract must use only: diff --git a/helper/README.md b/helper/README.md new file mode 100644 index 0000000..c47f0ff --- /dev/null +++ b/helper/README.md @@ -0,0 +1,99 @@ +# Contract lifecycle helper + +`contract_lifecycle.py` is the host-agnostic executable that owns +contract-lifecycle correctness — *where* a contract is written, *how* it is +persisted, and *whether* it is uploaded. It runs identically on every host +(Claude Code, Claude Desktop, claude.ai, Codex, plain shell) and on non-agent +callers, so correctness is universal by construction. Hooks and instruction +files (`SKILL.md` / `CLAUDE.md` / `AGENTS.md`) are optional front doors that +call this helper; they never re-implement its logic. + +Standard library only. The interpreter (`liminate`) is an optional, guarded +import: when it is absent, `init` validation degrades to a self-contained +parse check and still writes the contract. + +## Operations + +```bash +python3 helper/contract_lifecycle.py [options] +``` + +### `path` — resolve the canonical contract path + +```bash +python3 helper/contract_lifecycle.py path [--session-id ] +``` + +Prints the absolute contract path and creates its directory (mode `0700`). +Resolution precedence, **never the repo working tree**: + +1. `$LIMINATE_CONTRACTS_DIR` (explicit override) +2. `$XDG_DATA_HOME/liminate/contracts` +3. `$HOME/.liminate/contracts` (default) + +A resolved directory inside a git working tree is refused and falls back to +`$HOME/.liminate/contracts` — a contract must never land where it could be +committed. With no `--session-id`, one is generated. + +### `init` — create the contract from initial content + +```bash +python3 helper/contract_lifecycle.py init [--session-id ] [--from ] +``` + +Writes a contract to the canonical path and validates it through the +interpreter (Phase 1 only). With no `--from`, produces a valid bare template +contract. With a payload (a file path, or `-` for stdin), populates the +session's starting ground truth before the first claim. The payload is +**generic and source-agnostic** — it may originate from a prior checkpoint, a +pasted resume prompt, an inheritance preamble, or a hand-authored file. Shape +(every field optional): + +```json +{ + "sources": [{"name": "spec-doc", "text": "verbatim excerpt to cite later"}], + "decisions": ["locked-decision-slug"], + "open_questions": ["unresolved-question-slug"], + "resume_state": "one-line state carried forward" +} +``` + +If a payload is supplied, every item must land in the contract or `init` +errors (it never silently drops content). The standard lists are declared +before any `add`. On validation failure, nothing is written. + +### `save` — persist locally always; upload only with consent + +```bash +# unattended (default): persists locally, never uploads +python3 helper/contract_lifecycle.py save --session-id --from contract.limn + +# attended, with explicit human consent: persists AND uploads to Receipts +python3 helper/contract_lifecycle.py save --session-id --from contract.limn \ + --attended true --consent upload \ + [--label ] [--agent-id ] [--parent-id ] +``` + +`save` separates *persist locally* (always, first, never fails for lack of a +human) from *upload to Receipts* (consent-gated). The consent gate: + +| Condition | Result | +|---|---| +| unattended (no `--attended`, no TTY) | local-only; never sends a credential | +| `--attended false` | local-only | +| `--attended true`, no `--consent upload` | stops at the gate — exit code `10` ("needs confirmation"); ask the user, then re-invoke | +| `--attended true --consent upload` | uploads (the only path that POSTs) | +| consent given but no `$RECEIPTS_API_KEY` | local-only; reports the key is unset | + +The helper never calls `input()`, so it never blocks an unattended run. It +prints the local path always, and a Receipts permalink only when an upload +actually happened. + +## Degradations (all fail safe) + +- No `--session-id` → one is generated, recorded in the contract, and printed. +- No consent signal → unattended → local-only. +- No `$RECEIPTS_API_KEY` → local persistence still succeeds; only the upload + path reports the key is unset (see `receipts.liminate.dev/keys`). +- `liminate` not importable → `init` validation degrades to a parse check; the + contract is still written. diff --git a/helper/contract_lifecycle.py b/helper/contract_lifecycle.py new file mode 100644 index 0000000..e7d4b50 --- /dev/null +++ b/helper/contract_lifecycle.py @@ -0,0 +1,504 @@ +#!/usr/bin/env python3 +"""Host-agnostic contract-lifecycle helper for liminate-session-contracts. + +One executable owns the three lifecycle operations that were previously +scattered across a Claude-Code-only hook and prose the model executed by +hand. Running it identically on every host (Claude Code, Desktop, claude.ai, +Codex, plain shell) is what makes contract-lifecycle correctness universal: + + path resolve the canonical contract path (never the repo working tree) + init create the contract from initial content (or a bare template) + save persist locally always; upload to Receipts only with a present + human's explicit consent + +All correctness lives here. The hook is a thin trigger; the SKILL prose says +"call the helper", never re-describes these steps. Every missing signal +(session id, consent, API key, interpreter) degrades to a safe default — +never a crash, never an unattended upload. + +Standard library only. The optional `liminate` import is guarded so the +helper degrades to a self-contained parse check when the interpreter is not +installed. +""" + +from __future__ import annotations + +import argparse +import enum +import json +import os +import re +import secrets +import sys +from pathlib import Path + +# -------------------------------------------------------------------------- +# Constants +# -------------------------------------------------------------------------- + +SAVE_URL = "https://receipts.liminate.dev/save" +RECEIPTS_BASE = "https://receipts.liminate.dev" + +# Distinct exit code: an attended save reached the consent gate but no +# explicit `--consent upload` was given. The caller (the model, in prose) +# must obtain the human's consent and re-invoke. Not an error — a signal. +NEEDS_CONFIRMATION_EXIT = 10 + +# Reference material lives one level up from this file (repo/references/). +_REPO_ROOT = Path(__file__).resolve().parent.parent +PACK_PATH = _REPO_ROOT / "references" / "session_pack.json" +TEMPLATE_PATH = _REPO_ROOT / "references" / "session_contract_template.limn" + +# Substrings that mark contract source/claim text as potentially sensitive. +SENSITIVE_MARKERS = ( + "password", "passwd", "secret", "api key", "api_key", "apikey", + "bearer", "private key", "-----begin", "ssn", "social security", + "credit card", "credential", +) + + +# -------------------------------------------------------------------------- +# Operation 1: path — resolve the canonical contract path +# -------------------------------------------------------------------------- + +def _inside_git_worktree(path: Path) -> bool: + """True if `path` (or any ancestor) sits inside a git working tree.""" + p = Path(path).resolve() + for parent in (p, *p.parents): + if (parent / ".git").exists(): + return True + return False + + +def resolve_contracts_dir(env: dict | None = None) -> Path: + """Resolve the contracts directory deterministically, never the repo. + + Precedence: $LIMINATE_CONTRACTS_DIR > $XDG_DATA_HOME/liminate/contracts + > $HOME/.liminate/contracts. A resolved directory inside a git working + tree is refused (a contract must never land where it could be committed) + and falls back to $HOME/.liminate/contracts. + """ + env = os.environ if env is None else env + home = env.get("HOME") or os.path.expanduser("~") + fallback = Path(home) / ".liminate" / "contracts" + + override = env.get("LIMINATE_CONTRACTS_DIR") + xdg = env.get("XDG_DATA_HOME") + if override: + candidate = Path(override) + elif xdg: + candidate = Path(xdg) / "liminate" / "contracts" + else: + candidate = fallback + + if candidate != fallback and _inside_git_worktree(candidate): + candidate = fallback + return candidate + + +def ensure_dir(path: Path) -> Path: + """Create the directory (mode 0700) if absent. Returns it.""" + path.mkdir(parents=True, exist_ok=True) + try: + path.chmod(0o700) + except OSError: + pass + return path + + +def generate_session_id() -> str: + return secrets.token_urlsafe(8) + + +def resolve_path(session_id: str, env: dict | None = None) -> Path: + """Canonical absolute path for a session's contract. Creates the dir.""" + d = ensure_dir(resolve_contracts_dir(env)) + return d / f"{session_id}.limn" + + +# -------------------------------------------------------------------------- +# Operation 2: init — build the contract from initial content +# -------------------------------------------------------------------------- + +def _sanitize_text(text: str) -> str: + """Make a value safe to embed inside a double-quoted Liminate string.""" + return str(text).replace('"', "'").replace("\n", " ").replace("\r", " ").strip() + + +def _sanitize_name(name: str) -> str: + """Make a payload-supplied name a valid hyphenated Liminate identifier.""" + n = re.sub(r"[^a-zA-Z0-9-]+", "-", str(name).strip().lower()).strip("-") + return n or "unnamed-source" + + +def build_contract(content: dict, session_id: str) -> str: + """Render a contract from generic initial content. + + `content` is source-agnostic — it may come from a prior checkpoint, a + pasted resume prompt, an inheritance preamble, or a hand-authored + payload. An empty/absent payload yields a valid bare template contract. + Recognised fields: sources [{name, text}], decisions [str], + open_questions [str], resume_state (str). Any may be omitted. + """ + content = content or {} + sources = content.get("sources") or [] + decisions = content.get("decisions") or [] + questions = content.get("open_questions") or [] + resume_state = content.get("resume_state") + + lines: list[str] = [] + lines.append(f'show "=== Session contract: {_sanitize_text(session_id)} ==="') + lines.append("") + # State scalars and the standard lists, declared before any `add`. + lines.append('remember a string called source-state with "unscanned"') + lines.append('remember a string called claim-basis with "none"') + lines.append('remember a list called tracked-decisions with "none"') + lines.append('remember a list called open-questions with "none"') + lines.append('remember a list called session-corrections with "none"') + lines.append("remember a number called decision-count with 0") + lines.append(f'remember a string called session-id with "{_sanitize_text(session_id)}"') + if resume_state: + lines.append(f'remember a string called resume-state with "{_sanitize_text(resume_state)}"') + lines.append("") + # Initial sources (the populate-at-start ground truth). + for s in sources: + name = _sanitize_name(s.get("name", "") if isinstance(s, dict) else "") + text = _sanitize_text(s.get("text", "") if isinstance(s, dict) else s) + lines.append(f'remember a source called {name} with "{text}"') + if sources: + lines.append("") + # Reactive consistency guards (same shape as the template). + lines.append('when source-state is equal to "unscanned"') + lines.append(' show "HOLD: source not yet scanned — verify before consequential claims"') + lines.append('when claim-basis is equal to "inference" unless source-state is equal to "verified"') + lines.append(' show "WARNING: current claims are inferred, not verified against source"') + lines.append('when claim-basis is equal to "verified"') + lines.append(' show "OK: claims grounded in verified source"') + lines.append("") + # Initial decisions and open questions. + for d in decisions: + lines.append(f'add "{_sanitize_text(d)}" to tracked-decisions') + for q in questions: + lines.append(f'add "{_sanitize_text(q)}" to open-questions') + lines.append("") + lines.append("show source-state") + lines.append("show claim-basis") + lines.append("show tracked-decisions") + lines.append("show open-questions") + return "\n".join(lines) + "\n" + + +def _missing_items(content: dict, src: str) -> list[str]: + """Items in the payload that did not land in the rendered contract.""" + content = content or {} + missing: list[str] = [] + for s in content.get("sources") or []: + name = _sanitize_name(s.get("name", "") if isinstance(s, dict) else "") + if f"remember a source called {name} with" not in src: + missing.append(f"source:{name}") + for d in content.get("decisions") or []: + if f'add "{_sanitize_text(d)}" to tracked-decisions' not in src: + missing.append(f"decision:{d}") + for q in content.get("open_questions") or []: + if f'add "{_sanitize_text(q)}" to open-questions' not in src: + missing.append(f"question:{q}") + return missing + + +def _liminate_available() -> bool: + try: + import liminate # noqa: F401 + from liminate import cli # noqa: F401 + return True + except Exception: + return False + + +def _self_parse_check(src: str) -> tuple[bool, list[str]]: + """Minimal structural check the helper can do without the interpreter.""" + errors = [] + for i, ln in enumerate(src.splitlines(), 1): + if ln.count('"') % 2 != 0: + errors.append(f"line {i}: unbalanced quotes") + return (not errors), errors + + +def validate_contract(src: str) -> tuple[bool, list[str]]: + """Validate the contract. Uses the Liminate interpreter when available + (Phase 1 only — contracts are not live reactive programs), otherwise + degrades to a self-contained parse check.""" + if not _liminate_available(): + return _self_parse_check(src) + import liminate + from liminate import cli + try: + packs = [cli.load_pack_from_path(str(PACK_PATH))] if PACK_PATH.exists() else None + res = liminate.run(src, domain_packs=packs, enter_phase2=False) + except Exception as e: # interpreter blew up — surface it, write nothing + return False, [f"interpreter error: {e}"] + errors: list[str] = [] + if res.had_error: + for r in res.results: + status = getattr(r.status, "name", str(r.status)) + if "ERROR" in status: + errors.append(f"{status}: {getattr(r, 'message', '')}".strip()) + return (not res.had_error), errors + + +# -------------------------------------------------------------------------- +# Operation 3: save — consent-gated save +# -------------------------------------------------------------------------- + +class UploadDecision(enum.Enum): + LOCAL_ONLY_UNATTENDED = "local_only_unattended" + NEEDS_CONFIRMATION = "needs_confirmation" + UPLOAD = "upload" + LOCAL_ONLY_NO_KEY = "local_only_no_key" + + +def decide_upload(*, attended: bool, consent_upload: bool, + key_present: bool, sensitive: bool) -> UploadDecision: + """The consent gate, as pure logic. + + - Unattended (no human present): never upload. Local-only. + - Attended + explicit `--consent upload`: upload (only path that POSTs), + unless no key is set, in which case stay local. + - Attended without explicit consent: stop at the gate (needs + confirmation); the local copy is already safe. + """ + if not attended: + return UploadDecision.LOCAL_ONLY_UNATTENDED + if consent_upload: + return UploadDecision.UPLOAD if key_present else UploadDecision.LOCAL_ONLY_NO_KEY + return UploadDecision.NEEDS_CONFIRMATION + + +def scan_sensitive(src: str) -> bool: + """Flag potentially sensitive content in remembered sources/claims.""" + blob = " ".join( + m.group(1).lower() + for m in re.finditer( + r'remember a (?:source|claim) called \S+ with "([^"]*)"', src or "" + ) + ) + return any(marker in blob for marker in SENSITIVE_MARKERS) + + +def _upload(contract_src: str, key: str, *, label: str | None = None, + agent_id: str | None = None, session_id: str | None = None, + parent_id: str | None = None) -> str: + """POST the contract to Receipts and return the permalink. The ONLY + network call in this module — reached solely on the attended + + explicit-consent path.""" + import urllib.request + + payload: dict = {"source": contract_src} + if label: + payload["label"] = label + if agent_id: + payload["agent_id"] = agent_id + if session_id: + payload["session_id"] = session_id + if parent_id: + payload["parent_id"] = parent_id + body = json.dumps(payload).encode() + req = urllib.request.Request(SAVE_URL, data=body, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("Authorization", "Bearer " + key) + with urllib.request.urlopen(req, timeout=30) as r: + data = json.loads(r.read().decode()) + return RECEIPTS_BASE + data["contract"]["permalink"] + + +def do_save(*, session_id: str | None, env: dict, attended: bool | None, + consent_upload: bool, contract_src: str | None, isatty: bool, + label: str | None = None, agent_id: str | None = None, + parent_id: str | None = None) -> dict: + """Persist locally always, then apply the consent gate. Never blocks on a + human, never uploads unattended, never loses the contract.""" + if not session_id: + session_id = generate_session_id() + path = resolve_path(session_id, env) + + # 1. Persist locally, always, first. + if contract_src is not None: + path.write_text(contract_src) + elif path.exists(): + contract_src = path.read_text() + else: + contract_src = build_contract({}, session_id) + path.write_text(contract_src) + + # 2. Determine whether a human is present (default unattended). + if attended is None: + attended = bool(isatty) + key = env.get("RECEIPTS_API_KEY") or "" + sensitive = scan_sensitive(contract_src) + + # 3. Consent gate. + decision = decide_upload(attended=attended, consent_upload=consent_upload, + key_present=bool(key), sensitive=sensitive) + + result = { + "session_id": session_id, + "local_path": str(path), + "decision": decision.value, + "sensitive": sensitive, + "uploaded": False, + "needs_confirmation": False, + "permalink": None, + } + if decision is UploadDecision.UPLOAD: + result["permalink"] = _upload( + contract_src, key, label=label, agent_id=agent_id, + session_id=session_id, parent_id=parent_id, + ) + result["uploaded"] = True + elif decision is UploadDecision.NEEDS_CONFIRMATION: + result["needs_confirmation"] = True + return result + + +# -------------------------------------------------------------------------- +# Payload / contract reading +# -------------------------------------------------------------------------- + +def _read_text_arg(from_path: str | None) -> str | None: + """Read --from (a path, or `-` for stdin). None if not supplied.""" + if from_path is None: + return None + if from_path == "-": + return sys.stdin.read() + return Path(from_path).read_text() + + +def _read_payload(from_path: str | None) -> dict: + raw = _read_text_arg(from_path) + if raw is None or not raw.strip(): + return {} + return json.loads(raw) + + +# -------------------------------------------------------------------------- +# CLI +# -------------------------------------------------------------------------- + +def _cmd_path(args) -> int: + sid = args.session_id or generate_session_id() + print(resolve_path(sid, os.environ)) + return 0 + + +def _cmd_init(args) -> int: + sid = args.session_id or generate_session_id() + content = _read_payload(args.from_path) + src = build_contract(content, sid) + + missing = _missing_items(content, src) + if missing: + print("init failed — provided items dropped: " + ", ".join(missing), + file=sys.stderr) + return 1 + + ok, errors = validate_contract(src) + if not ok: + print("init failed — contract did not validate:", file=sys.stderr) + for e in errors: + print(f" {e}", file=sys.stderr) + return 1 + + path = resolve_path(sid, os.environ) + path.write_text(src) + print(path) + if not args.session_id: + print(f"session-id: {sid}") + if not _liminate_available(): + print("note: interpreter validation skipped (liminate not importable); " + "ran parse-only check", file=sys.stderr) + return 0 + + +def _cmd_save(args) -> int: + contract_src = _read_text_arg(args.from_path) + if contract_src is not None and not contract_src.strip(): + contract_src = None + attended: bool | None = None + if args.attended == "true": + attended = True + elif args.attended == "false": + attended = False + + result = do_save( + session_id=args.session_id, + env=os.environ, + attended=attended, + consent_upload=(args.consent == "upload"), + contract_src=contract_src, + isatty=sys.stdin.isatty(), + label=args.label, + agent_id=args.agent_id, + parent_id=args.parent_id, + ) + + print(f"local: {result['local_path']}") + if not args.session_id: + print(f"session-id: {result['session_id']}") + + if result["uploaded"]: + print(f"permalink: {result['permalink']}") + return 0 + if result["decision"] == UploadDecision.LOCAL_ONLY_UNATTENDED.value: + print("upload skipped: no human present to consent — local-only") + return 0 + if result["decision"] == UploadDecision.LOCAL_ONLY_NO_KEY.value: + print("upload skipped: RECEIPTS_API_KEY not set — local-only. " + "Generate a key at receipts.liminate.dev/keys") + return 0 + if result["needs_confirmation"]: + extra = " (sensitive content detected)" if result["sensitive"] else "" + print(f"needs confirmation{extra}: attended save requires explicit " + "`--consent upload` to upload. Local copy saved.") + return NEEDS_CONFIRMATION_EXIT + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="contract_lifecycle", + description="Host-agnostic contract-lifecycle helper " + "(path / init / save).", + ) + sub = parser.add_subparsers(dest="op", required=True) + + p_path = sub.add_parser("path", help="resolve the canonical contract path") + p_path.add_argument("--session-id") + p_path.set_defaults(func=_cmd_path) + + p_init = sub.add_parser("init", help="create the contract from initial content") + p_init.add_argument("--session-id") + p_init.add_argument("--from", dest="from_path", + help="JSON payload of initial content; `-` for stdin") + p_init.set_defaults(func=_cmd_init) + + p_save = sub.add_parser("save", help="persist locally; upload only with consent") + p_save.add_argument("--session-id") + p_save.add_argument("--from", dest="from_path", + help="contract source to persist; `-` for stdin") + p_save.add_argument("--attended", choices=["true", "false"], + help="whether a human is present (default: detect via TTY)") + p_save.add_argument("--consent", choices=["upload"], + help="explicit consent to upload to Receipts") + p_save.add_argument("--label") + p_save.add_argument("--agent-id", dest="agent_id") + p_save.add_argument("--parent-id", dest="parent_id") + p_save.set_defaults(func=_cmd_save) + return parser + + +def main(argv=None) -> int: + args = build_parser().parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hooks/codex.hooks.json b/hooks/codex.hooks.json new file mode 100644 index 0000000..634bd11 --- /dev/null +++ b/hooks/codex.hooks.json @@ -0,0 +1,18 @@ +{ + "_comment": "Codex registration of the agent-agnostic session-start trigger contract. Install at ~/.codex/hooks.json (user layer) or /.codex/hooks.json (project layer); or translate to inline [[hooks.SessionStart]] in ~/.codex/config.toml. Replace with this repo's absolute path. The command points at the SAME trigger script Claude Code uses — only the registration format differs. The script resolves the canonical path via helper/contract_lifecycle.py and injects the open-contract rule; it never creates the contract file.", + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "/hooks/contract-session-init.sh", + "statusMessage": "Resolving session contract path", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/hooks/contract-session-init.sh b/hooks/contract-session-init.sh index 29a764e..757fb3c 100755 --- a/hooks/contract-session-init.sh +++ b/hooks/contract-session-init.sh @@ -1,24 +1,39 @@ #!/bin/sh -# SessionStart hook for liminate-session-contracts. -# Supplies the agent its session_id and the keyed contract path, and ensures -# the contracts directory exists. Deliberately does NOT create the contract -# file — its presence is how the statusline verifies a contract is loaded, so -# the file must appear only when the agent genuinely opens a contract. +# Agent-agnostic SessionStart trigger for liminate-session-contracts. +# +# This is the one trigger script; each hook-capable agent registers it in its +# own config format (Claude Code: ~/.claude/settings.json; Codex: +# ~/.codex/hooks.json or [[hooks.SessionStart]] in config.toml — see +# hooks/codex.hooks.json). Its I/O is the shape both use: a `session_id` on +# stdin JSON in, `hookSpecificOutput.additionalContext` out — so the same +# script backs every such agent and supporting a new one is just a +# registration, never a script or helper change. +# +# It fulfils the trigger contract: (1) take the session_id from the host, +# (2) resolve the canonical path via the host-agnostic helper +# (helper/contract_lifecycle.py — the single owner of path/dir logic), and +# (3) inject the write-on-open rule. It deliberately does NOT create the +# contract file — its presence is how the statusline verifies a contract is +# loaded, so the file must appear only when the agent genuinely opens one. set -eu input=$(cat) sid=$(printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null || true) -# Reject any session_id that would escape the contracts directory +# Reject any session_id that would escape the contracts directory. case "$sid" in */*|*..*) exit 0 ;; esac -mkdir -p "$HOME/.claude/contracts" - [ -z "$sid" ] && exit 0 -path="$HOME/.claude/contracts/${sid}.limn" +# Resolve the canonical path via the helper (it also creates the directory, +# mode 0700, never inside a git working tree). Degrade silently if the helper +# is unavailable rather than breaking session start. +repo_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +path=$(python3 "$repo_dir/helper/contract_lifecycle.py" path --session-id "$sid" 2>/dev/null || true) +[ -z "$path" ] && exit 0 + context="Session contract persistence: your session_id is ${sid}. When you open a session contract (the liminate-session-contracts skill), write the full contract to ${path} and rewrite that file on every Channel-2 delta. Do NOT create that file unless you actually open a contract — its presence is how the statusline verifies a contract is loaded." jq -n --arg ctx "$context" \ diff --git a/references/save-procedure.md b/references/save-procedure.md index 68dcf86..018dec4 100644 --- a/references/save-procedure.md +++ b/references/save-procedure.md @@ -4,7 +4,36 @@ This is step 2 of the [Session end](../SKILL.md#session-end) sequence in the core skill: after emitting the final contract (step 1) and before closing it (step 3), save it to the Receipts inspection surface and present the permalink. The two-channel protocol and vocabulary constraint in [`SKILL.md`](../SKILL.md) still govern everything here. -## Generate a Receipts permalink +## Invoke the helper — do not assemble the call by hand + +The save flow is owned by [`helper/contract_lifecycle.py`](../helper/contract_lifecycle.py) +(see [`helper/README.md`](../helper/README.md)). **Invoke the helper; do not +re-assemble the save logic in prose.** It persists the contract locally +*always*, runs the sensitivity scan, applies the consent gate, and — only on +the attended + explicit-consent path — POSTs to Receipts with the payload and +`parent_id` lineage described below. + +```bash +# unattended (default): persists locally, never uploads +python3 helper/contract_lifecycle.py save --session-id "$sid" --from contract.limn + +# attended, with the human's explicit consent: persists AND uploads +python3 helper/contract_lifecycle.py save --session-id "$sid" --from contract.limn \ + --attended true --consent upload --label "design review · 2026-05-23" \ + --agent-id "claude-opus-4-8" --parent-id "HW496KG7" +``` + +Without `--consent upload` an attended save stops at the consent gate (exit +code 10, "needs confirmation") and uploads nothing — ask the user, then +re-invoke with consent. The helper never calls `input()`, so it never blocks +an unattended run. + +Everything below documents **what the helper does internally** — the Receipts +payload contract, the lineage procedure, and the classifier behaviour — and +serves as the manual fallback if the helper is unavailable. It is reference, +not a second procedure to execute by hand. + +## Generate a Receipts permalink (reference: what the helper does internally) Save the contract to the Receipts inspection surface and present the permalink. @@ -18,7 +47,7 @@ If no inheritance was used, omit `parent_id`. Do not skip this step — a contract that inherited decisions but ships without `parent_id` breaks the lineage chain. -**Tier 2+ (bash/file tools available):** Call the Receipts API directly: +**Tier 2+ (bash/file tools available):** the helper's `save --attended true --consent upload` makes exactly this call for you. Shown here as the contract it fulfils and the manual fallback if the helper is unavailable: ```bash curl -s -X POST https://receipts.liminate.dev/save \ @@ -65,7 +94,7 @@ simplest deterministic allow. via `/permissions` or in their `settings.local.json` `allow` array, then restart so permissions reload. An explicit allow rule is deterministic and bypasses the classifier; auto-mode is a model judgment that can change between sessions. The agent **cannot** add this rule itself — editing a permissions allow-list is a hard-blocked self-modification. Hand the rule to the user and let them apply it. (Verified live May 22, 2026: with the allow rule in place, the agent's direct `curl … /save` succeeds with no denial.) The allow rule only matters when running unattended — in interactive mode the user can skip it entirely and just approve the prompt. Note this classifier is Claude Code's; other agents (Codex, Cursor, …) have their own permission or sandbox policies and may prompt, block, or allow the call differently. -**Tier 1 (conversation only, no tools), or whenever the user must run the save themselves:** Do **not** emit a multi-line `curl` for the user to paste. Pasting a wrapped `curl -d '{…}'` block out of chat markdown corrupts the JSON body — line-wrapping and leading indentation inject stray whitespace into the `source` string, the server rejects the malformed JSON and returns an error object with no `contract` key, and the permalink extractor crashes with `KeyError: 'contract'`. This is a real, observed failure (May 22, 2026). Instead, give the user a **single self-contained Python file to run**, which has no paste step to mangle: +**Tier 1 (conversation only, no tools, or the helper cannot be invoked), or whenever the user must run the save themselves:** the consent gate still applies — only reach this fallback after the human has agreed to upload. Do **not** emit a multi-line `curl` for the user to paste. Pasting a wrapped `curl -d '{…}'` block out of chat markdown corrupts the JSON body — line-wrapping and leading indentation inject stray whitespace into the `source` string, the server rejects the malformed JSON and returns an error object with no `contract` key, and the permalink extractor crashes with `KeyError: 'contract'`. This is a real, observed failure (May 22, 2026). Instead, give the user a **single self-contained Python file to run**, which has no paste step to mangle: 1. Emit the full contract as a fenced `limn` block (step 1 above). 2. If you have file tools, write `save_receipt.py` to disk (file writes are not blocked by the classifier — only the network call is). Otherwise emit its contents in one fenced ```python block for the user to save. The script embeds the contract as a triple-quoted string, reads `RECEIPTS_API_KEY` from the environment, POSTs via `urllib`, and **prints the raw HTTP status and response body** before extracting the permalink — so a non-200 or a changed response shape is visible instead of crashing: diff --git a/references/starting-a-contract.md b/references/starting-a-contract.md index c87b400..30841ef 100644 --- a/references/starting-a-contract.md +++ b/references/starting-a-contract.md @@ -63,14 +63,52 @@ extraction, when to omit) is the canonical copy in Run it at session end, not at session start — at start, you only need to *record* the prior contract's ID (if known) for later use. -## From the template +## Create the contract — the helper's `init` -1. Read `references/session_contract_template.limn` for the starting shape. -2. Copy it to a working location (disk at tier 2+, conversation at tier 1). -3. Replace the template's example decisions/questions with the user's actual session goal. -4. Set `source-state` and `claim-basis` honestly. The default `unscanned` / `none` is correct at the start of most sessions. +Create the contract with the lifecycle helper +([`helper/contract_lifecycle.py`](../helper/contract_lifecycle.py), see +[`helper/README.md`](../helper/README.md)) rather than hand-copying the +template: -After that, every contract mutation flows through Channel 2 — the `limn` block at the end of each response. +```bash +# a bare contract from the template shape +python3 helper/contract_lifecycle.py init --session-id "$sid" + +# populated from initial content (the populate-at-start handoff) +python3 helper/contract_lifecycle.py init --session-id "$sid" --from initial.json +``` + +`init` writes the contract to the canonical path (see +[Session persistence](#session-persistence--verification)), validates it +through the interpreter, and — when content is supplied — populates the +session's starting ground truth *before the first claim*. + +The initial content is **generic and source-agnostic**. It may come from a +prior checkpoint, a pasted resume prompt, the +`liminate-contract-inheritance` skill's preamble, or a hand-authored payload; +the helper does not mandate any particular producer. A call with no payload +yields a valid bare template contract. Payload shape (every field optional): + +```json +{ + "sources": [{"name": "spec-doc", "text": "verbatim excerpt the contract can cite later"}], + "decisions": ["locked-decision-slug"], + "open_questions": ["unresolved-question-slug"], + "resume_state": "one-line state carried forward" +} +``` + +This is how the session-1 delta — the most important delta in the session — +is guaranteed when content is provided: every source, decision, and question +in the payload lands in the contract, or `init` errors rather than silently +dropping it. After `init`, every further contract mutation flows through +Channel 2 — the `limn` block at the end of each response. + +## The template shape (what `init` builds from) + +1. Read `references/session_contract_template.limn` for the starting shape — it is what the helper's `init` renders. +2. The bare `init` declares the standard lists and sets `source-state` / `claim-basis` to the honest defaults (`unscanned` / `none`), correct at the start of most sessions. +3. Provide a `--from` payload to replace the placeholder content with the user's actual sources, decisions, and questions. ## Session persistence & verification @@ -79,8 +117,11 @@ persisted to a stable, session-keyed path so an external process (a Claude Code statusline) can verify a contract is open. The `hooks/contract-session-init.sh` SessionStart hook injects your -`session_id` and the keyed path `~/.claude/contracts/.limn` into -context at session start. When you open a contract, **write the full contract +`session_id` and the keyed contract path into context at session start. That +path is resolved by the helper (`helper/contract_lifecycle.py path +--session-id `) — canonically `~/.liminate/contracts/.limn`, +or wherever `$LIMINATE_CONTRACTS_DIR` / `$XDG_DATA_HOME` redirect it, but never +inside a git working tree. When you open a contract, **write the full contract to that path**, and **rewrite it on every Channel-2 delta** so the file always holds the live contract. This single file: @@ -94,9 +135,9 @@ The hook deliberately does not create it. File present ⟺ a contract was opened this session — that is what makes the statusline indicator honest. Do not pre-create or touch the file to make the indicator turn green. -Contract files accumulate in `~/.claude/contracts/`. This is intentional — -inheritance reads prior files. Clean them up manually when desired; there is -no automatic pruner. +Contract files accumulate in the canonical contracts directory +(`~/.liminate/contracts/` by default). This is intentional — inheritance reads +prior files. Clean them up manually when desired; there is no automatic pruner. ## Install — hook & statusline @@ -114,6 +155,11 @@ Two optional pieces make persistence and verification automatic: ``` Use the absolute path to this skill's `hooks/contract-session-init.sh`. + This is Claude Code's registration of the agent-agnostic trigger + contract; other hook-capable agents register the same script in their own + config format (Codex: [`hooks/codex.hooks.json`](../hooks/codex.hooks.json)). + See [Session-start triggers — one contract, many registrations](../SKILL.md#session-start-triggers--one-contract-many-registrations) + for the full contract and the hookless instruction-file fallback. 2. **Statusline.** See [`references/statusline.md`](statusline.md) for the command block and what it renders (`contract: ` / diff --git a/tests/test_codex_registration.py b/tests/test_codex_registration.py new file mode 100644 index 0000000..27fda49 --- /dev/null +++ b/tests/test_codex_registration.py @@ -0,0 +1,85 @@ +"""The session-start trigger contract is agent-agnostic: one trigger script +(`hooks/contract-session-init.sh`) backs every hook-capable agent, and each +agent supplies only a small registration in its own config format. + +These tests prove (1) the Codex registration (`hooks/codex.hooks.json`) is a +valid Codex SessionStart registration pointing at the shared trigger script, +and (2) the shared script handles a Codex-shaped SessionStart payload — same +stdin `session_id` in, same `hookSpecificOutput.additionalContext` out — so +supporting Codex required no change to the helper or a new script. +""" + +import json +import os +import pathlib +import subprocess + +import pytest + +REPO = pathlib.Path(__file__).resolve().parent.parent +CODEX_REG = REPO / "hooks" / "codex.hooks.json" +TRIGGER = REPO / "hooks" / "contract-session-init.sh" + + +def test_codex_registration_is_valid_json(): + data = json.loads(CODEX_REG.read_text()) + assert isinstance(data, dict) + + +def test_codex_registration_registers_sessionstart_command(): + data = json.loads(CODEX_REG.read_text()) + groups = data["hooks"]["SessionStart"] + assert isinstance(groups, list) and groups + cmds = [ + h + for g in groups + for h in g.get("hooks", []) + if h.get("type") == "command" + ] + assert cmds, "no command hook registered for SessionStart" + + +def test_codex_registration_points_at_the_shared_trigger_script(): + data = json.loads(CODEX_REG.read_text()) + commands = [ + h["command"] + for g in data["hooks"]["SessionStart"] + for h in g.get("hooks", []) + if h.get("type") == "command" + ] + assert any("contract-session-init.sh" in c for c in commands), commands + + +def test_codex_matcher_targets_startup_and_resume(): + data = json.loads(CODEX_REG.read_text()) + matchers = [g.get("matcher", "") for g in data["hooks"]["SessionStart"]] + joined = " ".join(matchers) + assert "startup" in joined and "resume" in joined + + +def test_shared_trigger_handles_codex_payload(tmp_path): + """A Codex-shaped SessionStart payload (extra fields and all) yields the + same additionalContext with the helper-resolved path — and does NOT create + the contract file (trust model).""" + env = dict(os.environ) + env.update({"HOME": str(tmp_path), "XDG_DATA_HOME": "", + "LIMINATE_CONTRACTS_DIR": ""}) + payload = json.dumps({ + "session_id": "codex-sess-1", + "source": "startup", + "cwd": str(tmp_path), + "hook_event_name": "SessionStart", + "transcript_path": None, + "model": "gpt-5-codex", + "permission_mode": "default", + }) + proc = subprocess.run( + ["sh", str(TRIGGER)], input=payload, capture_output=True, text=True, env=env, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + ctx = out["hookSpecificOutput"]["additionalContext"] + expected_path = tmp_path / ".liminate" / "contracts" / "codex-sess-1.limn" + assert str(expected_path) in ctx + # trigger must not create the contract file + assert not expected_path.exists() diff --git a/tests/test_contract_lifecycle.py b/tests/test_contract_lifecycle.py new file mode 100644 index 0000000..f7d80e5 --- /dev/null +++ b/tests/test_contract_lifecycle.py @@ -0,0 +1,342 @@ +"""Tests for helper/contract_lifecycle.py — the host-agnostic contract +lifecycle helper. Covers path resolution (default / XDG / override / +repo-forbidden), the safe-default consent gate, local-always persistence, +init-from-initial-content, and the no-session-id / no-consent / no-key +degradations. + +The helper is a single-file executable, not a package, so it is loaded by +path. Tests exercise the importable functions directly (pure logic) and the +CLI via subprocess (end-to-end behaviour and exit codes). +""" + +import importlib.util +import json +import os +import subprocess +import sys +import pathlib + +import pytest + +REPO = pathlib.Path(__file__).resolve().parent.parent +HELPER_PATH = REPO / "helper" / "contract_lifecycle.py" + + +def load_helper(): + spec = importlib.util.spec_from_file_location("contract_lifecycle", HELPER_PATH) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def mod(): + return load_helper() + + +def run_cli(args, env=None, input_text=None): + """Invoke the helper as a subprocess. Returns (rc, stdout, stderr).""" + full_env = dict(os.environ) + if env: + full_env.update(env) + proc = subprocess.run( + [sys.executable, str(HELPER_PATH), *args], + capture_output=True, text=True, env=full_env, input=input_text, + ) + return proc.returncode, proc.stdout, proc.stderr + + +# -------------------------------------------------------------------------- +# Operation 1: path +# -------------------------------------------------------------------------- + +def test_path_defaults_under_home_dot_liminate(mod, tmp_path): + env = {"HOME": str(tmp_path), "XDG_DATA_HOME": "", "LIMINATE_CONTRACTS_DIR": ""} + d = mod.resolve_contracts_dir(env=env) + assert d == (tmp_path / ".liminate" / "contracts") + + +def test_path_honours_xdg_data_home(mod, tmp_path): + xdg = tmp_path / "xdg" + env = {"HOME": str(tmp_path), "XDG_DATA_HOME": str(xdg), "LIMINATE_CONTRACTS_DIR": ""} + d = mod.resolve_contracts_dir(env=env) + assert d == (xdg / "liminate" / "contracts") + + +def test_path_honours_explicit_override(mod, tmp_path): + override = tmp_path / "custom" / "contracts" + env = {"HOME": str(tmp_path), "XDG_DATA_HOME": str(tmp_path / "xdg"), + "LIMINATE_CONTRACTS_DIR": str(override)} + d = mod.resolve_contracts_dir(env=env) + assert d == override + + +def test_path_inside_git_tree_falls_back_to_home(mod, tmp_path): + # An override that points inside a git working tree must be refused. + repo = tmp_path / "somerepo" + (repo / ".git").mkdir(parents=True) + inside = repo / "contracts" + env = {"HOME": str(tmp_path), "XDG_DATA_HOME": "", + "LIMINATE_CONTRACTS_DIR": str(inside)} + d = mod.resolve_contracts_dir(env=env) + assert d == (tmp_path / ".liminate" / "contracts") + + +def test_path_cli_prints_path_outside_repo_and_creates_dir(tmp_path): + env = {"HOME": str(tmp_path), "XDG_DATA_HOME": "", "LIMINATE_CONTRACTS_DIR": ""} + rc, out, err = run_cli(["path", "--session-id", "sess-1"], env=env) + assert rc == 0, err + printed = pathlib.Path(out.strip()) + assert printed == tmp_path / ".liminate" / "contracts" / "sess-1.limn" + assert printed.parent.is_dir() + + +def test_path_cli_run_from_inside_repo_resolves_outside_it(): + # Default resolution (no override) from inside the actual repo tree must + # still land under the real $HOME, never inside the repo. + rc, out, err = run_cli(["path", "--session-id", "sess-x"]) + assert rc == 0, err + printed = pathlib.Path(out.strip()) + assert REPO not in printed.parents + assert printed.name == "sess-x.limn" + + +def test_path_dir_created_mode_0700(tmp_path): + env = {"HOME": str(tmp_path), "XDG_DATA_HOME": "", "LIMINATE_CONTRACTS_DIR": ""} + rc, out, err = run_cli(["path", "--session-id", "sess-2"], env=env) + assert rc == 0, err + d = tmp_path / ".liminate" / "contracts" + assert oct(d.stat().st_mode & 0o777) == "0o700" + + +def test_path_generates_session_id_when_absent(tmp_path): + env = {"HOME": str(tmp_path), "XDG_DATA_HOME": "", "LIMINATE_CONTRACTS_DIR": ""} + rc, out, err = run_cli(["path"], env=env) + assert rc == 0, err + printed = pathlib.Path(out.strip()) + assert printed.suffix == ".limn" + assert len(printed.stem) > 0 + + +# -------------------------------------------------------------------------- +# Operation 2: init +# -------------------------------------------------------------------------- + +def test_init_no_payload_produces_valid_bare_contract(tmp_path): + env = {"HOME": str(tmp_path), "XDG_DATA_HOME": "", "LIMINATE_CONTRACTS_DIR": ""} + rc, out, err = run_cli(["init", "--session-id", "bare-1"], env=env) + assert rc == 0, err + contract = (tmp_path / ".liminate" / "contracts" / "bare-1.limn") + assert contract.is_file() + text = contract.read_text() + # standard lists are declared (declare-before-add invariant) + assert 'remember a list called tracked-decisions' in text + assert 'remember a list called open-questions' in text + + +def test_init_with_payload_lands_every_item(tmp_path): + env = {"HOME": str(tmp_path), "XDG_DATA_HOME": "", "LIMINATE_CONTRACTS_DIR": ""} + payload = { + "sources": [{"name": "spec-doc", "text": "the spec says forty-two"}], + "decisions": ["use-the-helper"], + "open_questions": ["which-host-first"], + } + rc, out, err = run_cli( + ["init", "--session-id", "full-1", "--from", "-"], + env=env, input_text=json.dumps(payload), + ) + assert rc == 0, err + text = (tmp_path / ".liminate" / "contracts" / "full-1.limn").read_text() + assert 'remember a source called spec-doc with "the spec says forty-two"' in text + assert 'add "use-the-helper" to tracked-decisions' in text + assert 'add "which-host-first" to open-questions' in text + # lists declared before the adds + assert text.index('remember a list called tracked-decisions') < text.index('add "use-the-helper"') + + +def test_init_written_contract_validates_under_liminate(mod, tmp_path): + payload = { + "sources": [{"name": "readme", "text": "Liminate has 58 reserved words."}], + "decisions": ["bounded-vocabulary"], + "open_questions": ["pack-loader-design"], + } + src = mod.build_contract(payload, session_id="val-1") + ok, errors = mod.validate_contract(src) + assert ok, f"contract did not validate: {errors}" + + +def test_init_bare_contract_validates_under_liminate(mod): + src = mod.build_contract({}, session_id="val-bare") + ok, errors = mod.validate_contract(src) + assert ok, f"bare contract did not validate: {errors}" + + +def test_build_contract_quotes_are_escaped_safely(mod): + # A source text containing a double quote must not break the .limn. + payload = {"sources": [{"name": "q", "text": 'he said hi'}]} + src = mod.build_contract(payload, session_id="q-1") + ok, errors = mod.validate_contract(src) + assert ok, errors + + +# -------------------------------------------------------------------------- +# Operation 3: save — the consent gate +# -------------------------------------------------------------------------- + +def test_decide_unattended_never_uploads(mod): + d = mod.decide_upload(attended=False, consent_upload=False, + key_present=True, sensitive=False) + assert d == mod.UploadDecision.LOCAL_ONLY_UNATTENDED + + +def test_decide_unattended_never_uploads_even_with_consent(mod): + d = mod.decide_upload(attended=False, consent_upload=True, + key_present=True, sensitive=False) + assert d == mod.UploadDecision.LOCAL_ONLY_UNATTENDED + + +def test_decide_attended_no_consent_needs_confirmation(mod): + d = mod.decide_upload(attended=True, consent_upload=False, + key_present=True, sensitive=False) + assert d == mod.UploadDecision.NEEDS_CONFIRMATION + + +def test_decide_attended_consent_uploads(mod): + d = mod.decide_upload(attended=True, consent_upload=True, + key_present=True, sensitive=False) + assert d == mod.UploadDecision.UPLOAD + + +def test_decide_attended_consent_but_no_key_stays_local(mod): + d = mod.decide_upload(attended=True, consent_upload=True, + key_present=False, sensitive=False) + assert d == mod.UploadDecision.LOCAL_ONLY_NO_KEY + + +def test_save_unattended_persists_locally_and_never_posts(mod, tmp_path, monkeypatch): + posted = [] + monkeypatch.setattr(mod, "_upload", lambda *a, **k: posted.append(a) or "NOPE") + env = {"HOME": str(tmp_path), "XDG_DATA_HOME": "", "LIMINATE_CONTRACTS_DIR": "", + "RECEIPTS_API_KEY": "secret-key-present"} + contract = 'remember a string called source-state with "verified"\n' + result = mod.do_save(session_id="save-1", env=env, attended=None, + consent_upload=False, contract_src=contract, isatty=False) + assert posted == [] # never uploaded + local = tmp_path / ".liminate" / "contracts" / "save-1.limn" + assert local.is_file() + assert local.read_text() == contract + assert result["uploaded"] is False + + +def test_save_cli_unattended_no_upload_even_with_key(tmp_path): + env = {"HOME": str(tmp_path), "XDG_DATA_HOME": "", "LIMINATE_CONTRACTS_DIR": "", + "RECEIPTS_API_KEY": "secret-key-present"} + contract = 'remember a string called source-state with "verified"\n' + rc, out, err = run_cli( + ["save", "--session-id", "save-cli-1", "--from", "-", "--attended", "false"], + env=env, input_text=contract, + ) + assert rc == 0, err + assert "https://receipts" not in out # no permalink => no upload happened + assert (tmp_path / ".liminate" / "contracts" / "save-cli-1.limn").is_file() + + +def test_save_cli_attended_without_consent_stops_at_gate(tmp_path): + env = {"HOME": str(tmp_path), "XDG_DATA_HOME": "", "LIMINATE_CONTRACTS_DIR": "", + "RECEIPTS_API_KEY": "secret-key-present"} + contract = 'remember a string called source-state with "verified"\n' + rc, out, err = run_cli( + ["save", "--session-id", "save-cli-2", "--from", "-", "--attended", "true"], + env=env, input_text=contract, + ) + # distinct "needs confirmation" exit code, no upload + assert rc == mod_needs_confirmation_code() + assert "https://receipts" not in out + assert (tmp_path / ".liminate" / "contracts" / "save-cli-2.limn").is_file() + + +def mod_needs_confirmation_code(): + return load_helper().NEEDS_CONFIRMATION_EXIT + + +def test_save_only_uploads_on_attended_plus_consent(mod, tmp_path, monkeypatch): + posted = [] + monkeypatch.setattr(mod, "_upload", + lambda src, key, **k: posted.append((src, key)) or + "https://receipts.liminate.dev/c/TESTID") + env = {"HOME": str(tmp_path), "XDG_DATA_HOME": "", "LIMINATE_CONTRACTS_DIR": "", + "RECEIPTS_API_KEY": "secret-key-present"} + contract = 'remember a string called source-state with "verified"\n' + result = mod.do_save(session_id="save-up", env=env, attended=True, + consent_upload=True, contract_src=contract, isatty=False) + assert len(posted) == 1 + assert result["uploaded"] is True + assert result["permalink"] == "https://receipts.liminate.dev/c/TESTID" + + +def test_save_no_key_never_blocks_local_persist(mod, tmp_path, monkeypatch): + posted = [] + monkeypatch.setattr(mod, "_upload", lambda *a, **k: posted.append(a) or "NOPE") + env = {"HOME": str(tmp_path), "XDG_DATA_HOME": "", "LIMINATE_CONTRACTS_DIR": ""} + contract = 'remember a string called source-state with "verified"\n' + result = mod.do_save(session_id="nokey", env=env, attended=True, + consent_upload=True, contract_src=contract, isatty=False) + assert posted == [] # no key => cannot upload + assert (tmp_path / ".liminate" / "contracts" / "nokey.limn").is_file() + assert result["uploaded"] is False + + +# -------------------------------------------------------------------------- +# Degradations +# -------------------------------------------------------------------------- + +def test_generated_session_id_recorded_and_printed(tmp_path): + env = {"HOME": str(tmp_path), "XDG_DATA_HOME": "", "LIMINATE_CONTRACTS_DIR": ""} + rc, out, err = run_cli(["init"], env=env) + assert rc == 0, err + # the generated id is printed (so the caller can reuse it) and the file exists + files = list((tmp_path / ".liminate" / "contracts").glob("*.limn")) + assert len(files) == 1 + sid = files[0].stem + assert sid in out + + +def test_sensitivity_scan_flags_credentials(mod): + src = 'remember a source called creds with "password = hunter2"\n' + assert mod.scan_sensitive(src) is True + + +def test_sensitivity_scan_clears_benign_content(mod): + src = 'remember a source called readme with "Liminate has 58 reserved words."\n' + assert mod.scan_sensitive(src) is False + + +# -------------------------------------------------------------------------- +# Hygiene: no coupling to any non-public tool, stdlib-only +# -------------------------------------------------------------------------- + +def test_helper_does_not_reference_domain_loader(): + text = HELPER_PATH.read_text().lower() + assert "domain-loader" not in text + assert "domain_loader" not in text + + +def test_helper_imports_are_stdlib_only(): + import re + lines = HELPER_PATH.read_text().splitlines() + third_party = [] + stdlib_ok = { + "argparse", "json", "os", "sys", "pathlib", "secrets", "subprocess", + "urllib", "enum", "dataclasses", "typing", "re", "shutil", "stat", + "__future__", + } + for ln in lines: + m = re.match(r"^(?:import|from)\s+([a-zA-Z_][\w.]*)", ln.strip()) + if not m: + continue + top = m.group(1).split(".")[0] + if top == "liminate": + continue # guarded optional import + if top not in stdlib_ok: + third_party.append(top) + assert third_party == [], f"unexpected non-stdlib imports: {third_party}" diff --git a/tests/test_contract_session_init.sh b/tests/test_contract_session_init.sh index a9a451a..163058e 100755 --- a/tests/test_contract_session_init.sh +++ b/tests/test_contract_session_init.sh @@ -15,9 +15,9 @@ printf '%s\n' "$out" | grep -q '"additionalContext"' && a=1 || a=0 check "$a" 1 "emits additionalContext when session_id present" printf '%s\n' "$out" | grep -q 'abc123-def' && b=1 || b=0 check "$b" 1 "additionalContext mentions the session_id" -[ -d "$TMP/.claude/contracts" ] && c=1 || c=0 -check "$c" 1 "creates the contracts directory" -[ -f "$TMP/.claude/contracts/abc123-def.limn" ] && d=1 || d=0 +[ -d "$TMP/.liminate/contracts" ] && c=1 || c=0 +check "$c" 1 "creates the contracts directory (via the helper)" +[ -f "$TMP/.liminate/contracts/abc123-def.limn" ] && d=1 || d=0 check "$d" 0 "does NOT create the contract file (trust model)" # Case B: session_id absent @@ -39,7 +39,7 @@ EOF ) [ -z "$out4" ] && g=1 || g=0 check "$g" 1 "escape-id emits no output (fresh HOME)" -[ ! -d "$TMP2/.claude/contracts" ] && h=1 || h=0 -check "$h" 1 "escape-id does NOT create contracts dir (guard precedes mkdir)" +[ ! -d "$TMP2/.liminate/contracts" ] && h=1 || h=0 +check "$h" 1 "escape-id does NOT create contracts dir (guard precedes helper)" exit "$fail"