sembl 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.
sembl/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """
2
+ Sembl — Work Order generator for AI software development.
3
+ Turn messy repo intent into scoped execution contracts.
4
+ """
5
+
6
+ __version__ = "0.1.0"
sembl/cli.py ADDED
@@ -0,0 +1,381 @@
1
+ """
2
+ cli.py
3
+
4
+ Sembl CLI — the first machine.
5
+
6
+ Commands:
7
+ sembl generate — repo + task → Work Order
8
+ sembl show — display the latest Work Order
9
+ sembl list — list all Work Orders in this repo
10
+ """
11
+
12
+ import os
13
+ import json
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import click
18
+ from rich.console import Console
19
+ from rich.panel import Panel
20
+ from rich.table import Table
21
+ from rich.text import Text
22
+ from rich import box
23
+
24
+ from .repo_probe import probe_repo
25
+ from .generator import generate_work_order
26
+ from .output import write_work_order
27
+
28
+ console = Console()
29
+
30
+
31
+ @click.group()
32
+ @click.version_option("0.1.0", prog_name="sembl")
33
+ def main():
34
+ """Sembl — turn messy repo intent into scoped AI Work Orders."""
35
+ pass
36
+
37
+
38
+ # ── sembl generate ────────────────────────────────────────────────────────────
39
+
40
+ @main.command()
41
+ @click.option("--repo", "-r", default=".", show_default=True,
42
+ help="Path to the repository.")
43
+ @click.option("--task", "-t", required=True,
44
+ help="The task or request to turn into a Work Order.")
45
+ @click.option("--provider", "-p", default="openai",
46
+ type=click.Choice(["openai", "anthropic", "gemini", "nvidia"], case_sensitive=False),
47
+ show_default=True, help="LLM provider.")
48
+ @click.option("--model", "-m", default=None,
49
+ help="Model name. Defaults to gpt-4o (openai), claude-sonnet-4-6 (anthropic), gemini-2.5-flash (gemini), or mistralai/mistral-medium-3.5-128b (nvidia).")
50
+ @click.option("--api-key", default=None,
51
+ help="API key. Otherwise Sembl reads the selected provider env var.")
52
+ @click.option("--no-graphify", is_flag=True, default=False,
53
+ help="Skip graphify even if available.")
54
+ @click.option("--no-crg", is_flag=True, default=False,
55
+ help="Skip code-review-graph even if available.")
56
+ @click.option("--require-graph-context", is_flag=True, default=False,
57
+ help="Fail instead of using direct-probe fallback when graphify/CRG context is unavailable.")
58
+ def generate(repo, task, provider, model, api_key, no_graphify, no_crg, require_graph_context):
59
+ """Generate a Work Order from a repo and a task description."""
60
+
61
+ repo_path = str(Path(repo).resolve())
62
+
63
+ # ── Step 1: Probe the repo ────────────────────────────────────────────
64
+ console.print()
65
+ console.print(Panel(
66
+ f"[bold]Task:[/bold] {task}",
67
+ title="[bold blue]Sembl — Work Order Generator[/bold blue]",
68
+ border_style="blue"
69
+ ))
70
+ console.print()
71
+
72
+ with console.status("[blue]Probing repo...[/blue]"):
73
+ probe = probe_repo(
74
+ repo_path,
75
+ task,
76
+ use_graphify=not no_graphify,
77
+ use_crg=not no_crg,
78
+ )
79
+
80
+ _print_probe_summary(probe, no_graphify, no_crg)
81
+ if require_graph_context and probe.context_basis != "graph_pipeline":
82
+ console.print(
83
+ "\n[red]Graph context required but unavailable.[/red]\n"
84
+ "Run graphify/code-review-graph for this repo, check PATH/venv resolution, "
85
+ "or rerun without [bold]--require-graph-context[/bold] to allow direct-probe fallback.\n"
86
+ )
87
+ sys.exit(1)
88
+
89
+ # ── Step 2: Generate Work Order ───────────────────────────────────────
90
+ _check_api_key(provider, api_key)
91
+
92
+ with console.status(f"[blue]Generating Work Order via {provider}...[/blue]"):
93
+ try:
94
+ wo = generate_work_order(
95
+ task=task,
96
+ probe=probe,
97
+ model_provider=provider,
98
+ model=model,
99
+ api_key=api_key,
100
+ )
101
+ except Exception as e:
102
+ console.print(_format_generation_error(e))
103
+ sys.exit(1)
104
+
105
+ # ── Step 3: Write output files ────────────────────────────────────────
106
+ with console.status("[blue]Writing output files...[/blue]"):
107
+ out_dir = write_work_order(wo, repo_path)
108
+
109
+ # ── Step 4: Print summary ─────────────────────────────────────────────
110
+ _print_work_order_summary(wo, out_dir)
111
+
112
+
113
+ # ── sembl show ────────────────────────────────────────────────────────────────
114
+
115
+ @main.command()
116
+ @click.option("--repo", "-r", default=".", show_default=True,
117
+ help="Path to the repository.")
118
+ @click.option("--id", "wo_id", default=None,
119
+ help="Work Order ID. Defaults to latest.")
120
+ @click.option("--file", "output_file", default="work-order",
121
+ type=click.Choice(["work-order", "executor-prompt", "validation-plan"]),
122
+ show_default=True, help="Which file to show.")
123
+ def show(repo, wo_id, output_file):
124
+ """Show a Work Order. Defaults to the latest one."""
125
+ repo_path = Path(repo).resolve()
126
+ wo_dir = _find_wo_dir(repo_path, wo_id)
127
+ if not wo_dir:
128
+ console.print("[red]No Work Orders found in this repo.[/red]")
129
+ console.print("Run [bold]sembl generate[/bold] to create one.")
130
+ sys.exit(1)
131
+
132
+ target = wo_dir / f"{output_file}.md"
133
+ if not target.exists():
134
+ console.print(f"[red]File not found:[/red] {target}")
135
+ sys.exit(1)
136
+
137
+ content = target.read_text(encoding="utf-8")
138
+ from rich.markdown import Markdown
139
+ console.print(Markdown(content))
140
+
141
+
142
+ # ── sembl list ────────────────────────────────────────────────────────────────
143
+
144
+ @main.command("list")
145
+ @click.option("--repo", "-r", default=".", show_default=True,
146
+ help="Path to the repository.")
147
+ def list_orders(repo):
148
+ """List all Work Orders in this repo."""
149
+ repo_path = Path(repo).resolve()
150
+ sembl_dir = repo_path / ".sembl" / "work-orders"
151
+
152
+ if not sembl_dir.exists():
153
+ console.print("[yellow]No Work Orders found.[/yellow] Run [bold]sembl generate[/bold].")
154
+ return
155
+
156
+ dirs = sorted(sembl_dir.iterdir(), key=lambda d: d.stat().st_mtime, reverse=True)
157
+ if not dirs:
158
+ console.print("[yellow]No Work Orders found.[/yellow]")
159
+ return
160
+
161
+ table = Table(box=box.ROUNDED, border_style="blue", show_header=True)
162
+ table.add_column("Work Order ID", style="bold cyan")
163
+ table.add_column("Task Type", style="green")
164
+ table.add_column("Risk", style="yellow")
165
+ table.add_column("Created", style="dim")
166
+ table.add_column("Goal", style="white")
167
+
168
+ for d in dirs:
169
+ json_file = d / "work-order.json"
170
+ if not json_file.exists():
171
+ continue
172
+ try:
173
+ data = json.loads(json_file.read_text(encoding="utf-8"))
174
+ risk = data.get("risk_level", "?").upper()
175
+ risk_style = {"LOW": "green", "MEDIUM": "yellow", "HIGH": "red"}.get(risk, "white")
176
+ table.add_row(
177
+ data.get("id", d.name),
178
+ data.get("task_type", "?"),
179
+ Text(risk, style=risk_style),
180
+ data.get("created_at", "?")[:19].replace("T", " "),
181
+ data.get("clarified_goal", "")[:60] + ("…" if len(data.get("clarified_goal","")) > 60 else ""),
182
+ )
183
+ except Exception:
184
+ table.add_row(d.name, "?", "?", "?", "?")
185
+
186
+ console.print()
187
+ console.print(table)
188
+ console.print()
189
+
190
+
191
+ # ── Helpers ──────────────────────────────────────────────────────────────────
192
+
193
+ def _print_probe_summary(probe, skip_graphify: bool, skip_crg: bool):
194
+ table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))
195
+ table.add_column("Key", style="dim")
196
+ table.add_column("Value", style="white")
197
+
198
+ table.add_row("Project", probe.project_name or "unknown")
199
+ table.add_row("Type", probe.project_type or "unknown")
200
+ table.add_row("Languages", ", ".join(probe.primary_languages) or "unknown")
201
+ table.add_row("Frameworks", ", ".join(probe.framework_hints) or "none detected")
202
+ table.add_row("Branch", probe.git_branch or "unknown")
203
+ table.add_row("Dirty", "yes" if probe.git_is_dirty else "no")
204
+ table.add_row("Context basis", "graph pipeline" if probe.context_basis == "graph_pipeline" else "direct fallback")
205
+ if probe.graph_context_sources:
206
+ table.add_row("Graph sources", ", ".join(probe.graph_context_sources))
207
+
208
+ gstatus = "available" if probe.graphify_available else ("skipped" if skip_graphify else "not found")
209
+ cstatus = "available" if probe.crg_available else ("skipped" if skip_crg else "not found")
210
+ table.add_row("Graphify", f"[green]{gstatus}[/green]" if probe.graphify_available else f"[dim]{gstatus}[/dim]")
211
+ table.add_row("code-review-graph", f"[green]{cstatus}[/green]" if probe.crg_available else f"[dim]{cstatus}[/dim]")
212
+
213
+ if probe.crg_available and probe.crg_node_count:
214
+ table.add_row("CRG graph", f"{probe.crg_node_count:,} nodes · {probe.crg_edge_count:,} edges")
215
+ if probe.crg_blast_radius:
216
+ table.add_row("CRG impact summaries", str(len(probe.crg_blast_radius)))
217
+
218
+ console.print(Panel(table, title="[bold]Repo probe[/bold]", border_style="dim"))
219
+ console.print()
220
+
221
+
222
+ def _print_work_order_summary(wo, out_dir: Path):
223
+ risk_color = {"low": "green", "medium": "yellow", "high": "red"}.get(wo.risk_level, "white")
224
+
225
+ lines = [
226
+ f"[bold]{wo.id}[/bold]",
227
+ f"",
228
+ f"[bold]Goal:[/bold] {wo.clarified_goal}",
229
+ f"[bold]Outcome:[/bold] {wo.user_visible_outcome}",
230
+ f"[bold]Task type:[/bold] {wo.task_type} | "
231
+ f"[bold]Risk:[/bold] [{risk_color}]{wo.risk_level.upper()}[/{risk_color}]",
232
+ f"",
233
+ f"[bold]Acceptance criteria:[/bold] {len(wo.acceptance_criteria)} items",
234
+ f"[bold]Validation commands:[/bold] {len(wo.validation_commands)} commands",
235
+ f"[bold]Stop conditions:[/bold] {len(wo.stop_conditions)} triggers",
236
+ f"",
237
+ f"[dim]Output:[/dim] {out_dir}",
238
+ f"",
239
+ f" work-order.md — read this",
240
+ f" executor-prompt.md — paste into your agent",
241
+ f" validation-plan.md — run this after",
242
+ f" work-order.json — machine-readable",
243
+ ]
244
+
245
+ console.print(Panel(
246
+ "\n".join(lines),
247
+ title="[bold green]Work Order generated[/bold green]",
248
+ border_style="green"
249
+ ))
250
+ console.print()
251
+
252
+
253
+ def _find_wo_dir(repo_path: Path, wo_id: str | None) -> Path | None:
254
+ sembl_dir = repo_path / ".sembl" / "work-orders"
255
+ if not sembl_dir.exists():
256
+ return None
257
+ if wo_id:
258
+ target = sembl_dir / wo_id
259
+ return target if target.exists() else None
260
+ # Latest
261
+ dirs = sorted(sembl_dir.iterdir(), key=lambda d: d.stat().st_mtime, reverse=True)
262
+ return dirs[0] if dirs else None
263
+
264
+
265
+ def _check_api_key(provider: str, api_key: str | None):
266
+ env_keys = {
267
+ "openai": "OPENAI_API_KEY",
268
+ "anthropic": "ANTHROPIC_API_KEY",
269
+ "gemini": "GEMINI_API_KEY",
270
+ "nvidia": "NVIDIA_API_KEY",
271
+ }
272
+ env_key = env_keys[provider.lower()]
273
+ if not api_key and not os.environ.get(env_key):
274
+ console.print(f"\n[red]No API key found.[/red]")
275
+ console.print(f"Set [bold]{env_key}[/bold] or pass [bold]--api-key[/bold].\n")
276
+ sys.exit(1)
277
+
278
+
279
+ def _format_generation_error(error: Exception) -> str:
280
+ message = str(error)
281
+ provider = getattr(error, "provider", None)
282
+ code = getattr(error, "code", None)
283
+ status_code = getattr(error, "status_code", None)
284
+ lower_message = message.lower()
285
+
286
+ if provider == "gemini":
287
+ return _format_gemini_error(message, status_code, code)
288
+ if provider == "nvidia":
289
+ return _format_nvidia_error(message, status_code, code)
290
+
291
+ if code == "insufficient_quota" or "insufficient_quota" in lower_message:
292
+ return (
293
+ "\n[red]Generation failed:[/red] OpenAI quota is exhausted for this API project/org.\n\n"
294
+ "The request reached OpenAI, but the API account has no available quota or has hit a spend limit.\n"
295
+ "Check billing: https://platform.openai.com/settings/organization/billing\n"
296
+ "Check limits: https://platform.openai.com/settings/organization/limits\n\n"
297
+ "ChatGPT subscriptions and OpenAI API billing are separate. After adding credits or raising the limit, "
298
+ "rerun the same Sembl command.\n"
299
+ )
300
+
301
+ if status_code == 401 or code == "invalid_api_key" or "invalid_api_key" in lower_message:
302
+ return (
303
+ "\n[red]Generation failed:[/red] OpenAI rejected the API key.\n\n"
304
+ "Set a valid OPENAI_API_KEY for the selected project, then rerun the same Sembl command.\n"
305
+ )
306
+
307
+ if "rate_limit" in lower_message or code == "rate_limit_exceeded":
308
+ return (
309
+ "\n[red]Generation failed:[/red] OpenAI rate limit reached.\n\n"
310
+ "Wait briefly, reduce concurrency, or use a lower-throughput model/project before retrying.\n"
311
+ )
312
+
313
+ if status_code == 403 or "permission" in lower_message or "model_not_found" in lower_message:
314
+ return (
315
+ "\n[red]Generation failed:[/red] The API key does not have access to the requested model or project.\n\n"
316
+ "Check the model name, project, organization, and key scope, then rerun the command.\n"
317
+ )
318
+
319
+ return f"\n[red]Generation failed:[/red] {message}"
320
+
321
+
322
+ def _format_gemini_error(message: str, status_code: int | None, code: str | None) -> str:
323
+ code_text = str(code or "").upper()
324
+ lower_message = message.lower()
325
+
326
+ if status_code == 400 or code_text == "INVALID_ARGUMENT":
327
+ return (
328
+ "\n[red]Generation failed:[/red] Gemini rejected the request.\n\n"
329
+ f"{message}\n\n"
330
+ "Check the model name and request shape, then rerun the same Sembl command.\n"
331
+ )
332
+
333
+ if status_code == 401 or status_code == 403 or code_text in {"UNAUTHENTICATED", "PERMISSION_DENIED"}:
334
+ return (
335
+ "\n[red]Generation failed:[/red] Gemini rejected the API key or project access.\n\n"
336
+ "Set a valid GEMINI_API_KEY with access to the selected model, then rerun the same Sembl command.\n"
337
+ )
338
+
339
+ if status_code == 429 or code_text == "RESOURCE_EXHAUSTED" or "quota" in lower_message:
340
+ return (
341
+ "\n[red]Generation failed:[/red] Gemini quota or rate limit reached.\n\n"
342
+ "Wait briefly, lower the request volume, or check the Google AI Studio / Google Cloud quota for this key.\n"
343
+ )
344
+
345
+ if status_code == 404 or "not found" in lower_message:
346
+ return (
347
+ "\n[red]Generation failed:[/red] Gemini model was not found or is unavailable for this API key.\n\n"
348
+ "Try --model gemini-2.5-flash, then rerun the same Sembl command.\n"
349
+ )
350
+
351
+ return f"\n[red]Generation failed:[/red] Gemini API error: {message}"
352
+
353
+
354
+ def _format_nvidia_error(message: str, status_code: int | None, code: str | None) -> str:
355
+ lower_message = message.lower()
356
+
357
+ if status_code == 401 or status_code == 403 or "unauthorized" in lower_message:
358
+ return (
359
+ "\n[red]Generation failed:[/red] NVIDIA rejected the API key or model access.\n\n"
360
+ "Set a valid NVIDIA_API_KEY with access to the selected model, then rerun the same Sembl command.\n"
361
+ )
362
+
363
+ if status_code == 429 or "rate" in lower_message or "quota" in lower_message:
364
+ return (
365
+ "\n[red]Generation failed:[/red] NVIDIA quota or rate limit reached.\n\n"
366
+ "Wait briefly, choose another available NVIDIA model, or check the key limits in the NVIDIA API catalog.\n"
367
+ )
368
+
369
+ if status_code == 404 or "model" in lower_message and "not found" in lower_message:
370
+ return (
371
+ "\n[red]Generation failed:[/red] NVIDIA model was not found or is unavailable for this key.\n\n"
372
+ "Try --model mistralai/mistral-medium-3.5-128b or another model shown in the NVIDIA catalog.\n"
373
+ )
374
+
375
+ if "invalid json" in lower_message or "llm returned invalid json" in lower_message:
376
+ return (
377
+ "\n[red]Generation failed:[/red] NVIDIA returned text that was not valid Work Order JSON.\n\n"
378
+ "Retry once, or try a stronger instruction-following model from the NVIDIA catalog.\n"
379
+ )
380
+
381
+ return f"\n[red]Generation failed:[/red] NVIDIA API error: {message}"