Skip to content

Commit bbdf821

Browse files
committed
Add --catalog json discovery contract, AGENTS.md, llms.txt (v0.1.7)
Wire extract-cli fully into the contract-ops suite's agent conventions: - extract --catalog json: the suite-wide discovery contract {name, bin, version, description, commands[], exitCodes}, mirroring nda-review-cli/docx2pdf/sign. Agents call it at startup instead of hardcoding commands/flags. A test asserts it never drifts from the real argparse parser. Added to bash/zsh completion flag lists. - AGENTS.md: agent contract in the canonical section order (output contract / exit codes / discovery / failure -> recovery). - llms.txt: machine-readable tool summary at the repo root. - pyproject: suite-standard keywords (contract-ops, agent-first, legal-tech); ship AGENTS.md + llms.txt in the sdist. - README opens with Run this / Where to go next; --catalog documented in README + docs/INTEROP.md. No schema or extraction-logic change (extractor_version unchanged). mypy --strict clean; 110 passed / 2 skipped; spec-check OK.
1 parent 6ab861c commit bbdf821

8 files changed

Lines changed: 412 additions & 13 deletions

File tree

AGENTS.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Agents
2+
3+
Drive `extract-cli` from an LLM agent or non-interactive client. Same agent
4+
contract as the rest of the contract-ops suite: a stable machine-readable
5+
catalog, JSON on stdout, humans on stderr, and a small documented exit-code set.
6+
7+
`extract-cli` is the suite's **open-loop front door**: hand it any contract
8+
(`.md` / `.txt` / `.html` / `.docx` / `.pdf`, yours or a counterparty's) and it
9+
returns structured JSON the rest of the pipeline can consume. Every field
10+
carries a `confidence` and a `source`**verify, don't trust**.
11+
12+
## Output contract
13+
14+
- **Success**: a single JSON object to **stdout**, exit `0`. This is the machine
15+
payload; it's the default (no `--json` needed, though `--json` forces it).
16+
- Every extracted scalar is the envelope `{value, confidence, source}`;
17+
"not found" is the canonical `{value: null, confidence: 0.0, source: "none"}`.
18+
Lists (`parties`, `clauses`, `defined_terms`) carry per-item
19+
`confidence`/`source`. `source ∈ {deterministic, llm, none}`.
20+
- `_meta` records `extractor_version`, `tiers_used`, and `llm_used`.
21+
- The output shape is locked by a JSON Schema —
22+
[`docs/spec/extract-output.schema.json`](docs/spec/extract-output.schema.json),
23+
also printed by `extract schema`. Validate against it instead of trusting
24+
field shapes by convention. (Note: the `--no-confidence` projection is a
25+
reduced convenience view, **not** governed by the schema.)
26+
- **stderr** is for humans only: `--why` rationale, warnings, and errors.
27+
stdout stays clean JSON even under `--why`.
28+
- **Failure**: a one-line `error: <message>` on **stderr**, non-zero exit.
29+
The error shape is a flat string (the suite is not uniform on error-object
30+
shape) — **branch on the exit code, never on the human-readable message.**
31+
32+
## Exit codes
33+
34+
| Code | Meaning |
35+
|------|---------|
36+
| `0` | Success. |
37+
| `1` | Low-signal document — no high-signal fields (parties/clauses/dates) could be extracted; e.g. a scanned/image-only or empty file. A **finding**, not a crash: valid JSON is still emitted on stdout. |
38+
| `2` | Bad usage / user-actionable error (unreadable path, bad flag value, unsupported completion shell). |
39+
40+
## Discovery
41+
42+
Never hardcode command or flag names — call the catalog at startup:
43+
44+
```bash
45+
extract --catalog json # {name, bin, version, description, commands[], exitCodes}
46+
```
47+
48+
`--catalog json` is the suite-wide discovery contract (parallel to
49+
`nda-review-cli --catalog json`, `docx2pdf --catalog json`,
50+
`sign --catalog json`). It is **complete, accurate, and stable across minor
51+
versions** — a test asserts it never drifts from the real parser.
52+
53+
Tool-specific discovery extras:
54+
55+
```bash
56+
extract schema # the output JSON Schema (the cross-CLI data contract)
57+
extract fields # extractable fields and the tier that produces each
58+
extract fields --json # ...as JSON
59+
extract demo # run on a bundled fixture (zero-config first run)
60+
extract --version
61+
```
62+
63+
## Failure → recovery
64+
65+
| Symptom | Diagnose | Recover |
66+
|---|---|---|
67+
| Exit `1`, warning "no high-signal fields" | The document is likely scanned/image-only or has no recognizable structure. JSON is still emitted. | OCR the source first, or feed a text/`.docx`/`.md` version. The empty-but-valid JSON is safe to pass downstream. |
68+
| Exit `2`, `error: ...` | `extract --catalog json` (or `extract <cmd> --help`) for the real surface. | Fix the path/flag and retry. |
69+
| `clauses: []` on a real contract | The `.docx` likely auto-numbers via Word's numbering with no heading style (its numbers live only in `numbering.xml`), so the deterministic cascade sees no headings. | Re-run with `--llm` (opt-in): when no clauses are detected, the LLM is asked for section headings, normalized through the same canonical vocabulary and emitted with `tier: "llm"`, `source: "llm"`, and a modest confidence. Requires `~/.config/contract-ops/llm.json`. |
70+
| Low-fidelity `.docx`/`.pdf` text | The stdlib best-effort reader ran (no extras installed). | `pip install "extract-cli[docx]"` and/or `"extract-cli[pdf]"` for higher fidelity. The core always works without them. |
71+
| `--llm` only printed a warning | No LLM config found. | Copy [`config/llm.json.example`](config/llm.json.example) to `~/.config/contract-ops/llm.json`. Without it, deterministic output is still returned in full. |
72+
73+
## Recommended usage
74+
75+
```bash
76+
# Inspect any contract's structure, one tool for five formats.
77+
extract counterparty.docx | jq '{parties: [.parties[].name],
78+
governing_law: .governing_law.value, clauses: [.clauses[].canonical_title]}'
79+
80+
# Gate a workflow on extraction confidence (non-zero exit if any clause is shaky).
81+
extract draft.docx | jq -e '.clauses | all(.confidence > 0.7)' && echo ok
82+
```
83+
84+
The integration contract is the **output schema** + the **shared canonical
85+
clause vocabulary** (`canonical_title` values match what `template-vault-cli`
86+
detects and `nda-review-cli` keys policy on) — not per-tool flags. See
87+
[`docs/INTEROP.md`](docs/INTEROP.md).

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,25 @@ to [Semantic Versioning](https://semver.org/). Per the suite convention
66
(see [`docs/INTEROP.md`](docs/INTEROP.md)), **backward-incompatible changes to
77
the output schema require a major version bump**; new optional fields are minor.
88

9+
## [0.1.7] - 2026-05-22
10+
11+
### Added
12+
- **`extract --catalog json` — the suite's shared discovery contract.** Emits
13+
`{name, bin, version, description, commands[], exitCodes}` (mirroring
14+
`nda-review-cli --catalog json` / `docx2pdf --catalog json` /
15+
`sign --catalog json`) so agents can learn every command and flag at startup
16+
instead of hardcoding them. A test asserts the catalog never drifts from the
17+
real argparse parser. Also added to the bash/zsh completion flag lists.
18+
- **`AGENTS.md`** — the agent contract in the suite's canonical section order
19+
(output contract / exit codes / discovery / failure → recovery).
20+
- **`llms.txt`** — machine-readable tool summary at the repo root.
21+
22+
### Changed
23+
- Packaging: added the suite-standard keywords (`contract-ops`, `agent-first`,
24+
`legal-tech`); README now opens with `## Run this` / `## Where to go next`;
25+
`--catalog json` documented in the README and `docs/INTEROP.md`. No schema or
26+
extraction-logic change (`extractor_version` unchanged).
27+
928
## [0.1.6] - 2026-05-21
1029

1130
### Docs

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,30 @@ ingest (extract) → review → diff → convert → sign
2323
^you are here
2424
```
2525

26+
## Run this
27+
28+
```bash
29+
pipx run extract-cli demo # zero-config: extract a bundled NDA → structured JSON
30+
# or, installed: pip install extract-cli && extract demo
31+
```
32+
33+
That prints the full output contract — parties, dates, term, governing law, and
34+
a clause map normalized onto the suite's canonical vocabulary — for a bundled
35+
fixture, with no setup and no network. Point it at your own file with
36+
`extract path/to/contract.docx`.
37+
38+
## Where to go next
39+
40+
- **New here?** Keep reading — [What it does](#what-it-does) and
41+
[The two extraction tiers](#the-two-extraction-tiers).
42+
- **Driving it from an agent?** See [`AGENTS.md`](AGENTS.md) and call
43+
`extract --catalog json` at startup to discover commands/flags. The output
44+
shape is locked by [`docs/spec/extract-output.schema.json`](docs/spec/extract-output.schema.json).
45+
- **Wiring it into the pipeline?** See [`docs/INTEROP.md`](docs/INTEROP.md) — the
46+
contract is the output schema + the shared clause vocabulary.
47+
- **Contributing / building a sibling CLI?** [`CONTRIBUTING.md`](CONTRIBUTING.md)
48+
and [ARCHITECTURE.md](ARCHITECTURE.md).
49+
2650
## What it does
2751

2852
Give it a contract in **`.md` / `.txt` / `.html`** (native), **`.docx`**, or
@@ -77,6 +101,7 @@ for them.
77101

78102
```bash
79103
extract <path> # parse a document → structured JSON on stdout (default)
104+
extract --catalog json # machine-readable catalog of commands/flags (agents call at startup)
80105
extract schema # print the output JSON Schema (the cross-CLI contract)
81106
extract fields # list extractable fields and their tier
82107
extract demo # run on a bundled fixture and show the narrative
@@ -87,6 +112,7 @@ extract completion bash # emit a shell-completion script (bash|zsh)
87112

88113
| Flag | Meaning |
89114
|---|---|
115+
| `--catalog json` | Print the machine-readable command/flag catalog and exit (the suite discovery contract; agents call this at startup) |
90116
| `--llm` | Opt-in LLM enrichment of fuzzy fields (off by default) |
91117
| `--fields a,b,c` | Emit only a subset of top-level fields (e.g. `parties,clauses`) |
92118
| `--format json\|table` | Output format (default `json`) |

docs/INTEROP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ only stdlib `urllib`, so there is no runtime dependency.
118118
| Concern | Convention |
119119
|---|---|
120120
| Primary result | **stdout** (JSON payload, default) |
121+
| Discovery | `extract --catalog json` (commands/flags, the suite contract) + `extract schema` / `extract fields --json` |
121122
| `--why`, warnings, errors | **stderr** |
122123
| `--why` envelope | plain-text `[why] <header>` block (as in template-vault-cli / draft-cli) |
123124
| Quiet | `-q` / `--silent` / `--quiet` aliases |

extract_cli.py

Lines changed: 130 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
from pathlib import Path
4444
from typing import Any, Dict, List, Optional, Tuple
4545

46-
__version__ = "0.1.6"
46+
__version__ = "0.1.7"
4747

4848
# Bumped independently of the package version when the *extraction logic*
4949
# changes in a way downstream consumers should notice. Embedded in `_meta`.
@@ -995,7 +995,9 @@ def _read_docx_stdlib(raw: bytes) -> str:
995995
paras: List[str] = []
996996
# iter over w:p in document order (includes paragraphs inside table cells).
997997
for p in root.iter(w + "p"):
998-
style = _docx_paragraph_style(p.find(w + "pPr"), w)
998+
ppr = p.find(w + "pPr")
999+
style = _docx_paragraph_style(ppr, w)
1000+
numbered = ppr is not None and ppr.find(w + "numPr") is not None
9991001
run_texts: List[str] = []
10001002
any_text = False
10011003
all_bold = True
@@ -1012,17 +1014,21 @@ def _read_docx_stdlib(raw: bytes) -> str:
10121014
if not line:
10131015
paras.append("")
10141016
continue
1015-
# Word heading styles carry the clause structure (their numbers are
1016-
# auto-generated, so absent from text). Emit them as H2 so the clause
1017-
# cascade's strongest tier detects them; keep any run-in body too.
1018-
if _is_heading_style(style):
1017+
# Clause structure in real Word contracts lives in heading STYLES
1018+
# (Heading1-9/Title) or auto-NUMBERED paragraphs (w:numPr) -- in both the
1019+
# visible number is auto-generated and absent from the text. Emit such a
1020+
# paragraph as an H2 heading (strongest cascade tier) when its lead looks
1021+
# like a heading; _docx_heading_title rejects full-sentence body items
1022+
# (e.g. deep numbered sub-points), so this stays conservative. Keep any
1023+
# run-in body as a following paragraph.
1024+
if _is_heading_style(style) or numbered:
10191025
title = _docx_heading_title(line)
10201026
if title is not None:
10211027
paras.append(f"## {title}")
10221028
if len(title) < len(line):
10231029
paras.append(line[len(title):].lstrip(" .:\t"))
10241030
continue
1025-
# Sentence carrying a heading style -> treat as ordinary body text.
1031+
# Not heading-like -> treat as ordinary body text.
10261032
if any_text and all_bold:
10271033
line = f"**{line}**"
10281034
paras.append(line)
@@ -1851,7 +1857,8 @@ def cmd_demo(args: argparse.Namespace) -> int:
18511857
_SUBCOMMANDS = ("schema", "fields", "demo", "completion")
18521858
_GLOBAL_FLAGS = (
18531859
"--json", "--why", "-q", "--silent", "--no-color", "--llm",
1854-
"--format", "--fields", "--no-confidence", "-V", "--version", "-h", "--help",
1860+
"--format", "--fields", "--no-confidence", "--catalog",
1861+
"-V", "--version", "-h", "--help",
18551862
)
18561863

18571864
_BASH_COMPLETION = r"""# extract-cli bash completion
@@ -1860,7 +1867,7 @@ def cmd_demo(args: argparse.Namespace) -> int:
18601867
local cur prev
18611868
cur="${COMP_WORDS[COMP_CWORD]}"
18621869
local cmds="schema fields demo completion"
1863-
local flags="--json --why -q --silent --no-color --llm --format --fields --no-confidence -V --version -h --help"
1870+
local flags="--json --why -q --silent --no-color --llm --format --fields --no-confidence --catalog -V --version -h --help"
18641871
if [ "$COMP_CWORD" -eq 1 ]; then
18651872
COMPREPLY=( $(compgen -W "${cmds}" -- "${cur}") $(compgen -f -- "${cur}") )
18661873
return 0
@@ -1886,7 +1893,7 @@ def cmd_demo(args: argparse.Namespace) -> int:
18861893
)
18871894
flags=(
18881895
'--json' '--why' '-q' '--silent' '--no-color' '--llm'
1889-
'--format' '--fields' '--no-confidence' '-V' '--version'
1896+
'--format' '--fields' '--no-confidence' '--catalog' '-V' '--version'
18901897
)
18911898
if (( CURRENT == 2 )); then
18921899
_describe 'command' cmds
@@ -1925,6 +1932,102 @@ def _completion_handler(argv: List[str]) -> int:
19251932
return 0
19261933

19271934

1935+
# ---------------------------------------------------------------------------
1936+
# Machine-readable catalog (`extract --catalog json`)
1937+
# ---------------------------------------------------------------------------
1938+
# The suite's shared discovery contract: agents call `extract --catalog json`
1939+
# at startup to learn every command and flag instead of hardcoding them
1940+
# (parallel to `nda-review-cli --catalog json`, `docx2pdf --catalog json`,
1941+
# `sign --catalog json`). It is a STABLE contract — keep it complete and
1942+
# accurate; `tests/test_cli.py` asserts it never drifts from the real parser.
1943+
1944+
1945+
def _flag(name: str, *, aliases: Optional[List[str]] = None, help: str = "",
1946+
default: Any = None, choices: Optional[List[str]] = None,
1947+
required: bool = False) -> JSON:
1948+
return {
1949+
"name": name,
1950+
"aliases": aliases if aliases is not None else [],
1951+
"help": help,
1952+
"required": required,
1953+
"default": default,
1954+
"choices": choices,
1955+
}
1956+
1957+
1958+
# Output flags shared by `extract` and `demo` (mirror _add_common_output_flags).
1959+
_CATALOG_OUTPUT_FLAGS: Tuple[JSON, ...] = (
1960+
_flag("--json", help="Force JSON output to stdout (the default)."),
1961+
_flag("--format", default="json", choices=["json", "table"],
1962+
help="Output format (default: json)."),
1963+
_flag("--no-confidence",
1964+
help="Omit confidence/source markers (reduced convenience view)."),
1965+
_flag("--why", help="Print a rationale block to stderr."),
1966+
_flag("--silent", aliases=["-q", "--quiet"],
1967+
help="Suppress non-error diagnostics (and the human table)."),
1968+
)
1969+
1970+
1971+
def build_catalog() -> JSON:
1972+
"""The machine-readable catalog emitted by `extract --catalog json`."""
1973+
extract_flags: List[JSON] = [
1974+
_flag("--llm",
1975+
help="Opt-in LLM enrichment of fuzzy fields (renewal mechanics, "
1976+
"obligations, and a clause-map fallback). Off by default; the "
1977+
"deterministic core is fully useful without it."),
1978+
_flag("--fields", default="",
1979+
help="Comma-separated subset of top-level fields to emit "
1980+
"(e.g. parties,clauses,governing_law)."),
1981+
*_CATALOG_OUTPUT_FLAGS,
1982+
]
1983+
return {
1984+
"name": CLI_NAME,
1985+
"bin": "extract",
1986+
"version": __version__,
1987+
"description": (
1988+
"Open-loop front door of the contract-ops CLI suite: ingest any contract "
1989+
"(.md/.txt/.html/.docx/.pdf) and emit structured JSON."
1990+
),
1991+
"commands": [
1992+
{
1993+
"name": "extract",
1994+
"help": "Parse a document into structured JSON. The default action: "
1995+
"`extract <path>` works without naming the subcommand. "
1996+
"Positional: path to a .md/.txt/.html/.docx/.pdf file.",
1997+
"flags": extract_flags,
1998+
},
1999+
{
2000+
"name": "schema",
2001+
"help": "Print the output JSON Schema — the cross-CLI output contract.",
2002+
"flags": [],
2003+
},
2004+
{
2005+
"name": "fields",
2006+
"help": "List extractable fields and the tier that produces each.",
2007+
"flags": [_flag("--json", help="Emit the field list as JSON.")],
2008+
},
2009+
{
2010+
"name": "demo",
2011+
"help": "Run extraction on a bundled fixture (zero-config first run).",
2012+
"flags": list(_CATALOG_OUTPUT_FLAGS),
2013+
},
2014+
{
2015+
"name": "completion",
2016+
"help": "Emit a shell-completion script. Positional: bash | zsh.",
2017+
"flags": [],
2018+
},
2019+
],
2020+
"exitCodes": {
2021+
"0": "success",
2022+
"1": "low-signal document — no high-signal fields (parties/clauses/dates) "
2023+
"could be extracted; e.g. a scanned/image-only or empty file. "
2024+
"A finding, not a crash.",
2025+
"2": "bad usage / user-actionable error (unreadable path, bad flag value, "
2026+
"unsupported completion shell).",
2027+
},
2028+
}
2029+
2030+
19282031
# ---------------------------------------------------------------------------
19292032
# Argument parsing + main
19302033
# ---------------------------------------------------------------------------
@@ -2025,6 +2128,23 @@ def main(argv: Optional[List[str]] = None) -> int:
20252128
if argv and argv[0] == "__complete":
20262129
return _completion_handler(argv[1:])
20272130

2131+
# `extract --catalog json` (or `--catalog=json`): the suite discovery
2132+
# contract. Intercepted before routing so it works as a bare global flag.
2133+
catalog_fmt: Optional[str] = None
2134+
for i, a in enumerate(argv):
2135+
if a == "--catalog":
2136+
catalog_fmt = argv[i + 1] if i + 1 < len(argv) else "json"
2137+
break
2138+
if a.startswith("--catalog="):
2139+
catalog_fmt = a.split("=", 1)[1] or "json"
2140+
break
2141+
if catalog_fmt is not None:
2142+
if catalog_fmt != "json":
2143+
_eprint(_red("error:") + f" unknown --catalog format {catalog_fmt!r}; supported: json")
2144+
return 2
2145+
print(json.dumps(build_catalog(), indent=2, ensure_ascii=True))
2146+
return 0
2147+
20282148
if not argv:
20292149
build_parser().print_help()
20302150
return 0

0 commit comments

Comments
 (0)