Skip to content

Commit 3778e17

Browse files
feat: runnable quality bar and escalate HITL (v0.7.1)
Prefer pytest over compileall for greenfield Python, run whitelisted acceptance commands after workers, and critique until smoke/static/acceptance pass. Pause on max-retry escalate with Studio modal + TUI /escalate (30s → re-scope). Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8af5aa2 commit 3778e17

26 files changed

Lines changed: 1015 additions & 43 deletions

‎Formula/slmcode.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
class Slmcode < Formula
1010
desc "Coding harness for SLMs and any OpenAI-compatible LLM"
1111
homepage "https://unicolab.ai"
12-
version "0.7.0"
12+
version "0.7.1"
1313
license "MIT"
1414

1515
on_macos do

‎Makefile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MODULE := github.com/UnicoLab/slmcode
22
BIN := slmcode
3-
VERSION ?= 0.7.0
3+
VERSION ?= 0.7.1
44
PREFIX ?= $(HOME)/.local
55
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
66
BUILD_TIME := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)

‎TODO.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,12 @@ prompt/params control. Studio Pipeline tab + dynamic progress header follow the
9898
Also: project completeness reference bar (LangGraph/FastAPI/CLI), real-query eval suite,
9999
loop SSE (`kind=loop`) with continue/abort UX, preserve harness tasks when capping to 8,
100100
docs for pipeline + updated agents/studio/config/architecture.
101+
102+
## Runnable quality bar + escalate HITL (v0.7.1)
103+
**Done.** Push SLM output past "compiles": greenfield Python QA → pytest; whitelisted
104+
acceptance commands run after workers; critique until smoke/static/acceptance green;
105+
syntax-only QA cannot alone mark run success.
106+
107+
Mid-execute escalate no longer banner-only: pipeline pauses that task with Studio modal
108+
+ `GET/POST /api/escalate/*`; TUI `/escalate re_scope|retry|mark_done|abort`;
109+
config `escalate_ask` / `escalate_ask_timeout` (default ask / 30s → re_scope).

‎cmd/slmcode/cmd_tui.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ import (
77
"path/filepath"
88
"strings"
99
"sync"
10+
"time"
1011

1112
"github.com/spf13/cobra"
1213

1314
"github.com/UnicoLab/slmcode/pkg/agents"
1415
"github.com/UnicoLab/slmcode/pkg/cli"
1516
"github.com/UnicoLab/slmcode/pkg/harness"
17+
"github.com/UnicoLab/slmcode/pkg/hitl"
1618
"github.com/UnicoLab/slmcode/pkg/orchestrator"
1719
"github.com/UnicoLab/slmcode/pkg/plan"
1820
"github.com/UnicoLab/slmcode/pkg/rewind"
@@ -124,6 +126,20 @@ func runPremiumTUI() error {
124126
_ = h.Config.Save()
125127
cli.KeyVal("plan_approve", h.Config.PlanApprove)
126128
return false, nil
129+
case "/escalate":
130+
action := plan.NormalizeEscalateAction(strings.TrimSpace(arg))
131+
if strings.TrimSpace(arg) == "" {
132+
return false, fmt.Errorf("usage: /escalate re_scope|retry|mark_done|abort")
133+
}
134+
ans := plan.EscalateAnswer{
135+
Action: action,
136+
AnsweredAt: time.Now().UTC().Format(time.RFC3339),
137+
}
138+
if err := hitl.WriteAnswers(h.Config.SlmDir(), "escalate", ans); err != nil {
139+
return false, err
140+
}
141+
fmt.Println(cli.Success("escalate → " + action))
142+
return false, nil
127143
case "/history":
128144
hist := sess.History()
129145
if hist == nil {

‎cmd/slmcode/ui/app.jsx‎

Lines changed: 115 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ function parseLoopEvent(e) {
126126

127127
function PipelineHeader({
128128
phase, running, liveAgent, counts, intervention, turnMeter,
129-
loopState, continueAsk, onContinue,
129+
loopState, continueAsk, escalateAsk, onContinue,
130130
pipeOrder, pipeMeta, pipeGroups, slots,
131131
}) {
132132
const order = pipeOrder && pipeOrder.length ? pipeOrder : DEFAULT_PIPE;
@@ -135,7 +135,7 @@ function PipelineHeader({
135135
const idx = pipeIndex(phase, order);
136136
const total = order.length;
137137
const looping = !!(loopState && !LOOP_DONE_ACTIONS.has(loopState.action));
138-
const awaiting = !!(continueAsk || (loopState && loopState.awaiting));
138+
const awaiting = !!(continueAsk || escalateAsk || (loopState && loopState.awaiting));
139139
const rewindIdx = looping ? pipeIndex(loopState.to || phase, order) : -1;
140140
const effectiveIdx = looping && rewindIdx >= 0 ? Math.min(idx, rewindIdx) : idx;
141141
const doneCount = phase === "done" && !looping ? total : Math.max(0, effectiveIdx);
@@ -791,6 +791,7 @@ function App() {
791791
const [clarifyAnswers, setClarifyAnswers] = useState({});
792792
const [planAsk, setPlanAsk] = useState(null);
793793
const [continueAsk, setContinueAsk] = useState(null);
794+
const [escalateAsk, setEscalateAsk] = useState(null);
794795
const [loopState, setLoopState] = useState(null);
795796
const [pipelineView, setPipelineView] = useState(null);
796797
const [pipeDraft, setPipeDraft] = useState(null);
@@ -1027,6 +1028,9 @@ function App() {
10271028
});
10281029
setClarifyAnswers(seed);
10291030
showToast("Scope interview — choose options or use recommended");
1031+
} else if (ask && ask.kind === "escalate") {
1032+
setEscalateAsk(ask);
1033+
showToast("⚠ " + (ask.task_id || "Task") + " needs your decision");
10301034
} else if (ask && ask.kind === "continue") {
10311035
setContinueAsk(ask);
10321036
showToast("Retries exhausted — continue another wave?");
@@ -1043,16 +1047,24 @@ function App() {
10431047
setClarifyAsk(null);
10441048
setPlanAsk(null);
10451049
setContinueAsk(null);
1050+
setEscalateAsk(null);
10461051
setShellAsk(null);
10471052
}
10481053
if (e.kind === "intervention") {
10491054
const banner = {
10501055
code: e.scope || "quality",
10511056
message: e.message || "harness intervention",
10521057
detail: e.output || "",
1058+
taskId: e.task_id || "",
10531059
};
10541060
setIntervention(banner);
10551061
showToast("⚠ " + banner.message);
1062+
if (banner.code === "escalate") {
1063+
// Recover modal if KindAsk raced / was missed.
1064+
api("/api/escalate/pending").then((p) => {
1065+
if (p && p.pending && p.ask) setEscalateAsk(p.ask);
1066+
}).catch(() => {});
1067+
}
10561068
}
10571069
if (e.kind === "loop") {
10581070
const loop = parseLoopEvent(e);
@@ -1128,6 +1140,7 @@ function App() {
11281140
setPhase("init");
11291141
setLoopState(null);
11301142
setContinueAsk(null);
1143+
setEscalateAsk(null);
11311144
setIntervention(null);
11321145
setNav("run"); // jump to Live so progress is obvious
11331146
try {
@@ -1359,6 +1372,30 @@ function App() {
13591372
showToast(labels[action] || action);
13601373
}
13611374

1375+
async function submitEscalate(action) {
1376+
if (!escalateAsk) return;
1377+
await api("/api/escalate/answer", {
1378+
method: "POST",
1379+
body: JSON.stringify({ action }),
1380+
});
1381+
const taskId = escalateAsk.task_id || "";
1382+
setEscalateAsk(null);
1383+
setIntervention(null);
1384+
setLoopState((prev) => prev ? {
1385+
...prev,
1386+
awaiting: false,
1387+
action: "escalate_resolved",
1388+
reason: (taskId || "task") + " → " + action,
1389+
} : prev);
1390+
const labels = {
1391+
re_scope: "Left in backlog for re-scope",
1392+
retry: "Retrying task",
1393+
mark_done: "Marked done",
1394+
abort: "Task aborted",
1395+
};
1396+
showToast((taskId ? taskId + ": " : "") + (labels[action] || action));
1397+
}
1398+
13621399
async function saveConfig(patch) {
13631400
// Send only patch keys — never round-trip Public() config (api_key "***", etc.).
13641401
const body = { ...patch };
@@ -1583,6 +1620,7 @@ function App() {
15831620
turnMeter={turnMeter}
15841621
loopState={loopState}
15851622
continueAsk={continueAsk}
1623+
escalateAsk={escalateAsk}
15861624
onContinue={submitContinue}
15871625
pipeOrder={livePipe.order}
15881626
pipeMeta={livePipe.meta}
@@ -2503,6 +2541,39 @@ function App() {
25032541
<option value="off">off</option>
25042542
</select>
25052543
</label>
2544+
<label>Escalate ask
2545+
<select
2546+
value={config.escalate_ask || "ask"}
2547+
onChange={(e) => saveConfig({ escalate_ask: e.target.value })}
2548+
>
2549+
<option value="ask">ask (pause for decision)</option>
2550+
<option value="auto">auto (retry once)</option>
2551+
<option value="off">off (leave in backlog)</option>
2552+
</select>
2553+
</label>
2554+
<label>Escalate timeout (sec)
2555+
<input
2556+
type="number"
2557+
min={5}
2558+
max={600}
2559+
defaultValue={
2560+
typeof config.escalate_ask_timeout === "number"
2561+
? Math.max(5, Math.round(config.escalate_ask_timeout / 1e9))
2562+
: 30
2563+
}
2564+
onBlur={(e) => saveConfig({ escalate_ask_timeout_sec: Number(e.target.value) })}
2565+
/>
2566+
</label>
2567+
<label>Continue ask
2568+
<select
2569+
value={config.continue_ask || "ask"}
2570+
onChange={(e) => saveConfig({ continue_ask: e.target.value })}
2571+
>
2572+
<option value="ask">ask (after QA exhausted)</option>
2573+
<option value="auto">auto (one more wave)</option>
2574+
<option value="off">off</option>
2575+
</select>
2576+
</label>
25062577
<label>Shell permission
25072578
<select
25082579
value={config.shell_permission || "allow"}
@@ -2655,6 +2726,45 @@ function App() {
26552726
</div>
26562727
) : null}
26572728

2729+
{escalateAsk ? (
2730+
<div className="modal-backdrop escalate-modal" style={{
2731+
position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 85,
2732+
display: "flex", alignItems: "center", justifyContent: "center", padding: 16,
2733+
}}>
2734+
<div className="card" style={{
2735+
maxWidth: 640, width: "100%", maxHeight: "85vh", overflow: "auto",
2736+
background: "var(--panel)", border: "1px solid var(--border)", borderRadius: 12, padding: 16,
2737+
}}>
2738+
<h2 style={{ marginTop: 0 }}>⚠ Human review needed</h2>
2739+
<p className="lead">{escalateAsk.summary || "Task escalated after max retries."}</p>
2740+
<p style={{ color: "var(--muted)", fontSize: "0.84rem" }}>
2741+
Pipeline is paused on this task
2742+
{escalateAsk.timeout_sec ? ` · timeout ${escalateAsk.timeout_sec}s → re-scope` : ""}
2743+
</p>
2744+
<p style={{ fontSize: "0.85rem" }}>
2745+
<strong>{escalateAsk.task_id}</strong>
2746+
{escalateAsk.title ? ` — ${escalateAsk.title}` : ""}
2747+
{escalateAsk.role ? ` · @${escalateAsk.role}` : ""}
2748+
</p>
2749+
{(escalateAsk.files || []).length > 0 ? (
2750+
<p style={{ fontSize: "0.8rem" }}><strong>Files:</strong> {escalateAsk.files.join(", ")}</p>
2751+
) : null}
2752+
{escalateAsk.detail ? (
2753+
<pre style={{
2754+
whiteSpace: "pre-wrap", fontSize: 12, maxHeight: 180, overflow: "auto",
2755+
background: "var(--bg)", padding: 10, borderRadius: 8,
2756+
}}>{escalateAsk.detail}</pre>
2757+
) : null}
2758+
<div className="row" style={{ gap: 8, justifyContent: "flex-end", marginTop: 12, flexWrap: "wrap" }}>
2759+
<button className="ghost danger" onClick={() => submitEscalate("abort")}>Abort task</button>
2760+
<button className="ghost" onClick={() => submitEscalate("re_scope")}>Re-scope / fix later</button>
2761+
<button className="ghost" onClick={() => submitEscalate("mark_done")}>Mark done</button>
2762+
<button onClick={() => submitEscalate("retry")}>Retry now</button>
2763+
</div>
2764+
</div>
2765+
</div>
2766+
) : null}
2767+
26582768
{continueAsk ? (
26592769
<div className="modal-backdrop" style={{
26602770
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", zIndex: 83,
@@ -2713,7 +2823,9 @@ function App() {
27132823
{health?.root ? health.root.replace(/^\/Users\/[^/]+/, "~") : "…"}
27142824
</span>
27152825
<span className="footer-live">
2716-
{continueAsk || (loopState && loopState.awaiting)
2826+
{escalateAsk
2827+
? `⏳ escalate ${escalateAsk.task_id || ""}?`
2828+
: continueAsk || (loopState && loopState.awaiting)
27172829
? "⏳ continue/abort?"
27182830
: loopState && !LOOP_DONE_ACTIONS.has(loopState.action)
27192831
? `↺ ${LOOP_LABELS[loopState.action] || "loop"}${loopState.wave ? " W" + loopState.wave : ""}`

‎cmd/slmcode/version.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ package main
44
//
55
// go build -ldflags "-X main.Version=0.5.0 -X main.SourceRoot=/path -X main.GitCommit=abc -X main.BuildTime=…"
66
var (
7-
Version = "0.7.0"
7+
Version = "0.7.1"
88
SourceRoot = "" // absolute path to the slmcode checkout used to build this binary
99
GitCommit = "unknown"
1010
BuildTime = "unknown"

‎docs/changelog.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Changelog
22

3+
## v0.7.1 — Runnable quality bar + escalate HITL
4+
5+
### Highlights
6+
- Greenfield Python QA defaults to **pytest** (fail closed), not `compileall`
7+
- **Acceptance smoke** — whitelisted commands from task acceptance run after each worker
8+
- Worker critique loops until smoke/static/acceptance green (bounded by `max_retries`)
9+
- Run success requires a **strong** QA gate — syntax-only cannot rubber-stamp incomplete boards
10+
- **Escalate HITL** — max-retry escalate pauses the task with a Studio modal (or TUI
11+
`/escalate`); options: re-scope / retry / mark done / abort; default timeout **30s**
12+
→ re-scope (`escalate_ask`, `escalate_ask_timeout`)
13+
14+
### API
15+
- `GET` / `POST` `/api/escalate/pending|answer`
16+
17+
---
18+
319
## v0.7.0 — Pipeline control + reference quality
420

521
### Highlights

‎docs/config.md‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ qa_gate: true
126126
qa_gate_command: "" # empty = auto-detect (go/pytest/uv/npm/compileall)
127127
qa_gate_max_rounds: 3
128128
post_worker_smoke: true # py_compile / go test after each worker before review
129+
escalate_ask: ask # ask | auto | off — pause on max-retry escalate
130+
escalate_ask_timeout: 30s # timeout → re_scope (Studio modal / TUI /escalate)
131+
continue_ask: ask # ask | auto | off — after QA exhausted
132+
continue_ask_timeout: 2m
129133
```
130134

131135
### Planning / scope
@@ -148,17 +152,24 @@ exit blocks the tool. PostToolUse can run `compileall` after writes.
148152
`/rewind list` / `/rewind <id>`, API `GET/POST /api/rewind`. Real context
149153
compact: `/compact context` or `POST /api/compact`.
150154

151-
### QA / smoke
155+
### QA / smoke / acceptance
152156

153157
After workers, `post_worker_smoke` runs a fast deterministic check (`python -m
154158
py_compile` / `go test -short`) and blocks approve-on-disk-only when it fails.
155159

160+
When a task's acceptance text includes a **whitelisted** command (`python -m
161+
pytest`, `go test`, `python main.py`, …), the harness also runs **Acceptance
162+
smoke** and rejects the task until those commands exit 0. Free-form prose in
163+
acceptance is never executed as shell.
164+
165+
`worker_critique` keeps refining (up to `max_retries`) while smoke / static /
166+
acceptance sections stay red — not just a single self-fix pass.
167+
156168
After the finalize tester, `qa_gate` runs a real project command (and bootstraps
157169
deps when needed: `pip install -r requirements.txt`, `uv sync`, `go mod tidy`).
158-
Auto-detect covers `go test`, `python -m pytest` / `uv run pytest`,
159-
`python -m compileall`, and `npm test` / `cargo test` / `make test` — never
160-
`--help` as a quality proof. On failure, tester diagnoses → corrector patches →
161-
re-run.
170+
Auto-detect prefers `pytest` for greenfield Python (`main.py` +
171+
`requirements.txt`), not `compileall`. Syntax-only gates cannot alone mark the
172+
run successful. On failure, tester diagnoses → corrector patches → re-run.
162173

163174
---
164175

‎docs/install.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ One-liners fetch a shiny GitHub Release. Keep the compiler for contributing (or
7575
7676
```bash
7777
curl -fsSL https://raw.githubusercontent.com/UnicoLab/smlcode/main/scripts/install-remote.sh \
78-
| bash -s -- --version v0.7.0
78+
| bash -s -- --version v0.7.1
7979
```
8080
8181
=== "🪟 PowerShell"

‎docs/studio.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,32 @@ The loop reloads `board.json` each wave. Chaos, but *structured* chaos.
5555

5656
---
5757

58+
## Human-in-the-loop modals ✋
59+
60+
Studio blocks the relevant step with a modal (pipeline header shows **Awaiting you**):
61+
62+
| Modal | When | Options | Timeout default |
63+
|-------|------|---------|-----------------|
64+
| Clarify | vague PRD interview | pick options | 2m → recommended |
65+
| Plan approve | before execute | approve / replan | 2m → approve |
66+
| **Escalate** | task hits max review retries | **re-scope / retry / mark done / abort** | **30s → re-scope** |
67+
| Continue | QA/retries exhausted | continue / stop / flag | 2m → stop |
68+
| Shell | `shell_permission=ask` | approve / deny | 2m |
69+
70+
Config (Settings → Planning / scope, or YAML):
71+
72+
```yaml
73+
escalate_ask: ask # ask | auto | off
74+
escalate_ask_timeout: 30s
75+
continue_ask: ask
76+
```
77+
78+
TUI: `/escalate re_scope|retry|mark_done|abort` while the banner shows escalate pending.
79+
80+
API: `GET /api/escalate/pending` · `POST /api/escalate/answer` `{"action":"retry"}`.
81+
82+
---
83+
5884
## Live events (SSE) 📡
5985

6086
Same stream as `slmcode run -v`:
@@ -86,6 +112,8 @@ curl -N http://127.0.0.1:7420/api/events
86112
| `GET` | `/api/skills` `/api/models` |
87113
| `POST` | `/api/runs` `/api/runs/stop` |
88114
| `GET` | `/api/runs/latest` `/api/events` |
115+
| `GET`/`POST` | `/api/escalate/pending` · `/api/escalate/answer` |
116+
| `GET`/`POST` | `/api/continue/pending` · `/api/continue/answer` |
89117
| `GET` | `/` SPA |
90118

91119
→ Full pipeline schema: [Pipeline](pipeline.md)

0 commit comments

Comments
 (0)