antemortem 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
antemortem/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Antemortem CLI — tooling for the Antemortem pre-implementation reconnaissance discipline.
2
+
3
+ See https://github.com/hibou04-ops/Antemortem for the methodology.
4
+ """
5
+
6
+ __version__ = "0.2.0"
antemortem/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m antemortem`."""
2
+
3
+ from antemortem.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
antemortem/api.py ADDED
@@ -0,0 +1,159 @@
1
+ """Anthropic Claude API wrapper for the classification step.
2
+
3
+ The ``run_classification`` function is the single boundary between the CLI
4
+ and the Anthropic SDK. Everything else in the package is framework-free and
5
+ testable without the network. Tests mock the ``client`` argument.
6
+
7
+ Caching strategy:
8
+ - The system prompt is rendered with ``cache_control={"type": "ephemeral"}``
9
+ on a top-level system text block. On Opus 4.7 this requires the prompt to
10
+ exceed the 4096-token cacheable-prefix minimum; we size ``SYSTEM_PROMPT``
11
+ accordingly and verify via ``usage.cache_read_input_tokens`` on each call.
12
+ - The user payload carries no caching control — it's volatile (different
13
+ traps each run). A second breakpoint on the files block is a v0.2.1
14
+ optimization when we add iterative-run UX.
15
+
16
+ Model and sampling:
17
+ - ``claude-opus-4-7`` is the only supported model in v0.2 (matches Antemortem
18
+ discipline + enforces a known behavioral contract for the prompt).
19
+ - No ``temperature`` / ``top_p`` / ``top_k`` — removed on Opus 4.7.
20
+ - ``thinking={"type": "adaptive"}`` — off by default on 4.7; explicitly
21
+ enabled because classification benefits from multi-file chain tracing.
22
+ - ``output_config={"effort": "high"}`` — minimum recommended for
23
+ intelligence-sensitive work per Anthropic's migration guide.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Any, Protocol
29
+
30
+ from antemortem.prompts import SYSTEM_PROMPT
31
+ from antemortem.schema import AntemortemOutput
32
+
33
+ MODEL = "claude-opus-4-7"
34
+ DEFAULT_MAX_TOKENS = 16000
35
+
36
+
37
+ class _MessagesNamespace(Protocol):
38
+ """Duck-typed interface for the subset of ``anthropic.messages`` we use.
39
+
40
+ Accepting a Protocol rather than the concrete class keeps the hot path
41
+ testable without importing ``anthropic`` in unit tests.
42
+ """
43
+
44
+ def parse(self, **kwargs: Any) -> Any: ... # noqa: D401
45
+
46
+
47
+ class _AnthropicLike(Protocol):
48
+ """Minimal interface we need from an Anthropic client instance."""
49
+
50
+ messages: _MessagesNamespace
51
+
52
+
53
+ def _build_user_content(
54
+ spec: str,
55
+ traps_table_md: str,
56
+ files: list[tuple[str, str]],
57
+ ) -> str:
58
+ """Render the user-turn payload as the prompt expects."""
59
+ file_blocks: list[str] = []
60
+ for path, content in sorted(files, key=lambda item: item[0]):
61
+ # Normalize path separators so cache keys don't drift on Windows.
62
+ normalized = path.replace("\\", "/")
63
+ file_blocks.append(f'<file path="{normalized}">\n{content}\n</file>')
64
+ files_section = "\n".join(file_blocks)
65
+ return (
66
+ f"<files>\n{files_section}\n</files>\n\n"
67
+ f"<spec>\n{spec.strip()}\n</spec>\n\n"
68
+ f"<traps>\n{traps_table_md.strip()}\n</traps>"
69
+ )
70
+
71
+
72
+ def _usage_to_dict(usage: Any) -> dict[str, int]:
73
+ """Extract token counts from the SDK's usage object into a plain dict."""
74
+ return {
75
+ "input_tokens": getattr(usage, "input_tokens", 0) or 0,
76
+ "output_tokens": getattr(usage, "output_tokens", 0) or 0,
77
+ "cache_creation_input_tokens": getattr(usage, "cache_creation_input_tokens", 0) or 0,
78
+ "cache_read_input_tokens": getattr(usage, "cache_read_input_tokens", 0) or 0,
79
+ }
80
+
81
+
82
+ def run_classification(
83
+ client: _AnthropicLike,
84
+ spec: str,
85
+ traps_table_md: str,
86
+ files: list[tuple[str, str]],
87
+ *,
88
+ max_tokens: int = DEFAULT_MAX_TOKENS,
89
+ ) -> tuple[AntemortemOutput, dict[str, int]]:
90
+ """Call Claude Opus 4.7 to classify traps against provided files.
91
+
92
+ Parameters
93
+ ----------
94
+ client:
95
+ An ``anthropic.Anthropic`` instance (or duck-typed equivalent for
96
+ tests).
97
+ spec:
98
+ Text of the change description from the antemortem document.
99
+ traps_table_md:
100
+ The pre-recon Traps table as a markdown string (raw, including
101
+ header row).
102
+ files:
103
+ List of ``(path, content)`` pairs. Sorted internally so cache
104
+ behavior is deterministic regardless of caller ordering.
105
+ max_tokens:
106
+ Upper bound on output tokens. Defaults to 16000 — the ~8k-output
107
+ typical classification response has ample headroom.
108
+
109
+ Returns
110
+ -------
111
+ A tuple ``(output, usage)``:
112
+
113
+ - ``output``: a validated ``AntemortemOutput`` instance.
114
+ - ``usage``: ``{"input_tokens", "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"}``.
115
+ """
116
+ user_content = _build_user_content(spec, traps_table_md, files)
117
+
118
+ response = client.messages.parse(
119
+ model=MODEL,
120
+ max_tokens=max_tokens,
121
+ thinking={"type": "adaptive"},
122
+ output_config={"effort": "high"},
123
+ system=[
124
+ {
125
+ "type": "text",
126
+ "text": SYSTEM_PROMPT,
127
+ "cache_control": {"type": "ephemeral"},
128
+ }
129
+ ],
130
+ messages=[{"role": "user", "content": user_content}],
131
+ output_format=AntemortemOutput,
132
+ )
133
+
134
+ stop_reason = getattr(response, "stop_reason", None)
135
+ if stop_reason == "refusal":
136
+ text = ""
137
+ for block in getattr(response, "content", []) or []:
138
+ if getattr(block, "type", None) == "text":
139
+ text = getattr(block, "text", "")
140
+ break
141
+ raise RuntimeError(
142
+ "Claude refused the classification request. This usually means the "
143
+ "spec or traps contain content flagged by safety filters. "
144
+ f"Response text: {text!r}"
145
+ )
146
+
147
+ parsed: AntemortemOutput | None = getattr(response, "parsed_output", None)
148
+ if parsed is None:
149
+ raise RuntimeError(
150
+ "SDK returned no parsed_output. This indicates a schema mismatch or "
151
+ "a malformed response. Raw stop_reason: "
152
+ f"{stop_reason!r}"
153
+ )
154
+ if not isinstance(parsed, AntemortemOutput):
155
+ # Some SDK versions may pass through a dict — coerce defensively.
156
+ parsed = AntemortemOutput.model_validate(parsed)
157
+
158
+ usage = _usage_to_dict(getattr(response, "usage", None))
159
+ return parsed, usage
@@ -0,0 +1,135 @@
1
+ """Citation parsing and on-disk verification.
2
+
3
+ The discipline requires every classification to carry a ``path:line`` or
4
+ ``path:line-line`` citation. This module parses those strings, resolves them
5
+ against a repository root, and verifies the cited line range lies within the
6
+ file's actual bounds. It does not execute any cited code — read-only checks.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ _CITATION_RE = re.compile(
16
+ r"""^\s*
17
+ (?P<path>[^\s:]+(?:[^\s:]+)*) # non-whitespace, non-colon path
18
+ :
19
+ (?P<start>\d+) # start line (required)
20
+ (?:-(?P<end>\d+))? # optional end line
21
+ \s*$""",
22
+ re.VERBOSE,
23
+ )
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ParsedCitation:
28
+ """A parsed citation reference."""
29
+
30
+ path: str
31
+ start: int
32
+ end: int # equals start for single-line citations
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class VerificationResult:
37
+ """Outcome of verifying a citation against disk."""
38
+
39
+ ok: bool
40
+ reason: str = ""
41
+ parsed: ParsedCitation | None = None
42
+
43
+
44
+ def parse_citation(citation: str) -> ParsedCitation | None:
45
+ """Parse a ``path:line`` or ``path:line-line`` citation string.
46
+
47
+ Returns ``None`` when the citation does not match the expected shape.
48
+ """
49
+ if not citation:
50
+ return None
51
+ # Normalize Windows backslashes to forward slashes for uniform handling.
52
+ normalized = citation.replace("\\", "/").strip()
53
+ match = _CITATION_RE.match(normalized)
54
+ if match is None:
55
+ return None
56
+
57
+ start = int(match.group("start"))
58
+ end_raw = match.group("end")
59
+ end = int(end_raw) if end_raw is not None else start
60
+
61
+ if start < 1 or end < start:
62
+ return None
63
+
64
+ return ParsedCitation(path=match.group("path"), start=start, end=end)
65
+
66
+
67
+ def count_lines(path: Path) -> int:
68
+ """Count newline-terminated lines in a file. Tolerates non-UTF-8 encodings."""
69
+ try:
70
+ with path.open("r", encoding="utf-8") as fh:
71
+ return sum(1 for _ in fh)
72
+ except UnicodeDecodeError:
73
+ with path.open("r", encoding="utf-8", errors="replace") as fh:
74
+ return sum(1 for _ in fh)
75
+
76
+
77
+ def verify_citation(citation: str, repo_root: Path) -> VerificationResult:
78
+ """Check that ``citation`` resolves to a real file:line range within ``repo_root``.
79
+
80
+ Returns a ``VerificationResult`` whose ``ok`` field signals whether the
81
+ citation is valid. When ``ok`` is false, ``reason`` explains the failure in
82
+ one line suitable for CLI output.
83
+ """
84
+ parsed = parse_citation(citation)
85
+ if parsed is None:
86
+ return VerificationResult(
87
+ ok=False,
88
+ reason=f"invalid format — expected 'path:line' or 'path:line-line', got {citation!r}",
89
+ )
90
+
91
+ file_path = (repo_root / parsed.path).resolve()
92
+ try:
93
+ root_resolved = repo_root.resolve()
94
+ except FileNotFoundError:
95
+ return VerificationResult(
96
+ ok=False,
97
+ reason=f"--repo directory does not exist: {repo_root}",
98
+ parsed=parsed,
99
+ )
100
+
101
+ # Refuse paths that escape the repo root via '..' traversal.
102
+ try:
103
+ file_path.relative_to(root_resolved)
104
+ except ValueError:
105
+ return VerificationResult(
106
+ ok=False,
107
+ reason=f"cited path escapes repo root: {parsed.path!r}",
108
+ parsed=parsed,
109
+ )
110
+
111
+ if not file_path.exists():
112
+ return VerificationResult(
113
+ ok=False,
114
+ reason=f"cited file does not exist: {parsed.path}",
115
+ parsed=parsed,
116
+ )
117
+ if not file_path.is_file():
118
+ return VerificationResult(
119
+ ok=False,
120
+ reason=f"cited path is not a regular file: {parsed.path}",
121
+ parsed=parsed,
122
+ )
123
+
124
+ line_count = count_lines(file_path)
125
+ if parsed.start > line_count or parsed.end > line_count:
126
+ return VerificationResult(
127
+ ok=False,
128
+ reason=(
129
+ f"line {parsed.start}-{parsed.end} out of range "
130
+ f"(file {parsed.path} has {line_count} lines)"
131
+ ),
132
+ parsed=parsed,
133
+ )
134
+
135
+ return VerificationResult(ok=True, parsed=parsed)
antemortem/cli.py ADDED
@@ -0,0 +1,44 @@
1
+ """Top-level Typer application for the antemortem CLI."""
2
+
3
+ import typer
4
+
5
+ from antemortem import __version__
6
+ from antemortem.commands import init as init_cmd
7
+ from antemortem.commands import lint as lint_cmd
8
+ from antemortem.commands import run as run_cmd
9
+
10
+ app = typer.Typer(
11
+ name="antemortem",
12
+ help="CLI for the Antemortem pre-implementation reconnaissance discipline.",
13
+ no_args_is_help=True,
14
+ add_completion=False,
15
+ )
16
+
17
+
18
+ def _version_callback(value: bool) -> None:
19
+ if value:
20
+ typer.echo(f"antemortem {__version__}")
21
+ raise typer.Exit()
22
+
23
+
24
+ @app.callback()
25
+ def _root(
26
+ version: bool = typer.Option( # noqa: B008
27
+ False,
28
+ "--version",
29
+ "-V",
30
+ callback=_version_callback,
31
+ is_eager=True,
32
+ help="Show version and exit.",
33
+ ),
34
+ ) -> None:
35
+ """Antemortem — scaffold, run, and lint pre-implementation recon documents."""
36
+
37
+
38
+ app.command(name="init", help="Scaffold a new antemortem document from a template.")(init_cmd.init)
39
+ app.command(name="run", help="Run LLM-assisted classification on an antemortem document.")(run_cmd.run)
40
+ app.command(name="lint", help="Validate an antemortem document's schema and citations.")(lint_cmd.lint)
41
+
42
+
43
+ if __name__ == "__main__":
44
+ app()
@@ -0,0 +1 @@
1
+ """Subcommand modules for the antemortem CLI."""
@@ -0,0 +1,84 @@
1
+ """`antemortem init` — scaffold a new antemortem document from a template."""
2
+
3
+ from datetime import date
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from antemortem.templates import get_template
9
+
10
+
11
+ def _build_frontmatter(
12
+ name: str,
13
+ today: str,
14
+ enhanced: bool,
15
+ ) -> str:
16
+ template_label = "enhanced" if enhanced else "basic"
17
+ return (
18
+ "---\n"
19
+ f"name: {name}\n"
20
+ f"date: {today}\n"
21
+ "scope: change-local\n"
22
+ "reversibility: high\n"
23
+ "status: draft\n"
24
+ f"template: {template_label}\n"
25
+ "---\n\n"
26
+ )
27
+
28
+
29
+ def init(
30
+ name: str = typer.Argument( # noqa: B008
31
+ ...,
32
+ help="Short name for the change (used as filename). Example: my-feature, auth-refactor.",
33
+ ),
34
+ enhanced: bool = typer.Option( # noqa: B008
35
+ False,
36
+ "--enhanced",
37
+ "-e",
38
+ help="Use the enhanced template (calibration dimensions, skeptic pass, decision-first output).",
39
+ ),
40
+ output_dir: Path = typer.Option( # noqa: B008
41
+ Path("antemortem"),
42
+ "--output-dir",
43
+ "-o",
44
+ help="Directory to create the document in. Created if missing.",
45
+ file_okay=False,
46
+ dir_okay=True,
47
+ ),
48
+ force: bool = typer.Option( # noqa: B008
49
+ False,
50
+ "--force",
51
+ "-f",
52
+ help="Overwrite existing document if present.",
53
+ ),
54
+ ) -> None:
55
+ """Create antemortem/<name>.md with YAML frontmatter and the chosen template."""
56
+ if not name or any(ch in name for ch in ("/", "\\", "..")):
57
+ typer.secho(
58
+ f"Invalid name {name!r}: use a simple identifier (letters, digits, hyphens).",
59
+ fg=typer.colors.RED,
60
+ err=True,
61
+ )
62
+ raise typer.Exit(code=2)
63
+
64
+ output_dir.mkdir(parents=True, exist_ok=True)
65
+ target = output_dir / f"{name}.md"
66
+
67
+ if target.exists() and not force:
68
+ typer.secho(
69
+ f"Refusing to overwrite {target} — pass --force to replace.",
70
+ fg=typer.colors.RED,
71
+ err=True,
72
+ )
73
+ raise typer.Exit(code=1)
74
+
75
+ today = date.today().isoformat()
76
+ content = _build_frontmatter(name, today, enhanced) + get_template(enhanced)
77
+ target.write_text(content, encoding="utf-8")
78
+
79
+ label = "enhanced" if enhanced else "basic"
80
+ typer.secho(f"Created {target} ({label} template)", fg=typer.colors.GREEN)
81
+ typer.secho(
82
+ "Next: fill in the spec, enumerate traps, then run `antemortem run` to classify.",
83
+ fg=typer.colors.BRIGHT_BLACK,
84
+ )
@@ -0,0 +1,160 @@
1
+ """`antemortem lint` — schema and citation validation.
2
+
3
+ Two tiers of checks:
4
+
5
+ 1. **Document schema**: YAML frontmatter parses, spec is non-empty, at least
6
+ one trap row is present, and at least one file is listed under the Recon
7
+ protocol. These apply to every antemortem document.
8
+ 2. **Classification verification**: if a companion ``<doc>.json`` audit
9
+ artifact exists (produced by ``antemortem run``), validate that every
10
+ input trap has a classification, every citation parses, and every
11
+ ``file:line`` points to an existing line within ``--repo``.
12
+
13
+ Exit 0 on pass, 1 on failure. Suitable for CI.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from pathlib import Path
20
+ from typing import NamedTuple
21
+
22
+ import typer
23
+ from pydantic import ValidationError
24
+
25
+ from antemortem.citations import verify_citation
26
+ from antemortem.parser import DocumentParseError, parse_document
27
+ from antemortem.schema import AntemortemDocument, AntemortemOutput
28
+
29
+
30
+ class LintResult(NamedTuple):
31
+ """Outcome of a lint pass."""
32
+
33
+ ok: bool
34
+ violations: list[str]
35
+ checked: int # number of checks that ran (passed or failed)
36
+
37
+
38
+ def _lint_document(doc: AntemortemDocument) -> list[str]:
39
+ """Pre-run schema checks that apply to every antemortem document."""
40
+ violations: list[str] = []
41
+ if not doc.spec.strip():
42
+ violations.append("spec: '## 1. The change' section is empty or missing")
43
+ if not doc.traps:
44
+ violations.append("traps: no rows parsed from the pre-recon Traps table")
45
+ if not doc.files_to_read:
46
+ violations.append("files_to_read: no files listed under 'Recon protocol'")
47
+ return violations
48
+
49
+
50
+ def _lint_artifact(
51
+ artifact_path: Path,
52
+ doc: AntemortemDocument,
53
+ repo_root: Path,
54
+ ) -> list[str]:
55
+ """Post-run checks that apply when a JSON audit artifact exists."""
56
+ violations: list[str] = []
57
+ try:
58
+ payload = json.loads(artifact_path.read_text(encoding="utf-8"))
59
+ except json.JSONDecodeError as exc:
60
+ return [f"{artifact_path.name}: invalid JSON — {exc.msg} at line {exc.lineno}"]
61
+ except OSError as exc:
62
+ return [f"{artifact_path.name}: cannot read — {exc}"]
63
+
64
+ try:
65
+ output = AntemortemOutput.model_validate(payload)
66
+ except ValidationError as exc:
67
+ return [f"{artifact_path.name}: schema validation failed — {exc.error_count()} issues"]
68
+
69
+ trap_ids = {t.id for t in doc.traps}
70
+ classified_ids = {c.id for c in output.classifications}
71
+
72
+ for missing in sorted(trap_ids - classified_ids):
73
+ violations.append(f"classification: missing for trap {missing}")
74
+
75
+ for c in output.classifications:
76
+ if c.id not in trap_ids:
77
+ violations.append(
78
+ f"classification {c.id}: refers to a trap id not present in the input table"
79
+ )
80
+ if c.label == "UNRESOLVED":
81
+ if c.citation is not None:
82
+ violations.append(
83
+ f"classification {c.id}: UNRESOLVED must have citation=null (got {c.citation!r})"
84
+ )
85
+ continue
86
+ if not c.citation:
87
+ violations.append(f"classification {c.id}: citation is required for {c.label}")
88
+ continue
89
+ result = verify_citation(c.citation, repo_root)
90
+ if not result.ok:
91
+ violations.append(f"classification {c.id}: {result.reason}")
92
+
93
+ for nt in output.new_traps:
94
+ result = verify_citation(nt.citation, repo_root)
95
+ if not result.ok:
96
+ violations.append(f"new_trap {nt.id}: {result.reason}")
97
+
98
+ return violations
99
+
100
+
101
+ def run_lint(
102
+ document: Path,
103
+ repo_root: Path,
104
+ ) -> LintResult:
105
+ """Programmatic lint entry point. Returns ``LintResult`` without exiting."""
106
+ try:
107
+ doc = parse_document(document)
108
+ except DocumentParseError as exc:
109
+ return LintResult(ok=False, violations=[f"document: {exc}"], checked=1)
110
+
111
+ violations = _lint_document(doc)
112
+
113
+ artifact_path = document.with_suffix(".json")
114
+ ran_artifact = artifact_path.exists()
115
+ if ran_artifact:
116
+ violations.extend(_lint_artifact(artifact_path, doc, repo_root))
117
+
118
+ checked = 3 + (1 if ran_artifact else 0) # spec, traps, files, (+ artifact)
119
+ return LintResult(ok=len(violations) == 0, violations=violations, checked=checked)
120
+
121
+
122
+ def lint(
123
+ document: Path = typer.Argument( # noqa: B008
124
+ ...,
125
+ help="Path to the antemortem document to validate.",
126
+ exists=True,
127
+ file_okay=True,
128
+ dir_okay=False,
129
+ readable=True,
130
+ ),
131
+ repo: Path = typer.Option( # noqa: B008
132
+ Path.cwd(),
133
+ "--repo",
134
+ "-r",
135
+ help="Repository root to resolve cited files against. Defaults to current directory.",
136
+ exists=True,
137
+ file_okay=False,
138
+ dir_okay=True,
139
+ readable=True,
140
+ ),
141
+ ) -> None:
142
+ """Validate schema and verify file:line citations."""
143
+ result = run_lint(document, repo)
144
+ artifact_note = (
145
+ " (schema + classifications)"
146
+ if document.with_suffix(".json").exists()
147
+ else " (schema only; no audit artifact)"
148
+ )
149
+
150
+ if result.ok:
151
+ typer.secho(
152
+ f"PASS — {document.name} validates clean{artifact_note}",
153
+ fg=typer.colors.GREEN,
154
+ )
155
+ raise typer.Exit(code=0)
156
+
157
+ typer.secho(f"FAIL — {document.name}{artifact_note}", fg=typer.colors.RED, err=True)
158
+ for v in result.violations:
159
+ typer.secho(f" - {v}", fg=typer.colors.RED, err=True)
160
+ raise typer.Exit(code=1)