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.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/deadline.py +38 -4
- package/autonomy/loki +195 -3
- package/autonomy/run.sh +181 -29
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +207 -19
- package/dashboard/static/index.html +10 -9
- package/docs/STRATEGY-2026-2028.md +124 -0
- package/docs/adoption-baseline-2026-07-31.md +84 -0
- package/docs/environment-variables.md +36 -4
- package/loki-ts/dist/loki.js +424 -414
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/aider.sh +35 -3
- package/providers/claude.sh +12 -1
- package/providers/cline.sh +26 -3
- package/providers/model_catalog.json +17 -6
- package/providers/models.sh +58 -3
- package/providers/opencode.sh +8 -2
- package/references/multi-provider.md +9 -0
- package/skills/providers.md +35 -1
package/dashboard/__init__.py
CHANGED
package/dashboard/server.py
CHANGED
|
@@ -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
|
|
2816
|
+
"""Normalize a start-time model / advisor alias.
|
|
2808
2817
|
|
|
2809
|
-
|
|
2810
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
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 (
|
|
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
|
-
${
|
|
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
|
-
${
|
|
3786
|
-
<div class="model-disclosure"
|
|
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
|
|
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
|
-
${
|
|
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
|
-
${
|
|
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
|
+
```
|