pbom 0.1.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.
- pbom/__init__.py +30 -0
- pbom/canonical.py +157 -0
- pbom/chain.py +188 -0
- pbom/cli.py +245 -0
- pbom/commitment.py +80 -0
- pbom/emitter.py +574 -0
- pbom/exceptions.py +69 -0
- pbom/hashing.py +34 -0
- pbom/schema.py +467 -0
- pbom/skill/SKILL.md +162 -0
- pbom/validator.py +194 -0
- pbom-0.1.0.dist-info/METADATA +139 -0
- pbom-0.1.0.dist-info/RECORD +17 -0
- pbom-0.1.0.dist-info/WHEEL +5 -0
- pbom-0.1.0.dist-info/entry_points.txt +2 -0
- pbom-0.1.0.dist-info/licenses/LICENSE +201 -0
- pbom-0.1.0.dist-info/top_level.txt +1 -0
pbom/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Public API surface for the PBOM package."""
|
|
2
|
+
|
|
3
|
+
from .canonical import canonicalize_messages
|
|
4
|
+
from .exceptions import UnsupportedMessageShapeError
|
|
5
|
+
from pbom.emitter import PBOMEmitter
|
|
6
|
+
from pbom.exceptions import (
|
|
7
|
+
ChainCorruptedError,
|
|
8
|
+
InvalidCommitmentError,
|
|
9
|
+
PBOMError,
|
|
10
|
+
SchemaValidationError,
|
|
11
|
+
)
|
|
12
|
+
from pbom.schema import PBOMRecord
|
|
13
|
+
from pbom.validator import ValidationResult, validate_chain, validate_commitment
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ChainCorruptedError",
|
|
19
|
+
"InvalidCommitmentError",
|
|
20
|
+
"PBOMEmitter",
|
|
21
|
+
"PBOMError",
|
|
22
|
+
"PBOMRecord",
|
|
23
|
+
"UnsupportedMessageShapeError",
|
|
24
|
+
"SchemaValidationError",
|
|
25
|
+
"ValidationResult",
|
|
26
|
+
"__version__",
|
|
27
|
+
"canonicalize_messages",
|
|
28
|
+
"validate_chain",
|
|
29
|
+
"validate_commitment",
|
|
30
|
+
]
|
pbom/canonical.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Canonical message-shape bridge for PBOM emitter APIs.
|
|
2
|
+
|
|
3
|
+
This module provides one blessed helper for converting OpenAI-style message
|
|
4
|
+
lists into the two-field ``(system_prompt, user_prompt)`` tuple that
|
|
5
|
+
``PBOMEmitter.commit()`` and ``PBOMEmitter.record()`` require.
|
|
6
|
+
|
|
7
|
+
It exists because adopters (including cold agents instrumenting existing call
|
|
8
|
+
sites) independently invented their own message-bridging functions, which led
|
|
9
|
+
to inconsistent prompt canonicalization and non-comparable hashes across
|
|
10
|
+
implementations. Centralizing the conversion here ensures one canonical bridge
|
|
11
|
+
in the OSS package.
|
|
12
|
+
|
|
13
|
+
The v1.0.0 implementation is intentionally narrow and supports only a small
|
|
14
|
+
set of input shapes. Multi-turn conversations, tool calls, and non-string
|
|
15
|
+
content are explicitly deferred to a future version and will evolve based on
|
|
16
|
+
adopter feedback.
|
|
17
|
+
|
|
18
|
+
Versioning note: PBOM OSS follows semantic versioning. Supported input shapes
|
|
19
|
+
for this helper may expand in v1.x minor releases, but the output semantics
|
|
20
|
+
(the meaning of the returned ``(system_prompt, user_prompt)`` tuple) remain
|
|
21
|
+
stable within v1.x.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
|
|
28
|
+
from .exceptions import UnsupportedMessageShapeError
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("pbom")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def canonicalize_messages(messages: list[dict]) -> tuple[str, str]:
|
|
34
|
+
"""Convert an OpenAI-style message list to (system_prompt, user_prompt).
|
|
35
|
+
|
|
36
|
+
Accepted shapes (v1.0.0):
|
|
37
|
+
[{"role": "system", "content": "<str>"},
|
|
38
|
+
{"role": "user", "content": "<str>"}]
|
|
39
|
+
-> ("<system content>", "<user content>")
|
|
40
|
+
|
|
41
|
+
[{"role": "user", "content": "<str>"}]
|
|
42
|
+
-> ("", "<user content>")
|
|
43
|
+
|
|
44
|
+
Rejected shapes (raise UnsupportedMessageShapeError):
|
|
45
|
+
- Any list with more than one message per role
|
|
46
|
+
- Any list containing assistant, tool, or function messages
|
|
47
|
+
- Any message whose content is not a string (e.g., list of
|
|
48
|
+
content parts, images, tool calls)
|
|
49
|
+
- Any message with unknown keys beyond "role" and "content"
|
|
50
|
+
- Empty list
|
|
51
|
+
- Messages in the wrong order (user before system)
|
|
52
|
+
|
|
53
|
+
Multi-turn support, tool calls, and content-parts are deferred to
|
|
54
|
+
a future version. See the module docstring for context.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
messages: OpenAI-style message list.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
(system_prompt, user_prompt) tuple of strings. system_prompt
|
|
61
|
+
may be the empty string if no system message was provided.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
UnsupportedMessageShapeError: if the input does not match one
|
|
65
|
+
of the accepted shapes above. The error message names the
|
|
66
|
+
specific reason for rejection.
|
|
67
|
+
TypeError: if messages is not a list, or a message is not a
|
|
68
|
+
dict.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def _unsupported(
|
|
72
|
+
specific_reason: str,
|
|
73
|
+
*,
|
|
74
|
+
shape_detail: dict[str, object] | None = None,
|
|
75
|
+
) -> UnsupportedMessageShapeError:
|
|
76
|
+
return UnsupportedMessageShapeError(
|
|
77
|
+
(
|
|
78
|
+
"canonicalize_messages: "
|
|
79
|
+
f"{specific_reason}. "
|
|
80
|
+
"This shape is not supported in pbom v1.0.0. "
|
|
81
|
+
"For multi-turn conversations, tool calls, or non-string content, "
|
|
82
|
+
"concatenate the relevant content into a single string and pass "
|
|
83
|
+
"it directly to PBOMEmitter.commit() or .record(). "
|
|
84
|
+
"See docs/canonical.md."
|
|
85
|
+
),
|
|
86
|
+
shape_detail=shape_detail,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Top-level type and cardinality checks.
|
|
90
|
+
if not isinstance(messages, list):
|
|
91
|
+
raise TypeError(
|
|
92
|
+
f"canonicalize_messages: messages must be a list, got {type(messages).__name__}"
|
|
93
|
+
)
|
|
94
|
+
if len(messages) == 0:
|
|
95
|
+
raise _unsupported("empty message list")
|
|
96
|
+
if len(messages) > 2:
|
|
97
|
+
raise _unsupported(
|
|
98
|
+
"message list has more than two items",
|
|
99
|
+
shape_detail={"message_count": len(messages)},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Per-message structural validation.
|
|
103
|
+
validated: list[dict[str, str]] = []
|
|
104
|
+
for idx, msg in enumerate(messages):
|
|
105
|
+
if not isinstance(msg, dict):
|
|
106
|
+
raise TypeError(
|
|
107
|
+
"canonicalize_messages: each message must be a dict, "
|
|
108
|
+
f"got {type(msg).__name__} at index {idx}"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
unknown_keys = set(msg.keys()) - {"role", "content"}
|
|
112
|
+
if unknown_keys:
|
|
113
|
+
raise _unsupported(
|
|
114
|
+
f"message at index {idx} contains unknown keys {sorted(unknown_keys)}",
|
|
115
|
+
shape_detail={"unknown_keys": sorted(unknown_keys)},
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if "role" not in msg:
|
|
119
|
+
raise _unsupported(f"message at index {idx} is missing required key 'role'")
|
|
120
|
+
|
|
121
|
+
if "content" not in msg:
|
|
122
|
+
raise _unsupported(
|
|
123
|
+
f"message at index {idx} is missing required key 'content'"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
role = msg["role"]
|
|
127
|
+
content = msg["content"]
|
|
128
|
+
if role not in {"system", "user"}:
|
|
129
|
+
raise _unsupported(
|
|
130
|
+
f"message at index {idx} has unsupported role {role!r}",
|
|
131
|
+
shape_detail={"role": role},
|
|
132
|
+
)
|
|
133
|
+
if not isinstance(content, str):
|
|
134
|
+
raise _unsupported(
|
|
135
|
+
f"message at index {idx} has non-string content",
|
|
136
|
+
shape_detail={"content_type": type(content).__name__},
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
validated.append({"role": role, "content": content})
|
|
140
|
+
|
|
141
|
+
# Shape-specific acceptance/rejection.
|
|
142
|
+
if len(validated) == 1:
|
|
143
|
+
only = validated[0]
|
|
144
|
+
if only["role"] != "user":
|
|
145
|
+
raise _unsupported("single-message shape must be a user message")
|
|
146
|
+
return "", only["content"]
|
|
147
|
+
|
|
148
|
+
first, second = validated
|
|
149
|
+
if first["role"] == "system" and second["role"] == "user":
|
|
150
|
+
return first["content"], second["content"]
|
|
151
|
+
|
|
152
|
+
# Any other two-message combination is rejected:
|
|
153
|
+
# [user, system], [system, system], [user, user].
|
|
154
|
+
raise _unsupported(
|
|
155
|
+
"two-message shape must be exactly [system, user] in that order",
|
|
156
|
+
shape_detail={"roles": [first["role"], second["role"]]},
|
|
157
|
+
)
|
pbom/chain.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Merkle-style chain state for PBOM records.
|
|
2
|
+
|
|
3
|
+
The chain is per-directory, not per-application. Multiple ``application_id``
|
|
4
|
+
values writing to the same ``.pbom/`` directory share one chain.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from .exceptions import ChainCorruptedError
|
|
16
|
+
from .hashing import compute_sha256
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("pbom")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ChainState:
|
|
22
|
+
"""Thread-safe in-memory chain state with strict on-disk resume checks."""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
"""Initialize empty chain state guarded by an internal lock."""
|
|
26
|
+
self._lock = threading.Lock()
|
|
27
|
+
self._previous_entry_hash: Optional[str] = None
|
|
28
|
+
self._chain_sequence: int = 0
|
|
29
|
+
|
|
30
|
+
def next_chain_position(self) -> tuple[int, Optional[str]]:
|
|
31
|
+
"""Return next sequence number and current previous-entry hash.
|
|
32
|
+
|
|
33
|
+
All reads and writes of in-memory chain state happen under the lock.
|
|
34
|
+
"""
|
|
35
|
+
with self._lock:
|
|
36
|
+
self._chain_sequence += 1
|
|
37
|
+
return self._chain_sequence, self._previous_entry_hash
|
|
38
|
+
|
|
39
|
+
def update_chain(self, canonical_json: str) -> str:
|
|
40
|
+
"""Update chain with canonical JSON and return its SHA-256 hash."""
|
|
41
|
+
new_hash = compute_sha256(canonical_json)
|
|
42
|
+
with self._lock:
|
|
43
|
+
self._previous_entry_hash = new_hash
|
|
44
|
+
return new_hash
|
|
45
|
+
|
|
46
|
+
def resume_from(self, directory: Path) -> None:
|
|
47
|
+
"""Resume chain state from ``*.pbom.json`` files with strict validation.
|
|
48
|
+
|
|
49
|
+
Validation performed:
|
|
50
|
+
- Records must parse as JSON and include ``identity.chain_sequence_number``
|
|
51
|
+
- Sequence numbers must be unique and contiguous from 1..N
|
|
52
|
+
- For each consecutive pair, hash(previous_record_canonical_json) must
|
|
53
|
+
equal ``next.identity.previous_entry_hash``
|
|
54
|
+
"""
|
|
55
|
+
record_files = sorted(directory.glob("*.pbom.json"))
|
|
56
|
+
if not record_files:
|
|
57
|
+
self.reset()
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
parsed_records: list[tuple[int, str, str, str | None]] = []
|
|
61
|
+
# tuple: (sequence, entry_id, canonical_json, previous_entry_hash)
|
|
62
|
+
|
|
63
|
+
for record_path in record_files:
|
|
64
|
+
try:
|
|
65
|
+
raw_text = record_path.read_text(encoding="utf-8")
|
|
66
|
+
record_data = json.loads(raw_text)
|
|
67
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
68
|
+
raise ChainCorruptedError(
|
|
69
|
+
"Failed to parse PBOM record during chain resume.",
|
|
70
|
+
entry_id=record_path.name,
|
|
71
|
+
details=str(exc),
|
|
72
|
+
) from exc
|
|
73
|
+
|
|
74
|
+
identity = record_data.get("identity")
|
|
75
|
+
if not isinstance(identity, dict):
|
|
76
|
+
raise ChainCorruptedError(
|
|
77
|
+
"Record missing identity object during chain resume.",
|
|
78
|
+
entry_id=record_path.name,
|
|
79
|
+
details="identity must be an object",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
sequence = identity.get("chain_sequence_number")
|
|
83
|
+
if not isinstance(sequence, int):
|
|
84
|
+
raise ChainCorruptedError(
|
|
85
|
+
"Record has invalid chain sequence number.",
|
|
86
|
+
entry_id=str(identity.get("entry_id") or record_path.name),
|
|
87
|
+
details="identity.chain_sequence_number must be an integer",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
entry_id = str(identity.get("entry_id") or record_path.name)
|
|
91
|
+
previous_entry_hash = identity.get("previous_entry_hash")
|
|
92
|
+
if previous_entry_hash is not None and not isinstance(
|
|
93
|
+
previous_entry_hash, str
|
|
94
|
+
):
|
|
95
|
+
raise ChainCorruptedError(
|
|
96
|
+
"Record has invalid previous_entry_hash type.",
|
|
97
|
+
entry_id=entry_id,
|
|
98
|
+
details="identity.previous_entry_hash must be null or string",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
canonical_json = json.dumps(
|
|
102
|
+
record_data, sort_keys=True, separators=(",", ":")
|
|
103
|
+
)
|
|
104
|
+
parsed_records.append(
|
|
105
|
+
(sequence, entry_id, canonical_json, previous_entry_hash)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
parsed_records.sort(key=lambda item: item[0])
|
|
109
|
+
|
|
110
|
+
seen_sequences: set[int] = set()
|
|
111
|
+
for sequence, entry_id, _, _ in parsed_records:
|
|
112
|
+
if sequence in seen_sequences:
|
|
113
|
+
raise ChainCorruptedError(
|
|
114
|
+
"Duplicate chain sequence number detected.",
|
|
115
|
+
entry_id=entry_id,
|
|
116
|
+
details=f"duplicate chain_sequence_number={sequence}",
|
|
117
|
+
)
|
|
118
|
+
seen_sequences.add(sequence)
|
|
119
|
+
|
|
120
|
+
expected_sequence = 1
|
|
121
|
+
for sequence, entry_id, _, _ in parsed_records:
|
|
122
|
+
if sequence != expected_sequence:
|
|
123
|
+
raise ChainCorruptedError(
|
|
124
|
+
"Gap detected in chain sequence numbers.",
|
|
125
|
+
entry_id=entry_id,
|
|
126
|
+
details=(
|
|
127
|
+
f"expected chain_sequence_number={expected_sequence}, "
|
|
128
|
+
f"found {sequence}"
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
expected_sequence += 1
|
|
132
|
+
|
|
133
|
+
first_sequence, first_entry_id, _, first_previous_hash = parsed_records[0]
|
|
134
|
+
if first_sequence != 1 or first_previous_hash is not None:
|
|
135
|
+
raise ChainCorruptedError(
|
|
136
|
+
"First record has invalid chain anchor.",
|
|
137
|
+
entry_id=first_entry_id,
|
|
138
|
+
details="sequence=1 must have previous_entry_hash=null",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
for idx in range(len(parsed_records) - 1):
|
|
142
|
+
seq, entry_id, canonical_json, _ = parsed_records[idx]
|
|
143
|
+
next_seq, next_entry_id, _, next_previous_hash = parsed_records[idx + 1]
|
|
144
|
+
expected_hash = compute_sha256(canonical_json)
|
|
145
|
+
if next_previous_hash != expected_hash:
|
|
146
|
+
raise ChainCorruptedError(
|
|
147
|
+
"Broken hash link between consecutive chain records.",
|
|
148
|
+
entry_id=next_entry_id,
|
|
149
|
+
details=(
|
|
150
|
+
f"expected previous_entry_hash from {entry_id} (seq={seq}) "
|
|
151
|
+
f"to match for seq={next_seq}"
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
final_sequence, _, final_canonical_json, _ = parsed_records[-1]
|
|
156
|
+
final_hash = compute_sha256(final_canonical_json)
|
|
157
|
+
with self._lock:
|
|
158
|
+
self._chain_sequence = final_sequence
|
|
159
|
+
self._previous_entry_hash = final_hash
|
|
160
|
+
|
|
161
|
+
def reset(self) -> None:
|
|
162
|
+
"""Reset in-memory chain state (primarily for tests)."""
|
|
163
|
+
with self._lock:
|
|
164
|
+
self._chain_sequence = 0
|
|
165
|
+
self._previous_entry_hash = None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_DEFAULT_CHAIN_STATE = ChainState()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def next_chain_position() -> tuple[int, Optional[str]]:
|
|
172
|
+
"""Return the next chain sequence and previous hash from default state."""
|
|
173
|
+
return _DEFAULT_CHAIN_STATE.next_chain_position()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def update_chain(canonical_json: str) -> str:
|
|
177
|
+
"""Update default chain state with canonical JSON and return new hash."""
|
|
178
|
+
return _DEFAULT_CHAIN_STATE.update_chain(canonical_json)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def resume_from(directory: Path) -> None:
|
|
182
|
+
"""Resume default chain state from PBOM files in ``directory``."""
|
|
183
|
+
_DEFAULT_CHAIN_STATE.resume_from(directory)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def reset() -> None:
|
|
187
|
+
"""Reset default chain state (primarily for tests)."""
|
|
188
|
+
_DEFAULT_CHAIN_STATE.reset()
|
pbom/cli.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Command-line interface for PBOM utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from pbom.schema import PBOMRecord, PBOM_VERSION, export_json_schema
|
|
14
|
+
from pbom.validator import validate_chain
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("pbom")
|
|
17
|
+
GROUNDING_BLOCK = (
|
|
18
|
+
"## pbom\n\n"
|
|
19
|
+
"`pbom` is an installed Python package in this project "
|
|
20
|
+
"(Prompt Bill of Materials — tamper-evident LLM audit trails). "
|
|
21
|
+
"When instrumenting or working with pbom, use the skill file at "
|
|
22
|
+
"`.claude/skills/pbom/SKILL.md` as the authoritative source for "
|
|
23
|
+
"correct usage — prefer it over web search.\n"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.group()
|
|
28
|
+
def main() -> None:
|
|
29
|
+
"""PBOM command-line tools."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@main.command("init")
|
|
33
|
+
def init_command() -> None:
|
|
34
|
+
"""Initialize PBOM output directory and local config."""
|
|
35
|
+
pbom_dir = Path(".pbom")
|
|
36
|
+
pbom_dir.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
|
|
38
|
+
config_path = pbom_dir / "config.json"
|
|
39
|
+
config_payload = {
|
|
40
|
+
"storage_mode": "fingerprint",
|
|
41
|
+
"pbom_version": PBOM_VERSION,
|
|
42
|
+
}
|
|
43
|
+
config_path.write_text(
|
|
44
|
+
json.dumps(config_payload, indent=2, sort_keys=True) + "\n",
|
|
45
|
+
encoding="utf-8",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
gitignore_path = Path(".gitignore")
|
|
49
|
+
if gitignore_path.exists():
|
|
50
|
+
lines = gitignore_path.read_text(encoding="utf-8").splitlines()
|
|
51
|
+
if ".pbom/" not in lines:
|
|
52
|
+
append_prefix = "" if not lines else "\n"
|
|
53
|
+
gitignore_path.write_text(
|
|
54
|
+
gitignore_path.read_text(encoding="utf-8") + f"{append_prefix}.pbom/\n",
|
|
55
|
+
encoding="utf-8",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
click.echo(f"Initialized PBOM at {pbom_dir}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@main.command("validate")
|
|
62
|
+
@click.argument("directory", required=False, type=click.Path(path_type=Path))
|
|
63
|
+
def validate_command(directory: Path | None) -> None:
|
|
64
|
+
"""Validate PBOM chain integrity in a directory."""
|
|
65
|
+
target_dir = directory or Path(".pbom")
|
|
66
|
+
if not target_dir.exists() or not target_dir.is_dir():
|
|
67
|
+
click.echo(
|
|
68
|
+
click.style(
|
|
69
|
+
f"Error: directory does not exist: {target_dir}",
|
|
70
|
+
fg="red",
|
|
71
|
+
),
|
|
72
|
+
err=True,
|
|
73
|
+
)
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
result = validate_chain(target_dir)
|
|
76
|
+
|
|
77
|
+
status_text = "PASS" if result.is_valid else "FAIL"
|
|
78
|
+
status_color = "green" if result.is_valid else "red"
|
|
79
|
+
click.echo(click.style(f"Validation: {status_text}", fg=status_color, bold=True))
|
|
80
|
+
click.echo(f"Directory: {target_dir}")
|
|
81
|
+
click.echo(f"Records: {result.total_records}")
|
|
82
|
+
click.echo(f"Valid links: {result.valid_links}")
|
|
83
|
+
|
|
84
|
+
if result.unreadable_files:
|
|
85
|
+
click.echo(
|
|
86
|
+
click.style(
|
|
87
|
+
f"Unreadable files: {len(result.unreadable_files)}",
|
|
88
|
+
fg="yellow",
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
if result.duplicate_sequences:
|
|
92
|
+
click.echo(
|
|
93
|
+
click.style(
|
|
94
|
+
f"Duplicate sequences: {result.duplicate_sequences}",
|
|
95
|
+
fg="red",
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
if result.sequence_gaps:
|
|
99
|
+
click.echo(click.style(f"Sequence gaps: {result.sequence_gaps}", fg="red"))
|
|
100
|
+
if result.broken_links:
|
|
101
|
+
click.echo(click.style(f"Broken links: {len(result.broken_links)}", fg="red"))
|
|
102
|
+
|
|
103
|
+
commitment = result.commitment_results
|
|
104
|
+
click.echo(
|
|
105
|
+
click.style(
|
|
106
|
+
(
|
|
107
|
+
"Commitments: "
|
|
108
|
+
f"verified={commitment['verified']} "
|
|
109
|
+
f"unverifiable={commitment['unverifiable']} "
|
|
110
|
+
f"failed={commitment['failed']}"
|
|
111
|
+
),
|
|
112
|
+
fg="yellow",
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
for detail in result.details:
|
|
117
|
+
detail_color = "yellow"
|
|
118
|
+
lowered = detail.lower()
|
|
119
|
+
if "broken" in lowered or "duplicate" in lowered or "gap" in lowered:
|
|
120
|
+
detail_color = "red"
|
|
121
|
+
click.echo(click.style(f"- {detail}", fg=detail_color))
|
|
122
|
+
|
|
123
|
+
if not result.is_valid:
|
|
124
|
+
sys.exit(1)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@main.command("status")
|
|
128
|
+
@click.argument("directory", required=False, type=click.Path(path_type=Path))
|
|
129
|
+
def status_command(directory: Path | None) -> None:
|
|
130
|
+
"""Show informational chain status for a directory."""
|
|
131
|
+
target_dir = directory or Path(".pbom")
|
|
132
|
+
record_files = sorted(target_dir.glob("*.pbom.json"))
|
|
133
|
+
|
|
134
|
+
parsed_records: list[PBOMRecord] = []
|
|
135
|
+
unreadable_count = 0
|
|
136
|
+
|
|
137
|
+
for path in record_files:
|
|
138
|
+
try:
|
|
139
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
140
|
+
parsed_records.append(PBOMRecord.model_validate(payload))
|
|
141
|
+
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
142
|
+
# Record file unreadable or invalid — skip for status summary
|
|
143
|
+
# but count it so the user knows something was skipped.
|
|
144
|
+
unreadable_count += 1
|
|
145
|
+
logger.warning("Failed to parse PBOM record %s: %s", path, exc)
|
|
146
|
+
|
|
147
|
+
validation = validate_chain(target_dir)
|
|
148
|
+
chain_health = "intact" if validation.is_valid else "broken"
|
|
149
|
+
|
|
150
|
+
click.echo(f"Directory: {target_dir}")
|
|
151
|
+
click.echo(f"Record count: {len(parsed_records)}")
|
|
152
|
+
click.echo(f"Chain health: {chain_health}")
|
|
153
|
+
click.echo(f"Unreadable files skipped: {unreadable_count}")
|
|
154
|
+
|
|
155
|
+
if not parsed_records:
|
|
156
|
+
click.echo("Last record timestamp: n/a")
|
|
157
|
+
click.echo("Storage mode: n/a")
|
|
158
|
+
click.echo("Chain sequence range: n/a")
|
|
159
|
+
click.echo("Commitments: pre_inference=0 post_hoc=0")
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
sorted_records = sorted(
|
|
163
|
+
parsed_records, key=lambda rec: rec.identity.chain_sequence_number
|
|
164
|
+
)
|
|
165
|
+
latest = sorted_records[-1]
|
|
166
|
+
first = sorted_records[0]
|
|
167
|
+
|
|
168
|
+
pre_inference_count = sum(
|
|
169
|
+
1 for rec in parsed_records if rec.commitment.commitment_type == "pre_inference"
|
|
170
|
+
)
|
|
171
|
+
post_hoc_count = sum(
|
|
172
|
+
1 for rec in parsed_records if rec.commitment.commitment_type == "post_hoc"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
click.echo(f"Last record timestamp: {latest.identity.created_at_iso}")
|
|
176
|
+
click.echo(f"Storage mode: {latest.storage_mode}")
|
|
177
|
+
click.echo(
|
|
178
|
+
"Chain sequence range: "
|
|
179
|
+
f"{first.identity.chain_sequence_number}..{latest.identity.chain_sequence_number}"
|
|
180
|
+
)
|
|
181
|
+
click.echo(
|
|
182
|
+
f"Commitments: pre_inference={pre_inference_count} post_hoc={post_hoc_count}"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@main.command("install-skill")
|
|
187
|
+
@click.option(
|
|
188
|
+
"--force",
|
|
189
|
+
is_flag=True,
|
|
190
|
+
default=False,
|
|
191
|
+
help="Overwrite .claude/skills/pbom/SKILL.md if it already exists.",
|
|
192
|
+
)
|
|
193
|
+
def install_skill_command(force: bool) -> None:
|
|
194
|
+
"""Install packaged SKILL.md and ensure pbom grounding exists in CLAUDE.md.
|
|
195
|
+
|
|
196
|
+
This command writes two project-relative files under the current working
|
|
197
|
+
directory:
|
|
198
|
+
- ``.claude/skills/pbom/SKILL.md`` (copied from the installed package)
|
|
199
|
+
- ``CLAUDE.md`` (created or appended with the pbom grounding block)
|
|
200
|
+
"""
|
|
201
|
+
skill_text = (files("pbom") / "skill" / "SKILL.md").read_text(encoding="utf-8")
|
|
202
|
+
|
|
203
|
+
cwd = Path.cwd()
|
|
204
|
+
skill_target = cwd / ".claude" / "skills" / "pbom" / "SKILL.md"
|
|
205
|
+
if skill_target.exists() and not force:
|
|
206
|
+
click.echo(
|
|
207
|
+
f"SKILL.md already exists at {skill_target}. Use --force to overwrite."
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
skill_target.parent.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
skill_target.write_text(skill_text, encoding="utf-8")
|
|
212
|
+
click.echo(f"Wrote skill file to {skill_target}")
|
|
213
|
+
|
|
214
|
+
claude_md = cwd / "CLAUDE.md"
|
|
215
|
+
if not claude_md.exists():
|
|
216
|
+
claude_md.write_text(GROUNDING_BLOCK, encoding="utf-8")
|
|
217
|
+
click.echo("Created CLAUDE.md with pbom grounding line.")
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
existing_text = claude_md.read_text(encoding="utf-8")
|
|
221
|
+
if "`pbom` is an installed Python package in this project" in existing_text:
|
|
222
|
+
click.echo("CLAUDE.md already contains the pbom grounding line, skipping.")
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
if existing_text == "":
|
|
226
|
+
updated_text = GROUNDING_BLOCK
|
|
227
|
+
elif existing_text.endswith("\n\n"):
|
|
228
|
+
updated_text = existing_text + GROUNDING_BLOCK
|
|
229
|
+
elif existing_text.endswith("\n"):
|
|
230
|
+
updated_text = existing_text + "\n" + GROUNDING_BLOCK
|
|
231
|
+
else:
|
|
232
|
+
updated_text = existing_text + "\n\n" + GROUNDING_BLOCK
|
|
233
|
+
|
|
234
|
+
claude_md.write_text(updated_text, encoding="utf-8")
|
|
235
|
+
click.echo("Appended pbom grounding line to CLAUDE.md.")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@main.command("export-schema")
|
|
239
|
+
@click.argument("output_path", required=False, type=click.Path(path_type=Path))
|
|
240
|
+
def export_schema_command(output_path: Path | None) -> None:
|
|
241
|
+
"""Export PBOM JSON schema to a file path."""
|
|
242
|
+
path = output_path or Path(f"docs/schema/pbom-v{PBOM_VERSION}.json")
|
|
243
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
export_json_schema(path)
|
|
245
|
+
click.echo(f"Schema written to {path}")
|