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.
- sembl_stack/__init__.py +3 -0
- sembl_stack/adapters/__init__.py +0 -0
- sembl_stack/adapters/_redact.py +19 -0
- sembl_stack/adapters/base.py +179 -0
- sembl_stack/adapters/codegraph_cbm.py +95 -0
- sembl_stack/adapters/deploy_vercel.py +215 -0
- sembl_stack/adapters/execute_aider.py +115 -0
- sembl_stack/adapters/execute_claude.py +114 -0
- sembl_stack/adapters/execute_mock.py +53 -0
- sembl_stack/adapters/execute_opencode.py +114 -0
- sembl_stack/adapters/merge_git.py +107 -0
- sembl_stack/adapters/postdeploy_http.py +82 -0
- sembl_stack/adapters/review_coderabbit.py +215 -0
- sembl_stack/adapters/review_llm.py +142 -0
- sembl_stack/adapters/review_mock.py +42 -0
- sembl_stack/adapters/sandbox_worktree.py +79 -0
- sembl_stack/adapters/spec_sembl.py +91 -0
- sembl_stack/adapters/verify_sembl.py +77 -0
- sembl_stack/artifacts.py +207 -0
- sembl_stack/cli.py +759 -0
- sembl_stack/config.py +87 -0
- sembl_stack/contextgraph.py +154 -0
- sembl_stack/doctor.py +111 -0
- sembl_stack/loop.py +380 -0
- sembl_stack/onboarding.py +272 -0
- sembl_stack/presets.py +114 -0
- sembl_stack/profile.py +193 -0
- sembl_stack/reconciliation.py +138 -0
- sembl_stack/registry.py +91 -0
- sembl_stack/rsi.py +188 -0
- sembl_stack/runner.py +134 -0
- sembl_stack/session.py +86 -0
- sembl_stack/specgraph.py +146 -0
- sembl_stack/store.py +112 -0
- sembl_stack/tracing.py +51 -0
- sembl_stack/transport/__init__.py +0 -0
- sembl_stack/transport/mcp_client.py +58 -0
- sembl_stack/tui.py +86 -0
- sembl_stack/views.py +74 -0
- sembl_stack/wizard.py +233 -0
- sembl_stack-0.1.0.dist-info/METADATA +165 -0
- sembl_stack-0.1.0.dist-info/RECORD +45 -0
- sembl_stack-0.1.0.dist-info/WHEEL +4 -0
- sembl_stack-0.1.0.dist-info/entry_points.txt +2 -0
- 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()
|