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.
Files changed (89) hide show
  1. trackerkeeper/__init__.py +55 -0
  2. trackerkeeper/__main__.py +3 -0
  3. trackerkeeper/_version.py +24 -0
  4. trackerkeeper/app.py +445 -0
  5. trackerkeeper/assets/__init__.py +2 -0
  6. trackerkeeper/assets/trackerkeeper.svg +12 -0
  7. trackerkeeper/async_io.py +299 -0
  8. trackerkeeper/autostart/__init__.py +52 -0
  9. trackerkeeper/autostart/_linux.py +129 -0
  10. trackerkeeper/autostart/_macos.py +163 -0
  11. trackerkeeper/autostart/_msix.py +181 -0
  12. trackerkeeper/autostart/_unsupported.py +22 -0
  13. trackerkeeper/autostart/_windows.py +96 -0
  14. trackerkeeper/bake.py +217 -0
  15. trackerkeeper/blur/__init__.py +212 -0
  16. trackerkeeper/blur/_dwm.py +348 -0
  17. trackerkeeper/blur/_faux_frost.py +143 -0
  18. trackerkeeper/blur/_kwin.py +478 -0
  19. trackerkeeper/blur/_macos.py +356 -0
  20. trackerkeeper/blur/_unsupported.py +39 -0
  21. trackerkeeper/boot_timing.py +75 -0
  22. trackerkeeper/breadboard.py +1554 -0
  23. trackerkeeper/bus.py +110 -0
  24. trackerkeeper/catalog.py +176 -0
  25. trackerkeeper/color_tokens.py +697 -0
  26. trackerkeeper/credentials.py +471 -0
  27. trackerkeeper/custom_tooltip.py +254 -0
  28. trackerkeeper/dashboard.py +704 -0
  29. trackerkeeper/deliver.py +442 -0
  30. trackerkeeper/design_tokens.py +415 -0
  31. trackerkeeper/diagnostics.py +168 -0
  32. trackerkeeper/drag_repaint/__init__.py +68 -0
  33. trackerkeeper/drag_repaint/_kwin.py +192 -0
  34. trackerkeeper/drag_repaint/_unsupported.py +41 -0
  35. trackerkeeper/drag_repaint/effect/dragrepaint/contents/code/main.js +86 -0
  36. trackerkeeper/drag_repaint/effect/dragrepaint/metadata.json +17 -0
  37. trackerkeeper/frosted_dialog.py +377 -0
  38. trackerkeeper/i18n/__init__.py +101 -0
  39. trackerkeeper/i18n/fmt.py +137 -0
  40. trackerkeeper/icon_button.py +137 -0
  41. trackerkeeper/icons.py +532 -0
  42. trackerkeeper/identity.py +135 -0
  43. trackerkeeper/item_dialog.py +185 -0
  44. trackerkeeper/kde_titlebar.py +196 -0
  45. trackerkeeper/keyboard_focus.py +353 -0
  46. trackerkeeper/log.py +112 -0
  47. trackerkeeper/macos_menubar.py +282 -0
  48. trackerkeeper/macos_window.py +172 -0
  49. trackerkeeper/metadata.py +121 -0
  50. trackerkeeper/noborder/__init__.py +61 -0
  51. trackerkeeper/noborder/_kwin.py +247 -0
  52. trackerkeeper/noborder/_unsupported.py +42 -0
  53. trackerkeeper/notifications/__init__.py +84 -0
  54. trackerkeeper/notifications/_linux.py +68 -0
  55. trackerkeeper/notifications/_macos.py +159 -0
  56. trackerkeeper/notifications/_unsupported.py +22 -0
  57. trackerkeeper/notifications/_windows.py +115 -0
  58. trackerkeeper/platform_compat.py +149 -0
  59. trackerkeeper/power/__init__.py +84 -0
  60. trackerkeeper/power/_linux.py +79 -0
  61. trackerkeeper/power/_unsupported.py +18 -0
  62. trackerkeeper/power/_windows.py +52 -0
  63. trackerkeeper/rig.py +338 -0
  64. trackerkeeper/scaffold.py +310 -0
  65. trackerkeeper/selector.py +529 -0
  66. trackerkeeper/settings.py +226 -0
  67. trackerkeeper/settings_dialog.py +307 -0
  68. trackerkeeper/settings_migration.py +101 -0
  69. trackerkeeper/single_instance.py +330 -0
  70. trackerkeeper/smooth_scroll.py +155 -0
  71. trackerkeeper/sources.py +318 -0
  72. trackerkeeper/system_accent.py +363 -0
  73. trackerkeeper/terminal.py +423 -0
  74. trackerkeeper/test_bridge.py +514 -0
  75. trackerkeeper/theme.py +725 -0
  76. trackerkeeper/top_bar.py +154 -0
  77. trackerkeeper/tray.py +184 -0
  78. trackerkeeper/ui_helpers.py +2294 -0
  79. trackerkeeper/update_chip.py +119 -0
  80. trackerkeeper/updates.py +216 -0
  81. trackerkeeper/win_frameless.py +364 -0
  82. trackerkeeper/window.py +609 -0
  83. trackerkeeper/windows_shortcut.py +355 -0
  84. trackerkeeper-0.1.0.dist-info/METADATA +144 -0
  85. trackerkeeper-0.1.0.dist-info/RECORD +89 -0
  86. trackerkeeper-0.1.0.dist-info/WHEEL +5 -0
  87. trackerkeeper-0.1.0.dist-info/entry_points.txt +8 -0
  88. trackerkeeper-0.1.0.dist-info/licenses/LICENSE +338 -0
  89. 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())