specjam 0.0.1__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.
- specjam/__init__.py +3 -0
- specjam/__main__.py +6 -0
- specjam/archive.py +52 -0
- specjam/classification.py +30 -0
- specjam/cli.py +124 -0
- specjam/graph_engine.py +211 -0
- specjam/installer.py +280 -0
- specjam/model.py +149 -0
- specjam/payload/__init__.py +2 -0
- specjam/payload/bridge/AGENTS.md +79 -0
- specjam/payload/ignore-rules.txt +7 -0
- specjam/payload/workspace/WORKSPACE.md +32 -0
- specjam/payload/workspace/config.json +8 -0
- specjam/payload/workspace/graphs/delivery-graph.json +61 -0
- specjam/payload/workspace/graphs/discovery-graph.json +39 -0
- specjam/payload/workspace/graphs/postmortem-graph.json +47 -0
- specjam/payload/workspace/references/RWSA.md +11 -0
- specjam/payload/workspace/runtime/capability-matrix.md +15 -0
- specjam/payload/workspace/skills/specjam-bounded-review/SKILL.md +33 -0
- specjam/payload/workspace/skills/specjam-bounded-review/rws.json +11 -0
- specjam/payload/workspace/skills/specjam-daily-loop/SKILL.md +32 -0
- specjam/payload/workspace/skills/specjam-daily-loop/rws.json +12 -0
- specjam/payload/workspace/skills/specjam-flow/SKILL.md +37 -0
- specjam/payload/workspace/skills/specjam-flow/rws.json +13 -0
- specjam/payload/workspace/skills/specjam-skill-authoring/SKILL.md +36 -0
- specjam/payload/workspace/skills/specjam-skill-authoring/references/RWSA.md +4 -0
- specjam/payload/workspace/skills/specjam-skill-authoring/rws.json +13 -0
- specjam/payload/workspace/skills/specjam-trace-to-skill/SKILL.md +37 -0
- specjam/payload/workspace/skills/specjam-trace-to-skill/references/RWSA.md +4 -0
- specjam/payload/workspace/skills/specjam-trace-to-skill/rws.json +12 -0
- specjam/reviewers.py +80 -0
- specjam/rws.py +186 -0
- specjam-0.0.1.dist-info/METADATA +112 -0
- specjam-0.0.1.dist-info/RECORD +37 -0
- specjam-0.0.1.dist-info/WHEEL +5 -0
- specjam-0.0.1.dist-info/entry_points.txt +2 -0
- specjam-0.0.1.dist-info/top_level.txt +1 -0
specjam/__init__.py
ADDED
specjam/__main__.py
ADDED
specjam/archive.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Safe validation and extraction for self-contained SpecJam archives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import stat
|
|
6
|
+
import zipfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from re import match
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UnsafeArchiveError(ValueError):
|
|
12
|
+
"""Raised before extraction when an archive entry can escape its root."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _validate_name(name: str) -> None:
|
|
16
|
+
normalized = name.replace("\\", "/")
|
|
17
|
+
if normalized.startswith("/") or ".." in Path(normalized).parts:
|
|
18
|
+
raise UnsafeArchiveError(f"unsafe archive path: {name}")
|
|
19
|
+
if Path(normalized).drive or match(r"^[A-Za-z]:", normalized) or normalized.startswith("//"):
|
|
20
|
+
raise UnsafeArchiveError(f"archive path has a drive prefix: {name}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_archive(path: str | Path) -> None:
|
|
24
|
+
with zipfile.ZipFile(path) as archive:
|
|
25
|
+
for info in archive.infolist():
|
|
26
|
+
_validate_name(info.filename)
|
|
27
|
+
mode = (info.external_attr >> 16) & 0o170000
|
|
28
|
+
if mode == stat.S_IFLNK:
|
|
29
|
+
raise UnsafeArchiveError(f"symlink archive entry: {info.filename}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def extract_archive(path: str | Path, destination: str | Path) -> None:
|
|
33
|
+
"""Validate every entry before writing any bytes."""
|
|
34
|
+
|
|
35
|
+
validate_archive(path)
|
|
36
|
+
root = Path(destination).resolve()
|
|
37
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
with zipfile.ZipFile(path) as archive:
|
|
39
|
+
for info in archive.infolist():
|
|
40
|
+
relative = Path(info.filename.replace("\\", "/"))
|
|
41
|
+
target = (root / relative).resolve()
|
|
42
|
+
try:
|
|
43
|
+
target.relative_to(root)
|
|
44
|
+
except ValueError as exc:
|
|
45
|
+
raise UnsafeArchiveError(f"archive entry escapes destination: {info.filename}") from exc
|
|
46
|
+
if info.is_dir():
|
|
47
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
continue
|
|
49
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
with archive.open(info) as source, target.open("wb") as sink:
|
|
51
|
+
while chunk := source.read(1024 * 1024):
|
|
52
|
+
sink.write(chunk)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Deterministic proportional effort classification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Classification:
|
|
10
|
+
level: str
|
|
11
|
+
flow: str
|
|
12
|
+
reason: str
|
|
13
|
+
|
|
14
|
+
def to_dict(self) -> dict[str, str]:
|
|
15
|
+
return {"level": self.level, "flow": self.flow, "reason": self.reason}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def classify_request(text: str, *, ambiguous: bool = False, critical: bool = False) -> Classification:
|
|
19
|
+
normalized = text.strip().lower()
|
|
20
|
+
if not normalized:
|
|
21
|
+
return Classification("L3", "discovery", "empty requests are ambiguous and require discovery")
|
|
22
|
+
lookup_words = ("what is", "how do i", "lookup", "look up", "explain", "compare", "quantos", "qual é")
|
|
23
|
+
if not ambiguous and not critical and (normalized.endswith("?") or any(word in normalized for word in lookup_words)):
|
|
24
|
+
return Classification("L0", "daily", "lookup or explanation; no implementation specification required")
|
|
25
|
+
small_words = ("rename", "typo", "readme", "format", "small", "minor", "documentation")
|
|
26
|
+
if not ambiguous and not critical and any(word in normalized for word in small_words):
|
|
27
|
+
return Classification("L1", "daily", "bounded local change with proportional ceremony")
|
|
28
|
+
if ambiguous or critical or any(word in normalized for word in ("security", "payment", "migration", "breaking", "production")):
|
|
29
|
+
return Classification("L3", "discovery", "ambiguous or critical work requires discovery before delivery")
|
|
30
|
+
return Classification("L2", "delivery", "feature-sized work requires specification and implementation gates")
|
specjam/cli.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Command-line interface for SpecJam."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .classification import classify_request
|
|
11
|
+
from .graph_engine import load_graph, record_route
|
|
12
|
+
from .installer import inspect_installation, install, scaffold_flow, update, verify
|
|
13
|
+
from .model import RouteState
|
|
14
|
+
from .rws import load_rwsa, validate_rwsa
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _emit(value) -> None:
|
|
18
|
+
json.dump(value, sys.stdout, indent=2, sort_keys=True)
|
|
19
|
+
sys.stdout.write("\n")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _flags(values: list[str] | None) -> dict[str, bool]:
|
|
23
|
+
result: dict[str, bool] = {}
|
|
24
|
+
for value in values or []:
|
|
25
|
+
key, separator, raw = value.partition("=")
|
|
26
|
+
result[key] = raw.lower() not in {"0", "false", "no", "off"} if separator else True
|
|
27
|
+
return result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
31
|
+
parser = argparse.ArgumentParser(prog="specjam", description="Deterministic agentic engineering workspace")
|
|
32
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
33
|
+
|
|
34
|
+
install_parser = commands.add_parser("install", help="install the managed workspace")
|
|
35
|
+
install_parser.add_argument("--target", default=".")
|
|
36
|
+
install_parser.add_argument("--force", action="store_true")
|
|
37
|
+
|
|
38
|
+
verify_parser = commands.add_parser("verify", help="verify managed files and lockfile")
|
|
39
|
+
verify_parser.add_argument("--target", default=".")
|
|
40
|
+
|
|
41
|
+
update_parser = commands.add_parser("update", help="update unchanged managed files")
|
|
42
|
+
update_parser.add_argument("--target", default=".")
|
|
43
|
+
update_parser.add_argument("--remove-stale", action="store_true")
|
|
44
|
+
|
|
45
|
+
inspect_parser = commands.add_parser("inspect", help="inspect installation metadata")
|
|
46
|
+
inspect_parser.add_argument("--target", default=".")
|
|
47
|
+
|
|
48
|
+
classify_parser = commands.add_parser("classify", help="classify work into L0-L3")
|
|
49
|
+
classify_parser.add_argument("text", nargs="+")
|
|
50
|
+
classify_parser.add_argument("--ambiguous", action="store_true")
|
|
51
|
+
classify_parser.add_argument("--critical", action="store_true")
|
|
52
|
+
|
|
53
|
+
graph = commands.add_parser("graph", help="validate a graph")
|
|
54
|
+
graph_commands = graph.add_subparsers(dest="graph_command", required=True)
|
|
55
|
+
graph_validate = graph_commands.add_parser("validate")
|
|
56
|
+
graph_validate.add_argument("path")
|
|
57
|
+
|
|
58
|
+
route = commands.add_parser("route", help="evaluate one graph gate")
|
|
59
|
+
route.add_argument("--graph", required=True)
|
|
60
|
+
route.add_argument("--stage", required=True)
|
|
61
|
+
route.add_argument("--artifact", action="append", default=[])
|
|
62
|
+
route.add_argument("--flag", action="append", default=[])
|
|
63
|
+
route.add_argument("--trail")
|
|
64
|
+
route.add_argument("--run-id", default="local")
|
|
65
|
+
|
|
66
|
+
rws = commands.add_parser("rws", help="validate an RWSA contract")
|
|
67
|
+
rws_commands = rws.add_subparsers(dest="rws_command", required=True)
|
|
68
|
+
rws_validate = rws_commands.add_parser("validate")
|
|
69
|
+
rws_validate.add_argument("path")
|
|
70
|
+
|
|
71
|
+
flow = commands.add_parser("flow", help="scaffold durable flow artifacts")
|
|
72
|
+
flow_commands = flow.add_subparsers(dest="flow_command", required=True)
|
|
73
|
+
scaffold = flow_commands.add_parser("scaffold")
|
|
74
|
+
scaffold.add_argument("--target", default=".")
|
|
75
|
+
scaffold.add_argument("--flow", required=True, choices=("discovery", "delivery", "postmortem"))
|
|
76
|
+
scaffold.add_argument("--slug", required=True)
|
|
77
|
+
return parser
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main(argv: list[str] | None = None) -> int:
|
|
81
|
+
args = build_parser().parse_args(argv)
|
|
82
|
+
if args.command == "install":
|
|
83
|
+
_emit(install(args.target, force=args.force).to_dict())
|
|
84
|
+
return 0
|
|
85
|
+
if args.command == "verify":
|
|
86
|
+
report = verify(args.target)
|
|
87
|
+
_emit(report.to_dict())
|
|
88
|
+
return 0 if not (report.missing or report.modified or report.stale) else 1
|
|
89
|
+
if args.command == "update":
|
|
90
|
+
_emit(update(args.target, remove_stale=args.remove_stale).to_dict())
|
|
91
|
+
return 0
|
|
92
|
+
if args.command == "inspect":
|
|
93
|
+
_emit(inspect_installation(args.target))
|
|
94
|
+
return 0
|
|
95
|
+
if args.command == "classify":
|
|
96
|
+
_emit(classify_request(" ".join(args.text), ambiguous=args.ambiguous, critical=args.critical).to_dict())
|
|
97
|
+
return 0
|
|
98
|
+
if args.command == "graph" and args.graph_command == "validate":
|
|
99
|
+
graph = load_graph(args.path)
|
|
100
|
+
_emit({"valid": True, "graph": graph.id, "nodes": sorted(graph.nodes)})
|
|
101
|
+
return 0
|
|
102
|
+
if args.command == "route":
|
|
103
|
+
graph = load_graph(args.graph)
|
|
104
|
+
state = RouteState(args.stage, frozenset(args.artifact), _flags(args.flag))
|
|
105
|
+
if args.trail:
|
|
106
|
+
decision = record_route(__import__("specjam.graph_engine", fromlist=["TrailStore"]).TrailStore(args.trail), args.run_id, graph, state)
|
|
107
|
+
else:
|
|
108
|
+
from .graph_engine import route
|
|
109
|
+
decision = route(graph, state)
|
|
110
|
+
_emit(decision.to_dict())
|
|
111
|
+
return 0 if decision.may_advance or not decision.blocked else 2
|
|
112
|
+
if args.command == "rws" and args.rws_command == "validate":
|
|
113
|
+
profile = load_rwsa(args.path)
|
|
114
|
+
_emit({"valid": True, "skill": profile.routing.name, "workflow_steps": len(profile.workflow)})
|
|
115
|
+
return 0
|
|
116
|
+
if args.command == "flow" and args.flow_command == "scaffold":
|
|
117
|
+
path = scaffold_flow(args.target, args.flow, args.slug)
|
|
118
|
+
_emit({"flow": args.flow, "path": str(path)})
|
|
119
|
+
return 0
|
|
120
|
+
return 2
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
raise SystemExit(main())
|
specjam/graph_engine.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Pure graph routing and the side-effecting audit trail adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Iterable, Mapping
|
|
10
|
+
|
|
11
|
+
from .model import FlowGraph, GraphNode, RouteDecision, RouteState
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GraphValidationError(ValueError):
|
|
15
|
+
"""Raised when a graph cannot be evaluated safely."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_graph(path: str | Path) -> FlowGraph:
|
|
19
|
+
with Path(path).open(encoding="utf-8") as handle:
|
|
20
|
+
graph = FlowGraph.from_dict(json.load(handle))
|
|
21
|
+
validate_graph(graph)
|
|
22
|
+
return graph
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_graph(graph: FlowGraph) -> None:
|
|
26
|
+
errors: list[str] = []
|
|
27
|
+
if not graph.id:
|
|
28
|
+
errors.append("graph id is required")
|
|
29
|
+
if not graph.nodes:
|
|
30
|
+
errors.append("graph must contain at least one node")
|
|
31
|
+
if graph.start_stage not in graph.nodes:
|
|
32
|
+
errors.append(f"unknown start_stage: {graph.start_stage}")
|
|
33
|
+
for terminal in graph.terminal_stages:
|
|
34
|
+
if terminal not in graph.nodes:
|
|
35
|
+
errors.append(f"unknown terminal stage: {terminal}")
|
|
36
|
+
for node_id, node in graph.nodes.items():
|
|
37
|
+
if node_id != node.id:
|
|
38
|
+
errors.append(f"node key {node_id!r} does not match node id {node.id!r}")
|
|
39
|
+
if not node.agent:
|
|
40
|
+
errors.append(f"node {node.id!r} is missing agent")
|
|
41
|
+
if len(set(node.reviewers)) != len(node.reviewers):
|
|
42
|
+
errors.append(f"node {node.id!r} declares duplicate reviewer roles")
|
|
43
|
+
if any(not subagent.read_only for subagent in node.subagents):
|
|
44
|
+
errors.append(f"node {node.id!r} declares a subagent without read_only=true")
|
|
45
|
+
if node.writer and node.writer in node.reviewers:
|
|
46
|
+
errors.append(f"node {node.id!r} cannot use a reviewer as its synthesis writer")
|
|
47
|
+
conditions: set[str | None] = set()
|
|
48
|
+
for edge in node.transitions:
|
|
49
|
+
if edge.to not in graph.nodes:
|
|
50
|
+
errors.append(f"node {node.id!r} targets unknown stage {edge.to!r}")
|
|
51
|
+
if edge.when in conditions:
|
|
52
|
+
errors.append(f"node {node.id!r} has duplicate transition condition {edge.when!r}")
|
|
53
|
+
conditions.add(edge.when)
|
|
54
|
+
if node.id in graph.terminal_stages and any(edge.to != node.id for edge in node.transitions):
|
|
55
|
+
errors.append(f"terminal node {node.id!r} may only transition to itself")
|
|
56
|
+
if node.id not in graph.terminal_stages and not node.transitions:
|
|
57
|
+
errors.append(f"non-terminal node {node.id!r} must have a transition")
|
|
58
|
+
|
|
59
|
+
if graph.start_stage in graph.nodes:
|
|
60
|
+
reachable = _reachable(graph, graph.start_stage)
|
|
61
|
+
errors.extend(f"unreachable node: {node_id!r}" for node_id in sorted(set(graph.nodes) - reachable))
|
|
62
|
+
if errors:
|
|
63
|
+
raise GraphValidationError("; ".join(errors))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _reachable(graph: FlowGraph, start: str) -> set[str]:
|
|
67
|
+
found: set[str] = set()
|
|
68
|
+
pending = [start]
|
|
69
|
+
while pending:
|
|
70
|
+
current = pending.pop()
|
|
71
|
+
if current in found or current not in graph.nodes:
|
|
72
|
+
continue
|
|
73
|
+
found.add(current)
|
|
74
|
+
pending.extend(edge.to for edge in graph.nodes[current].transitions)
|
|
75
|
+
return found
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _matches(condition: str | None, flags: Mapping[str, bool]) -> bool:
|
|
79
|
+
if condition is None or condition in {"always", "true"}:
|
|
80
|
+
return True
|
|
81
|
+
if condition.startswith("!"):
|
|
82
|
+
return not bool(flags.get(condition[1:], False))
|
|
83
|
+
return bool(flags.get(condition, False))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def route(graph: FlowGraph, state: RouteState) -> RouteDecision:
|
|
87
|
+
"""Evaluate one gate without performing I/O or invoking an agent."""
|
|
88
|
+
|
|
89
|
+
validate_graph(graph)
|
|
90
|
+
try:
|
|
91
|
+
node = graph.nodes[state.stage]
|
|
92
|
+
except KeyError as exc:
|
|
93
|
+
raise GraphValidationError(f"unknown current stage: {state.stage}") from exc
|
|
94
|
+
|
|
95
|
+
missing = tuple(sorted(set(node.required_artifacts) - set(state.artifacts)))
|
|
96
|
+
if missing:
|
|
97
|
+
reason = node.blocking_reason or (
|
|
98
|
+
f"Cannot leave stage {node.id!r}; required artifacts are missing: {', '.join(missing)}."
|
|
99
|
+
)
|
|
100
|
+
return RouteDecision(
|
|
101
|
+
graph_id=graph.id,
|
|
102
|
+
stage=node.id,
|
|
103
|
+
next_stage=None,
|
|
104
|
+
next_agent=node.agent,
|
|
105
|
+
missing_artifacts=missing,
|
|
106
|
+
may_advance=False,
|
|
107
|
+
blocked=True,
|
|
108
|
+
blocking_reason=reason,
|
|
109
|
+
reviewers=node.reviewers,
|
|
110
|
+
writer=node.writer,
|
|
111
|
+
implementation_blocked=node.block_implementation,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if node.id in graph.terminal_stages:
|
|
115
|
+
return RouteDecision(graph.id, node.id, None, None, (), False, False, None, node.reviewers, node.writer, node.block_implementation)
|
|
116
|
+
|
|
117
|
+
next_stage = next((edge.to for edge in node.transitions if _matches(edge.when, state.flags)), None)
|
|
118
|
+
if next_stage is None:
|
|
119
|
+
return RouteDecision(
|
|
120
|
+
graph.id,
|
|
121
|
+
node.id,
|
|
122
|
+
None,
|
|
123
|
+
node.agent,
|
|
124
|
+
(),
|
|
125
|
+
False,
|
|
126
|
+
True,
|
|
127
|
+
f"No transition condition matched for stage {node.id!r}.",
|
|
128
|
+
node.reviewers,
|
|
129
|
+
node.writer,
|
|
130
|
+
node.block_implementation,
|
|
131
|
+
)
|
|
132
|
+
return RouteDecision(
|
|
133
|
+
graph_id=graph.id,
|
|
134
|
+
stage=node.id,
|
|
135
|
+
next_stage=next_stage,
|
|
136
|
+
next_agent=graph.nodes[next_stage].agent,
|
|
137
|
+
missing_artifacts=(),
|
|
138
|
+
may_advance=True,
|
|
139
|
+
blocked=False,
|
|
140
|
+
blocking_reason=None,
|
|
141
|
+
reviewers=node.reviewers,
|
|
142
|
+
writer=node.writer,
|
|
143
|
+
implementation_blocked=node.block_implementation,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class TrailEntry:
|
|
149
|
+
run_id: str
|
|
150
|
+
graph_id: str
|
|
151
|
+
stage: str
|
|
152
|
+
decision: RouteDecision
|
|
153
|
+
artifacts: tuple[str, ...]
|
|
154
|
+
flags: Mapping[str, bool]
|
|
155
|
+
recorded_at: str
|
|
156
|
+
|
|
157
|
+
def to_dict(self) -> dict[str, Any]:
|
|
158
|
+
return {
|
|
159
|
+
"run_id": self.run_id,
|
|
160
|
+
"graph_id": self.graph_id,
|
|
161
|
+
"stage": self.stage,
|
|
162
|
+
"decision": self.decision.to_dict(),
|
|
163
|
+
"artifacts": list(self.artifacts),
|
|
164
|
+
"flags": dict(self.flags),
|
|
165
|
+
"recorded_at": self.recorded_at,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class TrailStore:
|
|
170
|
+
"""Append-only JSONL persistence kept separate from routing."""
|
|
171
|
+
|
|
172
|
+
def __init__(self, path: str | Path):
|
|
173
|
+
self.path = Path(path)
|
|
174
|
+
|
|
175
|
+
def append(self, entry: TrailEntry) -> None:
|
|
176
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
with self.path.open("a", encoding="utf-8") as handle:
|
|
178
|
+
handle.write(json.dumps(entry.to_dict(), sort_keys=True) + "\n")
|
|
179
|
+
|
|
180
|
+
def read(self) -> list[dict[str, Any]]:
|
|
181
|
+
if not self.path.exists():
|
|
182
|
+
return []
|
|
183
|
+
with self.path.open(encoding="utf-8") as handle:
|
|
184
|
+
return [json.loads(line) for line in handle if line.strip()]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def record_route(
|
|
188
|
+
store: TrailStore,
|
|
189
|
+
run_id: str,
|
|
190
|
+
graph: FlowGraph,
|
|
191
|
+
state: RouteState,
|
|
192
|
+
decision: RouteDecision | None = None,
|
|
193
|
+
*,
|
|
194
|
+
now: datetime | None = None,
|
|
195
|
+
) -> RouteDecision:
|
|
196
|
+
"""Evaluate and append a gate evaluation to the audit trail."""
|
|
197
|
+
|
|
198
|
+
selected = decision or route(graph, state)
|
|
199
|
+
instant = now or datetime.now(timezone.utc)
|
|
200
|
+
store.append(
|
|
201
|
+
TrailEntry(
|
|
202
|
+
run_id=run_id,
|
|
203
|
+
graph_id=graph.id,
|
|
204
|
+
stage=state.stage,
|
|
205
|
+
decision=selected,
|
|
206
|
+
artifacts=tuple(sorted(state.artifacts)),
|
|
207
|
+
flags=state.flags,
|
|
208
|
+
recorded_at=instant.isoformat(),
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
return selected
|