← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.97+1.9.98
adom-bridge-fusion-manifest.json+4−4
@@ -1,10 +1,10 @@ {     "manifest_version": 1,     "name": "fusion360",-    "version": "1.9.97",-    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.112/adom-bridge-fusion-v1.9.97.zip",-    "sha256": "872e8f2f98c868821fe2775ca1d64cb3ce283b2fedc0a78e26da9d6fa5f7aece",-    "size": 433006,+    "version": "1.9.98",+    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.113/adom-bridge-fusion-v1.9.98.zip",+    "sha256": "afb12d123b86fa623446ae5013a5417e58da4cf2dae140924c7c148030c6d1cc",+    "size": 436531,     "verbPrefixes": [         "fusion_"     ],
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.97",+  "version": "1.9.98",   "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.",   "homepage": "https://wiki.adom.inc/adom/fusion-bridge",   "author": "Adom Inc.",
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.112"+version = "1.9.113" edition = "2021"  [dependencies]
dashboard/src/main.rs+5−2
@@ -91,7 +91,8 @@ fn gather_status_ex(ai_thread: &str, target: &str, launching: bool) -> serde_jso     // 36.7s against a remote box (measured live 2026-08-18) - the Dashboard sat blank that whole     // time. Fan them out and the wall clock is the slowest single call.     let verbs = ["fusion_readiness", "fusion_aps_status", "fusion_mcp_status",-                 "fusion_set_auto_update", "fusion_set_seat_takeover", "fusion_set_driving_badge"];+                 "fusion_set_auto_update", "fusion_set_seat_takeover", "fusion_set_driving_badge",+                 "fusion_set_activity_indicator"];     let handles: Vec<_> = verbs.iter().map(|v| {         let (ai, t, verb) = (ai_thread.to_string(), target.to_string(), v.to_string());         std::thread::spawn(move || unwrap_data(&bridge(&ai, &t, &verb, "{}")))@@ -102,6 +103,7 @@ fn gather_status_ex(ai_thread: &str, target: &str, launching: bool) -> serde_jso     }     let (rd, aps, mcp) = (res[0].clone(), res[1].clone(), res[2].clone());     let (auto_upd, seat, badge) = (res[3].clone(), res[4].clone(), res[5].clone());+    let activity = res.get(6).cloned().unwrap_or(serde_json::json!({}));      let running = rd["fusionRunning"].as_bool().or(rd["running"].as_bool()).unwrap_or(false);     let ready = rd["ready"].as_bool().unwrap_or(false);@@ -173,7 +175,8 @@ fn gather_status_ex(ai_thread: &str, target: &str, launching: bool) -> serde_jso             "autoUpdateFusion": auto_upd["autoUpdateFusion"].as_bool().unwrap_or(true),             "autoTakeOverSeat": seat["autoTakeOverSeat"].as_bool().unwrap_or(true),             "autoGreenOnLaunch": read_settings()["autoGreenOnLaunch"].as_bool().unwrap_or(true),-            "showDrivingBadge": badge["showDrivingBadge"].as_bool().unwrap_or(true)+            "showDrivingBadge": badge["showDrivingBadge"].as_bool().unwrap_or(true),+            "activityIndicator": activity["activityIndicator"].as_str().unwrap_or("progress")         },         "demoPart": read_settings()["demoPart"],         "_hint": "This is the Dashboard status payload. Every LED carries its own _hint naming the verb that fixes it. POST /api/action {action, args} to act; GET /api/status to re-read; subscribe to /events (SSE) for live pushes; POST /ui/toast {message,kind} to narrate on top of the app."
dashboard/ui/index.html+36
@@ -144,6 +144,14 @@ dialog.settings{max-width:560px;overflow:hidden;padding:0} .xbtn:hover{background:var(--elevated);border-color:var(--border);color:var(--text)} .xbtn:active{transform:scale(.9);background:var(--surface)} .xbtn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}+.segmented{display:flex;gap:6px;flex-wrap:wrap}+.seg{background:var(--panel);border:1px solid var(--line);color:var(--text-2);border-radius:8px;+  padding:7px 12px;font-size:12px;cursor:pointer;min-height:34px;transition:background .15s,border-color .15s,color .15s}+.seg:hover{border-color:var(--accent);color:var(--text)}+.seg:active{transform:translateY(1px)}+.seg:focus-visible{outline:2px solid var(--accent);outline-offset:2px}+.seg.on{background:color-mix(in srgb, var(--accent) 22%, transparent);border-color:var(--accent);color:var(--text)}+@media (max-width:600px){.seg{min-height:44px;padding:10px 14px}} .settings-card{background:var(--elevated);border:1px solid var(--border);border-left:3px solid color-mix(in srgb, var(--accent) 45%, transparent);border-radius:10px;padding:.9rem 1.1rem 1rem;margin-bottom:1rem} .card-title{font-size:13px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:10px;font-family:'Familjen Grotesk',sans-serif} .card-status{font-size:11px;color:var(--text-2);font-weight:400}@@ -245,6 +253,19 @@ dialog.settings{max-width:560px;overflow:hidden;padding:0}       <div class="toggle" id="tglBadge" role="switch" aria-checked="true"></div></div>   </div> +  <div class="settings-card">+    <div class="card-title">Activity indicator <span class="card-status" id="stActivity"></span></div>+    <div class="card-desc">How Fusion's taskbar button signals that Adom is running a verb. <b>Progress bar</b> paints a real bar paced by how long <i>this machine</i> has actually taken for that verb, and clears the moment it finishes or fails - so you can tell "nearly done" from "just started" at a glance. <b>Flash</b> is the older attention flash: it says something is happening, and nothing more. A verb with no measured history yet runs the bar indeterminate rather than inventing a percentage. Run times are recorded per verb to <code>verb-times.jsonl</code>; <code>fusion_verb_times</code> reports p50/p90 per verb.</div>+    <div class="divider"></div>+    <div class="ctlrow"><span class="lbl">Indicator</span>+      <div class="segmented" id="segActivity" role="radiogroup" aria-label="Activity indicator">+        <button class="seg" data-mode="progress" role="radio">Progress bar</button>+        <button class="seg" data-mode="flash" role="radio">Flash</button>+        <button class="seg" data-mode="both" role="radio">Both</button>+        <button class="seg" data-mode="none" role="radio">None</button>+      </div></div>+  </div>+   <div class="settings-card">     <div class="card-title">Surfaces demo <span class="card-status" id="stPart"></span></div>     <div class="card-desc">The Surfaces section walks one real adom basic part through every content type. It defaults to a DIFFERENT part than the Adom Bridge web-control demo uses, so clicking through proves the flow generalizes to a new component rather than re-proving one file. Change the wiki page below to demo any basic part that publishes a <code>.lbr</code> (and ideally a <code>.step</code> for the real 3D body).</div>@@ -381,6 +402,21 @@ $('#btnSettings').onclick=async()=>{   set('#tglBadge',bd);   $('#stBadge').textContent=bd?'badge on while driving':'badge off';   $('#stBadge').className='card-status'+(bd?' ok':'');+  const am=(s.activityIndicator||'progress');+  document.querySelectorAll('#segActivity .seg').forEach(b=>{+    b.classList.toggle('on', b.getAttribute('data-mode')===am);+    b.setAttribute('aria-checked', b.getAttribute('data-mode')===am ? 'true':'false');+    b.onclick=async()=>{+      const mode=b.getAttribute('data-mode');+      document.querySelectorAll('#segActivity .seg').forEach(x=>{+        x.classList.toggle('on', x===b); x.setAttribute('aria-checked', x===b?'true':'false');});+      $('#stActivity').textContent=mode==='none'?'no taskbar signal':(mode+' while a verb runs');+      try{ await act('set_activity_indicator',{mode}); toast('activity indicator: '+mode); }+      catch(e){ toast('could not set indicator: '+e,'err'); }+    };+  });+  $('#stActivity').textContent=am==='none'?'no taskbar signal':(am+' while a verb runs');+  $('#stActivity').className='card-status'+(am!=='none'?' ok':'');   $('#inpPart').value=dashSettings.demoPart||'aravk/in-s63btg';   $('#stPart').textContent=(dashSettings.demoPart||'').split('/').pop().toUpperCase();   const dlg=$('#dlgSettings');
dev-skills/fusion-verb-timing/SKILL.mdadded+82
@@ -0,0 +1,82 @@+---+name: fusion-verb-timing+description: DEVELOPER-only. USE WHEN adding or changing any slow Fusion verb, WHEN a caller reports something is hanging or "not responding", or WHEN you need to know how long a verb really takes on a given machine. Covers the two timing stores (rolling window for live estimates, append-only log for later analysis), the taskbar activity indicator paced by those measurements, and the rule that a guessed number must never be presented as a measured one. Trigger words - verb timing, how long does it take, p50, p90, verb-times.jsonl, activity indicator, taskbar progress, progress bar, slow verb, estimate, measured history.+user-invocable: false+---++# Measuring verb run times, and showing them honestly++Two stores, because they answer different questions. Keep both.++| store | file | answers |+|---|---|---|+| rolling window (last 10 per key) | `~/.adom/fusion-bridge/timings.json` | "how long will THIS verb take, on THIS machine, right now?" |+| append-only log (every run) | `~/.adom/fusion-bridge/verb-times.jsonl` | "what changed?", "which verb owns the 546s?", "did 1.9.9x regress the 3D package?" |++A rolling window can only answer what it still remembers. A log can be re-analysed for a+question nobody thought to ask up front, which is most of the interesting ones.++## Recording++`dispatch_command` times EVERY verb and calls `timings.log_run(verb, seconds, ok)`, which+writes both stores. Nothing to remember when adding a verb: it is measured automatically.++Record failures too. A verb that fails FAST is a different bug from one that is slow, and you+cannot tell them apart from durations alone. That is why the log carries `ok`.++Reading it back:++```python+timings.verb_estimate("show_symbol")   # (seconds, "measured" | "typical" | "unknown")+timings.verb_report()                  # per verb: count / p50 / p90 / max / failures+```++`fusion_verb_times` exposes the report as a verb, so the numbers are readable from the+Dashboard instead of requiring someone to SSH in and parse a file.++**p90 is the column to look at when hunting a slow surface.** A p50 of 15s next to a p90 of+400s is not "sometimes slow", it is two different code paths wearing one name, usually a cold+first-open versus a warm re-open.++## Showing it: the activity indicator++`fusion_set_activity_indicator {mode}` - `progress` | `flash` | `both` | `none`, sticky per+user, and a card in the Dashboard's Settings.++- **progress** paints a real taskbar bar via `desktop_taskbar {progress:{state,value}}`,+  paced by `verb_estimate` for that specific verb, and clears the instant the verb returns.+  On failure it flashes `error` (red) for a beat first, so an outcome is visible even if+  nobody was watching the moment it happened.+- **flash** is the older attention flash. It says "something is happening" and nothing else.+- The bar runs 0 to 95 and **holds**. It never reaches 100 while work continues, because a+  bar sitting at 100 during live work is the same lie as an ETA of zero.++## The honesty rule, which is the whole point++**A verb with no measured history runs the bar INDETERMINATE (marquee), never a percentage.**++We have made the opposite mistake twice, and both times it cost a real debugging session:++- an estimate revised as `elapsed * 1.25` pinned percent at exactly 80 forever, so a stuck run+  looked identical to a healthy one;+- a "couple of minutes" string that was never measured, never narrowed, and could not be drawn+  as a bar at all.++The confidence ladder is `measured` (3 or more real samples here) > `typical` (1-2 samples, or+a seeded literal) > `unknown`. Render `unknown` as motion without a number. ab dims a+non-`measured` block in its own window for the same reason: a bridge that guesses should never+look as authoritative as one that measured.++## When a caller says "it is hanging"++Check the measurements before believing it:++1. `fusion_verb_times` - is this verb normally slow? A 546s p90 means the caller is impatient,+   not that the bridge is broken.+2. `fusion_state.timings` - what has THIS machine actually done?+3. `errorCode` - `fusion_addin_busy` carries `elapsedSeconds`; compare it against p90 before+   concluding anything is wrong. Past roughly 2x p90 it is fair to call it stuck.++That comparison is the difference between "slow" and "stuck", and it is a measurement rather+than a judgement call. See `fusion-dialogs` for the neighbouring trap: a modal holding the main+thread looks exactly like a slow verb from outside.
handlers/taskbar_overlay.py+69
@@ -140,3 +140,72 @@ def clear_driving_badge(hwnd=None) -> bool:         if ok and (hwnd is None or _BADGED_HWND == int(hwnd)):             _BADGED_HWND = None         return ok+++# ── Activity indicator: taskbar PROGRESS driven by measured verb durations ──────────────+# John's experiment (2026-08-21): instead of flashing the Fusion taskbar button to say+# "Adom is doing something", draw a real PROGRESS BAR for the duration the verb is actually+# expected to take, and clear it the moment the verb finishes or errors.+#+# The estimate is not invented: timings.py keeps a rolling measured history per verb on THIS+# machine, so the bar reflects what this box actually does. Before a verb has any history the+# bar runs INDETERMINATE (a marquee) rather than pretending to a percentage - the same+# honesty rule as the progress contract: never dress a guess as a measurement.++_PROGRESS_STOP = {}          # verb -> threading.Event, so a finished verb can stop its ticker+++def _taskbar_progress(state: str, value=None) -> bool:+    """Paint (or clear) the progress bar on Fusion's own taskbar button."""+    try:+        from handlers.fusion_ui import _find_fusion_hwnd+        hwnd = _find_fusion_hwnd()+        if not hwnd:+            return False+        prog = {"state": state}+        if value is not None:+            prog["value"] = max(0, min(100, int(value)))+        return _taskbar({"hwnd": int(hwnd), "progress": prog})+    except Exception:+        return False+++def start_activity_progress(verb: str, estimate_s: float, confidence: str = "typical"):+    """Begin a progress bar for `verb`, paced by its measured estimate. Returns a stopper."""+    import threading+    import time as _t+    stop = threading.Event()+    _PROGRESS_STOP[verb] = stop++    def _tick():+        started = _t.time()+        # No history yet -> marquee. A percentage we cannot justify is worse than no number.+        if not estimate_s or confidence == "unknown":+            _taskbar_progress("indeterminate")+            while not stop.wait(1.0):+                pass+            return+        while not stop.wait(0.7):+            elapsed = _t.time() - started+            # 0 -> 95 across the estimate, then hold: the run is not done until it says so,+            # and a bar that hits 100 while work continues is the lie we already fixed once.+            pct = min(95, int(95 * elapsed / estimate_s)) if estimate_s > 0 else 0+            if not _taskbar_progress("normal", pct):+                return+    threading.Thread(target=_tick, daemon=True).start()+    return stop+++def end_activity_progress(verb: str, failed: bool = False):+    """Stop the bar. On failure flash red briefly so the outcome is visible, then clear."""+    import time as _t+    stop = _PROGRESS_STOP.pop(verb, None)+    if stop:+        stop.set()+    try:+        if failed:+            _taskbar_progress("error", 100)+            _t.sleep(1.2)+        _taskbar_progress("none")+    except Exception:+        pass
install.sh+1−1
@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.9.112"+DASH_VERSION="1.9.113" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.112",+  "version": "1.9.113",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {
page.json+10−7
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.112",+  "version": "1.9.113",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {@@ -11,6 +11,10 @@     "uninstall": "./uninstall.sh"   },   "tags": [+    "ab",+    "bridge",+    "fusion",+    "desktop-app",     "fusion360",     "autodesk",     "cad",@@ -70,7 +74,10 @@     "build a basic parts library",     "add a 3d model to a component",     "make a fusion 360 library with 3d",-    "set up the fusion bridge"+    "set up the fusion bridge",+    "fusion dashboard",+    "fusion status page",+    "open the fusion launch page"   ],   "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).",   "brief": "Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.",@@ -99,9 +106,5 @@       "label": "Part volume",       "prompt": "How much material does each printed part in this assembly use?"     }-  ],-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"-  }+  ] }\ No newline at end of file
server.py+113−1
@@ -5719,6 +5719,8 @@ def _handle_set_seat_takeover(fusion_info: dict, args: dict) -> dict: # lines earlier) is the import-time NameError that crash-looped the bridge on 2026-07-22. COMMAND_HANDLERS["set_seat_takeover"] = _handle_set_seat_takeover COMMAND_HANDLERS["set_driving_badge"] = _handle_set_driving_badge+COMMAND_HANDLERS["set_activity_indicator"] = _handle_set_activity_indicator+COMMAND_HANDLERS["verb_times"] = _handle_verb_times COMMAND_HANDLERS["set_window_sizing"] = _handle_set_window_sizing  @@ -8666,6 +8668,115 @@ def _register_docs_we_opened(before: set) -> None:         pass  ++_ACTIVITY_MODES = ("flash", "progress", "both", "none")+++def _activity_mode() -> str:+    """How to signal 'Adom is driving Fusion' on the taskbar. Sticky, user-settable."""+    try:+        m = str(_get_bridge_pref("activityIndicator", "progress") or "progress").lower()+    except Exception:+        m = "progress"+    return m if m in _ACTIVITY_MODES else "progress"+++def _time_and_indicate(command: str, args: dict, run):+    """Time EVERY verb, log it, and paint the chosen taskbar activity indicator.++    John's experiment (2026-08-21): a flash says "something is happening" and nothing more.+    A progress bar paced by this machine's OWN measured history for THIS verb says how long+    it will take, and disappears the moment the verb finishes or fails. Modes: flash |+    progress | both | none.++    The estimate comes from timings.verb_estimate; a verb with no history runs the bar+    INDETERMINATE rather than inventing a percentage.+    """+    import time as _t+    import timings as _tm+    mode = _activity_mode()+    slow = command in LONG_RUNNING_COMMANDS or command in _WINDOW_RAISING+    est, conf = _tm.verb_estimate(command)+    stop = None+    if slow and mode in ("progress", "both"):+        try:+            from handlers.taskbar_overlay import start_activity_progress+            stop = start_activity_progress(command, est, conf)+        except Exception:+            stop = None+    if slow and mode in ("flash", "both"):+        try:+            _ad_call("desktop_taskbar", {"titleContains": "Autodesk Fusion",+                                         "flash": "until_focused"}, timeout=10)+        except Exception:+            pass+    t0 = _t.time()+    failed = False+    try:+        res = run()+        failed = isinstance(res, dict) and res.get("success") is False+        return res+    except Exception:+        failed = True+        raise+    finally:+        took = _t.time() - t0+        try:+            _tm.log_run(command, took, ok=not failed)+        except Exception:+            pass+        if stop is not None or (slow and mode in ("flash", "both")):+            try:+                from handlers.taskbar_overlay import end_activity_progress+                end_activity_progress(command, failed=failed)+                if mode in ("flash", "both"):+                    _ad_call("desktop_taskbar", {"titleContains": "Autodesk Fusion",+                                                 "flash": "stop"}, timeout=8)+            except Exception:+                pass++++def _handle_set_activity_indicator(fusion_info: dict, args: dict) -> dict:+    """Read or set how the taskbar signals that Adom is driving Fusion."""+    if "mode" in args:+        mode = str(args.get("mode") or "").lower()+        if mode not in _ACTIVITY_MODES:+            return {"success": False,+                    "error": "mode must be one of %s" % ", ".join(_ACTIVITY_MODES),+                    "errorCode": "bad_activity_mode"}+        _set_bridge_pref("activityIndicator", mode)+        if mode in ("none", "flash"):+            try:+                from handlers.taskbar_overlay import _taskbar_progress+                _taskbar_progress("none")          # clear immediately, never leave a stale bar+            except Exception:+                pass+        if mode in ("none", "progress"):+            try:+                _ad_call("desktop_taskbar", {"titleContains": "Autodesk Fusion",+                                             "flash": "stop"}, timeout=8)+            except Exception:+                pass+    return {"success": True, "activityIndicator": _activity_mode(),+            "modes": list(_ACTIVITY_MODES),+            "_hint": ("Sticky (~/.adom/fusion-bridge/prefs.json). progress = a taskbar bar paced "+                      "by THIS machine's measured time for that verb; flash = the old "+                      "attention flash; both; none. A verb with no measured history yet runs "+                      "the bar indeterminate rather than inventing a percentage.")}+++def _handle_verb_times(fusion_info: dict, args: dict) -> dict:+    """The measured run-time report, aggregated from the append-only verb log."""+    import timings as _tm+    rep = _tm.verb_report(int(args.get("limit") or 40))+    return {"success": True, "verbs": rep, "count": len(rep),+            "_hint": ("Aggregated from ~/.adom/fusion-bridge/verb-times.jsonl (every run, with "+                      "its outcome). p90 is the useful column when hunting a slow surface; "+                      "`failures` next to a healthy p50 usually means a verb that fails FAST, "+                      "which is a different bug from one that is slow.")}++ def dispatch_command(command: str, args: dict, caller_identity: dict = None) -> dict:     """Thin wrapper: run the verb, then guarantee the windowEtiquette annotation lands on     EVERY backgrounded window-raising result - the inner dispatcher's closure only covered@@ -8677,7 +8788,8 @@ def dispatch_command(command: str, args: dict, caller_identity: dict = None) ->     # diff after, so every document our verbs create is attributable to us - and only ours.     _cmd_short = command[7:] if command.startswith("fusion_") else command     _docs_before = _snapshot_open_docs() if _cmd_short in _WINDOW_RAISING else None-    res = _dispatch_command_inner(command, args, caller_identity)+    res = _time_and_indicate(_cmd_short, args,+                             lambda: _dispatch_command_inner(command, args, caller_identity))     if _docs_before is not None:         _register_docs_we_opened(_docs_before)     # ── capture:true - ONE call = ONE consent = surface opened AND proven (issue #30) ──
timings.py+75
@@ -225,3 +225,78 @@ def sentence():     if not bits:         return None     return "on this machine Fusion starts in " + ", ".join(bits) + " (measured)"+++# ── Per-VERB run times ─────────────────────────────────────────────────────────────────+# John, 2026-08-21: "you need to start tracking the length of time it takes to run verbs+# and possibly ... store times in the log and analyze the log later to calc run times".+#+# Two stores on purpose, because they answer different questions:+#   - the ROLLING window above (last _KEEP samples) drives the live estimate: recent, cheap,+#     and it tracks a machine that got slower or faster.+#   - the APPEND-ONLY LOG below is the historical record: every run with its outcome, so a+#     question nobody thought to ask up front ("did the 3D package regress after 1.9.9x?",+#     "which verb owns the 546s?") is answerable later instead of lost.+# A rolling window can only answer what it still remembers; a log can be re-analysed.++_RUNLOG_MAX_BYTES = 4 * 1024 * 1024+++def _runlog_path():+    return _path().parent / "verb-times.jsonl"+++def log_run(verb: str, seconds: float, ok: bool = True, note: str = "") -> None:+    """Append one verb run to the historical log AND the rolling window. Never raises."""+    try:+        record("verb:" + verb, seconds)+        p = _runlog_path()+        try:+            if p.exists() and p.stat().st_size > _RUNLOG_MAX_BYTES:+                p.replace(str(p) + ".1")        # one generation back, never unbounded+        except Exception:+            pass+        with open(p, "a", encoding="utf-8") as f:+            f.write(json.dumps({"at": int(time.time()), "verb": verb,+                                "seconds": round(float(seconds), 2),+                                "ok": bool(ok), "note": note[:120]}) + "\n")+    except Exception:+        pass+++def verb_estimate(verb: str):+    """(seconds, confidence) for a verb from its rolling window. (0,'unknown') if new."""+    st = stats("verb:" + verb)+    if st["count"] >= 3 and st["p50"]:+        return float(st["p50"]), "measured"+    if st["count"] >= 1 and st["p50"]:+        return float(st["p50"]), "typical"+    return 0.0, "unknown"+++def verb_report(limit: int = 40) -> dict:+    """Aggregate the historical log: per verb count / p50 / p90 / max / failures.++    This is the 'analyse the log later' half. Cheap enough to expose from a verb, so the+    numbers are readable from the Dashboard instead of requiring someone to SSH in.+    """+    runs = {}+    try:+        with open(_runlog_path(), "r", encoding="utf-8") as f:+            for line in f:+                try:+                    r = json.loads(line)+                except Exception:+                    continue+                d = runs.setdefault(r.get("verb", "?"), {"secs": [], "fail": 0})+                d["secs"].append(float(r.get("seconds") or 0))+                if not r.get("ok"):+                    d["fail"] += 1+    except Exception:+        return {}+    out = {}+    for verb, d in runs.items():+        s = d["secs"]+        out[verb] = {"count": len(s), "p50": _pct(s, 0.5), "p90": _pct(s, 0.9),+                     "max": max(s) if s else None, "failures": d["fail"]}+    return dict(sorted(out.items(), key=lambda kv: -(kv[1]["p90"] or 0))[:limit])