trackerkeeper 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.
- trackerkeeper/__init__.py +55 -0
- trackerkeeper/__main__.py +3 -0
- trackerkeeper/_version.py +24 -0
- trackerkeeper/app.py +445 -0
- trackerkeeper/assets/__init__.py +2 -0
- trackerkeeper/assets/trackerkeeper.svg +12 -0
- trackerkeeper/async_io.py +299 -0
- trackerkeeper/autostart/__init__.py +52 -0
- trackerkeeper/autostart/_linux.py +129 -0
- trackerkeeper/autostart/_macos.py +163 -0
- trackerkeeper/autostart/_msix.py +181 -0
- trackerkeeper/autostart/_unsupported.py +22 -0
- trackerkeeper/autostart/_windows.py +96 -0
- trackerkeeper/bake.py +217 -0
- trackerkeeper/blur/__init__.py +212 -0
- trackerkeeper/blur/_dwm.py +348 -0
- trackerkeeper/blur/_faux_frost.py +143 -0
- trackerkeeper/blur/_kwin.py +478 -0
- trackerkeeper/blur/_macos.py +356 -0
- trackerkeeper/blur/_unsupported.py +39 -0
- trackerkeeper/boot_timing.py +75 -0
- trackerkeeper/breadboard.py +1554 -0
- trackerkeeper/bus.py +110 -0
- trackerkeeper/catalog.py +176 -0
- trackerkeeper/color_tokens.py +697 -0
- trackerkeeper/credentials.py +471 -0
- trackerkeeper/custom_tooltip.py +254 -0
- trackerkeeper/dashboard.py +704 -0
- trackerkeeper/deliver.py +442 -0
- trackerkeeper/design_tokens.py +415 -0
- trackerkeeper/diagnostics.py +168 -0
- trackerkeeper/drag_repaint/__init__.py +68 -0
- trackerkeeper/drag_repaint/_kwin.py +192 -0
- trackerkeeper/drag_repaint/_unsupported.py +41 -0
- trackerkeeper/drag_repaint/effect/dragrepaint/contents/code/main.js +86 -0
- trackerkeeper/drag_repaint/effect/dragrepaint/metadata.json +17 -0
- trackerkeeper/frosted_dialog.py +377 -0
- trackerkeeper/i18n/__init__.py +101 -0
- trackerkeeper/i18n/fmt.py +137 -0
- trackerkeeper/icon_button.py +137 -0
- trackerkeeper/icons.py +532 -0
- trackerkeeper/identity.py +135 -0
- trackerkeeper/item_dialog.py +185 -0
- trackerkeeper/kde_titlebar.py +196 -0
- trackerkeeper/keyboard_focus.py +353 -0
- trackerkeeper/log.py +112 -0
- trackerkeeper/macos_menubar.py +282 -0
- trackerkeeper/macos_window.py +172 -0
- trackerkeeper/metadata.py +121 -0
- trackerkeeper/noborder/__init__.py +61 -0
- trackerkeeper/noborder/_kwin.py +247 -0
- trackerkeeper/noborder/_unsupported.py +42 -0
- trackerkeeper/notifications/__init__.py +84 -0
- trackerkeeper/notifications/_linux.py +68 -0
- trackerkeeper/notifications/_macos.py +159 -0
- trackerkeeper/notifications/_unsupported.py +22 -0
- trackerkeeper/notifications/_windows.py +115 -0
- trackerkeeper/platform_compat.py +149 -0
- trackerkeeper/power/__init__.py +84 -0
- trackerkeeper/power/_linux.py +79 -0
- trackerkeeper/power/_unsupported.py +18 -0
- trackerkeeper/power/_windows.py +52 -0
- trackerkeeper/rig.py +338 -0
- trackerkeeper/scaffold.py +310 -0
- trackerkeeper/selector.py +529 -0
- trackerkeeper/settings.py +226 -0
- trackerkeeper/settings_dialog.py +307 -0
- trackerkeeper/settings_migration.py +101 -0
- trackerkeeper/single_instance.py +330 -0
- trackerkeeper/smooth_scroll.py +155 -0
- trackerkeeper/sources.py +318 -0
- trackerkeeper/system_accent.py +363 -0
- trackerkeeper/terminal.py +423 -0
- trackerkeeper/test_bridge.py +514 -0
- trackerkeeper/theme.py +725 -0
- trackerkeeper/top_bar.py +154 -0
- trackerkeeper/tray.py +184 -0
- trackerkeeper/ui_helpers.py +2294 -0
- trackerkeeper/update_chip.py +119 -0
- trackerkeeper/updates.py +216 -0
- trackerkeeper/win_frameless.py +364 -0
- trackerkeeper/window.py +609 -0
- trackerkeeper/windows_shortcut.py +355 -0
- trackerkeeper-0.1.0.dist-info/METADATA +144 -0
- trackerkeeper-0.1.0.dist-info/RECORD +89 -0
- trackerkeeper-0.1.0.dist-info/WHEEL +5 -0
- trackerkeeper-0.1.0.dist-info/entry_points.txt +8 -0
- trackerkeeper-0.1.0.dist-info/licenses/LICENSE +338 -0
- trackerkeeper-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1554 @@
|
|
|
1
|
+
"""``trackerkeeper breadboard`` — the live maker surface (docs/TODO.md §THE BREADBOARD).
|
|
2
|
+
|
|
3
|
+
The breadboard is the thing the maker INTERACTS WITH during each step of
|
|
4
|
+
building with trackerkeeper: one frosted window, tabs across the top for the phases —
|
|
5
|
+
jump back to the brief, forward to the delivery channels. The AI agent fills
|
|
6
|
+
and updates it; the maker checks, unchecks, moves cards and leaves notes — and
|
|
7
|
+
the agent RE-INGESTS those as directives (the protocol lives in AGENTS.md).
|
|
8
|
+
|
|
9
|
+
Each phase gets its own shape (August's design, 2026-07-08):
|
|
10
|
+
|
|
11
|
+
* **Ingredients** — the app summary page: logo + name + summary from the
|
|
12
|
+
metadata sidecar, an editable "purpose" (boil the app down), the feature
|
|
13
|
+
cards, then the brief's checklist.
|
|
14
|
+
* **Baking** — a kanban: priority columns (Now / Next / Later / Done); cards
|
|
15
|
+
move with ◀ ▶, complete with ✓, delete with ✕; the agent populates, the
|
|
16
|
+
maker steers.
|
|
17
|
+
* **Delivery** — the real channel list from ``deliver``: per-platform steps
|
|
18
|
+
with DETECTED states, links, and the next action's guide.
|
|
19
|
+
* **Improvements** — the forever-lap checklist.
|
|
20
|
+
|
|
21
|
+
**State is a file; the window is a view.** ``<slug>-breadboard.toml`` sits in
|
|
22
|
+
the checkout root, git-tracked, human-editable, AI-writable — the file is the
|
|
23
|
+
API between maker, window, and agent (the same two-way-door philosophy as the
|
|
24
|
+
sync manifest). The window file-watches and live-reloads on outside edits; its
|
|
25
|
+
own edits write straight back. No daemon, no IPC.
|
|
26
|
+
|
|
27
|
+
Schema (v1): ``schema``/``product``/``goal``/``purpose`` scalars, then one
|
|
28
|
+
array-of-tables per phase — items with ``text``, ``done``, ``by``, ``date``,
|
|
29
|
+
``note``, and (baking only) ``priority`` in {now, next, later}. tomllib reads
|
|
30
|
+
it; :func:`save` emits it deterministically (no TOML-writer dependency).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import argparse
|
|
36
|
+
import os
|
|
37
|
+
import secrets
|
|
38
|
+
import sys
|
|
39
|
+
import tomllib
|
|
40
|
+
from datetime import date
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
SCHEMA = 2 # v2: every item carries a stable `id`; empty fields are omitted
|
|
44
|
+
PHASES = ("ingredients", "baking", "delivery", "improvements")
|
|
45
|
+
_PHASE_TITLES = {
|
|
46
|
+
"ingredients": "Ingredients",
|
|
47
|
+
"baking": "Baking",
|
|
48
|
+
"delivery": "Delivery",
|
|
49
|
+
"improvements": "Improvements",
|
|
50
|
+
}
|
|
51
|
+
PRIORITIES = ("now", "next", "later")
|
|
52
|
+
|
|
53
|
+
# The package this tool ships in — a fork's whole-word rename keeps it correct.
|
|
54
|
+
_PKG = (__package__ or "trackerkeeper").split(".")[0]
|
|
55
|
+
|
|
56
|
+
# The board file is named after the APP (trackerkeeper-breadboard.toml here,
|
|
57
|
+
# myapp-breadboard.toml in a fork) — derived from the package, not a literal,
|
|
58
|
+
# so the fork rename can't half-apply it.
|
|
59
|
+
FILENAME = f"{_PKG}-breadboard.toml"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── the file half ────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def repo_root() -> Path:
|
|
66
|
+
"""The checkout root (nearest pyproject.toml above this file) — the board
|
|
67
|
+
belongs to the repo, like the rig goldens."""
|
|
68
|
+
for parent in Path(__file__).resolve().parents:
|
|
69
|
+
if (parent / "pyproject.toml").is_file():
|
|
70
|
+
return parent
|
|
71
|
+
return Path.cwd()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def board_path() -> Path:
|
|
75
|
+
return repo_root() / FILENAME
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load(path: Path) -> dict:
|
|
79
|
+
"""The parsed board, with every phase key present (missing → empty) and
|
|
80
|
+
baking items carrying a priority. A schema-1 file (no ids) loads fine —
|
|
81
|
+
ids are minted on the next :func:`save`."""
|
|
82
|
+
data = tomllib.loads(Path(path).read_text(encoding="utf-8"))
|
|
83
|
+
for phase in PHASES:
|
|
84
|
+
data.setdefault(phase, [])
|
|
85
|
+
data.setdefault("schema", SCHEMA)
|
|
86
|
+
data.setdefault("product", _PKG)
|
|
87
|
+
data.setdefault("goal", "")
|
|
88
|
+
data.setdefault("purpose", "")
|
|
89
|
+
data.setdefault("agent_request", "")
|
|
90
|
+
for item in data["baking"]:
|
|
91
|
+
if item.get("priority") not in PRIORITIES:
|
|
92
|
+
item["priority"] = "next"
|
|
93
|
+
return data
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _new_id() -> str:
|
|
97
|
+
"""A short, collision-improbable item id (6 hex chars) — the stable handle
|
|
98
|
+
the agent and git history reference an item by, immune to reordering."""
|
|
99
|
+
return secrets.token_hex(3)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _ensure_ids(board: dict) -> None:
|
|
103
|
+
"""Mint a stable `id` for every item that lacks one (or collides), in
|
|
104
|
+
place. Idempotent: an item that already has a unique id keeps it, so
|
|
105
|
+
ids survive edits, reorders, and round-trips."""
|
|
106
|
+
seen: set[str] = set()
|
|
107
|
+
for phase in PHASES:
|
|
108
|
+
for item in board.get(phase, []):
|
|
109
|
+
iid = item.get("id")
|
|
110
|
+
while not iid or iid in seen:
|
|
111
|
+
iid = _new_id()
|
|
112
|
+
item["id"] = iid
|
|
113
|
+
seen.add(iid)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def discover_projects(home: Path | None = None) -> list[tuple[str, Path]]:
|
|
117
|
+
"""(product, board-file) for this checkout AND its sibling checkouts that
|
|
118
|
+
carry a breadboard — the maker's project switcher. The home project is
|
|
119
|
+
always first."""
|
|
120
|
+
home = home or repo_root()
|
|
121
|
+
out: list[tuple[str, Path]] = []
|
|
122
|
+
for d in [home] + sorted(
|
|
123
|
+
p for p in home.parent.iterdir() if p.is_dir() and p != home
|
|
124
|
+
):
|
|
125
|
+
boards = sorted(d.glob("*-breadboard.toml"))
|
|
126
|
+
if not boards or not (d / "pyproject.toml").is_file():
|
|
127
|
+
continue
|
|
128
|
+
try:
|
|
129
|
+
product = load(boards[0]).get("product", d.name)
|
|
130
|
+
except Exception:
|
|
131
|
+
continue # malformed board — skip, don't break the switcher
|
|
132
|
+
out.append((product, boards[0]))
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _toml_str(s: str) -> str:
|
|
137
|
+
"""A one-line TOML basic string."""
|
|
138
|
+
escaped = (
|
|
139
|
+
str(s).replace("\\", "\\\\").replace('"', '\\"')
|
|
140
|
+
.replace("\n", "\\n").replace("\t", "\\t")
|
|
141
|
+
)
|
|
142
|
+
return f'"{escaped}"'
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def save(path: Path, board: dict) -> None:
|
|
146
|
+
"""Deterministic emit — same input, same bytes, so git diffs stay honest
|
|
147
|
+
and the agent/window never fight over formatting. Mints stable ids first
|
|
148
|
+
(see :func:`_ensure_ids`) and OMITS empty ``by``/``date``/``note`` so the
|
|
149
|
+
file stays skimmable and hand-editable — "the file IS the API" only holds
|
|
150
|
+
while a human still wants to open it."""
|
|
151
|
+
_ensure_ids(board)
|
|
152
|
+
lines = [
|
|
153
|
+
"# The breadboard — the live maker surface. The WINDOW (`{0}-breadboard`) and".format(_PKG),
|
|
154
|
+
"# the AI AGENT both read and write this file; your edits here are directives",
|
|
155
|
+
"# the agent re-ingests (see AGENTS.md). Git-tracked on purpose.",
|
|
156
|
+
"",
|
|
157
|
+
# a save always emits the CURRENT format, so it declares the current
|
|
158
|
+
# schema — this is how a hand-written schema-1 file upgrades on write
|
|
159
|
+
f"schema = {SCHEMA}",
|
|
160
|
+
f"product = {_toml_str(board.get('product', _PKG))}",
|
|
161
|
+
f"goal = {_toml_str(board.get('goal', ''))}",
|
|
162
|
+
f"purpose = {_toml_str(board.get('purpose', ''))}",
|
|
163
|
+
# the maker's direct line: "wind down" etc. — the agent fulfils + clears
|
|
164
|
+
f"agent_request = {_toml_str(board.get('agent_request', ''))}",
|
|
165
|
+
]
|
|
166
|
+
for phase in PHASES:
|
|
167
|
+
for item in board.get(phase, []):
|
|
168
|
+
lines += [
|
|
169
|
+
"",
|
|
170
|
+
f"[[{phase}]]",
|
|
171
|
+
f"id = {_toml_str(item['id'])}",
|
|
172
|
+
f"text = {_toml_str(item.get('text', ''))}",
|
|
173
|
+
f"done = {'true' if item.get('done') else 'false'}",
|
|
174
|
+
]
|
|
175
|
+
if phase == "baking":
|
|
176
|
+
prio = item.get("priority", "next")
|
|
177
|
+
lines.append(
|
|
178
|
+
f"priority = {_toml_str(prio if prio in PRIORITIES else 'next')}"
|
|
179
|
+
)
|
|
180
|
+
for key in ("by", "date", "note"):
|
|
181
|
+
val = item.get(key, "")
|
|
182
|
+
if val: # omit-empty: a blank stamp/note writes no line
|
|
183
|
+
lines.append(f"{key} = {_toml_str(val)}")
|
|
184
|
+
Path(path).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def default_board(product: str) -> dict:
|
|
188
|
+
"""A fresh board seeded with the maker workflow — the Ingredients list IS
|
|
189
|
+
the brief's checklist; later phases start with their skeleton laps."""
|
|
190
|
+
|
|
191
|
+
def items(*texts: str, prio: str | None = None) -> list[dict]:
|
|
192
|
+
out = [{"text": t, "done": False, "by": "", "date": "", "note": ""} for t in texts]
|
|
193
|
+
if prio:
|
|
194
|
+
for it in out:
|
|
195
|
+
it["priority"] = prio
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
"schema": SCHEMA,
|
|
200
|
+
"product": product,
|
|
201
|
+
"goal": f"Ship {product} to real users through the Delivery matrix.",
|
|
202
|
+
"purpose": "",
|
|
203
|
+
"ingredients": items(
|
|
204
|
+
"Name + slug settled (trackerkeeper new done)",
|
|
205
|
+
"One-line summary + long description written into the sidecar",
|
|
206
|
+
"Brand: logo SVG replaced, accent colour picked",
|
|
207
|
+
"Definitions: who is this for, what does v1 do (the brief)",
|
|
208
|
+
"Feature list drafted — MVP #1 chosen",
|
|
209
|
+
"Delivery targets chosen (which channels matter for THIS app)",
|
|
210
|
+
),
|
|
211
|
+
"baking": items(
|
|
212
|
+
"MVP #1 built and boots (rig boot green)",
|
|
213
|
+
"Tests green + ruff clean + bake --check clean",
|
|
214
|
+
prio="now",
|
|
215
|
+
)
|
|
216
|
+
+ items(
|
|
217
|
+
"First-looks polish pass on the real desktop",
|
|
218
|
+
"rig baseline goldens baked (the visual-bump gate)",
|
|
219
|
+
prio="next",
|
|
220
|
+
),
|
|
221
|
+
"delivery": items(
|
|
222
|
+
"Version tagged (the tag IS the version)",
|
|
223
|
+
"Release drafted by release.yml, reviewed, PUBLISHED",
|
|
224
|
+
),
|
|
225
|
+
"improvements": items(
|
|
226
|
+
"Pull base updates (sync_loaf) and re-verify",
|
|
227
|
+
"Refine → re-bake → re-deliver: the forever lap",
|
|
228
|
+
),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _project_info(root: Path) -> dict:
|
|
233
|
+
"""Summary-card facts (slug, display name, summary, feature cards, icon)
|
|
234
|
+
for ANY checkout root — a generic ``[tool.<slug>.metadata]`` scan (the
|
|
235
|
+
sidecar key is renamed per fork, same lesson as sync_loaf._identity), so
|
|
236
|
+
the switcher can show a sibling loaf's card, not just this checkout's.
|
|
237
|
+
Degrades to directory-name basics on anything malformed."""
|
|
238
|
+
try:
|
|
239
|
+
data = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
|
|
240
|
+
for section in data.get("tool", {}).values():
|
|
241
|
+
meta = section.get("metadata") if isinstance(section, dict) else None
|
|
242
|
+
if isinstance(meta, dict) and "app_slug" in meta:
|
|
243
|
+
return {
|
|
244
|
+
"slug": meta["app_slug"],
|
|
245
|
+
"display_name": meta.get("display_name", meta["app_slug"]),
|
|
246
|
+
"summary": meta.get("summary", ""),
|
|
247
|
+
"feature_cards": meta.get("feature_cards", []),
|
|
248
|
+
"icon": str(root / meta.get("icon_svg_source", "")),
|
|
249
|
+
}
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
return {"slug": root.name, "display_name": root.name, "summary": "",
|
|
253
|
+
"feature_cards": [], "icon": ""}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ── the window half ──────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _make_view(path: Path, restore: dict | None = None):
|
|
260
|
+
"""The breadboard window content. Imported lazily so the file half stays
|
|
261
|
+
importable headless (tests, agents).
|
|
262
|
+
|
|
263
|
+
``restore`` (set only by a self-reload relaunch) re-opens the same phase +
|
|
264
|
+
agent drawer and resumes the agent conversation once the window is live."""
|
|
265
|
+
import re
|
|
266
|
+
|
|
267
|
+
from PySide6.QtCore import QFileSystemWatcher, Qt, QThread, QTimer, Signal
|
|
268
|
+
from PySide6.QtGui import QDesktopServices, QPixmap
|
|
269
|
+
from PySide6.QtWidgets import (
|
|
270
|
+
QApplication,
|
|
271
|
+
QCheckBox,
|
|
272
|
+
QFrame,
|
|
273
|
+
QHBoxLayout,
|
|
274
|
+
QLabel,
|
|
275
|
+
QLineEdit,
|
|
276
|
+
QPlainTextEdit,
|
|
277
|
+
QPushButton,
|
|
278
|
+
QScrollArea,
|
|
279
|
+
QSizePolicy,
|
|
280
|
+
QStackedWidget,
|
|
281
|
+
QVBoxLayout,
|
|
282
|
+
QWidget,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
from trackerkeeper import ui_helpers
|
|
286
|
+
from trackerkeeper.design_tokens import TYPE_BODY, TYPE_DISPLAY, type_qss
|
|
287
|
+
from trackerkeeper.settings import get_settings
|
|
288
|
+
|
|
289
|
+
accent = ui_helpers.ACCENT
|
|
290
|
+
|
|
291
|
+
_EDIT_QSS = (
|
|
292
|
+
"background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.12);"
|
|
293
|
+
"border-radius:6px;padding:4px 8px;color:#ddd;"
|
|
294
|
+
)
|
|
295
|
+
# `.QFrame` (exact type) not `QFrame` — a bare selector cascades the border
|
|
296
|
+
# into child QLabels (QLabel subclasses QFrame), boxing every line of text.
|
|
297
|
+
_CARD_QSS = (
|
|
298
|
+
".QFrame{background:rgba(255,255,255,0.045);border:1px solid "
|
|
299
|
+
"rgba(255,255,255,0.10);border-radius:10px;}"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def _linkify(text: str) -> str:
|
|
303
|
+
import html
|
|
304
|
+
|
|
305
|
+
escaped = html.escape(text)
|
|
306
|
+
escaped = re.sub(
|
|
307
|
+
r"(https?://[^\s<]+)",
|
|
308
|
+
rf'<a href="\1" style="color:{accent};">\1</a>',
|
|
309
|
+
escaped,
|
|
310
|
+
)
|
|
311
|
+
return escaped.replace("\n", "<br>")
|
|
312
|
+
|
|
313
|
+
class _ChannelProbe(QThread):
|
|
314
|
+
"""deliver's DETECTED state — the trust engine. Runs off the UI thread
|
|
315
|
+
(git/gh/network). Emits ``{"tag", "channels":[...]}`` where each channel
|
|
316
|
+
is a dict carrying its live step states, the next guide, and — when
|
|
317
|
+
LIVE — the real store URL + install command for the celebratory card.
|
|
318
|
+
Emits the exception if probing itself failed. Every value here is a
|
|
319
|
+
true probe result: nothing is asserted, so a green can't be faked."""
|
|
320
|
+
|
|
321
|
+
ready = Signal(object)
|
|
322
|
+
|
|
323
|
+
def run(self) -> None: # noqa: N802 (Qt override)
|
|
324
|
+
try:
|
|
325
|
+
from trackerkeeper import deliver
|
|
326
|
+
|
|
327
|
+
ctx = deliver._ctx()
|
|
328
|
+
channels = []
|
|
329
|
+
for ch in deliver._channels():
|
|
330
|
+
states = ch.states(ctx)
|
|
331
|
+
guide = ""
|
|
332
|
+
for step, st in zip(ch.steps, states, strict=True):
|
|
333
|
+
if st is not True:
|
|
334
|
+
guide = step.guide(ctx)
|
|
335
|
+
break
|
|
336
|
+
live = bool(states) and all(s is True for s in states)
|
|
337
|
+
channels.append({
|
|
338
|
+
"key": ch.key,
|
|
339
|
+
"title": ch.title,
|
|
340
|
+
"note": ch.note,
|
|
341
|
+
"stub": ch.stub,
|
|
342
|
+
"alert": ch.alert(ctx),
|
|
343
|
+
"steps": [(s.title, st)
|
|
344
|
+
for s, st in zip(ch.steps, states, strict=True)],
|
|
345
|
+
"guide": guide,
|
|
346
|
+
"live": live,
|
|
347
|
+
"store_url": ch.store_url(ctx) if live else "",
|
|
348
|
+
"install_cmd": ch.install_cmd(ctx) if live else "",
|
|
349
|
+
})
|
|
350
|
+
self.ready.emit({"tag": ctx.tag, "channels": channels})
|
|
351
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
352
|
+
self.ready.emit(exc)
|
|
353
|
+
|
|
354
|
+
class BoardView(QWidget):
|
|
355
|
+
"""Tabs across the top; every interaction writes the file."""
|
|
356
|
+
|
|
357
|
+
def __init__(self) -> None:
|
|
358
|
+
super().__init__()
|
|
359
|
+
self._path = Path(path)
|
|
360
|
+
self._root = self._path.parent
|
|
361
|
+
self._home_path = board_path()
|
|
362
|
+
self._board = load(self._path)
|
|
363
|
+
self._writing = False
|
|
364
|
+
self._probe: _ChannelProbe | None = None
|
|
365
|
+
self._channel_rows = None # probed on first Delivery open; Refresh re-runs
|
|
366
|
+
self._probe_tag = None
|
|
367
|
+
# launch mode — the self-refreshing board while a release is in flight
|
|
368
|
+
self._launch_timer = QTimer(self)
|
|
369
|
+
self._launch_timer.setSingleShot(True)
|
|
370
|
+
self._launch_timer.timeout.connect(self._start_probe)
|
|
371
|
+
self._launch_active = False
|
|
372
|
+
self._launch_interval = 0
|
|
373
|
+
self._launch_polls = 0
|
|
374
|
+
self._launch_stable = 0
|
|
375
|
+
self._last_state_sig = None
|
|
376
|
+
self._settled_sig = None # the state we auto-stopped on — don't re-arm for it
|
|
377
|
+
self._live_since: dict = {} # channel key → wall-clock it flipped LIVE (this session)
|
|
378
|
+
|
|
379
|
+
root = QVBoxLayout(self)
|
|
380
|
+
root.setContentsMargins(16, 10, 16, 12)
|
|
381
|
+
root.setSpacing(10)
|
|
382
|
+
|
|
383
|
+
# ── the project bar: which loaf is on the bench + wind-down ──
|
|
384
|
+
projbar = QHBoxLayout()
|
|
385
|
+
projbar.setSpacing(8)
|
|
386
|
+
plabel = QLabel("Project")
|
|
387
|
+
plabel.setStyleSheet("color:#888;")
|
|
388
|
+
projbar.addWidget(plabel)
|
|
389
|
+
from trackerkeeper.selector import Selector, selector_qss
|
|
390
|
+
|
|
391
|
+
self._project_sel = Selector()
|
|
392
|
+
self._project_sel.setStyleSheet(selector_qss())
|
|
393
|
+
self._projects = discover_projects()
|
|
394
|
+
for product, bpath in self._projects:
|
|
395
|
+
label = product + (" (here)" if bpath == self._home_path else "")
|
|
396
|
+
self._project_sel.addItem(label, str(bpath))
|
|
397
|
+
idx = self._project_sel.findData(str(self._path))
|
|
398
|
+
if idx >= 0:
|
|
399
|
+
self._project_sel.setCurrentIndex(idx)
|
|
400
|
+
self._project_sel.setFixedWidth(240)
|
|
401
|
+
self._project_sel.currentIndexChanged.connect(self._on_project_pick)
|
|
402
|
+
projbar.addWidget(self._project_sel)
|
|
403
|
+
projbar.addStretch(1)
|
|
404
|
+
self._winddown_note = QLabel("")
|
|
405
|
+
self._winddown_note.setStyleSheet("color:#8f8;font-size:11px;")
|
|
406
|
+
projbar.addWidget(self._winddown_note)
|
|
407
|
+
wind = ui_helpers.RoundedButton("Wind down…", variant="ghost")
|
|
408
|
+
wind.setToolTip(
|
|
409
|
+
"Ask the agent to run the wind-down ritual for this project\n"
|
|
410
|
+
"(land green → update the handoff → commit + push). Written into\n"
|
|
411
|
+
"the board as agent_request — the agent fulfils it and clears it.")
|
|
412
|
+
self._wind_btn = wind
|
|
413
|
+
wind.clicked.connect(self._request_wind_down)
|
|
414
|
+
projbar.addWidget(wind)
|
|
415
|
+
# ⌨ Agent — a real Claude Code terminal beside the board it drives.
|
|
416
|
+
self._agent_btn = ui_helpers.RoundedButton("⌨ Agent", variant="ghost")
|
|
417
|
+
self._agent_btn.setCheckable(True)
|
|
418
|
+
# drop clicked(checked)'s bool arg — it must NOT land in force_off,
|
|
419
|
+
# which would force the drawer closed the instant you open it.
|
|
420
|
+
self._agent_btn.clicked.connect(lambda _=False: self._toggle_agent())
|
|
421
|
+
self._prime_agent_button()
|
|
422
|
+
projbar.addWidget(self._agent_btn)
|
|
423
|
+
# ⟳ Reload — restart the breadboard onto the code now on disk (after
|
|
424
|
+
# the agent edits trackerkeeper), keeping your place + the agent session.
|
|
425
|
+
self._reload_btn = ui_helpers.RoundedButton("⟳ Reload", variant="ghost")
|
|
426
|
+
self._reload_btn.setToolTip(
|
|
427
|
+
"Restart the breadboard on the latest trackerkeeper code without losing\n"
|
|
428
|
+
"your place. Validates the code imports first, saves which project\n"
|
|
429
|
+
"and phase you're on, and resumes the agent conversation "
|
|
430
|
+
"(claude --continue).")
|
|
431
|
+
self._reload_btn.clicked.connect(lambda _=False: self._request_reload())
|
|
432
|
+
projbar.addWidget(self._reload_btn)
|
|
433
|
+
root.addLayout(projbar)
|
|
434
|
+
|
|
435
|
+
# ── board (top) + agent terminal drawer (bottom), splittable ──
|
|
436
|
+
from PySide6.QtWidgets import QSplitter
|
|
437
|
+
|
|
438
|
+
self._split = QSplitter(Qt.Orientation.Vertical)
|
|
439
|
+
self._split.setChildrenCollapsible(False)
|
|
440
|
+
self._split.setHandleWidth(6)
|
|
441
|
+
board_pane = QWidget()
|
|
442
|
+
self._board_pane = board_pane
|
|
443
|
+
bp = QVBoxLayout(board_pane)
|
|
444
|
+
bp.setContentsMargins(0, 0, 0, 0)
|
|
445
|
+
bp.setSpacing(10)
|
|
446
|
+
|
|
447
|
+
pills = QHBoxLayout()
|
|
448
|
+
pills.setSpacing(6)
|
|
449
|
+
self._pill_buttons: dict[str, QPushButton] = {}
|
|
450
|
+
for phase in PHASES:
|
|
451
|
+
b = ui_helpers.RoundedButton(_PHASE_TITLES[phase], variant="pill",
|
|
452
|
+
radius=15)
|
|
453
|
+
b.setMinimumHeight(30)
|
|
454
|
+
b.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
|
455
|
+
b.clicked.connect(lambda _=False, ph=phase: self._show_phase(ph))
|
|
456
|
+
pills.addWidget(b, 1)
|
|
457
|
+
self._pill_buttons[phase] = b
|
|
458
|
+
bp.addLayout(pills)
|
|
459
|
+
|
|
460
|
+
self._goal = QLabel(self._board.get("goal", ""))
|
|
461
|
+
self._goal.setWordWrap(True)
|
|
462
|
+
self._goal.setStyleSheet(type_qss(TYPE_DISPLAY) + "color:#ddd;")
|
|
463
|
+
bp.addWidget(self._goal)
|
|
464
|
+
|
|
465
|
+
self._stack = QStackedWidget()
|
|
466
|
+
bp.addWidget(self._stack, 1)
|
|
467
|
+
self._split.addWidget(board_pane)
|
|
468
|
+
|
|
469
|
+
# remembered dock (bottom / left / right) — read before the drawer
|
|
470
|
+
# is built so its dock button can label the current side
|
|
471
|
+
self._agent_dock = get_settings().agent_dock
|
|
472
|
+
self._term_host = self._build_agent_drawer()
|
|
473
|
+
self._term_host.setVisible(False)
|
|
474
|
+
self._term = None # the live TerminalWidget, spawned on first open
|
|
475
|
+
self._resume_agent = False # one-shot: next spawn does claude --continue
|
|
476
|
+
self._split.addWidget(self._term_host)
|
|
477
|
+
root.addWidget(self._split, 1)
|
|
478
|
+
# apply orientation + board↔terminal order now; sizes wait for open
|
|
479
|
+
self._apply_agent_dock(resize=False)
|
|
480
|
+
|
|
481
|
+
self._pages: dict[str, QWidget] = {}
|
|
482
|
+
self._rebuild_pages()
|
|
483
|
+
self._show_phase(self._first_open_phase())
|
|
484
|
+
|
|
485
|
+
# outside edits (the agent, an editor, git) reload the view live
|
|
486
|
+
self._watcher = QFileSystemWatcher([str(self._path)])
|
|
487
|
+
self._watcher.fileChanged.connect(self._on_file_changed)
|
|
488
|
+
self._rewatch = QTimer(self)
|
|
489
|
+
self._rewatch.setSingleShot(True)
|
|
490
|
+
self._rewatch.timeout.connect(self._ensure_watched)
|
|
491
|
+
|
|
492
|
+
# Let a running channel probe finish before teardown — closing the
|
|
493
|
+
# window mid-probe would otherwise abort ("QThread destroyed while
|
|
494
|
+
# running"). Off-thread work is short (git/gh); wait briefly.
|
|
495
|
+
app = QApplication.instance()
|
|
496
|
+
if app is not None:
|
|
497
|
+
app.aboutToQuit.connect(self._cleanup)
|
|
498
|
+
|
|
499
|
+
# Live accent: the whole view bakes `accent` (a _make_view closure
|
|
500
|
+
# local) into its QSS, which _propagate_theme_constants can't reach.
|
|
501
|
+
# On a theme change, refresh that local and re-stamp/rebuild.
|
|
502
|
+
from trackerkeeper.bus import register_for_theme
|
|
503
|
+
|
|
504
|
+
register_for_theme(self, self._on_theme)
|
|
505
|
+
|
|
506
|
+
# a self-reload relaunch restores its place once the window is live
|
|
507
|
+
# (after show + layout, so the split sizes for the real geometry)
|
|
508
|
+
if restore:
|
|
509
|
+
QTimer.singleShot(0, lambda: self._apply_restore(restore))
|
|
510
|
+
|
|
511
|
+
def _on_theme(self) -> None:
|
|
512
|
+
nonlocal accent
|
|
513
|
+
accent = ui_helpers.ACCENT # refresh the frozen closure local
|
|
514
|
+
# pills + wind + agent are painted RoundedButtons — they read ACCENT
|
|
515
|
+
# live and repaint themselves (register_for_theme). Only the QSS
|
|
516
|
+
# surfaces (the project Selector + the rebuilt cards) need re-stamp.
|
|
517
|
+
from trackerkeeper.selector import selector_qss
|
|
518
|
+
|
|
519
|
+
self._project_sel.setStyleSheet(selector_qss())
|
|
520
|
+
self._rebuild_pages() # re-bakes cards/checkboxes/adders with the new accent
|
|
521
|
+
|
|
522
|
+
def _cleanup(self) -> None:
|
|
523
|
+
self._launch_timer.stop()
|
|
524
|
+
if self._probe is not None and self._probe.isRunning():
|
|
525
|
+
self._probe.wait(3000)
|
|
526
|
+
self._stop_agent()
|
|
527
|
+
|
|
528
|
+
# ── the agent terminal (⌨) ────────────────────────────────────────
|
|
529
|
+
def _prime_agent_button(self) -> None:
|
|
530
|
+
from trackerkeeper import terminal
|
|
531
|
+
|
|
532
|
+
if not terminal.is_supported():
|
|
533
|
+
self._agent_btn.setEnabled(False)
|
|
534
|
+
self._agent_btn.setToolTip(
|
|
535
|
+
"The embedded terminal needs a POSIX pty + pyte "
|
|
536
|
+
"(pip install 'trackerkeeper-base[terminal]'); not available here.")
|
|
537
|
+
elif not terminal.agent_available():
|
|
538
|
+
self._agent_btn.setEnabled(False)
|
|
539
|
+
self._agent_btn.setToolTip(
|
|
540
|
+
"Claude Code (`claude`) isn't on PATH — install it, or set "
|
|
541
|
+
"TRACKERKEEPER_AGENT_CMD to the command to run.")
|
|
542
|
+
else:
|
|
543
|
+
self._agent_btn.setToolTip(
|
|
544
|
+
"Open a Claude Code terminal in this project — talk to the "
|
|
545
|
+
"agent right beside the board it edits.")
|
|
546
|
+
|
|
547
|
+
def _build_agent_drawer(self) -> QWidget:
|
|
548
|
+
host = QWidget()
|
|
549
|
+
v = QVBoxLayout(host)
|
|
550
|
+
v.setContentsMargins(0, 6, 0, 0)
|
|
551
|
+
v.setSpacing(4)
|
|
552
|
+
bar = QHBoxLayout()
|
|
553
|
+
self._agent_title = QLabel("")
|
|
554
|
+
self._agent_title.setStyleSheet("color:#999;font-size:11px;")
|
|
555
|
+
bar.addWidget(self._agent_title)
|
|
556
|
+
bar.addStretch(1)
|
|
557
|
+
self._dock_btn = QPushButton(self._dock_label())
|
|
558
|
+
self._dock_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
559
|
+
self._dock_btn.setStyleSheet(self._mini_agent_qss())
|
|
560
|
+
self._dock_btn.setToolTip("Dock the agent bottom / right / left of the board")
|
|
561
|
+
self._dock_btn.clicked.connect(self._cycle_agent_dock)
|
|
562
|
+
bar.addWidget(self._dock_btn)
|
|
563
|
+
restart = QPushButton("restart")
|
|
564
|
+
restart.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
565
|
+
restart.setStyleSheet(self._mini_agent_qss())
|
|
566
|
+
restart.clicked.connect(self._restart_agent)
|
|
567
|
+
bar.addWidget(restart)
|
|
568
|
+
close = QPushButton("hide ✕")
|
|
569
|
+
close.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
570
|
+
close.setStyleSheet(self._mini_agent_qss())
|
|
571
|
+
close.clicked.connect(lambda: self._toggle_agent(force_off=True))
|
|
572
|
+
bar.addWidget(close)
|
|
573
|
+
v.addLayout(bar)
|
|
574
|
+
self._term_slot = QVBoxLayout()
|
|
575
|
+
self._term_slot.setContentsMargins(0, 0, 0, 0)
|
|
576
|
+
v.addLayout(self._term_slot, 1)
|
|
577
|
+
return host
|
|
578
|
+
|
|
579
|
+
@staticmethod
|
|
580
|
+
def _mini_agent_qss() -> str:
|
|
581
|
+
return ("QPushButton{border:none;border-radius:6px;padding:3px 10px;"
|
|
582
|
+
"background:rgba(255,255,255,0.06);color:#bbb;font-size:11px;}"
|
|
583
|
+
"QPushButton:hover{background:rgba(255,255,255,0.14);color:#fff;}")
|
|
584
|
+
|
|
585
|
+
# ── agent dock: bottom / right / left of the board ────────────────
|
|
586
|
+
_DOCK_LABELS = {"bottom": "⬓ bottom", "right": "◨ right", "left": "◧ left"}
|
|
587
|
+
|
|
588
|
+
def _dock_label(self) -> str:
|
|
589
|
+
return self._DOCK_LABELS.get(self._agent_dock, "⬓ bottom")
|
|
590
|
+
|
|
591
|
+
def _apply_agent_dock(self, resize: bool = True) -> None:
|
|
592
|
+
"""Reorient the splitter and order the board/terminal panes for the
|
|
593
|
+
current dock. Horizontal for left/right, vertical for bottom; the
|
|
594
|
+
terminal leads the pair only when docked left."""
|
|
595
|
+
pos = self._agent_dock
|
|
596
|
+
horizontal = pos in ("left", "right")
|
|
597
|
+
self._split.setOrientation(
|
|
598
|
+
Qt.Orientation.Horizontal if horizontal else Qt.Orientation.Vertical)
|
|
599
|
+
first, second = (
|
|
600
|
+
(self._term_host, self._board_pane) if pos == "left"
|
|
601
|
+
else (self._board_pane, self._term_host))
|
|
602
|
+
self._split.insertWidget(0, first) # moves the existing widgets —
|
|
603
|
+
self._split.insertWidget(1, second) # QSplitter re-parents in place
|
|
604
|
+
if getattr(self, "_dock_btn", None) is not None:
|
|
605
|
+
self._dock_btn.setText(self._dock_label())
|
|
606
|
+
if resize and self._term_host.isVisible():
|
|
607
|
+
self._resize_agent_split()
|
|
608
|
+
|
|
609
|
+
def _resize_agent_split(self) -> None:
|
|
610
|
+
"""Give the terminal a comfortable slice — ~40% tall docked bottom,
|
|
611
|
+
~42% wide docked to a side — with the board taking the rest."""
|
|
612
|
+
horizontal = self._agent_dock in ("left", "right")
|
|
613
|
+
total = max(1, self._split.width() if horizontal else self._split.height())
|
|
614
|
+
term = int(total * (0.42 if horizontal else 0.40))
|
|
615
|
+
board = max(1, total - term)
|
|
616
|
+
self._split.setSizes(
|
|
617
|
+
[term, board] if self._agent_dock == "left" else [board, term])
|
|
618
|
+
|
|
619
|
+
def _cycle_agent_dock(self) -> None:
|
|
620
|
+
order = ("bottom", "right", "left")
|
|
621
|
+
self._agent_dock = order[(order.index(self._agent_dock) + 1) % len(order)]
|
|
622
|
+
get_settings().agent_dock = self._agent_dock
|
|
623
|
+
self._apply_agent_dock(resize=True)
|
|
624
|
+
|
|
625
|
+
def _toggle_agent(self, force_off: bool = False) -> None:
|
|
626
|
+
show = self._agent_btn.isChecked() and not force_off
|
|
627
|
+
self._agent_btn.setChecked(show)
|
|
628
|
+
self._term_host.setVisible(show)
|
|
629
|
+
if show:
|
|
630
|
+
if self._term is None:
|
|
631
|
+
self._spawn_agent()
|
|
632
|
+
self._resize_agent_split() # size for the current dock
|
|
633
|
+
if self._term is not None:
|
|
634
|
+
self._term.setFocus()
|
|
635
|
+
|
|
636
|
+
def _spawn_agent(self) -> None:
|
|
637
|
+
from trackerkeeper import terminal
|
|
638
|
+
|
|
639
|
+
slug = _project_info(self._root)["slug"]
|
|
640
|
+
resume, self._resume_agent = self._resume_agent, False # consume one-shot
|
|
641
|
+
self._agent_title.setText(
|
|
642
|
+
f"⌨ claude · {slug} ({self._root})"
|
|
643
|
+
+ (" — resuming" if resume else ""))
|
|
644
|
+
self._term = terminal.TerminalWidget(
|
|
645
|
+
terminal.claude_argv(resume=resume), cwd=self._root)
|
|
646
|
+
self._term.exited.connect(self._on_agent_exit)
|
|
647
|
+
self._term_slot.addWidget(self._term)
|
|
648
|
+
|
|
649
|
+
def _stop_agent(self) -> None:
|
|
650
|
+
if self._term is not None:
|
|
651
|
+
self._term.stop()
|
|
652
|
+
self._term.setParent(None)
|
|
653
|
+
self._term.deleteLater()
|
|
654
|
+
self._term = None
|
|
655
|
+
|
|
656
|
+
def _restart_agent(self) -> None:
|
|
657
|
+
self._stop_agent()
|
|
658
|
+
self._spawn_agent()
|
|
659
|
+
self._term.setFocus()
|
|
660
|
+
|
|
661
|
+
def _on_agent_exit(self, code: int) -> None:
|
|
662
|
+
if self._term is not None:
|
|
663
|
+
self._agent_title.setText(
|
|
664
|
+
self._agent_title.text() + f" — exited ({code}); press restart")
|
|
665
|
+
|
|
666
|
+
# ── self-reload: restart onto the code now on disk ────────────────
|
|
667
|
+
def _current_phase(self) -> str:
|
|
668
|
+
for ph, b in self._pill_buttons.items():
|
|
669
|
+
if b.isChecked():
|
|
670
|
+
return ph
|
|
671
|
+
return self._first_open_phase()
|
|
672
|
+
|
|
673
|
+
@staticmethod
|
|
674
|
+
def _validate_reload() -> tuple[bool, str]:
|
|
675
|
+
"""Prove the on-disk code still imports BEFORE we exec into it — a
|
|
676
|
+
syntax error the agent just left would otherwise crash-loop the
|
|
677
|
+
relaunch. A fresh subprocess imports the surfaces a relaunch needs;
|
|
678
|
+
non-zero means don't reload (stay on the good in-memory code)."""
|
|
679
|
+
import subprocess
|
|
680
|
+
|
|
681
|
+
mods = ("app", "window", "breadboard", "terminal", "ui_helpers")
|
|
682
|
+
code = "import " + ", ".join(f"{_PKG}.{m}" for m in mods)
|
|
683
|
+
try:
|
|
684
|
+
r = subprocess.run(
|
|
685
|
+
[sys.executable, "-c", code],
|
|
686
|
+
capture_output=True, text=True, timeout=60)
|
|
687
|
+
except Exception as e: # pragma: no cover - subprocess spawn failure
|
|
688
|
+
return False, str(e)
|
|
689
|
+
return r.returncode == 0, (r.stderr.strip() or r.stdout.strip())
|
|
690
|
+
|
|
691
|
+
def _apply_restore(self, r: dict) -> None:
|
|
692
|
+
"""Re-open the saved phase + agent drawer after a self-reload."""
|
|
693
|
+
phase = r.get("phase")
|
|
694
|
+
if phase in PHASES:
|
|
695
|
+
self._show_phase(phase)
|
|
696
|
+
if r.get("agent") and self._agent_btn.isEnabled():
|
|
697
|
+
self._resume_agent = bool(r.get("resume")) # → claude --continue
|
|
698
|
+
self._agent_btn.setChecked(True)
|
|
699
|
+
self._toggle_agent()
|
|
700
|
+
|
|
701
|
+
def _request_reload(self) -> None:
|
|
702
|
+
from PySide6.QtWidgets import QMessageBox
|
|
703
|
+
|
|
704
|
+
ok, err = self._validate_reload()
|
|
705
|
+
if not ok:
|
|
706
|
+
QMessageBox.warning(
|
|
707
|
+
self, "Reload blocked",
|
|
708
|
+
"The code on disk didn't import — not restarting (your running "
|
|
709
|
+
"app is untouched). Fix this, then reload again:\n\n"
|
|
710
|
+
+ (err or "(no detail)")[-1500:])
|
|
711
|
+
return
|
|
712
|
+
restore = {
|
|
713
|
+
"project": str(self._path),
|
|
714
|
+
"phase": self._current_phase(),
|
|
715
|
+
# resume the agent only if a terminal is live right now
|
|
716
|
+
"agent": self._term is not None and not self._term_host.isHidden(),
|
|
717
|
+
"resume": self._term is not None,
|
|
718
|
+
}
|
|
719
|
+
get_settings().save_geometry(self.window()) # execv skips closeEvent
|
|
720
|
+
self._cleanup() # stop probes/timers + close the pty (claude flushed)
|
|
721
|
+
import json
|
|
722
|
+
|
|
723
|
+
os.environ["TRACKERKEEPER_BREADBOARD_RESTORE"] = json.dumps(restore)
|
|
724
|
+
argv = [sys.executable, "-m", f"{_PKG}.breadboard", *sys.argv[1:]]
|
|
725
|
+
try:
|
|
726
|
+
os.execve(sys.executable, argv, os.environ)
|
|
727
|
+
except OSError as e: # exec failed → we're still alive; report it
|
|
728
|
+
os.environ.pop("TRACKERKEEPER_BREADBOARD_RESTORE", None)
|
|
729
|
+
from PySide6.QtWidgets import QMessageBox
|
|
730
|
+
|
|
731
|
+
QMessageBox.critical(self, "Reload failed", f"Couldn't relaunch:\n{e}")
|
|
732
|
+
|
|
733
|
+
# ── project switching + the wind-down request ─────────────────────
|
|
734
|
+
def _on_project_pick(self, _idx: int) -> None:
|
|
735
|
+
data = self._project_sel.currentData()
|
|
736
|
+
if data and Path(data) != self._path:
|
|
737
|
+
self.set_project(Path(data))
|
|
738
|
+
|
|
739
|
+
def set_project(self, new_path: Path) -> None:
|
|
740
|
+
"""Put another checkout's board on the bench: reload state, re-anchor
|
|
741
|
+
the summary card + watcher; live channel detection stays home-only
|
|
742
|
+
(deliver probes THIS checkout — a sibling's truth needs its own
|
|
743
|
+
`<slug>-breadboard`)."""
|
|
744
|
+
self._launch_timer.stop() # a new project's delivery is its own story
|
|
745
|
+
self._launch_active = False
|
|
746
|
+
self._last_state_sig = None
|
|
747
|
+
self._settled_sig = None
|
|
748
|
+
self._live_since = {}
|
|
749
|
+
self._stop_agent() # the terminal is scoped to a project dir
|
|
750
|
+
self._agent_btn.setChecked(False)
|
|
751
|
+
self._term_host.setVisible(False)
|
|
752
|
+
self._watcher.removePath(str(self._path))
|
|
753
|
+
self._path = Path(new_path)
|
|
754
|
+
self._root = self._path.parent
|
|
755
|
+
self._board = load(self._path)
|
|
756
|
+
self._channel_rows = None
|
|
757
|
+
self._winddown_note.setText("")
|
|
758
|
+
self._watcher.addPath(str(self._path))
|
|
759
|
+
self._goal.setText(self._board.get("goal", ""))
|
|
760
|
+
self._rebuild_pages()
|
|
761
|
+
self._show_phase(self._first_open_phase())
|
|
762
|
+
|
|
763
|
+
def _is_home(self) -> bool:
|
|
764
|
+
return self._path == self._home_path
|
|
765
|
+
|
|
766
|
+
def _request_wind_down(self) -> None:
|
|
767
|
+
self._board["agent_request"] = (
|
|
768
|
+
f"wind down — requested by the maker {date.today().isoformat()}"
|
|
769
|
+
)
|
|
770
|
+
self._write()
|
|
771
|
+
self._winddown_note.setText("wind-down requested — the agent will land it")
|
|
772
|
+
|
|
773
|
+
# ── phases ────────────────────────────────────────────────────────
|
|
774
|
+
def _first_open_phase(self) -> str:
|
|
775
|
+
for phase in PHASES: # land the maker on the working phase
|
|
776
|
+
if any(not i.get("done") for i in self._board.get(phase, [])):
|
|
777
|
+
return phase
|
|
778
|
+
return PHASES[0]
|
|
779
|
+
|
|
780
|
+
def _show_phase(self, phase: str) -> None:
|
|
781
|
+
for ph, b in self._pill_buttons.items():
|
|
782
|
+
b.setChecked(ph == phase)
|
|
783
|
+
self._stack.setCurrentWidget(self._pages[phase])
|
|
784
|
+
if phase == "delivery" and self._channel_rows is None:
|
|
785
|
+
self._start_probe()
|
|
786
|
+
|
|
787
|
+
def _rebuild_pages(self) -> None:
|
|
788
|
+
current = None
|
|
789
|
+
for ph, b in self._pill_buttons.items():
|
|
790
|
+
if b.isChecked():
|
|
791
|
+
current = ph
|
|
792
|
+
while self._stack.count():
|
|
793
|
+
w = self._stack.widget(0)
|
|
794
|
+
self._stack.removeWidget(w)
|
|
795
|
+
w.deleteLater()
|
|
796
|
+
self._pages = {
|
|
797
|
+
"ingredients": self._build_ingredients(),
|
|
798
|
+
"baking": self._build_baking(),
|
|
799
|
+
"delivery": self._build_delivery(),
|
|
800
|
+
"improvements": self._build_checklist_page("improvements"),
|
|
801
|
+
}
|
|
802
|
+
for phase in PHASES:
|
|
803
|
+
self._stack.addWidget(self._pages[phase])
|
|
804
|
+
if current:
|
|
805
|
+
self._show_phase(current)
|
|
806
|
+
|
|
807
|
+
@staticmethod
|
|
808
|
+
def _scroll(inner: QWidget) -> QScrollArea:
|
|
809
|
+
s = QScrollArea()
|
|
810
|
+
s.setWidgetResizable(True)
|
|
811
|
+
s.setFrameShape(QFrame.Shape.NoFrame)
|
|
812
|
+
s.setWidget(inner)
|
|
813
|
+
# the slim, auto-fading accent pills the app family uses everywhere
|
|
814
|
+
# (no track, no gutter fill — just the handle over the frost)
|
|
815
|
+
ui_helpers.install_autofade_scrollbars(s)
|
|
816
|
+
return s
|
|
817
|
+
|
|
818
|
+
# ── Ingredients: the app summary page ─────────────────────────────
|
|
819
|
+
def _build_ingredients(self) -> QWidget:
|
|
820
|
+
side = _project_info(self._root)
|
|
821
|
+
self._slug = side["slug"]
|
|
822
|
+
inner = QWidget()
|
|
823
|
+
vbox = QVBoxLayout(inner)
|
|
824
|
+
vbox.setContentsMargins(2, 4, 2, 4)
|
|
825
|
+
vbox.setSpacing(10)
|
|
826
|
+
|
|
827
|
+
card = QFrame()
|
|
828
|
+
card.setStyleSheet(_CARD_QSS)
|
|
829
|
+
head = QHBoxLayout(card)
|
|
830
|
+
head.setContentsMargins(14, 12, 14, 12)
|
|
831
|
+
head.setSpacing(14)
|
|
832
|
+
logo = QLabel()
|
|
833
|
+
pm = QPixmap(side["icon"]) if side["icon"] else QPixmap()
|
|
834
|
+
if not pm.isNull():
|
|
835
|
+
logo.setPixmap(pm.scaled(
|
|
836
|
+
64, 64, Qt.AspectRatioMode.KeepAspectRatio,
|
|
837
|
+
Qt.TransformationMode.SmoothTransformation))
|
|
838
|
+
else:
|
|
839
|
+
logo.setText("◌")
|
|
840
|
+
logo.setStyleSheet("font-size:40px;color:#666;")
|
|
841
|
+
head.addWidget(logo)
|
|
842
|
+
names = QVBoxLayout()
|
|
843
|
+
title = QLabel(side["display_name"])
|
|
844
|
+
title.setStyleSheet(type_qss(TYPE_DISPLAY) + "color:#fff;")
|
|
845
|
+
names.addWidget(title)
|
|
846
|
+
summary = QLabel(side["summary"])
|
|
847
|
+
summary.setWordWrap(True)
|
|
848
|
+
summary.setStyleSheet("color:#aaa;")
|
|
849
|
+
names.addWidget(summary)
|
|
850
|
+
head.addLayout(names, 1)
|
|
851
|
+
brand = QPushButton("Brand assets…")
|
|
852
|
+
brand.setToolTip("Open the assets folder (the logo SVG lives there)")
|
|
853
|
+
brand.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
854
|
+
brand.setStyleSheet(
|
|
855
|
+
"QPushButton{border:1px solid rgba(255,255,255,0.2);border-radius:8px;"
|
|
856
|
+
"padding:6px 12px;background:transparent;color:#ccc;}"
|
|
857
|
+
f"QPushButton:hover{{border-color:{accent};color:#fff;}}"
|
|
858
|
+
)
|
|
859
|
+
brand.clicked.connect(self._open_brand_assets)
|
|
860
|
+
head.addWidget(brand)
|
|
861
|
+
vbox.addWidget(card)
|
|
862
|
+
|
|
863
|
+
purpose_label = QLabel("Purpose — boil the app down (the agent reads this)")
|
|
864
|
+
purpose_label.setStyleSheet("color:#888;")
|
|
865
|
+
vbox.addWidget(purpose_label)
|
|
866
|
+
self._purpose = QPlainTextEdit(self._board.get("purpose", ""))
|
|
867
|
+
self._purpose.setPlaceholderText(
|
|
868
|
+
"Who is this for? What does v1 do? What is deliberately out?")
|
|
869
|
+
self._purpose.setFixedHeight(84)
|
|
870
|
+
self._purpose.setStyleSheet(f"QPlainTextEdit{{{_EDIT_QSS}}}")
|
|
871
|
+
self._purpose.textChanged.connect(self._purpose_changed)
|
|
872
|
+
vbox.addWidget(self._purpose)
|
|
873
|
+
|
|
874
|
+
if side["feature_cards"]:
|
|
875
|
+
feats = QLabel("Major features")
|
|
876
|
+
feats.setStyleSheet("color:#888;")
|
|
877
|
+
vbox.addWidget(feats)
|
|
878
|
+
for fc in side["feature_cards"]:
|
|
879
|
+
lab = QLabel(f"• <b>{fc.get('title', '')}</b> — {fc.get('body', '')}")
|
|
880
|
+
lab.setWordWrap(True)
|
|
881
|
+
lab.setTextFormat(Qt.TextFormat.RichText)
|
|
882
|
+
lab.setStyleSheet("color:#bbb;")
|
|
883
|
+
vbox.addWidget(lab)
|
|
884
|
+
|
|
885
|
+
checklist = QLabel("The brief's checklist")
|
|
886
|
+
checklist.setStyleSheet("color:#888;")
|
|
887
|
+
vbox.addWidget(checklist)
|
|
888
|
+
for item in self._board.get("ingredients", []):
|
|
889
|
+
vbox.addLayout(self._build_check_row(item))
|
|
890
|
+
vbox.addWidget(self._build_adder("ingredients"))
|
|
891
|
+
vbox.addStretch(1)
|
|
892
|
+
return self._scroll(inner)
|
|
893
|
+
|
|
894
|
+
def _open_brand_assets(self) -> None:
|
|
895
|
+
from PySide6.QtCore import QUrl
|
|
896
|
+
|
|
897
|
+
assets = self._root / getattr(self, "_slug", _PKG) / "assets"
|
|
898
|
+
QDesktopServices.openUrl(QUrl.fromLocalFile(str(assets)))
|
|
899
|
+
|
|
900
|
+
def _purpose_changed(self) -> None:
|
|
901
|
+
self._board["purpose"] = self._purpose.toPlainText()
|
|
902
|
+
self._write()
|
|
903
|
+
|
|
904
|
+
# ── Baking: the kanban ─────────────────────────────────────────────
|
|
905
|
+
def _build_baking(self) -> QWidget:
|
|
906
|
+
inner = QWidget()
|
|
907
|
+
row = QHBoxLayout(inner)
|
|
908
|
+
row.setContentsMargins(2, 4, 2, 4)
|
|
909
|
+
row.setSpacing(10)
|
|
910
|
+
titles = {"now": "Now", "next": "Next", "later": "Later", "done": "Done ✓"}
|
|
911
|
+
for col in (*PRIORITIES, "done"):
|
|
912
|
+
frame = QFrame()
|
|
913
|
+
# a soft LANE, not a bordered box — the cards are the boxes, so
|
|
914
|
+
# the column just needs a faint tint to read as a drop zone
|
|
915
|
+
# (borders-in-borders got heavy — August's note).
|
|
916
|
+
frame.setStyleSheet(
|
|
917
|
+
".QFrame{background:rgba(255,255,255,0.025);border:none;"
|
|
918
|
+
"border-radius:10px;}")
|
|
919
|
+
v = QVBoxLayout(frame)
|
|
920
|
+
v.setContentsMargins(10, 8, 10, 8)
|
|
921
|
+
v.setSpacing(6)
|
|
922
|
+
head = QLabel(titles[col])
|
|
923
|
+
head.setStyleSheet(
|
|
924
|
+
f"color:{'#8f8' if col == 'done' else '#fff'};font-weight:bold;")
|
|
925
|
+
v.addWidget(head)
|
|
926
|
+
for item in self._board.get("baking", []):
|
|
927
|
+
in_col = (item.get("done") and col == "done") or (
|
|
928
|
+
not item.get("done") and item.get("priority") == col)
|
|
929
|
+
if in_col:
|
|
930
|
+
v.addWidget(self._build_card(item, col))
|
|
931
|
+
if col != "done":
|
|
932
|
+
adder = QLineEdit()
|
|
933
|
+
adder.setPlaceholderText("add…")
|
|
934
|
+
adder.setStyleSheet(f"QLineEdit{{{_EDIT_QSS}}}")
|
|
935
|
+
adder.returnPressed.connect(
|
|
936
|
+
lambda c=col, e=adder: self._add_item("baking", e.text(), priority=c))
|
|
937
|
+
v.addWidget(adder)
|
|
938
|
+
v.addStretch(1)
|
|
939
|
+
row.addWidget(frame, 1)
|
|
940
|
+
return self._scroll(inner)
|
|
941
|
+
|
|
942
|
+
def _build_card(self, item: dict, col: str) -> QWidget:
|
|
943
|
+
card = QFrame()
|
|
944
|
+
done = col == "done"
|
|
945
|
+
card.setStyleSheet(
|
|
946
|
+
".QFrame{background:rgba(255,255,255,0.06);border:1px solid "
|
|
947
|
+
+ ("rgba(140,255,140,0.25)" if done else "rgba(255,255,255,0.14)")
|
|
948
|
+
+ ";border-radius:8px;}"
|
|
949
|
+
)
|
|
950
|
+
v = QVBoxLayout(card)
|
|
951
|
+
v.setContentsMargins(8, 6, 8, 6)
|
|
952
|
+
v.setSpacing(4)
|
|
953
|
+
lab = QLabel(item.get("text", ""))
|
|
954
|
+
lab.setWordWrap(True)
|
|
955
|
+
lab.setStyleSheet(
|
|
956
|
+
type_qss(TYPE_BODY) + ("color:#9a9;" if done else "color:#ddd;"))
|
|
957
|
+
v.addWidget(lab)
|
|
958
|
+
stamp = f"{item.get('by', '')} {item.get('date', '')}".strip()
|
|
959
|
+
if stamp or item.get("note"):
|
|
960
|
+
meta = QLabel(stamp + (" · " + item["note"] if item.get("note") else ""))
|
|
961
|
+
meta.setWordWrap(True)
|
|
962
|
+
meta.setStyleSheet("color:#777;font-size:11px;")
|
|
963
|
+
v.addWidget(meta)
|
|
964
|
+
btns = QHBoxLayout()
|
|
965
|
+
btns.setSpacing(4)
|
|
966
|
+
|
|
967
|
+
def _mini(text, tip, slot, disabled=False):
|
|
968
|
+
b = QPushButton(text)
|
|
969
|
+
b.setFixedSize(24, 20)
|
|
970
|
+
b.setToolTip(tip)
|
|
971
|
+
b.setEnabled(not disabled)
|
|
972
|
+
b.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
973
|
+
b.setStyleSheet(
|
|
974
|
+
"QPushButton{border:none;border-radius:4px;color:#8a8a8a;"
|
|
975
|
+
"background:transparent;}" # no resting chip — only hover fills
|
|
976
|
+
f"QPushButton:hover{{color:#fff;background:{accent};}}"
|
|
977
|
+
"QPushButton:disabled{color:#444;}")
|
|
978
|
+
b.clicked.connect(slot)
|
|
979
|
+
btns.addWidget(b)
|
|
980
|
+
|
|
981
|
+
order = [*PRIORITIES, "done"]
|
|
982
|
+
i = order.index(col)
|
|
983
|
+
_mini("◀", "move left", lambda: self._move_card(item, order[max(0, i - 1)]),
|
|
984
|
+
disabled=i == 0)
|
|
985
|
+
_mini("▶", "move right",
|
|
986
|
+
lambda: self._move_card(item, order[min(len(order) - 1, i + 1)]),
|
|
987
|
+
disabled=i == len(order) - 1)
|
|
988
|
+
if not done:
|
|
989
|
+
_mini("✓", "done", lambda: self._move_card(item, "done"))
|
|
990
|
+
_mini("✕", "remove", lambda: self._remove_item("baking", item))
|
|
991
|
+
btns.addStretch(1)
|
|
992
|
+
v.addLayout(btns)
|
|
993
|
+
return card
|
|
994
|
+
|
|
995
|
+
def _move_card(self, item: dict, col: str) -> None:
|
|
996
|
+
item["done"] = col == "done"
|
|
997
|
+
if col in PRIORITIES:
|
|
998
|
+
item["priority"] = col
|
|
999
|
+
self._stamp(item)
|
|
1000
|
+
self._write()
|
|
1001
|
+
self._rebuild_pages()
|
|
1002
|
+
|
|
1003
|
+
def _remove_item(self, phase: str, item: dict) -> None:
|
|
1004
|
+
self._board[phase] = [i for i in self._board.get(phase, []) if i is not item]
|
|
1005
|
+
self._write()
|
|
1006
|
+
self._rebuild_pages()
|
|
1007
|
+
|
|
1008
|
+
# ── Delivery: the platform checklist (deliver's detections) ───────
|
|
1009
|
+
def _build_delivery(self) -> QWidget:
|
|
1010
|
+
inner = QWidget()
|
|
1011
|
+
v = QVBoxLayout(inner)
|
|
1012
|
+
v.setContentsMargins(2, 4, 2, 4)
|
|
1013
|
+
v.setSpacing(10)
|
|
1014
|
+
if not self._is_home():
|
|
1015
|
+
away = QLabel(
|
|
1016
|
+
"Live channel detection runs in the project's own checkout — "
|
|
1017
|
+
f"open it with `{_project_info(self._root)['slug']}-breadboard`. "
|
|
1018
|
+
"The board's own delivery items are below.")
|
|
1019
|
+
away.setWordWrap(True)
|
|
1020
|
+
away.setStyleSheet("color:#888;")
|
|
1021
|
+
v.addWidget(away)
|
|
1022
|
+
for item in self._board.get("delivery", []):
|
|
1023
|
+
v.addLayout(self._build_check_row(item))
|
|
1024
|
+
v.addWidget(self._build_adder("delivery"))
|
|
1025
|
+
v.addStretch(1)
|
|
1026
|
+
return self._scroll(inner)
|
|
1027
|
+
bar = QHBoxLayout()
|
|
1028
|
+
self._delivery_status = QLabel("")
|
|
1029
|
+
self._delivery_status.setWordWrap(True)
|
|
1030
|
+
self._delivery_status.setStyleSheet("color:#888;")
|
|
1031
|
+
bar.addWidget(self._delivery_status, 1)
|
|
1032
|
+
_btn_qss = (
|
|
1033
|
+
"QPushButton{border:1px solid rgba(255,255,255,0.2);border-radius:8px;"
|
|
1034
|
+
"padding:4px 12px;background:transparent;color:#ccc;}"
|
|
1035
|
+
f"QPushButton:hover{{border-color:{accent};}}"
|
|
1036
|
+
f"QPushButton:checked{{border-color:{accent};color:{accent};}}")
|
|
1037
|
+
self._watch_btn = QPushButton("Watch")
|
|
1038
|
+
self._watch_btn.setCheckable(True)
|
|
1039
|
+
self._watch_btn.setToolTip(
|
|
1040
|
+
"While a release is in flight, keep re-probing so channels flip to LIVE "
|
|
1041
|
+
"on their own. Auto-arms when a tag exists and something isn't live yet.")
|
|
1042
|
+
self._watch_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
1043
|
+
self._watch_btn.setStyleSheet(_btn_qss)
|
|
1044
|
+
self._watch_btn.clicked.connect(self._toggle_watch)
|
|
1045
|
+
bar.addWidget(self._watch_btn)
|
|
1046
|
+
refresh = QPushButton("Refresh")
|
|
1047
|
+
refresh.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
1048
|
+
refresh.setStyleSheet(_btn_qss)
|
|
1049
|
+
refresh.clicked.connect(self._start_probe)
|
|
1050
|
+
bar.addWidget(refresh)
|
|
1051
|
+
v.addLayout(bar)
|
|
1052
|
+
|
|
1053
|
+
self._channels_box = QVBoxLayout()
|
|
1054
|
+
self._channels_box.setSpacing(8)
|
|
1055
|
+
v.addLayout(self._channels_box)
|
|
1056
|
+
self._watch_btn.setChecked(self._launch_active)
|
|
1057
|
+
if self._channel_rows is not None:
|
|
1058
|
+
self._render_channels(self._channel_rows)
|
|
1059
|
+
|
|
1060
|
+
extra = QLabel("Extra delivery items")
|
|
1061
|
+
extra.setStyleSheet("color:#888;")
|
|
1062
|
+
v.addWidget(extra)
|
|
1063
|
+
for item in self._board.get("delivery", []):
|
|
1064
|
+
v.addLayout(self._build_check_row(item))
|
|
1065
|
+
v.addWidget(self._build_adder("delivery"))
|
|
1066
|
+
v.addStretch(1)
|
|
1067
|
+
return self._scroll(inner)
|
|
1068
|
+
|
|
1069
|
+
def _start_probe(self) -> None:
|
|
1070
|
+
if self._probe and self._probe.isRunning():
|
|
1071
|
+
return
|
|
1072
|
+
if not self._launch_active:
|
|
1073
|
+
self._delivery_status.setText("probing channels…")
|
|
1074
|
+
self._probe = _ChannelProbe(self)
|
|
1075
|
+
self._probe.ready.connect(self._on_probe_ready)
|
|
1076
|
+
self._probe.start()
|
|
1077
|
+
|
|
1078
|
+
# ── launch mode: the board that moves on its own ──────────────────
|
|
1079
|
+
# While a release is in flight, re-probe on a backing-off cadence so
|
|
1080
|
+
# channels flip ▶→✓→LIVE without the maker touching anything. Every
|
|
1081
|
+
# green is still a real probe result — the timer never fakes state.
|
|
1082
|
+
_LAUNCH_START_MS = 5000
|
|
1083
|
+
_LAUNCH_MAX_MS = 20000
|
|
1084
|
+
_LAUNCH_STOP_STABLE = 3 # settle after N unchanged polls
|
|
1085
|
+
_LAUNCH_MAX_POLLS = 60 # hard backstop
|
|
1086
|
+
|
|
1087
|
+
@staticmethod
|
|
1088
|
+
def _state_sig(channels) -> tuple:
|
|
1089
|
+
return tuple((c["key"], c["live"], sum(1 for _, s in c["steps"] if s is True))
|
|
1090
|
+
for c in channels)
|
|
1091
|
+
|
|
1092
|
+
def _all_live(self, channels) -> bool:
|
|
1093
|
+
real = [c for c in channels if not c["stub"] and not c["alert"]]
|
|
1094
|
+
return bool(real) and all(c["live"] for c in real)
|
|
1095
|
+
|
|
1096
|
+
def _toggle_watch(self) -> None:
|
|
1097
|
+
if self._watch_btn.isChecked():
|
|
1098
|
+
self._start_launch(manual=True)
|
|
1099
|
+
else:
|
|
1100
|
+
self._stop_launch("stopped by maker")
|
|
1101
|
+
|
|
1102
|
+
def _start_launch(self, manual: bool = False) -> None:
|
|
1103
|
+
if self._launch_active:
|
|
1104
|
+
return
|
|
1105
|
+
self._launch_active = True
|
|
1106
|
+
self._launch_interval = self._LAUNCH_START_MS
|
|
1107
|
+
self._launch_polls = 0
|
|
1108
|
+
self._launch_stable = 0
|
|
1109
|
+
if manual:
|
|
1110
|
+
self._settled_sig = None # the maker overrides the settle latch
|
|
1111
|
+
self._watch_btn.setChecked(True)
|
|
1112
|
+
self._watch_btn.setText("Watching ●")
|
|
1113
|
+
# A manual click kicks a probe now; an AUTO arm from _on_probe_ready
|
|
1114
|
+
# rides the timer it's about to set (no redundant double-probe).
|
|
1115
|
+
if manual and not (self._probe and self._probe.isRunning()):
|
|
1116
|
+
self._start_probe()
|
|
1117
|
+
|
|
1118
|
+
def _stop_launch(self, why: str, sig=None) -> None:
|
|
1119
|
+
self._launch_active = False
|
|
1120
|
+
self._launch_timer.stop()
|
|
1121
|
+
self._settled_sig = sig # don't auto-re-arm for this same state
|
|
1122
|
+
self._watch_btn.setChecked(False)
|
|
1123
|
+
self._watch_btn.setText("Watch")
|
|
1124
|
+
|
|
1125
|
+
def _on_probe_ready(self, payload) -> None:
|
|
1126
|
+
if isinstance(payload, Exception):
|
|
1127
|
+
self._delivery_status.setText(f"probe failed: {payload}")
|
|
1128
|
+
self._stop_launch("probe error")
|
|
1129
|
+
return
|
|
1130
|
+
channels = payload["channels"]
|
|
1131
|
+
self._probe_tag = payload["tag"]
|
|
1132
|
+
sig = self._state_sig(channels)
|
|
1133
|
+
changed = sig != self._last_state_sig
|
|
1134
|
+
# stamp the wall-clock of any channel we WATCHED flip live this run
|
|
1135
|
+
from datetime import datetime
|
|
1136
|
+
|
|
1137
|
+
prev = {c["key"]: c["live"] for c in (self._channel_rows or [])}
|
|
1138
|
+
for c in channels:
|
|
1139
|
+
if c["live"] and not prev.get(c["key"]) and c["key"] not in self._live_since:
|
|
1140
|
+
self._live_since[c["key"]] = datetime.now().strftime("%H:%M")
|
|
1141
|
+
self._last_state_sig = sig
|
|
1142
|
+
self._channel_rows = channels
|
|
1143
|
+
self._render_channels(channels)
|
|
1144
|
+
|
|
1145
|
+
all_live = self._all_live(channels)
|
|
1146
|
+
# auto-arm launch mode: a tag exists, the release isn't fully live,
|
|
1147
|
+
# and we haven't already settled on this exact state (no re-arm loop)
|
|
1148
|
+
if (not self._launch_active and self._probe_tag and not all_live
|
|
1149
|
+
and sig != self._settled_sig):
|
|
1150
|
+
self._start_launch()
|
|
1151
|
+
|
|
1152
|
+
if self._launch_active:
|
|
1153
|
+
self._launch_polls += 1
|
|
1154
|
+
self._launch_stable = 0 if changed else self._launch_stable + 1
|
|
1155
|
+
if all_live:
|
|
1156
|
+
self._stop_launch("all channels live", sig)
|
|
1157
|
+
self._delivery_status.setText("✓ every channel is LIVE — shipped.")
|
|
1158
|
+
elif (self._launch_stable >= self._LAUNCH_STOP_STABLE
|
|
1159
|
+
or self._launch_polls >= self._LAUNCH_MAX_POLLS):
|
|
1160
|
+
self._stop_launch("settled", sig)
|
|
1161
|
+
self._delivery_status.setText(
|
|
1162
|
+
"state settled — press Watch to keep polling, or Refresh once.")
|
|
1163
|
+
else:
|
|
1164
|
+
if changed:
|
|
1165
|
+
self._launch_interval = self._LAUNCH_START_MS # reset on progress
|
|
1166
|
+
else:
|
|
1167
|
+
self._launch_interval = min(
|
|
1168
|
+
int(self._launch_interval * 1.5), self._LAUNCH_MAX_MS)
|
|
1169
|
+
self._launch_timer.start(self._launch_interval)
|
|
1170
|
+
secs = self._launch_interval // 1000
|
|
1171
|
+
self._delivery_status.setText(
|
|
1172
|
+
f"● watching for go-live — re-probing every {secs}s "
|
|
1173
|
+
f"(poll {self._launch_polls}). Every green is a real probe.")
|
|
1174
|
+
else:
|
|
1175
|
+
self._delivery_status.setText(
|
|
1176
|
+
"detected state — ✓ done · ▶ next · ? unknowable from here")
|
|
1177
|
+
|
|
1178
|
+
def _render_channels(self, channels) -> None:
|
|
1179
|
+
while self._channels_box.count():
|
|
1180
|
+
it = self._channels_box.takeAt(0)
|
|
1181
|
+
if it.widget():
|
|
1182
|
+
it.widget().deleteLater()
|
|
1183
|
+
for c in channels:
|
|
1184
|
+
if c["live"]:
|
|
1185
|
+
self._channels_box.addWidget(self._live_card(c))
|
|
1186
|
+
else:
|
|
1187
|
+
self._channels_box.addWidget(self._pending_card(c))
|
|
1188
|
+
|
|
1189
|
+
def _live_card(self, c) -> QWidget:
|
|
1190
|
+
"""The celebratory row: a channel the probe found fully LIVE, with
|
|
1191
|
+
the real public URL + the one-line install command. This card is
|
|
1192
|
+
the payoff — a green nobody can fake, because it's detected."""
|
|
1193
|
+
card = QFrame()
|
|
1194
|
+
card.setStyleSheet(
|
|
1195
|
+
".QFrame{background:rgba(86,196,141,0.10);border:1px solid "
|
|
1196
|
+
"rgba(86,196,141,0.45);border-radius:10px;}")
|
|
1197
|
+
cv = QVBoxLayout(card)
|
|
1198
|
+
cv.setContentsMargins(14, 11, 14, 12)
|
|
1199
|
+
cv.setSpacing(7)
|
|
1200
|
+
since = self._live_since.get(c["key"])
|
|
1201
|
+
head = QLabel(
|
|
1202
|
+
f'{c["title"]} '
|
|
1203
|
+
f'<span style="color:#56c48d;font-weight:700;">● LIVE</span>'
|
|
1204
|
+
+ (f' <span style="color:#6a8;font-size:11px;">went live {since}</span>'
|
|
1205
|
+
if since else ""))
|
|
1206
|
+
head.setTextFormat(Qt.TextFormat.RichText)
|
|
1207
|
+
head.setStyleSheet("color:#fff;font-weight:600;")
|
|
1208
|
+
cv.addWidget(head)
|
|
1209
|
+
if c["store_url"]:
|
|
1210
|
+
link = QLabel(
|
|
1211
|
+
f'<a href="{c["store_url"]}" style="color:{accent};'
|
|
1212
|
+
f'text-decoration:none;">{c["store_url"]} →</a>')
|
|
1213
|
+
link.setTextFormat(Qt.TextFormat.RichText)
|
|
1214
|
+
link.setOpenExternalLinks(True)
|
|
1215
|
+
link.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
|
1216
|
+
cv.addWidget(link)
|
|
1217
|
+
if c["install_cmd"]:
|
|
1218
|
+
cmd = QLineEdit(c["install_cmd"])
|
|
1219
|
+
cmd.setReadOnly(True)
|
|
1220
|
+
cmd.setCursorPosition(0)
|
|
1221
|
+
cmd.setToolTip("select-all + copy")
|
|
1222
|
+
cmd.setStyleSheet(
|
|
1223
|
+
"QLineEdit{background:rgba(0,0,0,0.30);border:1px solid "
|
|
1224
|
+
"rgba(255,255,255,0.10);border-radius:6px;padding:6px 10px;"
|
|
1225
|
+
"color:#dfe;font-family:monospace;font-size:13px;}")
|
|
1226
|
+
cv.addWidget(cmd)
|
|
1227
|
+
return card
|
|
1228
|
+
|
|
1229
|
+
def _pending_card(self, c) -> QWidget:
|
|
1230
|
+
card = QFrame()
|
|
1231
|
+
card.setStyleSheet(_CARD_QSS)
|
|
1232
|
+
cv = QVBoxLayout(card)
|
|
1233
|
+
cv.setContentsMargins(12, 8, 12, 8)
|
|
1234
|
+
cv.setSpacing(4)
|
|
1235
|
+
alert = c["alert"]
|
|
1236
|
+
head = QLabel(c["title"] + (f" — ⚠ {alert}" if alert else "")
|
|
1237
|
+
+ (" [stub]" if c["stub"] else ""))
|
|
1238
|
+
head.setStyleSheet(
|
|
1239
|
+
"color:#f88;font-weight:bold;" if alert else "color:#fff;font-weight:bold;")
|
|
1240
|
+
cv.addWidget(head)
|
|
1241
|
+
if c["note"]:
|
|
1242
|
+
n = QLabel(c["note"])
|
|
1243
|
+
n.setWordWrap(True)
|
|
1244
|
+
n.setStyleSheet("color:#888;font-size:11px;")
|
|
1245
|
+
cv.addWidget(n)
|
|
1246
|
+
marks = {True: ("✓", "#56c48d"), False: ("▶", accent), None: ("?", "#888")}
|
|
1247
|
+
for step_title, st in c["steps"]:
|
|
1248
|
+
mark, color = marks.get(st, ("?", "#888"))
|
|
1249
|
+
s = QLabel(f'<span style="color:{color};">{mark}</span> {step_title}')
|
|
1250
|
+
s.setTextFormat(Qt.TextFormat.RichText)
|
|
1251
|
+
s.setStyleSheet("color:#ccc;")
|
|
1252
|
+
cv.addWidget(s)
|
|
1253
|
+
if c["guide"]:
|
|
1254
|
+
g = QLabel(_linkify(c["guide"]))
|
|
1255
|
+
g.setTextFormat(Qt.TextFormat.RichText)
|
|
1256
|
+
g.setOpenExternalLinks(True)
|
|
1257
|
+
g.setWordWrap(True)
|
|
1258
|
+
g.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
|
1259
|
+
g.setStyleSheet(
|
|
1260
|
+
"color:#aaa;background:rgba(0,0,0,0.25);border-radius:6px;"
|
|
1261
|
+
"padding:6px;font-family:monospace;font-size:11px;")
|
|
1262
|
+
cv.addWidget(g)
|
|
1263
|
+
return card
|
|
1264
|
+
|
|
1265
|
+
# ── shared checklist rows (Ingredients tail + Improvements) ───────
|
|
1266
|
+
def _build_checklist_page(self, phase: str) -> QWidget:
|
|
1267
|
+
inner = QWidget()
|
|
1268
|
+
vbox = QVBoxLayout(inner)
|
|
1269
|
+
vbox.setContentsMargins(2, 4, 2, 4)
|
|
1270
|
+
vbox.setSpacing(6)
|
|
1271
|
+
for item in self._board.get(phase, []):
|
|
1272
|
+
vbox.addLayout(self._build_check_row(item))
|
|
1273
|
+
vbox.addWidget(self._build_adder(phase))
|
|
1274
|
+
vbox.addStretch(1)
|
|
1275
|
+
return self._scroll(inner)
|
|
1276
|
+
|
|
1277
|
+
def _build_adder(self, phase: str) -> QLineEdit:
|
|
1278
|
+
adder = QLineEdit()
|
|
1279
|
+
adder.setPlaceholderText(f"add a {_PHASE_TITLES[phase].lower()} item…")
|
|
1280
|
+
adder.setStyleSheet(f"QLineEdit{{{_EDIT_QSS}}}")
|
|
1281
|
+
adder.returnPressed.connect(
|
|
1282
|
+
lambda ph=phase, e=adder: self._add_item(ph, e.text()))
|
|
1283
|
+
return adder
|
|
1284
|
+
|
|
1285
|
+
def _build_check_row(self, item: dict) -> QHBoxLayout:
|
|
1286
|
+
row = QHBoxLayout()
|
|
1287
|
+
row.setSpacing(8)
|
|
1288
|
+
box = QCheckBox(item.get("text", ""))
|
|
1289
|
+
box.setChecked(bool(item.get("done")))
|
|
1290
|
+
box.setStyleSheet(
|
|
1291
|
+
type_qss(TYPE_BODY)
|
|
1292
|
+
+ f"QCheckBox{{color:#ddd;}}QCheckBox::indicator:checked{{background:{accent};"
|
|
1293
|
+
f"border:1px solid {accent};border-radius:3px;}}"
|
|
1294
|
+
"QCheckBox::indicator{width:14px;height:14px;border:1px solid "
|
|
1295
|
+
"rgba(255,255,255,0.35);border-radius:3px;}")
|
|
1296
|
+
box.toggled.connect(lambda on, it=item: self._set_done(it, on))
|
|
1297
|
+
row.addWidget(box, 1)
|
|
1298
|
+
stamp = QLabel(f"{item.get('by', '')} {item.get('date', '')}".strip())
|
|
1299
|
+
stamp.setStyleSheet("color:#777;font-size:11px;")
|
|
1300
|
+
row.addWidget(stamp)
|
|
1301
|
+
note = QLineEdit(item.get("note", ""))
|
|
1302
|
+
note.setPlaceholderText("note to the agent…")
|
|
1303
|
+
note.setFixedWidth(220)
|
|
1304
|
+
note.setStyleSheet(
|
|
1305
|
+
"QLineEdit{background:rgba(255,255,255,0.05);border:1px solid "
|
|
1306
|
+
"rgba(255,255,255,0.10);border-radius:6px;padding:3px 8px;color:#cbb8ff;}")
|
|
1307
|
+
note.editingFinished.connect(lambda it=item, e=note: self._set_note(it, e.text()))
|
|
1308
|
+
row.addWidget(note)
|
|
1309
|
+
return row
|
|
1310
|
+
|
|
1311
|
+
# ── edits (every one writes the file) ─────────────────────────────
|
|
1312
|
+
def _stamp(self, item: dict) -> None:
|
|
1313
|
+
item["by"] = "maker"
|
|
1314
|
+
item["date"] = date.today().isoformat()
|
|
1315
|
+
|
|
1316
|
+
def _set_done(self, item: dict, on: bool) -> None:
|
|
1317
|
+
item["done"] = on
|
|
1318
|
+
self._stamp(item)
|
|
1319
|
+
self._write()
|
|
1320
|
+
|
|
1321
|
+
def _set_note(self, item: dict, text: str) -> None:
|
|
1322
|
+
if item.get("note", "") == text:
|
|
1323
|
+
return
|
|
1324
|
+
item["note"] = text
|
|
1325
|
+
self._stamp(item)
|
|
1326
|
+
self._write()
|
|
1327
|
+
|
|
1328
|
+
def _add_item(self, phase: str, text: str, priority: str | None = None) -> None:
|
|
1329
|
+
text = text.strip()
|
|
1330
|
+
if not text:
|
|
1331
|
+
return
|
|
1332
|
+
item = {"text": text, "done": False, "by": "maker",
|
|
1333
|
+
"date": date.today().isoformat(), "note": ""}
|
|
1334
|
+
if phase == "baking":
|
|
1335
|
+
item["priority"] = priority if priority in PRIORITIES else "next"
|
|
1336
|
+
self._board.setdefault(phase, []).append(item)
|
|
1337
|
+
self._write()
|
|
1338
|
+
self._rebuild_pages()
|
|
1339
|
+
self._show_phase(phase)
|
|
1340
|
+
|
|
1341
|
+
def _write(self) -> None:
|
|
1342
|
+
self._writing = True
|
|
1343
|
+
save(self._path, self._board)
|
|
1344
|
+
self._rewatch.start(200) # editors/agents may replace the inode
|
|
1345
|
+
|
|
1346
|
+
# ── live reload on outside edits ──────────────────────────────────
|
|
1347
|
+
def _ensure_watched(self) -> None:
|
|
1348
|
+
self._writing = False
|
|
1349
|
+
if str(self._path) not in self._watcher.files():
|
|
1350
|
+
self._watcher.addPath(str(self._path))
|
|
1351
|
+
|
|
1352
|
+
def _on_file_changed(self, *_):
|
|
1353
|
+
self._rewatch.start(200)
|
|
1354
|
+
if self._writing:
|
|
1355
|
+
return # our own write echoing back
|
|
1356
|
+
try:
|
|
1357
|
+
self._board = load(self._path)
|
|
1358
|
+
except Exception:
|
|
1359
|
+
return # mid-edit / malformed — keep showing the last good state
|
|
1360
|
+
self._goal.setText(self._board.get("goal", ""))
|
|
1361
|
+
if hasattr(self, "_purpose"):
|
|
1362
|
+
if self._purpose.toPlainText() != self._board.get("purpose", ""):
|
|
1363
|
+
self._purpose.blockSignals(True)
|
|
1364
|
+
self._purpose.setPlainText(self._board.get("purpose", ""))
|
|
1365
|
+
self._purpose.blockSignals(False)
|
|
1366
|
+
self._rebuild_pages()
|
|
1367
|
+
|
|
1368
|
+
return BoardView()
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
# ── the headless CLI (agent-facing: edit the board without hand-writing TOML) ──
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def _find_item(board: dict, iid: str) -> "tuple[str | None, dict | None]":
|
|
1375
|
+
"""(phase, item) for the item whose id is ``iid``, or (None, None)."""
|
|
1376
|
+
for phase in PHASES:
|
|
1377
|
+
for item in board.get(phase, []):
|
|
1378
|
+
if item.get("id") == iid:
|
|
1379
|
+
return phase, item
|
|
1380
|
+
return None, None
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def _print_board(board: dict, only: str | None = None) -> None:
|
|
1384
|
+
"""A readable dump — the ids + state every other subcommand takes as input."""
|
|
1385
|
+
req = board.get("agent_request", "")
|
|
1386
|
+
if req and not only:
|
|
1387
|
+
print(f"! agent_request: {req}")
|
|
1388
|
+
for phase in ([only] if only else PHASES):
|
|
1389
|
+
items = board.get(phase, [])
|
|
1390
|
+
openn = sum(1 for i in items if not i.get("done"))
|
|
1391
|
+
print(f"# {phase} ({openn} open / {len(items)})")
|
|
1392
|
+
for it in items:
|
|
1393
|
+
box = "x" if it.get("done") else " "
|
|
1394
|
+
prio = (f" [{it.get('priority')}]"
|
|
1395
|
+
if phase == "baking" and it.get("priority") else "")
|
|
1396
|
+
note = f" — {it['note']}" if it.get("note") else ""
|
|
1397
|
+
print(f" [{box}] {it.get('id', '??????')} {it.get('text', '')}{prio}{note}")
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
def _apply_cmd(args, path: Path) -> int:
|
|
1401
|
+
"""Run one headless subcommand: load → mutate → byte-stable save. The open
|
|
1402
|
+
window's file-watch live-reloads each change, so the maker sees it land."""
|
|
1403
|
+
if not path.is_file():
|
|
1404
|
+
print(f"no board at {path} — seed one with `{_PKG}-breadboard --init`",
|
|
1405
|
+
file=sys.stderr)
|
|
1406
|
+
return 1
|
|
1407
|
+
board = load(path)
|
|
1408
|
+
|
|
1409
|
+
if args.cmd == "list":
|
|
1410
|
+
_print_board(board, args.phase)
|
|
1411
|
+
return 0
|
|
1412
|
+
|
|
1413
|
+
if args.cmd == "add":
|
|
1414
|
+
item = {
|
|
1415
|
+
"text": args.text, "done": bool(args.done),
|
|
1416
|
+
"by": args.by, "date": date.today().isoformat(), "note": args.note or "",
|
|
1417
|
+
}
|
|
1418
|
+
if args.phase == "baking":
|
|
1419
|
+
item["priority"] = args.priority or "next"
|
|
1420
|
+
board.setdefault(args.phase, []).append(item)
|
|
1421
|
+
save(path, board) # mints the stable id in place
|
|
1422
|
+
print(item["id"]) # the handle for a later check/note/rm
|
|
1423
|
+
return 0
|
|
1424
|
+
|
|
1425
|
+
if args.cmd == "request":
|
|
1426
|
+
board["agent_request"] = "" if args.clear else args.text
|
|
1427
|
+
save(path, board)
|
|
1428
|
+
print("agent_request cleared" if args.clear or not args.text
|
|
1429
|
+
else f"agent_request set: {board['agent_request']}")
|
|
1430
|
+
return 0
|
|
1431
|
+
|
|
1432
|
+
# everything below addresses one existing item by its id
|
|
1433
|
+
phase, item = _find_item(board, args.id)
|
|
1434
|
+
if item is None:
|
|
1435
|
+
print(f"no breadboard item with id {args.id!r} "
|
|
1436
|
+
f"(run `{_PKG}-breadboard list` to see ids)", file=sys.stderr)
|
|
1437
|
+
return 1
|
|
1438
|
+
if args.cmd == "check":
|
|
1439
|
+
item["done"] = not args.off
|
|
1440
|
+
item["by"] = args.by
|
|
1441
|
+
item["date"] = date.today().isoformat()
|
|
1442
|
+
if args.note is not None:
|
|
1443
|
+
item["note"] = args.note
|
|
1444
|
+
msg = "reopened" if args.off else "done"
|
|
1445
|
+
elif args.cmd == "note":
|
|
1446
|
+
item["note"] = args.text
|
|
1447
|
+
msg = "note set"
|
|
1448
|
+
elif args.cmd == "priority":
|
|
1449
|
+
if phase != "baking":
|
|
1450
|
+
print(f"priority applies to baking items; {args.id} is in {phase}",
|
|
1451
|
+
file=sys.stderr)
|
|
1452
|
+
return 1
|
|
1453
|
+
item["priority"] = args.priority
|
|
1454
|
+
msg = f"priority → {args.priority}"
|
|
1455
|
+
elif args.cmd == "rm":
|
|
1456
|
+
board[phase].remove(item)
|
|
1457
|
+
msg = "removed"
|
|
1458
|
+
save(path, board)
|
|
1459
|
+
print(f"{args.id}: {msg}")
|
|
1460
|
+
return 0
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
1464
|
+
parser = argparse.ArgumentParser(
|
|
1465
|
+
prog=f"{_PKG}-breadboard",
|
|
1466
|
+
description="The live maker board: goals + phase checklists, shared with the AI agent.",
|
|
1467
|
+
)
|
|
1468
|
+
parser.add_argument("--init", action="store_true",
|
|
1469
|
+
help=f"seed a fresh {FILENAME} (refuses to overwrite)")
|
|
1470
|
+
sub = parser.add_subparsers(
|
|
1471
|
+
dest="cmd", metavar="<command>",
|
|
1472
|
+
help="edit the board headlessly (no window); omit to open the window")
|
|
1473
|
+
|
|
1474
|
+
p = sub.add_parser("list", help="print items with their ids + state")
|
|
1475
|
+
p.add_argument("phase", nargs="?", choices=PHASES, help="only this phase")
|
|
1476
|
+
|
|
1477
|
+
p = sub.add_parser("add", help="add an item to a phase; prints its new id")
|
|
1478
|
+
p.add_argument("phase", choices=PHASES)
|
|
1479
|
+
p.add_argument("text")
|
|
1480
|
+
p.add_argument("--priority", choices=PRIORITIES, help="baking column (default next)")
|
|
1481
|
+
p.add_argument("--by", default="agent", help="who added it (default agent)")
|
|
1482
|
+
p.add_argument("--note", default="", help="a note on the item")
|
|
1483
|
+
p.add_argument("--done", action="store_true", help="add it already checked")
|
|
1484
|
+
|
|
1485
|
+
p = sub.add_parser("check", help="mark an item done (--off to reopen)")
|
|
1486
|
+
p.add_argument("id")
|
|
1487
|
+
p.add_argument("--off", action="store_true", help="uncheck instead of check")
|
|
1488
|
+
p.add_argument("--by", default="agent")
|
|
1489
|
+
p.add_argument("--note", default=None, help="also set the item's note")
|
|
1490
|
+
|
|
1491
|
+
p = sub.add_parser("note", help="set an item's note")
|
|
1492
|
+
p.add_argument("id")
|
|
1493
|
+
p.add_argument("text")
|
|
1494
|
+
|
|
1495
|
+
p = sub.add_parser("priority", help="move a baking item between kanban columns")
|
|
1496
|
+
p.add_argument("id")
|
|
1497
|
+
p.add_argument("priority", choices=PRIORITIES)
|
|
1498
|
+
|
|
1499
|
+
p = sub.add_parser("rm", help="remove an item")
|
|
1500
|
+
p.add_argument("id")
|
|
1501
|
+
|
|
1502
|
+
p = sub.add_parser("request", help="set / clear the top-level agent_request")
|
|
1503
|
+
p.add_argument("text", nargs="?", default="")
|
|
1504
|
+
p.add_argument("--clear", action="store_true", help="clear it (once you've fulfilled it)")
|
|
1505
|
+
return parser
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1509
|
+
args = _build_parser().parse_args(argv)
|
|
1510
|
+
|
|
1511
|
+
path = board_path()
|
|
1512
|
+
if args.init:
|
|
1513
|
+
if path.exists():
|
|
1514
|
+
print(f"{path} already exists — not overwriting.", file=sys.stderr)
|
|
1515
|
+
return 1
|
|
1516
|
+
save(path, default_board(_PKG))
|
|
1517
|
+
print(f"seeded {path}. Open it with `{_PKG}-breadboard`.")
|
|
1518
|
+
return 0
|
|
1519
|
+
if getattr(args, "cmd", None): # a headless subcommand → no window
|
|
1520
|
+
return _apply_cmd(args, path)
|
|
1521
|
+
if not path.is_file():
|
|
1522
|
+
save(path, default_board(_PKG))
|
|
1523
|
+
print(f"(no board yet — seeded {path})")
|
|
1524
|
+
|
|
1525
|
+
# a self-reload relaunch hands us its place back through the environment;
|
|
1526
|
+
# pop it so it never leaks into the agent subprocess or a later reload
|
|
1527
|
+
restore = _read_restore_env()
|
|
1528
|
+
if restore and restore.get("project"):
|
|
1529
|
+
rp = Path(restore["project"])
|
|
1530
|
+
if rp.is_file():
|
|
1531
|
+
path = rp # re-open the SAME project the maker was on
|
|
1532
|
+
|
|
1533
|
+
from trackerkeeper.app import run_app
|
|
1534
|
+
|
|
1535
|
+
return run_app(lambda window: _make_view(path, restore), single_instance=False)
|
|
1536
|
+
|
|
1537
|
+
|
|
1538
|
+
def _read_restore_env() -> dict | None:
|
|
1539
|
+
"""Decode + consume the self-reload restore token (see BoardView._request_
|
|
1540
|
+
reload). Popped from the environment so it can't leak downstream."""
|
|
1541
|
+
import json
|
|
1542
|
+
|
|
1543
|
+
raw = os.environ.pop("TRACKERKEEPER_BREADBOARD_RESTORE", None)
|
|
1544
|
+
if not raw:
|
|
1545
|
+
return None
|
|
1546
|
+
try:
|
|
1547
|
+
data = json.loads(raw)
|
|
1548
|
+
return data if isinstance(data, dict) else None
|
|
1549
|
+
except (ValueError, TypeError):
|
|
1550
|
+
return None
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
if __name__ == "__main__": # pragma: no cover
|
|
1554
|
+
raise SystemExit(main())
|