Skip to content

feat: host-agnostic contract-lifecycle helper — one executable owns path, persistence, and consent across all hosts#10

Merged
rmichaelthomas merged 6 commits into
mainfrom
feat/contract-lifecycle-helper
May 29, 2026
Merged

feat: host-agnostic contract-lifecycle helper — one executable owns path, persistence, and consent across all hosts#10
rmichaelthomas merged 6 commits into
mainfrom
feat/contract-lifecycle-helper

Conversation

@rmichaelthomas
Copy link
Copy Markdown
Owner

The problem

Contract-lifecycle correctness — where a contract is written, how it is saved — was specified almost entirely as prose the model executed by hand, with exactly one piece of real enforcement: the Claude-Code-only SessionStart hook. Because the logic lived in prose everywhere except that one hook on that one host, the skill diverged across hosts:

  • Desktop / claude.ai: no hook; initiation depended on the model noticing.
  • Claude Code auto-mode: the save step blocked on an interactive consent prompt with no human to answer.
  • Codex: saved without asking, or skipped consent inconsistently.
  • All hosts: no single canonical write path, so contracts landed in temp dirs or the repo working tree (committed, then lost on cleanup).

What this does

Puts contract-lifecycle correctness into one host-agnostic Python executable, helper/contract_lifecycle.py (stdlib-only; the liminate import is guarded). Three operations:

  • path — resolve the canonical path ($LIMINATE_CONTRACTS_DIR > $XDG_DATA_HOME/liminate/contracts > $HOME/.liminate/contracts), refused inside a git working tree and created mode 0700. Never the repo.
  • init — build the contract from generic, source-agnostic initial content (sources / decisions / open questions / resume_state) or a bare template, validated through liminate.run(enter_phase2=False). Populate-at-start: the session-1 ground truth lands before the first claim. The content's producer is the caller's concern — the helper names no specific or non-public tool.
  • save — persist locally always, first; upload to Receipts only on the attended + explicit-consent path. Unattended never POSTs (never sends a credential); the consent gate is an exit code (10 = needs confirmation), never an input() call — so auto-mode never blocks.

The hook is reduced to a thin trigger: it delegates path/dir resolution to the helper and injects the resolved path into additionalContext. SessionStart output shape and the trust model (hook never creates the contract file) are unchanged.

The prose is rewired to invoke, not describe: SKILL.md's session-end steps, save-procedure.md, and starting-a-contract.md now say "call the helper." The Receipts payload table, lineage procedure, and Tier-1 save_receipt.py fallback remain as reference for what the helper does internally. SKILL.md's two-channel-protocol, vocabulary, and session-pack sections are byte-unchanged.

This realizes the locked v11 §25 principle: correctness is universal (the helper floor), agents are front doors (hooks for hosts that have them, instruction files for hosts that don't).

Safe-degradation table

Missing signal Behaviour
no --session-id helper generates one, records it in the contract, prints it
no consent signal / no TTY unattended → local-only, never uploads
--attended true, no --consent upload stops at the gate (exit 10); ask, then re-invoke
no $RECEIPTS_API_KEY local persistence still succeeds; only the upload path reports the key is unset
liminate not importable init validation degrades to a parse check; contract still written
resolved dir inside a git tree refused → falls back to $HOME/.liminate/contracts

Behaviour change worth review

The canonical path migrates from ~/.claude/contracts/ to ~/.liminate/contracts/ (the spec's §3 precedence). The hook injects the helper-resolved path, so the statusline stays consistent, but existing contracts under ~/.claude/contracts/ are not seen by future sessions unless LIMINATE_CONTRACTS_DIR=$HOME/.claude/contracts is set or they are moved.

Host validation (§8)

  • Claude Code: gate passed — hook fires, helper resolves the path, contract lands in ~/.liminate/contracts/, not the repo.
  • Desktop / claude.ai / Codex: per-host triggering is deferred and unverified. The helper itself runs identically anywhere python3 runs; what remains additive is the front-door wiring per host — the Codex hooks.json / config.toml trigger registration, and confirming the instruction-file path on Desktop/claude.ai (claude.ai degrades to emit-for-you-to-persist). Documented as the remaining additive work, not built here.

Tests

  • pytest tests/test_contract_lifecycle.py → 28 passed (path resolution, init bare+payload, consent gate, local-always, stdlib-only & no-domain-loader hygiene)
  • full pytest tests/ → 28 passed; tests/test_contract_session_init.sh → 8/8 ok

🤖 Generated with Claude Code

rmichaelthomas and others added 6 commits May 28, 2026 19:29
Single stdlib-only executable that owns where a contract is written, how
it is persisted, and whether it is uploaded — correctness universal by
construction, with the optional liminate import guarded.

- path: canonical path ($LIMINATE_CONTRACTS_DIR > $XDG_DATA_HOME >
  $HOME/.liminate/contracts), refused inside a git working tree.
- init: build the contract from generic, source-agnostic initial content
  (or a bare template), validated via liminate.run(enter_phase2=False).
- save: persist locally always; upload to Receipts only on the attended +
  explicit-consent path. Unattended never POSTs; consent gate is an exit
  code, never input(). All missing signals degrade safe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hook no longer constructs the path or mkdir's inline — it delegates
to `contract_lifecycle.py path` (which creates the dir, mode 0700, never
in a git tree) and injects the helper-resolved path into additionalContext.
SessionStart output shape is unchanged; the trust model (hook never creates
the contract file) is preserved. Hook test updated to the delegated
~/.liminate/contracts path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Session-end save, sensitivity check, and curl assembly become "call the
helper"; contract initiation becomes the helper's init (populate-at-start
from generic, source-agnostic content). The Receipts payload table, lineage
procedure, and Tier-1 save_receipt.py fallback remain as reference for what
the helper does internally / the manual fallback. Canonical path references
updated to ~/.liminate/contracts. SKILL two-channel, vocabulary, and
session-pack sections are byte-unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
28 tests over path resolution (default / XDG / override / repo-forbidden /
0700 / generated id), init (bare + payload, all-items-land, validates under
liminate, quote-safe), the consent gate (unattended never uploads, attended
needs explicit consent, no-key stays local), local-always persistence, and
hygiene (stdlib-only imports, no domain-loader coupling).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The trigger contract is agent-agnostic. Codex's SessionStart hook uses the
same I/O as Claude Code's — `session_id` on stdin, `hookSpecificOutput.
additionalContext` out — so the existing trigger script already satisfies it;
only the registration format differs. Add hooks/codex.hooks.json (a ready
~/.codex/hooks.json / config.toml registration pointing at the same script)
and reframe the script's header from Claude-Code-specific to the one
agent-agnostic trigger. New agents are supported by a registration, never by
changing the helper or the script.

Codex SessionStart schema per developers.openai.com/codex/hooks; conforms to
the documented contract but not yet exercised in a live Codex session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SKILL.md now specifies one trigger contract — resolve the path via the
helper, inject the open-contract rule, pass or generate a session id — with
Claude Code's settings.json hook and Codex's hooks.json/config.toml as two
concrete registrations of that single contract, framed as "any hook-capable
agent registers the same trigger in its own config format." The
instruction-file path (CLAUDE.md / AGENTS.md / equivalent) is the universal
fallback for agents without hooks. starting-a-contract.md points at the
contract. Two-channel, vocabulary, and session-pack sections unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@rmichaelthomas
Copy link
Copy Markdown
Owner Author

Update: per-host triggering generalized and Codex trigger built

The earlier "per-host triggering deferred" note is now narrowed. Two commits added:

  • eca8f6d feat — Codex SessionStart registration (hooks/codex.hooks.json) + generalized the trigger script. Codex's hook I/O turns out to be identical to Claude Code's (session_id on stdin → hookSpecificOutput.additionalContext out, per developers.openai.com/codex/hooks), so the existing script already satisfies it — only the registration format differs.
  • 6bcda9d docs — SKILL.md now documents one agent-agnostic trigger contract (resolve path via the helper, inject the open-contract rule, pass/generate a session id), with Claude Code's settings.json hook and Codex's hooks.json/config.toml as two concrete registrations of that single contract, plus the instruction-file (CLAUDE.md/AGENTS.md/equivalent) path as the universal fallback for hookless agents. A new agent is supported by one small registration — never by changing the helper or the script.

Still honestly unverified: the Codex registration conforms to the documented schema but has not been exercised in a live Codex session. That live host-validation remains the only deferred item.

Tests: full suite now 33 passed (28 helper + 5 trigger-contract); test_codex_registration.py::test_shared_trigger_handles_codex_payload proves the shared script handles a Codex-shaped payload unchanged.

@rmichaelthomas rmichaelthomas merged commit 8e9fa7a into main May 29, 2026
1 of 2 checks passed
@rmichaelthomas rmichaelthomas deleted the feat/contract-lifecycle-helper branch May 29, 2026 03:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant