@splinterzzz/ouro 0.1.4 → 0.1.6

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.
@@ -53,7 +53,7 @@ Error generating stack: `+s.message+`
53
53
  *
54
54
  * This source code is licensed under the MIT license found in the
55
55
  * LICENSE file in the root directory of this source tree.
56
- */var Tl=z,Sp=kp;function jp(e,t){return e===t&&(e!==0||1/e===1/t)||e!==e&&t!==t}var Np=typeof Object.is=="function"?Object.is:jp,Ep=Sp.useSyncExternalStore,Cp=Tl.useRef,_p=Tl.useEffect,Tp=Tl.useMemo,zp=Tl.useDebugValue;Tc.useSyncExternalStoreWithSelector=function(e,t,n,r,l){var s=Cp(null);if(s.current===null){var i={hasValue:!1,value:null};s.current=i}else i=s.current;s=Tp(function(){function u(x){if(!c){if(c=!0,v=x,x=r(x),l!==void 0&&i.hasValue){var y=i.value;if(l(y,x))return h=y}return h=x}if(y=h,Np(v,x))return y;var w=r(x);return l!==void 0&&l(y,w)?(v=x,y):(v=x,h=w)}var c=!1,v,h,m=n===void 0?null:n;return[function(){return u(t())},m===null?void 0:function(){return u(m())}]},[t,n,r,l]);var a=Ep(e,s[0],s[1]);return _p(function(){i.hasValue=!0,i.value=a},[a]),zp(a),a};_c.exports=Tc;var Pp=_c.exports;const Lp=pa(Pp),Lc={},{useDebugValue:Mp}=Na,{useSyncExternalStoreWithSelector:Rp}=Lp;let sa=!1;const Ip=e=>e;function Op(e,t=Ip,n){(Lc?"production":void 0)!=="production"&&n&&!sa&&(console.warn("[DEPRECATED] Use `createWithEqualityFn` instead of `create` or use `useStoreWithEqualityFn` instead of `useStore`. They can be imported from 'zustand/traditional'. https://github.com/pmndrs/zustand/discussions/1937"),sa=!0);const r=Rp(e.subscribe,e.getState,e.getServerState||e.getInitialState,t,n);return Mp(r),r}const ia=e=>{(Lc?"production":void 0)!=="production"&&typeof e!="function"&&console.warn("[DEPRECATED] Passing a vanilla store will be unsupported in a future version. Instead use `import { useStore } from 'zustand'`.");const t=typeof e=="function"?dp(e):e,n=(r,l)=>Op(t,r,l);return Object.assign(n,t),n},zl=e=>e?ia(e):ia,Qe=zl((e,t)=>({agents:[],toolUniverse:[],defaultAgentId:null,loading:!0,error:null,selectedId:null,async hydrate(){try{const n=await fetch("/api/agents");if(!n.ok)throw new Error(`GET /api/agents → ${n.status}`);const{agents:r,toolUniverse:l,defaultAgentId:s}=await n.json();e(i=>{var a;return{agents:r,toolUniverse:l,defaultAgentId:s,loading:!1,error:null,selectedId:r.some(u=>u.id===i.selectedId)?i.selectedId:((a=r[0])==null?void 0:a.id)??null}})}catch(n){e({loading:!1,error:String(n.message||n)})}},select(n){e({selectedId:n})},async create(n){const r=await fetch("/api/agents",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:n})});if(!r.ok)throw new Error((await r.json().catch(()=>({}))).error??`HTTP ${r.status}`);const l=await r.json();return await t().hydrate(),e({selectedId:l.id}),l},async save(n,r){const l=await fetch(`/api/agents/${n}`,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)});if(!l.ok)throw new Error((await l.json().catch(()=>({}))).error??`HTTP ${l.status}`);const s=await l.json();return await t().hydrate(),s},async remove(n){const r=await fetch(`/api/agents/${n}`,{method:"DELETE"});if(!r.ok)throw new Error(`HTTP ${r.status}`);e({selectedId:null}),await t().hydrate()},async fetchRaw(n){const r=await fetch(`/api/agents/${n}`);if(!r.ok)throw new Error(`HTTP ${r.status}`);return(await r.json()).raw??""}}));let Ue=null,oa=null,zr=500;function Dp(e){if(e.type==="error")return"bad";if(e.type==="cancelled"||e.type==="warn")return"warn";if(e.type==="ship-done")return"good";if(e.type==="ship")return"run";const t=e.event??{};return t.type==="stderr"?"warn":t.type==="result"?t.is_error?"bad":"good":t.type==="raw"||t.type==="rate_limit_event"||t.type==="system"&&t.subtype!=="init"?"dim":"run"}function $p(e){var r,l,s;if(e.type==="error")return`error: ${e.text}`;if(e.type==="cancelled")return e.text||"cancelled";if(e.type==="ship"||e.type==="ship-done"||e.type==="warn")return e.text;const t=e.event;if(!t)return JSON.stringify(e).slice(0,200);if(t.type==="raw"||t.type==="stderr")return(t.text??"").trim();if(t.type==="system")return t.subtype==="init"?`session started · ${t.model??"ready"}`:`· ${t.subtype??"system"}`;if(t.type==="rate_limit_event")return"· rate limit notice";if(t.type==="result"){const i=[t.is_error?"failed":"done"];return t.num_turns&&i.push(`${t.num_turns} turns`),t.duration_ms&&i.push(`${(t.duration_ms/1e3).toFixed(1)}s`),`${t.is_error?"✗":"✓"} ${i.join(" · ")}`}const n=(r=t.message)==null?void 0:r.content;if(Array.isArray(n)){const i=[];for(const a of n)if(a.type==="tool_use"){const u=a.input??{},c=u.file_path??u.path??u.command??u.pattern??u.prompt??"";i.push(`▶ ${a.name}${c?` ${String(c).slice(0,90)}`:""}`)}else a.type==="text"&&((l=a.text)!=null&&l.trim())?i.push(a.text.trim().slice(0,160)):a.type==="thinking"?i.push("· thinking"):a.type==="tool_result"&&i.push("◂ result");if(i.length)return i.join(" ")}return(s=t.item)!=null&&s.text?`${t.type}: ${t.item.text.slice(0,140)}`:`· ${t.type??"event"}`}let aa=0;const B=zl((e,t)=>({tickets:[],connected:!1,backend:null,defaultMode:"human",feed:[],selectedId:null,select(n){e(r=>({selectedId:r.selectedId===n?null:n}))},async hydrate(){const r=await(await fetch("/api/tickets")).json(),l=r.flatMap(s=>(s.log??[]).map(i=>({...ua(s.id,i),id:++aa}))).sort((s,i)=>s.ts.localeCompare(i.ts)).slice(-600);e({tickets:r,feed:l})},async hydrateConfig(){const n=await fetch("/api/config"),{backend:r,defaultMode:l}=await n.json();e({backend:r,defaultMode:l??"human"})},async setBackend(n){const r=t().backend;e({backend:n});try{const l=await fetch("/api/config/backend",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({backend:n})});if(!l.ok)throw new Error(await l.text());const s=await l.json();e({backend:s.backend??n})}catch{e({backend:r})}},async setDefaultMode(n){const r=t().defaultMode;e({defaultMode:n});try{const l=await fetch("/api/config/mode",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({mode:n})});if(!l.ok)throw new Error(await l.text())}catch{e({defaultMode:r})}},connectSocket(){if(Ue&&Ue.readyState<=1)return;const n=location.protocol==="https:"?"wss":"ws";Ue=new WebSocket(`${n}://${location.host}/ws`),Ue.onopen=()=>{zr=500,e({connected:!0}),t().hydrate(),t().hydrateConfig()},Ue.onclose=()=>{e({connected:!1}),clearTimeout(oa),oa=setTimeout(()=>t().connectSocket(),zr),zr=Math.min(zr*2,8e3)},Ue.onerror=()=>Ue==null?void 0:Ue.close(),Ue.onmessage=r=>{let l;try{l=JSON.parse(r.data)}catch{return}t().applyEvent(l)}},applyEvent(n){if(n.type==="log"){const l={...ua(n.ticketId,n.entry),id:++aa};e(s=>({feed:[...s.feed,l].slice(-600),tickets:s.tickets.map(i=>i.id===n.ticketId?{...i,log:[...i.log??[],n.entry].slice(-400)}:i)}));return}if(n.type==="deleted"){e(l=>({tickets:l.tickets.filter(s=>s.id!==n.ticketId),selectedId:l.selectedId===n.ticketId?null:l.selectedId}));return}if(n.type==="config"){e({defaultMode:n.defaultMode});return}if(n.type==="agent"||n.type==="agent-deleted"){Qe.getState().hydrate();return}const r=n.ticket;r&&e(l=>({tickets:l.tickets.some(i=>i.id===r.id)?l.tickets.map(i=>i.id===r.id?r:i):[r,...l.tickets]}))},clearFeed(){e({feed:[]})},async setMode(n,r){await Me(`/api/tickets/${n}/mode`,{mode:r})},async setAgent(n,r){await Me(`/api/tickets/${n}/agent`,{agentId:r})},async analyzeTicket(n){await Me(`/api/tickets/${n}/analyze`)},async runTicket(n){await Me(`/api/tickets/${n}/run`)},async approveTicket(n){await Me(`/api/tickets/${n}/approve`)},async cancelTicket(n){await Me(`/api/tickets/${n}/cancel`)},async reopenTicket(n){await Me(`/api/tickets/${n}/reopen`)},async qaApprove(n){await Me(`/api/tickets/${n}/qa/approve`)},async qaReject(n){await Me(`/api/tickets/${n}/qa/reject`)},async shipTicket(n){const r=await Me(`/api/tickets/${n}/ship`);return r.json().catch(()=>({ok:!1,error:`HTTP ${r.status}`}))},async deleteTicket(n){await fetch(`/api/tickets/${n}`,{method:"DELETE"})},async createTicket(n){const r=await Me("/api/tickets",{source:"manual",...n});return r==null?void 0:r.json()}}));function ua(e,t){return{ticketId:e,ts:t.ts??new Date().toISOString(),tone:Dp(t),text:$p(t)}}function Me(e,t){return fetch(e,{method:"POST",headers:{"Content-Type":"application/json"},...t?{body:JSON.stringify(t)}:{}})}const Mc="ouro.ui.v1",ca={view:"board",railCollapsed:!1,ticketsOpen:!0,agentsOpen:!0,terminalOpen:!0,terminalHeight:200,autoscroll:!0},Rc=90,Ic=560;function Fp(){try{return{...ca,...JSON.parse(localStorage.getItem(Mc)??"{}")}}catch{return{...ca}}}const Xe=zl((e,t)=>({...Fp(),set(n){e(n);const{view:r,railCollapsed:l,ticketsOpen:s,agentsOpen:i,terminalOpen:a,terminalHeight:u,autoscroll:c}=t();try{localStorage.setItem(Mc,JSON.stringify({view:r,railCollapsed:l,ticketsOpen:s,agentsOpen:i,terminalOpen:a,terminalHeight:u,autoscroll:c}))}catch{}},toggle(n){t().set({[n]:!t()[n]})},setTerminalHeight(n){t().set({terminalHeight:Math.min(Ic,Math.max(Rc,n))})}})),Ap={inbox:"M22 12h-6l-2 3h-4l-2-3H2 M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z",agents:"M12 8V4H8 M4 8h16v12H4z M2 14h2 M20 14h2 M15 13v2 M9 13v2",play:"m6 3 14 9-14 9V3z",stop:"M5 5h14v14H5z",check:"M20 6 9 17l-5-5",x:"M18 6 6 18 M6 6l12 12",chevronRight:"m9 18 6-6-6-6",chevronDown:"m6 9 6 6 6-6",chevronsLeft:"m11 17-5-5 5-5 M18 17l-5-5 5-5",chevronsRight:"m6 17 5-5-5-5 M13 17l5-5-5-5",plus:"M5 12h14 M12 5v14",terminal:"m4 17 6-6-6-6 M12 19h8",trash:"M3 6h18 M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2",rotate:"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8 M3 3v5h5",save:"M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z M17 21v-8H7v8 M7 3v5h8",file:"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z M14 2v4a2 2 0 0 0 2 2h4",alert:"M12 9v4 M12 17h.01 M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z",search:"m21 21-4.34-4.34 M17 11a6 6 0 1 1-12 0 6 6 0 0 1 12 0z",gitBranch:"M6 3v12 M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M15 9a9 9 0 0 1-9 9",settings:"M20 7h-9 M14 17H5 M17 14a3 3 0 1 0 0 6 3 3 0 0 0 0-6z M7 4a3 3 0 1 0 0 6 3 3 0 0 0 0-6z",send:"m22 2-7 20-4-9-9-4Z M22 2 11 13"};function T({name:e,size:t=16,className:n="",...r}){const l=Ap[e];return l?o.jsx("svg",{className:`icon ${n}`,width:t,height:t,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",...r,children:l.split(" M").map((s,i)=>o.jsx("path",{d:i===0?s:`M${s}`},i))}):null}const Up=[[100,4],[135,2.5],[170,4],[205,3],[240,4.5],[275,2.5],[310,4],[340,3]];function Vp({size:e=20,title:t,className:n=""}){return o.jsxs("svg",{className:`logo ${n}`,width:e,height:e,viewBox:"0 0 64 64",fill:"none",stroke:"currentColor",strokeLinecap:"round",strokeLinejoin:"round",role:t?"img":void 0,"aria-hidden":t?void 0:"true","aria-label":t,children:[t&&o.jsx("title",{children:t}),o.jsx("path",{d:"M43 12.95A22 22 0 1 1 28.18 10.33",strokeWidth:"4"}),o.jsx("path",{d:"M28.18 10.33A22 22 0 0 1 32 10",strokeWidth:"2.6"}),o.jsx("path",{d:"M32 10A22 22 0 0 1 33.92 10.08",strokeWidth:"1.5"}),o.jsx("path",{d:"M43 12.95A22 22 0 1 1 28.18 10.33",strokeWidth:"1.3",opacity:"0.7"}),o.jsxs("g",{opacity:"0.4",strokeWidth:"0.8",children:[Up.map(([r,l])=>o.jsxs("g",{transform:`rotate(${r} 32 32)`,children:[o.jsx("path",{d:`M32 13v${l}`}),o.jsx("circle",{cx:"32",cy:13+l,r:"0.85",fill:"currentColor",stroke:"none"})]},r)),o.jsx("path",{d:"M15.26 34.95A17 17 0 0 0 23.5 46.72"}),o.jsx("path",{d:"M43.4 43.4A17 17 0 0 0 48.4 36.1"})]}),o.jsx("path",{d:`M42.8 14.8
56
+ */var Tl=z,Sp=kp;function jp(e,t){return e===t&&(e!==0||1/e===1/t)||e!==e&&t!==t}var Np=typeof Object.is=="function"?Object.is:jp,Ep=Sp.useSyncExternalStore,Cp=Tl.useRef,_p=Tl.useEffect,Tp=Tl.useMemo,zp=Tl.useDebugValue;Tc.useSyncExternalStoreWithSelector=function(e,t,n,r,l){var s=Cp(null);if(s.current===null){var i={hasValue:!1,value:null};s.current=i}else i=s.current;s=Tp(function(){function u(x){if(!c){if(c=!0,v=x,x=r(x),l!==void 0&&i.hasValue){var y=i.value;if(l(y,x))return h=y}return h=x}if(y=h,Np(v,x))return y;var w=r(x);return l!==void 0&&l(y,w)?(v=x,y):(v=x,h=w)}var c=!1,v,h,m=n===void 0?null:n;return[function(){return u(t())},m===null?void 0:function(){return u(m())}]},[t,n,r,l]);var a=Ep(e,s[0],s[1]);return _p(function(){i.hasValue=!0,i.value=a},[a]),zp(a),a};_c.exports=Tc;var Pp=_c.exports;const Lp=pa(Pp),Lc={},{useDebugValue:Mp}=Na,{useSyncExternalStoreWithSelector:Rp}=Lp;let sa=!1;const Ip=e=>e;function Op(e,t=Ip,n){(Lc?"production":void 0)!=="production"&&n&&!sa&&(console.warn("[DEPRECATED] Use `createWithEqualityFn` instead of `create` or use `useStoreWithEqualityFn` instead of `useStore`. They can be imported from 'zustand/traditional'. https://github.com/pmndrs/zustand/discussions/1937"),sa=!0);const r=Rp(e.subscribe,e.getState,e.getServerState||e.getInitialState,t,n);return Mp(r),r}const ia=e=>{(Lc?"production":void 0)!=="production"&&typeof e!="function"&&console.warn("[DEPRECATED] Passing a vanilla store will be unsupported in a future version. Instead use `import { useStore } from 'zustand'`.");const t=typeof e=="function"?dp(e):e,n=(r,l)=>Op(t,r,l);return Object.assign(n,t),n},zl=e=>e?ia(e):ia,Qe=zl((e,t)=>({agents:[],toolUniverse:[],defaultAgentId:null,loading:!0,error:null,selectedId:null,async hydrate(){try{const n=await fetch("/api/agents");if(!n.ok)throw new Error(`GET /api/agents → ${n.status}`);const{agents:r,toolUniverse:l,defaultAgentId:s}=await n.json();e(i=>{var a;return{agents:r,toolUniverse:l,defaultAgentId:s,loading:!1,error:null,selectedId:r.some(u=>u.id===i.selectedId)?i.selectedId:((a=r[0])==null?void 0:a.id)??null}})}catch(n){e({loading:!1,error:String(n.message||n)})}},select(n){e({selectedId:n})},async create(n){const r=await fetch("/api/agents",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:n})});if(!r.ok)throw new Error((await r.json().catch(()=>({}))).error??`HTTP ${r.status}`);const l=await r.json();return await t().hydrate(),e({selectedId:l.id}),l},async save(n,r){const l=await fetch(`/api/agents/${n}`,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)});if(!l.ok)throw new Error((await l.json().catch(()=>({}))).error??`HTTP ${l.status}`);const s=await l.json();return await t().hydrate(),s},async remove(n){const r=await fetch(`/api/agents/${n}`,{method:"DELETE"});if(!r.ok)throw new Error(`HTTP ${r.status}`);e({selectedId:null}),await t().hydrate()},async fetchRaw(n){const r=await fetch(`/api/agents/${n}`);if(!r.ok)throw new Error(`HTTP ${r.status}`);return(await r.json()).raw??""}}));let Ue=null,oa=null,zr=500;function Dp(e){if(e.type==="error")return"bad";if(e.type==="cancelled"||e.type==="warn")return"warn";if(e.type==="ship-done")return"good";if(e.type==="ship")return"run";const t=e.event??{};return t.type==="stderr"?"warn":t.type==="result"?t.is_error?"bad":"good":t.type==="raw"||t.type==="rate_limit_event"||t.type==="system"&&t.subtype!=="init"?"dim":"run"}function $p(e){var r,l,s;if(e.type==="error")return`error: ${e.text}`;if(e.type==="cancelled")return e.text||"cancelled";if(e.type==="ship"||e.type==="ship-done"||e.type==="warn")return e.text;const t=e.event;if(!t)return JSON.stringify(e).slice(0,200);if(t.type==="raw"||t.type==="stderr")return(t.text??"").trim();if(t.type==="system")return t.subtype==="init"?`session started · ${t.model??"ready"}`:`· ${t.subtype??"system"}`;if(t.type==="rate_limit_event")return"· rate limit notice";if(t.type==="result"){const i=[t.is_error?"failed":"done"];return t.num_turns&&i.push(`${t.num_turns} turns`),t.duration_ms&&i.push(`${(t.duration_ms/1e3).toFixed(1)}s`),`${t.is_error?"✗":"✓"} ${i.join(" · ")}`}const n=(r=t.message)==null?void 0:r.content;if(Array.isArray(n)){const i=[];for(const a of n)if(a.type==="tool_use"){const u=a.input??{},c=u.file_path??u.path??u.command??u.pattern??u.prompt??"";i.push(`▶ ${a.name}${c?` ${String(c).slice(0,90)}`:""}`)}else a.type==="text"&&((l=a.text)!=null&&l.trim())?i.push(a.text.trim().slice(0,160)):a.type==="tool_result"&&i.push("◂ result");return i.length?i.join(" "):null}return(s=t.item)!=null&&s.text?`${t.type}: ${t.item.text.slice(0,140)}`:`· ${t.type??"event"}`}let aa=0;const B=zl((e,t)=>({tickets:[],connected:!1,backend:null,defaultMode:"human",feed:[],selectedId:null,select(n){e(r=>({selectedId:r.selectedId===n?null:n}))},async hydrate(){const r=await(await fetch("/api/tickets")).json(),l=r.flatMap(s=>(s.log??[]).map(i=>({...ua(s.id,i),id:++aa}))).filter(s=>s.text!=null).sort((s,i)=>s.ts.localeCompare(i.ts)).slice(-600);e({tickets:r,feed:l})},async hydrateConfig(){const n=await fetch("/api/config"),{backend:r,defaultMode:l}=await n.json();e({backend:r,defaultMode:l??"human"})},async setBackend(n){const r=t().backend;e({backend:n});try{const l=await fetch("/api/config/backend",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({backend:n})});if(!l.ok)throw new Error(await l.text());const s=await l.json();e({backend:s.backend??n})}catch{e({backend:r})}},async setDefaultMode(n){const r=t().defaultMode;e({defaultMode:n});try{const l=await fetch("/api/config/mode",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({mode:n})});if(!l.ok)throw new Error(await l.text())}catch{e({defaultMode:r})}},connectSocket(){if(Ue&&Ue.readyState<=1)return;const n=location.protocol==="https:"?"wss":"ws";Ue=new WebSocket(`${n}://${location.host}/ws`),Ue.onopen=()=>{zr=500,e({connected:!0}),t().hydrate(),t().hydrateConfig()},Ue.onclose=()=>{e({connected:!1}),clearTimeout(oa),oa=setTimeout(()=>t().connectSocket(),zr),zr=Math.min(zr*2,8e3)},Ue.onerror=()=>Ue==null?void 0:Ue.close(),Ue.onmessage=r=>{let l;try{l=JSON.parse(r.data)}catch{return}t().applyEvent(l)}},applyEvent(n){if(n.type==="log"){const l={...ua(n.ticketId,n.entry),id:++aa};e(s=>({feed:l.text==null?s.feed:[...s.feed,l].slice(-600),tickets:s.tickets.map(i=>i.id===n.ticketId?{...i,log:[...i.log??[],n.entry].slice(-400)}:i)}));return}if(n.type==="deleted"){e(l=>({tickets:l.tickets.filter(s=>s.id!==n.ticketId),selectedId:l.selectedId===n.ticketId?null:l.selectedId}));return}if(n.type==="config"){e({defaultMode:n.defaultMode});return}if(n.type==="agent"||n.type==="agent-deleted"){Qe.getState().hydrate();return}const r=n.ticket;r&&e(l=>({tickets:l.tickets.some(i=>i.id===r.id)?l.tickets.map(i=>i.id===r.id?r:i):[r,...l.tickets]}))},clearFeed(){e({feed:[]})},async setMode(n,r){await Me(`/api/tickets/${n}/mode`,{mode:r})},async setAgent(n,r){await Me(`/api/tickets/${n}/agent`,{agentId:r})},async analyzeTicket(n){await Me(`/api/tickets/${n}/analyze`)},async runTicket(n){await Me(`/api/tickets/${n}/run`)},async approveTicket(n){await Me(`/api/tickets/${n}/approve`)},async cancelTicket(n){await Me(`/api/tickets/${n}/cancel`)},async reopenTicket(n){await Me(`/api/tickets/${n}/reopen`)},async qaApprove(n){await Me(`/api/tickets/${n}/qa/approve`)},async qaReject(n){await Me(`/api/tickets/${n}/qa/reject`)},async shipTicket(n){const r=await Me(`/api/tickets/${n}/ship`);return r.json().catch(()=>({ok:!1,error:`HTTP ${r.status}`}))},async deleteTicket(n){await fetch(`/api/tickets/${n}`,{method:"DELETE"})},async createTicket(n){const r=await Me("/api/tickets",{source:"manual",...n});return r==null?void 0:r.json()}}));function ua(e,t){return{ticketId:e,ts:t.ts??new Date().toISOString(),tone:Dp(t),text:$p(t)}}function Me(e,t){return fetch(e,{method:"POST",headers:{"Content-Type":"application/json"},...t?{body:JSON.stringify(t)}:{}})}const Mc="ouro.ui.v1",ca={view:"board",railCollapsed:!1,ticketsOpen:!0,agentsOpen:!0,terminalOpen:!0,terminalHeight:200,autoscroll:!0},Rc=90,Ic=560;function Fp(){try{return{...ca,...JSON.parse(localStorage.getItem(Mc)??"{}")}}catch{return{...ca}}}const Xe=zl((e,t)=>({...Fp(),set(n){e(n);const{view:r,railCollapsed:l,ticketsOpen:s,agentsOpen:i,terminalOpen:a,terminalHeight:u,autoscroll:c}=t();try{localStorage.setItem(Mc,JSON.stringify({view:r,railCollapsed:l,ticketsOpen:s,agentsOpen:i,terminalOpen:a,terminalHeight:u,autoscroll:c}))}catch{}},toggle(n){t().set({[n]:!t()[n]})},setTerminalHeight(n){t().set({terminalHeight:Math.min(Ic,Math.max(Rc,n))})}})),Ap={inbox:"M22 12h-6l-2 3h-4l-2-3H2 M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z",agents:"M12 8V4H8 M4 8h16v12H4z M2 14h2 M20 14h2 M15 13v2 M9 13v2",play:"m6 3 14 9-14 9V3z",stop:"M5 5h14v14H5z",check:"M20 6 9 17l-5-5",x:"M18 6 6 18 M6 6l12 12",chevronRight:"m9 18 6-6-6-6",chevronDown:"m6 9 6 6 6-6",chevronsLeft:"m11 17-5-5 5-5 M18 17l-5-5 5-5",chevronsRight:"m6 17 5-5-5-5 M13 17l5-5-5-5",plus:"M5 12h14 M12 5v14",terminal:"m4 17 6-6-6-6 M12 19h8",trash:"M3 6h18 M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2",rotate:"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8 M3 3v5h5",save:"M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z M17 21v-8H7v8 M7 3v5h8",file:"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z M14 2v4a2 2 0 0 0 2 2h4",alert:"M12 9v4 M12 17h.01 M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z",search:"m21 21-4.34-4.34 M17 11a6 6 0 1 1-12 0 6 6 0 0 1 12 0z",gitBranch:"M6 3v12 M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M15 9a9 9 0 0 1-9 9",settings:"M20 7h-9 M14 17H5 M17 14a3 3 0 1 0 0 6 3 3 0 0 0 0-6z M7 4a3 3 0 1 0 0 6 3 3 0 0 0 0-6z",send:"m22 2-7 20-4-9-9-4Z M22 2 11 13"};function T({name:e,size:t=16,className:n="",...r}){const l=Ap[e];return l?o.jsx("svg",{className:`icon ${n}`,width:t,height:t,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",...r,children:l.split(" M").map((s,i)=>o.jsx("path",{d:i===0?s:`M${s}`},i))}):null}const Up=[[100,4],[135,2.5],[170,4],[205,3],[240,4.5],[275,2.5],[310,4],[340,3]];function Vp({size:e=20,title:t,className:n=""}){return o.jsxs("svg",{className:`logo ${n}`,width:e,height:e,viewBox:"0 0 64 64",fill:"none",stroke:"currentColor",strokeLinecap:"round",strokeLinejoin:"round",role:t?"img":void 0,"aria-hidden":t?void 0:"true","aria-label":t,children:[t&&o.jsx("title",{children:t}),o.jsx("path",{d:"M43 12.95A22 22 0 1 1 28.18 10.33",strokeWidth:"4"}),o.jsx("path",{d:"M28.18 10.33A22 22 0 0 1 32 10",strokeWidth:"2.6"}),o.jsx("path",{d:"M32 10A22 22 0 0 1 33.92 10.08",strokeWidth:"1.5"}),o.jsx("path",{d:"M43 12.95A22 22 0 1 1 28.18 10.33",strokeWidth:"1.3",opacity:"0.7"}),o.jsxs("g",{opacity:"0.4",strokeWidth:"0.8",children:[Up.map(([r,l])=>o.jsxs("g",{transform:`rotate(${r} 32 32)`,children:[o.jsx("path",{d:`M32 13v${l}`}),o.jsx("circle",{cx:"32",cy:13+l,r:"0.85",fill:"currentColor",stroke:"none"})]},r)),o.jsx("path",{d:"M15.26 34.95A17 17 0 0 0 23.5 46.72"}),o.jsx("path",{d:"M43.4 43.4A17 17 0 0 0 48.4 36.1"})]}),o.jsx("path",{d:`M42.8 14.8
57
57
  C46.6 14.6 49.6 12.3 49.4 8.8
58
58
  C49.2 6.0 46.8 4.5 43.6 5.0
59
59
  L33.6 7.7
@@ -9,7 +9,7 @@
9
9
  flash white around a near-black app on load. -->
10
10
  <meta name="theme-color" content="#0b0a12" />
11
11
  <title>ouro</title>
12
- <script type="module" crossorigin src="/assets/index-Btdawh-I.js"></script>
12
+ <script type="module" crossorigin src="/assets/index-D-niaPon.js"></script>
13
13
  <link rel="stylesheet" crossorigin href="/assets/index-C21ZXjPS.css">
14
14
  </head>
15
15
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@splinterzzz/ouro",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Loop engineering CLI: repo-rooted kanban + agent loop, powered by Claude Code / Codex headless mode (no API key required).",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -55,7 +55,10 @@ function runClaude(args, { cwd, onEvent, signal } = {}) {
55
55
  return new Promise((resolve, reject) => {
56
56
  // `signal` wires cancellation straight to the child: aborting SIGTERMs the
57
57
  // CLI rather than leaving it running detached. See lib/runs.js.
58
- const proc = spawn(CLAUDE_BIN, args, { cwd, env: process.env, signal });
58
+ // windowsHide: the dashboard daemon spawns this per run — without it, each
59
+ // run flashes an empty `claude` console window on Windows. Output is piped
60
+ // and parsed here, so the child never needs a visible console of its own.
61
+ const proc = spawn(CLAUDE_BIN, args, { cwd, env: process.env, signal, windowsHide: true });
59
62
 
60
63
  const rl = readline.createInterface({ input: proc.stdout });
61
64
  let sessionId = null;
@@ -63,7 +63,9 @@ function agentFlags(agent) {
63
63
  function runCodexExec(args, { cwd, onEvent, signal } = {}) {
64
64
  return new Promise((resolve, reject) => {
65
65
  // `signal` makes cancellation kill the child process. See lib/runs.js.
66
- const proc = spawn(CODEX_BIN, args, { cwd, env: process.env, signal });
66
+ // windowsHide: keep the per-run child from flashing an empty console window
67
+ // on Windows — output is piped and parsed here, not shown in a console.
68
+ const proc = spawn(CODEX_BIN, args, { cwd, env: process.env, signal, windowsHide: true });
67
69
 
68
70
  const rl = readline.createInterface({ input: proc.stdout });
69
71
  let sessionId = null;
package/src/lib/config.js CHANGED
@@ -30,6 +30,8 @@ const DEFAULTS = {
30
30
  // preview is expected to listen (used to build the clickable URL).
31
31
  staging: {
32
32
  testCommand: null,
33
+ lintCommand: null,
34
+ buildCommand: null,
33
35
  previewCommand: null,
34
36
  previewPort: null,
35
37
  },
@@ -108,6 +110,8 @@ export function getStaging() {
108
110
  const s = readConfig().staging ?? {};
109
111
  return {
110
112
  testCommand: s.testCommand ?? null,
113
+ lintCommand: s.lintCommand ?? null,
114
+ buildCommand: s.buildCommand ?? null,
111
115
  previewCommand: s.previewCommand ?? null,
112
116
  previewPort: s.previewPort ?? null,
113
117
  };
@@ -45,7 +45,7 @@ function killTree(proc) {
45
45
  if (!proc?.pid) return;
46
46
  try {
47
47
  if (process.platform === "win32") {
48
- spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"], { stdio: "ignore" });
48
+ spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
49
49
  } else {
50
50
  proc.kill("SIGTERM");
51
51
  }
@@ -112,7 +112,7 @@ export async function startPreview(ticketId, { cwd, signal } = {}) {
112
112
 
113
113
  let proc;
114
114
  try {
115
- proc = spawn(command, { cwd, shell: true });
115
+ proc = spawn(command, { cwd, shell: true, windowsHide: true });
116
116
  } catch (err) {
117
117
  return { started: false, reason: `spawn failed: ${err.message || err}`, source };
118
118
  }
@@ -2,37 +2,57 @@ import { spawn } from "node:child_process";
2
2
  import { getStaging } from "./config.js";
3
3
  import { askJson } from "./agentBackend.js";
4
4
 
5
- // Staging test execution. ouro runs the tests (deterministic capture) and the
6
- // QA agent judges the result — so the test command has to be resolved and run
7
- // here, not left to the agent's Bash tool.
5
+ // Staging checks. ouro runs the deterministic checks (test, lint, build) and the
6
+ // QA agent judges the result — so the commands have to be resolved and run here,
7
+ // not left to the agent's Bash tool (QA is read-only by design).
8
8
  //
9
- // Resolution order: config.staging.testCommand if set, else a cheap read-only
10
- // agent inference from the repo (package.json scripts, CLAUDE.md, etc.). If
11
- // nothing resolves, the stage proceeds tests-only-absent never a hard failure.
9
+ // Resolution order per check: config.staging.<kind>Command if set, else a cheap
10
+ // read-only agent inference from the repo. If nothing resolves, that check is
11
+ // reported as not-run the QA gate treats a check with no command in the repo
12
+ // as N/A, never a failure, so a repo without a linter or build step still ships.
13
+
14
+ // One entry per deterministic check. `configKey` is the config.staging field
15
+ // that pins the command; `infer` is the read-only prompt used when it isn't set.
16
+ const CHECKS = [
17
+ {
18
+ kind: "test",
19
+ configKey: "testCommand",
20
+ infer:
21
+ "Infer the single shell command that runs this project's automated tests, from its config (package.json scripts, Makefile, pyproject, etc.). " +
22
+ 'Respond with ONLY JSON: {"command": string|null}. Use null if the repo has no test setup. No prose, no fences.',
23
+ },
24
+ {
25
+ kind: "lint",
26
+ configKey: "lintCommand",
27
+ infer:
28
+ 'Infer the single shell command that lints this project, from its config (a package.json "lint" script, eslint/ruff/flake8 config, etc.). ' +
29
+ 'Respond with ONLY JSON: {"command": string|null}. Use null if the repo has no linter. No prose, no fences.',
30
+ },
31
+ {
32
+ kind: "build",
33
+ configKey: "buildCommand",
34
+ infer:
35
+ 'Infer the single shell command that builds or compiles this project, from its config (a package.json "build" script, tsc, a bundler config, etc.). ' +
36
+ 'Respond with ONLY JSON: {"command": string|null}. Use null if the repo has no build step. No prose, no fences.',
37
+ },
38
+ ];
12
39
 
13
40
  /**
14
- * Resolve the test command. Returns { command, source } where source is
41
+ * Resolve one check's command. Returns { command, source } where source is
15
42
  * "config" | "inferred" | "none".
16
43
  */
17
- export async function resolveTestCommand({ cwd, signal }) {
18
- const configured = getStaging().testCommand;
44
+ async function resolveCommand({ configKey, infer, cwd, signal }) {
45
+ const configured = getStaging()[configKey];
19
46
  if (configured) return { command: String(configured), source: "config" };
20
47
 
21
- const inferred = await askJson({
22
- prompt:
23
- "Infer the single shell command that runs this project's automated tests, from its config (package.json scripts, Makefile, pyproject, etc.). " +
24
- 'Respond with ONLY JSON: {"command": string|null}. Use null if the repo has no test setup. No prose, no fences.',
25
- cwd,
26
- signal,
27
- }).catch(() => null);
28
-
48
+ const inferred = await askJson({ prompt: infer, cwd, signal }).catch(() => null);
29
49
  const command = typeof inferred?.command === "string" ? inferred.command.trim() : "";
30
50
  return { command: command || null, source: command ? "inferred" : "none" };
31
51
  }
32
52
 
33
53
  /**
34
54
  * Run a shell command, capturing output. Bounded by a timeout so a hanging
35
- * suite can't wedge the stage. Never rejects — a spawn error resolves as a
55
+ * check can't wedge the stage. Never rejects — a spawn error resolves as a
36
56
  * failed result the QA agent can read.
37
57
  */
38
58
  export function runShell(command, { cwd, signal, timeoutMs = 10 * 60 * 1000 } = {}) {
@@ -40,7 +60,9 @@ export function runShell(command, { cwd, signal, timeoutMs = 10 * 60 * 1000 } =
40
60
  let stdout = "";
41
61
  let stderr = "";
42
62
  // shell:true so a full command string ("npm test", "pytest -q") runs as-is.
43
- const proc = spawn(command, { cwd, shell: true, signal });
63
+ // windowsHide: the shell child is cmd.exe on win32 — without it every check
64
+ // run (and every QA loop-back retry) flashes a console window.
65
+ const proc = spawn(command, { cwd, shell: true, signal, windowsHide: true });
44
66
 
45
67
  const timer = setTimeout(() => {
46
68
  try {
@@ -75,18 +97,18 @@ function tail(text, lines = 40) {
75
97
  }
76
98
 
77
99
  /**
78
- * Resolve + run the tests. Returns a structured result for the card and the QA
79
- * agent. `ran: false` means no command resolved the stage goes on tests-only-
80
- * absent, never failing on the absence itself.
100
+ * Resolve + run one check. `ran: false` means no command resolved the QA gate
101
+ * reads that as N/A for this repo, never a failure.
81
102
  */
82
- export async function runTests({ cwd, signal }) {
83
- const { command, source } = await resolveTestCommand({ cwd, signal });
103
+ async function runOne(check, { cwd, signal }) {
104
+ const { command, source } = await resolveCommand({ ...check, cwd, signal });
84
105
  if (!command) {
85
- return { ran: false, command: null, source, passed: null, output: "No test command resolved for this repo." };
106
+ return { kind: check.kind, ran: false, command: null, source, passed: null, output: `No ${check.kind} command resolved for this repo.` };
86
107
  }
87
108
 
88
109
  const res = await runShell(command, { cwd, signal });
89
110
  return {
111
+ kind: check.kind,
90
112
  ran: true,
91
113
  command,
92
114
  source,
@@ -95,3 +117,19 @@ export async function runTests({ cwd, signal }) {
95
117
  output: tail(`${res.stdout}\n${res.stderr}`),
96
118
  };
97
119
  }
120
+
121
+ /**
122
+ * Run every deterministic check (test, lint, build), in order. Sequential so
123
+ * build and test don't race on the same worktree. Returns { test, lint, build },
124
+ * each a structured result for the card and the QA agent. Honors `signal` —
125
+ * once aborted, remaining checks are skipped as not-run.
126
+ */
127
+ export async function runChecks({ cwd, signal }) {
128
+ const out = {};
129
+ for (const check of CHECKS) {
130
+ out[check.kind] = signal?.aborted
131
+ ? { kind: check.kind, ran: false, command: null, source: "none", passed: null, output: "cancelled" }
132
+ : await runOne(check, { cwd, signal });
133
+ }
134
+ return out;
135
+ }
package/src/lib/store.js CHANGED
@@ -126,7 +126,8 @@ class TicketStore extends EventEmitter {
126
126
  acceptanceCriteria: [],
127
127
  analyzing: false, // transient: the read-only Analyze pass is in flight
128
128
  // Staging stage (Feature 9).
129
- testResult: null, // { ran, command, source, passed, code, output }
129
+ testResult: null, // { ran, command, source, passed, code, output } — the test check
130
+ checks: null, // { test, lint, build } — every deterministic check ouro ran for QA
130
131
  previewUrl: null, // clickable local-preview link while one is running
131
132
  previewNote: null, // why there's no preview, when there isn't
132
133
  qaVerdict: null, // { ready, summary, reasons, uiChange, visualMethod, questions }
@@ -210,6 +211,7 @@ class TicketStore extends EventEmitter {
210
211
  awaitingQa: false,
211
212
  escalated: false,
212
213
  testResult: null,
214
+ checks: null,
213
215
  qaVerdict: null,
214
216
  qaAttempts: 0,
215
217
  previewUrl: null,
@@ -8,7 +8,7 @@ import { analyze, runAgent, planTicket, executeTicket, qaReview, getBackendName,
8
8
  import { createTicketWorktree, diffWorktree } from "../lib/worktree.js";
9
9
  import { contextManifest, listContextFiles, listReferencedFiles } from "../lib/artifacts.js";
10
10
  import { appendRunLog, readRunLog } from "../lib/ouroLog.js";
11
- import { runTests } from "../lib/staging.js";
11
+ import { runChecks } from "../lib/staging.js";
12
12
  import { startPreview, stopPreview, previewInfo } from "../lib/preview.js";
13
13
  import { readConfig, writeConfig, getDefaultMode, setDefaultMode, getAutoShip, getMaxQaAttempts, telegramTokenVar } from "../lib/config.js";
14
14
  import { readEnvVars, writeEnvVars } from "../lib/env.js";
@@ -60,7 +60,7 @@ export function shipOutcome(result) {
60
60
 
61
61
  // The full context the Senior QA Engineer agent judges against. ouro has already
62
62
  // run the tests; the agent validates the running result and the visual review.
63
- export function qaPrompt(ticket, testResult, preview) {
63
+ export function qaPrompt(ticket, checks, preview) {
64
64
  const parts = [
65
65
  `You are the QA gate validating ticket [#${ticket.id}] in its git worktree, after it was implemented. You have READ-ONLY tools (Read/Grep/Glob) — you validate, you never modify or execute.`,
66
66
  "",
@@ -78,16 +78,19 @@ export function qaPrompt(ticket, testResult, preview) {
78
78
  parts.push("", "No explicit acceptance criteria were recorded — validate against the ticket intent above.");
79
79
  }
80
80
 
81
- parts.push("", "Tests (ouro already ran these — you cannot and need not re-run them):");
82
- if (testResult?.ran) {
83
- parts.push(
84
- `- command: ${testResult.command}`,
85
- `- result: ${testResult.passed ? "PASSED" : "FAILED"} (exit ${testResult.code})`,
86
- "- output tail:",
87
- testResult.output || "(no output)"
88
- );
89
- } else {
90
- parts.push(`- none run (${testResult?.output || "no test command resolved"})`);
81
+ parts.push("", "Checks (ouro already ran these — you cannot and need not re-run them):");
82
+ for (const [label, r] of [
83
+ ["Tests", checks?.test],
84
+ ["Lint", checks?.lint],
85
+ ["Build", checks?.build],
86
+ ]) {
87
+ if (r?.ran) {
88
+ parts.push(`- ${label}: ${r.passed ? "PASSED" : "FAILED"} (exit ${r.code}) — ${r.command}`);
89
+ // Only the failing output earns space in the prompt; a PASSED line is enough.
90
+ if (!r.passed) parts.push(" output tail:", r.output || "(no output)");
91
+ } else {
92
+ parts.push(`- ${label}: not run — no such command in this repo (N/A, not a failure).`);
93
+ }
91
94
  }
92
95
 
93
96
  parts.push("", "Change under review (git diff of the worktree):");
@@ -102,8 +105,8 @@ export function qaPrompt(ticket, testResult, preview) {
102
105
  "2. So if it IS a UI change, read the changed UI files and any rendered/built HTML with your Read tool and assess visually from those — do NOT silently skip UI validation.",
103
106
  "3. If it is not a UI change, tests-only is fine.",
104
107
  "",
105
- "Judge the running result against the acceptance criteria and the test results. Never call something ready just because it was asked for.",
106
- 'Respond with ONLY a JSON object, no prose and no fences: {"ready": boolean, "summary": string, "reasons": string[], "ui_change": boolean, "visual_method": "screenshot"|"html"|"none", "questions": string[]}. When not ready, `reasons` must be concrete and actionable. `questions` are for the human in human-in-loop mode.'
108
+ "Judge the running result against the acceptance criteria and the check results above. A check that RAN and FAILED is grounds to send this back. A check listed as not run (no such command in this repo) is N/A — never block, and never raise a question, solely because a check was not run or could not be verified; ouro runs every check it can and you cannot run more. Never call something ready just because it was asked for.",
109
+ 'Respond with ONLY a JSON object, no prose and no fences: {"ready": boolean, "summary": string, "reasons": string[], "ui_change": boolean, "visual_method": "screenshot"|"html"|"none", "questions": string[]}. When not ready, `reasons` must be concrete and actionable. `questions` are open items to resolve before shipping — in an agent loop the engineer answers them itself, in human-in-loop the person does.'
107
110
  );
108
111
  return parts.join("\n");
109
112
  }
@@ -139,10 +142,25 @@ export function qaSummaryLine(v) {
139
142
  }
140
143
 
141
144
  export function qaFeedbackBlock(v) {
142
- if (!v?.reasons?.length) return "";
143
- return `\n\nThe QA gate sent this back. Address every point before it can ship:\n${v.reasons
144
- .map((r, i) => `${i + 1}. ${r}`)
145
- .join("\n")}`;
145
+ const blocks = [];
146
+ if (v?.reasons?.length) {
147
+ blocks.push(
148
+ `The QA gate sent this back. Address every point before it can ship:\n${v.reasons
149
+ .map((r, i) => `${i + 1}. ${r}`)
150
+ .join("\n")}`
151
+ );
152
+ }
153
+ // Agent loop: no human will answer QA's questions, so the engineer resolves
154
+ // them itself. Only reached on the agent-loop loop-back path (enterStaging),
155
+ // so framing them as "yours to answer" is always accurate here.
156
+ if (v?.questions?.length) {
157
+ blocks.push(
158
+ `QA raised these open questions. This is an agent loop — no human will answer them, so resolve each one yourself: investigate the code, decide, act on it, and state the answer in your final message:\n${v.questions
159
+ .map((q, i) => `${i + 1}. ${q}`)
160
+ .join("\n")}`
161
+ );
162
+ }
163
+ return blocks.length ? `\n\n${blocks.join("\n\n")}` : "";
146
164
  }
147
165
 
148
166
  /**
@@ -305,21 +323,22 @@ export function createServer() {
305
323
  while (!signal.aborted) {
306
324
  const ticket = store.get(ticketId);
307
325
 
308
- // 1. Tests — ouro runs them, deterministically.
309
- const testResult = await runTests({ cwd: ticket.worktree, signal });
310
- store.update(ticketId, { testResult });
326
+ // 1. Checks — ouro runs test, lint, and build deterministically. testResult
327
+ // stays the test result (the card renders it); checks carries all three.
328
+ const checks = await runChecks({ cwd: ticket.worktree, signal });
329
+ store.update(ticketId, { testResult: checks.test, checks });
311
330
  if (signal.aborted) return;
312
331
 
313
332
  // 2. QA agent judges the running result against the acceptance criteria.
314
333
  const verdict = normalizeVerdict(
315
334
  await qaReview({
316
- prompt: qaPrompt(ticket, testResult, previewInfo(ticketId)),
335
+ prompt: qaPrompt(ticket, checks, previewInfo(ticketId)),
317
336
  cwd: ticket.worktree,
318
337
  signal,
319
338
  onEvent,
320
339
  agent: getAgent("senior-qa-engineer"),
321
340
  }).catch(() => null),
322
- testResult
341
+ checks.test
323
342
  );
324
343
  if (signal.aborted) return;
325
344
  const attempt = (store.get(ticketId).qaAttempts ?? 0) + 1;