loki-mode 9.3.0 → 9.5.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 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 v9.3.0
6
+ # Loki Mode v9.5.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
- **v9.3.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
472
+ **v9.5.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 9.3.0
1
+ 9.5.0
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "9.3.0"
10
+ __version__ = "9.5.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -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="9.3.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,zV0,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"),zV0=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}
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="9.5.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,zV0,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"),zV0=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 uW0=await mW0(Bun.argv.slice(2));process.exit(uW0);
1234
1234
 
1235
- //# debugId=869F9122829FD31C64756E2164756E21
1235
+ //# debugId=22E8830AAAE92CCB64756E2164756E21
package/mcp/__init__.py CHANGED
@@ -75,4 +75,4 @@ try:
75
75
  except ImportError:
76
76
  __all__ = ['mcp']
77
77
 
78
- __version__ = '9.3.0'
78
+ __version__ = '9.5.0'
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": "9.3.0",
4
+ "version": "9.5.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": "9.3.0",
5
+ "version": "9.5.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,394 @@
1
+ #!/usr/bin/env python3
2
+ """Where did a run's cost and time actually GO, stage by stage.
3
+
4
+ run-replay.py reconstructs a run along the ITERATION axis: what happened in
5
+ iteration 3, did cost climb, did a gate fail twice. That is the right axis for
6
+ debugging one bad run and the wrong axis for the question a team asks after
7
+ paying the bill, which is about STAGES: is the money going to the agent, or to
8
+ code review, or to a doc-generation step nobody reads.
9
+
10
+ This reads only what a run already wrote -- .loki/events.jsonl and
11
+ .loki/metrics/efficiency/iteration-*.json. It starts nothing and spends
12
+ nothing.
13
+
14
+ THE REFUSAL THAT IS THE WHOLE POINT OF THIS TOOL:
15
+
16
+ THERE IS NO PER-STAGE COST IN THE ARTIFACTS, SO NONE IS REPORTED.
17
+
18
+ A stage_complete record carries a stage name, a duration and an iteration. An
19
+ efficiency record carries dollars for a whole ITERATION. Nothing anywhere
20
+ attributes a dollar to a stage. So the per-stage cost column here is a
21
+ sentence, not a number.
22
+
23
+ The tempting fix is not even dividing the iteration's cost evenly across its
24
+ stages -- that is obviously invented and nobody would ship it. The tempting
25
+ fix is weighting by duration: cost_usd * (stage_seconds / iteration_seconds).
26
+ That is the SAME invention wearing a defensible-looking coat, and it is worse
27
+ precisely because a reader will believe it. A stage that burns thirty seconds
28
+ of wall clock spawning a linter costs nothing in tokens; a stage that spends
29
+ ten seconds streaming a huge completion costs most of the iteration. Wall
30
+ clock is not a cost proxy, and presenting it as one manufactures a fact the
31
+ run never recorded.
32
+
33
+ So the axes are reported separately and honestly:
34
+
35
+ TIME per stage measured, summed over the iterations that recorded it
36
+ COST per iteration measured, with unmeasured iterations EXCLUDED
37
+ the largest contributor is named on each axis, and labelled with which one
38
+
39
+ THE OTHER HONESTY RULES, each of which this repo has paid for:
40
+
41
+ 1. An iteration with no efficiency record reads "cost not recorded" and is
42
+ EXCLUDED from the total. Never summed as 0. A run of four iterations
43
+ where two were never measured must not report the two measured ones as
44
+ the whole bill -- so the total says how many of how many it covers.
45
+ 2. A stage that never emitted stage_complete reads "not recorded", never
46
+ 0s. 0s reads as "instant", a different claim. A genuine duration_s of 0
47
+ from a fast gate IS real data and survives as 0.
48
+ 3. A corrupt event line is COUNTED and reported. A tool that silently skips
49
+ bad lines reports a cleaner run than happened, and drops them most often
50
+ when something upstream is broken.
51
+ 4. Empty or missing events exits non-zero, EVEN IF the efficiency records
52
+ survived. An empty-looking report certifies a run that never happened,
53
+ and a cost table printed under the heading "cost attribution" when the
54
+ stage axis was never read over-claims its own subject.
55
+
56
+ Usage:
57
+ tools/cost-attribute.py [workspace] [--json]
58
+
59
+ Exit: 0 report produced, 3 events present but nothing parseable to attribute,
60
+ 66 no events file at all, 64 usage error.
61
+ """
62
+
63
+ import argparse
64
+ import json
65
+ import os
66
+ import sys
67
+
68
+ sys.dont_write_bytecode = True
69
+
70
+ _HERE = os.path.dirname(os.path.abspath(__file__))
71
+ _REPO_ROOT = os.path.dirname(_HERE)
72
+ # Resolved from __file__, never from the workspace argument: the workspace
73
+ # being read is a different tree and has no autonomy/lib.
74
+ sys.path.insert(0, os.path.join(_REPO_ROOT, "autonomy", "lib"))
75
+
76
+ from efficiency_cost import record_is_measured # noqa: E402
77
+
78
+ OK, NOTHING_TO_CHECK, USAGE, NO_INPUT = 0, 3, 64, 66
79
+
80
+
81
+ class _Parser(argparse.ArgumentParser):
82
+ """argparse exits 2 on a usage error, and 2 here means "could not check".
83
+
84
+ Those are opposite facts. "You typed the flag wrong" and "the instrument
85
+ is blind" must not share an exit code, because a CI job branching on 2
86
+ would treat a typo as a broken measurement and carry on.
87
+ """
88
+
89
+ def error(self, message):
90
+ self.print_usage(sys.stderr)
91
+ print("%s: error: %s" % (self.prog, message), file=sys.stderr)
92
+ raise SystemExit(USAGE)
93
+
94
+
95
+ def _iter_key(value):
96
+ """Iteration number as an int, or None when unusable."""
97
+ if isinstance(value, bool):
98
+ return None
99
+ if isinstance(value, int):
100
+ return value
101
+ if isinstance(value, float):
102
+ return int(value)
103
+ try:
104
+ return int(str(value).strip())
105
+ except Exception:
106
+ return None
107
+
108
+
109
+ def load_events(path):
110
+ """Return (events, corrupt_line_count).
111
+
112
+ The count is a deliverable, not a debug aid: it is the only evidence that
113
+ this report is reading a whole recording.
114
+ """
115
+ events = []
116
+ corrupt = 0
117
+ with open(path, errors="replace") as fh:
118
+ for line in fh:
119
+ if not line.strip():
120
+ continue
121
+ try:
122
+ e = json.loads(line)
123
+ except Exception:
124
+ corrupt += 1
125
+ continue
126
+ if not isinstance(e, dict):
127
+ # A bare JSON scalar parses fine but is not a record.
128
+ corrupt = corrupt + 1
129
+ continue
130
+ events.append(e)
131
+ return events, corrupt
132
+
133
+
134
+ def iteration_cost(workspace, iteration):
135
+ """One iteration's measured USD, or None when it was never measured.
136
+
137
+ Read per FILE rather than via collect_efficiency(), which sums the whole
138
+ directory into one aggregate and so cannot say WHICH iterations were
139
+ measured. record_is_measured() is the single shared rule and is applied
140
+ unchanged; a second copy of that predicate is how the honesty rule drifts.
141
+ """
142
+ path = os.path.join(
143
+ workspace, ".loki", "metrics", "efficiency",
144
+ "iteration-%d.json" % iteration,
145
+ )
146
+ try:
147
+ with open(path) as fh:
148
+ rec = json.load(fh)
149
+ except Exception:
150
+ return None
151
+ if not isinstance(rec, dict) or not record_is_measured(rec):
152
+ return None
153
+ usd = rec.get("cost_usd")
154
+ if isinstance(usd, bool) or not isinstance(usd, (int, float)):
155
+ # Tokens were measured but no dollar figure was priced. That is a
156
+ # measured run with an unmeasured COST, and this tool sums dollars.
157
+ return None
158
+ return float(usd)
159
+
160
+
161
+ def attribute(workspace):
162
+ """Build the attribution report. Returns (report dict, exit code)."""
163
+ events_path = os.path.join(workspace, ".loki", "events.jsonl")
164
+ if not os.path.isfile(events_path):
165
+ return {
166
+ "status": "no_events",
167
+ "workspace": os.path.abspath(workspace),
168
+ "why": "no events at %s -- pass the workspace directory of a "
169
+ "completed run" % events_path,
170
+ }, NO_INPUT
171
+
172
+ events, corrupt = load_events(events_path)
173
+
174
+ # stage_seconds[stage] = [durations...]; iterations that recorded it.
175
+ stage_seconds = {}
176
+ stage_iterations = {}
177
+ seen_iterations = set()
178
+ for e in events:
179
+ data = e.get("data") if isinstance(e.get("data"), dict) else {}
180
+ it = _iter_key(data.get("iteration"))
181
+ if it is not None:
182
+ seen_iterations.add(it)
183
+ if (e.get("type") or e.get("event")) != "stage_complete":
184
+ continue
185
+ name = data.get("stage")
186
+ secs = data.get("duration_s")
187
+ if it is None or not name:
188
+ continue
189
+ # isinstance guard, not truthiness: duration_s of 0 is REAL data from
190
+ # a fast gate. Filtering on falsy would turn a genuine 0 into "not
191
+ # recorded" and break the absent-stage rule the other way round.
192
+ if isinstance(secs, bool) or not isinstance(secs, (int, float)):
193
+ continue
194
+ stage_seconds.setdefault(str(name), []).append(float(secs))
195
+ stage_iterations.setdefault(str(name), set()).add(it)
196
+
197
+ # The efficiency directory is a SECOND, independent witness to which
198
+ # iterations existed. An events.jsonl truncated at a line boundary parses
199
+ # clean, so the corrupt count is 0 while a whole iteration is missing.
200
+ try:
201
+ for name in os.listdir(os.path.join(
202
+ workspace, ".loki", "metrics", "efficiency")):
203
+ if name.startswith("iteration-") and name.endswith(".json"):
204
+ n = _iter_key(name[len("iteration-"):-len(".json")])
205
+ if n is not None:
206
+ seen_iterations.add(n)
207
+ except Exception:
208
+ pass
209
+
210
+ # Keyed on PARSEABLE EVENTS, not on the union with the efficiency scan.
211
+ # Those records are a second witness to which iterations existed, and they
212
+ # are deliberately NOT enough to call this a report: the deliverable here
213
+ # is the STAGE axis, and stage records live only in events.jsonl. A
214
+ # workspace whose events file is empty or entirely corrupt, but whose
215
+ # efficiency records survive, would otherwise print a confident cost table
216
+ # under the heading "cost attribution" while the thing being attributed TO
217
+ # was never read. That is a report over-claiming its own subject, and it
218
+ # exits non-zero instead. cost-history.py and run-replay.py remain the
219
+ # right tools when only the cost axis is wanted.
220
+ if not events:
221
+ return {
222
+ "status": "nothing_to_attribute",
223
+ "workspace": os.path.abspath(workspace),
224
+ "corrupt_lines": corrupt,
225
+ "why": "%s has no parseable event records (%d corrupt line(s) "
226
+ "counted) -- there is no stage axis to attribute to, so "
227
+ "no attribution is reported even if efficiency records "
228
+ "survived" % (events_path, corrupt),
229
+ }, NOTHING_TO_CHECK
230
+
231
+ stages = []
232
+ for name in sorted(stage_seconds):
233
+ secs = stage_seconds[name]
234
+ stages.append({
235
+ "stage": name,
236
+ "total_s": round(sum(secs), 1),
237
+ "iterations_recorded": len(stage_iterations[name]),
238
+ "recorded": True,
239
+ })
240
+
241
+ # Costs, per iteration. An unmeasured one carries None and is kept in the
242
+ # list so the report can say how much of the run the total covers.
243
+ per_iteration = []
244
+ for n in sorted(seen_iterations):
245
+ per_iteration.append({"iteration": n,
246
+ "usd": iteration_cost(workspace, n)})
247
+
248
+ costs = [it["usd"] for it in per_iteration if it["usd"] is not None]
249
+ # `if costs else` would be a falsy test on the LIST, which is correct
250
+ # (an empty list is the only falsy list) -- but spelled explicitly so a
251
+ # later edit cannot quietly turn it into a test on the SUM, where a
252
+ # genuine total of 0.0 would flip to UNKNOWN.
253
+ total = round(sum(costs), 6) if len(costs) > 0 else None
254
+
255
+ slowest = None
256
+ if stages:
257
+ top = max(stages, key=lambda s: s["total_s"])
258
+ slowest = {"stage": top["stage"], "total_s": top["total_s"]}
259
+
260
+ # `if measured_iters`, not `if costs`: a run measured at exactly $0.00 is
261
+ # real data and still has a largest contributor. A falsy check would drop
262
+ # it, which is the measured-zero rule failing in the other direction. The
263
+ # list is also the SAME sequence `costs` came from, so this cannot be
264
+ # non-empty while that generator is empty.
265
+ measured_iters = [it for it in per_iteration if it["usd"] is not None]
266
+ priciest = None
267
+ if measured_iters:
268
+ top = max(measured_iters, key=lambda it: it["usd"])
269
+ priciest = {"iteration": top["iteration"], "usd": round(top["usd"], 6)}
270
+
271
+ return {
272
+ "status": "ok",
273
+ "workspace": os.path.abspath(workspace),
274
+ "read_only": True,
275
+ "corrupt_lines": corrupt,
276
+ "stages": stages,
277
+ "per_iteration_cost": per_iteration,
278
+ "measured_iterations": len(costs),
279
+ "total_iterations": len(per_iteration),
280
+ "total_measured_usd": total,
281
+ "slowest_stage": slowest,
282
+ "largest_cost_iteration": priciest,
283
+ "cost_per_stage": None,
284
+ "cost_per_stage_why": (
285
+ "REFUSED. events.jsonl records a stage's DURATION and the "
286
+ "efficiency records price a whole ITERATION; no artifact "
287
+ "attributes a dollar to a stage. Splitting the iteration cost by "
288
+ "each stage's share of wall clock would look like a measurement "
289
+ "and would not be one -- wall clock is not a token proxy. Time "
290
+ "per stage below is measured; cost per stage was never recorded."
291
+ ),
292
+ }, OK
293
+
294
+
295
+ def render(rep):
296
+ if rep["status"] == "no_events":
297
+ return "NO DATA: %s" % rep["why"]
298
+ if rep["status"] == "nothing_to_attribute":
299
+ return "NOTHING TO ATTRIBUTE: %s" % rep["why"]
300
+
301
+ out = ["COST ATTRIBUTION -- read from artifacts only; nothing was started "
302
+ "and nothing was spent.",
303
+ " workspace: %s" % rep["workspace"]]
304
+ if rep["corrupt_lines"]:
305
+ out.append(" CORRUPT LINES: %d (counted, not dropped -- this report "
306
+ "is reading an incomplete recording)"
307
+ % rep["corrupt_lines"])
308
+ else:
309
+ out.append(" corrupt lines: 0 (whole recording read)")
310
+ out.append("")
311
+
312
+ out.append("TIME PER STAGE (measured)")
313
+ out.append("-" * 60)
314
+ if rep["stages"]:
315
+ for s in rep["stages"]:
316
+ out.append(" %-24s %8.1fs over %d iteration(s)"
317
+ % (s["stage"], s["total_s"], s["iterations_recorded"]))
318
+ else:
319
+ out.append(" no stage emitted stage_complete -- stage timing not "
320
+ "recorded for this run (not 0s, which would mean instant)")
321
+ out.append("")
322
+
323
+ out.append("COST PER ITERATION (measured)")
324
+ out.append("-" * 60)
325
+ for it in rep["per_iteration_cost"]:
326
+ if it["usd"] is None:
327
+ out.append(" iteration %-14d cost not recorded (excluded from "
328
+ "the total)" % it["iteration"])
329
+ else:
330
+ out.append(" iteration %-14d $%.4f" % (it["iteration"], it["usd"]))
331
+ out.append("")
332
+
333
+ out.append("COST PER STAGE")
334
+ out.append("-" * 60)
335
+ for chunk in rep["cost_per_stage_why"].split(" -- "):
336
+ out.append(" %s" % chunk)
337
+ out.append("")
338
+
339
+ out.append("=" * 60)
340
+ if rep["slowest_stage"]:
341
+ out.append("largest time contributor : %s (%.1fs across the run)"
342
+ % (rep["slowest_stage"]["stage"],
343
+ rep["slowest_stage"]["total_s"]))
344
+ else:
345
+ out.append("largest time contributor : not recorded, no stage "
346
+ "durations in this run")
347
+
348
+ if rep["total_measured_usd"] is None:
349
+ out.append("largest cost contributor : cost not recorded for any of "
350
+ "the %d iteration(s)" % rep["total_iterations"])
351
+ out.append("total measured cost : UNKNOWN (0 of %d iterations "
352
+ "measured; unmeasured is not free)"
353
+ % rep["total_iterations"])
354
+ else:
355
+ out.append("largest cost contributor : iteration %d at $%.4f"
356
+ % (rep["largest_cost_iteration"]["iteration"],
357
+ rep["largest_cost_iteration"]["usd"]))
358
+ out.append("total measured cost : $%.4f (%d of %d iterations "
359
+ "measured)"
360
+ % (rep["total_measured_usd"], rep["measured_iterations"],
361
+ rep["total_iterations"]))
362
+ if rep["measured_iterations"] < rep["total_iterations"]:
363
+ # A claim over a subset must say it is a claim over a subset.
364
+ out.append(" the %d unmeasured "
365
+ "iteration(s) are excluded, not counted as free, so "
366
+ "the real bill is HIGHER than this."
367
+ % (rep["total_iterations"]
368
+ - rep["measured_iterations"]))
369
+ return "\n".join(out)
370
+
371
+
372
+ def main(argv=None):
373
+ ap = _Parser(
374
+ description="Attribute a run's cost and time to where they went, "
375
+ "per stage. Reads only; starts nothing, spends nothing.")
376
+ ap.add_argument("workspace", nargs="?", default=".",
377
+ help="workspace root containing .loki/ (default .)")
378
+ ap.add_argument("--json", action="store_true", dest="as_json",
379
+ help="emit the report as JSON")
380
+ args = ap.parse_args(argv)
381
+
382
+ rep, code = attribute(args.workspace)
383
+ if args.as_json:
384
+ # A structured error is still structured output. The caller asked for
385
+ # machine-readable and must not get a bare text line on the failure
386
+ # path; the exit code carries the verdict either way.
387
+ print(json.dumps(rep, indent=2))
388
+ else:
389
+ print(render(rep), file=sys.stderr if code else sys.stdout)
390
+ return code
391
+
392
+
393
+ if __name__ == "__main__":
394
+ sys.exit(main())
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env python3
2
+ """Render a ci-gate verdict as a shields.io badge, without laundering it.
3
+
4
+ WHY THIS EXISTS. `ci-gate.py --json` already emits an honest three-state
5
+ verdict, but nobody reads a JSON body on a README. They read a badge, at a
6
+ glance, and act on the colour alone. That glance is the last place the
7
+ three-state distinction can be lost, and it is the easiest place to lose it:
8
+ shields.io has exactly two colours everyone reaches for, and a two-colour
9
+ rendering of a three-state verdict has to fold one state into another.
10
+
11
+ Folding UNEVALUABLE into PASS is the failure this file exists to prevent. It
12
+ puts a green badge on a README at exactly the moment the gate went blind --
13
+ the loudest possible reassurance backed by the least possible evidence. So
14
+ UNEVALUABLE gets its own colour, and a test asserts that colour is neither in
15
+ the green family nor equal to whatever the pass path renders.
16
+
17
+ THREE RULES THIS ENFORCES BEYOND COLOUR:
18
+
19
+ Never invent a verdict. Empty stdin and malformed stdin are not states of
20
+ the gate, they are the absence of a reading. Both exit 2 and render an
21
+ "input" badge. Neither can render a pass.
22
+
23
+ Trust nothing the input contradicts. The payload carries `state` (which
24
+ picks the colour) and `exit_code` (which this process re-raises). If they
25
+ disagree -- UNEVALUABLE alongside exit 0 -- the payload is not a verdict
26
+ this tool recognises, and it is rejected rather than half-believed.
27
+
28
+ A measured zero survives. `exit_code` 0 is the PASS code, and it is falsy.
29
+ `payload.get("exit_code") or UNEVALUABLE` would silently convert every
30
+ passing gate into a blind one. Read it with `is None`, never truthiness.
31
+
32
+ Usage:
33
+ tools/ci-gate.py <ws> --max-usd 5 --json | tools/gate-badge.py [--label NAME]
34
+
35
+ Exit: mirrors the input verdict (0 pass, 1 failed, 2 unevaluable), so a shell
36
+ pipe cannot turn a failure into a success. 64 on a usage error.
37
+ """
38
+
39
+ import argparse
40
+ import json
41
+ import sys
42
+
43
+ PASS, FAIL, UNEVALUABLE = 0, 1, 2
44
+
45
+ # The colour and the words for each state. Deliberately NOT a green/red pair:
46
+ # "orange" is the whole point of the file, and the message names the actual
47
+ # state rather than a generic "error" that reads the same for all three.
48
+ _BADGE = {
49
+ "PASS": ("passing", "brightgreen"),
50
+ "FAIL": ("failing", "red"),
51
+ "UNEVALUABLE": ("unevaluable", "orange"),
52
+ }
53
+
54
+ # A verdict this tool did not receive, rendered so a reader can tell "the gate
55
+ # is blind" from "nobody told me what the gate said".
56
+ _NO_INPUT = ("no gate verdict on stdin", "lightgrey")
57
+
58
+
59
+ class _Parser(argparse.ArgumentParser):
60
+ """A typo must never read as a blind gate.
61
+
62
+ argparse exits 2 on a usage error, and 2 here means "could not be
63
+ checked" -- so a mistyped flag would be indistinguishable from a gate
64
+ that genuinely went dark, and an operator would go hunting for broken
65
+ instrumentation that was never broken.
66
+ """
67
+
68
+ def error(self, message):
69
+ self.print_usage(sys.stderr)
70
+ sys.stderr.write("%s: error: %s\n" % (self.prog, message))
71
+ raise SystemExit(64)
72
+
73
+
74
+ def badge(payload, label="gate"):
75
+ """Map one ci-gate verdict onto a shields.io endpoint body.
76
+
77
+ Returns (body, exit_code). Raises ValueError when the payload is not a
78
+ verdict, so no caller can fall through into a rendered pass.
79
+ """
80
+ if not isinstance(payload, dict):
81
+ raise ValueError("expected a ci-gate JSON object, got %s"
82
+ % type(payload).__name__)
83
+
84
+ state = payload.get("state")
85
+ code = payload.get("exit_code")
86
+
87
+ if state not in _BADGE:
88
+ raise ValueError("unrecognised gate state: %r" % (state,))
89
+ # `is None` and an explicit int check, not truthiness: 0 is the PASS code
90
+ # and is falsy, and `True` is an int to Python but not an exit code.
91
+ if code is None or not isinstance(code, int) or isinstance(code, bool):
92
+ raise ValueError("gate payload carries no usable exit_code: %r"
93
+ % (code,))
94
+
95
+ # The two fields must agree. A payload saying UNEVALUABLE while carrying
96
+ # exit 0 is either corrupt or hand-edited toward green; believing half of
97
+ # it would let the half that is green through.
98
+ expected = {"PASS": PASS, "FAIL": FAIL, "UNEVALUABLE": UNEVALUABLE}[state]
99
+ if code != expected:
100
+ raise ValueError(
101
+ "gate payload disagrees with itself: state=%s implies exit %d "
102
+ "but exit_code=%d" % (state, expected, code))
103
+
104
+ message, color = _BADGE[state]
105
+ return ({"schemaVersion": 1, "label": label, "message": message,
106
+ "color": color}, code)
107
+
108
+
109
+ def main(argv=None):
110
+ ap = _Parser(
111
+ description="Render a ci-gate --json verdict as a shields.io badge.")
112
+ ap.add_argument("--label", default="gate",
113
+ help="badge label text; default 'gate'")
114
+ # No positional. A stray path argument is a usage error (64), never a file
115
+ # this tool silently judges.
116
+ args = ap.parse_args(argv)
117
+
118
+ raw = sys.stdin.read()
119
+ if not raw.strip():
120
+ # Absence of a reading, not a reading of absence. Rendering a pass
121
+ # here would mean an unplugged pipe certifies a merge.
122
+ message, color = _NO_INPUT
123
+ print(json.dumps({"schemaVersion": 1, "label": args.label,
124
+ "message": message, "color": color}))
125
+ sys.stderr.write("gate-badge: empty stdin -- pipe `ci-gate.py --json` "
126
+ "into this tool. No verdict was read, so none is "
127
+ "rendered.\n")
128
+ return UNEVALUABLE
129
+
130
+ try:
131
+ body, code = badge(json.loads(raw), args.label)
132
+ except (ValueError, TypeError) as exc:
133
+ message, color = _NO_INPUT
134
+ print(json.dumps({"schemaVersion": 1, "label": args.label,
135
+ "message": message, "color": color}))
136
+ sys.stderr.write("gate-badge: %s\n" % exc)
137
+ return UNEVALUABLE
138
+
139
+ print(json.dumps(body))
140
+ return code
141
+
142
+
143
+ if __name__ == "__main__":
144
+ sys.exit(main())