loki-mode 8.69.0 → 8.71.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/loki +151 -2
- package/dashboard/__init__.py +1 -1
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/tools/estimate-run.py +346 -0
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: loki-mode
|
|
|
3
3
|
description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Loki Mode v8.
|
|
6
|
+
# Loki Mode v8.71.0
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
@@ -469,4 +469,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
|
|
|
469
469
|
|
|
470
470
|
---
|
|
471
471
|
|
|
472
|
-
**v8.
|
|
472
|
+
**v8.71.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
8.
|
|
1
|
+
8.71.0
|
package/autonomy/loki
CHANGED
|
@@ -4137,6 +4137,85 @@ cmd_ship() {
|
|
|
4137
4137
|
return 0
|
|
4138
4138
|
}
|
|
4139
4139
|
|
|
4140
|
+
# Per-error_class remediation, shared by BOTH cmd_why paths (human + --json).
|
|
4141
|
+
#
|
|
4142
|
+
# WHY THIS IS A SHELL VARIABLE. cmd_why runs two SEPARATE python3 heredocs, so a
|
|
4143
|
+
# helper defined in one is invisible to the other. Copy-pasting the map into
|
|
4144
|
+
# both is exactly the drift this file warns against everywhere else (see the
|
|
4145
|
+
# GUIDE / _loki_next_action "kept in lockstep" comments). Defining it once and
|
|
4146
|
+
# injecting it into both makes divergence impossible rather than merely
|
|
4147
|
+
# discouraged.
|
|
4148
|
+
#
|
|
4149
|
+
# SCOPE. The GUIDE map keys on run STATUS (council_approved / failed / ...).
|
|
4150
|
+
# This keys on the LAST_ERROR error_class -- a different, orthogonal axis. A
|
|
4151
|
+
# `failed` run got the same "read the logs" line whether the cause was a 401 or
|
|
4152
|
+
# a timeout; this is what makes the two cases differ.
|
|
4153
|
+
#
|
|
4154
|
+
# run.sh WRITES the classification (_loki_classify_iteration_error, :1857) and
|
|
4155
|
+
# injects a heal hint naming the class (:1889), but neither carries remediation
|
|
4156
|
+
# text -- so there is no second surface to mirror. This is the sole home for the
|
|
4157
|
+
# wording. If run.sh ever gains one, keep them in lockstep.
|
|
4158
|
+
#
|
|
4159
|
+
# HONESTY. last_error_action() returns None for any class not in the map --
|
|
4160
|
+
# including "unknown", which run.sh writes when it will not guess. A None means
|
|
4161
|
+
# the caller must print the recorded brief verbatim and infer NOTHING. Never add
|
|
4162
|
+
# a fallback action here: a generic string on an unknown class is a fabricated
|
|
4163
|
+
# diagnosis wearing an action's clothes.
|
|
4164
|
+
read -r -d '' _LOKI_WHY_ACTIONS_PY <<'WHYACTIONS' || true
|
|
4165
|
+
# error_class -> (action). Each is a command or env var the reader can act on.
|
|
4166
|
+
LAST_ERROR_ACTIONS = {
|
|
4167
|
+
"rate_limited":
|
|
4168
|
+
"Wait for the provider limit to reset, then re-run: loki start <spec>",
|
|
4169
|
+
"build_timeout":
|
|
4170
|
+
"Raise the per-iteration limit, or narrow the spec so one iteration does less:\n"
|
|
4171
|
+
"LOKI_ITERATION_TIMEOUT=3600 loki start <spec>",
|
|
4172
|
+
"provider_empty_output":
|
|
4173
|
+
"The provider returned nothing. Check provider health first: loki doctor\n"
|
|
4174
|
+
"then re-run. If doctor is clean, try another provider: LOKI_PROVIDER=<name> loki start <spec>",
|
|
4175
|
+
}
|
|
4176
|
+
|
|
4177
|
+
# Auth remediation is PROVIDER-SPECIFIC and the LAST_ERROR schema records no
|
|
4178
|
+
# provider. So we use LOKI_PROVIDER when it is set, and when it is not we list
|
|
4179
|
+
# every option LABELLED rather than presenting one provider's fix as the answer.
|
|
4180
|
+
PROVIDER_AUTH = {
|
|
4181
|
+
"claude": "claude login (or set ANTHROPIC_API_KEY)",
|
|
4182
|
+
"codex": "codex login (or set OPENAI_API_KEY)",
|
|
4183
|
+
"aider": "set the API key for your aider model (OPENAI_API_KEY / ANTHROPIC_API_KEY)",
|
|
4184
|
+
"cline": "re-enter the API key in Cline's provider settings",
|
|
4185
|
+
"opencode": "opencode auth login",
|
|
4186
|
+
}
|
|
4187
|
+
|
|
4188
|
+
|
|
4189
|
+
def last_error_action(rec, provider=""):
|
|
4190
|
+
"""Concrete next action for a LAST_ERROR record, or None if unrecognized.
|
|
4191
|
+
|
|
4192
|
+
None is a real answer: it means we have no mapped action and the caller must
|
|
4193
|
+
fall back to the recorded brief verbatim. Do not turn it into a default.
|
|
4194
|
+
"""
|
|
4195
|
+
if not isinstance(rec, dict):
|
|
4196
|
+
return None
|
|
4197
|
+
ec = str(rec.get("error_class") or "").strip()
|
|
4198
|
+
if ec == "auth_error":
|
|
4199
|
+
p = str(provider or "").strip().lower()
|
|
4200
|
+
if p in PROVIDER_AUTH:
|
|
4201
|
+
return "Re-authenticate %s: %s" % (p, PROVIDER_AUTH[p])
|
|
4202
|
+
lines = ["Re-authenticate the provider in use (LOKI_PROVIDER is unset, so "
|
|
4203
|
+
"the provider was not recorded):"]
|
|
4204
|
+
for _n in sorted(PROVIDER_AUTH):
|
|
4205
|
+
lines.append(" %-9s %s" % (_n, PROVIDER_AUTH[_n]))
|
|
4206
|
+
return "\n".join(lines)
|
|
4207
|
+
action = LAST_ERROR_ACTIONS.get(ec)
|
|
4208
|
+
if action is None:
|
|
4209
|
+
return None
|
|
4210
|
+
if ec == "rate_limited":
|
|
4211
|
+
# retry_after is NOT in the documented schema. Read it defensively and
|
|
4212
|
+
# print nothing when absent -- a placeholder would read as measured.
|
|
4213
|
+
_r = rec.get("retry_after", rec.get("retry_after_seconds"))
|
|
4214
|
+
if _r not in (None, ""):
|
|
4215
|
+
action += "\nProvider reported retry-after: %s" % _r
|
|
4216
|
+
return action
|
|
4217
|
+
WHYACTIONS
|
|
4218
|
+
|
|
4140
4219
|
# loki why -- actionable failure/outcome diagnosis (B5).
|
|
4141
4220
|
# Reads the already-captured run artifacts (no new state): the terminal run state
|
|
4142
4221
|
# (.loki/<autonomy-state>.json: status, lastExitCode, iterationCount), the durable
|
|
@@ -4173,8 +4252,11 @@ cmd_why() {
|
|
|
4173
4252
|
_LOKI_WHY_STATE="$state_file" _LOKI_WHY_COMPLETION="$completion_file" \
|
|
4174
4253
|
_LOKI_WHY_HEAD_SHA="$_why_json_head_sha" \
|
|
4175
4254
|
_LOKI_WHY_LAST_ERROR="$loki_dir/state/LAST_ERROR.json" \
|
|
4255
|
+
_LOKI_WHY_ACTIONS_PY="$_LOKI_WHY_ACTIONS_PY" \
|
|
4176
4256
|
_LOKI_WHY_EFFICIENCY="$loki_dir/metrics/efficiency" python3 - <<'WHYJSON'
|
|
4177
4257
|
import json, os
|
|
4258
|
+
# Shared per-class action map (single definition; see _LOKI_WHY_ACTIONS_PY).
|
|
4259
|
+
exec(os.environ.get("_LOKI_WHY_ACTIONS_PY", ""))
|
|
4178
4260
|
def load(p):
|
|
4179
4261
|
try:
|
|
4180
4262
|
with open(p) as f: return json.load(f)
|
|
@@ -4235,10 +4317,38 @@ if _effdir:
|
|
|
4235
4317
|
except Exception:
|
|
4236
4318
|
_rework = None
|
|
4237
4319
|
|
|
4320
|
+
# The machine-readable surface got the raw record and no action, so every
|
|
4321
|
+
# consumer had to re-derive the remediation and drift from the printed report.
|
|
4322
|
+
# Emit the SAME mapping the human path prints, plus an explicit honesty triple
|
|
4323
|
+
# so a consumer can distinguish the three no-action cases instead of seeing one
|
|
4324
|
+
# indistinguishable null:
|
|
4325
|
+
# present=False -> no failure was recorded (NOT "it succeeded")
|
|
4326
|
+
# present=True, readable=False-> a record exists but is malformed
|
|
4327
|
+
# recognized=False -> real record, class we have no action for
|
|
4328
|
+
_le_path = os.environ.get("_LOKI_WHY_LAST_ERROR", "")
|
|
4329
|
+
_le_present = bool(_le_path) and os.path.exists(_le_path)
|
|
4330
|
+
_le_readable = isinstance(last_error, dict) and bool(last_error)
|
|
4331
|
+
if _le_present and not _le_readable:
|
|
4332
|
+
# load() collapses unreadable and absent into {}. Re-read to tell them
|
|
4333
|
+
# apart -- reporting a malformed record as "no failure" is the exact lie
|
|
4334
|
+
# this command exists to prevent.
|
|
4335
|
+
try:
|
|
4336
|
+
with open(_le_path) as _lf:
|
|
4337
|
+
_parsed = json.load(_lf)
|
|
4338
|
+
_le_readable = isinstance(_parsed, dict)
|
|
4339
|
+
except Exception:
|
|
4340
|
+
_le_readable = False
|
|
4341
|
+
_le_action = last_error_action(last_error, os.environ.get("LOKI_PROVIDER", "")) \
|
|
4342
|
+
if _le_readable else None
|
|
4343
|
+
|
|
4238
4344
|
print(json.dumps({
|
|
4239
4345
|
"state": state,
|
|
4240
4346
|
"completion": comp,
|
|
4241
4347
|
"last_error": last_error,
|
|
4348
|
+
"last_error_present": _le_present,
|
|
4349
|
+
"last_error_readable": _le_readable,
|
|
4350
|
+
"last_error_recognized": _le_action is not None,
|
|
4351
|
+
"last_error_action": _le_action,
|
|
4242
4352
|
"completion_is_stale": comp_is_stale,
|
|
4243
4353
|
"head_sha": head_sha or None,
|
|
4244
4354
|
"rework": _rework,
|
|
@@ -4260,8 +4370,11 @@ WHYJSON
|
|
|
4260
4370
|
_LOKI_WHY_CONVERGENCE="$loki_dir/council/convergence.log" \
|
|
4261
4371
|
_LOKI_WHY_GATE="$loki_dir/signals/GATE_ESCALATION.json" \
|
|
4262
4372
|
_LOKI_WHY_TARGET="${TARGET_DIR:-$(dirname "$loki_dir")}" \
|
|
4373
|
+
_LOKI_WHY_ACTIONS_PY="$_LOKI_WHY_ACTIONS_PY" \
|
|
4263
4374
|
_LOKI_WHY_HANDOFFS="$loki_dir/memory/handoffs" python3 - <<'WHYTXT'
|
|
4264
4375
|
import json, os, glob
|
|
4376
|
+
# Shared per-class action map (single definition; see _LOKI_WHY_ACTIONS_PY).
|
|
4377
|
+
exec(os.environ.get("_LOKI_WHY_ACTIONS_PY", ""))
|
|
4265
4378
|
def load(p):
|
|
4266
4379
|
try:
|
|
4267
4380
|
with open(p) as f: return json.load(f)
|
|
@@ -4490,11 +4603,29 @@ if _unc and os.path.exists(_unc):
|
|
|
4490
4603
|
# (that would be a fake-green-adjacent lie) - using the same SUCCESS set below.
|
|
4491
4604
|
SUCCESS_STATUSES = {"council_approved", "completion_promise_fulfilled", "complete", "completed"}
|
|
4492
4605
|
le = {}
|
|
4606
|
+
_le_path = os.environ.get("_LOKI_WHY_LAST_ERROR", "")
|
|
4607
|
+
_le_bad = ""
|
|
4493
4608
|
try:
|
|
4494
|
-
with open(
|
|
4609
|
+
with open(_le_path) as f:
|
|
4495
4610
|
le = json.load(f)
|
|
4496
|
-
|
|
4611
|
+
if not isinstance(le, dict):
|
|
4612
|
+
# A bare list/string/null is not a record. Matches the isinstance guard
|
|
4613
|
+
# below rather than silently rendering an empty one.
|
|
4614
|
+
_le_bad = "record is a %s, expected a JSON object" % type(le).__name__
|
|
4615
|
+
le = {}
|
|
4616
|
+
except IOError:
|
|
4617
|
+
le = {} # absent: genuinely no recorded failure. Say nothing here.
|
|
4618
|
+
except ValueError as _e:
|
|
4619
|
+
# Present but unparseable. Reporting silence here would read as "no failure
|
|
4620
|
+
# recorded", which is a different and false claim.
|
|
4497
4621
|
le = {}
|
|
4622
|
+
_le_bad = str(_e)
|
|
4623
|
+
if _le_bad and status not in SUCCESS_STATUSES:
|
|
4624
|
+
print()
|
|
4625
|
+
print(f" Last error : a failure record exists but could not be read.")
|
|
4626
|
+
print(f" File : {_le_path}")
|
|
4627
|
+
print(f" Reason: {_le_bad}")
|
|
4628
|
+
print(f" Nothing is inferred from an unreadable record.")
|
|
4498
4629
|
if isinstance(le, dict) and le.get("error_class") and status not in SUCCESS_STATUSES:
|
|
4499
4630
|
print()
|
|
4500
4631
|
_it = le.get("iteration")
|
|
@@ -4502,6 +4633,24 @@ if isinstance(le, dict) and le.get("error_class") and status not in SUCCESS_STAT
|
|
|
4502
4633
|
print(f" Last error : {le.get('error_class')}{_it_s}")
|
|
4503
4634
|
if le.get("brief"):
|
|
4504
4635
|
print(f" {le.get('brief')}")
|
|
4636
|
+
# Per-class remediation. Naming the error_class told the user WHAT broke and
|
|
4637
|
+
# nothing about what to do; the GUIDE map above is keyed on run STATUS, so a
|
|
4638
|
+
# failed run got the same generic "read the logs" line whether the cause was
|
|
4639
|
+
# a 401 or a timeout. This maps the CLASS to its own concrete action.
|
|
4640
|
+
#
|
|
4641
|
+
# Kept in lockstep with LAST_ERROR_ACTIONS in the --json path above -- the
|
|
4642
|
+
# two must not drift (same norm as the GUIDE / _loki_next_action pair).
|
|
4643
|
+
# run.sh classifies the error but carries NO remediation wording, so this is
|
|
4644
|
+
# the sole home for these strings.
|
|
4645
|
+
_act = last_error_action(le, os.environ.get("LOKI_PROVIDER", ""))
|
|
4646
|
+
if _act:
|
|
4647
|
+
for _i, _line in enumerate(_act.splitlines()):
|
|
4648
|
+
print(f" {'What to do :' if _i == 0 else ' '} {_line}")
|
|
4649
|
+
else:
|
|
4650
|
+
# Unrecognized class: the brief above is all we honestly know. Say that
|
|
4651
|
+
# rather than emitting a generic action that implies a diagnosis.
|
|
4652
|
+
print(" (Unrecognized error class -- the description above is "
|
|
4653
|
+
"the recorded text, verbatim. No cause is inferred.)")
|
|
4505
4654
|
|
|
4506
4655
|
# Surface the latest structured handoff (already-captured context), honestly.
|
|
4507
4656
|
hd = sorted(glob.glob(os.path.join(os.environ.get("_LOKI_WHY_HANDOFFS",""), "*.md")))
|
package/dashboard/__init__.py
CHANGED
package/loki-ts/dist/loki.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
var m_=Object.create;var{getPrototypeOf:u_,defineProperty:eK,getOwnPropertyNames:p_}=Object;var d_=Object.prototype.hasOwnProperty;function c_(Z){return this[Z]}var l_,i_,a_=(Z,X,Q)=>{var Y=Z!=null&&typeof Z==="object";if(Y){var J=X?l_??=new WeakMap:i_??=new WeakMap,z=J.get(Z);if(z)return z}Q=Z!=null?m_(u_(Z)):{};let K=X||!Z||!Z.__esModule?eK(Q,"default",{value:Z,enumerable:!0}):Q;for(let $ of p_(Z))if(!d_.call(K,$))eK(K,$,{get:c_.bind(Z,$),enumerable:!0});if(Y)J.set(Z,K);return K};var HQ=(Z,X)=>()=>(X||Z((X={exports:{}}).exports,X),X.exports);var s_=(Z)=>Z;function n_(Z,X){this[Z]=s_.bind(null,X)}var l0=(Z,X)=>{for(var Q in X)eK(Z,Q,{get:X[Q],enumerable:!0,configurable:!0,set:n_.bind(X,Q)})};var p=(Z,X)=>()=>(Z&&(X=Z(Z=0)),X);var e0=import.meta.require;var kO={};l0(kO,{lokiDir:()=>j0,homeLokiDir:()=>R4,findRepoRootForVersion:()=>X$,REPO_ROOT:()=>i0});import{resolve as n7,dirname as Z$}from"path";import{fileURLToPath as o_}from"url";import{existsSync as UQ}from"fs";import{homedir as r_}from"os";function t_(){let Z=RO;for(let X=0;X<6;X++){if(UQ(n7(Z,"VERSION"))&&UQ(n7(Z,"autonomy/run.sh")))return Z;let Q=Z$(Z);if(Q===Z)break;Z=Q}return n7(RO,"..","..","..")}function X$(Z){let X=Z;for(let Q=0;Q<6;Q++){if(UQ(n7(X,"VERSION"))&&UQ(n7(X,"autonomy/run.sh")))return X;let Y=Z$(X);if(Y===X)break;X=Y}return n7(Z,"..","..","..")}function j0(){return process.env.LOKI_DIR??n7(process.cwd(),".loki")}function R4(){return n7(r_(),".loki")}var RO,i0;var H8=p(()=>{RO=Z$(o_(import.meta.url));i0=t_()});import{readFileSync as e_}from"fs";import{resolve as Zf,dirname as Xf}from"path";import{fileURLToPath as Qf}from"url";function h3(){if(h5!==null)return h5;let Z="8.
|
|
2
|
+
var m_=Object.create;var{getPrototypeOf:u_,defineProperty:eK,getOwnPropertyNames:p_}=Object;var d_=Object.prototype.hasOwnProperty;function c_(Z){return this[Z]}var l_,i_,a_=(Z,X,Q)=>{var Y=Z!=null&&typeof Z==="object";if(Y){var J=X?l_??=new WeakMap:i_??=new WeakMap,z=J.get(Z);if(z)return z}Q=Z!=null?m_(u_(Z)):{};let K=X||!Z||!Z.__esModule?eK(Q,"default",{value:Z,enumerable:!0}):Q;for(let $ of p_(Z))if(!d_.call(K,$))eK(K,$,{get:c_.bind(Z,$),enumerable:!0});if(Y)J.set(Z,K);return K};var HQ=(Z,X)=>()=>(X||Z((X={exports:{}}).exports,X),X.exports);var s_=(Z)=>Z;function n_(Z,X){this[Z]=s_.bind(null,X)}var l0=(Z,X)=>{for(var Q in X)eK(Z,Q,{get:X[Q],enumerable:!0,configurable:!0,set:n_.bind(X,Q)})};var p=(Z,X)=>()=>(Z&&(X=Z(Z=0)),X);var e0=import.meta.require;var kO={};l0(kO,{lokiDir:()=>j0,homeLokiDir:()=>R4,findRepoRootForVersion:()=>X$,REPO_ROOT:()=>i0});import{resolve as n7,dirname as Z$}from"path";import{fileURLToPath as o_}from"url";import{existsSync as UQ}from"fs";import{homedir as r_}from"os";function t_(){let Z=RO;for(let X=0;X<6;X++){if(UQ(n7(Z,"VERSION"))&&UQ(n7(Z,"autonomy/run.sh")))return Z;let Q=Z$(Z);if(Q===Z)break;Z=Q}return n7(RO,"..","..","..")}function X$(Z){let X=Z;for(let Q=0;Q<6;Q++){if(UQ(n7(X,"VERSION"))&&UQ(n7(X,"autonomy/run.sh")))return X;let Y=Z$(X);if(Y===X)break;X=Y}return n7(Z,"..","..","..")}function j0(){return process.env.LOKI_DIR??n7(process.cwd(),".loki")}function R4(){return n7(r_(),".loki")}var RO,i0;var H8=p(()=>{RO=Z$(o_(import.meta.url));i0=t_()});import{readFileSync as e_}from"fs";import{resolve as Zf,dirname as Xf}from"path";import{fileURLToPath as Qf}from"url";function h3(){if(h5!==null)return h5;let Z="8.71.0";if(typeof Z==="string"&&Z.length>0)return h5=Z,h5;try{let X=Xf(Qf(import.meta.url)),Q=X$(X);h5=e_(Zf(Q,"VERSION"),"utf-8").trim()}catch{h5="unknown"}return h5}var h5=null;var BQ=p(()=>{H8()});var bO={};l0(bO,{runOrThrow:()=>jf,run:()=>E0,readStreamCapped:()=>NQ,commandVersion:()=>Tf,commandExists:()=>X9,ShellError:()=>Q$,MAX_STDOUT_BYTES:()=>yO});async function NQ(Z,X=yO){let Q=Z.getReader(),Y=new TextDecoder,J="",z=0;try{while(z<X){let{done:K,value:$}=await Q.read();if(K)break;if(!$)continue;if(z+=$.byteLength,z>X){let W=$.byteLength-(z-X);J+=Y.decode($.subarray(0,W),{stream:!0});break}J+=Y.decode($,{stream:!0})}J+=Y.decode()}finally{try{await Q.cancel()}catch{}Q.releaseLock()}return J}async function E0(Z,X={}){let Q=Bun.spawn({cmd:[...Z],stdout:"pipe",stderr:"pipe",env:X.env?{...process.env,...X.env}:process.env,cwd:X.cwd}),Y,J;if(X.timeoutMs&&X.timeoutMs>0)Y=setTimeout(()=>{try{Q.kill("SIGTERM")}catch{}J=setTimeout(()=>{try{Q.kill("SIGKILL")}catch{}},2000)},X.timeoutMs);try{let[z,K,$]=await Promise.all([NQ(Q.stdout),new Response(Q.stderr).text(),Q.exited]);return{stdout:z,stderr:K,exitCode:$}}finally{if(Y)clearTimeout(Y);if(J)clearTimeout(J)}}async function jf(Z,X={}){let Q=await E0(Z,X);if(Q.exitCode!==0)throw new Q$(`command failed (${Q.exitCode}): ${Z.join(" ")}`,Q.exitCode,Q.stdout,Q.stderr);return Q}async function X9(Z){let X=Mf(Z),Q=await E0(["sh","-c",`command -v ${X}`],{timeoutMs:5000});if(Q.exitCode===0)return Q.stdout.trim()||null;return null}function Mf(Z){if(!/^[A-Za-z0-9._/-]+$/.test(Z))throw Error(`refused to shell-escape suspect token: ${Z}`);return Z}async function Tf(Z,X="--version"){if(!await X9(Z))return null;let Y=await E0([Z,X],{timeoutMs:5000});if(Y.exitCode!==0)return null;return((Y.stdout||Y.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var yO=16777216,Q$;var x9=p(()=>{Q$=class Q$ extends Error{message;exitCode;stdout;stderr;constructor(Z,X,Q,Y){super(Z);this.message=Z;this.exitCode=X;this.stdout=Q;this.stderr=Y;this.name="ShellError"}}});function o7(Z){return wf?"":Z}var wf,L0,F8,p0,YV0,a0,W8,Q9,v;var S6=p(()=>{wf=(process.env.NO_COLOR??"").length>0;L0=o7("\x1B[0;31m"),F8=o7("\x1B[0;32m"),p0=o7("\x1B[1;33m"),YV0=o7("\x1B[0;34m"),a0=o7("\x1B[0;36m"),W8=o7("\x1B[1m"),Q9=o7("\x1B[2m"),v=o7("\x1B[0m")});import{existsSync as bf}from"fs";async function E7(){if(x4!==void 0)return x4;let Z="/opt/homebrew/bin/python3.12";if(bf(Z))return x4=Z,Z;let X=await X9("python3.12");if(X)return x4=X,X;let Q=await X9("python3");return x4=Q,Q}async function Y7(Z,X={}){let Q=await E7();if(!Q)return{stdout:"",stderr:"python3 not found",exitCode:127};return E0([Q,"-c",Z],X)}var x4;var r7=p(()=>{x9()});var ZL={};l0(ZL,{runStatus:()=>Kh});import{existsSync as Y9,readFileSync as g3,readdirSync as iO,statSync as aO}from"fs";import{resolve as h8,basename as rf}from"path";import{homedir as tf}from"os";function sO(Z){let X=Math.trunc(Z);if(X>=1e6)return`${(Math.trunc(X/1e6*10)/10).toFixed(1)}M`;if(X>=1000)return`${(Math.trunc(X/1000*10)/10).toFixed(1)}K`;return String(X)}function nO(Z,X,Q){if(X===0)return null;let Y=Math.trunc(Z*100/X),J=Math.trunc(Z*LQ/X);if(J>LQ)J=LQ;let z=LQ-J,K=F8;if(Y>=80)K=L0;else if(Y>=50)K=p0;let $="=".repeat(Math.max(0,J))+" ".repeat(Math.max(0,z)),W=sO(Z),V=sO(X);return` ${W8}${Q}${v} ${K}[${$}]${v} ${Y}% (${W} / ${V})`}async function Zh(){if(await X9("jq"))return!0;return process.stdout.write(`${L0}Error: jq is required but not installed.${v}
|
|
3
3
|
`),process.stdout.write(`Install with:
|
|
4
4
|
`),process.stdout.write(` brew install jq (macOS)
|
|
5
5
|
`),process.stdout.write(` apt install jq (Debian/Ubuntu)
|
|
@@ -1232,4 +1232,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
|
|
|
1232
1232
|
`),2}case"start":{let{runStart:Y}=await Promise.resolve().then(() => (h_(),f_));return Y(Q)}default:return process.stderr.write(`Unknown command: ${X}
|
|
1233
1233
|
`),process.stderr.write(v_),2}}cO();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var gW0=await vW0(Bun.argv.slice(2));process.exit(gW0);
|
|
1234
1234
|
|
|
1235
|
-
//# debugId=
|
|
1235
|
+
//# debugId=C61644368E9FCF6164756E2164756E21
|
package/mcp/__init__.py
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loki-mode",
|
|
3
3
|
"mcpName": "io.github.asklokesh/loki-mode",
|
|
4
|
-
"version": "8.
|
|
4
|
+
"version": "8.71.0",
|
|
5
5
|
"description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"agent",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "loki-mode",
|
|
4
4
|
"displayName": "Loki Mode",
|
|
5
|
-
"version": "8.
|
|
5
|
+
"version": "8.71.0",
|
|
6
6
|
"description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Autonomi",
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pre-run cost estimate: what is this run likely to cost, and on what basis.
|
|
3
|
+
|
|
4
|
+
WHY THIS EXISTS. cost-summary.py answers "what did that run cost" AFTER the
|
|
5
|
+
fact. The PRD-shaped estimator behind `loki plan` answers "what will a build of
|
|
6
|
+
this PRD cost" from PRD heuristics, before any run exists. Neither answers the
|
|
7
|
+
question an operator asks when they already have history in this workspace:
|
|
8
|
+
given what iterations here have ACTUALLY cost, what is N more likely to cost.
|
|
9
|
+
|
|
10
|
+
THE HONESTY RULE THIS INHERITS. Unmeasured is not free. That confusion shipped
|
|
11
|
+
to a user on four separate surfaces (v8.51.0 through v8.54.0). This is another
|
|
12
|
+
surface reading the same records, so it obeys the same rule by importing the
|
|
13
|
+
same predicate rather than restating it:
|
|
14
|
+
|
|
15
|
+
- Zero measured records means NO BASIS. The projection is None and the render
|
|
16
|
+
says so. It never prints $0.00, because a fabricated zero is exactly the
|
|
17
|
+
defect this lineage keeps paying down, and a forward-looking $0.00 is worse
|
|
18
|
+
than a backward-looking one: it invites someone to start a run believing it
|
|
19
|
+
is free.
|
|
20
|
+
- Unmeasured records are EXCLUDED from the basis, never averaged in as zero.
|
|
21
|
+
The count that informed the estimate is always stated, so a 2-of-9 basis
|
|
22
|
+
can never be mistaken for a 9-of-9 one.
|
|
23
|
+
- A single data point is labelled as a single data point. A median of one is
|
|
24
|
+
arithmetically fine and epistemically nearly worthless; saying "median"
|
|
25
|
+
without saying "of 1" is how a guess acquires unearned authority.
|
|
26
|
+
- The output is labelled an ESTIMATE with its basis. Never a guarantee.
|
|
27
|
+
|
|
28
|
+
MEASURED IS NOT THE SAME AS PRICED. record_is_measured() is field-agnostic on
|
|
29
|
+
purpose: a record carrying real tokens but cost_usd 0 is "measured" on the
|
|
30
|
+
strength of its tokens. For a COST basis that record is useless, and averaging
|
|
31
|
+
its 0 in would drag the projection toward a fabricated low -- the headline rule
|
|
32
|
+
inverted. Real costs are stored raw (0.018719), so a sub-cent charge is 0.0001
|
|
33
|
+
and never exactly 0; an exact zero is therefore a reliable unpriced signal.
|
|
34
|
+
cost-summary.py draws the same line for the same reason. So three counts are
|
|
35
|
+
reported, not two:
|
|
36
|
+
|
|
37
|
+
found iteration-*.json records the shared reader accepted
|
|
38
|
+
measured record_is_measured() -- carried an observed value
|
|
39
|
+
priced measured AND cost_usd is non-zero -- the actual basis
|
|
40
|
+
|
|
41
|
+
WHAT THIS DELIBERATELY DOES NOT DO. It does not derive a median ITERATION COUNT.
|
|
42
|
+
One workspace's .loki/metrics/efficiency/ holds one run's iterations, so a
|
|
43
|
+
"median iteration count" over it would be a median of one sample dressed up as a
|
|
44
|
+
distribution. There is no multi-run archive to draw a real one from, so
|
|
45
|
+
--iterations is REQUIRED. Inventing the horizon and then multiplying a real
|
|
46
|
+
per-iteration cost by it would launder a guess through an honest number.
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
python3 tools/estimate-run.py --iterations 12 [WORKSPACE] [--json]
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import argparse
|
|
55
|
+
import importlib.util
|
|
56
|
+
import json
|
|
57
|
+
import os
|
|
58
|
+
import statistics
|
|
59
|
+
import sys
|
|
60
|
+
|
|
61
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
62
|
+
_REPO_ROOT = os.path.dirname(_HERE)
|
|
63
|
+
_LIB = os.path.join(_REPO_ROOT, "autonomy", "lib")
|
|
64
|
+
if _LIB not in sys.path:
|
|
65
|
+
sys.path.insert(0, _LIB)
|
|
66
|
+
|
|
67
|
+
# THE single definition of "measured". Restating it here is how the honesty
|
|
68
|
+
# rule drifts; the four surfaces that once rendered an unmeasured run as
|
|
69
|
+
# "$0.00" each had their own idea of what counted.
|
|
70
|
+
from efficiency_cost import record_is_measured # noqa: E402
|
|
71
|
+
|
|
72
|
+
# iteration_attribution.py already reads, filters and sorts the efficiency dir
|
|
73
|
+
# (skipping malformed records rather than defaulting them to zero).
|
|
74
|
+
# cost-summary.py imports it for exactly this reason: one reader of that
|
|
75
|
+
# directory. A third copy would drift the same way a second predicate would.
|
|
76
|
+
_ia_spec = importlib.util.spec_from_file_location(
|
|
77
|
+
"iteration_attribution", os.path.join(_LIB, "iteration_attribution.py"))
|
|
78
|
+
_ia = importlib.util.module_from_spec(_ia_spec)
|
|
79
|
+
_ia_spec.loader.exec_module(_ia)
|
|
80
|
+
|
|
81
|
+
# Alias-keyed table: {"pricing": {"sonnet": {"input": 3.0, "output": 15.0, ...}}}
|
|
82
|
+
# USD per 1M tokens. NOT the same schema as benchmarks/bench/prices.json (which
|
|
83
|
+
# efficiency_cost.price_from_tokens reads, keyed models.<x>.input_per_mtok), so
|
|
84
|
+
# this reads model-pricing.json directly rather than routing through it.
|
|
85
|
+
PRICING_PATH = os.path.join(
|
|
86
|
+
_REPO_ROOT, "loki-ts", "data", "model-pricing.json")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _num(v):
|
|
90
|
+
"""Non-bool int/float, else None. Never coerces junk to 0."""
|
|
91
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
|
92
|
+
return None
|
|
93
|
+
return v
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def load_pricing(path=None):
|
|
97
|
+
"""Return the alias -> rates map, or {} when unreadable.
|
|
98
|
+
|
|
99
|
+
A missing price table means we cannot quote a price, which is an honest
|
|
100
|
+
null. It never blocks the projection: observed cost comes from the recorded
|
|
101
|
+
cost_usd, not from this table.
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
with open(path or PRICING_PATH, encoding="utf-8") as handle:
|
|
105
|
+
data = json.load(handle)
|
|
106
|
+
except Exception:
|
|
107
|
+
return {}
|
|
108
|
+
pricing = data.get("pricing") if isinstance(data, dict) else None
|
|
109
|
+
return pricing if isinstance(pricing, dict) else {}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def resolve_model_now(workspace="."):
|
|
113
|
+
"""The model a run started NOW would use, and the lever that chose it.
|
|
114
|
+
|
|
115
|
+
Mirrors the runner's precedence -- a pending mid-flight override file is the
|
|
116
|
+
most specific, just-requested intent and wins over the session pin. Returns
|
|
117
|
+
(model, source); (None, None) when neither lever is set, which is an honest
|
|
118
|
+
"unresolved" rather than a guessed default. Guessing here would let the
|
|
119
|
+
report assert the projection transfers when it may not.
|
|
120
|
+
"""
|
|
121
|
+
override = os.path.join(workspace, ".loki", "state", "model-override")
|
|
122
|
+
try:
|
|
123
|
+
with open(override, encoding="utf-8") as handle:
|
|
124
|
+
val = handle.read().strip().lower()
|
|
125
|
+
if val:
|
|
126
|
+
return val, "model-override file"
|
|
127
|
+
except OSError:
|
|
128
|
+
pass
|
|
129
|
+
val = (os.environ.get("LOKI_SESSION_MODEL") or "").strip().lower()
|
|
130
|
+
if val:
|
|
131
|
+
return val, "LOKI_SESSION_MODEL"
|
|
132
|
+
return None, None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def estimate(workspace=".", iterations=None, pricing_path=None):
|
|
136
|
+
"""Build the estimate dict. Pure derivation, no guessing."""
|
|
137
|
+
loki_dir = os.path.join(workspace, ".loki")
|
|
138
|
+
recs = _ia._iteration_records(loki_dir)
|
|
139
|
+
|
|
140
|
+
found = len(recs)
|
|
141
|
+
measured = 0
|
|
142
|
+
cost_points = [] # priced costs only -- THE basis
|
|
143
|
+
basis_models = [] # models of the priced records, in order
|
|
144
|
+
|
|
145
|
+
for rec in recs:
|
|
146
|
+
if not record_is_measured(rec):
|
|
147
|
+
# EXCLUDED, not added as zero. Averaging an unmeasured iteration in
|
|
148
|
+
# as 0 is indistinguishable from a real measurement of 0.
|
|
149
|
+
continue
|
|
150
|
+
measured += 1
|
|
151
|
+
usd = _num(rec.get("cost_usd"))
|
|
152
|
+
# Measured on tokens but unpriced: real for token accounting, useless
|
|
153
|
+
# for a cost basis, and a fabricated low if averaged in. See module
|
|
154
|
+
# docstring.
|
|
155
|
+
if usd is None or usd == 0:
|
|
156
|
+
continue
|
|
157
|
+
cost_points.append(float(usd))
|
|
158
|
+
model = rec.get("model")
|
|
159
|
+
basis_models.append(str(model) if model else "")
|
|
160
|
+
|
|
161
|
+
# THE HONESTY GUARD. No priced history means no basis, so every downstream
|
|
162
|
+
# number is None and the render says why. Turning this into 0.0 is the
|
|
163
|
+
# "unmeasured becomes free" defect, pointed at the future.
|
|
164
|
+
median_usd = None if not cost_points else statistics.median(cost_points)
|
|
165
|
+
|
|
166
|
+
known_models = sorted({m for m in basis_models if m})
|
|
167
|
+
model_now, model_source = resolve_model_now(workspace)
|
|
168
|
+
rates = load_pricing(pricing_path).get(model_now) if model_now else None
|
|
169
|
+
|
|
170
|
+
out = {
|
|
171
|
+
"workspace": os.path.abspath(workspace),
|
|
172
|
+
"label": "ESTIMATE",
|
|
173
|
+
"iterations_found": found,
|
|
174
|
+
"iterations_measured": measured,
|
|
175
|
+
"iterations_priced": len(cost_points),
|
|
176
|
+
"basis_count": len(cost_points),
|
|
177
|
+
"has_basis": bool(cost_points),
|
|
178
|
+
"single_point_basis": len(cost_points) == 1,
|
|
179
|
+
"median_cost_per_iteration_usd": (
|
|
180
|
+
None if median_usd is None else round(median_usd, 4)),
|
|
181
|
+
"min_cost_per_iteration_usd": (
|
|
182
|
+
round(min(cost_points), 4) if cost_points else None),
|
|
183
|
+
"max_cost_per_iteration_usd": (
|
|
184
|
+
round(max(cost_points), 4) if cost_points else None),
|
|
185
|
+
"iterations_projected": iterations,
|
|
186
|
+
"projected_cost_usd": (
|
|
187
|
+
round(median_usd * iterations, 4)
|
|
188
|
+
if median_usd is not None and iterations else None),
|
|
189
|
+
"basis_models": known_models,
|
|
190
|
+
"model_now": model_now,
|
|
191
|
+
"model_now_source": model_source,
|
|
192
|
+
"model_now_price_per_mtok": (
|
|
193
|
+
{"input": rates.get("input"), "output": rates.get("output"),
|
|
194
|
+
"cache_read": rates.get("cache_read")}
|
|
195
|
+
if isinstance(rates, dict) else None),
|
|
196
|
+
"projection_transfers": None,
|
|
197
|
+
"notes": [],
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
n = out["notes"]
|
|
201
|
+
|
|
202
|
+
if found == 0:
|
|
203
|
+
n.append(
|
|
204
|
+
"no iteration records found in this workspace: there is NO history "
|
|
205
|
+
"to project from, so no cost is estimated (not $0.00)")
|
|
206
|
+
elif not cost_points:
|
|
207
|
+
n.append(
|
|
208
|
+
"no measured, priced iteration in %d record(s): there is NO basis "
|
|
209
|
+
"to project from, so no cost is estimated (not $0.00)" % found)
|
|
210
|
+
if measured:
|
|
211
|
+
n.append(
|
|
212
|
+
"%d of %d records carried tokens but no cost (unpriced model): "
|
|
213
|
+
"spend is unknown rather than zero, so they cannot form a basis"
|
|
214
|
+
% (measured - len(cost_points), measured))
|
|
215
|
+
else:
|
|
216
|
+
n.append(
|
|
217
|
+
"ESTIMATE based on %d measured, priced iteration(s) of %d found -- "
|
|
218
|
+
"not a guarantee" % (len(cost_points), found))
|
|
219
|
+
if len(cost_points) == 1:
|
|
220
|
+
n.append(
|
|
221
|
+
"the basis is a SINGLE data point: this is one observation "
|
|
222
|
+
"extrapolated, not a distribution, and the range is that one "
|
|
223
|
+
"point")
|
|
224
|
+
if len(cost_points) < found:
|
|
225
|
+
n.append(
|
|
226
|
+
"PARTIAL: %d of %d records did not inform the estimate "
|
|
227
|
+
"(unmeasured or unpriced), and were excluded rather than "
|
|
228
|
+
"counted as zero" % (found - len(cost_points), found))
|
|
229
|
+
if iterations is None:
|
|
230
|
+
n.append(
|
|
231
|
+
"no --iterations given and no multi-run history exists to "
|
|
232
|
+
"derive a median iteration count from: pass --iterations N for "
|
|
233
|
+
"a projection")
|
|
234
|
+
|
|
235
|
+
# MODEL TRANSFER. Naming the model is not enough -- if history was priced on
|
|
236
|
+
# a different model than the one that would run now, the per-iteration
|
|
237
|
+
# median does not carry over, and saying so is the difference between an
|
|
238
|
+
# estimate and a misleading one.
|
|
239
|
+
if cost_points:
|
|
240
|
+
if len(known_models) > 1:
|
|
241
|
+
out["projection_transfers"] = False
|
|
242
|
+
n.append(
|
|
243
|
+
"the basis MIXES models (%s): a single median across different "
|
|
244
|
+
"price points may not transfer to either" % ", ".join(known_models))
|
|
245
|
+
elif not known_models:
|
|
246
|
+
out["projection_transfers"] = None
|
|
247
|
+
n.append(
|
|
248
|
+
"the basis records name no model: whether this projection "
|
|
249
|
+
"transfers to the model that would run now is unknown")
|
|
250
|
+
elif model_now is None:
|
|
251
|
+
out["projection_transfers"] = None
|
|
252
|
+
n.append(
|
|
253
|
+
"basis model is %s; no model is pinned for a run now "
|
|
254
|
+
"(LOKI_SESSION_MODEL unset, no override file), so whether the "
|
|
255
|
+
"projection transfers is unknown" % known_models[0])
|
|
256
|
+
elif model_now != known_models[0]:
|
|
257
|
+
out["projection_transfers"] = False
|
|
258
|
+
n.append(
|
|
259
|
+
"basis model is %s but a run now would use %s (%s): this "
|
|
260
|
+
"projection MAY NOT TRANSFER" % (
|
|
261
|
+
known_models[0], model_now, model_source))
|
|
262
|
+
else:
|
|
263
|
+
out["projection_transfers"] = True
|
|
264
|
+
|
|
265
|
+
if model_now and rates is None:
|
|
266
|
+
n.append(
|
|
267
|
+
"no price listed for %s in the pricing table: its rate is not "
|
|
268
|
+
"quoted (the projection still comes from observed cost, not price)"
|
|
269
|
+
% model_now)
|
|
270
|
+
|
|
271
|
+
return out
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _fmt_usd(v):
|
|
275
|
+
"""UNKNOWN, never $0.00, when there is nothing to report."""
|
|
276
|
+
return "UNKNOWN" if v is None else "$%.4f" % v
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def render(est):
|
|
280
|
+
"""Human-readable report. The honesty lives here too, not only in the dict."""
|
|
281
|
+
lines = []
|
|
282
|
+
lines.append("Run cost ESTIMATE -- %s" % est["workspace"])
|
|
283
|
+
lines.append("")
|
|
284
|
+
|
|
285
|
+
if not est["has_basis"]:
|
|
286
|
+
lines.append(" NO BASIS: no measured, priced iteration to project from.")
|
|
287
|
+
lines.append(" Cost per iteration: UNKNOWN")
|
|
288
|
+
lines.append(" Projected cost: UNKNOWN")
|
|
289
|
+
lines.append(" Records found: %d measured: %d priced: %d"
|
|
290
|
+
% (est["iterations_found"], est["iterations_measured"],
|
|
291
|
+
est["iterations_priced"]))
|
|
292
|
+
else:
|
|
293
|
+
lines.append(" Basis: %d measured, priced iteration(s) "
|
|
294
|
+
"of %d found" % (est["basis_count"], est["iterations_found"]))
|
|
295
|
+
lines.append(" Cost per iteration: median %s (range %s - %s)" % (
|
|
296
|
+
_fmt_usd(est["median_cost_per_iteration_usd"]),
|
|
297
|
+
_fmt_usd(est["min_cost_per_iteration_usd"]),
|
|
298
|
+
_fmt_usd(est["max_cost_per_iteration_usd"])))
|
|
299
|
+
if est["iterations_projected"]:
|
|
300
|
+
label = "Projected for %d:" % est["iterations_projected"]
|
|
301
|
+
lines.append(" %-20s %s"
|
|
302
|
+
% (label, _fmt_usd(est["projected_cost_usd"])))
|
|
303
|
+
else:
|
|
304
|
+
lines.append(" Projected cost: UNKNOWN (pass --iterations N)")
|
|
305
|
+
|
|
306
|
+
basis_models = est["basis_models"]
|
|
307
|
+
lines.append(" Basis model(s): %s"
|
|
308
|
+
% (", ".join(basis_models) if basis_models else "not recorded"))
|
|
309
|
+
if est["model_now"]:
|
|
310
|
+
rate = est["model_now_price_per_mtok"]
|
|
311
|
+
price = ("not in pricing table" if not rate else
|
|
312
|
+
"$%s in / $%s out per Mtok" % (rate["input"], rate["output"]))
|
|
313
|
+
lines.append(" Model now: %s (via %s) -- %s"
|
|
314
|
+
% (est["model_now"], est["model_now_source"], price))
|
|
315
|
+
else:
|
|
316
|
+
lines.append(" Model now: not pinned")
|
|
317
|
+
|
|
318
|
+
lines.append("")
|
|
319
|
+
for note in est["notes"]:
|
|
320
|
+
lines.append(" - %s" % note)
|
|
321
|
+
return "\n".join(lines)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def main(argv=None):
|
|
325
|
+
ap = argparse.ArgumentParser(
|
|
326
|
+
description="Estimate what a run is likely to cost, from measured history.")
|
|
327
|
+
ap.add_argument("workspace", nargs="?", default=".")
|
|
328
|
+
ap.add_argument("--iterations", type=int, default=None,
|
|
329
|
+
help="how many iterations to project (no multi-run history "
|
|
330
|
+
"exists to derive this, so it is required for a "
|
|
331
|
+
"projected total)")
|
|
332
|
+
ap.add_argument("--json", action="store_true")
|
|
333
|
+
args = ap.parse_args(argv)
|
|
334
|
+
|
|
335
|
+
est = estimate(args.workspace, args.iterations)
|
|
336
|
+
if args.json:
|
|
337
|
+
print(json.dumps(est, indent=2))
|
|
338
|
+
else:
|
|
339
|
+
print(render(est))
|
|
340
|
+
# Exit 0 either way: "no basis" is a successful, honest answer, not a tool
|
|
341
|
+
# failure. Callers read has_basis.
|
|
342
|
+
return 0
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
if __name__ == "__main__":
|
|
346
|
+
sys.exit(main())
|