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.
Files changed (37) hide show
  1. specjam/__init__.py +3 -0
  2. specjam/__main__.py +6 -0
  3. specjam/archive.py +52 -0
  4. specjam/classification.py +30 -0
  5. specjam/cli.py +124 -0
  6. specjam/graph_engine.py +211 -0
  7. specjam/installer.py +280 -0
  8. specjam/model.py +149 -0
  9. specjam/payload/__init__.py +2 -0
  10. specjam/payload/bridge/AGENTS.md +79 -0
  11. specjam/payload/ignore-rules.txt +7 -0
  12. specjam/payload/workspace/WORKSPACE.md +32 -0
  13. specjam/payload/workspace/config.json +8 -0
  14. specjam/payload/workspace/graphs/delivery-graph.json +61 -0
  15. specjam/payload/workspace/graphs/discovery-graph.json +39 -0
  16. specjam/payload/workspace/graphs/postmortem-graph.json +47 -0
  17. specjam/payload/workspace/references/RWSA.md +11 -0
  18. specjam/payload/workspace/runtime/capability-matrix.md +15 -0
  19. specjam/payload/workspace/skills/specjam-bounded-review/SKILL.md +33 -0
  20. specjam/payload/workspace/skills/specjam-bounded-review/rws.json +11 -0
  21. specjam/payload/workspace/skills/specjam-daily-loop/SKILL.md +32 -0
  22. specjam/payload/workspace/skills/specjam-daily-loop/rws.json +12 -0
  23. specjam/payload/workspace/skills/specjam-flow/SKILL.md +37 -0
  24. specjam/payload/workspace/skills/specjam-flow/rws.json +13 -0
  25. specjam/payload/workspace/skills/specjam-skill-authoring/SKILL.md +36 -0
  26. specjam/payload/workspace/skills/specjam-skill-authoring/references/RWSA.md +4 -0
  27. specjam/payload/workspace/skills/specjam-skill-authoring/rws.json +13 -0
  28. specjam/payload/workspace/skills/specjam-trace-to-skill/SKILL.md +37 -0
  29. specjam/payload/workspace/skills/specjam-trace-to-skill/references/RWSA.md +4 -0
  30. specjam/payload/workspace/skills/specjam-trace-to-skill/rws.json +12 -0
  31. specjam/reviewers.py +80 -0
  32. specjam/rws.py +186 -0
  33. specjam-0.0.1.dist-info/METADATA +112 -0
  34. specjam-0.0.1.dist-info/RECORD +37 -0
  35. specjam-0.0.1.dist-info/WHEEL +5 -0
  36. specjam-0.0.1.dist-info/entry_points.txt +2 -0
  37. specjam-0.0.1.dist-info/top_level.txt +1 -0
specjam/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """SpecJam's deterministic agentic engineering primitives."""
2
+
3
+ __version__ = "0.0.1"
specjam/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
6
+
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())
@@ -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