sembl-stack 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.
Files changed (45) hide show
  1. sembl_stack/__init__.py +3 -0
  2. sembl_stack/adapters/__init__.py +0 -0
  3. sembl_stack/adapters/_redact.py +19 -0
  4. sembl_stack/adapters/base.py +179 -0
  5. sembl_stack/adapters/codegraph_cbm.py +95 -0
  6. sembl_stack/adapters/deploy_vercel.py +215 -0
  7. sembl_stack/adapters/execute_aider.py +115 -0
  8. sembl_stack/adapters/execute_claude.py +114 -0
  9. sembl_stack/adapters/execute_mock.py +53 -0
  10. sembl_stack/adapters/execute_opencode.py +114 -0
  11. sembl_stack/adapters/merge_git.py +107 -0
  12. sembl_stack/adapters/postdeploy_http.py +82 -0
  13. sembl_stack/adapters/review_coderabbit.py +215 -0
  14. sembl_stack/adapters/review_llm.py +142 -0
  15. sembl_stack/adapters/review_mock.py +42 -0
  16. sembl_stack/adapters/sandbox_worktree.py +79 -0
  17. sembl_stack/adapters/spec_sembl.py +91 -0
  18. sembl_stack/adapters/verify_sembl.py +77 -0
  19. sembl_stack/artifacts.py +207 -0
  20. sembl_stack/cli.py +759 -0
  21. sembl_stack/config.py +87 -0
  22. sembl_stack/contextgraph.py +154 -0
  23. sembl_stack/doctor.py +111 -0
  24. sembl_stack/loop.py +380 -0
  25. sembl_stack/onboarding.py +272 -0
  26. sembl_stack/presets.py +114 -0
  27. sembl_stack/profile.py +193 -0
  28. sembl_stack/reconciliation.py +138 -0
  29. sembl_stack/registry.py +91 -0
  30. sembl_stack/rsi.py +188 -0
  31. sembl_stack/runner.py +134 -0
  32. sembl_stack/session.py +86 -0
  33. sembl_stack/specgraph.py +146 -0
  34. sembl_stack/store.py +112 -0
  35. sembl_stack/tracing.py +51 -0
  36. sembl_stack/transport/__init__.py +0 -0
  37. sembl_stack/transport/mcp_client.py +58 -0
  38. sembl_stack/tui.py +86 -0
  39. sembl_stack/views.py +74 -0
  40. sembl_stack/wizard.py +233 -0
  41. sembl_stack-0.1.0.dist-info/METADATA +165 -0
  42. sembl_stack-0.1.0.dist-info/RECORD +45 -0
  43. sembl_stack-0.1.0.dist-info/WHEEL +4 -0
  44. sembl_stack-0.1.0.dist-info/entry_points.txt +2 -0
  45. sembl_stack-0.1.0.dist-info/licenses/LICENSE +201 -0
sembl_stack/cli.py ADDED
@@ -0,0 +1,759 @@
1
+ """sembl-stack CLI.
2
+
3
+ Each stage is independently invokable and reads/writes artifacts, so you can run the
4
+ whole loop OR any subset, enter at any point (supply the upstream artifact), and slot a
5
+ custom step between two stages (read the upstream artifact, write the downstream one):
6
+
7
+ sembl-stack bounds --task t.yaml --out bounds.json
8
+ sembl-stack specgraph --task t.yaml --bounds b.json --out specgraph.json
9
+ sembl-stack reconcile --specgraph specgraph.json --codegraph codegraph.json
10
+ sembl-stack merge --verdict verdict.json --out merge_record.json
11
+ sembl-stack deploy --verdict verdict.json --out delivery.json
12
+ sembl-stack postdeploy --delivery delivery.json --out prod-verdict.json
13
+ sembl-stack execute --task t.yaml --bounds b.json --out change.json
14
+ sembl-stack verify --change change.json --bounds b.json # the gate, standalone
15
+ sembl-stack loop t.yaml # the full wiring
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import subprocess
21
+ from pathlib import Path
22
+
23
+ import click
24
+ import yaml
25
+
26
+ from . import artifacts, doctor as doctor_mod, presets, registry
27
+ from .artifacts import Bounds, Change, Delivery, SpecGraph, Task, Verdict
28
+ from .config import load
29
+ from .loop import run as run_loop
30
+ from .reconciliation import reconcile_spec_code
31
+ from .specgraph import build_spec_graph
32
+ from .store import RunStore
33
+
34
+
35
+ # --- helpers ------------------------------------------------------------------
36
+
37
+ def _resolve(base: Path, p: str | None) -> str | None:
38
+ if not p:
39
+ return p
40
+ pp = Path(p)
41
+ return str(pp if pp.is_absolute() else (base / pp).resolve())
42
+
43
+
44
+ def _load_task(task_file, repo, spec, text) -> Task:
45
+ if task_file:
46
+ data = yaml.safe_load(Path(task_file).read_text(encoding="utf-8")) or {}
47
+ base = Path(task_file).resolve().parent
48
+ return Task(text=data.get("text", ""),
49
+ repo=_resolve(base, data.get("repo", ".")),
50
+ spec_path=_resolve(base, data.get("spec_path")))
51
+ return Task(text=text or "",
52
+ repo=str(Path(repo).resolve()),
53
+ spec_path=(str(Path(spec).resolve()) if spec else None))
54
+
55
+
56
+ def _emit(artifact, out: str | None):
57
+ """Write an artifact to --out, or to stdout."""
58
+ if out:
59
+ Path(out).write_text(artifact.to_json(), encoding="utf-8")
60
+ click.echo(f"wrote {artifact.KIND} -> {out}")
61
+ else:
62
+ click.echo(artifact.to_json())
63
+
64
+
65
+ def _read_bounds(path: str) -> Bounds:
66
+ return Bounds.from_json(Path(path).read_text(encoding="utf-8-sig"))
67
+
68
+
69
+ def _read_specgraph(path: str) -> SpecGraph:
70
+ artifact = artifacts.from_dict(json.loads(Path(path).read_text(encoding="utf-8-sig")))
71
+ if not isinstance(artifact, SpecGraph):
72
+ raise click.UsageError(f"{path} is not a SpecGraph artifact")
73
+ return artifact
74
+
75
+
76
+ def _read_verdict(path: str) -> Verdict:
77
+ artifact = artifacts.from_dict(json.loads(Path(path).read_text(encoding="utf-8-sig")))
78
+ if not isinstance(artifact, Verdict):
79
+ raise click.UsageError(f"{path} is not a Verdict artifact")
80
+ return artifact
81
+
82
+
83
+ def _read_delivery(path: str) -> Delivery:
84
+ artifact = artifacts.from_dict(json.loads(Path(path).read_text(encoding="utf-8-sig")))
85
+ if not isinstance(artifact, Delivery):
86
+ raise click.UsageError(f"{path} is not a Delivery artifact")
87
+ return artifact
88
+
89
+
90
+ def _resolve_config(config_path: str, repo: str) -> str | None:
91
+ """Resolve --config: as given (CWD-relative) first, then <repo>/<config_path>.
92
+
93
+ `deploy`/`postdeploy` take `--repo` as a separate argument from CWD (orchestrating a
94
+ target repo from elsewhere is a supported use), but the bare default `sembl.stack.yaml`
95
+ only ever resolved against CWD — so pointing `--repo` at another repo silently loaded
96
+ no config (built-in defaults) instead of that repo's own layer/health contract, with no
97
+ error. Falling back to the repo dir closes that gap without changing the CWD-relative
98
+ behavior anyone already relies on.
99
+ """
100
+ if Path(config_path).is_file():
101
+ return config_path
102
+ repo_relative = Path(repo) / config_path
103
+ if repo_relative.is_file():
104
+ return str(repo_relative)
105
+ return None
106
+
107
+
108
+ # --- full loop ----------------------------------------------------------------
109
+
110
+ @click.group(invoke_without_command=True)
111
+ @click.option("--reconfigure", is_flag=True,
112
+ help="Run first-time onboarding again before opening the stage rail.")
113
+ @click.version_option()
114
+ @click.pass_context
115
+ def main(ctx, reconfigure):
116
+ """sembl-stack — an open, swappable spec-driven coding factory.
117
+
118
+ Run bare (no subcommand) to launch the guided TUI wizard (New/Existing -> stage rail,
119
+ leave/continue-anywhere via .sembl/session.json).
120
+ """
121
+ if ctx.invoked_subcommand is not None:
122
+ return
123
+ from . import onboarding, profile, wizard
124
+ needs_onboarding = reconfigure or profile.load() is None
125
+ if needs_onboarding and not onboarding.available():
126
+ raise click.UsageError(
127
+ "the guided TUI needs Textual - `pip install \"sembl-stack[tui]\"`.\n"
128
+ " (or run a stage directly, e.g. `sembl-stack loop task.yaml`)")
129
+ if not wizard.available():
130
+ raise click.UsageError(
131
+ "the guided TUI needs Textual — `pip install \"sembl-stack[tui]\"`.\n"
132
+ " (or run a stage directly, e.g. `sembl-stack loop task.yaml`)")
133
+ if needs_onboarding:
134
+ configured = onboarding.launch(".")
135
+ if configured is None:
136
+ return
137
+ wizard.launch(".")
138
+
139
+
140
+ @click.argument("task_file", type=click.Path(exists=True, dir_okay=False))
141
+ @click.option("--config", "config_path", default="sembl.stack.yaml")
142
+ def _loop_cmd(task_file: str, config_path: str):
143
+ """Run the full wiring: plan -> execute -> verify (retry on BLOCK)."""
144
+ task = _load_task(task_file, None, None, None)
145
+ # Resolve against the task's repo too — an explicit sembl.stack.yaml there must win
146
+ # over the profile even when the loop is launched from a different directory.
147
+ cfg_file = _resolve_config(config_path, task.repo)
148
+ overrides = None
149
+ if cfg_file is None: # no repo config: the onboarded profile is the default
150
+ from . import profile as profile_mod
151
+ prof = profile_mod.load()
152
+ if prof is not None:
153
+ overrides = profile_mod.to_stack_overrides(prof)
154
+ click.echo(f"(no {config_path} — using your profile: "
155
+ f"runner={prof.runner}, executor={prof.executor})")
156
+ cfg = load(cfg_file, overrides)
157
+ click.echo(f"layers: {cfg.raw['layers']}")
158
+ click.echo(f"task: {task.text!r}\nrepo: {task.repo}\n")
159
+
160
+ result = run_loop(cfg, task)
161
+
162
+ click.echo(f"engine: {result.engine}")
163
+ for attempt, status in result.history:
164
+ mark = {"PASS": "OK", "WARN": "~", "BLOCK": "X"}.get(status, "?")
165
+ click.echo(f" attempt {attempt}: [{mark}] {status}")
166
+ v = result.verdict
167
+ click.echo("")
168
+ click.secho(f"FINAL: {v.status} (after {result.attempts} attempt(s))",
169
+ fg="green" if v.status == "PASS" else
170
+ "yellow" if v.status == "WARN" else "red")
171
+ for r in v.reasons:
172
+ click.echo(f" - {r}")
173
+ if result.run_id:
174
+ click.echo(f"\nrun: {result.run_id} (.sembl/runs/{result.run_id}/)")
175
+ click.echo(f"inspect: sembl-stack runs {result.run_id} --repo {task.repo}")
176
+ if v.status in ("PASS", "WARN"):
177
+ click.echo(f"apply: sembl-stack apply {result.run_id} --repo {task.repo}")
178
+ raise SystemExit(0 if v.status in ("PASS", "WARN") else 1)
179
+
180
+
181
+ main.command(name="loop")(_loop_cmd)
182
+ main.command(name="run")(_loop_cmd) # alias
183
+
184
+
185
+ # --- individual stages --------------------------------------------------------
186
+
187
+ @main.command()
188
+ @click.option("--task", "task_file", type=click.Path(exists=True, dir_okay=False))
189
+ @click.option("--repo", default=".")
190
+ @click.option("--spec", default=None, help="Spec Kit tasks.md / feature dir.")
191
+ @click.option("--text", default=None, help="Task text (if no --task file).")
192
+ @click.option("--config", "config_path", default="sembl.stack.yaml")
193
+ @click.option("--expand/--no-expand", default=False,
194
+ help="Widen editable_paths along the L1 context graph's coupling closure "
195
+ "(EXP-05: recovers legitimate sibling files; hops=1, closure-capped).")
196
+ @click.option("--hops", default=1, show_default=True, help="Coupling hops when --expand.")
197
+ @click.option("--out", default=None, help="Write the Bounds artifact here (else stdout).")
198
+ def bounds(task_file, repo, spec, text, config_path, expand, hops, out):
199
+ """L2: Task -> Bounds. Derive the scope contract from a spec."""
200
+ task = _load_task(task_file, repo, spec, text)
201
+ cfg = load(_resolve_config(config_path, repo))
202
+ bnds = cfg.spec.plan(task)
203
+ if expand:
204
+ bnds = _expand_bounds(bnds, task.repo, cfg, hops)
205
+ _emit(bnds, out)
206
+
207
+
208
+ @main.command()
209
+ @click.option("--task", "task_file", type=click.Path(exists=True, dir_okay=False))
210
+ @click.option("--repo", default=".")
211
+ @click.option("--spec", default=None, help="Spec Kit tasks.md / feature dir.")
212
+ @click.option("--text", default=None, help="Task text (if no --task file).")
213
+ @click.option("--bounds", "bounds_path", type=click.Path(exists=True),
214
+ help="Optional Bounds artifact to include declared scope.")
215
+ @click.option("--out", default=None, help="Write the SpecGraph artifact here (else stdout).")
216
+ def specgraph(task_file, repo, spec, text, bounds_path, out):
217
+ """L2: Task(+Bounds) -> SpecGraph. Emit the spec-side reconciliation graph."""
218
+ task = _load_task(task_file, repo, spec, text)
219
+ bnds = _read_bounds(bounds_path) if bounds_path else None
220
+ _emit(build_spec_graph(task, bnds), out)
221
+
222
+
223
+ @main.command()
224
+ @click.option("--specgraph", "specgraph_path", required=True,
225
+ type=click.Path(exists=True, dir_okay=False))
226
+ @click.option("--codegraph", "codegraph_path", default=None,
227
+ type=click.Path(exists=True, dir_okay=False),
228
+ help="Code graph JSON (hand-passed). Omit and pass --live to build it from CBM.")
229
+ @click.option("--live", is_flag=True,
230
+ help="Build the code graph live from a real codebase-memory-mcp index.")
231
+ @click.option("--repo", default=".", help="Repo to index/graph when --live.")
232
+ @click.option("--config", "config_path", default="sembl.stack.yaml")
233
+ @click.option("--out", default=None,
234
+ help="Write the ReconciliationReport artifact here (else stdout).")
235
+ def reconcile(specgraph_path, codegraph_path, live, repo, config_path, out):
236
+ """L5.5: SpecGraph+CodeGraph -> advisory ReconciliationReport (advisory, never a gate)."""
237
+ spec_graph = _read_specgraph(specgraph_path)
238
+ if live:
239
+ # Advisory, never a gate: a missing/failed code graph yields an empty graph -> UNKNOWN
240
+ # report at exit 0 (the adapter already degrades internally). Never raise on CBM
241
+ # absence — only genuinely contradictory input below is a usage error.
242
+ cfg = load(_resolve_config(config_path, repo))
243
+ code_graph = cfg.codegraph.code_graph(repo) if cfg.codegraph is not None else {}
244
+ elif codegraph_path:
245
+ code_graph = json.loads(Path(codegraph_path).read_text(encoding="utf-8-sig"))
246
+ else:
247
+ raise click.UsageError("supply --codegraph <file> or --live")
248
+ _emit(reconcile_spec_code(spec_graph, code_graph), out)
249
+
250
+
251
+ @main.command()
252
+ @click.option("--diff", "diff_path", required=True, type=click.Path(exists=True, dir_okay=False),
253
+ help="Unified diff / .patch to review.")
254
+ @click.option("--config", "config_path", default="sembl.stack.yaml")
255
+ @click.option("--out", default=None, help="Write the ReviewReport artifact here (else stdout).")
256
+ def review(diff_path, config_path, out):
257
+ """L5.5 (quality): diff -> advisory ReviewReport (advisory, never a gate)."""
258
+ cfg = load(config_path if Path(config_path).is_file() else None)
259
+ diff = Path(diff_path).read_text(encoding="utf-8-sig")
260
+ _emit(cfg.review.review(diff), out)
261
+
262
+
263
+ @main.command()
264
+ @click.option("--repo", default=".")
265
+ @click.option("--verdict", "verdict_path", required=True,
266
+ type=click.Path(exists=True, dir_okay=False),
267
+ help="Final gate Verdict artifact. Must be PASS unless --allow-warn.")
268
+ @click.option("--into", default="main", show_default=True, help="Target branch to merge into.")
269
+ @click.option("--source", default="HEAD", show_default=True, help="Ref to merge.")
270
+ @click.option("--allow-warn", is_flag=True,
271
+ help="Allow merging a WARN verdict. BLOCK is never merged.")
272
+ @click.option("--no-ff/--ff", default=True, help="Create a merge commit (default) vs fast-forward.")
273
+ @click.option("--skip-binding-check", is_flag=True,
274
+ help="Merge even when the verdict's judged file set can't be matched "
275
+ "against the source ref (recorded in the MergeRecord).")
276
+ @click.option("--config", "config_path", default="sembl.stack.yaml")
277
+ @click.option("--out", default=None, help="Write the MergeRecord artifact here.")
278
+ def merge(repo, verdict_path, into, source, allow_warn, no_ff, skip_binding_check,
279
+ config_path, out):
280
+ """L6.5: Verdict(PASS) -> MergeRecord. Gated merge into the target branch."""
281
+ verdict = _read_verdict(verdict_path)
282
+ if verdict.status == "BLOCK":
283
+ raise click.UsageError("refusing to merge a BLOCK verdict")
284
+ if verdict.status == "WARN" and not allow_warn:
285
+ raise click.UsageError("refusing to merge WARN without --allow-warn")
286
+ if verdict.status not in ("PASS", "WARN"):
287
+ raise click.UsageError(f"unsupported verdict status: {verdict.status}")
288
+
289
+ # Verdict-to-source binding: the verdict names the files it judged; the merge
290
+ # must ship exactly those. Otherwise any PASS verdict file green-lights merging
291
+ # any branch. Unbound (pre-binding) verdicts pass through with a note.
292
+ binding = _check_merge_binding(verdict, repo, into, source) \
293
+ if not skip_binding_check else {"status": "skipped (--skip-binding-check)"}
294
+ if binding.get("mismatch") and not skip_binding_check:
295
+ raise click.UsageError(
296
+ "verdict/source mismatch — the verdict did not judge what this merge "
297
+ f"would ship: {binding['mismatch']} "
298
+ "(re-gate the branch, or --skip-binding-check to override; overrides "
299
+ "are recorded)")
300
+
301
+ cfg = load(_resolve_config(config_path, repo))
302
+ record = cfg.merge.merge(repo, into=into, source=source, no_ff=no_ff)
303
+ record.data["source_binding"] = binding
304
+ _emit(record, out)
305
+ raise SystemExit(0 if record.status == "merged" else 1)
306
+
307
+
308
+ def _check_merge_binding(verdict, repo, into, source) -> dict:
309
+ """Compare the verdict's judged file set to what `into...source` would merge."""
310
+ subject = (getattr(verdict, "raw", {}) or {}).get("subject") or {}
311
+ judged = subject.get("files")
312
+ if judged is None:
313
+ judged = verdict.raw.get("changed_files") if isinstance(
314
+ verdict.raw.get("changed_files"), list) else None
315
+ if judged is None:
316
+ return {"status": "unbound (verdict predates source binding)"}
317
+ proc = subprocess.run(
318
+ ["git", "-C", str(Path(repo).resolve()), "diff", "--name-only",
319
+ f"{into}...{source}"],
320
+ capture_output=True, text=True, encoding="utf-8", errors="replace")
321
+ if proc.returncode != 0:
322
+ return {"mismatch": f"could not diff {into}...{source} "
323
+ f"({proc.stderr.strip() or 'git diff failed'})"}
324
+ actual = {p.strip() for p in proc.stdout.splitlines() if p.strip()}
325
+ judged_set = set(judged)
326
+ extra, missing = sorted(actual - judged_set), sorted(judged_set - actual)
327
+ if extra or missing:
328
+ bits = []
329
+ if extra:
330
+ bits.append(f"unjudged in merge: {', '.join(extra[:5])}")
331
+ if missing:
332
+ bits.append(f"judged but absent: {', '.join(missing[:5])}")
333
+ return {"mismatch": "; ".join(bits), "extra": extra, "missing": missing}
334
+ return {"status": "verified", "files": sorted(judged_set)}
335
+
336
+
337
+ @main.command()
338
+ @click.option("--repo", default=".")
339
+ @click.option("--verdict", "verdict_path", required=True,
340
+ type=click.Path(exists=True, dir_okay=False),
341
+ help="Final gate Verdict artifact. Must be PASS unless --allow-warn.")
342
+ @click.option("--allow-warn", is_flag=True,
343
+ help="Allow deploying a WARN verdict. BLOCK is never deployed.")
344
+ @click.option("--prod/--preview", "production", default=False,
345
+ help="Deploy to production instead of preview.")
346
+ @click.option("--prebuilt/--no-prebuilt", default=False,
347
+ help="Deploy existing Vercel build output with `vercel deploy --prebuilt`.")
348
+ @click.option("--config", "config_path", default="sembl.stack.yaml")
349
+ @click.option("--out", default=None, help="Write the Delivery artifact here.")
350
+ def deploy(repo, verdict_path, allow_warn, production, prebuilt, config_path, out):
351
+ """L7: Verdict(PASS) -> Delivery. Deploy via the configured adapter."""
352
+ verdict = _read_verdict(verdict_path)
353
+ if verdict.status == "BLOCK":
354
+ raise click.UsageError("refusing to deploy a BLOCK verdict")
355
+ if verdict.status == "WARN" and not allow_warn:
356
+ raise click.UsageError("refusing to deploy WARN without --allow-warn")
357
+ if verdict.status != "PASS" and verdict.status != "WARN":
358
+ raise click.UsageError(f"unsupported verdict status: {verdict.status}")
359
+
360
+ cfg = load(_resolve_config(config_path, repo))
361
+ delivery = cfg.deploy.deploy(repo, production=production, prebuilt=prebuilt)
362
+ _emit(delivery, out)
363
+ raise SystemExit(0 if delivery.status == "deployed" else 1)
364
+
365
+
366
+ @main.command()
367
+ @click.option("--delivery", "delivery_path", required=True,
368
+ type=click.Path(exists=True, dir_okay=False))
369
+ @click.option("--health-path", default=None,
370
+ help="Override the configured health path (default from options.postdeploy).")
371
+ @click.option("--timeout", "timeout_s", default=10.0, show_default=True, type=float)
372
+ @click.option("--rollback/--no-rollback", "do_rollback", default=False,
373
+ help="On a BLOCK verdict, fire a rollback via the deploy adapter (promote previous).")
374
+ @click.option("--repo", default=".", help="Repo dir for the rollback call (linked Vercel project).")
375
+ @click.option("--config", "config_path", default="sembl.stack.yaml")
376
+ @click.option("--out", default=None, help="Write the production Verdict artifact here.")
377
+ def postdeploy(delivery_path, health_path, timeout_s, do_rollback, repo, config_path, out):
378
+ """L8: Delivery -> Verdict. Deterministic post-deploy health gate (+ optional rollback)."""
379
+ delivery = _read_delivery(delivery_path)
380
+ cfg = load(_resolve_config(config_path, repo))
381
+ # health_path=None lets the adapter use its configured default (options.postdeploy.health_path
382
+ # + expect_json payload contract); an explicit --health-path overrides per-call.
383
+ verdict = cfg.postdeploy.verify(delivery, health_path=health_path, timeout_s=timeout_s)
384
+
385
+ # L8 rollback trigger: a BLOCK means the live deploy is bad — revert it. Opt-in so default
386
+ # behavior is unchanged. The rollback outcome is recorded in the prod Verdict, never hidden.
387
+ if do_rollback and verdict.status == "BLOCK":
388
+ rollback = cfg.deploy.rollback(repo)
389
+ verdict.raw["rollback"] = rollback.to_dict()
390
+ verdict.reasons.append(f"rollback triggered: {rollback.status}")
391
+
392
+ _emit(verdict, out)
393
+ raise SystemExit(0 if verdict.status in ("PASS", "WARN") else 1)
394
+
395
+
396
+ def _expand_bounds(bnds, repo, cfg, hops):
397
+ """Grow bounds.editable_paths via the configured context graph (no-op if unavailable)."""
398
+ from .contextgraph import expand_bounds as _eb
399
+ g = cfg.context
400
+ if g is None or not getattr(g, "available", lambda: False)():
401
+ click.echo("(context graph unavailable — bounds left as-is)", err=True)
402
+ return bnds
403
+ opts = (cfg.raw.get("options", {}) or {}).get("context", {}) or {}
404
+ g.index(repo)
405
+ fg = g.file_graph(repo)
406
+ before = list(bnds.editable_paths)
407
+ bnds.editable_paths = _eb(before, fg, hops=hops,
408
+ min_strength=opts.get("min_strength", 0),
409
+ max_fraction=opts.get("max_fraction", 0.4))
410
+ click.echo(f"(context: {len(fg.nodes)} files; editable_paths "
411
+ f"{len(before)} -> {len(bnds.editable_paths)})", err=True)
412
+ return bnds
413
+
414
+
415
+ @main.command()
416
+ @click.option("--repo", default=".")
417
+ @click.option("--config", "config_path", default="sembl.stack.yaml")
418
+ def context(repo, config_path):
419
+ """L1: index the repo with the context graph and show its size + densest files."""
420
+ cfg = load(_resolve_config(config_path, repo))
421
+ g = cfg.context
422
+ if g is None or not getattr(g, "available", lambda: False)():
423
+ raise click.UsageError("no context adapter configured/available "
424
+ "(set layers.context: symgraph, install symgraph)")
425
+ repo = str(Path(repo).resolve())
426
+ g.index(repo)
427
+ fg = g.file_graph(repo)
428
+ click.echo(f"files: {len(fg.nodes)} edges: {len(fg.edges)}")
429
+ top = sorted(fg.edges, key=lambda e: e.get("strength", 0), reverse=True)[:8]
430
+ for e in top:
431
+ click.echo(f" {e['from']} -> {e['to']} (strength {e.get('strength','?')})")
432
+
433
+
434
+ @main.command()
435
+ @click.option("--task", "task_file", type=click.Path(exists=True, dir_okay=False))
436
+ @click.option("--repo", default=".")
437
+ @click.option("--text", default=None)
438
+ @click.option("--bounds", "bounds_path", required=True, type=click.Path(exists=True))
439
+ @click.option("--feedback", default=None, help="Gate feedback to act on (retry).")
440
+ @click.option("--config", "config_path", default="sembl.stack.yaml")
441
+ @click.option("--out", default=None, help="Write the Change artifact here (else stdout).")
442
+ def execute(task_file, repo, text, bounds_path, feedback, config_path, out):
443
+ """L3: Task+Bounds -> Change. Run the executor in a sandbox, capture the diff."""
444
+ task = _load_task(task_file, repo, None, text)
445
+ cfg = load(_resolve_config(config_path, repo))
446
+ bnds = _read_bounds(bounds_path)
447
+ sandbox = cfg.sandbox.open(task.repo)
448
+ try:
449
+ change = cfg.execute.run(task, bnds, sandbox, feedback)
450
+ finally:
451
+ sandbox.close() # diff is captured in the artifact; cage is disposable
452
+ _emit(change, out)
453
+
454
+
455
+ @main.command()
456
+ @click.option("--change", "change_path", type=click.Path(exists=True),
457
+ help="A Change artifact to gate.")
458
+ @click.option("--diff", "diff_path", type=click.Path(exists=True),
459
+ help="A raw unified diff / .patch (the adoption wedge: gate any diff).")
460
+ @click.option("--report", "report_path", type=click.Path(exists=True),
461
+ help="An executor self-report JSON (used with --diff).")
462
+ @click.option("--bounds", "bounds_path", required=True, type=click.Path(exists=True))
463
+ @click.option("--strict/--no-strict", default=True)
464
+ @click.option("--config", "config_path", default="sembl.stack.yaml")
465
+ @click.option("--out", default=None, help="Write the Verdict artifact here.")
466
+ def verify(change_path, diff_path, report_path, bounds_path, strict, config_path, out):
467
+ """L5: Change+Bounds -> Verdict. The deterministic gate, standalone."""
468
+ cfg = load(config_path if Path(config_path).is_file() else None)
469
+ bnds = _read_bounds(bounds_path)
470
+ if change_path:
471
+ change = Change.from_json(Path(change_path).read_text(encoding="utf-8"))
472
+ elif diff_path:
473
+ report = (json.loads(Path(report_path).read_text(encoding="utf-8"))
474
+ if report_path else {})
475
+ change = Change(diff=Path(diff_path).read_text(encoding="utf-8"), report=report)
476
+ else:
477
+ raise click.UsageError("provide --change or --diff")
478
+
479
+ verdict = cfg.verify.verify(bnds, change, strict)
480
+ if out:
481
+ _emit(verdict, out)
482
+ click.secho(f"{verdict.status}", fg="green" if verdict.status == "PASS" else
483
+ "yellow" if verdict.status == "WARN" else "red")
484
+ for r in verdict.reasons:
485
+ click.echo(f" - {r}")
486
+ raise SystemExit(0 if verdict.status in ("PASS", "WARN") else 1)
487
+
488
+
489
+ # --- onboarding (C4) -----------------------------------------------------------
490
+
491
+ @main.command()
492
+ @click.option("--preset", type=click.Choice(presets.names()),
493
+ default=presets.DEFAULT_PRESET, show_default=True,
494
+ help="just-gate (wedge) | gate+sandbox (mock loop) | full-loop (real agent).")
495
+ @click.option("--config", "config_path", default="sembl.stack.yaml", show_default=True)
496
+ @click.option("--task/--no-task", "with_task", default=True,
497
+ help="Also scaffold a starter task.yaml.")
498
+ @click.option("--force", is_flag=True, help="Overwrite existing files.")
499
+ def init(preset, config_path, with_task, force):
500
+ """Scaffold a sembl.stack.yaml (+ a starter task.yaml) from a preset."""
501
+ cfg_p = Path(config_path)
502
+ if cfg_p.exists() and not force:
503
+ raise click.UsageError(f"{config_path} already exists (use --force to overwrite)")
504
+ cfg_p.write_text(presets.render(preset), encoding="utf-8")
505
+ click.secho(f"wrote {config_path} (preset: {preset})", fg="green")
506
+ if with_task:
507
+ tp = Path("task.yaml")
508
+ if tp.exists() and not force:
509
+ click.echo(" task.yaml exists — left as-is")
510
+ else:
511
+ tp.write_text(presets.starter_task(), encoding="utf-8")
512
+ click.secho("wrote task.yaml", fg="green")
513
+ click.echo("\nnext:\n sembl-stack doctor # check your environment\n"
514
+ " sembl-stack loop task.yaml # run the loop")
515
+
516
+
517
+ @main.command()
518
+ @click.option("--config", "config_path", default="sembl.stack.yaml")
519
+ def doctor(config_path):
520
+ """Preflight: check the environment for the layers your config selects."""
521
+ cfg = load(config_path) if Path(config_path).is_file() else None
522
+ if cfg is None:
523
+ click.echo(f"(no {config_path} — checking defaults; `sembl-stack init` first)\n")
524
+ checks = doctor_mod.run_checks(cfg)
525
+ for c in checks:
526
+ mark, color = ("OK", "green") if c.ok else \
527
+ (("X", "red") if c.required else ("~", "yellow"))
528
+ click.secho(f" [{mark}] {c.name:26} {c.detail}", fg=color)
529
+ if not c.ok and c.hint:
530
+ click.echo(f" -> {c.hint}")
531
+ ready, blocking, warnings = doctor_mod.summarize(checks)
532
+ click.echo("")
533
+ if ready:
534
+ extra = f" ({len(warnings)} optional not installed)" if warnings else ""
535
+ click.secho(f"doctor: ready{extra}", fg="green")
536
+ else:
537
+ click.secho(f"doctor: NOT ready — {len(blocking)} required item(s) missing", fg="red")
538
+ raise SystemExit(0 if ready else 1)
539
+
540
+
541
+ # --- introspection ------------------------------------------------------------
542
+
543
+ @main.command()
544
+ def layers():
545
+ """List the available adapters per layer."""
546
+ for layer in ("spec", "execute", "sandbox", "verify", "context", "merge", "deploy", "postdeploy", "review"):
547
+ click.echo(f"{layer:9}: {', '.join(registry.names(layer))}")
548
+
549
+
550
+ @main.command()
551
+ @click.argument("run_id", required=False)
552
+ @click.option("--repo", default=".")
553
+ @click.option("-v", "--verbose", is_flag=True, help="Show per-attempt latency/cost.")
554
+ def runs(run_id, repo, verbose):
555
+ """List recorded runs, or inspect one in detail: `sembl-stack runs <id>`."""
556
+ store = RunStore(repo)
557
+ if run_id:
558
+ _show_run(store, run_id)
559
+ return
560
+ ids = store.list_runs()
561
+ if not ids:
562
+ click.echo("no runs yet — try `sembl-stack loop task.yaml`")
563
+ return
564
+ for rid in ids:
565
+ m = store.open(rid).manifest()
566
+ lat = m.get("total_latency_s")
567
+ lat_s = f"{lat:.2f}s" if isinstance(lat, (int, float)) else "-"
568
+ click.echo(f"{rid} {m.get('status','?'):6} "
569
+ f"attempts={m.get('attempts','-')} {lat_s:>8} "
570
+ f"{m.get('task',{}).get('text','')}")
571
+ if verbose:
572
+ for e in m.get("attempts_log", []):
573
+ bits = [f"latency={e.get('latency_s','-')}s"]
574
+ for k in ("model", "exit_code", "tokens", "cost"):
575
+ if e.get(k) is not None:
576
+ bits.append(f"{k}={e[k]}")
577
+ click.echo(f" attempt {e.get('attempt','?')}: " + " ".join(bits))
578
+
579
+
580
+ @main.command()
581
+ @click.option("--repo", default=".")
582
+ @click.option("--json", "as_json", is_flag=True, help="Emit the full summary as JSON.")
583
+ def rsi(repo, as_json):
584
+ """RSI-L1 readout: per-executor iterations-to-green + cost over the recorded runs.
585
+
586
+ The "measured selection" artifact (north-star first rung): every number is read back
587
+ from run-store artifacts the loop persisted — cost shows "not yet recorded" for runs
588
+ whose executor reported no usage, never an invented number.
589
+ """
590
+ from . import rsi as rsi_mod
591
+ summary = rsi_mod.aggregate(RunStore(repo))
592
+ if as_json:
593
+ click.echo(json.dumps(summary, indent=2))
594
+ else:
595
+ click.echo(rsi_mod.render(summary))
596
+
597
+
598
+ @main.command(name="apply")
599
+ @click.argument("run_id")
600
+ @click.option("--repo", default=".")
601
+ @click.option("--allow-warn", is_flag=True,
602
+ help="Allow applying a final WARN verdict. BLOCK is never applied.")
603
+ @click.option("--allow-dirty", is_flag=True,
604
+ help="Apply even when the target working tree has uncommitted changes.")
605
+ @click.option("--check", "check_only", is_flag=True,
606
+ help="Only verify that the patch applies; do not change the working tree.")
607
+ def apply_run(run_id, repo, allow_warn, allow_dirty, check_only):
608
+ """Apply a run's final accepted patch to the source repo working tree."""
609
+ repo_path = Path(repo).resolve()
610
+ store = RunStore(str(repo_path))
611
+ run = store.open(run_id)
612
+ m = run.manifest()
613
+ if not m:
614
+ raise click.UsageError(f"no run '{run_id}' under {store.root}")
615
+
616
+ verdict = run.get("verdict")
617
+ status = getattr(verdict, "status", m.get("status", "BLOCK"))
618
+ if status == "BLOCK":
619
+ raise click.UsageError("refusing to apply a BLOCKed run")
620
+ if status == "WARN" and not allow_warn:
621
+ raise click.UsageError("refusing to apply WARN without --allow-warn")
622
+
623
+ change = run.get("change")
624
+ if change is None:
625
+ attempts = m.get("attempts")
626
+ change = run.get(f"change-{attempts}") if attempts else None
627
+ if change is None or not (getattr(change, "diff", "") or "").strip():
628
+ raise click.UsageError("run has no final patch to apply")
629
+
630
+ # Verdict-to-source binding: the verdict must have been issued for THIS patch.
631
+ # Without it, any PASS verdict file (edited, or copied from another run) would
632
+ # green-light applying an unjudged diff.
633
+ subject = (getattr(verdict, "raw", {}) or {}).get("subject") or {}
634
+ want = subject.get("diff_sha256")
635
+ if want:
636
+ from .artifacts import diff_sha256
637
+ have = diff_sha256(change.diff)
638
+ if have != want:
639
+ raise click.UsageError(
640
+ "verdict/patch mismatch: the run's verdict was not issued for this "
641
+ f"patch (judged sha256 {want[:12]}…, patch is {have[:12]}…) — "
642
+ "the run's artifacts have diverged; re-run the loop")
643
+
644
+ # Dirty-tree guard: applying over uncommitted edits mixes judged and unjudged
645
+ # changes in one tree (and a failed apply can't be cleanly undone). Opt out
646
+ # explicitly with --allow-dirty.
647
+ if not check_only and not allow_dirty and _tree_is_dirty(repo_path):
648
+ raise click.UsageError(
649
+ "target working tree has uncommitted changes — commit/stash them "
650
+ "first, or pass --allow-dirty")
651
+
652
+ _git_apply(repo_path, change.diff, check_only=check_only)
653
+ action = "checked" if check_only else "applied"
654
+ click.secho(f"{action} {run_id} -> {repo_path}", fg="green")
655
+
656
+
657
+ def _tree_is_dirty(repo: Path) -> bool:
658
+ """Uncommitted changes in the working tree, ignoring the run store (.sembl/)."""
659
+ proc = subprocess.run(
660
+ ["git", "status", "--porcelain"], cwd=repo, capture_output=True, text=True,
661
+ encoding="utf-8", errors="replace")
662
+ if proc.returncode != 0: # not a git repo etc. — git apply will say so
663
+ return False
664
+ # porcelain v1: `XY path` (rename: `XY old -> new`); path starts at column 3
665
+ paths = (line[3:].strip().strip('"') for line in proc.stdout.splitlines() if len(line) > 3)
666
+ return any(p and not p.startswith(".sembl") for p in paths)
667
+
668
+
669
+ def _git_apply(repo: Path, diff: str, *, check_only: bool) -> None:
670
+ check = subprocess.run(
671
+ ["git", "apply", "--check", "-"], cwd=repo, input=diff,
672
+ capture_output=True, text=True)
673
+ if check.returncode != 0:
674
+ raise click.ClickException(
675
+ "patch does not apply cleanly: "
676
+ + (check.stderr.strip() or check.stdout.strip() or "git apply --check failed"))
677
+ if check_only:
678
+ return
679
+ proc = subprocess.run(
680
+ ["git", "apply", "-"], cwd=repo, input=diff,
681
+ capture_output=True, text=True)
682
+ if proc.returncode != 0:
683
+ raise click.ClickException(
684
+ proc.stderr.strip() or proc.stdout.strip() or "git apply failed")
685
+
686
+
687
+ @main.command()
688
+ @click.option("--repo", default=".")
689
+ @click.option("--refresh", default=3.0, show_default=True,
690
+ help="Seconds between live refreshes (0 to disable).")
691
+ def dash(repo, refresh):
692
+ """Live run dashboard (O6 TUI). Needs the tui extra: pip install 'sembl-stack[tui]'."""
693
+ from . import tui
694
+ if not tui.available():
695
+ raise click.UsageError(
696
+ "the TUI needs Textual — `pip install \"sembl-stack[tui]\"`.\n"
697
+ " (meanwhile `sembl-stack runs` and `runs <id>` give the same data as text.)")
698
+ store = RunStore(repo)
699
+ if not store.list_runs():
700
+ click.echo("no runs yet — try `sembl-stack loop task.yaml`")
701
+ return
702
+ tui.run_dashboard(store, refresh_s=refresh)
703
+
704
+
705
+ def _show_run(store, run_id: str) -> None:
706
+ """Detailed single-run view: task, bounds, per-attempt verdict+latency, final."""
707
+ run = store.open(run_id)
708
+ m = run.manifest()
709
+ if not m:
710
+ raise click.UsageError(f"no run '{run_id}' under {store.root}")
711
+ click.secho(f"run {run_id}", bold=True)
712
+ lat = m.get("total_latency_s")
713
+ lat_s = f"{lat:.2f}s" if isinstance(lat, (int, float)) else "-"
714
+ click.echo(f" status: {m.get('status','?')} attempts={m.get('attempts','-')} "
715
+ f"engine={m.get('engine','-')} latency={lat_s}")
716
+ task = m.get("task", {})
717
+ if task:
718
+ click.echo(f" task: {task.get('text','')}")
719
+ click.echo(f" repo: {task.get('repo','')}")
720
+ bounds = run.get("bounds")
721
+ if bounds is not None:
722
+ click.echo(f" bounds: editable={bounds.editable_paths} "
723
+ f"forbidden={bounds.forbidden_areas} churn={bounds.churn_budget}")
724
+
725
+ log = {e.get("attempt"): e for e in m.get("attempts_log", [])}
726
+ n = m.get("attempts") or 0
727
+ if n:
728
+ click.echo(" attempts:")
729
+ for i in range(1, n + 1):
730
+ v = run.get(f"verdict-{i}")
731
+ meta = log.get(i, {})
732
+ status = v.status if v else "?"
733
+ color = "green" if status == "PASS" else "yellow" if status == "WARN" else "red"
734
+ extra = f" model={meta['model']}" if meta.get("model") else ""
735
+ click.secho(f" {i}: [{status}] latency={meta.get('latency_s','-')}s{extra}",
736
+ fg=color)
737
+ for r in (v.reasons if v else []):
738
+ click.echo(f" - {r}")
739
+
740
+ fv = run.get("verdict")
741
+ if fv is not None:
742
+ color = "green" if fv.status == "PASS" else "yellow" if fv.status == "WARN" else "red"
743
+ click.secho(f" final: {fv.status}", fg=color)
744
+ ch = run.get("change")
745
+ if ch is None and n:
746
+ ch = run.get(f"change-{n}")
747
+ if ch is not None:
748
+ files = (getattr(ch, "report", {}) or {}).get("files_modified") or []
749
+ suffix = f" files={files}" if files else ""
750
+ click.echo(f" patch: change.json{suffix}")
751
+ if fv is not None and fv.status in ("PASS", "WARN"):
752
+ repo = (task or {}).get("repo", ".")
753
+ warn = " --allow-warn" if fv.status == "WARN" else ""
754
+ click.echo(f" apply: sembl-stack apply {run_id} --repo {repo}{warn}")
755
+ click.echo(f" artifacts: {run.dir}")
756
+
757
+
758
+ if __name__ == "__main__":
759
+ main()