Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 78 additions & 3 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> --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.

Expand All @@ -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/<session_id>.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 <repo>/helper/contract_lifecycle.py path --session-id <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": "<repo>/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": "<repo>/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:
Expand Down
99 changes: 99 additions & 0 deletions helper/README.md
Original file line number Diff line number Diff line change
@@ -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 <operation> [options]
```

### `path` — resolve the canonical contract path

```bash
python3 helper/contract_lifecycle.py path [--session-id <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 <id>] [--from <payload.json|->]
```

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 <id> --from contract.limn

# attended, with explicit human consent: persists AND uploads to Receipts
python3 helper/contract_lifecycle.py save --session-id <id> --from contract.limn \
--attended true --consent upload \
[--label <text>] [--agent-id <id>] [--parent-id <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.
Loading
Loading