loki-mode 8.6.0 → 8.8.0

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.
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "8.6.0"
10
+ __version__ = "8.8.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -2802,16 +2802,153 @@ _SESSION_MODEL_ALLOWLIST = ("haiku", "sonnet", "opus", "fable")
2802
2802
  # allowlist is unchanged) because it is an explicit live-run control.
2803
2803
  _START_MODEL_ALLOWLIST = ("haiku", "sonnet", "opus")
2804
2804
 
2805
+ # Provider-agnostic capability tiers. These are the vocabulary the picker offers
2806
+ # on a non-Claude provider, and they resolve per-provider through
2807
+ # providers/models.sh (loki_tier_alias): small -> fast, medium -> development,
2808
+ # high -> planning. Accepting them here is what makes the start-time picker work
2809
+ # on codex at all -- the Claude aliases above are meaningless there, so before
2810
+ # this every value a codex user could pick normalized to "" and was silently
2811
+ # dropped, and the run started on the provider default with no feedback.
2812
+ _START_MODEL_GENERIC_TIERS = ("small", "medium", "high")
2813
+
2805
2814
 
2806
2815
  def _normalize_start_model(raw: str | None) -> str:
2807
- """Normalize a start-time model / advisor alias (haiku|sonnet|opus, no fable).
2816
+ """Normalize a start-time model / advisor alias.
2808
2817
 
2809
- Same trim + lowercase + exact-match rule as _normalize_session_model, but on
2810
- the narrower _START_MODEL_ALLOWLIST. Returns "" for absent/invalid/fable so
2818
+ Accepts the Claude aliases (haiku|sonnet|opus, no fable) and the generic
2819
+ capability tiers (small|medium|high). Returns "" for absent/invalid/fable so
2811
2820
  callers can treat empty as "no selection" (engine uses its own default).
2821
+
2822
+ fable stays excluded: it is advisory-only and the runner collapses it to
2823
+ opus, so offering it as a start-time execution model would be a cost
2824
+ surprise. That reasoning is unchanged by adding the generic tiers.
2812
2825
  """
2813
2826
  val = (raw or "").strip().lower()
2814
- return val if val in _START_MODEL_ALLOWLIST else ""
2827
+ if val in _START_MODEL_ALLOWLIST or val in _START_MODEL_GENERIC_TIERS:
2828
+ return val
2829
+ return ""
2830
+
2831
+
2832
+ # =============================================================================
2833
+ # Provider-aware model offer set
2834
+ # =============================================================================
2835
+ # The two allowlists above are WIRE values: what run.sh will actually honor in
2836
+ # .loki/state/model-override. They are Claude aliases because run.sh:20996 gates
2837
+ # the whole override block on PROVIDER_NAME=claude and feeds the file straight
2838
+ # into `claude --model`. They must not change.
2839
+ #
2840
+ # What the dashboard OFFERS is a separate question, and it was the bug: the
2841
+ # picker rendered those four Claude aliases on every run, so a codex session was
2842
+ # offered Haiku/Sonnet/Opus/Fable, none of which codex can dispatch. The offer
2843
+ # set below is derived from the RUNNING session's provider plus the canonical
2844
+ # providers/model_catalog.json, so the picker never names a model the active
2845
+ # provider cannot run.
2846
+
2847
+ # Generic tier -> catalog key. These three tier names are provider-independent
2848
+ # (every catalog entry carries latest_fast/development/planning), which is what
2849
+ # makes the picker portable across providers.
2850
+ _TIER_LABELS = (
2851
+ ("small", "fast"),
2852
+ ("medium", "development"),
2853
+ ("high", "planning"),
2854
+ )
2855
+
2856
+
2857
+ def _active_provider() -> str:
2858
+ """The provider the CURRENT run is executing on.
2859
+
2860
+ Resolution order mirrors the CLI (autonomy/loki:5142): the per-project state
2861
+ file run.sh writes at launch (run.sh:1458), then the environment, then the
2862
+ stock default. The state file wins because it is the only source that
2863
+ reflects the live run rather than the dashboard process's own environment.
2864
+ """
2865
+ try:
2866
+ p = _get_loki_dir() / "state" / "provider"
2867
+ if p.is_file():
2868
+ val = p.read_text().strip().lower()
2869
+ if val:
2870
+ return val
2871
+ except OSError:
2872
+ pass
2873
+ return (os.environ.get("LOKI_PROVIDER") or "claude").strip().lower() or "claude"
2874
+
2875
+
2876
+ def _load_model_catalog() -> dict:
2877
+ """Read providers/model_catalog.json, the single source of truth for model ids.
2878
+
2879
+ Same candidate paths as GET /api/providers/models. Returns {} when the
2880
+ catalog is unreadable; every caller degrades to "no model ids to show"
2881
+ rather than inventing one.
2882
+ """
2883
+ for path in (
2884
+ _Path(__file__).resolve().parent.parent / "providers" / "model_catalog.json",
2885
+ _Path("providers/model_catalog.json"),
2886
+ ):
2887
+ try:
2888
+ if path.exists():
2889
+ with path.open("r", encoding="utf-8") as fh:
2890
+ return json.load(fh)
2891
+ except (json.JSONDecodeError, OSError):
2892
+ continue
2893
+ return {}
2894
+
2895
+
2896
+ def _resolve_catalog_model(provider: str, catalog_tier: str) -> str:
2897
+ """The model id `provider` dispatches for `catalog_tier` (fast/development/planning).
2898
+
2899
+ Python mirror of loki_latest_model (providers/models.sh:22), including its
2900
+ env-override chain and its "generic" registry fallback for a provider the
2901
+ catalog does not name. Kept in Python rather than shelling out to models.sh:
2902
+ the dashboard answers this per request and a subprocess per tier per poll is
2903
+ not worth it. Model IDS still come only from the catalog, never from here.
2904
+ """
2905
+ provider_env = re.sub(r"[^A-Z0-9_]", "_", provider.upper())
2906
+ for var in (
2907
+ f"LOKI_{provider_env}_MODEL_{catalog_tier.upper()}",
2908
+ f"LOKI_{provider_env}_MODEL",
2909
+ ):
2910
+ val = (os.environ.get(var) or "").strip()
2911
+ if val:
2912
+ return val
2913
+ providers = _load_model_catalog().get("providers", {})
2914
+ entry = providers.get(provider) or providers.get("generic") or {}
2915
+ return str(entry.get(f"latest_{catalog_tier}") or "")
2916
+
2917
+
2918
+ def _provider_model_offers(provider: str) -> list[dict]:
2919
+ """The model choices to OFFER for `provider`, each with what it resolves to.
2920
+
2921
+ Claude keeps its established alias picker byte-for-byte: those aliases are
2922
+ the values run.sh honors in the override file, so changing them would break
2923
+ the one provider where mid-run switching actually works.
2924
+
2925
+ Every other provider is offered the generic tiers (small/medium/high), which
2926
+ are provider-independent, each annotated with the concrete model id the
2927
+ catalog says that provider dispatches. That is what makes the picker read
2928
+ "medium -> gpt-5.3-codex" on codex and "medium -> claude-sonnet-5" on claude
2929
+ without the frontend knowing a single model id.
2930
+ """
2931
+ if provider == "claude":
2932
+ aliases = _load_model_catalog().get("providers", {}).get("claude", {}).get("cli_aliases", {})
2933
+ return [
2934
+ {"value": alias, "tier": None, "model": aliases.get(alias, "")}
2935
+ for alias in _SESSION_MODEL_ALLOWLIST
2936
+ ]
2937
+ return [
2938
+ {"value": tier, "tier": tier, "model": _resolve_catalog_model(provider, catalog_tier)}
2939
+ for tier, catalog_tier in _TIER_LABELS
2940
+ ]
2941
+
2942
+
2943
+ def _provider_supports_model_switch(provider: str) -> bool:
2944
+ """Whether a live run on `provider` honors .loki/state/model-override.
2945
+
2946
+ Only claude does: run.sh:20996 gates the entire override-read block on
2947
+ PROVIDER_NAME=claude. On any other provider the file is written and never
2948
+ read, so the POST path rejects rather than reporting a switch that will not
2949
+ happen.
2950
+ """
2951
+ return provider == "claude"
2815
2952
 
2816
2953
 
2817
2954
  class SessionModelRequest(BaseModel):
@@ -2890,17 +3027,28 @@ def _normalize_session_model(raw: str | None) -> str:
2890
3027
  # tier names ARE valid pins.
2891
3028
  _SESSION_PIN_ALLOWLIST = _SESSION_MODEL_ALLOWLIST + ("planning", "development", "fast")
2892
3029
 
3030
+ # Generic capability vocabulary. A user picks a CLASS of model (small/medium/
3031
+ # high) and each provider supplies its own latest model in that class, so nobody
3032
+ # has to know a vendor's model names. These are translated onto the canonical
3033
+ # tier names rather than added to the allowlist, keeping this mirror in step
3034
+ # with run.sh's entry-point case and the `loki plan` estimator without widening
3035
+ # what any of the three actually route on. Mirrors loki_tier_alias() in
3036
+ # providers/models.sh.
3037
+ _GENERIC_TIERS = {"small": "fast", "medium": "development", "high": "planning"}
3038
+
2893
3039
 
2894
3040
  def _normalize_session_pin(raw: str | None) -> str:
2895
3041
  """Normalize a LOKI_SESSION_MODEL pin value (aliases + raw tier names).
2896
3042
 
2897
3043
  Mirrors run.sh's session-pin case: trim + lowercase, accept the four model
2898
- aliases and the three tier names. Interior whitespace is preserved (so
3044
+ aliases and the three tier names, and translate the generic small/medium/
3045
+ high vocabulary onto those tier names. Interior whitespace is preserved (so
2899
3046
  "fab le" stays junk and falls through to the default tier, exactly like the
2900
3047
  runner's "*" arm). Use this for the session-pin (no-override) derivation;
2901
3048
  use _normalize_session_model for the override-file / POST path.
2902
3049
  """
2903
3050
  val = (raw or "").strip().lower()
3051
+ val = _GENERIC_TIERS.get(val, val)
2904
3052
  return val if val in _SESSION_PIN_ALLOWLIST else ""
2905
3053
 
2906
3054
 
@@ -3126,11 +3274,24 @@ async def get_session_model():
3126
3274
  # the reported effective model agrees with dispatch on BOTH routes (v7.39.1).
3127
3275
  if effective == "fable":
3128
3276
  effective = "opus"
3277
+ provider = _active_provider()
3278
+ offers = _provider_model_offers(provider)
3279
+ if provider != "claude":
3280
+ # Non-claude: the claude-alias default/effective computed above describe a
3281
+ # dispatch that is not happening on this run. Report what the provider
3282
+ # actually runs, from the catalog, and drop the stale override (run.sh
3283
+ # never reads the file on this provider, so it cannot be in effect).
3284
+ override = None
3285
+ default = "medium"
3286
+ effective = _resolve_catalog_model(provider, "development")
3129
3287
  return {
3130
3288
  "override": override,
3131
3289
  "default": default,
3132
3290
  "effective": effective,
3133
- "allowed": list(_SESSION_MODEL_ALLOWLIST),
3291
+ "provider": provider,
3292
+ "switchable": _provider_supports_model_switch(provider),
3293
+ "offers": offers,
3294
+ "allowed": [o["value"] for o in offers],
3134
3295
  }
3135
3296
 
3136
3297
 
@@ -3156,6 +3317,22 @@ async def set_session_model(request: SessionModelRequest):
3156
3317
  """
3157
3318
  requested_raw = (request.model or "").strip().lower()
3158
3319
  override_path = _model_override_path()
3320
+ # Mid-run switching is a claude-only runtime capability: run.sh:20996 gates the
3321
+ # override-read block on PROVIDER_NAME=claude, so on any other provider this
3322
+ # file would be written and never read. Reject instead of writing a file that
3323
+ # does nothing and reporting success (a false affordance is worse than no
3324
+ # control). Clearing is still allowed everywhere: removing a stale file is
3325
+ # always safe and never claims a switch.
3326
+ provider = _active_provider()
3327
+ if requested_raw != "" and not _provider_supports_model_switch(provider):
3328
+ raise HTTPException(
3329
+ status_code=409,
3330
+ detail=(
3331
+ f"Mid-run model switching is not supported on provider '{provider}'. "
3332
+ f"The run dispatches {_resolve_catalog_model(provider, 'development') or 'its configured model'}; "
3333
+ "restart the run with a different model to change it."
3334
+ ),
3335
+ )
3159
3336
  if requested_raw == "":
3160
3337
  # Clear the override; revert to tier mapping.
3161
3338
  try:
@@ -3965,19 +4142,30 @@ async def start_build(request: Request, body: StartBuildRequest):
3965
4142
  popen_env["LOKI_TARGET_DIR"] = str(workspace_dir)
3966
4143
  popen_env["LOKI_DIR"] = str(loki_dir)
3967
4144
  if start_model:
3968
- # EXACT-model pin (not the session-pin tier route): set all three tier
3969
- # models to the chosen alias so resolve_model_for_tier returns the alias
3970
- # for every tier and every iteration dispatches exactly the picked model.
3971
- # This is the honest start-time equivalent of the mid-flight override
3972
- # file, which run.sh clears at iteration 0. LOKI_SESSION_MODEL is set too
3973
- # for internal coherence (the run's own tier accounting/logging), but the
3974
- # env triple is the load-bearing dispatch-honesty mechanism: on the
3975
- # v7.104.0 stock config the session pin alone would remap opus->planning->
3976
- # sonnet and haiku->fast->sonnet, dispatching sonnet for both.
3977
- popen_env["LOKI_CLAUDE_MODEL_PLANNING"] = start_model
3978
- popen_env["LOKI_CLAUDE_MODEL_DEVELOPMENT"] = start_model
3979
- popen_env["LOKI_CLAUDE_MODEL_FAST"] = start_model
3980
- popen_env["LOKI_SESSION_MODEL"] = start_model
4145
+ if start_model in _START_MODEL_GENERIC_TIERS:
4146
+ # A generic capability tier is provider-agnostic BY CONSTRUCTION --
4147
+ # it names a capability class, not a model, and each provider
4148
+ # resolves its own latest model for that class via
4149
+ # providers/models.sh. Pinning the LOKI_CLAUDE_MODEL_* triple here
4150
+ # would be actively wrong: those variables are inert on codex and
4151
+ # every other non-Claude provider, so the pin would silently do
4152
+ # nothing. LOKI_SESSION_MODEL is the correct and only lever.
4153
+ popen_env["LOKI_SESSION_MODEL"] = start_model
4154
+ else:
4155
+ # EXACT-model pin (not the session-pin tier route): set all three tier
4156
+ # models to the chosen alias so resolve_model_for_tier returns the alias
4157
+ # for every tier and every iteration dispatches exactly the picked model.
4158
+ # This is the honest start-time equivalent of the mid-flight override
4159
+ # file, which run.sh clears at iteration 0. LOKI_SESSION_MODEL is set too
4160
+ # for internal coherence (the run's own tier accounting/logging), but the
4161
+ # env triple is the load-bearing dispatch-honesty mechanism: on the
4162
+ # v7.104.0 stock config the session pin alone would remap
4163
+ # opus->planning->sonnet and haiku->fast->sonnet, dispatching sonnet
4164
+ # for both.
4165
+ popen_env["LOKI_CLAUDE_MODEL_PLANNING"] = start_model
4166
+ popen_env["LOKI_CLAUDE_MODEL_DEVELOPMENT"] = start_model
4167
+ popen_env["LOKI_CLAUDE_MODEL_FAST"] = start_model
4168
+ popen_env["LOKI_SESSION_MODEL"] = start_model
3981
4169
  if advisor_model:
3982
4170
  # Opt-in Opus (or other) judge for code review; execution model unchanged.
3983
4171
  popen_env["LOKI_ADVISOR_MODEL"] = advisor_model
@@ -3774,19 +3774,19 @@ var LokiDashboard=(()=>{var Re=Object.defineProperty;var _t=Object.getOwnPropert
3774
3774
  </div>
3775
3775
  `}).join("")}
3776
3776
  </div>
3777
- `}}_attachEventListeners(){let e=this.shadowRoot.getElementById("refresh-btn");e&&e.addEventListener("click",()=>this._loadTasks());let t=this.shadowRoot.getElementById("bulk-toggle-btn");t&&t.addEventListener("click",()=>this._toggleBulkMode()),this.shadowRoot.querySelectorAll(".filter-pill").forEach(r=>{r.addEventListener("click",()=>this._setFilter(r.dataset.filter))});let i=this.shadowRoot.getElementById("task-search");i&&i.addEventListener("input",r=>this._setSearch(r.target.value)),this.shadowRoot.querySelectorAll(".show-more-btn").forEach(r=>{r.addEventListener("click",()=>this._showMore(r.dataset.showMore))}),this.shadowRoot.querySelectorAll(".bulk-btn").forEach(r=>{r.addEventListener("click",()=>{let o=r.dataset.bulkAction;o==="delete"?this._bulkDelete():this._bulkMove(o)})}),this.shadowRoot.querySelectorAll(".add-task-btn").forEach(r=>{r.addEventListener("click",()=>{this._openAddTaskModal(r.dataset.status)})}),this.shadowRoot.querySelectorAll(".task-checkbox").forEach(r=>{r.addEventListener("click",o=>{o.stopPropagation(),this._toggleTaskSelection(r.dataset.checkId,o)})}),this.shadowRoot.querySelectorAll(".expand-toggle").forEach(r=>{r.addEventListener("click",o=>{o.stopPropagation(),this._toggleCardExpand(r.dataset.expandId)})}),this.shadowRoot.querySelectorAll(".task-card").forEach(r=>{let o=r.dataset.taskId,n=this._tasks.find(l=>l.id.toString()===o);n&&(r.addEventListener("click",l=>{if(this._bulkMode){this._toggleTaskSelection(o,l);return}this._openTaskDetail(n)}),r.addEventListener("keydown",l=>{l.key==="Enter"||l.key===" "?(l.preventDefault(),this._bulkMode?this._toggleTaskSelection(o,l):this._openTaskDetail(n)):(l.key==="ArrowDown"||l.key==="ArrowUp")&&(l.preventDefault(),this._navigateTaskCards(r,l.key==="ArrowDown"?"next":"prev"))}),r.classList.contains("draggable")&&(r.addEventListener("dragstart",l=>this._handleDragStart(l,n)),r.addEventListener("dragend",l=>this._handleDragEnd(l))))}),this.shadowRoot.querySelectorAll(".kanban-tasks").forEach(r=>{r.addEventListener("dragover",o=>this._handleDragOver(o)),r.addEventListener("dragenter",o=>this._handleDragEnter(o)),r.addEventListener("dragleave",o=>this._handleDragLeave(o)),r.addEventListener("drop",o=>this._handleDrop(o,r.dataset.status))});let a=this.shadowRoot.getElementById("modal-close-btn");a&&a.addEventListener("click",()=>this._closeTaskDetail());let s=this.shadowRoot.getElementById("task-detail-overlay");s&&s.addEventListener("click",r=>{r.target===s&&this._closeTaskDetail()})}_escapeHtml(e){let t=document.createElement("div");return t.textContent=e,t.innerHTML}_navigateTaskCards(e,t){let i=Array.from(this.shadowRoot.querySelectorAll(".task-card")),a=i.indexOf(e);if(a===-1)return;let s=t==="next"?a+1:a-1;s>=0&&s<i.length&&i[s].focus()}};customElements.get("loki-task-board")||customElements.define("loki-task-board",Q);var X=class extends v{static get observedAttributes(){return["api-url","theme","compact"]}constructor(){super(),this._status={mode:"offline",phase:null,iteration:null,complexity:null,connected:!1,version:null,uptime:0,activeAgents:0,pendingTasks:0},this._model={override:null,default:"sonnet",effective:"sonnet",notice:""},this._modelBusy=!1,this._startBusy=!1,this._startNotice="",this._specText="",this._startModel="",this._advisorModel="",this._api=null,this._state=U(),this._statusUpdateHandler=null,this._connectedHandler=null,this._disconnectedHandler=null}connectedCallback(){super.connectedCallback(),this._setupApi(),this._loadStatus(),this._loadModel(),this._startPolling()}disconnectedCallback(){super.disconnectedCallback(),this._stopPolling(),this._teardownApiListeners()}_teardownApiListeners(){this._api&&(this._statusUpdateHandler&&this._api.removeEventListener(f.STATUS_UPDATE,this._statusUpdateHandler),this._connectedHandler&&this._api.removeEventListener(f.CONNECTED,this._connectedHandler),this._disconnectedHandler&&this._api.removeEventListener(f.DISCONNECTED,this._disconnectedHandler))}attributeChangedCallback(e,t,i){t!==i&&(e==="api-url"&&this._api&&(this._teardownApiListeners(),this._setupApi(),this._loadStatus(),this._loadModel()),e==="theme"&&this._applyTheme(),e==="compact"&&this.render())}_setupApi(){let e=this.getAttribute("api-url")||window.location.origin;this._api=h({baseUrl:e}),this._statusUpdateHandler=t=>this._updateFromStatus(t.detail),this._connectedHandler=()=>{this._status.connected=!0,this.render()},this._disconnectedHandler=()=>{this._status.connected=!1,this._status.mode="offline",this.render()},this._api.addEventListener(f.STATUS_UPDATE,this._statusUpdateHandler),this._api.addEventListener(f.CONNECTED,this._connectedHandler),this._api.addEventListener(f.DISCONNECTED,this._disconnectedHandler)}async _loadStatus(){let e=this._api;try{let t=await e.getStatus();if(e!==this._api)return;this._updateFromStatus(t)}catch{if(e!==this._api)return;this._status.connected=!1,this._status.mode="offline",this.render()}}_updateFromStatus(e){e&&(this._status={...this._status,connected:!0,mode:e.status||"running",version:e.version,uptime:e.uptime_seconds||0,activeAgents:e.running_agents||0,pendingTasks:e.pending_tasks||0,phase:e.phase,iteration:e.iteration,complexity:e.complexity},this._state.updateSession({connected:!0,mode:this._status.mode,lastSync:new Date().toISOString()}),this.render())}_startPolling(){this._poll=b({loadFn:()=>this._loadStatus(),intervalMs:3e3,sectionId:null,immediate:!1})}_stopPolling(){this._poll&&(this._poll.stop(),this._poll=null)}_formatUptime(e){if(!e||e<0)return"--";let t=Math.floor(e/3600),i=Math.floor(e%3600/60),a=Math.floor(e%60);return t>0?`${t}h ${i}m`:i>0?`${i}m ${a}s`:`${a}s`}_escapeHtml(e){let t=document.createElement("div");return t.textContent=String(e??""),t.innerHTML}_getStatusClass(){switch(this._status.mode){case"running":case"autonomous":return"active";case"paused":return"paused";case"stopped":return"stopped";case"error":return"error";default:return"offline"}}_getStatusLabel(){switch(this._status.mode){case"running":case"autonomous":return"AUTONOMOUS";case"paused":return"PAUSED";case"stopped":return"STOPPED";case"error":return"ERROR";default:return"OFFLINE"}}async _triggerStart(){if(this._startBusy)return;let e=(this._specText||"").trim();if(!e){this._startNotice="Enter a spec or one-line brief to start a build.",this.render();return}if(!this._api||typeof this._api.startSession!="function"){this._startNotice="Start is not available on this server.",this.render();return}this._startBusy=!0,this._startNotice="Starting build...",this.render();try{let t=await this._api.startSession(e,{provider:this._status.provider||"claude",model:this._startModel||"",advisorModel:this._advisorModel||""});if(t&&t.error)throw new Error(t.error);this._startBusy=!1,this._startNotice="",this._specText="",this._status.mode="running",this._status.connected=!0,this.render(),this._loadStatus(),this.dispatchEvent(new CustomEvent("session-start",{detail:{...this._status,pid:t&&t.pid,spec:t&&t.spec}}))}catch(t){console.error("Failed to start build:",t),this._startBusy=!1,this._startNotice=t&&t.message?`Could not start: ${t.message}`:"Could not start the build. Try again.",this.render()}}_onSpecInput(e){this._specText=e}async _triggerPause(){try{let e=await this._api.pauseSession();if(e&&e.error)throw new Error(e.error);this._status.mode="paused",this.render(),this.dispatchEvent(new CustomEvent("session-pause",{detail:this._status}))}catch(e){console.error("Failed to pause session:",e),this.render()}}async _triggerResume(){try{let e=await this._api.resumeSession();if(e&&e.error)throw new Error(e.error);this._status.mode="running",this.render(),this.dispatchEvent(new CustomEvent("session-resume",{detail:this._status}))}catch(e){console.error("Failed to resume session:",e),this.render()}}async _triggerStop(){try{let e=await this._api.stopSession();if(e&&e.error)throw new Error(e.error);this._status.mode="stopped",this.render(),this.dispatchEvent(new CustomEvent("session-stop",{detail:this._status}))}catch(e){console.error("Failed to stop session:",e),this.render()}}async _loadModel(){if(!this._api||typeof this._api.getSessionModel!="function")return;let e=this._api;try{let t=await e.getSessionModel();if(e!==this._api)return;t&&!t.error&&(this._model={...this._model,override:t.override??null,default:t.default||"sonnet",effective:t.effective||t.default||"sonnet"},this.render())}catch{}}async _onModelChange(e){if(this._modelBusy)return;this._modelBusy=!0;let t=e===""?null:e;try{let i=await this._api.setSessionModel(t);if(i&&i.error)throw new Error(i.error);this._model.override=t,this._model.notice=t?`Switching to ${t}. Applies from the next iteration, for the current run only.`:"Override cleared. Reverts to the tier mapping from the next iteration.",this._modelBusy=!1,await this._loadModel()}catch(i){console.error("Failed to set session model:",i),this._model.notice="Could not change the model. Try again.",this._modelBusy=!1,this.render()}}_renderModelControl(){let e=this._model.override||"",i=[{value:"",label:`Default (tier: ${this._escapeHtml(this._model.default)})`},{value:"haiku",label:"Haiku (fastest, cheapest)"},{value:"sonnet",label:"Sonnet 5 (balanced)"},{value:"opus",label:"Opus (top coding)"},{value:"fable",label:"Fable 5 (2x Opus cost: $10/$50 per MTok)"}].map(s=>{let r=s.value===e?" selected":"";return`<option value="${this._escapeHtml(s.value)}"${r}>${this._escapeHtml(s.label)}</option>`}).join(""),a=this._model.effective==="fable";return`
3777
+ `}}_attachEventListeners(){let e=this.shadowRoot.getElementById("refresh-btn");e&&e.addEventListener("click",()=>this._loadTasks());let t=this.shadowRoot.getElementById("bulk-toggle-btn");t&&t.addEventListener("click",()=>this._toggleBulkMode()),this.shadowRoot.querySelectorAll(".filter-pill").forEach(r=>{r.addEventListener("click",()=>this._setFilter(r.dataset.filter))});let i=this.shadowRoot.getElementById("task-search");i&&i.addEventListener("input",r=>this._setSearch(r.target.value)),this.shadowRoot.querySelectorAll(".show-more-btn").forEach(r=>{r.addEventListener("click",()=>this._showMore(r.dataset.showMore))}),this.shadowRoot.querySelectorAll(".bulk-btn").forEach(r=>{r.addEventListener("click",()=>{let o=r.dataset.bulkAction;o==="delete"?this._bulkDelete():this._bulkMove(o)})}),this.shadowRoot.querySelectorAll(".add-task-btn").forEach(r=>{r.addEventListener("click",()=>{this._openAddTaskModal(r.dataset.status)})}),this.shadowRoot.querySelectorAll(".task-checkbox").forEach(r=>{r.addEventListener("click",o=>{o.stopPropagation(),this._toggleTaskSelection(r.dataset.checkId,o)})}),this.shadowRoot.querySelectorAll(".expand-toggle").forEach(r=>{r.addEventListener("click",o=>{o.stopPropagation(),this._toggleCardExpand(r.dataset.expandId)})}),this.shadowRoot.querySelectorAll(".task-card").forEach(r=>{let o=r.dataset.taskId,n=this._tasks.find(l=>l.id.toString()===o);n&&(r.addEventListener("click",l=>{if(this._bulkMode){this._toggleTaskSelection(o,l);return}this._openTaskDetail(n)}),r.addEventListener("keydown",l=>{l.key==="Enter"||l.key===" "?(l.preventDefault(),this._bulkMode?this._toggleTaskSelection(o,l):this._openTaskDetail(n)):(l.key==="ArrowDown"||l.key==="ArrowUp")&&(l.preventDefault(),this._navigateTaskCards(r,l.key==="ArrowDown"?"next":"prev"))}),r.classList.contains("draggable")&&(r.addEventListener("dragstart",l=>this._handleDragStart(l,n)),r.addEventListener("dragend",l=>this._handleDragEnd(l))))}),this.shadowRoot.querySelectorAll(".kanban-tasks").forEach(r=>{r.addEventListener("dragover",o=>this._handleDragOver(o)),r.addEventListener("dragenter",o=>this._handleDragEnter(o)),r.addEventListener("dragleave",o=>this._handleDragLeave(o)),r.addEventListener("drop",o=>this._handleDrop(o,r.dataset.status))});let a=this.shadowRoot.getElementById("modal-close-btn");a&&a.addEventListener("click",()=>this._closeTaskDetail());let s=this.shadowRoot.getElementById("task-detail-overlay");s&&s.addEventListener("click",r=>{r.target===s&&this._closeTaskDetail()})}_escapeHtml(e){let t=document.createElement("div");return t.textContent=e,t.innerHTML}_navigateTaskCards(e,t){let i=Array.from(this.shadowRoot.querySelectorAll(".task-card")),a=i.indexOf(e);if(a===-1)return;let s=t==="next"?a+1:a-1;s>=0&&s<i.length&&i[s].focus()}};customElements.get("loki-task-board")||customElements.define("loki-task-board",Q);var X=class d extends v{static get observedAttributes(){return["api-url","theme","compact"]}constructor(){super(),this._status={mode:"offline",phase:null,iteration:null,complexity:null,connected:!1,version:null,uptime:0,activeAgents:0,pendingTasks:0},this._model={override:null,default:"sonnet",effective:"sonnet",notice:"",provider:"claude",switchable:!0,offers:[]},this._modelBusy=!1,this._startBusy=!1,this._startNotice="",this._specText="",this._startModel="",this._advisorModel="",this._api=null,this._state=U(),this._statusUpdateHandler=null,this._connectedHandler=null,this._disconnectedHandler=null}connectedCallback(){super.connectedCallback(),this._setupApi(),this._loadStatus(),this._loadModel(),this._startPolling()}disconnectedCallback(){super.disconnectedCallback(),this._stopPolling(),this._teardownApiListeners()}_teardownApiListeners(){this._api&&(this._statusUpdateHandler&&this._api.removeEventListener(f.STATUS_UPDATE,this._statusUpdateHandler),this._connectedHandler&&this._api.removeEventListener(f.CONNECTED,this._connectedHandler),this._disconnectedHandler&&this._api.removeEventListener(f.DISCONNECTED,this._disconnectedHandler))}attributeChangedCallback(e,t,i){t!==i&&(e==="api-url"&&this._api&&(this._teardownApiListeners(),this._setupApi(),this._loadStatus(),this._loadModel()),e==="theme"&&this._applyTheme(),e==="compact"&&this.render())}_setupApi(){let e=this.getAttribute("api-url")||window.location.origin;this._api=h({baseUrl:e}),this._statusUpdateHandler=t=>this._updateFromStatus(t.detail),this._connectedHandler=()=>{this._status.connected=!0,this.render()},this._disconnectedHandler=()=>{this._status.connected=!1,this._status.mode="offline",this.render()},this._api.addEventListener(f.STATUS_UPDATE,this._statusUpdateHandler),this._api.addEventListener(f.CONNECTED,this._connectedHandler),this._api.addEventListener(f.DISCONNECTED,this._disconnectedHandler)}async _loadStatus(){let e=this._api;try{let t=await e.getStatus();if(e!==this._api)return;this._updateFromStatus(t)}catch{if(e!==this._api)return;this._status.connected=!1,this._status.mode="offline",this.render()}}_updateFromStatus(e){e&&(this._status={...this._status,connected:!0,mode:e.status||"running",version:e.version,uptime:e.uptime_seconds||0,activeAgents:e.running_agents||0,pendingTasks:e.pending_tasks||0,phase:e.phase,iteration:e.iteration,complexity:e.complexity},this._state.updateSession({connected:!0,mode:this._status.mode,lastSync:new Date().toISOString()}),this.render())}_startPolling(){this._poll=b({loadFn:()=>this._loadStatus(),intervalMs:3e3,sectionId:null,immediate:!1})}_stopPolling(){this._poll&&(this._poll.stop(),this._poll=null)}_formatUptime(e){if(!e||e<0)return"--";let t=Math.floor(e/3600),i=Math.floor(e%3600/60),a=Math.floor(e%60);return t>0?`${t}h ${i}m`:i>0?`${i}m ${a}s`:`${a}s`}_escapeHtml(e){let t=document.createElement("div");return t.textContent=String(e??""),t.innerHTML}_getStatusClass(){switch(this._status.mode){case"running":case"autonomous":return"active";case"paused":return"paused";case"stopped":return"stopped";case"error":return"error";default:return"offline"}}_getStatusLabel(){switch(this._status.mode){case"running":case"autonomous":return"AUTONOMOUS";case"paused":return"PAUSED";case"stopped":return"STOPPED";case"error":return"ERROR";default:return"OFFLINE"}}async _triggerStart(){if(this._startBusy)return;let e=(this._specText||"").trim();if(!e){this._startNotice="Enter a spec or one-line brief to start a build.",this.render();return}if(!this._api||typeof this._api.startSession!="function"){this._startNotice="Start is not available on this server.",this.render();return}this._startBusy=!0,this._startNotice="Starting build...",this.render();try{let t=await this._api.startSession(e,{provider:this._status.provider||"claude",model:this._startModel||"",advisorModel:this._advisorModel||""});if(t&&t.error)throw new Error(t.error);this._startBusy=!1,this._startNotice="",this._specText="",this._status.mode="running",this._status.connected=!0,this.render(),this._loadStatus(),this.dispatchEvent(new CustomEvent("session-start",{detail:{...this._status,pid:t&&t.pid,spec:t&&t.spec}}))}catch(t){console.error("Failed to start build:",t),this._startBusy=!1,this._startNotice=t&&t.message?`Could not start: ${t.message}`:"Could not start the build. Try again.",this.render()}}_onSpecInput(e){this._specText=e}async _triggerPause(){try{let e=await this._api.pauseSession();if(e&&e.error)throw new Error(e.error);this._status.mode="paused",this.render(),this.dispatchEvent(new CustomEvent("session-pause",{detail:this._status}))}catch(e){console.error("Failed to pause session:",e),this.render()}}async _triggerResume(){try{let e=await this._api.resumeSession();if(e&&e.error)throw new Error(e.error);this._status.mode="running",this.render(),this.dispatchEvent(new CustomEvent("session-resume",{detail:this._status}))}catch(e){console.error("Failed to resume session:",e),this.render()}}async _triggerStop(){try{let e=await this._api.stopSession();if(e&&e.error)throw new Error(e.error);this._status.mode="stopped",this.render(),this.dispatchEvent(new CustomEvent("session-stop",{detail:this._status}))}catch(e){console.error("Failed to stop session:",e),this.render()}}async _loadModel(){if(!this._api||typeof this._api.getSessionModel!="function")return;let e=this._api;try{let t=await e.getSessionModel();if(e!==this._api)return;t&&!t.error&&(this._model={...this._model,override:t.override??null,default:t.default||"sonnet",effective:t.effective||t.default||"sonnet",provider:t.provider||"claude",switchable:t.switchable!==!1,offers:Array.isArray(t.offers)?t.offers:[]},this.render())}catch{}}async _onModelChange(e){if(this._modelBusy)return;this._modelBusy=!0;let t=e===""?null:e;try{let i=await this._api.setSessionModel(t);if(i&&i.error)throw new Error(i.error);this._model.override=t,this._model.notice=t?`Switching to ${t}. Applies from the next iteration, for the current run only.`:"Override cleared. Reverts to the tier mapping from the next iteration.",this._modelBusy=!1,await this._loadModel()}catch(i){console.error("Failed to set session model:",i),this._model.notice="Could not change the model. Try again.",this._modelBusy=!1,this.render()}}static get MODEL_HINTS(){return{haiku:"fastest, cheapest",sonnet:"balanced",opus:"top coding",fable:"2x Opus cost: $10/$50 per MTok"}}_renderModelControl(){let e=this._model.override||"",t=this._model.offers,i=d.MODEL_HINTS,s=[{value:"",label:`Default (${this._model.default})`},...t.map(l=>{let c=i[l.value],p=c?`${l.value} (${c})`:l.value;return{value:l.value,label:l.model?`${p} -> ${l.model}`:p}})].map(l=>{let c=l.value===e?" selected":"";return`<option value="${this._escapeHtml(l.value)}"${c}>${this._escapeHtml(l.label)}</option>`}).join(""),r=this._model.effective==="fable",o=this._model.switchable===!1;return`
3778
3778
  <div class="model-control">
3779
3779
  <div class="model-row">
3780
3780
  <label for="model-select">Model</label>
3781
- <select class="model-select" id="model-select" aria-label="Run model"${this._modelBusy?" disabled":""}>
3782
- ${i}
3781
+ <select class="model-select" id="model-select" aria-label="Run model"${this._modelBusy||o?" disabled":""}>
3782
+ ${s}
3783
3783
  </select>
3784
3784
  </div>
3785
- ${a?'<div class="model-cost-note">Fable 5 costs 2x Opus per token ($10/$50 per MTok).</div>':""}
3786
- <div class="model-disclosure">Model changes apply from the next iteration, for the current run only.</div>
3785
+ ${r?'<div class="model-cost-note">Fable 5 costs 2x Opus per token ($10/$50 per MTok).</div>':""}
3786
+ <div class="model-disclosure">${o?`Provider ${this._escapeHtml(this._model.provider)} runs ${this._escapeHtml(this._model.effective)} and does not support switching models mid-run. Restart the run to change it.`:"Model changes apply from the next iteration, for the current run only."}</div>
3787
3787
  ${this._model.notice?`<div class="model-notice">${this._escapeHtml(this._model.notice)}</div>`:""}
3788
3788
  </div>
3789
- `}_renderStartControl(){let t=[{value:"",label:"Sonnet 5 (default)"},{value:"haiku",label:"Haiku (fastest, cheapest)"},{value:"sonnet",label:"Sonnet 5 (balanced)"},{value:"opus",label:"Opus (top coding, priciest)"}].map(s=>{let r=s.value===this._startModel?" selected":"";return`<option value="${this._escapeHtml(s.value)}"${r}>${this._escapeHtml(s.label)}</option>`}).join(""),a=[{value:"",label:"Account default"},{value:"opus",label:"Opus (stronger judge)"}].map(s=>{let r=s.value===this._advisorModel?" selected":"";return`<option value="${this._escapeHtml(s.value)}"${r}>${this._escapeHtml(s.label)}</option>`}).join("");return`
3789
+ `}_renderStartControl(){let e=d.MODEL_HINTS,i=[{value:"",label:`Default (${this._model.default})`},...this._model.offers.filter(r=>r.value!=="fable").map(r=>{let o=e[r.value],n=o?`${r.value} (${o})`:r.value;return{value:r.value,label:r.model?`${n} -> ${r.model}`:n}})].map(r=>{let o=r.value===this._startModel?" selected":"";return`<option value="${this._escapeHtml(r.value)}"${o}>${this._escapeHtml(r.label)}</option>`}).join(""),a=this._model.provider==="claude"?[{value:"",label:"Account default"},{value:"opus",label:"Opus (stronger judge)"}]:[],s=a.map(r=>{let o=r.value===this._advisorModel?" selected":"";return`<option value="${this._escapeHtml(r.value)}"${o}>${this._escapeHtml(r.label)}</option>`}).join("");return`
3790
3790
  <div class="start-control">
3791
3791
  <label class="start-label" for="spec-input">Start a build from a spec</label>
3792
3792
  <textarea
@@ -3804,10 +3804,11 @@ var LokiDashboard=(()=>{var Re=Object.defineProperty;var _t=Object.getOwnPropert
3804
3804
  id="start-model-select"
3805
3805
  aria-label="Execution model for this build"
3806
3806
  ${this._startBusy?"disabled":""}>
3807
- ${t}
3807
+ ${i}
3808
3808
  </select>
3809
3809
  </div>
3810
3810
 
3811
+ ${a.length?`
3811
3812
  <div class="start-field">
3812
3813
  <label for="advisor-select">Advisor</label>
3813
3814
  <select
@@ -3815,10 +3816,10 @@ var LokiDashboard=(()=>{var Re=Object.defineProperty;var _t=Object.getOwnPropert
3815
3816
  id="advisor-select"
3816
3817
  aria-label="Advisor (code-review judge) model"
3817
3818
  ${this._startBusy?"disabled":""}>
3818
- ${a}
3819
+ ${s}
3819
3820
  </select>
3820
3821
  </div>
3821
- <div class="start-hint" id="advisor-hint">Advisor judges the code-review gate; execution stays on the model above.</div>
3822
+ <div class="start-hint" id="advisor-hint">Advisor judges the code-review gate; execution stays on the model above.</div>`:""}
3822
3823
 
3823
3824
  <button class="control-btn start" id="start-btn" aria-label="Start build" ${this._startBusy?"disabled":""}>
3824
3825
  <svg viewBox="0 0 24 24" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/></svg>
@@ -0,0 +1,124 @@
1
+ # Two-year adoption strategy
2
+
3
+ Written 2026-07-31 from measured inputs: registry download data, competitor
4
+ documentation fetched the same day, and benchmark results from this repository.
5
+ Where a claim is inference rather than measurement, it says so.
6
+
7
+ ## The one number that matters
8
+
9
+ **Floor: ~94 downloads/day. Peak: 1,263.** The 13x swing tracks our own
10
+ release activity -- eight releases landed on 07-30. A curve that rises when we
11
+ publish and falls when we stop is CI and mirrors, not word of mouth.
12
+
13
+ Organic growth is a **rising floor**. Everything below is judged against that
14
+ single number, measured on days we ship nothing.
15
+
16
+ Two years from now the question is not "how many releases did we cut." It is
17
+ "what is the floor, and does it rise when we are quiet."
18
+
19
+ ## What we actually sell, stated so it survives a demo
20
+
21
+ Competitor documentation, fetched 2026-07-31:
22
+
23
+ - **Lovable** runs a security scan on every publish and admins can block the
24
+ publish outright.
25
+ - **Claude Code** has a review step that checks findings against actual code
26
+ behavior.
27
+ - **Replit** says its agent tests its own work.
28
+
29
+ So **"we verify and they don't" is false**, and a founder demo against Lovable
30
+ would expose it. That framing is retired.
31
+
32
+ What is true and unoccupied across all seven competitors: **nobody ships a
33
+ persisted, portable, diff-bound artifact.** Theirs live in a dashboard --
34
+ Lovable's is a findings count in a dialog, Claude Code's check run is
35
+ deliberately non-blocking. Ours is a file: bound to a diff by `diff_sha256`,
36
+ recording what was NOT proven as prominently as what was, verifiable by someone
37
+ who never installed us.
38
+
39
+ That is the sentence. Portable, diff-bound, honest about gaps.
40
+
41
+ ## The second thing we sell, now measured
42
+
43
+ **The harness carries quality, not the model.** On `hard-2-ledger`, a task
44
+ authored so a bare model fails it:
45
+
46
+ | arm | result | cost |
47
+ |---|---|---|
48
+ | haiku, harness off | 1/4 passed | $0.86 |
49
+ | haiku, harness on | 1/1 passed | $0.54 |
50
+
51
+ Same model. Same prompt. The harness is the only variable. A correct
52
+ implementation cost **less** than the failing ones, because a cheap failure is
53
+ not cheap.
54
+
55
+ Caveat, stated because it will be checked: n is small and trials are still
56
+ accumulating. This demonstrates the mechanism. The rate needs more trials, and
57
+ those are now worth buying -- before this task existed, the baseline passed
58
+ everything and more trials bought precision around a ceiling.
59
+
60
+ ## Where we win, and where we should not fight
61
+
62
+ **Do not fight on:** hosted preview URLs, visual editing, zero-install browser
63
+ onboarding, managed backend primitives. Those are structural properties of a
64
+ hosted product. Lovable publishes to `[name].lovable.app` free at zero credit
65
+ balance; we cannot and should not try.
66
+
67
+ **Win on the three axes competitors structurally cannot occupy:**
68
+
69
+ 1. **Air-gapped operation.** Measured with egress severed: `version`, `doctor`,
70
+ `plan --json`, `proof verify`, `heal --assess` all return real results. No
71
+ competitor can do this -- "Devin's brain always resides within Cognition's
72
+ Cloud." For defence, government, and regulated banking this is winnable on
73
+ this axis alone. One required egress (model inference), disclosed.
74
+
75
+ 2. **In-place brownfield.** Lovable **cannot import an existing repository at
76
+ all**. Replit and Cursor import into *their* environment. For a private
77
+ monorepo with internal dependencies, that is frequently not permitted. We
78
+ run where the code already lives.
79
+
80
+ 3. **Cost per correct result.** Haiku-plus-harness beat opus-baseline in the
81
+ aggregate at roughly an eighth the cost. If that holds under more trials, it
82
+ is a procurement argument, not a benchmark curiosity.
83
+
84
+ ## The two-year sequence
85
+
86
+ **Year 1, first half -- earn the floor.** Every item judged by whether a first
87
+ run reaches a result. Ship `first_run_blocked` (done, v8.6.0), read what it
88
+ says, fix the top blocker, repeat. The floor is the scoreboard.
89
+
90
+ Concretely already done and testable: 43 of 112 commands were unreachable from
91
+ `loki help` including `loki proof`; a first-run dead end on provider-less hosts;
92
+ `loki proof md` so the receipt travels into a PR or Slack.
93
+
94
+ **Year 1, second half -- make the receipt the artefact people forward.** A
95
+ receipt in a PR is a person showing a colleague. That is the only word-of-mouth
96
+ mechanic available to a CLI, and it costs no infrastructure.
97
+
98
+ **Year 2 -- enterprise pull, not push.** Air-gapped + in-place brownfield +
99
+ signed provenance is a procurement story no competitor can match today. It sells
100
+ to the buyer who cannot use the others at all, and those buyers talk to each
101
+ other.
102
+
103
+ ## What would falsify this
104
+
105
+ Stated so it is checkable rather than reassuring:
106
+
107
+ - **The floor does not rise** over the next quarter despite first-run fixes ->
108
+ the bottleneck is not discoverability, and this plan is wrong.
109
+ - **`first_run_blocked` shows trials dying on something we did not predict** ->
110
+ follow the data, not this document.
111
+ - **The harness lift does not survive more trials** -> the cost argument
112
+ collapses and the differentiator narrows to portability alone.
113
+ - **A competitor ships a portable signed receipt** -> the wedge is gone and we
114
+ compete on cost and air-gap only.
115
+
116
+ ## What is deliberately not here
117
+
118
+ No 80-item backlog. The items that exist are the ones with a measured
119
+ mechanism. Padding this list to look comprehensive would be the fabrication
120
+ this project has already paid for once.
121
+
122
+ Release cadence is explicitly **not** a growth lever: eight releases in one day
123
+ produced a 1,263 spike and a 94 floor. If the floor is the goal, cadence is
124
+ noise.
@@ -0,0 +1,84 @@
1
+ # Adoption baseline, 2026-07-31
2
+
3
+ The first numbers in this project taken from the registry rather than from
4
+ intuition. Recorded so the next measurement has something to compare against.
5
+
6
+ ## What we actually have
7
+
8
+ **4,456 npm downloads in the last 7 days.** Daily:
9
+
10
+ | Day | Downloads |
11
+ |---|---|
12
+ | 07-24 | 863 |
13
+ | 07-25 | 1,160 |
14
+ | 07-26 | 196 |
15
+ | 07-27 | 94 |
16
+ | 07-28 | 386 |
17
+ | 07-29 | 494 |
18
+ | 07-30 | 1,263 |
19
+
20
+ Two things follow, and the second matters more than the first.
21
+
22
+ **1. We have users.** Roughly 4.5k downloads a week is not a project nobody
23
+ has heard of. Every strategy discussion in this repo has proceeded as though
24
+ adoption were hypothetical. It is not.
25
+
26
+ **2. The shape is release-driven, not organic.** The 13x swing between 07-27
27
+ (94) and 07-30 (1,263) tracks publishing activity -- eight releases landed on
28
+ 07-30 alone. A curve that rises when we publish and falls when we stop is
29
+ mirrors and CI, not word of mouth. Organic growth would show a floor that
30
+ rises over time; this shows a floor near 94.
31
+
32
+ **Do not read 4,456 as 4,456 humans.** npm counts mirrors, CI, and Docker
33
+ layer pulls. The honest statement is that the ceiling is real and the floor is
34
+ what needs to move.
35
+
36
+ ## Why the floor is the metric
37
+
38
+ The founder's goal is word-of-mouth growth over two years. The number that
39
+ measures it is the **trough**, not the peak: how many installs happen on a day
40
+ we publish nothing. Today that is ~94.
41
+
42
+ Peaks are bought with releases. Floors are earned by people telling other
43
+ people. Every adoption item should be judged against whether it moves the
44
+ floor.
45
+
46
+ ## What we still cannot see, and what changed today
47
+
48
+ Until v8.6.0 shipped this morning, we could see that a first run was ATTEMPTED
49
+ and nothing about whether it succeeded. `first_run_blocked` (v8.6.0) now names
50
+ the class of dependency that stops a first run -- enum-clamped, once per
51
+ install, strict opt-in.
52
+
53
+ That data does not exist yet: the release is hours old and the telemetry is
54
+ off by default behind a second opt-in. It will accumulate slowly and from a
55
+ minority of users, which is the correct trade for not exfiltrating anyone's
56
+ environment.
57
+
58
+ So the sequence is: floor today ~94/day -> ship things that plausibly move it
59
+ -> watch the floor, not the peak.
60
+
61
+ ## The three things measured this session that plausibly move it
62
+
63
+ Ranked by how directly they affect someone's first ten minutes:
64
+
65
+ 1. **43 of 112 commands were unreachable from `loki help`**, including
66
+ `loki proof` -- the Evidence Receipt, the thing the product argues on. Fixed
67
+ and gated. A user who cannot find the differentiator does not repeat it to
68
+ anyone.
69
+ 2. **A first-run dead end on hosts with no provider CLI.** One route named the
70
+ blockers and pointed at `loki tour` (no provider, no key, no spend); the
71
+ other said "some required prerequisites are missing" and stopped. That is
72
+ the exact moment an evaluator decides whether to continue.
73
+ 3. **`loki proof md`** puts the receipt in a form a person can paste into a PR
74
+ or a Slack message. Competitors' verification output lives in their
75
+ dashboard; a file is the only artifact that travels.
76
+
77
+ None of these is proven to move the floor. They are the candidates with a
78
+ plausible mechanism, and the floor is now being watched.
79
+
80
+ ## Reproduce
81
+
82
+ ```sh
83
+ curl -s "https://api.npmjs.org/downloads/range/last-week/loki-mode"
84
+ ```