loki-mode 9.4.0 → 9.6.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.4.0
6
+ # Loki Mode v9.6.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.4.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
472
+ **v9.6.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 9.4.0
1
+ 9.6.0
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "9.4.0"
10
+ __version__ = "9.6.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.4.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.6.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=232D7DEBCB9B9EB464756E2164756E21
1235
+ //# debugId=86F920657B9F1C4064756E2164756E21
package/mcp/__init__.py CHANGED
@@ -75,4 +75,4 @@ try:
75
75
  except ImportError:
76
76
  __all__ = ['mcp']
77
77
 
78
- __version__ = '9.4.0'
78
+ __version__ = '9.6.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.4.0",
4
+ "version": "9.6.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.4.0",
5
+ "version": "9.6.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,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())
@@ -0,0 +1,361 @@
1
+ #!/usr/bin/env python3
2
+ """Is this repo's merge gate actually set up and working? One screen.
3
+
4
+ WHY THIS EXISTS. The gate line ships as eight separate tools, each honest on
5
+ its own axis: policy-load.py validates the policy file, baseline-pin.py holds
6
+ the cost reference, signing-status.py proves the keyring can sign,
7
+ cost-history.py holds the measured trend, ci-gate.py enforces the lot. Every
8
+ one of them answers a question an operator did not ask. The question they
9
+ actually ask, at the moment they need it -- before a release, after inheriting
10
+ a repo, when a green check stops being believable -- is one question:
11
+
12
+ is the gate ON, and would it do anything right now?
13
+
14
+ Nobody could answer it without running five commands and knowing how to read
15
+ five different exit conventions. So the honest answer to "is the gate working"
16
+ was, in practice, "somebody said it was". That is the exact shape this repo has
17
+ paid for fifteen times over: an assurance nobody re-derived.
18
+
19
+ NOTHING HERE RE-IMPLEMENTS A CHECK. Each line is produced by invoking the tool
20
+ that owns that rule as a subprocess (sys.executable + the tool path) and
21
+ mapping its exit code. A second copy of a rule is how the rule drifts, and this
22
+ repo proved that five times over with provider lists alone. Concretely: this
23
+ file does NOT know what makes a policy valid, what counts as baseline drift,
24
+ what proves a keyring can sign, or what makes a cost record measured. It knows
25
+ only which tool owns each question.
26
+
27
+ THREE STATES, NEVER A BOOLEAN, on every line:
28
+
29
+ OK the owning tool checked, and the answer is yes
30
+ PROBLEM the owning tool checked, and the answer is no
31
+ UNKNOWN the owning tool could not check, or is not on disk
32
+
33
+ UNKNOWN is the state this whole file exists to keep. "We checked and it is
34
+ fine" and "we could not check" are opposite facts about the world, and a status
35
+ screen is exactly where the second silently becomes the first -- a dash, a
36
+ blank cell, a skipped row. Absent is not zero, and unmeasured is not OK.
37
+
38
+ A MISSING COMPOSED TOOL READS "unavailable" AND POISONS THE VERDICT. If
39
+ signing-status.py is not on disk, this cannot report on signing, so it says so
40
+ and the overall verdict is not OK. A status screen that drops a line it could
41
+ not produce and still says "healthy" is reporting on a smaller repo than the
42
+ one it was pointed at. That is the tarball-assertion defect again: a check
43
+ reporting a pass without having checked.
44
+
45
+ THE VERDICT IS WEAKEST-LINK. Never a count, never a percentage, never "4 of 5".
46
+ A score cannot answer "is the gate working", because the one axis that is
47
+ blind is the one that matters. UNKNOWN outranks PROBLEM in the verdict for the
48
+ same reason ci-gate.py ranks them that way: told PROBLEM, an operator fixes the
49
+ named thing, re-runs, sees green, and is still blind on the dead axis.
50
+
51
+ READ-ONLY, AND IT SAYS SO. Every composed tool is invoked in a reporting mode
52
+ that starts no run, spends nothing, and contacts no provider: policy-load
53
+ reads a file, baseline-pin `show` reads a pin, signing-status round-trips
54
+ against the local keyring only, cost-history `report` reads recorded history,
55
+ and ci-gate is NOT executed at all -- the "would the gate run" line is answered
56
+ from whether a policy implies flags, because running the real gate would be the
57
+ one composed call that is not free.
58
+
59
+ Usage:
60
+ tools/gate-status.py [workspace] [--json] [--policy .loki-policy.json]
61
+
62
+ Exit: 0 every component checked and healthy, 1 a component checked and is not,
63
+ 2 a component could not be checked (including a missing tool), 66 the
64
+ workspace path does not exist.
65
+ """
66
+
67
+ import argparse
68
+ import json
69
+ import os
70
+ import subprocess
71
+ import sys
72
+
73
+ _HERE = os.path.dirname(os.path.abspath(__file__))
74
+
75
+ OK, PROBLEM, UNKNOWN = "OK", "PROBLEM", "UNKNOWN"
76
+
77
+ # Exit codes, per the repo-wide tools/ convention.
78
+ EXIT_OK, EXIT_PROBLEM, EXIT_UNKNOWN, EXIT_USAGE, EXIT_MISSING = 0, 1, 2, 64, 66
79
+
80
+ _VERDICT_EXIT = {OK: EXIT_OK, PROBLEM: EXIT_PROBLEM, UNKNOWN: EXIT_UNKNOWN}
81
+
82
+ # Module-level so a test can point one at a nonexistent path and assert that a
83
+ # missing component reads unavailable AND drags the verdict off OK.
84
+ POLICY_LOAD = os.path.join(_HERE, "policy-load.py")
85
+ BASELINE_PIN = os.path.join(_HERE, "baseline-pin.py")
86
+ SIGNING_STATUS = os.path.join(_HERE, "signing-status.py")
87
+ COST_HISTORY = os.path.join(_HERE, "cost-history.py")
88
+
89
+
90
+ class _Usage(argparse.ArgumentParser):
91
+ """argparse defaults a usage error to exit 2, which here means "could not
92
+ be checked" -- a typo in a flag would read as a blind gate rather than as
93
+ the operator error it is. 64 keeps those two facts distinct."""
94
+
95
+ def error(self, message):
96
+ self.print_usage(sys.stderr)
97
+ sys.stderr.write("%s: error: %s\n" % (self.prog, message))
98
+ raise SystemExit(EXIT_USAGE)
99
+
100
+
101
+ def _line(component, state, detail):
102
+ return {"component": component, "state": state, "detail": detail}
103
+
104
+
105
+ def _invoke(tool, argv):
106
+ """Run a component tool read-only. Returns (returncode, stdout, stderr) or
107
+ None when the tool is not on disk. Never recomputes the tool's rule."""
108
+ if not os.path.isfile(tool):
109
+ return None
110
+ try:
111
+ proc = subprocess.run([sys.executable, tool] + argv,
112
+ capture_output=True, text=True)
113
+ except OSError as exc:
114
+ return (None, "", str(exc))
115
+ return (proc.returncode, proc.stdout, proc.stderr)
116
+
117
+
118
+ def _unavailable(component, tool):
119
+ return _line(component, UNKNOWN,
120
+ "unavailable: %s is not on disk, so this axis was never "
121
+ "checked -- absent is not OK" % os.path.basename(tool))
122
+
123
+
124
+ def _first_line(*texts):
125
+ for text in texts:
126
+ for raw in (text or "").splitlines():
127
+ if raw.strip():
128
+ return raw.strip()
129
+ return "no detail reported"
130
+
131
+
132
+ def check_policy(policy_file):
133
+ """Is a merge policy file present and valid? policy-load.py owns the rule."""
134
+ got = _invoke(POLICY_LOAD, ["--file", policy_file, "--as-args"])
135
+ if got is None:
136
+ return _unavailable("policy", POLICY_LOAD)
137
+ rc, out, err = got
138
+ if rc is None:
139
+ return _line("policy", UNKNOWN, "could not run policy-load.py: %s" % err)
140
+ if rc == 0:
141
+ return _line("policy", OK,
142
+ "%s is valid and enforces: %s" % (policy_file, out.strip()))
143
+ if rc == 1:
144
+ # policy-load's own words: missing file, unknown key, bad value, or a
145
+ # policy that enforces nothing. All are "checked, and it is not set up".
146
+ return _line("policy", PROBLEM, _first_line(err, out))
147
+ return _line("policy", UNKNOWN,
148
+ "policy-load.py exited %d, which is not a verdict: %s"
149
+ % (rc, _first_line(err, out)))
150
+
151
+
152
+ def check_baseline(pin_file):
153
+ """Is a cost baseline pinned and undrifted? baseline-pin.py owns the rule,
154
+ including what counts as drift (raw-bytes sha256, not the receipt's own
155
+ canonical digest). That distinction is not restated here."""
156
+ got = _invoke(BASELINE_PIN, ["show", "--file", pin_file, "--json"])
157
+ if got is None:
158
+ return _unavailable("baseline", BASELINE_PIN)
159
+ rc, out, err = got
160
+ if rc is None:
161
+ return _line("baseline", UNKNOWN,
162
+ "could not run baseline-pin.py: %s" % err)
163
+ if rc == 0:
164
+ return _line("baseline", OK, _first_line(err))
165
+ if rc == 1:
166
+ # No pin at all, or a pin whose receipt drifted since. Both are
167
+ # "checked, and there is no trustworthy baseline".
168
+ return _line("baseline", PROBLEM, _first_line(err, out))
169
+ return _line("baseline", UNKNOWN,
170
+ "baseline-pin.py exited %d, which is not a verdict: %s"
171
+ % (rc, _first_line(err, out)))
172
+
173
+
174
+ def check_signing():
175
+ """Can this machine sign receipts? signing-status.py owns the rule, and it
176
+ already refuses to collapse its four states into a boolean. Its
177
+ not_configured (2) and gpg_absent (3) are NOT problems -- signing is opt-in
178
+ and nothing is broken -- but neither are they proof of origin, so they read
179
+ UNKNOWN here rather than OK. Only a completed sign+verify round trip is OK."""
180
+ got = _invoke(SIGNING_STATUS, ["--json"])
181
+ if got is None:
182
+ return _unavailable("signing", SIGNING_STATUS)
183
+ rc, out, err = got
184
+ if rc is None:
185
+ return _line("signing", UNKNOWN,
186
+ "could not run signing-status.py: %s" % err)
187
+ try:
188
+ detail = json.loads(out)
189
+ except ValueError:
190
+ detail = None
191
+ status = detail.get("status") if isinstance(detail, dict) else None
192
+ reason = (detail or {}).get("reason") if isinstance(detail, dict) else None
193
+
194
+ if rc == 0 and status == "ok":
195
+ return _line("signing", OK,
196
+ "sign+verify round trip completed: receipts carry origin")
197
+ if rc == 1 and status == "broken":
198
+ return _line("signing", PROBLEM,
199
+ "a key is configured but cannot sign, so receipts emit "
200
+ "UNSIGNED silently: %s" % (reason or "no reason reported"))
201
+ if rc == 2 and status == "not_configured":
202
+ return _line("signing", UNKNOWN,
203
+ "signing is opt-in and off (LOKI_PROOF_GPG_KEY unset): "
204
+ "receipts prove integrity but NOT origin")
205
+ if rc == 3 and status == "gpg_absent":
206
+ return _line("signing", UNKNOWN,
207
+ "no gpg on PATH, so origin cannot be proven here")
208
+ return _line("signing", UNKNOWN,
209
+ "signing-status.py exited %d with status %r, which is not a "
210
+ "verdict: %s" % (rc, status, _first_line(err, out)))
211
+
212
+
213
+ def check_cost_history(history_file):
214
+ """Is there measured cost history? cost-history.py owns the rule, including
215
+ what counts as measured (it imports record_is_measured; this file does not
216
+ restate that predicate, and must not)."""
217
+ got = _invoke(COST_HISTORY, ["report", "--file", history_file, "--json"])
218
+ if got is None:
219
+ return _unavailable("cost_history", COST_HISTORY)
220
+ rc, out, err = got
221
+ if rc is None:
222
+ return _line("cost_history", UNKNOWN,
223
+ "could not run cost-history.py: %s" % err)
224
+ try:
225
+ detail = json.loads(out)
226
+ except ValueError:
227
+ detail = None
228
+ if rc == 0 and isinstance(detail, dict):
229
+ runs = detail.get("measured")
230
+ median = detail.get("median_usd")
231
+ # A measured ZERO must survive as 0, so both of these ask `is None`,
232
+ # never falsy. `if not runs` would report a real count of zero as an
233
+ # absent count, and `median or "unknown"` would render a genuine,
234
+ # measured $0.00 median as unknown -- the exact collapse this repo has
235
+ # paid for on fifteen surfaces, run in the opposite direction.
236
+ if runs is None:
237
+ return _line("cost_history", UNKNOWN,
238
+ "cost-history reported no measured-run count")
239
+ return _line("cost_history", OK,
240
+ "%d measured run(s) in %s; median $%s; direction: %s"
241
+ % (runs, history_file,
242
+ "UNKNOWN" if median is None else "%.4f" % median,
243
+ detail.get("direction", "UNKNOWN")))
244
+ if rc == 1:
245
+ # Empty history, or history with no measured run. Zero runs is an
246
+ # absent measurement, which is a blind axis, not a healthy one.
247
+ why = detail.get("why") if isinstance(detail, dict) else None
248
+ return _line("cost_history", UNKNOWN,
249
+ "no measured cost history: %s"
250
+ % (why or _first_line(err) or history_file))
251
+ return _line("cost_history", UNKNOWN,
252
+ "cost-history.py exited %d, which is not a verdict: %s"
253
+ % (rc, _first_line(err, out)))
254
+
255
+
256
+ def check_would_run(policy_line):
257
+ """Would the gate do anything right now?
258
+
259
+ Answered from the policy line, NOT by executing ci-gate.py: running the
260
+ real gate is the one composed call that is not free, and this tool promises
261
+ to spend nothing. A gate invoked with no flags enforces nothing while
262
+ looking configured -- ci-gate.py's own docstring calls that vacuously
263
+ green -- so an unusable policy means the gate would not run.
264
+ """
265
+ if policy_line["state"] == OK:
266
+ return _line("would_run", OK,
267
+ "yes: ci-gate would enforce the loaded policy on the next "
268
+ "run (not executed here -- this tool spends nothing)")
269
+ if policy_line["state"] == PROBLEM:
270
+ return _line("would_run", PROBLEM,
271
+ "no: the policy does not load, so ci-gate would run with "
272
+ "nothing to enforce, which is green without checking")
273
+ return _line("would_run", UNKNOWN,
274
+ "cannot say: the policy itself could not be checked")
275
+
276
+
277
+ def evaluate(workspace=".", policy_file=None, history_file=None):
278
+ """Every component, then the weakest-link verdict."""
279
+ root = os.path.normpath(workspace)
280
+ if os.path.basename(root) == ".loki":
281
+ root = os.path.dirname(root) or "."
282
+ if policy_file is None:
283
+ policy_file = os.path.join(root, ".loki-policy.json")
284
+ if history_file is None:
285
+ history_file = os.path.join(root, ".loki", "cost-history.jsonl")
286
+ pin_file = os.path.join(root, ".loki", "baseline.json")
287
+
288
+ policy = check_policy(policy_file)
289
+ lines = [policy,
290
+ check_baseline(pin_file),
291
+ check_signing(),
292
+ check_cost_history(history_file),
293
+ check_would_run(policy)]
294
+
295
+ # THE DECISION. Weakest link, UNKNOWN outranking PROBLEM. Written once,
296
+ # here, so a mutation of it has nowhere to hide. Never a count: "4 of 5
297
+ # healthy" cannot answer "is the gate working", because the blind axis is
298
+ # exactly the one that matters.
299
+ states = [line["state"] for line in lines]
300
+ verdict = UNKNOWN if UNKNOWN in states else (
301
+ PROBLEM if PROBLEM in states else OK)
302
+
303
+ return {
304
+ "workspace": root,
305
+ "components": lines,
306
+ "verdict": verdict,
307
+ "exit_code": _VERDICT_EXIT[verdict],
308
+ "read_only": True,
309
+ "headline": _HEADLINE[verdict],
310
+ }
311
+
312
+
313
+ _HEADLINE = {
314
+ OK: "the merge gate is configured and would enforce the policy",
315
+ PROBLEM: "the merge gate is configured but something it checked is wrong",
316
+ UNKNOWN: "the merge gate cannot be fully verified from here, so it is not "
317
+ "known to be working",
318
+ }
319
+
320
+
321
+ def render(result):
322
+ lines = ["Merge gate status for %s" % result["workspace"],
323
+ " (read-only: starts nothing, spends nothing, contacts no "
324
+ "provider)", ""]
325
+ lines.append("%-14s %-9s %s" % ("COMPONENT", "STATE", "DETAIL"))
326
+ for row in result["components"]:
327
+ lines.append("%-14s %-9s %s"
328
+ % (row["component"], row["state"], row["detail"]))
329
+ lines += ["", "GATE: %s -- %s" % (result["verdict"], result["headline"])]
330
+ return "\n".join(lines)
331
+
332
+
333
+ def main(argv=None):
334
+ ap = _Usage(
335
+ description="One-screen answer to whether this repo's merge gate is "
336
+ "set up and working. Read-only: starts nothing, spends "
337
+ "nothing, contacts no provider.")
338
+ ap.add_argument("workspace", nargs="?", default=".",
339
+ help="workspace root (or its .loki dir); default .")
340
+ ap.add_argument("--policy", default=None,
341
+ help="policy file; default <workspace>/.loki-policy.json")
342
+ ap.add_argument("--history", default=None,
343
+ help="cost history JSONL; default "
344
+ "<workspace>/.loki/cost-history.jsonl")
345
+ ap.add_argument("--json", action="store_true", dest="as_json",
346
+ help="emit the status as JSON")
347
+ args = ap.parse_args(argv)
348
+
349
+ if not os.path.isdir(args.workspace):
350
+ sys.stderr.write("gate-status: workspace does not exist: %s\n"
351
+ % args.workspace)
352
+ return EXIT_MISSING
353
+
354
+ result = evaluate(args.workspace, args.policy, args.history)
355
+ print(json.dumps(result, indent=2, sort_keys=True) if args.as_json
356
+ else render(result))
357
+ return result["exit_code"]
358
+
359
+
360
+ if __name__ == "__main__":
361
+ sys.exit(main())