unstuck / PROMPTS.md
art87able's picture
feat: similar-task recall via Nebius embeddings (keyless; recall degrades off without NEBIUS_API_KEY)
79e91df verified
|
Raw
History Blame Contribute Delete
68.8 kB
# Unstuck — Codex Prompt Pack
Per-task prompts for building Unstuck with the **OpenAI Codex CLI**. The Codex Track judges
*how well you used Codex*, so these prompts are part of the submission — keep them tight and scoped.
## How to run this
- **One task at a time.** Run Codex on exactly one prompt below, as a single one-shot run — not a
long agentic session. This keeps token use low and produces a clean, auditable per-task commit
trail (the actual showcase for the Codex Track).
- **Review between tasks.** After each run, read the diff and the test output before starting the
next prompt. Confirm the named files (and only those) changed and the stated test command passes.
- **No scope creep.** Each prompt names the only files that task may touch and the only commit it
may make. If Codex wants to do more, stop and re-scope.
- **Attribution:** Codex-attributed commits are permitted **in this repo only** (Codex Track rule).
Let Codex author and attribute its own commits here; do not strip that attribution.
- **Order matters.** Run Task 0 → Task 9 in sequence; later tasks import earlier modules.
- Run from the `unstuck/` repo root. Source lives under `src/unstuck/`; tests under `tests/`.
---
## Task 0 — Project scaffold
```
Implement ONLY the project scaffold for Unstuck. Touch only these files: requirements.txt,
src/unstuck/__init__.py, tests/__init__.py, pytest.ini.
1. Create requirements.txt with exactly:
gradio>=4.44
pytest>=8.0
(Do NOT add a model backend dep — that comes in a later task.)
2. Create the package + test init files:
mkdir -p src/unstuck tests scripts docs/deliverables
printf 'from __future__ import annotations\n' > src/unstuck/__init__.py
: > tests/__init__.py
3. Create pytest.ini with:
[pytest]
pythonpath = . src
testpaths = tests
4. Verify config loads — run: python -m pytest -q
Expected: "no tests ran" (exit code 5). This is success, not failure.
5. Commit with message: "chore: project scaffold (pytest, package layout)"
Stage only: requirements.txt src/unstuck/__init__.py tests/__init__.py pytest.ini
Do not write any application code. Make exactly one commit, authored and attributed by Codex.
```
---
## Task 1 — Step schema + validator
```
Implement ONLY the step schema. Touch only: src/unstuck/schema.py and tests/test_schema.py.
Follow TDD strictly: failing test first, then implementation.
1. Write tests/test_schema.py covering: a valid payload parses (est_minutes coerced to int),
missing "steps" key raises StepValidationError, empty steps list raises, a bad category raises,
a non-positive estimate raises, an estimate over 25 minutes (e.g. 40) raises, blank/whitespace
text raises, and CATEGORIES equals {"admin","creative","errand","deep-work"}. Import from
unstuck.schema: validate_steps_payload, StepValidationError, CATEGORIES.
2. Run: python -m pytest tests/test_schema.py -q
Expected: FAIL with ModuleNotFoundError: No module named 'unstuck.schema'.
3. Implement src/unstuck/schema.py: CATEGORIES = ("admin","creative","errand","deep-work");
a StepValidationError(ValueError); dataclasses Step(text,category,est_minutes) and
Steps(task, steps); and validate_steps_payload(payload)->Steps that validates structure,
rejects bool est_minutes, coerces est_minutes to an int via int(round(...)) and requires
0 < est_minutes <= 25 (a step over 25 min is invalid — this routes to the model adapter's
repair retry so the model splits it), strips text, and returns Steps(task="", steps=[...]).
Raise StepValidationError on any structural problem.
4. Run: python -m pytest tests/test_schema.py -q
Expected: PASS (8 passed).
5. Commit: "feat: typed step schema + payload validator"
Stage only: src/unstuck/schema.py tests/test_schema.py
One commit, Codex-attributed. Do not touch any other file.
```
---
## Task 2 — Calibration (time-bias multiplier)
```
Implement ONLY the calibration unit. Touch only: src/unstuck/calibration.py and
tests/test_calibration.py. TDD: failing test first.
1. Write tests/test_calibration.py importing multiplier, calibrate, MIN_SAMPLES from
unstuck.calibration. Cover: no history -> 1.0; a category's multiplier is the MEDIAN of its
actual/estimated ratios once it has >= MIN_SAMPLES samples; with fewer than MIN_SAMPLES in the
target category it falls back to the global median across all records (assert MIN_SAMPLES == 3);
records with zero/missing est or actual are ignored; calibrate(10, 3.0) == 30 and
calibrate(10, 0.04) == 1 (rounds, floors at 1, never returns 0).
2. Run: python -m pytest tests/test_calibration.py -q
Expected: FAIL with ModuleNotFoundError: No module named 'unstuck.calibration'.
3. Implement src/unstuck/calibration.py: MIN_SAMPLES = 3; a helper that yields valid actual/est
ratios (optionally filtered by category, skipping zero/missing values); multiplier(category,
records) using the category median when it has >= MIN_SAMPLES valid ratios else the global
median else 1.0; and calibrate(raw_minutes, mult) = max(1, int(round(raw_minutes * mult))).
4. Run: python -m pytest tests/test_calibration.py -q
Expected: PASS (5 passed).
5. Commit: "feat: per-category time-bias calibration"
Stage only: src/unstuck/calibration.py tests/test_calibration.py
One commit, Codex-attributed. No other files.
```
---
## Task 3 — SQLite store
```
Implement ONLY the SQLite store. Touch only: src/unstuck/store.py and tests/test_store.py.
TDD: failing test first. Depends on unstuck.schema.Step (already implemented).
1. Write tests/test_store.py importing Store from unstuck.store and Step from unstuck.schema.
Cover: a roundtrip — add_task(text, now=) returns an id, add_steps(task_id, [Step...]),
first_step_id(task_id), record_actual(step_id, category, est, actual, now=), and get_records()
returns [{"category","est_minutes","actual_minutes"}]; and that export_json() returns JSON with
"tasks","steps","records" tables populated and steps[0]["category"] == "admin". Use
Store(":memory:").
2. Run: python -m pytest tests/test_store.py -q
Expected: FAIL with ModuleNotFoundError: No module named 'unstuck.store'.
3. Implement src/unstuck/store.py: a Store(path=":memory:") opening sqlite3 with row_factory =
sqlite3.Row, creating task(id,text,created_at), step(id,task_id,text,category,est_minutes,ord),
record(id,step_id,category,est_minutes,actual_minutes,completed_at). Methods: add_task,
add_steps (ordered by insertion via an ord column), first_step_id, record_actual, get_records,
export_json (json.dumps with the three tables, indent=2). now= params default to time.time().
4. Run: python -m pytest tests/test_store.py -q
Expected: PASS (2 passed).
5. Commit: "feat: SQLite store with export"
Stage only: src/unstuck/store.py tests/test_store.py
One commit, Codex-attributed. No other files.
```
---
## Task 4 — Prompts
```
Implement ONLY the prompt builders. Touch only: src/unstuck/prompts.py and tests/test_schema.py
(extend the existing file — do NOT rewrite the schema tests already there). TDD: failing test first.
1. Append two tests to tests/test_schema.py, importing breakdown_prompt and repair_prompt from
unstuck.prompts: breakdown_prompt("write the quarterly review") contains that task text, the
word "json" (case-insensitive), every category in CATEGORIES, and a mention of the 25-minute
hard maximum (assert "25" is in the prompt); repair_prompt("task x", "GARBLED{", "no JSON
object found") contains both the bad output and the error string.
2. Run: python -m pytest tests/test_schema.py -q
Expected: FAIL with ModuleNotFoundError: No module named 'unstuck.prompts'.
3. Implement src/unstuck/prompts.py importing CATEGORIES from unstuck.schema. A system block that
instructs the model to break ONE task into 4-8 tiny ordered ADHD-friendly steps, each a single
action, each with a positive-integer minute estimate that NEVER exceeds 25 minutes (hard max —
split anything bigger into multiple steps) and exactly one category from CATEGORIES,
returning ONLY a JSON object {"steps":[{"text","category","est_minutes"}]} with no prose/fence.
breakdown_prompt(task) = system block + a one-line few-shot example + the quoted task.
repair_prompt(task, bad_output, error) restates the error, echoes the previous reply, and asks
for ONLY the JSON object in the exact schema.
4. Run: python -m pytest tests/test_schema.py -q
Expected: PASS (10 passed).
5. Commit: "feat: breakdown + repair prompts"
Stage only: src/unstuck/prompts.py tests/test_schema.py
One commit, Codex-attributed. No other files.
```
---
## Task 5 — Model adapter (validation + repair retry)
```
Implement ONLY the model adapter. Touch only: src/unstuck/model_adapter.py and
tests/test_model_adapter.py. TDD: failing test first. Depends on unstuck.prompts and unstuck.schema.
The LLM is injected as a generate(prompt)->str callable — NEVER import or load a real model.
1. Write tests/test_model_adapter.py importing ModelAdapter from unstuck.model_adapter and
StepValidationError from unstuck.schema. Use a helper that builds a generate() yielding canned
responses in order. Cover: parses good JSON on the first try and sets steps.task to the input
task; strips surrounding prose and a ```json code fence; repairs after one bad reply
(ModelAdapter(make([GARBLED, GOOD]), max_repairs=1) succeeds); raises StepValidationError after
exhausting repairs (make([GARBLED, GARBLED]), max_repairs=1).
2. Run: python -m pytest tests/test_model_adapter.py -q
Expected: FAIL with ModuleNotFoundError: No module named 'unstuck.model_adapter'.
3. Implement src/unstuck/model_adapter.py: a _extract_json(text) that regex-matches the first
{...} (re.DOTALL), raising StepValidationError("no JSON object found") if none and on JSON
decode error; ModelAdapter(generate, max_repairs=1) with breakdown(task)->Steps that calls
generate(breakdown_prompt(task)), validates via validate_steps_payload(_extract_json(raw)),
sets steps.task = task on success, and on StepValidationError re-prompts with repair_prompt up
to max_repairs times before re-raising.
4. Run: python -m pytest tests/test_model_adapter.py -q
Expected: PASS (4 passed).
5. Commit: "feat: model adapter with JSON validation + repair retry"
Stage only: src/unstuck/model_adapter.py tests/test_model_adapter.py
One commit, Codex-attributed. No other files.
```
---
## Task 6 — Service (breakdown → calibrate → store)
```
Implement ONLY the application service. Touch only: src/unstuck/service.py and
tests/test_service.py. TDD: failing test first. Ties together model_adapter, calibration, store.
LLM injected as generate(prompt)->str — never load a real model.
1. Write tests/test_service.py importing Unstuck from unstuck.service and Store from unstuck.store,
with a constant GOOD = '{"steps":[{"text":"Open doc","category":"admin","est_minutes":10}]}'.
Cover: Unstuck(generate=lambda p: GOOD, store=Store(":memory:")).breakdown("write review")
returns a view with task_id > 0 and rows[0].raw_minutes == 10 and (no history) calibrated_minutes
== 10; and after seeding 3 admin records each 3x over estimate (via breakdown + log_actual),
a fresh breakdown shows raw_minutes == 10 and calibrated_minutes == 30.
2. Run: python -m pytest tests/test_service.py -q
Expected: FAIL with ModuleNotFoundError: No module named 'unstuck.service'.
3. Implement src/unstuck/service.py: dataclasses StepRow(step_id,text,category,raw_minutes,
calibrated_minutes) and BreakdownView(task_id, rows). Unstuck(generate, store, max_repairs=1)
wraps a ModelAdapter. breakdown(task): adapter.breakdown -> store.add_task -> store.add_steps ->
read records -> map persisted step ids (ordered by ord) to steps, per-row apply
multiplier(category, records) and calibrate(...). log_actual(step_id, actual_minutes): look up
the step's category + est_minutes and store.record_actual(...).
4. Run: python -m pytest tests/test_service.py -q
Expected: PASS (2 passed).
5. Run the whole suite — python -m pytest -q — expect all passing, then commit.
Commit: "feat: Unstuck service (breakdown -> calibrate -> store)"
Stage only: src/unstuck/service.py tests/test_service.py
One commit, Codex-attributed. No other files.
```
---
## Task 7 — Hybrid inference backend (ZeroGPU default + HF Inference fallback) + bake-off harness
> Updated 2026-06-09 (see docs/superpowers/specs/2026-06-09-unstuck-inference-addendum.md): model
> LOCKED to Qwen/Qwen3-4B-Instruct-2507; backend is a hybrid; ZeroGPU is free via the
> build-small-hackathon Team org (the personal account is not PRO).
```
Implement ONLY the inference backend + bake-off harness. Touch only: scripts/bakeoff.py,
src/unstuck/backend.py, and requirements.txt. This task has NO unit test — backend.py and
bakeoff.py import real models / call the network and MUST never be imported by the test suite.
Context: the model is LOCKED to Qwen/Qwen3-4B-Instruct-2507 (native qwen3 arch, no
trust_remote_code). backend.py is a HYBRID exposing one generate(prompt)->str selected by the env
var UNSTUCK_BACKEND (default "zerogpu"): a ZeroGPU/transformers path (model on the Space GPU) and an
"hf_inference" fallback (huggingface_hub InferenceClient -> nscale/featherless).
1. Write scripts/bakeoff.py: a MANUAL harness (puts "src" on sys.path) with ~5 SAMPLE_TASKS and a
MODELS list of <=4B candidates, Qwen/Qwen3-4B-Instruct-2507 first (MiniCPM / Nemotron-Nano as
alternates). make_generate(model_id) returns generate(prompt)->str backed by
huggingface_hub.InferenceClient(model_id).chat_completion (so the bake-off scores JSON-validity
over the serverless API without local weights). score(model_id) runs ModelAdapter(gen,
max_repairs=1).breakdown over the tasks and returns the valid-JSON success rate; __main__ prints
each model's rate. Keep all real network calls under the __main__ guard.
2. Implement src/unstuck/backend.py with MODEL_ID = "Qwen/Qwen3-4B-Instruct-2507" and
BACKEND = os.environ.get("UNSTUCK_BACKEND", "zerogpu"):
- If BACKEND == "zerogpu": import spaces; import torch and transformers; load the tokenizer and
AutoModelForCausalLM on cuda at MODULE SCOPE (device_map="cuda", torch_dtype="auto"); define
generate(prompt)->str decorated @spaces.GPU(duration=30) that builds a chat prompt via
tokenizer.apply_chat_template(..., add_generation_prompt=True), runs
model.generate(max_new_tokens=512, do_sample=False), decodes ONLY the newly generated tokens,
and returns a plain str (NEVER a CUDA tensor).
- Elif BACKEND == "hf_inference": from huggingface_hub import InferenceClient; create
InferenceClient(MODEL_ID); define generate(prompt)->str calling
.chat_completion(messages=[{"role":"user","content":prompt}], max_tokens=512, temperature=0)
and returning choices[0].message.content.
Put each branch's heavy imports INSIDE that branch so importing the module with the other backend
selected stays cheap. Do NOT import spaces/torch at top level outside the zerogpu branch.
3. Replace requirements.txt with the ZeroGPU runtime set (gradio is pinned via the README
sdk_version, NOT here; never list spaces — the platform pins it):
pytest>=8.0
torch==2.8.0
transformers
accelerate
huggingface_hub
4. Sanity: run python -m pytest -q — the existing suite (23 tests) must still pass (bakeoff/backend
are not collected). Do NOT run scripts/bakeoff.py or import backend.py here (they need real
models / network).
5. Commit: "feat: hybrid inference backend (ZeroGPU default + HF Inference fallback) + bake-off harness"
Stage only: scripts/bakeoff.py src/unstuck/backend.py requirements.txt
One commit, Codex-attributed. No other files.
```
---
## Task 8 — Gradio app + smoke test
> Updated 2026-06-09: include the data-export button (Space storage is ephemeral — see the inference
> addendum / ZEROGPU_SPACE_NOTES §4); the smoke test importorskips gradio (gradio is supplied by the
> Space `sdk_version`, not listed in `requirements.txt`).
```
Implement ONLY the Gradio UI and its smoke test. Touch only: app.py and tests/test_app_smoke.py.
TDD: failing test first. The smoke test must not load a model or hit the network — it injects a
fake generate() and a :memory: Store, and must importorskip gradio (gradio is provided by the Space
sdk_version, not listed in requirements.txt).
1. Write tests/test_app_smoke.py: at module top do `gr = pytest.importorskip("gradio")` so the test
skips cleanly when gradio is absent. Then import app, build an Unstuck with
generate=lambda p: '{"steps":[{"text":"x","category":"admin","est_minutes":3}]}' and
Store(":memory:"), call app.build_ui(svc), and assert the returned gr.Blocks is not None.
(build_ui MUST accept an injected service so tests never load a model.)
2. Run: python -m pytest tests/test_app_smoke.py -q
Expected: FAIL — No module named 'app' (or build_ui missing).
3. Implement app.py: put "src" on sys.path, import gradio. build_ui(service: Unstuck) -> gr.Blocks
with: a task textbox; a "Break it down" button rendering a markdown table of step #, text, AI est
(raw_minutes), and "For you" (calibrated_minutes) held in gr.State; an accordion to log a step's
actual minutes (calls service.log_actual); and an "Export my data (JSON)" control that writes
service.store.export_json() to a tempfile and serves it for download (gr.DownloadButton or a
button + gr.File) — the escape hatch for ephemeral Space storage. A main() (mark # pragma: no
cover) that makes the DB dir, imports backend.generate, builds the service, and launches. Guard
with if __name__ == "__main__".
4. Run: python -m pytest tests/test_app_smoke.py -q
Expected: PASS (1 passed).
5. Run the full suite — python -m pytest -q — expect all passing. (Commit handled by reviewer — DO
NOT run git.) Intended message: "feat: Gradio UI (raw vs calibrated, data export) + smoke test",
staging only app.py tests/test_app_smoke.py.
```
---
## Task 9 — Space card + deliverable drafts
> Updated 2026-06-09: README front-matter is the ZeroGPU Gradio Space config (sdk_version pinned to
> the tested gradio, python_version 3.12.12); privacy framing corrected; include the GitHub repo
> link (OpenAI Codex Track rule).
```
Implement ONLY the docs/deliverables. Touch only: README.md, docs/deliverables/demo-script.md,
docs/deliverables/social-post.md. No code, no tests.
1. Write README.md as a Hugging Face ZeroGPU Gradio Space card. YAML front-matter exactly:
---
title: Unstuck
emoji: 🧩
colorFrom: indigo
colorTo: purple
sdk: gradio
sdk_version: "6.17.3"
python_version: "3.12.12"
app_file: app.py
pinned: false
---
Do NOT add a `spaces` line or a hardware key (ZeroGPU is selected in Space Settings). Then:
- A short description: Unstuck turns one overwhelming task into tiny timed steps, then learns your
personal time-blindness and recalibrates the estimates to you. Built for the HF Build Small
Hackathon (Backyard AI track). Runs a ≤4B model (Qwen/Qwen3-4B-Instruct-2507).
- A privacy line: "Runs a ≤4B model you can host yourself; your task history stays in the app's
own store and is never sent to a third-party LLM."
- A "Run locally" block: `pip install -r requirements.txt gradio`, then for the lightweight path
`UNSTUCK_BACKEND=hf_inference HF_TOKEN=... python app.py` (the default backend is `zerogpu`,
which the Space uses). Note Space storage is ephemeral — hence the in-app **Export** button.
- A "Source" line linking the GitHub repo: https://github.com/art87able/unstuck (Codex Track).
2. Write docs/deliverables/demo-script.md: a ~90-second timed demo script (hook about ADHD task
initiation + time-blindness; break-it-down; log a couple of actuals over the estimate; re-run a
similar task showing the raw AI estimate vs the higher "for you" estimate; the ≤4B "you can run
it yourself" line; close on the repo/Space link). Mark it "USER RECORDS".
3. Write docs/deliverables/social-post.md: a short #BuildSmallHackathon post — paste a task, get tiny
timed steps, it learns your personal time-blindness, runs on a ≤4B model, built for an ADHD brain,
with a [Space link] placeholder. Mark it "USER POSTS".
4. (Commit handled by reviewer — DO NOT run git.) Intended message: "docs: Space card + demo script
+ social post drafts", staging only README.md docs/deliverables/.
(Creating the Space, recording the video, and posting are manual user steps — not part of this task.)
```
---
## Task 10 — Prompt tuning (better breakdowns from the 4B model)
> Added 2026-06-10: the v1 prompt's single-step example biases the small model toward terse,
> mono-category output. Tune the prompt; the schema and adapter stay untouched.
```
Improve ONLY the model prompts. Touch only: src/unstuck/prompts.py and tests/test_prompts.py (new).
Follow TDD: failing tests first, then the prompt changes. Do NOT modify schema.py, model_adapter.py,
or any other file.
1. Write tests/test_prompts.py covering breakdown_prompt and repair_prompt:
- breakdown_prompt(task) contains the task text, every category name from unstuck.schema.CATEGORIES,
and the literal substring '"steps"'.
- The few-shot example inside breakdown_prompt is itself VALID: extract the example JSON object
from the prompt (regex the line containing '"steps"' in the example block) and pass it through
unstuck.schema.validate_steps_payload — it must parse with 3+ steps, more than one distinct
category, every est_minutes <= 25, and the FIRST example step's est_minutes <= 5.
- breakdown_prompt mentions a tiny starter first step (assert "first step" appears, case-insensitive).
- repair_prompt(task, bad, err) contains the task, the bad output, the error text, and '"steps"'.
2. Run: python -m pytest tests/test_prompts.py -q — expect FAIL on the new assertions.
3. Rewrite the prompt text in src/unstuck/prompts.py (keep the function signatures and the
JSON-only output contract exactly as-is):
- Keep: 4-8 ordered steps, one concrete action each, positive-integer minutes, 25-minute hard max,
JSON-only with the exact {"steps":[{"text","category","est_minutes"}]} schema.
- Add a one-line definition per category: admin (forms, email, scheduling, tidying),
creative (writing, design, making something new), errand (leaving the house or fetching/buying),
deep-work (sustained focused thinking or problem-solving).
- Add: the FIRST step must be a tiny starter action of 5 minutes or less that gets the person
physically moving or the file/page open (beats ADHD task-initiation paralysis).
- Add: every step text starts with an imperative verb; no vague steps like "work on it".
- Replace the one-step example with ONE worked example task ("Clean my apartment before a friend
visits tonight") whose JSON answer has 4 steps, at least two distinct categories, a first step
of <=5 minutes, and all estimates <=25. Keep the example on a single line so it stays easy to
regex in tests.
- repair_prompt: keep its structure, just inherit the improved system block.
4. Run: python -m pytest -q — expect the FULL suite passing (existing 24 tests + the new ones).
5. (Commit handled by reviewer — DO NOT run git.) Intended message:
"feat(prompts): category definitions, starter-step rule, richer few-shot example"
staging only src/unstuck/prompts.py tests/test_prompts.py.
```
## Task 11 — Plan summary chip + graceful backend errors
> Added 2026-06-11: demo polish. Two UI-layer improvements; the service and store stay untouched.
```
Improve ONLY the Gradio layer. Touch only: app.py and tests/test_app_smoke.py.
Follow TDD where testable: extend the smoke test first, then implement. Do NOT modify anything
under src/unstuck/ or any other file. Do NOT run git.
1. Plan summary chip. In app.py, add a helper `summary_html(rows) -> str` at module level
(so tests can import it without building the UI):
- rows is the same list-of-dicts shape used in rows_state (keys: logged, actual_minutes,
calibrated_minutes, raw_minutes).
- Returns "" for empty rows.
- Otherwise returns a div with class "summary" reading like:
"For you: ~{total_for_you} min total · AI estimate: {total_raw} min"
where total_for_you sums actual_minutes for logged rows plus calibrated_minutes for
unlogged rows, and total_raw sums raw_minutes.
- If at least one row is logged, append " · {n_done}/{n} done".
Render it via a gr.HTML placed between the readout and the step list, updated by the same
events that update rows_state (break_down and every Done click). Add a matching .summary
CSS rule consistent with the existing calm-minimal style (similar to .readout but neutral
stone background, centered).
2. Graceful backend errors. In break_down, wrap the service.breakdown call in try/except
Exception: on failure, call gr.Warning with a short friendly message and return
([], <explainer div>, "") where the explainer says the model backend is busy or out of
GPU quota and suggests trying again in a minute (mention that logging in to Hugging Face
raises the free ZeroGPU quota). Never let a traceback reach the UI. Keep the existing
empty-input branch behaviour (adjusted for the new third output).
3. Tests in tests/test_app_smoke.py (keep the existing test):
- summary_html([]) == ""
- summary_html with two unlogged rows (calibrated 10+20, raw 8+15) contains "30" and "23".
- summary_html with one logged row (actual 12) + one unlogged (calibrated 20, raw 15+15)
contains "32" and "30" and "1/2 done".
- build_ui still returns a gr.Blocks when given a service whose generate raises
RuntimeError (constructing the UI must not call the backend).
4. Run: python -m pytest -q — full suite green (existing tests + new ones).
5. (Commit handled by reviewer.) Intended message:
"feat(ui): plan summary chip + graceful backend-error handling"
staging only app.py tests/test_app_smoke.py.
```
## Task 12 — "Still stuck?" per-step re-breakdown
> Added 2026-06-11: the core ADHD loop closed recursively — any step can itself be the new
> overwhelming task. UI-layer feature reusing the existing service; src/unstuck/ stays untouched.
```
Touch only: app.py and tests/test_app_smoke.py. TDD where testable. Do NOT modify anything
under src/unstuck/. Do NOT run git. Note: app.py already has a plan-summary chip
(summary_html) and a 3-output break_down (rows, explainer/readout, summary) from Task 11 —
keep all of that working.
1. Module-level helper `splice_rows(rows, step_id, new_rows) -> list` in app.py:
- Returns a new list where the single row whose "step_id" == step_id is replaced, in
place, by the rows in new_rows (same dict shape). All other rows keep their order.
- If step_id is not present, return rows unchanged.
2. UI: on every UNLOGGED step row (next to the "took (min) → Done" controls), add a small
secondary button "Still stuck?". Clicking it:
- Calls service.breakdown(<that step's text>) — this creates a new task in the store and
returns calibrated rows; map them through the same view-rows dict shape already used.
- Splices the sub-steps in place of the clicked step via splice_rows, updates rows_state,
the readout, and the summary chip (same outputs as the Done click).
- On backend failure (any Exception): gr.Warning with a short friendly message (busy /
out of GPU quota, try again in a minute) and return the rows UNCHANGED.
- Guard: if the current rows list already has 16 or more rows, warn
("That's plenty of steps — try starting the first tiny one") and do nothing.
3. Tests in tests/test_app_smoke.py (keep all existing tests):
- splice_rows replaces the middle row of three with two new rows → length 4, order
preserved (ids before/after intact).
- splice_rows with an unknown step_id returns the input list equal to itself.
- build_ui still returns gr.Blocks (existing tests keep passing).
4. Run: python -m pytest -q — full suite green.
5. (Commit handled by reviewer.) Intended message:
"feat(ui): per-step re-breakdown via Still stuck? button"
staging only app.py tests/test_app_smoke.py.
```
## Task 13 — Nebius serverless backend (third branch of the hybrid seam)
> Added 2026-06-11: entry path for the Nebius Serverless AI Builders Challenge (deadline
> 2026-06-30). Same `generate(prompt) -> str` contract; no new dependencies — Nebius AI Studio
> is OpenAI-compatible and huggingface_hub's InferenceClient accepts a base_url.
```
Touch ONLY: src/unstuck/backend.py and tests/test_backend.py (new). TDD: failing tests first.
Do NOT modify any other file. Do NOT run git.
1. Tests in tests/test_backend.py. backend.py selects its branch from env at import time, so
each test must set env vars with monkeypatch BEFORE importing/reloading the module
(importlib.reload). Do NOT let any test import torch, spaces, or hit the network:
- With UNSTUCK_BACKEND=nebius and NEBIUS_API_KEY=dummy: monkeypatch
huggingface_hub.InferenceClient with a fake class that records its constructor kwargs and
whose chat_completion returns a canned object shaped like the real response
(choices[0].message.content == "ok"). Assert: backend.generate("hi") == "ok"; the fake
received base_url == backend.NEBIUS_BASE_URL and api_key == "dummy"; chat_completion was
called with temperature=0 and max_tokens=512 and the prompt in the messages.
- With UNSTUCK_BACKEND=nebius and NEBIUS_API_KEY unset: importing/reloading backend raises
a clear error mentioning NEBIUS_API_KEY.
- With UNSTUCK_BACKEND=bogus: reload raises ValueError mentioning "bogus".
2. Run: python -m pytest tests/test_backend.py -q — expect FAIL.
3. Implement in src/unstuck/backend.py:
- Module constants: NEBIUS_BASE_URL = os.environ.get("NEBIUS_BASE_URL",
"https://api.studio.nebius.com/v1/") and NEBIUS_MODEL = os.environ.get("NEBIUS_MODEL",
MODEL_ID).
- New elif BACKEND == "nebius" branch (keep zerogpu and hf_inference exactly as-is):
read NEBIUS_API_KEY from env, raise RuntimeError("NEBIUS_API_KEY is required for the
nebius backend") if missing; client = InferenceClient(base_url=NEBIUS_BASE_URL,
api_key=key); generate(prompt) mirrors the hf_inference branch (model=NEBIUS_MODEL passed
to chat_completion, max_tokens=512, temperature=0, returns str of the message content).
- Import huggingface_hub inside the branch (heavy imports stay lazy, same as the others).
4. Run: python -m pytest -q — FULL suite green (34 existing + the new ones).
5. (Commit handled by reviewer.) Intended message:
"feat(backend): nebius serverless branch (UNSTUCK_BACKEND=nebius)"
staging only src/unstuck/backend.py tests/test_backend.py.
```
## Task 14 — Import my data (restore an exported JSON)
> Added 2026-06-11: Space storage is ephemeral — a restart wipes the SQLite file, losing the
> user's calibration history. Export already exists; this adds the matching import so users
> own their data round-trip. Usefulness + privacy story.
```
Touch ONLY: src/unstuck/store.py, app.py, tests/test_store.py, tests/test_app_smoke.py.
TDD: failing tests first. Do NOT run git.
1. Store: add `import_json(payload: str) -> dict` to Store:
- Parses the JSON produced by export_json() ({"tasks":[...],"steps":[...],"records":[...]}).
- Validates the shape: top-level keys present and lists; every record row has category (str),
est_minutes (int>0), actual_minutes (int>0). On any violation raise ValueError with a
short reason — never partially import.
- Inserts ONLY the records table rows (tasks/steps are session UI state; records are what
calibration needs). Insert with the original category/est/actual/completed_at but fresh
autoincrement ids; step_id may not resolve after a wipe — store it as given.
- Idempotence guard: skip a row if an identical (category, est_minutes, actual_minutes,
completed_at) row already exists.
- Returns {"imported": n, "skipped": m}.
- Tests in tests/test_store.py: round-trip (export from one store, import into a fresh one,
get_records() equal); re-import → all skipped; malformed JSON and bad rows raise ValueError.
2. UI in app.py: an "Import my data (JSON)" gr.UploadButton next to the export button.
Handler reads the uploaded file (utf-8), calls service.store.import_json, then returns
updated readout + summary (same outputs as Done clicks: rows_state untouched -> recalibrate
visible rows via the existing recalibrated() helper so unlogged chips refresh, plus readout
and summary). On ValueError or OSError: gr.Warning("That file doesn't look like an Unstuck
export.") and leave everything unchanged. Smoke test: module-level behaviour only — build_ui
still returns Blocks; plus a direct test that the import handler logic is testable if you
expose a module-level helper `parse_import(file_path, store) -> str` returning a short
status string ("Imported N records (M duplicates skipped)").
3. Run: python -m pytest -q — FULL suite green (37 + new).
4. (Commit handled by reviewer.) Intended message:
"feat(store,ui): import exported JSON to restore calibration history"
staging only the four named files.
```
## Task 15 — Built-in step timer (Start → Done auto-fills minutes)
> Added 2026-06-11: manually tracking minutes is the one thing time-blind users can't do —
> the app currently asks them to do it. A Start button stamps the clock; Done computes elapsed
> minutes automatically. Manual entry stays as an override.
```
Touch ONLY: app.py and tests/test_app_smoke.py. TDD: failing tests first. Do NOT run git.
1. Module-level pure helper in app.py:
def finish_minutes(manual: float | None, started_at: float | None, now: float) -> int | None
- If manual is a number > 0: return int(round(manual)) (manual always wins).
- Else if started_at is set: return max(1, int(round((now - started_at) / 60.0))).
- Else: return None.
Tests (tests/test_app_smoke.py): manual wins over timer; timer path rounds and floors at 1
(e.g. 30s -> 1); neither set -> None; manual=0 or negative falls through to timer/None.
2. Row dicts gain a "started_at": float | None key (None in view_rows()). Per unlogged row add
a "Start" button (size="sm", variant="secondary"). Its handler sets started_at=time.time()
for that row only and returns rows_state, readout, summary (same 3 outputs as the others).
When a row has started_at set, render its chip area with an extra
'<div class="chip chip-timer">⏱ timing</div>' and label the button "Restart".
3. log_step handler: replace the minutes<=0 early-return with
actual = finish_minutes(minutes, row.get("started_at"), time.time())
for the clicked row; if actual is None -> gr.Warning("Press Start first or enter minutes.")
and return unchanged. Otherwise proceed exactly as before with actual.
4. CSS: add .chip-timer { background: #fef3c7; color: #b45309; font-weight: 600; }.
5. Existing tests must keep passing (splice/summary tests construct row dicts — use .get()
for started_at anywhere you read it so old fixtures without the key still work).
6. Run: python -m pytest -q — FULL suite green (42 + new).
7. (Commit handled by reviewer.) Intended message:
"feat(ui): built-in step timer — Start stamps, Done auto-computes minutes"
staging only app.py tests/test_app_smoke.py.
```
## Task 16 — Copy plan as a markdown checklist + Enter submits
> Added 2026-06-11: the plan should leave the app — paste into Notes/Todoist/Obsidian.
> Also the task box should respond to Enter.
```
Touch ONLY: app.py and tests/test_app_smoke.py. TDD: failing tests first. Do NOT run git.
1. Module-level pure helper in app.py:
def plan_markdown(task: str, rows: list[dict]) -> str
- Empty rows -> "".
- Header line: "## {task.strip() or 'My plan'}" then a blank line.
- One line per row, in order: logged rows -> "- [x] {text} (took {actual_minutes} min)";
unlogged -> "- [ ] {text} (~{calibrated_minutes} min)".
- Footer line after a blank line: "Total for you: ~{N} min" using the same arithmetic as
summary_html (actual when logged else calibrated).
Tests: golden string for a 3-row mixed plan; empty rows -> "".
2. UI: a "Copy as checklist" gr.Button next to the export/import row. Click handler takes
(task, rows_state) and writes plan_markdown(...) into a gr.Textbox(label="Checklist",
show_copy_button=True, lines=8, visible=False) — make it visible=True in the same handler
via gr.update when there is content, keep hidden for empty plans.
3. task.submit(break_down, inputs=task, outputs=[rows_state, readout_output, summary_output])
so Enter in the task box triggers the breakdown (same handler as the button).
4. Run: python -m pytest -q — FULL suite green.
5. (Commit handled by reviewer.) Intended message:
"feat(ui): copy plan as markdown checklist + Enter submits task"
staging only app.py tests/test_app_smoke.py.
```
## Task 17 — Plan survives a page reload
> Added 2026-06-11: a refresh currently wipes the in-progress plan — brutal mid-task. Snapshot
> the visible rows to SQLite on every change; restore on app load.
```
Touch ONLY: src/unstuck/store.py, app.py, tests/test_store.py, tests/test_app_smoke.py.
TDD: failing tests first. Do NOT run git.
1. Store: two methods on Store:
def save_plan(self, task: str, rows_json: str) -> None
def load_plan(self) -> tuple[str, str] | None
- Table plan_snapshot(id INTEGER PRIMARY KEY CHECK (id = 1), task TEXT, rows_json TEXT,
saved_at TEXT) — a single-row upsert (INSERT ... ON CONFLICT(id) DO UPDATE).
- load_plan returns (task, rows_json) or None when never saved.
- rows_json is opaque to the store: no parsing/validation there.
- Tests in tests/test_store.py: save then load round-trips; second save overwrites;
fresh store -> None.
2. app.py: a module-level helper
def snapshot(store, task: str, rows: list[dict]) -> None
that json.dumps rows and calls store.save_plan — swallow nothing: let errors raise in tests
but in handlers wrap with try/except Exception: pass (persistence must never break the UI).
Call it at the end of every handler that returns rows_state (break_down, log_step handler,
break_down_step handler, import_data, the Task 15 start handler if present).
3. Restore on load: a restore() handler that reads store.load_plan(); if present,
json.loads the rows (on any error return empty state) and returns
(rows, readout(), summary_html(rows), gr.update(value=saved_task)).
Wire ui.load(restore, outputs=[rows_state, readout_output, summary_output, task]).
4. Smoke tests: snapshot writes something load_plan returns; restore round-trips rows incl.
logged/started_at keys; corrupted rows_json in the table -> restore yields empty rows, no
exception.
5. Run: python -m pytest -q — FULL suite green.
6. (Commit handled by reviewer.) Intended message:
"feat(store,ui): plan snapshot — survive page reloads"
staging only the four named files.
```
## Task 18 — "Your patterns" stats panel (calibration history made visible)
> Added 2026-06-11: the calibration data is the product's moat but it's invisible — one readout
> sentence. A panel that shows per-category history turns "the app adjusts" into something the
> user can see and trust (and demos well).
```
Touch ONLY: app.py and tests/test_app_smoke.py. TDD: failing tests first. Do NOT run git.
1. Module-level pure helper in app.py:
def patterns_html(records: list[dict]) -> str
records are store.get_records() rows: {category, est_minutes, actual_minutes, completed_at}.
- Empty records -> "".
- One block per category (sorted alphabetically), each:
<div class="pattern-row"> with:
- <span class="pattern-cat">{category}</span>
- <span class="pattern-mult">~{mult:.1f}× — {verdict}</span> where mult comes from the
existing unstuck.calibration.multiplier(category, records) and verdict matches the
readout wording: mult > 1.05 -> "you underestimate these"; mult < 0.95 -> "you
overestimate these"; else "your gut is right".
- a mini bar strip of the LAST 5 records for that category (chronological by
completed_at): for each, a <span class="bar" style="height:{h}px" title="est {e} →
took {a} min"></span> where h = clamp(int(round(ratio * 14)), 4, 36) and
ratio = actual/est.
- <span class="pattern-n">{n} logged</span>
- Wrap everything in <div class="patterns">...</div>.
Tests: empty -> ""; two categories sorted; verdict strings for under/over/right cases;
bar heights clamped (ratio 10 -> 36px, tiny ratio -> 4px); only last 5 bars when 7 records.
2. UI: a gr.Accordion("Your patterns", open=False) below the readout/summary area containing
a gr.HTML. Refresh it from every handler that already returns readout (break_down,
log_step, break_down_step, start_step, import_data, restore) — simplest: those handlers
gain a 4th output patterns_html(service.store.get_records()); update all outputs= lists
accordingly, including ui.load (restore_snapshot gains the extra return value).
3. CSS: .patterns { display:flex; flex-direction:column; gap:8px; }
.pattern-row { display:flex; align-items:flex-end; gap:10px; background:#fff;
border:1px solid #eee9e2; border-radius:10px; padding:8px 14px; font-size:0.88rem; }
.pattern-cat { font-weight:600; color:#292524; min-width:90px; }
.pattern-mult { color:#4338ca; flex:1; }
.bar { display:inline-block; width:7px; background:#c7d2fe; border-radius:3px 3px 0 0;
margin-right:2px; }
.pattern-n { color:#a8a29e; font-size:0.8rem; }
4. Run: python -m pytest -q — FULL suite green (54 + new).
5. (Commit handled by reviewer.) Intended message:
"feat(ui): Your patterns panel — per-category calibration history"
staging only app.py tests/test_app_smoke.py.
```
## Task 19 — Progressive reveal (spotlight the next step)
> Added 2026-06-11: ADHD-core — seeing 10 steps at once recreates the overwhelm the app exists
> to remove. Spotlight the single next unlogged step; dim the rest (still readable/loggable).
```
Touch ONLY: app.py and tests/test_app_smoke.py. TDD: failing tests first. Do NOT run git.
1. Module-level pure helper in app.py:
def next_step_id(rows: list[dict]) -> int | None
Returns the step_id of the FIRST row (list order) with not row["logged"], else None.
Tests: first unlogged picked; all-logged -> None; empty -> None; first row logged ->
second's id.
2. render_rows: compute spotlight = next_step_id(rows) once. Per row, the step-card div class
becomes:
- "step-card step-next" if row["step_id"] == spotlight
- "step-card step-later" if not row["logged"] and not spotlight row
- "step-card" for logged rows (unchanged).
Also, ONLY the spotlight row shows the full control set (Start, took-input, Done,
Still stuck?). Later unlogged rows render the card only — no controls (they get theirs
when they become the spotlight). Logged rows unchanged.
3. CSS: .step-next { border-color:#4f46e5; box-shadow:0 2px 8px rgba(79,70,229,0.18); }
.step-next .step-num { background:#4f46e5; color:#fff; }
.step-later { opacity:0.55; }
4. Run: python -m pytest -q — FULL suite green.
5. (Commit handled by reviewer.) Intended message:
"feat(ui): progressive reveal — spotlight the next step, dim the rest"
staging only app.py tests/test_app_smoke.py.
```
## Task 20 — "New plan" button (clear the board + the snapshot)
> Added 2026-06-11: plan persistence (Task 17) means the old plan reappears on every reload
> with no way to dismiss it. A New plan button clears rows, task box, and the saved snapshot.
```
Touch ONLY: src/unstuck/store.py, app.py, tests/test_store.py, tests/test_app_smoke.py.
TDD: failing tests first. Do NOT run git.
1. Store: def clear_plan(self) -> None — delete the plan_snapshot row (id=1) if present.
Tests: save then clear -> load_plan() is None; clear on fresh store doesn't raise.
2. app.py UI: a "New plan" gr.Button (variant="secondary") next to "Break it down". Handler:
calls service.store.clear_plan() (wrapped try/except Exception: pass), returns
([], "", "", patterns(), gr.update(value="")) wired to
[rows_state, readout_output, summary_output, patterns_output, task].
Note: readout_output cleared to "" deliberately — fresh board, history stays in the store
and still shows inside the patterns accordion.
3. Run: python -m pytest -q — FULL suite green (63 + new).
4. (Commit handled by reviewer.) Intended message:
"feat(store,ui): New plan button — clear board and saved snapshot"
staging only the four named files.
```
## Task 21 — Skip step (resolve without polluting calibration)
> Added 2026-06-11: real plans contain steps that stop mattering. Skipping must advance the
> spotlight but record nothing — a skipped step is not calibration data.
```
Touch ONLY: app.py and tests/test_app_smoke.py. TDD: failing tests first. Do NOT run git.
1. Row dicts gain "skipped": bool (False in view_rows(); use row.get("skipped") everywhere
you read it so old fixtures/snapshots without the key keep working).
2. Helper changes (all module-level, all tested):
- next_step_id: a row is resolved when logged OR skipped — return the first row with
neither. Tests: skipped first row -> second's id; all logged-or-skipped -> None.
- summary_html: skipped rows contribute NOTHING to total_for_you or total_raw; n_done
counts only logged. Append " · {k} skipped" to the text when k > 0.
Test: 3 rows (1 logged, 1 skipped, 1 pending) -> totals exclude the skipped row,
text contains "1 skipped".
- plan_markdown: skipped rows render "- [-] {text} (skipped)" and add nothing to the
total. Test: golden line.
3. UI: a "Skip" button (size="sm", variant="secondary") on the spotlight row only, after
"Still stuck?". Handler marks that row {"skipped": True, "started_at": None}, does NOT
call service.log_actual, persists, returns the usual
(rows, readout(), summary_html(rows), patterns()) outputs.
4. render_rows: a skipped row renders card-only (no controls), class
"step-card step-skipped", step-num shows "–" and chip area shows
'<div class="chip chip-skip">skipped</div>' instead of the For-you/took chips.
CSS: .step-skipped { opacity:0.45; } .step-skipped .step-text
{ text-decoration:line-through; } .chip-skip { background:#f5f5f4; color:#a8a29e; }
5. Run: python -m pytest -q — FULL suite green.
6. (Commit handled by reviewer.) Intended message:
"feat(ui): Skip step — resolve a step without logging calibration data"
staging only app.py tests/test_app_smoke.py.
```
## Task 22 — Completion banner (close the loop with honest stats)
> Added 2026-06-11: when the last step resolves, nothing happens — anticlimax. A banner with
> honest stats rewards finishing and showcases the calibration story in one line.
```
Touch ONLY: app.py and tests/test_app_smoke.py. TDD: failing tests first. Do NOT run git.
1. Module-level pure helper:
def completion_html(rows: list[dict]) -> str
- "" unless rows is non-empty AND every row is logged or skipped AND at least one is
logged.
- Else: n_done = logged count, n_skipped = skipped count,
took = sum(actual_minutes of logged rows), est = sum(raw_minutes of logged rows).
Line 1: "🎉 Done — {n_done} steps in {took} min." (append " ({n_skipped} skipped)"
when n_skipped > 0).
Line 2: ratio = took/est: ratio > 1.05 -> "The AI guessed {est} min — you took
{ratio:.1f}× longer, and Unstuck now knows that."; ratio < 0.95 -> "The AI guessed
{est} min — you beat it, finishing in {ratio:.1f}× the time."; else -> "The AI guessed
{est} min — almost exactly right."
Wrap: <div class="completion">{line1}<br>{line2}</div>.
Tests: incomplete plan -> ""; all-skipped -> ""; done-with-skips golden; the three ratio
verdicts.
2. UI: summary handlers gain nothing new — instead summary_html callers stay as-is and the
completion banner is PREPENDED by the existing summary output position: change every
handler return that currently sends summary_html(rows) to send
completion_html(rows) + summary_html(rows) (string concat; completion is "" until done).
Keep restore_snapshot consistent (its summary return value gets the same treatment).
3. CSS: .completion { background:#ecfdf5; color:#047857; border-radius:12px;
padding:14px 18px; font-size:1rem; text-align:center; margin-top:10px; font-weight:600; }
4. Run: python -m pytest -q — FULL suite green.
5. (Commit handled by reviewer.) Intended message:
"feat(ui): completion banner — honest stats when the plan is done"
staging only app.py tests/test_app_smoke.py.
```
## Task 23 — Per-user data via gr.BrowserState (the multi-user fix)
> Added 2026-06-11: the hosted Space shares one server SQLite across ALL visitors — plans and
> calibration leak between users. Move user data (records + plan snapshot) into
> gr.BrowserState (browser localStorage). Server store stays for task/step bookkeeping only.
> This is also the privacy story: calibration history lives in the user's browser.
```
Touch ONLY: app.py and tests/test_app_smoke.py. TDD: failing tests first. Do NOT run git.
1. Module-level pure helpers in app.py (all tested with plain dict/list fixtures):
- def make_record(category: str, est_minutes: int, actual_minutes: int, now: float) -> dict
Returns {"category", "est_minutes", "actual_minutes", "completed_at": now}.
- def merge_records(existing: list[dict], payload: str) -> tuple[list[dict], int, int]
Parses an export payload (json with a "records" key, same shape store.export_json
produces), validates every record row (category str, est_minutes int>0,
actual_minutes int>0) raising ValueError on any violation (never partially merge),
skips rows already present in existing by (category, est_minutes, actual_minutes,
completed_at) equality, returns (merged_list, imported_count, skipped_count).
- def export_payload(records: list[dict]) -> str
json.dumps({"tasks": [], "steps": [], "records": records}) — same top-level shape as
store.export_json so old exports import cleanly and vice versa.
Tests: round-trip export_payload -> merge_records into empty = same records; re-merge ->
all skipped; bad payloads raise ValueError.
2. Browser state: in build_ui add
user_data = gr.BrowserState({"records": [], "plan": None})
(storage_key="unstuck-v1" if the constructor accepts it; otherwise default).
Rules for ALL handlers: user_data joins inputs AND outputs whenever records or plan are
read or written. The dict is treated immutably (build a new dict, never mutate in place).
3. Records source of truth = user_data["records"]:
- readout(), patterns(), recalibrated() lose their store reads and take records as a
parameter (records passed down from the handler's user_data input).
- log_step: on success, records = records + [make_record(row["category"],
row["raw_minutes"], actual, time.time())]; do NOT call service.log_actual any more.
- import_data: replace store.import_json with merge_records on user_data["records"];
status string unchanged ("Imported N records (M duplicates skipped)"). parse_import
may be deleted if nothing else uses it (remove its test too).
- export_data: write export_payload(records) to the temp file instead of
store.export_json().
4. Plan snapshot source of truth = user_data["plan"] = {"task": str, "rows": [...]} | None:
- persist()/snapshot() and restore_snapshot() stop touching store.save_plan/load_plan;
snapshot becomes: new user_data with plan set (pure dict work — keep a pure helper
def with_plan(data: dict, task: str, rows: list) -> dict and
def with_records(data: dict, records: list) -> dict for the handlers to compose).
- restore on ui.load reads user_data["plan"]; malformed/missing -> empty board.
- new_plan clears user_data["plan"] (records untouched).
5. The Store class and its tests stay untouched (still used by service.breakdown for
task/step ids and by local scripts). app.py simply no longer reads/writes records or
plan_snapshot from it.
6. Update existing smoke tests that called the store-backed paths; keep coverage equivalent.
7. Run: python -m pytest -q — FULL suite green (77 baseline; net count may shift slightly
with replaced tests — state the final number).
8. (Commit handled by reviewer.) Intended message:
"feat(ui): per-user data via BrowserState — plans and calibration stay in the browser"
staging only app.py tests/test_app_smoke.py.
```
## Task 24 — Step-size control (granularity through the whole seam)
> Added 2026-06-11: Goblin Tools has a "spiciness" granularity dial; we have nothing global.
> Thread a granularity setting from the UI through service → adapter → prompt. "Still stuck?"
> re-breakdowns always use "tiny" (that's what being stuck means).
```
Touch ONLY: src/unstuck/prompts.py, src/unstuck/model_adapter.py, src/unstuck/service.py,
app.py, tests/test_prompts.py, tests/test_model_adapter.py, tests/test_service.py,
tests/test_app_smoke.py. TDD: failing tests first. Do NOT run git.
1. prompts.py: breakdown_prompt(task, granularity="regular") and
repair_prompt(task, bad_output, error, granularity="regular").
GRANULARITY_RULES = {
"chunky": "Produce 3-5 bigger steps. Estimates may be up to 25 minutes.",
"regular": "Produce 4-8 tiny steps.",
"tiny": "Produce 5-10 very tiny steps. No estimate may exceed 10 minutes. The first step must take 2 minutes or less.",
}
Unknown granularity -> ValueError. The rule line is appended to the system block (replace
the current hardcoded "4-8 tiny ordered" sentence with a granularity-neutral version:
"You break ONE overwhelming task into tiny ordered ADHD-friendly steps." and inject the
rule as its own line right after it). Keep everything else (25-min hard max, categories,
starter-step rule, JSON schema line) unchanged.
Tests: each granularity's rule text appears in its prompt; default regular; ValueError on
unknown; repair_prompt carries the rule too.
2. model_adapter.py: ModelAdapter.breakdown(task, granularity="regular") passes granularity
to both prompt builders. Test: fake generate captures the prompt; tiny rule present;
repair path keeps granularity.
3. service.py: Unstuck.breakdown(task, granularity="regular") passes through. Test: canned
generate asserting the rule text reached the prompt.
4. app.py:
- gr.Radio(choices=["chunky", "regular", "tiny"], value="regular", label="Step size",
info="How small should the pieces be?") rendered between the task box and the buttons.
- break_down handler gains the granularity input (wire the radio into break_button.click
and task.submit inputs) and calls service.breakdown(clean_task, granularity).
- break_down_step ("Still stuck?") always calls service.breakdown(step_text, "tiny").
- Smoke test: with a fake generate that records prompts, drive /break_down via
gradio_client with granularity "tiny" and assert the recorded prompt contains the tiny
rule (follow the existing test_break_down_calibrates_from_browser_records launch
pattern, different port).
5. Run: python -m pytest -q — FULL suite green (89 baseline).
6. (Commit handled by reviewer.) Intended message:
"feat(prompts,ui): step-size control — chunky/regular/tiny granularity end to end"
staging only the eight named files.
```
## Task 25 — Response quality: validator quality gates + granularity-matched examples
> Added 2026-06-11: the schema gate is shape-only — vague/duplicate/rambling steps pass. Every
> new rejection drives the existing repair retry, so a stricter gate = the model fixes its own
> output in one round-trip. And the few-shot example must match the granularity it's asked for.
```
Touch ONLY: src/unstuck/schema.py, src/unstuck/prompts.py, tests/test_schema.py,
tests/test_prompts.py. TDD: failing tests first. Do NOT run git.
1. schema.py quality gates (inside validate_steps_payload/_validate_step; each raises
StepValidationError with the exact message shown — these messages reach the repair prompt):
- Normalize text first: collapse internal whitespace runs to one space, strip, drop ONE
trailing period (keep "..." untouched).
- len(steps) > 12 -> "too many steps - return at most 12"
- Fewer than 2 words in a step text -> "step text must be a concrete action of at least
two words"
- len(text) > 90 -> "step text too long - keep each step under 90 characters"
- Vague starters: if text.lower() starts with any of {"work on", "continue ", "deal with",
"handle ", "think about", "try to ", "start working", "do the work", "make progress"}
-> 'step "<text>" is vague - start with a concrete physical action'
- Case-insensitive duplicate texts across steps -> "steps must not repeat"
Tests in test_schema.py: one per gate (incl. normalization: "Open the file." ->
"Open the file"; "Wait..." keeps the ellipsis) + a valid payload still passes.
2. prompts.py: per-granularity examples. EXAMPLES = {granularity: example_string} where
- "regular" keeps the current apartment example unchanged.
- "tiny": same task, 6 steps, every est <= 10, first step 2 minutes ("Stand up and grab
an empty trash bag" style), categories from the enum.
- "chunky": same task, 3 steps, ests 15-25.
breakdown_prompt picks EXAMPLES[granularity]. repair_prompt unchanged (no example).
Tests: tiny prompt contains its 2-minute starter; chunky prompt contains a 25; regular
prompt unchanged vs current golden expectations (update existing tests only if they
reference the example text).
3. Run: python -m pytest -q — FULL suite green (99 baseline; sandbox cannot run the two
gradio launch tests - run the rest and say so; the reviewer runs the full suite).
4. (Commit handled by reviewer.) Intended message:
"feat(schema,prompts): quality gates + granularity-matched few-shot examples"
staging only the four named files.
```
## Task 26 — Undo (un-Done / un-Skip a resolved step)
> Added 2026-06-11: misclicks are an ADHD reality. Done/Skip are irreversible today, and a
> wrong Done pollutes calibration forever.
```
Touch ONLY: app.py and tests/test_app_smoke.py. TDD: failing tests first. Do NOT run git.
1. When log_step logs a row, store the record's timestamp on the row:
{"logged": True, "actual_minutes": actual, "started_at": None, "record_at": now}
where now is the same time.time() value passed to make_record (capture it once).
2. Module-level pure helpers (tested):
- def undo_row(rows: list[dict], step_id: int) -> list[dict]
Returns rows with that row reset: logged=False, skipped=False, actual_minutes=None,
started_at=None, record_at removed (pop). Other rows untouched. Unknown id -> rows
unchanged.
- def remove_record(records: list[dict], category: str, est_minutes: int,
actual_minutes: int, completed_at: float | None) -> list[dict]
Removes THE ONE record matching all four fields (when completed_at is None match on the
other three, removing the LAST match). No match -> unchanged copy.
3. UI: resolved rows (logged or skipped) get one small "Undo" button (size="sm",
variant="secondary") instead of no controls. Handler:
- skipped row -> rows = undo_row(...); records unchanged.
- logged row -> records = remove_record(records, row["category"], row["raw_minutes"],
row["actual_minutes"], row.get("record_at")); rows = undo_row(...);
then rows = recalibrated(rows, records).
- Outputs: the usual (rows_state, readout, summary(+completion), patterns, user_data) —
update user_data with with_records and persist the plan.
4. Run: python -m pytest -q (minus the launch tests if the sandbox blocks sockets — say so).
5. (Commit handled by reviewer.) Intended message:
"feat(ui): Undo — reverse a mistaken Done or Skip, calibration data included"
staging only app.py tests/test_app_smoke.py.
```
## Task 27 — Edit a step + add your own step
> Added 2026-06-11: the plan is read-only — you can't fix the model's wording or add the step
> only you know about. Both close the "it's MY plan" gap.
```
Touch ONLY: app.py and tests/test_app_smoke.py. TDD: failing tests first. Do NOT run git.
1. Module-level pure helpers (tested):
- def edit_row_text(rows: list[dict], step_id: int, new_text: str) -> list[dict]
Strips new_text; empty/whitespace -> rows unchanged; else replaces that row's "text".
- def add_manual_row(rows: list[dict], text: str, minutes: float | None,
records: list[dict]) -> list[dict]
Strips text; empty -> unchanged. est = int(round(minutes)) if minutes and minutes > 0
else 10. New row: step_id = (max existing step_ids) + 1 or 1 when empty, category
"admin", raw_minutes est, calibrated_minutes = calibrate(est, multiplier("admin",
records)), logged/skipped False, actual_minutes/started_at None. Appended at the end.
2. UI:
- Edit: on the SPOTLIGHT row only, next to "Skip", an "Edit" button toggling visibility of
a small gr.Textbox(value=row text, show_label=False) + "Save" button rendered on a second
row beneath the step (simplest within @gr.render: render the textbox+Save row always for
the spotlight row but inside a gr.Row(visible=False) toggled by the Edit button via
gr.update; if toggling proves awkward in render context, render the edit row only when a
gr.State flag editing_step_id == row step_id — set by Edit, cleared by Save).
Save handler: rows = edit_row_text(rows, step_id, new_text); persist; usual outputs.
- Add: below the steps (above export row), gr.Textbox(placeholder="Add your own step",
show_label=False) + gr.Number(placeholder="min", minimum=1, precision=0) + "Add step"
button. Handler: add_manual_row + persist + usual outputs; clear the textbox
(gr.update(value="")).
3. Run: python -m pytest -q (minus launch tests if sandbox blocks sockets — say so).
4. (Commit handled by reviewer.) Intended message:
"feat(ui): edit any step's text + add your own steps"
staging only app.py tests/test_app_smoke.py.
```
## Task 28 — Quota-resilient backend: GPU→serverless fallback + tunable temperature
> Added 2026-06-11: live smoke test showed anonymous ZeroGPU quota is ZERO right now — an
> anonymous judge gets the "busy" message and never sees a plan. The hybrid seam already
> exists; make the failover automatic inside generate(). Also expose decoding temperature as
> an env knob (default stays greedy) so a later eval can decide whether mild sampling is safe.
```
Touch ONLY: src/unstuck/backend.py, tests/test_backend.py. TDD: failing tests first.
Do NOT run git.
1. Module-level pure factory in backend.py (defined BEFORE the BACKEND if/elif chain so it
imports without torch/spaces):
def with_fallback(primary, fallback):
"""Return generate() that tries primary, then fallback on any exception."""
Behaviour: call primary(prompt); on Exception call fallback(prompt) and return its value;
if the fallback ALSO raises, let the fallback's exception propagate. When primary
succeeds, fallback must not be called.
2. Temperature knob: TEMPERATURE = float(os.environ.get("UNSTUCK_TEMPERATURE", "0")).
- zerogpu branch: if TEMPERATURE > 0 pass do_sample=True, temperature=TEMPERATURE to
model.generate; else keep do_sample=False (no temperature kwarg).
- hf_inference + nebius branches: pass temperature=TEMPERATURE instead of the literal 0.
3. zerogpu branch wiring: keep the @spaces.GPU function but bind it to a name like
_gpu_generate. After it, build the serverless fallback ONLY when os.environ.get("HF_TOKEN")
is set: a lazily-initialised InferenceClient(MODEL_ID, token=...) (import huggingface_hub
inside the fallback function so the zerogpu branch keeps zero extra import cost) calling
chat_completion(messages=[{"role":"user","content":prompt}], max_tokens=512,
temperature=TEMPERATURE) and returning message.content as str.
generate = with_fallback(_gpu_generate, _serverless_fallback) if HF_TOKEN else _gpu_generate
4. Tests (reload_backend pattern already in the file; no GPU needed):
- with_fallback: primary ok -> fallback not called; primary raises -> fallback result
returned; both raise -> the fallback's exception type propagates. Use plain fakes —
test with_fallback directly, no backend reload required.
- nebius branch respects UNSTUCK_TEMPERATURE=0.7 (chat_completion kwargs show 0.7) and
defaults to 0 when unset (existing test keeps passing).
- hf_inference branch: temperature flows through the same way (fake huggingface_hub).
5. Run: python -m pytest -q — full suite green (133 baseline; sandbox cannot run the gradio
launch tests — run the rest and say so; the reviewer runs the full suite).
6. (Commit handled by reviewer.) Intended message:
"feat(backend): automatic ZeroGPU→serverless fallback + UNSTUCK_TEMPERATURE knob"
staging only the two named files.
```
## Task 29 — Accessible labels + sane default for the add-your-own-step row
> Added 2026-06-11: the a11y tree announces the manual-step inputs as literal "Textbox" and
> "Number", and the Number starts at an invalid empty/0 state (minimum=1). Screen readers and
> the polish-conscious judge both lose.
```
Touch ONLY: app.py, tests/test_app_smoke.py. TDD: failing tests first. Do NOT run git.
1. In the manual-step gr.Row in build_ui:
- manual_text: add label="Your own step" (keep show_label=False and the placeholder) so
the accessible name is meaningful without changing the visuals.
- manual_minutes: add label="Minutes" (keep show_label=False, placeholder, minimum=1,
precision=0) and set value=5 so the control starts valid.
2. Tests in test_app_smoke.py: build the ui (existing pattern), walk ui.blocks.values() and
assert (a) a Textbox with label "Your own step" exists, (b) a Number with label "Minutes"
and value 5 exists.
3. Run: python -m pytest -q (minus launch tests if the sandbox blocks sockets — say so).
4. (Commit handled by reviewer.) Intended message:
"fix(ui): accessible labels + default minutes for the manual-step row"
staging only the two named files.
```
## Task 30 — JSON extraction must prefer the steps payload over inner objects
> Added 2026-06-11: measured eval (12 tasks x 3 granularities, hf_inference) scored 35/36
> first-try valid. The one failure exposed a real extraction bug: the model corrupted its JSON
> mid-string (switched to single quotes after an apostrophe), and _extract_json's brace scan
> decoded an INNER step object ({"text": ...}) as the payload — so validation said "payload
> must include non-empty steps" and the repair prompt carried a misleading diagnosis.
```
Touch ONLY: src/unstuck/model_adapter.py, tests/test_model_adapter.py. TDD: failing tests
first. Do NOT run git.
1. _extract_json: scan ALL decodable JSON objects in the text (current raw_decode-from-each-
"{" loop). Return the FIRST decoded dict containing a "steps" key. If none has "steps",
return the first decodable object (preserves current behaviour for malformed-but-single
payloads). Still raise StepValidationError("no JSON object found") when nothing decodes.
2. Tests (use this real corrupted output shape, shortened): a string where the outer
{"steps":[...]} is broken by a mid-string quote switch but a later inner step object
{"text":"Connect the phone via USB","category":"errand","est_minutes":10} decodes —
assert _extract_json now raises... no: assert it returns an object WITHOUT "steps" only
when no steps-bearing object exists; and that for output where a steps-bearing object
appears AFTER a non-steps object, the steps one wins. Plus: existing tests stay green.
3. Run: python -m pytest -q (133+ baseline minus launch tests if sandbox blocks sockets).
4. (Commit handled by reviewer.) Intended message:
"fix(model): JSON extraction prefers the steps payload over inner step objects"
staging only the two named files.
```