From e558f4d5bbe13223fcc02388824eb728b08e9520 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:59:13 -0400 Subject: [PATCH 1/3] feat(dashboard): reduce poll/render cost with leaner usage queries and UI refinements --- README.md | 4 +- src/dashboard/app-bundle.ts | 4 +- src/dashboard/app/app.ts | 267 +++++-- src/dashboard/app/components.ts | 158 ++-- src/dashboard/app/helpers.ts | 40 +- src/dashboard/data.ts | 55 +- src/dashboard/render.ts | 6 + src/dashboard/server.ts | 5 +- src/loop/token-usage.ts | 58 +- src/storage/repos/loop-session-usage-repo.ts | 20 +- src/storage/repos/plans-repo.ts | 13 + src/storage/repos/section-plans-repo.ts | 14 + src/tools/loop.ts | 22 +- src/utils/loop-format.ts | 52 +- test/dashboard/app-dom.test.ts | 727 ++++++++++++++++++- test/dashboard/app-helpers.test.ts | 89 ++- test/dashboard/data.test.ts | 303 +++++++- test/dashboard/server.test.ts | 81 ++- test/loop-format.test.ts | 130 +++- test/loop-session-usage-repo.test.ts | 109 ++- test/loop-status-tool.test.ts | 84 +++ test/loop/token-usage.test.ts | 105 +++ 22 files changed, 2142 insertions(+), 204 deletions(-) diff --git a/README.md b/README.md index 2be2a115f7..1098be522e 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,9 @@ All endpoints are read-only (non-GET requests return 404): | Endpoint | Description | |----------|-------------| | `GET /` | HTML page (inlined SolidJS app) | -| `GET /api/data` | JSON snapshot of Forge loop/project state | +| `GET /api/data` | JSON snapshot of Forge loop/project state. Accepts optional `project` and `loop` query parameters (`/api/data?project=&loop=`) to scope the payload: per-loop text (`plan`, `goal`, `lastAuditResult`, `postActionReport`, `sections`, `amendments`) is materialised only for the scoped loop, and `transitions` only for the scoped project; `findings`, `usage`, and `duration` remain populated for every loop. `hasPlan` and `sectionCount` are always populated so the tab set and section counts render correctly while detail is in flight. | + +In the browser, the loop table, repo findings list, plans list, and loop picker cap their rendered rows behind a "Showing N of M" affordance (a "Show all" toggle expands the loop table, findings, and plans lists). ## Screenshots diff --git a/src/dashboard/app-bundle.ts b/src/dashboard/app-bundle.ts index 6dbdd20502..8af870aef7 100644 --- a/src/dashboard/app-bundle.ts +++ b/src/dashboard/app-bundle.ts @@ -1,3 +1,3 @@ // Auto-generated from src/dashboard/app. Do not edit. -export const DASHBOARD_APP_BUNDLE: string = "var j={context:void 0,registry:void 0,effects:void 0,done:!1,getContextId(){return vn(this.context.count)},getNextContextId(){return vn(this.context.count++)}};function vn(e){let n=String(e),t=n.length-1;return j.context.id+(t?String.fromCharCode(96+t):\"\")+n}function Qe(e){j.context=e}function Ni(){return{...j.context,id:j.getNextContextId(),count:0}}var Pi=!0,Ii=(e,n)=>e===n,X=Symbol(\"solid-proxy\"),Vi=typeof Proxy===\"function\",Le=Symbol(\"solid-track\"),Gn=Symbol(\"solid-dev-component\"),Pe={equals:Ii},Fn=null,zn=Jn,q=1,we=2,Ti={};var N=null,O=null,$e=null,ue=null,v=null,U=null,K=null,Ve=0,Z={afterUpdate:null,afterCreateOwner:null,afterCreateSignal:null,afterRegisterGraph:null};function Yn(e,n){let t=v,i=N,f=e.length===0,u=n===void 0?i:n,r=f?{owned:null,cleanups:null,context:null,owner:null}:{owned:null,cleanups:null,context:u?u.context:null,owner:u},c=f?()=>e(()=>{throw Error(\"Dispose method must be an explicit argument to createRoot function\")}):()=>e(()=>G(()=>p(r)));Z.afterCreateOwner&&Z.afterCreateOwner(r),N=r,v=null;try{return Q(c,!0)}finally{v=t,N=i}}function F(e,n){n=n?Object.assign({},Pe,n):Pe;let t={value:e,observers:null,observerSlots:null,comparator:n.equals||void 0};{if(n.name)t.name=n.name;if(n.internal)t.internal=!0;else if(Xn(t),Z.afterCreateSignal)Z.afterCreateSignal(t)}let i=(f)=>{if(typeof f===\"function\")if(O&&O.running&&O.sources.has(t))f=f(t.tValue);else f=f(t.value);return Be(t,f)};return[Zn.bind(t),i]}function R(e,n,t){let i=_e(e,n,!1,q,t);if($e&&O&&O.running)U.push(i);else ce(i)}function m(e,n,t){zn=Di;let i=_e(e,n,!1,q,t),f=Re&&ji(Re);if(f)i.suspense=f;if(!t||!t.render)i.user=!0;K?K.push(i):ce(i)}function w(e,n,t){t=t?Object.assign({},Pe,t):Pe;let i=_e(e,n,!0,0,t);if(i.observers=null,i.observerSlots=null,i.comparator=t.equals||void 0,$e&&O&&O.running)i.tState=q,U.push(i);else ce(i);return Zn.bind(i)}function qn(e){return Q(e,!1)}function G(e){if(!ue&&v===null)return e();let n=v;v=null;try{if(ue)return ue.untrack(e);return e()}finally{v=n}}function Hn(e){m(()=>G(e))}function re(e){if(N===null)console.warn(\"cleanups created outside a `createRoot` or `render` will never be run\");else if(N.cleanups===null)N.cleanups=[e];else N.cleanups.push(e);return e}function Te(){return v}function _i(e){if(O&&O.running)return e(),O.done;let n=v,t=N;return Promise.resolve().then(()=>{v=n,N=t;let i;if($e||Re)i=O||(O={sources:new Set,effects:[],promises:new Set,disposed:new Set,queue:new Set,running:!0}),i.done||(i.done=new Promise((f)=>i.resolve=f)),i.running=!0;return Q(e,!1),v=N=null,i?i.done:void 0})}var[iu,Dn]=F(!1);function Un(e,n){let t=_e(()=>G(()=>{return Object.assign(e,{[Gn]:!0}),e(n)}),void 0,!0,0);return t.props=n,t.observers=null,t.observerSlots=null,t.name=e.name,t.component=e,ce(t),t.tValue!==void 0?t.tValue:t.value}function Xn(e){if(N){if(N.sourceMap)N.sourceMap.push(e);else N.sourceMap=[e];e.graph=N}if(Z.afterRegisterGraph)Z.afterRegisterGraph(e)}function ji(e){let n;return N&&N.context&&(n=N.context[e.id])!==void 0?n:e.defaultValue}var Re;function Zn(){let e=O&&O.running;if(this.sources&&(e?this.tState:this.state))if((e?this.tState:this.state)===q)ce(this);else{let n=U;U=null,Q(()=>Ie(this),!1),U=n}if(v){let n=this.observers?this.observers.length:0;if(!v.sources)v.sources=[this],v.sourceSlots=[n];else v.sources.push(this),v.sourceSlots.push(n);if(!this.observers)this.observers=[v],this.observerSlots=[v.sources.length-1];else this.observers.push(v),this.observerSlots.push(v.sources.length-1)}if(e&&O.sources.has(this))return this.tValue;return this.value}function Be(e,n,t){let i=O&&O.running&&O.sources.has(e)?e.tValue:e.value;if(!e.comparator||!e.comparator(i,n)){if(O){let f=O.running;if(f||!t&&O.sources.has(e))O.sources.add(e),e.tValue=n;if(!f)e.value=n}else e.value=n;if(e.observers&&e.observers.length)Q(()=>{for(let f=0;f1e6){if(U=[],Pi)throw Error(\"Potential Infinite Loop Detected.\");throw Error()}},!1)}return n}function ce(e){if(!e.fn)return;p(e);let n=Ve;if(Wn(e,O&&O.running&&O.sources.has(e)?e.tValue:e.value,n),O&&!O.running&&O.sources.has(e))queueMicrotask(()=>{Q(()=>{O&&(O.running=!0),v=N=e,Wn(e,e.tValue,n),v=N=null},!1)})}function Wn(e,n,t){let i,f=N,u=v;v=N=e;try{i=e.fn(n)}catch(r){if(e.pure)if(O&&O.running)e.tState=q,e.tOwned&&e.tOwned.forEach(p),e.tOwned=void 0;else e.state=q,e.owned&&e.owned.forEach(p),e.owned=null;return e.updatedAt=t+1,me(r)}finally{v=u,N=f}if(!e.updatedAt||e.updatedAt<=t){if(e.updatedAt!=null&&\"observers\"in e)Be(e,i,!0);else if(O&&O.running&&e.pure){if(!O.sources.has(e))e.value=i;O.sources.add(e),e.tValue=i}else e.value=i;e.updatedAt=t}}function _e(e,n,t,i=q,f){let u={fn:e,state:i,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:n,owner:N,context:N?N.context:null,pure:t};if(O&&O.running)u.state=0,u.tState=i;if(N===null)console.warn(\"computations created outside a `createRoot` or `render` will never be disposed\");else if(N!==Ti)if(O&&O.running&&N.pure)if(!N.tOwned)N.tOwned=[u];else N.tOwned.push(u);else if(!N.owned)N.owned=[u];else N.owned.push(u);if(f&&f.name)u.name=f.name;if(ue&&u.fn){let r=u.fn,[c,l]=F(void 0,{equals:!1}),a=ue.factory(r,l);re(()=>a.dispose());let d,b=()=>_i(l).then(()=>{if(d)d.dispose(),d=void 0});u.fn=(E)=>{if(c(),O&&O.running){if(!d)d=ue.factory(r,b);return d.track(E)}return a.track(E)}}return Z.afterCreateOwner&&Z.afterCreateOwner(u),u}function be(e){let n=O&&O.running;if((n?e.tState:e.state)===0)return;if((n?e.tState:e.state)===we)return Ie(e);if(e.suspense&&G(e.suspense.inFallback))return e.suspense.effects.push(e);let t=[e];while((e=e.owner)&&(!e.updatedAt||e.updatedAt=0;i--){if(e=t[i],n){let f=e,u=t[i+1];while((f=f.owner)&&f!==u)if(O.disposed.has(f))return}if((n?e.tState:e.state)===q)ce(e);else if((n?e.tState:e.state)===we){let f=U;U=null,Q(()=>Ie(e,t[0]),!1),U=f}}}function Q(e,n){if(U)return e();let t=!1;if(!n)U=[];if(K)t=!0;else K=[];Ve++;try{let i=e();return vi(t),i}catch(i){if(!t)K=null;U=null,me(i)}}function vi(e){if(U){if($e&&O&&O.running)Fi(U);else Jn(U);U=null}if(e)return;let n;if(O){if(!O.promises.size&&!O.queue.size){let{sources:i,disposed:f}=O;K.push.apply(K,O.effects),n=O.resolve;for(let u of K)\"tState\"in u&&(u.state=u.tState),delete u.tState;O=null,Q(()=>{for(let u of f)p(u);for(let u of i){if(u.value=u.tValue,u.owned)for(let r=0,c=u.owned.length;rzn(t),!1);else Z.afterUpdate&&Z.afterUpdate();if(n)n()}function Jn(e){for(let n=0;n{i.delete(t),Q(()=>{O.running=!0,be(t)},!1),O&&(O.running=!1)})}}function Di(e){let n,t=0;for(n=0;n=0;n--)p(e.tOwned[n]);delete e.tOwned}if(O&&O.running&&e.pure)Rn(e,!0);else if(e.owned){for(n=e.owned.length-1;n>=0;n--)p(e.owned[n]);e.owned=null}if(e.cleanups){for(n=e.cleanups.length-1;n>=0;n--)e.cleanups[n]();e.cleanups=null}if(O&&O.running)e.tState=0;else e.state=0;delete e.sourceMap}function Rn(e,n){if(!n)e.tState=0,O.disposed.add(e);if(e.owned)for(let t=0;t=0;c--){let l=Je(e[c])[r];if(l!==void 0)return l}},has(r){for(let c=e.length-1;c>=0;c--)if(r in Je(e[c]))return!0;return!1},keys(){let r=[];for(let c=0;c=0;r--){let c=e[r];if(!c)continue;let l=Object.getOwnPropertyNames(c);for(let a=l.length-1;a>=0;a--){let d=l[a];if(d===\"__proto__\"||d===\"constructor\")continue;let b=Object.getOwnPropertyDescriptor(c,d);if(!i[d])i[d]=b.get?{enumerable:!0,configurable:!0,get:Gi.bind(t[d]=[b.get.bind(c)])}:b.value!==void 0?b:void 0;else{let E=t[d];if(E){if(b.get)E.push(b.get.bind(c));else if(b.value!==void 0)E.push(()=>b.value)}}}}let f={},u=Object.keys(i);for(let r=u.length-1;r>=0;r--){let c=u[r],l=i[c];if(l&&l.get)Object.defineProperty(f,c,l);else f[c]=l?l.value:void 0}return f}var Ln={hooks:Z,writeSignal:Be,registerGraph:Xn};if(globalThis)if(!globalThis.Solid$$)globalThis.Solid$$=!0;else console.warn(\"You appear to have multiple instances of Solid. This can lead to unexpected behavior.\");var Yi=[\"allowfullscreen\",\"async\",\"alpha\",\"autofocus\",\"autoplay\",\"checked\",\"controls\",\"default\",\"disabled\",\"formnovalidate\",\"hidden\",\"indeterminate\",\"inert\",\"ismap\",\"loop\",\"multiple\",\"muted\",\"nomodule\",\"novalidate\",\"open\",\"playsinline\",\"readonly\",\"required\",\"reversed\",\"seamless\",\"selected\",\"adauctionheaders\",\"browsingtopics\",\"credentialless\",\"defaultchecked\",\"defaultmuted\",\"defaultselected\",\"defer\",\"disablepictureinpicture\",\"disableremoteplayback\",\"preservespitch\",\"shadowrootclonable\",\"shadowrootcustomelementregistry\",\"shadowrootdelegatesfocus\",\"shadowrootserializable\",\"sharedstoragewritable\"],nn=new Set([\"className\",\"value\",\"readOnly\",\"noValidate\",\"formNoValidate\",\"isMap\",\"noModule\",\"playsInline\",\"adAuctionHeaders\",\"allowFullscreen\",\"browsingTopics\",\"defaultChecked\",\"defaultMuted\",\"defaultSelected\",\"disablePictureInPicture\",\"disableRemotePlayback\",\"preservesPitch\",\"shadowRootClonable\",\"shadowRootCustomElementRegistry\",\"shadowRootDelegatesFocus\",\"shadowRootSerializable\",\"sharedStorageWritable\",...Yi]),tn=new Set([\"innerHTML\",\"textContent\",\"innerText\",\"children\"]),fn=Object.assign(Object.create(null),{className:\"class\",htmlFor:\"for\"}),qi=Object.assign(Object.create(null),{class:\"className\",novalidate:{$:\"noValidate\",FORM:1},formnovalidate:{$:\"formNoValidate\",BUTTON:1,INPUT:1},ismap:{$:\"isMap\",IMG:1},nomodule:{$:\"noModule\",SCRIPT:1},playsinline:{$:\"playsInline\",VIDEO:1},readonly:{$:\"readOnly\",INPUT:1,TEXTAREA:1},adauctionheaders:{$:\"adAuctionHeaders\",IFRAME:1},allowfullscreen:{$:\"allowFullscreen\",IFRAME:1},browsingtopics:{$:\"browsingTopics\",IMG:1},defaultchecked:{$:\"defaultChecked\",INPUT:1},defaultmuted:{$:\"defaultMuted\",AUDIO:1,VIDEO:1},defaultselected:{$:\"defaultSelected\",OPTION:1},disablepictureinpicture:{$:\"disablePictureInPicture\",VIDEO:1},disableremoteplayback:{$:\"disableRemotePlayback\",AUDIO:1,VIDEO:1},preservespitch:{$:\"preservesPitch\",AUDIO:1,VIDEO:1},shadowrootclonable:{$:\"shadowRootClonable\",TEMPLATE:1},shadowrootdelegatesfocus:{$:\"shadowRootDelegatesFocus\",TEMPLATE:1},shadowrootserializable:{$:\"shadowRootSerializable\",TEMPLATE:1},sharedstoragewritable:{$:\"sharedStorageWritable\",IFRAME:1,IMG:1}});function un(e,n){let t=qi[e];return typeof t===\"object\"?t[n]?t.$:void 0:t}var rn=new Set([\"beforeinput\",\"click\",\"dblclick\",\"contextmenu\",\"focusin\",\"focusout\",\"input\",\"keydown\",\"keyup\",\"mousedown\",\"mousemove\",\"mouseout\",\"mouseover\",\"mouseup\",\"pointerdown\",\"pointermove\",\"pointerout\",\"pointerover\",\"pointerup\",\"touchend\",\"touchmove\",\"touchstart\"]),et=new Set([\"altGlyph\",\"altGlyphDef\",\"altGlyphItem\",\"animate\",\"animateColor\",\"animateMotion\",\"animateTransform\",\"circle\",\"clipPath\",\"color-profile\",\"cursor\",\"defs\",\"desc\",\"ellipse\",\"feBlend\",\"feColorMatrix\",\"feComponentTransfer\",\"feComposite\",\"feConvolveMatrix\",\"feDiffuseLighting\",\"feDisplacementMap\",\"feDistantLight\",\"feDropShadow\",\"feFlood\",\"feFuncA\",\"feFuncB\",\"feFuncG\",\"feFuncR\",\"feGaussianBlur\",\"feImage\",\"feMerge\",\"feMergeNode\",\"feMorphology\",\"feOffset\",\"fePointLight\",\"feSpecularLighting\",\"feSpotLight\",\"feTile\",\"feTurbulence\",\"filter\",\"font\",\"font-face\",\"font-face-format\",\"font-face-name\",\"font-face-src\",\"font-face-uri\",\"foreignObject\",\"g\",\"glyph\",\"glyphRef\",\"hkern\",\"image\",\"line\",\"linearGradient\",\"marker\",\"mask\",\"metadata\",\"missing-glyph\",\"mpath\",\"path\",\"pattern\",\"polygon\",\"polyline\",\"radialGradient\",\"rect\",\"set\",\"stop\",\"svg\",\"switch\",\"symbol\",\"text\",\"textPath\",\"tref\",\"tspan\",\"use\",\"view\",\"vkern\"]),cn={xlink:\"http://www.w3.org/1999/xlink\",xml:\"http://www.w3.org/XML/1998/namespace\"};function Hi(e,n,t){let i=t.length,f=n.length,u=i,r=0,c=0,l=n[f-1].nextSibling,a=null;while(rd-c){let o=n[r];while(c{f=u,n===document?e():hn(n,e(),n.firstChild?null:void 0,t)},i.owner),()=>{f(),n.textContent=\"\"}}function ln(e,n=window.document){let t=n[Bn]||(n[Bn]=new Set);for(let i=0,f=e.length;if.call(e,t[1],u))}else e.addEventListener(n,t,typeof t!==\"function\"&&t)}function dn(e,n,t={}){let i=Object.keys(n||{}),f=Object.keys(t),u,r;for(u=0,r=f.length;uf.children=se(e,n.children,f.children));return R(()=>typeof n.ref===\"function\"&&Ji(n.ref,e)),R(()=>Qi(e,n,t,!0,f,!0)),f}function it(e,n){let t=e[n];return Object.defineProperty(e,n,{get(){return t()},enumerable:!0}),e}function Ji(e,n,t){return G(()=>e(n,t))}function hn(e,n,t,i){if(t!==void 0&&!i)i=[];if(typeof n!==\"function\")return se(e,n,i,t);R((f)=>se(e,n(),f,t),i)}function Qi(e,n,t,i,f={},u=!1){n||(n={});for(let r in f)if(!(r in n)){if(r===\"children\")continue;f[r]=Mn(e,r,null,f[r],t,u,n)}for(let r in n){if(r===\"children\"){if(!i)se(e,n.children);continue}let c=n[r];f[r]=Mn(e,r,c,f[r],t,u,n)}}function oe(e){return!!j.context&&!j.done&&(!e||e.isConnected)}function Ri(e){return e.toLowerCase().replace(/-([a-z])/g,(n,t)=>t.toUpperCase())}function mn(e,n,t){let i=n.trim().split(/\\s+/);for(let f=0,u=i.length;f-1&&cn[n.split(\":\")[0]];if(E)sn(e,E,n,t);else xe(e,fn[n]||n,t)}return t}function Li(e){if(j.registry&&j.events){if(j.events.find(([l,a])=>a===e))return}let n=e.target,t=`$$${e.type}`,i=e.target,f=e.currentTarget,u=(l)=>Object.defineProperty(e,\"target\",{configurable:!0,value:l}),r=()=>{let l=n[t];if(l&&!n.disabled){let a=n[`${t}Data`];if(a!==void 0?l.call(n,a,e):l.call(n,e),e.cancelBubble)return}return n.host&&typeof n.host!==\"string\"&&!n.host._$host&&n.contains(e.target)&&u(n.host),!0},c=()=>{while(r()&&(n=n._$host||n.parentNode||n.host));};if(Object.defineProperty(e,\"currentTarget\",{configurable:!0,get(){return n||document}}),j.registry&&!j.done)j.done=_$HY.done=!0;if(e.composedPath){let l=e.composedPath();u(l[0]);for(let a=0;a{let l=n();while(typeof l===\"function\")l=l();t=se(e,l,t,i)}),()=>t;else if(Array.isArray(n)){let l=[],a=t&&Array.isArray(t);if(en(l,n,t,f))return R(()=>t=se(e,l,t,i,!0)),()=>t;if(u){if(!l.length)return t;if(i===void 0)return t=[...e.childNodes];let d=l[0];if(d.parentNode!==e)return t;let b=[d];while((d=d.nextSibling)!==i)b.push(d);return t=b}if(l.length===0){if(t=le(e,t,i),c)return t}else if(a)if(t.length===0)pn(e,l,i);else Hi(e,t,l);else t&&le(e),pn(e,l);t=l}else if(n.nodeType){if(u&&n.parentNode)return t=c?[n]:n;if(Array.isArray(t)){if(c)return t=le(e,t,i,n);le(e,t,null,n)}else if(t==null||t===\"\"||!e.firstChild)e.appendChild(n);else e.replaceChild(n,e.firstChild);t=n}else console.warn(\"Unrecognized value. Skipped inserting\",n);return t}function en(e,n,t,i){let f=!1;for(let u=0,r=n.length;u=0;r--){let c=n[r];if(f!==c){let l=c.parentNode===e;if(!u&&!r)l?e.replaceChild(f,c):e.insertBefore(f,t);else l&&c.remove()}else u=!0}}else e.insertBefore(f,t);return[f]}var $u=Symbol();var yn=Symbol(\"store-raw\"),ae=Symbol(\"store-node\"),M=Symbol(\"store-has\"),ut=Symbol(\"store-self\"),ft={onStoreNodeUpdate:null};function rt(e){let n=e[X];if(!n){if(Object.defineProperty(e,X,{value:n=new Proxy(e,Mi)}),!Array.isArray(e)){let t=Object.keys(e),i=Object.getOwnPropertyDescriptors(e);for(let f=0,u=t.length;fe[X][n],t}function ct(e){Te()&&Ee(je(e,ae),ut)()}function mi(e){return ct(e),Reflect.ownKeys(e)}var Mi={get(e,n,t){if(n===yn)return e;if(n===X)return t;if(n===Le)return ct(e),t;let i=je(e,ae),f=i[n],u=f?f():e[n];if(n===ae||n===M||n===\"__proto__\")return u;if(!f){let r=Object.getOwnPropertyDescriptor(e,n);if(Te()&&(typeof u!==\"function\"||e.hasOwnProperty(n))&&!(r&&r.get))u=Ee(i,n,u)()}return ee(u)?rt(u):u},has(e,n){if(n===yn||n===X||n===Le||n===ae||n===M||n===\"__proto__\")return!0;return Te()&&Ee(je(e,M),n)(),n in e},set(){return console.warn(\"Cannot mutate a Store directly\"),!0},deleteProperty(){return console.warn(\"Cannot mutate a Store directly\"),!0},ownKeys:mi,getOwnPropertyDescriptor:Bi};function H(e,n,t,i=!1){if(!i&&e[n]===t)return;let f=e[n],u=e.length;if(ft.onStoreNodeUpdate&&ft.onStoreNodeUpdate(e,n,t,f),t===void 0){if(delete e[n],e[M]&&e[M][n]&&f!==void 0)e[M][n].$()}else if(e[n]=t,e[M]&&e[M][n]&&f===void 0)e[M][n].$();let r=je(e,ae),c;if(c=Ee(r,n,f))c.$(()=>t);if(Array.isArray(e)&&e.length!==u){for(let l=e.length;l1){i=n.shift();let r=typeof i,c=Array.isArray(e);if(Array.isArray(i)){for(let l=0;l1){Oe(e[i],n,[i].concat(t));return}f=e[i],t=[i].concat(t)}let u=n[0];if(typeof u===\"function\"){if(u=u(f,t),u===f)return}if(i===void 0&&u==null)return;if(u=he(u),i===void 0||ee(f)&&ee(u)&&!Array.isArray(u))lt(f,u);else H(e,i,u)}function st(...[e,n]){let t=he(e||{}),i=Array.isArray(t);if(typeof t!==\"object\"&&typeof t!==\"function\")throw Error(`Unexpected type ${typeof t} received when initializing 'createStore'. Expected an object.`);let f=rt(t);Ln.registerGraph({value:t,name:n&&n.name});function u(...r){qn(()=>{i&&r.length===1?pi(t,r[0]):Oe(t,r)})}return[f,u]}var gn=Symbol(\"store-root\");function de(e,n,t,i,f){let u=n[t];if(e===u)return;let r=Array.isArray(e);if(t!==gn&&(!ee(e)||!ee(u)||r!==Array.isArray(u)||f&&e[f]!==u[f])){H(n,t,e);return}if(r){if(e.length&&u.length&&(!i||f&&e[0]&&e[0][f]!=null)){let a,d,b,E,g,o,s,x;for(b=0,E=Math.min(u.length,e.length);b=b&&g>=b&&(u[E]===e[g]||f&&u[E]&&e[g]&&u[E][f]&&u[E][f]===e[g][f]);E--,g--)k[g]=u[E];if(b>g||b>E){for(d=b;d<=g;d++)H(u,d,e[d]);for(;de.length)H(u,\"length\",e.length);return}s=Array(g+1);for(d=g;d>=b;d--)o=e[d],x=f&&o?o[f]:o,a=P.get(x),s[d]=a===void 0?-1:a,P.set(x,d);for(a=b;a<=E;a++)if(o=u[a],x=f&&o?o[f]:o,d=P.get(x),d!==void 0&&d!==-1)k[d]=u[a],d=s[d],P.set(x,d);for(d=b;de.length)H(u,\"length\",e.length);return}let c=Object.keys(e);for(let a=0,d=c.length;a{if(!ee(u)||!ee(f))return f;let r=de(f,{[gn]:u},gn,t,i);return r===void 0?u:r}}var ef=/(?:|<(?:\"[^\"]*\"['\"]*|'[^']*'['\"]*|[^'\">])+>)/g,nf=/(?:\\s(?[^/\\s><=]+?)(?=[\\s/>]))|(?:(?\\S+?)(?:\\s*=\\s*(?:(['\"])(?[\\s\\S]*?)\\3|(?[^\\s>]+))))/g,tf={area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,menuitem:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0};function ff(e){let n={type:\"tag\",name:\"\",voidElement:!1,attrs:[],children:[]},t=e.match(/<\\/?([^\\s]+?)[/\\s>]/);if(t){if(n.name=t[1],tf[t[1].toLowerCase()]||e.charAt(e.length-2)===\"/\")n.voidElement=!0;if(n.name.startsWith(\"!--\")){let f=e.indexOf(\"-->\");return{type:\"comment\",comment:f!==-1?e.slice(4,f):\"\"}}}let i=new RegExp(nf);for(let f of e.matchAll(i))if((f[1]||f[2]).startsWith(\"use:\"))n.attrs.push({type:\"directive\",name:f[1]||f[2],value:f[4]||f[5]||\"\"});else n.attrs.push({type:\"attr\",name:f[1]||f[2],value:f[4]||f[5]||\"\"});return n}function dt(e,n,t){let i=n.indexOf(\"<\",t),f=n.slice(t,i===-1?void 0:i);if(!/^\\s*$/.test(f))e.push({type:\"text\",content:f})}function at(e,n){let t=n.replace(\"\",\"\");if(!/^\\s*$/.test(t))e.push({type:\"comment\",content:t})}function uf(e){let n=[],t=void 0,i=-1,f=[],u={};return e.replace(ef,(r,c)=>{let l=r.charAt(1)!==\"/\",a=r.slice(0,4)===\"\"}}function cf(e){return e.reduce(function(n,t){return n+gt(\"\",t)},\"\")}var ht=new Map,lf=/^(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$/i,ve=\" \\\\f\\\\n\\\\r\\\\t\",wt=\"[^\"+ve+`\\\\/>\"'=]+`,bn=\"[ \"+ve+\"]+(?:use:|\"+wt+\")\",bt=\"<([A-Za-z$#]+[A-Za-z0-9:_-]*)((?:\",$t=`(?:\\\\s*=\\\\s*(?:'[^']*?'|\"[^\"]*?\"|\\\\([^)]*?\\\\)|<[^>]*?>|`+wt+\"))?)\",sf=new RegExp(bt+bn+$t+\"+)([ \"+ve+\"]*/?>)\",\"g\"),of=new RegExp(\"(\"+bn+`\\\\s*=\\\\s*)(|['\"(]([\\\\w\\\\s]*[\\\\w\\\\s]*)*['\")])`,\"gi\"),df=new RegExp(bt+bn+$t+\"*)([ \"+ve+\"]*/>)\",\"g\"),yt=\"\",af=new Set([\"class\",\"on\",\"oncapture\",\"style\",\"use\",\"prop\",\"attr\"]);function hf(e,n,t,i){return\"<\"+n+t.replace(of,yf)+i}function yf(e,n,t){return n.replace(//g,\"###\")+(t[0]==='\"'||t[0]===\"'\"?t.replace(//g,\"###\"):'\"###\"')}function gf(e,n,t){return lf.test(n)?e:\"<\"+n+t+\">\"}function wf(e){return e.toLowerCase().replace(/-([a-z])/g,(n,t)=>t.toUpperCase())}function wn(e,n,t,i){if(e===\"use:###\"&&n===\"###\"){let f=i.counter++;i.exprs.push(`typeof exprs[${f}] === \"function\" ? r.use(exprs[${f}], ${t}, exprs[${i.counter++}]) : (()=>{throw new Error(\"use:### must be a function\")})()`)}else throw Error(`Not support syntax ${e} must be use:{function}`)}function bf(e,{delegateEvents:n=!0,functionBuilder:t=(...i)=>Function(...i)}={}){let i=1;e.wrapProps=(g)=>{let o=Object.getOwnPropertyDescriptors(g);for(let s in o)if(typeof o[s].value===\"function\"&&!o[s].value.length)e.dynamicProperty(g,s);return g};function f(g,o){let s=0,x=\"\";for(;s\";x=x+g[s],x=[[df,gf],[/<()/g,\"<###\"],[/\\.\\.\\.()/g,\"###\"],[sf,hf],[/>\\n+\\s*/g,\">\"],[/\\n+\\s*\\s+/g,\"> \"]].reduce((V,T)=>{return V.replace(T[0],T[1])},x);let P=uf(x),[S,A]=b(P,o.funcBuilder),C=[];for(let V=0;V-1){let L=_.split(yt).reduce((B,Ce,qe)=>{return qe&&B.push(\"\"),B.push(Ce),B},[]);T[V].firstChild.replaceWith(...L)}}}return C[0].create=A,ht.set(g,C),C}function u(g,o,s,x,k,P,S){let A=x===\"###\"?`!doNotWrap ? exprs[${S.counter}]() : exprs[${S.counter++}]`:x.split(\"###\").map((_,L)=>L?` + (typeof exprs[${S.counter}] === \"function\" ? exprs[${S.counter}]() : exprs[${S.counter++}]) + \"${_}\"`:`\"${_}\"`).join(\"\"),C,V;if((C=s.split(\":\"))&&C[1]&&af.has(C[0]))s=C[1],V=C[0];let T=e.ChildProperties.has(s),W=e.Properties.has(s);if(s===\"style\"){let _=`_$v${i++}`;S.decl.push(`${_}={}`),S.exprs.push(`r.style(${o},${A},${_})`)}else if(s===\"classList\"){let _=`_$v${i++}`;S.decl.push(`${_}={}`),S.exprs.push(`r.classList(${o},${A},${_})`)}else if(V!==\"attr\"&&(T||!k&&(e.getPropAlias(s,g.name.toUpperCase())||W)||P||V===\"prop\")){if(P&&!T&&!W&&V!==\"prop\")s=wf(s);S.exprs.push(`${o}.${e.getPropAlias(s,g.name.toUpperCase())||s} = ${A}`)}else{let _=k&&s.indexOf(\":\")>-1&&e.SVGNamespace[s.split(\":\")[0]];if(_)S.exprs.push(`r.setAttributeNS(${o},\"${_}\",\"${s}\",${A})`);else S.exprs.push(`r.setAttribute(${o},\"${e.Aliases[s]||s}\",${A})`)}}function r(g,o,s,x,k,P,S){if(s.slice(0,2)===\"on\")if(!s.includes(\":\")){let A=s.slice(2).toLowerCase(),C=n&&e.DelegatedEvents.has(A);S.exprs.push(`r.addEventListener(${o},\"${A}\",exprs[${S.counter++}],${C})`),C&&S.delegatedEvents.add(A)}else{let A=s.startsWith(\"oncapture:\");S.exprs.push(`${o}.addEventListener(\"${s.slice(A?10:3)}\",exprs[${S.counter++}]${A?\",true\":\"\"})`)}else if(s===\"ref\")S.exprs.push(`exprs[${S.counter++}](${o})`);else{let A=Object.assign({},S,{exprs:[]}),C=S.counter;if(u(g,o,s,x,k,P,A),S.decl.push(`_fn${C} = (${x===\"###\"?\"doNotWrap\":\"\"}) => {\n${A.exprs.join(`;\n`)};\n}`),x===\"###\")S.exprs.push(`typeof exprs[${C}] === \"function\" ? r.effect(_fn${C}) : _fn${C}(true)`);else{let V=\"\";for(let T=C;T1)for(let k=0;k1?`r.mergeProps(${o.join(\",\")})`:o[0]}function a(g,o){let s=[],x=Object.keys(g.attrs),k=[s],P=o.counter++;for(let A=0;A exprs[${o.counter++}]`);else if(g.children.length){let A={type:\"fragment\",children:g.children},C=Object.assign({},o,{first:!0,decl:[],exprs:[],parent:!1});d(A,C),s.push(`children: () => { ${C.exprs.join(`;\n`)}}`),o.templateId=C.templateId,o.counter=C.counter}let S;if(o.multi)S=`_$el${i++}`,o.decl.push(`${S} = ${o.path}.${o.first?\"firstChild\":\"nextSibling\"}`);if(o.parent)o.exprs.push(`r.insert(${o.parent}, r.createComponent(exprs[${P}],${l(k)})${S?`, ${S}`:\"\"})`);else o.exprs.push(`${o.fragment?\"\":\"return \"}r.createComponent(exprs[${P}],${l(k)})`);o.path=S,o.first=!1}function d(g,o){if(g.type===\"fragment\"){let s=[];g.children.forEach((x)=>{if(x.type===\"tag\"){if(x.name===\"###\"){let S=Object.assign({},o,{first:!0,fragment:!0,decl:[],exprs:[]});a(x,S),s.push(S.exprs[0]),o.counter=S.counter,o.templateId=S.templateId;return}o.templateId++;let k=i,P=Object.assign({},o,{first:!0,decl:[],exprs:[]});o.templateNodes.push([x]),d(x,P),s.push(`function() { ${P.decl.join(`,\n`)+`;\n`+P.exprs.join(`;\n`)+`;\nreturn _$el${k};\n`}}()`),o.counter=P.counter,o.templateId=P.templateId}else if(x.type===\"text\")s.push(`\"${x.content}\"`);else if(x.type===\"comment\"){if(x.content===\"#\")s.push(`exprs[${o.counter++}]`);else if(x.content)for(let k=0;kA.name===\"is\");if(o.hasCustomElement=S,o.isImportNode=(g.name===\"img\"||g.name===\"iframe\")&&g.attrs.some((A)=>A.name===\"loading\"&&A.value===\"lazy\"),g.attrs.some((A)=>A.name===\"###\")){let A=[],C=\"\",V=[];for(let T=0;T({${C}})`),C=\"\";A.push(`exprs[${o.counter++}]`)}else V.push(g.attrs[T]);else if(W===\"directive\")wn(_,L,s,o)}if(g.attrs=V,C.length)A.push(`()=>({${C}})`);o.exprs.push(`r.spread(${s},${A.length===1?`typeof ${A[0]} === \"function\" ? r.mergeProps(${A[0]}) : ${A[0]}`:`r.mergeProps(${A.join(\",\")})`},${P},${!!g.children.length})`)}else for(let A=0;A document.importNode(tmpls[${k}].content.firstChild, true))`:`const ${s} = tmpls[${k}].content.firstChild.cloneNode(true)`}else if(g.type===\"text\"){let s=`_$el${i++}`;o.decl.push(`${s} = ${o.path}.${o.first?\"firstChild\":\"nextSibling\"}`),o.path=s,o.first=!1}else if(g.type===\"comment\"){let s=`_$el${i++}`;if(o.decl.push(`${s} = ${o.path}.${o.first?\"firstChild\":\"nextSibling\"}`),g.content===\"#\")if(o.multi)o.exprs.push(`r.insert(${o.parent}, exprs[${o.counter++}], ${s})`);else o.exprs.push(`r.insert(${o.parent}, exprs[${o.counter++}])`);o.path=s,o.first=!1}}function b(g,o){let s={path:\"\",decl:[],exprs:[],delegatedEvents:new Set,counter:0,first:!0,multi:!1,templateId:0,templateNodes:[]},x=i,k=g,P;if(g.length>1)g=[{type:\"fragment\",children:g}];if(g[0].name===\"###\")P=!0,a(g[0],s);else d(g[0],s);return e.delegateEvents(Array.from(s.delegatedEvents)),[[k].concat(s.templateNodes).map((A)=>cf(A)),o(\"tmpls\",\"exprs\",\"r\",s.decl.join(`,\n`)+`;\n`+s.exprs.join(`;\n`)+(P?\"\":`;\nreturn _$el${x};\n`))]}function E(g,...o){let s=ht.get(g)||f(g,{funcBuilder:t});return s[0].create(s,o,e)}return E}var h=bf({effect:R,style:an,insert:hn,untrack:G,spread:tt,createComponent:Me,delegateEvents:ln,classList:dn,mergeProps:pe,dynamicProperty:it,setAttribute:xe,setAttributeNS:sn,addEventListener:on,Aliases:fn,getPropAlias:un,Properties:nn,ChildProperties:tn,DelegatedEvents:rn,SVGElements:et,SVGNamespace:cn});function ke(e){let n=Math.floor(e/3600),t=Math.floor(e%3600/60),i=e%60;if(n>0)return`${n}h ${t}m`;return t>0?`${t}m ${i}s`:`${i}s`}function Fe(e,n){if(!e)return 0;let t=new Date(e).getTime(),i=n?new Date(n).getTime():Date.now();return Math.round((i-t)/1000)}var $f=new Set([\"loops\",\"groups\",\"findings\",\"plans\"]),xf=new Set([\"overview\",\"timeline\",\"sections\",\"findings\",\"plan\",\"usage\"]);function te(e){try{return decodeURIComponent(e)}catch{return e}}function Of(){return{projectId:null,section:\"loops\",loopName:null,tab:\"overview\",groupId:null,statuses:[],query:\"\"}}function $n(e,n){if(!n)return;for(let t of n.split(\"&\")){if(!t)continue;let i=t.indexOf(\"=\");if(i===-1)continue;let f=te(t.slice(0,i)),u=te(t.slice(i+1));if(f===\"status\")e.statuses=u.split(\",\").map((r)=>r.trim()).filter((r)=>r!==\"\");else if(f===\"q\")e.query=u}}function De(e){let n=Of(),t=(e||\"\").replace(/^#/,\"\");if(!t)return n;let i=t.indexOf(\"?\"),f=i===-1?t:t.slice(0,i),u=i===-1?\"\":t.slice(i+1),r=f.indexOf(\"/\"),c,l;if(r===-1)c=f,l=\"\";else c=f.slice(0,r),l=f.slice(r+1);if(c)n.projectId=te(c);if(!l)return $n(n,u),n;let a=l.split(\"/\").filter((b)=>b!==\"\");if(a.length===0)return $n(n,u),n;let d=te(a[0]);if(d===\"loop\"){if(a.length>=2){if(n.loopName=te(a[1]),a.length>=3){let b=te(a[2]);if(xf.has(b))n.tab=b}}}else if($f.has(d)){if(n.section=d,d===\"groups\"&&a.length>=2)n.groupId=te(a[1])}else n.loopName=d;return $n(n,u),n}function Ue(e){if(!e.projectId)return\"\";let n=\"#\"+encodeURIComponent(e.projectId);if(e.section===\"loops\"){if(e.loopName){if(n+=\"/loop/\"+encodeURIComponent(e.loopName),e.tab!==\"overview\")n+=\"/\"+e.tab}}else if(e.section===\"groups\"){if(n+=\"/groups\",e.groupId)n+=\"/\"+encodeURIComponent(e.groupId)}else n+=\"/\"+e.section;let t=[];if(e.statuses.length>0)t.push(\"status=\"+e.statuses.map((i)=>encodeURIComponent(i)).join(\",\"));if(e.query)t.push(\"q=\"+encodeURIComponent(e.query));if(t.length>0)n+=\"?\"+t.join(\"&\");return n}function xt(e,n){let i=\"#\"+(location.hash||\"\").replace(/^#/,\"\"),f=\"#\"+e.replace(/^#/,\"\");if(i!==f)n.current=!0,location.hash=e}function z(e){if(!e||e===0)return\"\";let n=new Date(e),t=(l)=>l<10?\"0\"+l:String(l),i=t(n.getMonth()+1),f=t(n.getDate()),u=n.getFullYear(),r=n.getHours(),c=r>=12?\"PM\":\"AM\";if(r=r%12,r===0)r=12;return i+\"-\"+f+\"-\"+u+\" \"+r+\":\"+t(n.getMinutes())+\" \"+c}function ie(e){return\"status-badge status-\"+e}function xn(e){return\"section-status section-\"+e}function Ot(e){return\"feature-stage feature-stage-\"+e}function Et(e,n,t,i){if(!(n.size===0||n.has(e.status)))return!1;if(!t)return!0;let u=t.toLowerCase(),r=e.worktreeBranch??\"\";return((e.loopName||\"\")+\" \"+r+\" \"+(i||\"\")).toLowerCase().indexOf(u)!==-1}function kt(e,n){if(n===\"recent\")return[...e];let t=[...e];if(n===\"cost\")t.sort((i,f)=>{let u=i.usage?.totalCost??null,r=f.usage?.totalCost??null;if(u===null&&r===null)return 0;if(u===null)return 1;if(r===null)return-1;return r-u});else if(n===\"duration\")t.sort((i,f)=>{let u=Fe(i.loop.startedAt,i.loop.completedAt??void 0);return Fe(f.loop.startedAt,f.loop.completedAt??void 0)-u});else if(n===\"findings\")t.sort((i,f)=>f.findings.length-i.findings.length);return t}function At(e){return JSON.stringify(e.projects)}function St(e){let n=new Map;for(let f of e){if(f===\"\"){n.set(f,[]);continue}let u=f.split(\"/\"),r=u.length;while(r>0&&u[r-1]===\"\")r--;n.set(f,r>0?u.slice(0,r):[f])}let t=new Map;for(let f of e){let u=n.get(f);t.set(f,u.length?u[u.length-1]:f)}let i=new Map;for(let f of e){let u=t.get(f),r=i.get(u);if(r)r.push(f);else i.set(u,[f])}for(let[,f]of i){let u=[...new Set(f)];if(u.length<2)continue;let r=1;while(r<1024){let c=new Set,l=!0,a=!0;for(let d of u){let b=n.get(d),E=Math.max(0,b.length-r),g=b.slice(E).join(\"/\");if(c.has(g))l=!1;else c.add(g);if(E>0)a=!1}if(l||a)break;r++}for(let c of u){let l=n.get(c),a=Math.max(0,l.length-r);t.set(c,l.slice(a).join(\"/\"))}}return t}function On(e){let n=[],t=[];for(let i of e)if(i.severity===\"bug\")n.push(i);else t.push(i);return{bugs:n,warnings:t}}function Ct(e){if(e.bugs.length>0)return\"bug\";if(e.warnings.length>0)return\"warn\";return\"clean\"}function ye(e,n){return e+\" \"+n+(e===1?\"\":\"s\")}function En(e,n){if(!n||n<=0)return 0;return Math.max(0,Math.min(100,e/n*100))}function Nt(e){let n=e.file+\":\"+e.line+\" — \"+e.description;if(e.scenario)n+=\" (\"+e.scenario+\")\";return n}function Pt(e,n){return\" \"+e+\": $\"+n.cost.toFixed(6)+\", \"+n.inputTokens+\" in / \"+n.outputTokens+\" out (reasoning: \"+n.reasoningTokens+\", cache R: \"+n.cacheReadTokens+\" W: \"+n.cacheWriteTokens+\"), messages: \"+n.messageCount}function We(e){if(!Number.isFinite(e)||e<=0)return\"0\";if(e>=1e6)return(e/1e6).toFixed(1).replace(/\\.0$/,\"\")+\"M\";if(e>=1000)return(e/1000).toFixed(1).replace(/\\.0$/,\"\")+\"k\";return String(e)}function Ke(e){if(!Number.isFinite(e)||e<=0)return\"$0\";return\"$\"+(e>=1?e.toFixed(2):e.toFixed(4))}function It(e){let n=[{label:\"Input\",value:e.totalInputTokens,color:\"var(--seg-input)\"},{label:\"Output\",value:e.totalOutputTokens,color:\"var(--seg-output)\"},{label:\"Reasoning\",value:e.totalReasoningTokens,color:\"var(--seg-reasoning)\"},{label:\"Cache R\",value:e.totalCacheReadTokens,color:\"var(--seg-cache-read)\"},{label:\"Cache W\",value:e.totalCacheWriteTokens,color:\"var(--seg-cache-write)\"}],t=n.reduce((i,f)=>i+Math.max(0,f.value),0);return n.map((i)=>({...i,pct:t>0?Math.max(0,i.value)/t*100:0}))}function Vt(e){let n={role:\"execution\",cost:0,messageCount:0,pct:0},t={role:\"auditor\",cost:0,messageCount:0,pct:0},i={role:\"other\",cost:0,messageCount:0,pct:0};for(let[r,c]of Object.entries(e.byRole??{})){let l=r===\"auditor\"?t:r===\"code\"?n:i;l.cost+=c.cost,l.messageCount+=c.messageCount}let f=i.cost>0||i.messageCount>0?[n,t,i]:[n,t],u=f.reduce((r,c)=>Math.max(r,c.cost),0);for(let r of f)r.pct=u>0?r.cost/u*100:0;return f}function Tt(e){let n=Object.keys(e.byModel).map((i)=>({model:i,...e.byModel[i]})),t=n.reduce((i,f)=>Math.max(i,f.cost),0);return n.sort((i,f)=>f.cost-i.cost).map((i)=>({model:i.model,cost:i.cost,inputTokens:i.inputTokens,outputTokens:i.outputTokens,messageCount:i.messageCount,pct:t>0?i.cost/t*100:0}))}function _t(e,n){if(!e)return\"\";let t=Fe(e,n??void 0);return t>0?ke(t):\"\"}function ge(e){if(!e||e===0)return\"\";let n=Date.now()-e,t=Math.floor(n/1000);if(t<60)return t+\"s ago\";let i=Math.floor(t/60);if(i<60)return i+\"m ago\";let f=Math.floor(i/60);if(f<24)return f+\"h ago\";let u=Math.floor(f/24);if(u<30)return u+\"d ago\";return z(e)}var Ef=/([\\s\\S]*?)<\\/h\\1>/g;function jt(e){return e.replace(/<[^>]+>/g,\"\").replace(/&[a-z#0-9]+;/gi,\"\").trim()}function kf(e){return jt(e).toLowerCase().replace(/[^\\w\\s-]/g,\"\").replace(/[\\s_-]+/g,\"-\").replace(/^-+|-+$/g,\"\")||\"heading\"}function Af(e){if(!//.test(e))return{html:e,outline:[]};let n=[];return{html:e.replace(Ef,(i,f,u)=>{let r=Number(f),c=kf(u);return n.push({depth:r,id:c,text:jt(u)}),`${u}`}),outline:n}}function vt(e){if(!e)return{html:\"\",outline:[]};let n=Ae.get(e);if(n!==void 0)return n;let t=globalThis.marked;if(!t)return{html:\"\",outline:[]};let{html:i,outline:f}=Af(t.parse(e)),u={html:i,outline:f};if(Ae.size>=Sf){let r=Ae.keys().next().value;if(r!==void 0)Ae.delete(r)}return Ae.set(e,u),u}function Ft(e){return vt(e).html}function Dt(e){return vt(e)}var Ae=new Map,Sf=200;function Ge(e){let n=[\"overview\",\"timeline\"];if(e.sections.length>0)n.push(\"sections\");if(n.push(\"findings\"),e.plan)n.push(\"plan\");return n.push(\"usage\"),n}function Ut(e,n,t,i,f=\"coding\"){let u=[],r=e.findIndex((b)=>b.createdAt>=n),c=r===-1?[]:e.slice(r);if(t!==null)c=c.filter((b)=>b.createdAt<=t);let l;if(c.length>0&&c[0].eventType===\"restart\"&&c[0].toPhase!==null)l=c[0].toPhase;else if(c.length>0&&c[0].eventType!==\"restart\")l=c[0].fromPhase;else l=f;let a=n;for(let b of c){if(b.eventType===\"restart\"){if(b.toPhase!==null)l=b.toPhase;continue}if(l===\"\")l=b.fromPhase,a=Math.max(a,b.createdAt);let E=b.createdAt,g=Math.max(a,E);if(u.push({phase:l,startedAt:a,endedAt:g,durationMs:g-a,open:!1}),b.toPhase===null)l=\"\",a=g;else l=b.toPhase,a=g}if(l!==\"\"){let E=Math.max(a,t??i);u.push({phase:l,startedAt:a,endedAt:t,durationMs:E-a,open:t===null})}let d=e.length>0&&e[0].createdAt>=n&&e[0].createdAt>n+1000;if(d&&u.length>0)u[0]={...u[0],phase:\"\"};return{spans:u,truncated:d}}function Wt(e){let n={};for(let t of e){if(!t.phase)continue;n[t.phase]=(n[t.phase]??0)+t.durationMs}return n}function Kt(e,n,t){let i=[];for(let f=0;f=n)r=Math.max(0,u.createdAt-n);else if(u.createdAt=n)r=Math.max(0,u.createdAt-n);else r=null}i.push({transition:u,elapsedMs:r})}return i.reverse(),i}var Cf=[{phase:\"coding\",label:\"coding\",cx:70,cy:60},{phase:\"auditing\",label:\"auditing\",cx:220,cy:60},{phase:\"final_auditing\",label:\"final_auditing\",cx:370,cy:60},{phase:\"post_action\",label:\"post_action\",cx:520,cy:60},{phase:\"final_audit_fix\",label:\"final_audit_fix\",cx:370,cy:160}],ze=110,Ye=36,Gt=655,zt=160,Nf=[{key:\"coding→auditing\",d:\"M 125 60 L 165 60\",labelX:145,labelY:52},{key:\"auditing→final_auditing\",d:\"M 275 60 L 315 60\",labelX:295,labelY:52},{key:\"final_auditing→post_action\",d:\"M 425 60 L 465 60\",labelX:445,labelY:52},{key:\"auditing→coding\",d:\"M 220 42 Q 145 14 70 42\",labelX:145,labelY:18},{key:\"auditing→post_action\",d:\"M 220 42 Q 370 8 520 42\",labelX:370,labelY:16},{key:\"final_auditing→auditing\",d:\"M 315 42 Q 295 24 275 42\",labelX:295,labelY:30},{key:\"final_auditing→coding\",d:\"M 370 78 C 370 110 145 110 70 78\",labelX:220,labelY:102},{key:\"final_auditing→final_audit_fix\",d:\"M 370 78 L 370 142\",labelX:358,labelY:112},{key:\"final_audit_fix→final_auditing\",d:\"M 415 142 L 415 78\",labelX:442,labelY:116},{key:\"coding→final_audit_fix\",d:\"M 70 78 C 70 95 200 115 315 142\",labelX:175,labelY:118},{key:\"final_audit_fix→coding\",d:\"M 315 178 C 250 210 100 210 70 78\",labelX:175,labelY:205},{key:\"coding→terminal\",d:\"M 125 78 L 600 142\",labelX:240,labelY:90},{key:\"auditing→terminal\",d:\"M 275 78 L 600 142\",labelX:395,labelY:90},{key:\"final_auditing→terminal\",d:\"M 425 78 L 600 142\",labelX:505,labelY:90},{key:\"post_action→terminal\",d:\"M 520 78 L 600 142\",labelX:565,labelY:95},{key:\"final_audit_fix→terminal\",d:\"M 425 160 L 600 160\",labelX:512,labelY:152}];function Pf(e){if(e.toPhase===null)return e.fromPhase+\"→terminal\";return e.fromPhase+\"→\"+e.toPhase}function If(e){return e.toPhase??e.status??\"\"}function kn(e){let n=Array.isArray(e)?e[0]:e,t=n instanceof SVGSVGElement?n.firstElementChild:null;if(!(t instanceof SVGElement))throw Error(\"Expected an SVG child\");return t}function Yt(e){let n=()=>e.loop(),t=w(()=>n().status===\"running\"),i=w(()=>{let c=new Map;for(let l of e.transitions()){let a=Pf(l);c.set(a,(c.get(a)??0)+1)}return c}),f=Nf.map((c)=>kn(h`\n \n \n ${()=>{let l=i().get(c.key);return l?String(l):\"\"}}\n \n `)),u=Cf.map((c)=>{let l=c.cx-ze/2,a=c.cy-Ye/2;return kn(h`\n \"mg-node\"+(n().phase===c.phase&&t()?\" mg-node-active\":\"\")}>\n \n ${c.label}\n \n `)}),r=kn(h`\n \n \n ${()=>n().status}\n \n `);return h`
\n \n \n \n \n \n \n\n ${f}\n ${u}\n ${()=>t()?\"\":r}\n \n\n
\n
Transition history
\n
\n ${()=>e.transitions().slice(-20).reverse().map((c)=>h`
\n ${c.eventType}\n ${c.fromPhase} → ${If(c)}\n ${ge(c.createdAt)}\n
`)}\n
\n
\n
`}function Zt(e){let[n,t]=F(e.query()),i=null,f=(r)=>{let c=r.target.value;if(t(c),i)clearTimeout(i);i=setTimeout(()=>{i=null,e.onChangeQuery(c)},250)};re(()=>{if(i)clearTimeout(i),i=null}),m(()=>{e.externalNav();let r=G(()=>e.query());if(i!==null)clearTimeout(i),i=null;t(r)});let u=w(()=>{let r=e.counts();return[{label:\"Running\",key:\"running\",value:r.running},{label:\"Completed\",key:\"completed\",value:r.completed},{label:\"Cancelled\",key:\"cancelled\",value:r.cancelled},{label:\"Errored\",key:\"errored\",value:r.errored},{label:\"Stalled\",key:\"stalled\",value:r.stalled}]});return h`
\n ${()=>u().map(({label:r,key:c,value:l})=>{let d=`badge badge-filter${e.statuses().has(c)?\" badge-active\":\"\"}`;return h`e.onToggleStatus(c)}>${r}: ${l}`})}\n \n \n
`}function Jt(e){return h`
Last updated: ${new Date(e.generatedAt).toLocaleString()}
`}function Qt(e){let n=w(()=>e.entries().map((t)=>{let i=t.loops.some((r)=>r.loop.status===\"running\"),f=t.proj.projectDir||t.proj.projectId||\"\",u=e.labels().get(f)??f;return h`
e.onSelect(t.proj.projectId)}>\n ${i?h``:\"\"}\n ${u}\n ${t.loops.length}\n
`}));return h`
${()=>n()}
`}function Vf(e,n){let t=0,i=0,f=0,u=[],r=[];for(let c of e){let l=c.proj.projectDir||c.proj.projectId||\"\",a=n.get(l)??l;for(let d of c.loops){i++;for(let g of d.findings)if(g.severity===\"bug\")f++;let b=d.loop.status,E=d.loop.completedAt||d.loop.startedAt||0;if(b===\"running\")t++,u.push({projectId:c.proj.projectId,label:a,loopName:d.loop.loopName,phase:d.loop.phase||\"\"});r.push({projectId:c.proj.projectId,label:a,loopName:d.loop.loopName,status:b,when:E})}}return r.sort((c,l)=>l.when-c.when),{runningCount:t,loopCount:i,bugCount:f,runningCards:u,recentRows:r.slice(0,8)}}function Rt(e){let n=w(()=>Vf(e.entries(),e.labels())),t=w(()=>n().runningCount),i=w(()=>n().loopCount),f=w(()=>n().bugCount),u=w(()=>n().runningCards),r=w(()=>n().recentRows);return h`
\n
\n

All repositories

\n
${()=>`${t()} running · ${i()} loops · ${f()} open bugs`}
\n
\n ${()=>u().length>0?h`
\n

Running

\n
\n ${u().map((c)=>h`
e.onOpenLoop(c.projectId,c.loopName)}>\n ${c.label}\n ${c.loopName}\n ${c.phase}\n
`)}\n
\n
`:\"\"}\n ${()=>r().length>0?h`
\n

Recent activity

\n
\n ${r().map((c)=>h`
e.onOpenLoop(c.projectId,c.loopName)}>\n ie(c.status)}>${c.status}\n ${c.label}\n ${c.loopName}\n ${z(c.when)}\n
`)}\n
\n
`:\"\"}\n
`}function Tf(e){let[n,t]=F(!1),[i,f]=F(\"\"),[u,r]=F(0),c=w(()=>{let s=i().trim().toLowerCase(),x=e.loops();return s?x.filter((k)=>k.name.toLowerCase().indexOf(s)!==-1):x}),l=()=>{if(n())return;f(\"\");let s=e.loops().findIndex((x)=>x.name===e.loopName);r(s<0?0:s),t(!0)},a=()=>{t(!1),f(\"\"),r(0)},d=(s)=>{if(a(),o.blur(),s!==e.loopName)e.onSelect(s)},b=h`n()?\"display:block\":\"display:none\"}\n onmousedown=${(s)=>s.preventDefault()}\n >\n ${()=>c().map((s,x)=>h`\"loop-picker-option\"+(x===u()?\" loop-picker-option-active\":\"\")+(s.name===e.loopName?\" loop-picker-option-current\":\"\")}\n onclick=${()=>d(s.name)}\n >\n ${s.name}\n ${z(s.when)}\n `)}\n ${()=>c().length===0?h`
No matching loops
`:\"\"}\n `,E=()=>{b.querySelector(\".loop-picker-option-active\")?.scrollIntoView?.({block:\"nearest\"})},g=(s)=>{if(s.key===\"ArrowDown\"||s.key===\"ArrowUp\"){if(s.preventDefault(),!n()){l();return}let x=c().length;if(x===0)return;let k=s.key===\"ArrowDown\"?1:-1;r((P)=>(P+k+x)%x),E();return}if(s.key===\"Enter\"){s.preventDefault();let x=c()[u()];if(x)d(x.name);return}if(s.key===\"Escape\")s.preventDefault(),a(),o.blur()},o=h`n()?i():e.loopName}\n onfocus=${l}\n oninput=${(s)=>{f(s.target.value),r(0),t(!0)}}\n onkeydown=${g}\n onblur=${a}\n />`;return h`\n ${o}\n {if(s.preventDefault(),n())a(),o.blur();else o.focus()}}>▾\n ${b}\n `}function Lt(e){if(e.loopName)return h`
\n ← ${e.repoLabel}\n /\n ${Tf({loopName:e.loopName,loops:e.loops,onSelect:e.onSelectLoop})}\n ${()=>{let n=e.loopNav();return n?h`\n \n ${n.index+1} of ${n.total}\n \n `:\"\"}}\n
`;return h`
\n ← Repositories\n /\n ${e.repoLabel}\n ${e.projectDir}\n
`}function Bt(e){let n=[{key:\"loops\",label:\"Loops\",count:e.counts.loops},{key:\"groups\",label:\"Groups\",count:e.counts.groups},{key:\"findings\",label:\"Findings\",count:e.counts.findings},{key:\"plans\",label:\"Plans\",count:e.counts.plans}];return h`
\n ${n.map((t)=>{let f=`section-nav-item${t.key===e.section?\" section-nav-active\":\"\"}`;return h`
e.onNavigate(t.key)}>\n ${t.label}\n ${t.count}\n
`})}\n
`}function Sn(e){let n=()=>En(e.current(),e.total());return h`\n \"width:\"+n()+\"%\"}>\n ${()=>e.current()}/${()=>e.total()}\n `}var _f={sm:4,md:14,lg:22};function Cn(e){let n=_f[e.variant],t=w(()=>e.spans().filter((u)=>u.phase!==\"\")),i=w(()=>Math.max(0,t().reduce((u,r)=>u+r.durationMs,0))),f=w(()=>e.spans().some((u)=>u.phase===\"\"));return h`
\"phase-bar phase-bar-\"+e.variant+(f()?\" phase-bar-trunc\":\"\")} data-truncated=${()=>f()?\"true\":\"false\"} style=${()=>\"height:\"+n+\"px\"}>\n ${()=>f()?h`\\u22ef`:\"\"}\n ${()=>{let u=t(),r=i();return u.map((c)=>{let l=r>0?c.durationMs/r*100:0,a=\"phase-seg\"+(c.open?\" phase-seg-open\":\"\");return h`
`})}}\n
`}function Nn(e,n){let t=e.loop;return Ut(e.transitions??[],t.startedAt,t.completedAt,t.completedAt===null?n():0,t.phase)}function mt(e){return h`\n \n \n \n \n \n ${()=>e.loops().map((n)=>jf({dashLoop:n,now:e.now,onOpen:e.onOpen}))}\n \n
StatusLoopPhasePhase spanIterSectionsFindingsCostDurationUpdated
`}function jf(e){let n=()=>e.dashLoop.loop,t=()=>e.dashLoop,i=w(()=>On(t().findings)),f=w(()=>Nn(t(),e.now).spans);return h`e.onOpen(e.dashLoop.loop.loopName)}>\n ie(n().status)}>${()=>n().status}\n ${()=>n().loopName}\n ${()=>n().phase}\n ${()=>Cn({spans:f,variant:\"sm\"})}\n ${Sn({current:()=>n().iteration,total:()=>n().maxIterations})}\n ${()=>n().totalSections>0?Sn({current:()=>n().currentSectionIndex,total:()=>n().totalSections}):h``}\n ${()=>{let u=i();if(u.bugs.length===0&&u.warnings.length===0)return h``;return h`\n ${u.bugs.length>0?h`${ye(u.bugs.length,\"bug\")}`:\"\"}\n ${u.bugs.length>0&&u.warnings.length>0?\" · \":\"\"}\n ${u.warnings.length>0?h`${ye(u.warnings.length,\"warn\")}`:\"\"}\n `}}\n ${()=>t().usage?Ke(t().usage.totalCost):h``}\n ${()=>t().duration||\"\"}\n ${()=>z(n().completedAt||n().startedAt)}\n `}function Se(e){let n=()=>typeof e.label===\"function\"?e.label():e.label,[t,i]=F(!1),f=()=>i((c)=>!c),u=w(()=>Dt(e.src()||\"\")),r=w(()=>u().outline.filter((c)=>c.depth<=3));return h`
\n
\n t()?\"false\":\"true\"}\n onclick=${f}\n onkeydown=${(c)=>{if(c.key!==\"Enter\"&&c.key!==\" \")return;c.preventDefault(),f()}}\n >\n ${()=>t()?\"▸\":\"▾\"}\n

${()=>n()}

\n
\n \"Copy \"+n()+\" as markdown\"}\n onclick=${(c)=>{c.stopPropagation();let l=c.target,a=\"Copy\";navigator.clipboard.writeText(e.src()||\"\").then(()=>{l.textContent=\"Copied!\"},()=>{l.textContent=\"Failed\"}).then(()=>{setTimeout(()=>{l.textContent=a},2000)})}}\n >Copy\n
\n
t()?\"display:none\":\"display:block\"}>\n ${()=>{let c=r();return c.length>=2?vf({items:()=>c}):null}}\n
u().html}>
\n
\n `}function vf(e){let n=(t,i)=>{t.preventDefault();let f=t.currentTarget.closest(\".markdown-body\"),u=f?Array.from(f.querySelectorAll(\"h1,h2,h3,h4,h5,h6\")).find((r)=>r.id===i):document.getElementById(i);if(u&&typeof u.scrollIntoView===\"function\")u.scrollIntoView({behavior:\"smooth\",block:\"start\"})};return h``}function An(e){return h`
\n
${e.label}
\n
Ft(e.value()||\"\")}>
\n
`}function Ff(e){let n=e.sec,t=w(()=>!!n.startedAt||!!n.completedAt),i=w(()=>!!n.summaryDone),f=w(()=>!!n.summaryDeviations),u=w(()=>!!n.summaryFollowUps),r=w(()=>!!n.content),c=w(()=>t()||i()||f()||u()||r());return h`
\n ${()=>t()?h`
\n ${()=>n.startedAt?\"Started \"+z(n.startedAt):\"Not started\"}\n ${()=>n.completedAt?h` → Completed ${()=>z(n.completedAt)}`:\"\"}\n
`:\"\"}\n ${()=>i()?An({label:\"Done\",value:()=>n.summaryDone}):\"\"}\n ${()=>f()?An({label:\"Deviations\",value:()=>n.summaryDeviations}):\"\"}\n ${()=>u()?An({label:\"Follow-ups\",value:()=>n.summaryFollowUps}):\"\"}\n ${()=>r()?Se({label:\"Section Plan\",src:()=>n.content}):\"\"}\n ${()=>c()?\"\":h`
No details captured for this section yet.
`}\n
`}function Df(e){let n=e.sec,t=w(()=>_t(n.startedAt,n.completedAt));return h`
\"section-list-row section-item-\"+n.status} onclick=${e.onOpen}>\n #${n.sectionIndex}\n ${()=>n.title}\n xn(n.status)}>${()=>n.status}\n ${()=>t()?h`${()=>t()}`:\"\"}\n ${()=>n.attempts>0?h`${()=>n.attempts} attempts`:\"\"}\n \n
`}function Uf(e){let[n,t]=F(null),i=w(()=>{let f=n();if(f===null)return null;return e.sections().find((u)=>u.sectionIndex===f)??null});return h`
\n

Sections

\n ${()=>{let f=i();if(f)return h`
\n
t(null)}>← Back to sections
\n
\n #${f.sectionIndex}\n ${()=>f.title}\n xn(f.status)}>${()=>f.status}\n
\n ${Ff({sec:f})}\n
`;return h`
\n ${e.sections().map((u)=>Df({sec:u,onOpen:()=>t(u.sectionIndex)}))}\n
`}}\n
`}function Y(e){return h`
\n ${e.label}\n ${()=>e.value()}\n
`}function qt(e){let n=()=>En(e.current(),e.total());return h`
\n
\n ${e.label}\n ${()=>e.current()} / ${()=>e.total()}\n
\n
\n
\"width:\"+n()+\"%\"}>
\n
\n
`}function Wf(e){let n=()=>e.dashLoop.loop,t=()=>e.dashLoop,i=w(()=>!!n().completedAt),f=w(()=>!!t().duration),u=w(()=>n().totalSections>0),r=w(()=>!!n().terminationReason),c=w(()=>Ct(e.split())),l=w(()=>!!e.dashLoop.usage);return h`
\n
\"ldh-findings ldh-findings-\"+c()}>\n ${()=>{let a=e.split();if(a.bugs.length===0&&a.warnings.length===0)return\"No findings\";let d=[];if(a.bugs.length>0)d.push(ye(a.bugs.length,\"bug\"));if(a.warnings.length>0)d.push(ye(a.warnings.length,\"warning\"));return d.join(\" · \")}}\n
\n
\n ie(n().status)}>${()=>n().status}\n

${()=>n().loopName}

\n ${()=>n().phase}\n
\n\n
\n ${Y({label:\"Started\",value:()=>z(n().startedAt)})}\n ${()=>i()?Y({label:\"Completed\",value:()=>z(n().completedAt)}):\"\"}\n ${()=>f()?Y({label:\"Duration\",value:()=>t().duration||\"\"}):\"\"}\n ${Y({label:\"Iteration\",value:()=>n().iteration+\" / \"+n().maxIterations})}\n ${()=>u()?Y({label:\"Section\",value:()=>n().currentSectionIndex+\" / \"+n().totalSections}):\"\"}\n ${()=>l()?Y({label:\"Messages\",value:()=>String(e.dashLoop.usage.totalMessageCount)}):\"\"}\n ${Y({label:\"Execution model\",value:()=>n().executionModel??\"—\"})}\n ${Y({label:\"Auditor model\",value:()=>n().auditorModel??\"—\"})}\n ${Y({label:\"Branch\",value:()=>n().worktreeBranch??\"—\"})}\n ${Y({label:\"Sandbox\",value:()=>n().sandbox?\"on\":\"off\"})}\n ${Y({label:\"Kind\",value:()=>n().kind??\"—\"})}\n ${Y({label:\"Audits\",value:()=>String(n().auditCount)})}\n ${Y({label:\"Errors\",value:()=>String(n().errorCount)})}\n
\n\n
\n ${qt({label:\"Iterations\",current:()=>n().iteration,total:()=>n().maxIterations})}\n ${()=>u()?qt({label:\"Sections\",current:()=>n().currentSectionIndex,total:()=>n().totalSections}):\"\"}\n
\n\n ${()=>r()?h`
\"ldh-banner ldh-banner-\"+n().status}>${()=>n().terminationReason}
`:\"\"}\n
`}function Kf(e){let n=()=>e.usage(),t=w(()=>It(n())),i=w(()=>Tt(n())),f=w(()=>i().length>0),u=w(()=>Vt(n()));return h`
\n

Usage

\n\n
\n
Role split
\n
\n ${()=>u().map((r)=>h`
r.role}>\n
\n r.role}>${r.role}\n ${()=>Ke(r.cost)}\n
\n
\n
\"width:\"+r.pct+\"%\"}>
\n
\n
${()=>r.messageCount+\" msg\"}
\n
`)}\n
\n
\n\n
\n
Token composition
\n
\n ${()=>t().map((r)=>r.pct>0?h`
`:\"\")}\n
\n
\n ${()=>t().map((r)=>h`
\n \n ${r.label}\n ${We(r.value)}\n
`)}\n
\n
\n\n ${()=>f()?h`
\n
Cost by model
\n
\n ${()=>i().map((r)=>h`
\n
\n ${r.model}\n ${Ke(r.cost)}\n
\n
\n
\n
\n
\n ${We(r.inputTokens)} in / ${We(r.outputTokens)} out · ${r.messageCount} msg\n
\n
`)}\n
\n
`:\"\"}\n `}function Ht(e){try{return JSON.parse(e)}catch{return[]}}function Xt(e){return\"#\"+e.index+\" \"+e.title}function Gf(e){let n=e.amendment,t=w(()=>Ht(n.sectionsBefore)),i=w(()=>Ht(n.sectionsAfter));return h`
\n
\n ${()=>ge(n.createdAt)}\n applied @ section ${()=>n.appliedAtSection}\n ${()=>n.source}\n ${()=>n.rationale}\n ${()=>e.expanded()?\"▾\":\"▸\"}\n
\n
e.expanded()?\"display:block\":\"display:none\"}>\n
\n ${()=>t().length>0?h`
\n
Before
\n ${()=>t().map((f)=>h`
${()=>Xt(f)}
`)}\n
`:\"\"}\n ${()=>i().length>0?h`
\n
After
\n ${()=>i().map((f)=>h`
${()=>Xt(f)}
`)}\n
`:\"\"}\n
\n
\n
`}function zf(e){let n=()=>e.amendments(),[t,i]=F(null);return h`
\n

Plan amendments

\n
\n ${()=>n().map((f)=>Gf({amendment:f,expanded:()=>t()===f.id,onToggle:()=>i(t()===f.id?null:f.id)}))}\n
\n
`}var Yf=[\"overview\",\"timeline\",\"sections\",\"findings\",\"plan\",\"usage\"],qf={overview:\"Overview\",timeline:\"Timeline\",sections:\"Sections\",findings:\"Findings\",plan:\"Plan\",usage:\"Usage\"};function Hf(e){return h`
\n ${()=>e.tabs().map((n)=>{let t=qf[n];return h`\"tab-item\"+(e.activeTab()===n?\" tab-active\":\"\")} data-tab=\"${n}\" onclick=${()=>e.onSelect(n)}>${t}`})}\n
`}function Xf(e){let n=()=>e.dashLoop,t=()=>n().loop,i=w(()=>!!n().goal),f=w(()=>n().postActionReport??t().completionSummary??null),u=w(()=>n().postActionReport?\"Post-Action Report\":\"Completion Summary\"),r=w(()=>!!f()),c=w(()=>!!n().lastAuditResult),l=w(()=>Nn(n(),e.now));return h`
\n ${()=>Cn({spans:()=>l().spans,variant:\"md\"})}\n ${()=>i()?Se({label:\"Goal\",src:()=>n().goal}):\"\"}\n ${Wf({dashLoop:e.dashLoop,split:e.split})}\n ${()=>r()?Se({label:u,src:()=>f()}):\"\"}\n ${()=>c()?Se({label:\"Last Audit Result\",src:()=>n().lastAuditResult}):\"\"}\n
`}function Zf(e){let n=e.ev.transition,t=e.ev.elapsedMs,i=t===null?null:Math.floor(t/1000),f=n.toPhase===null?n.status??\"\":n.toPhase;return h`
\n ${ge(n.createdAt)}\n ${n.fromPhase} → ${f}\n ${n.transitionKind}\n iter ${n.iteration}\n ${()=>n.sectionIndex===null?\"—\":\"sect \"+(n.sectionIndex+1)}\n ${()=>i===null?\"—\":i>0?ke(i):\"0s\"}\n
`}function Jf(e){let n=()=>e.dashLoop,t=()=>n().loop,i=()=>n().transitions??[],f=w(()=>Nn(n(),e.now)),u=w(()=>f().spans),r=w(()=>f().truncated),c=w(()=>Wt(u())),l=w(()=>u().filter((s)=>s.phase!==\"\").reduce((s,x)=>s+x.durationMs,0)),a=w(()=>{let s=c();return Object.keys(s).filter((x)=>x!==\"\").sort((x,k)=>s[k]-s[x])}),d=w(()=>Kt(i(),t().startedAt,r())),[b,E]=F(!1),g=w(()=>{let s=d();return b()?s:s.slice(0,20)}),o=w(()=>b()?0:Math.max(0,d().length-20));return h`
\n ${Yt({loop:()=>t(),transitions:i})}\n ${()=>Cn({spans:u,variant:\"lg\"})}\n ${()=>r()?h`
Earlier history was truncated by the 100-row fetch window.
`:\"\"}\n\n
\n ${()=>a().map((s)=>{let x=c()[s],k=l()>0?Math.round(x/l()*100):0;return h`
\n \n ${s}\n \n ${()=>x>=1000?ke(Math.floor(x/1000)):x+\"ms\"} · ${k}%\n
`})}\n
\n\n
\n ${()=>g().map((s)=>Zf({ev:s}))}\n ${()=>o()>0?h`
E(!0)}>[ ${()=>o()} earlier events ]
`:\"\"}\n
\n
`}function Qf(e){return Uf({sections:()=>e.dashLoop.sections})}function Rf(e){let n=[],t=new Map;for(let i of e){let f=i.sectionIndex??null;if(!t.has(f))t.set(f,[]),n.push(f);t.get(f).push(i)}return n.sort((i,f)=>{if(i===null&&f===null)return 0;if(i===null)return 1;if(f===null)return-1;return i-f}),n.map((i)=>({key:i,label:i===null?\"cross-section\":\"sect \"+(i+1),rows:t.get(i)}))}function Mt(e){return h`
\n ${e.rows.map((n)=>h`
\"finding finding-\"+n.severity}>\n ${()=>ge(n.createdAt)}\n ${()=>Nt(n)}\n
`)}\n
`}function pt(e){let n=()=>Ue({projectId:e.projectId(),section:\"loops\",loopName:e.loopName,tab:\"overview\",groupId:null,statuses:[],query:\"\"});return h`{if(t.defaultPrevented||t.button!==0||t.metaKey||t.ctrlKey||t.shiftKey||t.altKey)return;t.preventDefault(),e.onOpen(e.loopName)}}>${e.loopName}`}function Lf(e){let n=()=>e.dashLoop,t=w(()=>n().findings.length>0),i=w(()=>Rf(n().findings));return h`
\n ${()=>t()?i().map((f)=>h`
f.key===null?\"cross\":String(f.key)}>\n
${f.label}
\n ${Mt({rows:f.rows})}\n
`):h`
No findings recorded for this loop.
`}\n
`}function Bf(e){let n=()=>e.dashLoop,t=w(()=>!!n().plan),i=w(()=>!!n().amendments&&n().amendments.length>0);return h`
\n ${()=>t()?Se({label:\"Plan\",src:()=>n().plan}):h`
No plan recorded for this loop.
`}\n ${()=>i()?zf({amendments:()=>n().amendments}):\"\"}\n
`}function mf(e){let n=()=>e.dashLoop,t=w(()=>!!n().usage);return h`
\n ${()=>t()?Kf({usage:()=>n().usage}):h`
No usage recorded for this loop.
`}\n
`}function Mf(e){let n=w(()=>e.tabs().includes(e.tab)&&e.activeTab()===e.tab),t=(()=>{switch(e.tab){case\"overview\":return Xf({dashLoop:e.dashLoop,split:e.split,now:e.now});case\"timeline\":return Jf({dashLoop:e.dashLoop,now:e.now});case\"sections\":return Qf({dashLoop:e.dashLoop});case\"findings\":return Lf({dashLoop:e.dashLoop});case\"plan\":return Bf({dashLoop:e.dashLoop});case\"usage\":return mf({dashLoop:e.dashLoop})}})();return h`
n()?\"display:block\":\"display:none\"}>\n ${t}\n
`}function pf(e){return h`
\n ${Yf.map((n)=>Mf({tab:n,dashLoop:e.dashLoop,split:e.split,now:e.now,tabs:e.tabs,activeTab:e.activeTab}))}\n
`}function ei(e){let n=()=>e.dashLoop,t=w(()=>On(n().findings)),i=w(()=>Ge(n())),f=w(()=>{let u=e.routeTab();return i().includes(u)?u:\"overview\"});return h`
\n
\n ${Hf({tabs:i,activeTab:f,onSelect:e.onSelectTab})}\n ${pf({dashLoop:e.dashLoop,split:t,now:e.now,tabs:i,activeTab:f})}\n
\n
`}function Pn(){return h`
No loops match the current filters.
`}var ni=new Set([\"completed\",\"cancelled\",\"errored\"]);function ti(e){let n=e.length;return{completed:e.filter((i)=>i.stage===\"completed\").length,total:n}}function eu(e){return\"group-row\"+(ni.has(e)?\" group-row-terminal\":\" group-row-active\")}function ii(e){return h`
\n

Groups

\n ${()=>{let n=e.groups();if(n.length===0)return h`
No feature groups for this repo.
`;return h`
\n ${n.map((t)=>{let i=t.group,f=w(()=>ti(t.features));return h`
eu(i.status)} data-group-status=${()=>i.status} onclick=${()=>e.onOpen(i.groupId)}>\n ie(i.status)}>${()=>i.status}\n ${()=>i.title}\n ${Sn({current:()=>f().completed,total:()=>f().total})}\n max ${()=>i.maxConcurrent}\n \n Created ${()=>z(i.createdAt)}\n ${()=>i.completedAt?h`Completed ${()=>z(i.completedAt)}`:\"\"}\n \n
`})}\n
`}}\n
`}function nu(e){return\"group-header\"+(ni.has(e)?\" group-header-terminal\":\" group-header-active\")}function fi(e){return h`
\n
← Back to groups
\n ${()=>{let n=e.group();if(!n)return h`
Group not found.
`;let t=n.group,i=w(()=>ti(n.features));return h`
\n
nu(t.status)} data-group-status=${()=>t.status}>\n
\n ie(t.status)}>${()=>t.status}\n

${()=>t.title}

\n max ${()=>t.maxConcurrent}\n
\n
\n ${()=>\"Features \"+i().completed+\"/\"+i().total}\n Created ${()=>z(t.createdAt)}\n ${()=>t.completedAt?h`Completed ${()=>z(t.completedAt)}`:\"\"}\n ${()=>t.error?h`${()=>t.error}`:\"\"}\n
\n ${()=>t.prdPreview?h`
\n

PRD preview

\n
${()=>t.prdPreview||\"\"}
\n
`:\"\"}\n
\n
\n ${n.features.map((f)=>h`
f.stage}>\n #${()=>f.featureIndex}\n ${()=>f.title}\n Ot(f.stage)}>${()=>f.stage}\n ${()=>f.attempts} attempts\n ${()=>!!f.loopName&&e.loopNames().has(f.loopName)?pt({className:\"feature-loop-link\",projectId:e.projectId,loopName:f.loopName,onOpen:e.onOpenLoop}):\"\"}\n ${()=>f.error?h`${()=>f.error}`:\"\"}\n
`)}\n
\n
`}}\n
`}function tu(e){let n=[],t=new Map;for(let i of e){if(i.findings.length===0)continue;if(!t.has(i.loop.loopName))t.set(i.loop.loopName,[]),n.push(i.loop.loopName);t.get(i.loop.loopName).push(...i.findings)}return n.map((i)=>({loopName:i,rows:t.get(i)}))}function ui(e){let n=w(()=>tu(e.loops())),t=w(()=>n().reduce((i,f)=>i+f.rows.length,0));return h`
\n

Findings ${()=>t()}

\n ${()=>{let i=n();if(i.length===0)return h`
No findings recorded for this repo.
`;return h`
\n ${i.map((f)=>h`
f.loopName}>\n
\n ${pt({className:\"findings-loop-link\",projectId:e.projectId,loopName:f.loopName,onOpen:e.onOpenLoop})}\n ${()=>f.rows.length}\n
\n ${Mt({rows:f.rows})}\n
`)}\n
`}}\n
`}function ri(e){let n=w(()=>e.loops().filter((t)=>!!t.plan));return h`
\n

Plans ${()=>n().length}

\n ${()=>{let t=n();if(t.length===0)return h`
No plans recorded for this repo.
`;return h`
\n ${t.map((i)=>h`
e.onOpenLoop(i.loop.loopName)}>\n ie(i.loop.status)}>${()=>i.loop.status}\n ${()=>i.loop.loopName}\n \n ${()=>ye(i.findings.length,\"finding\")}\n \n iter ${()=>i.loop.iteration}/${()=>i.loop.maxIterations}\n
`)}\n
`}}\n
`}function ci(){let[e,n]=st({generatedAt:0,projects:[]}),[t,i]=F(!1),[f,u]=F(De(\"\")),[r,c]=F(null),[l,a]=F(\"recent\"),[d,b]=F(0),[E,g]=F(Date.now()),o={current:\"\"},s={current:!1},x=async()=>{try{let $=await(await fetch(\"/api/data\",{cache:\"no-store\"})).json(),I=At($);if(I!==o.current)o.current=I,n(ot($,{key:\"id\"})),i(!0),c(null);g(Date.now())}catch(y){let $=y instanceof Error?y.message:String(y);c(\"Failed to load dashboard data: \"+$)}},k=(y)=>{u(($)=>{let I={...$,...y};if(y.projectId!==void 0&&y.projectId!==$.projectId)I.statuses=[],I.query=\"\";return I})},P=(y)=>{k({tab:y})},S=(y)=>{k({loopName:y,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})},A=(y)=>{let $=new Set(f().statuses);if($.has(y))$.delete(y);else $.add(y);k({statuses:[...$]})},C=(y)=>{k({query:y})},V=(y)=>{a(y)},T=w(()=>new Set(f().statuses)),W=w(()=>St(e.projects.map((y)=>y.projectDir||y.projectId||\"\"))),_=w(()=>{if(!t()||e.projects.length===0)return[];let y=T(),$=f().query,I=W(),D=[],ne=$.trim().toLowerCase();for(let fe of e.projects){let Ze=[],Tn=fe.projectDir||fe.projectId||\"\",_n=I.get(Tn)??Tn;for(let jn of fe.loops)if(Et(jn.loop,y,$,_n))Ze.push(jn);let Ci=(fe.groups?.length??0)>0;if(Ze.length>0)D.push({proj:fe,loops:Ze});else if(Ci&&(!ne||_n.toLowerCase().indexOf(ne)!==-1))D.push({proj:fe,loops:[]})}return D}),L=w(()=>f().projectId===null),B=w(()=>f().section===\"groups\"),Ce=w(()=>f().section===\"loops\"),qe=w(()=>f().section===\"findings\"),si=w(()=>f().section===\"plans\"),In=w(()=>{let y=_(),$=f().projectId;if(!$)return null;return y.find((I)=>I.proj.projectId===$)??null}),He=w(()=>{let y=f();if(!y.projectId||!y.loopName)return null;let $=J();if(!$)return null;return $.loops.find((I)=>I.loop.loopName===y.loopName)??null}),J=w(()=>{let y=f().projectId;if(!y)return null;return e.projects.find(($)=>$.projectId===y)??null}),oi=w(()=>{let y=J(),$=y?.groups?.length??0;if(!y)return{loops:0,groups:$,findings:0,plans:0};let I=0,D=0;for(let ne of y.loops)if(I+=ne.findings.length,ne.plan)D++;return{loops:y.loops.length,groups:$,findings:I,plans:D}}),Xe=w(()=>{let y=J();if(!y)return[];return y.loops.map(($)=>({name:$.loop.loopName,when:$.loop.completedAt||$.loop.startedAt||0})).sort(($,I)=>I.when-$.when||$.name.localeCompare(I.name))}),di=w(()=>{let y=f().loopName;if(!y)return-1;return Xe().findIndex(($)=>$.name===y)}),ai=w(()=>{let y=di();if(y<0)return null;let $=Xe();if($.length<=1)return null;return{index:y,total:$.length,onPrev:()=>{if(y>0)k({loopName:$[y-1].name})},onNext:()=>{if(y<$.length-1)k({loopName:$[y+1].name})}}}),hi=w(()=>{let y=J(),$={running:0,completed:0,cancelled:0,errored:0,stalled:0};if(!y)return $;for(let I of y.loops){let D=I.loop.status;if(D===\"running\")$.running++;else if(D===\"completed\")$.completed++;else if(D===\"cancelled\")$.cancelled++;else if(D===\"errored\")$.errored++;else if(D===\"stalled\")$.stalled++}return $}),yi=w(()=>{let y=f();if(!y.projectId)return\"\";let $=J();if(!$)return\"\";let I=$.projectDir||$.projectId||\"\",D=W().get(I)??I;return G(()=>Lt({repoLabel:D,projectDir:I,loopName:y.loopName,loops:Xe,loopNav:ai,onSelectLoop:(ne)=>k({loopName:ne}),onBackToRepos:()=>k({projectId:null,loopName:null,section:\"loops\"}),onBackToRepo:()=>k({loopName:null})}))}),gi=w(()=>{let y=f();if(!y.projectId||y.loopName)return\"\";return Bt({section:y.section,counts:oi(),onNavigate:($)=>k({section:$,loopName:null,groupId:null})})}),wi=w(()=>{let y=f();return y.projectId!==null&&y.loopName===null&&y.section===\"loops\"}),bi=w(()=>{if(!wi())return\"\";return G(()=>Zt({counts:hi,statuses:T,query:()=>f().query,sortMode:l,onToggleStatus:A,onChangeQuery:C,onChangeSort:V,externalNav:d}))}),$i=w(()=>{if(!Ce())return\"\";let y=He();return y?ei({dashLoop:y,now:E,routeTab:()=>f().tab,onSelectTab:P}):\"\"}),xi=w(()=>{if(!Ce())return\"\";if(He())return\"\";if(!f().projectId)return\"\";return h`
\n ${mt({loops:()=>{let $=In();return $?kt($.loops,l()):[]},now:E,onOpen:($)=>S($)})}\n ${()=>In()?\"\":Pn()}\n
`}),Oi=w(()=>{let y=f();if(y.section!==\"groups\"||!y.groupId)return null;let $=J();if(!$)return null;return $.groups.find((I)=>I.group.groupId===y.groupId)??null}),Ei=w(()=>{let y=J();if(!y)return new Set;return new Set(y.loops.map(($)=>$.loop.loopName))}),ki=w(()=>{if(!B())return\"\";let y=f();if(!y.projectId)return\"\";if(y.groupId)return fi({group:Oi,loopNames:Ei,projectId:()=>f().projectId,onBack:()=>k({groupId:null}),onOpenLoop:S});return ii({groups:()=>J()?.groups??[],onOpen:($)=>k({groupId:$})})}),Vn=w(()=>{let y=J();return y?y.loops:[]}),Ai=w(()=>{if(!qe())return\"\";if(!f().projectId)return\"\";return ui({loops:Vn,projectId:()=>f().projectId,onOpenLoop:S})}),Si=w(()=>{if(!si())return\"\";if(!f().projectId)return\"\";return ri({loops:Vn,onOpenLoop:S})});return m(()=>{if(!t())return;let y=f();if(y.projectId===null)return;if(!e.projects.some((I)=>I.projectId===y.projectId))u((I)=>({...I,projectId:null,loopName:null,section:\"loops\",statuses:[],query:\"\"}))}),m(()=>{let y=f();if(!y.projectId||!y.loopName)return;let $=J();if(!$)return;if(!$.loops.some((D)=>D.loop.loopName===y.loopName))u((D)=>({...D,loopName:null}))}),m(()=>{let y=f();if(!y.projectId||!y.loopName||y.tab===\"overview\")return;let $=He();if(!$)return;if(!Ge($).includes(y.tab))u((D)=>({...D,tab:\"overview\"}))}),m(()=>{if(!t())return;xt(Ue(f()),s)}),Hn(()=>{u(De(location.hash)),x();let y=setInterval(()=>{x()},5000),$=()=>{if(s.current){s.current=!1;return}u(De(location.hash)),b((I)=>I+1)};window.addEventListener(\"hashchange\",$),re(()=>{clearInterval(y),window.removeEventListener(\"hashchange\",$)})}),h`
\n

Forge Dashboard

\n\n ${()=>{let y=r();return y?h`
${y}
`:\"\"}}\n\n ${()=>{if(!t())return\"\";return h`
\n ${Jt({generatedAt:e.generatedAt})}\n
`}}\n\n ${()=>{if(!t())return\"\";if(L()&&_().length===0)return Pn();return h`
\n ${()=>{if(L())return h`
\n ${Qt({entries:()=>_(),labels:()=>W(),onSelect:(y)=>k({projectId:y,loopName:null,section:\"loops\",groupId:null})})}\n ${Rt({entries:()=>_(),labels:()=>W(),onOpenLoop:(y,$)=>k({projectId:y,loopName:$,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})})}\n
`;return h`
\n ${yi}\n ${bi}\n ${gi}\n ${xi}\n ${$i}\n ${ki}\n ${Ai}\n ${Si}\n
`}}\n
`}}\n
`}var li=document.getElementById(\"forge-app-root\");if(li)nt(ci,li);\n"; -export const DASHBOARD_APP_SOURCE_HASH: string = "8a04a88dd3baa24a999239fd7a3ae90515229398b44bcc5e38db356e2fb92081"; +export const DASHBOARD_APP_BUNDLE: string = "var z={context:void 0,registry:void 0,effects:void 0,done:!1,getContextId(){return pn(this.context.count)},getNextContextId(){return pn(this.context.count++)}};function pn(e){let n=String(e),t=n.length-1;return z.context.id+(t?String.fromCharCode(96+t):\"\")+n}function rn(e){z.context=e}function nf(){return{...z.context,id:z.getNextContextId(),count:0}}var tf=!0,ff=(e,n)=>e===n,X=Symbol(\"solid-proxy\"),uf=typeof Proxy===\"function\",cn=Symbol(\"solid-track\"),ut=Symbol(\"solid-dev-component\"),Ye={equals:ff},et=null,rt=ht,q=1,Oe=2,rf={};var P=null,O=null,Ee=null,le=null,v=null,W=null,Y=null,je=0,Q={afterUpdate:null,afterCreateOwner:null,afterCreateSignal:null,afterRegisterGraph:null};function lt(e,n){let t=v,i=P,f=e.length===0,u=n===void 0?i:n,r=f?{owned:null,cleanups:null,context:null,owner:null}:{owned:null,cleanups:null,context:u?u.context:null,owner:u},l=f?()=>e(()=>{throw Error(\"Dispose method must be an explicit argument to createRoot function\")}):()=>e(()=>I(()=>ne(r)));Q.afterCreateOwner&&Q.afterCreateOwner(r),P=r,v=null;try{return B(l,!0)}finally{v=t,P=i}}function U(e,n){n=n?Object.assign({},Ye,n):Ye;let t={value:e,observers:null,observerSlots:null,comparator:n.equals||void 0};{if(n.name)t.name=n.name;if(n.internal)t.internal=!0;else if(dt(t),Q.afterCreateSignal)Q.afterCreateSignal(t)}let i=(f)=>{if(typeof f===\"function\")if(O&&O.running&&O.sources.has(t))f=f(t.tValue);else f=f(t.value);return dn(t,f)};return[ot.bind(t),i]}function M(e,n,t){let i=He(e,n,!1,q,t);if(Ee&&O&&O.running)W.push(i);else se(i)}function R(e,n,t){rt=of;let i=He(e,n,!1,q,t),f=ln&&cf(ln);if(f)i.suspense=f;if(!t||!t.render)i.user=!0;Y?Y.push(i):se(i)}function $(e,n,t){t=t?Object.assign({},Ye,t):Ye;let i=He(e,n,!0,0,t);if(i.observers=null,i.observerSlots=null,i.comparator=t.equals||void 0,Ee&&O&&O.running)i.tState=q,W.push(i);else se(i);return ot.bind(i)}function Ce(e){return B(e,!1)}function I(e){if(!le&&v===null)return e();let n=v;v=null;try{if(le)return le.untrack(e);return e()}finally{v=n}}function ct(e,n,t){let i=Array.isArray(e),f,u=t&&t.defer;return(r)=>{let l;if(i){l=Array(e.length);for(let s=0;sn(l,f,r));return f=l,c}}function st(e){R(()=>I(e))}function ce(e){if(P===null)console.warn(\"cleanups created outside a `createRoot` or `render` will never be run\");else if(P.cleanups===null)P.cleanups=[e];else P.cleanups.push(e);return e}function Ge(){return v}function De(){return P}function sn(e,n){let t=P,i=v;P=e,v=null;try{return B(n,!0)}catch(f){Ze(f)}finally{P=t,v=i}}function lf(e){if(O&&O.running)return e(),O.done;let n=v,t=P;return Promise.resolve().then(()=>{v=n,P=t;let i;if(Ee||ln)i=O||(O={sources:new Set,effects:[],promises:new Set,disposed:new Set,queue:new Set,running:!0}),i.done||(i.done=new Promise((f)=>i.resolve=f)),i.running=!0;return B(e,!1),v=P=null,i?i.done:void 0})}var[vu,nt]=U(!1);function tt(e,n){let t=He(()=>I(()=>{return Object.assign(e,{[ut]:!0}),e(n)}),void 0,!0,0);return t.props=n,t.observers=null,t.observerSlots=null,t.name=e.name,t.component=e,se(t),t.tValue!==void 0?t.tValue:t.value}function dt(e){if(P){if(P.sourceMap)P.sourceMap.push(e);else P.sourceMap=[e];e.graph=P}if(Q.afterRegisterGraph)Q.afterRegisterGraph(e)}function cf(e){let n;return P&&P.context&&(n=P.context[e.id])!==void 0?n:e.defaultValue}var ln;function ot(){let e=O&&O.running;if(this.sources&&(e?this.tState:this.state))if((e?this.tState:this.state)===q)se(this);else{let n=W;W=null,B(()=>Ie(this),!1),W=n}if(v){let n=this.observers?this.observers.length:0;if(!v.sources)v.sources=[this],v.sourceSlots=[n];else v.sources.push(this),v.sourceSlots.push(n);if(!this.observers)this.observers=[v],this.observerSlots=[v.sources.length-1];else this.observers.push(v),this.observerSlots.push(v.sources.length-1)}if(e&&O.sources.has(this))return this.tValue;return this.value}function dn(e,n,t){let i=O&&O.running&&O.sources.has(e)?e.tValue:e.value;if(!e.comparator||!e.comparator(i,n)){if(O){let f=O.running;if(f||!t&&O.sources.has(e))O.sources.add(e),e.tValue=n;if(!f)e.value=n}else e.value=n;if(e.observers&&e.observers.length)B(()=>{for(let f=0;f1e6){if(W=[],tf)throw Error(\"Potential Infinite Loop Detected.\");throw Error()}},!1)}return n}function se(e){if(!e.fn)return;ne(e);let n=je;if(it(e,O&&O.running&&O.sources.has(e)?e.tValue:e.value,n),O&&!O.running&&O.sources.has(e))queueMicrotask(()=>{B(()=>{O&&(O.running=!0),v=P=e,it(e,e.tValue,n),v=P=null},!1)})}function it(e,n,t){let i,f=P,u=v;v=P=e;try{i=e.fn(n)}catch(r){if(e.pure)if(O&&O.running)e.tState=q,e.tOwned&&e.tOwned.forEach(ne),e.tOwned=void 0;else e.state=q,e.owned&&e.owned.forEach(ne),e.owned=null;return e.updatedAt=t+1,Ze(r)}finally{v=u,P=f}if(!e.updatedAt||e.updatedAt<=t){if(e.updatedAt!=null&&\"observers\"in e)dn(e,i,!0);else if(O&&O.running&&e.pure){if(!O.sources.has(e))e.value=i;O.sources.add(e),e.tValue=i}else e.value=i;e.updatedAt=t}}function He(e,n,t,i=q,f){let u={fn:e,state:i,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:n,owner:P,context:P?P.context:null,pure:t};if(O&&O.running)u.state=0,u.tState=i;if(P===null)console.warn(\"computations created outside a `createRoot` or `render` will never be disposed\");else if(P!==rf)if(O&&O.running&&P.pure)if(!P.tOwned)P.tOwned=[u];else P.tOwned.push(u);else if(!P.owned)P.owned=[u];else P.owned.push(u);if(f&&f.name)u.name=f.name;if(le&&u.fn){let r=u.fn,[l,c]=U(void 0,{equals:!1}),s=le.factory(r,c);ce(()=>s.dispose());let d,g=()=>lf(c).then(()=>{if(d)d.dispose(),d=void 0});u.fn=(k)=>{if(l(),O&&O.running){if(!d)d=le.factory(r,g);return d.track(k)}return s.track(k)}}return Q.afterCreateOwner&&Q.afterCreateOwner(u),u}function ke(e){let n=O&&O.running;if((n?e.tState:e.state)===0)return;if((n?e.tState:e.state)===Oe)return Ie(e);if(e.suspense&&I(e.suspense.inFallback))return e.suspense.effects.push(e);let t=[e];while((e=e.owner)&&(!e.updatedAt||e.updatedAt=0;i--){if(e=t[i],n){let f=e,u=t[i+1];while((f=f.owner)&&f!==u)if(O.disposed.has(f))return}if((n?e.tState:e.state)===q)se(e);else if((n?e.tState:e.state)===Oe){let f=W;W=null,B(()=>Ie(e,t[0]),!1),W=f}}}function B(e,n){if(W)return e();let t=!1;if(!n)W=[];if(Y)t=!0;else Y=[];je++;try{let i=e();return sf(t),i}catch(i){if(!t)Y=null;W=null,Ze(i)}}function sf(e){if(W){if(Ee&&O&&O.running)df(W);else ht(W);W=null}if(e)return;let n;if(O){if(!O.promises.size&&!O.queue.size){let{sources:i,disposed:f}=O;Y.push.apply(Y,O.effects),n=O.resolve;for(let u of Y)\"tState\"in u&&(u.state=u.tState),delete u.tState;O=null,B(()=>{for(let u of f)ne(u);for(let u of i){if(u.value=u.tValue,u.owned)for(let r=0,l=u.owned.length;rrt(t),!1);else Q.afterUpdate&&Q.afterUpdate();if(n)n()}function ht(e){for(let n=0;n{i.delete(t),B(()=>{O.running=!0,ke(t)},!1),O&&(O.running=!1)})}}function of(e){let n,t=0;for(n=0;n=0;n--)ne(e.tOwned[n]);delete e.tOwned}if(O&&O.running&&e.pure)yt(e,!0);else if(e.owned){for(n=e.owned.length-1;n>=0;n--)ne(e.owned[n]);e.owned=null}if(e.cleanups){for(n=e.cleanups.length-1;n>=0;n--)e.cleanups[n]();e.cleanups=null}if(O&&O.running)e.tState=0;else e.state=0;delete e.sourceMap}function yt(e,n){if(!n)e.tState=0,O.disposed.add(e);if(e.owned)for(let t=0;t=0;l--){let c=un(e[l])[r];if(c!==void 0)return c}},has(r){for(let l=e.length-1;l>=0;l--)if(r in un(e[l]))return!0;return!1},keys(){let r=[];for(let l=0;l=0;r--){let l=e[r];if(!l)continue;let c=Object.getOwnPropertyNames(l);for(let s=c.length-1;s>=0;s--){let d=c[s];if(d===\"__proto__\"||d===\"constructor\")continue;let g=Object.getOwnPropertyDescriptor(l,d);if(!i[d])i[d]=g.get?{enumerable:!0,configurable:!0,get:bf.bind(t[d]=[g.get.bind(l)])}:g.value!==void 0?g:void 0;else{let k=t[d];if(k){if(g.get)k.push(g.get.bind(l));else if(g.value!==void 0)k.push(()=>g.value)}}}}let f={},u=Object.keys(i);for(let r=u.length-1;r>=0;r--){let l=u[r],c=i[l];if(c&&c.get)Object.defineProperty(f,l,c);else f[l]=c?c.value:void 0}return f}var bt={hooks:Q,writeSignal:dn,registerGraph:dt};if(globalThis)if(!globalThis.Solid$$)globalThis.Solid$$=!0;else console.warn(\"You appear to have multiple instances of Solid. This can lead to unexpected behavior.\");var $f=[\"allowfullscreen\",\"async\",\"alpha\",\"autofocus\",\"autoplay\",\"checked\",\"controls\",\"default\",\"disabled\",\"formnovalidate\",\"hidden\",\"indeterminate\",\"inert\",\"ismap\",\"loop\",\"multiple\",\"muted\",\"nomodule\",\"novalidate\",\"open\",\"playsinline\",\"readonly\",\"required\",\"reversed\",\"seamless\",\"selected\",\"adauctionheaders\",\"browsingtopics\",\"credentialless\",\"defaultchecked\",\"defaultmuted\",\"defaultselected\",\"defer\",\"disablepictureinpicture\",\"disableremoteplayback\",\"preservespitch\",\"shadowrootclonable\",\"shadowrootcustomelementregistry\",\"shadowrootdelegatesfocus\",\"shadowrootserializable\",\"sharedstoragewritable\"],yn=new Set([\"className\",\"value\",\"readOnly\",\"noValidate\",\"formNoValidate\",\"isMap\",\"noModule\",\"playsInline\",\"adAuctionHeaders\",\"allowFullscreen\",\"browsingTopics\",\"defaultChecked\",\"defaultMuted\",\"defaultSelected\",\"disablePictureInPicture\",\"disableRemotePlayback\",\"preservesPitch\",\"shadowRootClonable\",\"shadowRootCustomElementRegistry\",\"shadowRootDelegatesFocus\",\"shadowRootSerializable\",\"sharedStorageWritable\",...$f]),bn=new Set([\"innerHTML\",\"textContent\",\"innerText\",\"children\"]),$n=Object.assign(Object.create(null),{className:\"class\",htmlFor:\"for\"}),wf=Object.assign(Object.create(null),{class:\"className\",novalidate:{$:\"noValidate\",FORM:1},formnovalidate:{$:\"formNoValidate\",BUTTON:1,INPUT:1},ismap:{$:\"isMap\",IMG:1},nomodule:{$:\"noModule\",SCRIPT:1},playsinline:{$:\"playsInline\",VIDEO:1},readonly:{$:\"readOnly\",INPUT:1,TEXTAREA:1},adauctionheaders:{$:\"adAuctionHeaders\",IFRAME:1},allowfullscreen:{$:\"allowFullscreen\",IFRAME:1},browsingtopics:{$:\"browsingTopics\",IMG:1},defaultchecked:{$:\"defaultChecked\",INPUT:1},defaultmuted:{$:\"defaultMuted\",AUDIO:1,VIDEO:1},defaultselected:{$:\"defaultSelected\",OPTION:1},disablepictureinpicture:{$:\"disablePictureInPicture\",VIDEO:1},disableremoteplayback:{$:\"disableRemotePlayback\",AUDIO:1,VIDEO:1},preservespitch:{$:\"preservesPitch\",AUDIO:1,VIDEO:1},shadowrootclonable:{$:\"shadowRootClonable\",TEMPLATE:1},shadowrootdelegatesfocus:{$:\"shadowRootDelegatesFocus\",TEMPLATE:1},shadowrootserializable:{$:\"shadowRootSerializable\",TEMPLATE:1},sharedstoragewritable:{$:\"sharedStorageWritable\",IFRAME:1,IMG:1}});function wn(e,n){let t=wf[e];return typeof t===\"object\"?t[n]?t.$:void 0:t}var gn=new Set([\"beforeinput\",\"click\",\"dblclick\",\"contextmenu\",\"focusin\",\"focusout\",\"input\",\"keydown\",\"keyup\",\"mousedown\",\"mousemove\",\"mouseout\",\"mouseover\",\"mouseup\",\"pointerdown\",\"pointermove\",\"pointerout\",\"pointerover\",\"pointerup\",\"touchend\",\"touchmove\",\"touchstart\"]),Ot=new Set([\"altGlyph\",\"altGlyphDef\",\"altGlyphItem\",\"animate\",\"animateColor\",\"animateMotion\",\"animateTransform\",\"circle\",\"clipPath\",\"color-profile\",\"cursor\",\"defs\",\"desc\",\"ellipse\",\"feBlend\",\"feColorMatrix\",\"feComponentTransfer\",\"feComposite\",\"feConvolveMatrix\",\"feDiffuseLighting\",\"feDisplacementMap\",\"feDistantLight\",\"feDropShadow\",\"feFlood\",\"feFuncA\",\"feFuncB\",\"feFuncG\",\"feFuncR\",\"feGaussianBlur\",\"feImage\",\"feMerge\",\"feMergeNode\",\"feMorphology\",\"feOffset\",\"fePointLight\",\"feSpecularLighting\",\"feSpotLight\",\"feTile\",\"feTurbulence\",\"filter\",\"font\",\"font-face\",\"font-face-format\",\"font-face-name\",\"font-face-src\",\"font-face-uri\",\"foreignObject\",\"g\",\"glyph\",\"glyphRef\",\"hkern\",\"image\",\"line\",\"linearGradient\",\"marker\",\"mask\",\"metadata\",\"missing-glyph\",\"mpath\",\"path\",\"pattern\",\"polygon\",\"polyline\",\"radialGradient\",\"rect\",\"set\",\"stop\",\"svg\",\"switch\",\"symbol\",\"text\",\"textPath\",\"tref\",\"tspan\",\"use\",\"view\",\"vkern\"]),xn={xlink:\"http://www.w3.org/1999/xlink\",xml:\"http://www.w3.org/XML/1998/namespace\"};function gf(e,n,t){let i=t.length,f=n.length,u=i,r=0,l=0,c=n[f-1].nextSibling,s=null;while(rd-l){let o=n[r];while(l{f=u,n===document?e():Sn(n,e(),n.firstChild?null:void 0,t)},i.owner),()=>{f(),n.textContent=\"\"}}function On(e,n=window.document){let t=n[$t]||(n[$t]=new Set);for(let i=0,f=e.length;if.call(e,t[1],u))}else e.addEventListener(n,t,typeof t!==\"function\"&&t)}function Cn(e,n,t={}){let i=Object.keys(n||{}),f=Object.keys(t),u,r;for(u=0,r=f.length;uf.children=oe(e,n.children,f.children));return M(()=>typeof n.ref===\"function\"&&kf(n.ref,e)),M(()=>Ef(e,n,t,!0,f,!0)),f}function Ct(e,n){let t=e[n];return Object.defineProperty(e,n,{get(){return t()},enumerable:!0}),e}function kf(e,n,t){return I(()=>e(n,t))}function Sn(e,n,t,i){if(t!==void 0&&!i)i=[];if(typeof n!==\"function\")return oe(e,n,i,t);M((f)=>oe(e,n(),f,t),i)}function Ef(e,n,t,i,f={},u=!1){n||(n={});for(let r in f)if(!(r in n)){if(r===\"children\")continue;f[r]=gt(e,r,null,f[r],t,u,n)}for(let r in n){if(r===\"children\"){if(!i)oe(e,n.children);continue}let l=n[r];f[r]=gt(e,r,l,f[r],t,u,n)}}function he(e){return!!z.context&&!z.done&&(!e||e.isConnected)}function Cf(e){return e.toLowerCase().replace(/-([a-z])/g,(n,t)=>t.toUpperCase())}function wt(e,n,t){let i=n.trim().split(/\\s+/);for(let f=0,u=i.length;f-1&&xn[n.split(\":\")[0]];if(k)kn(e,k,n,t);else Ae(e,$n[n]||n,t)}return t}function Af(e){if(z.registry&&z.events){if(z.events.find(([c,s])=>s===e))return}let n=e.target,t=`$$${e.type}`,i=e.target,f=e.currentTarget,u=(c)=>Object.defineProperty(e,\"target\",{configurable:!0,value:c}),r=()=>{let c=n[t];if(c&&!n.disabled){let s=n[`${t}Data`];if(s!==void 0?c.call(n,s,e):c.call(n,e),e.cancelBubble)return}return n.host&&typeof n.host!==\"string\"&&!n.host._$host&&n.contains(e.target)&&u(n.host),!0},l=()=>{while(r()&&(n=n._$host||n.parentNode||n.host));};if(Object.defineProperty(e,\"currentTarget\",{configurable:!0,get(){return n||document}}),z.registry&&!z.done)z.done=_$HY.done=!0;if(e.composedPath){let c=e.composedPath();u(c[0]);for(let s=0;s{let c=n();while(typeof c===\"function\")c=c();t=oe(e,c,t,i)}),()=>t;else if(Array.isArray(n)){let c=[],s=t&&Array.isArray(t);if(an(c,n,t,f))return M(()=>t=oe(e,c,t,i,!0)),()=>t;if(u){if(!c.length)return t;if(i===void 0)return t=[...e.childNodes];let d=c[0];if(d.parentNode!==e)return t;let g=[d];while((d=d.nextSibling)!==i)g.push(d);return t=g}if(c.length===0){if(t=de(e,t,i),l)return t}else if(s)if(t.length===0)xt(e,c,i);else gf(e,t,c);else t&&de(e),xt(e,c);t=c}else if(n.nodeType){if(u&&n.parentNode)return t=l?[n]:n;if(Array.isArray(t)){if(l)return t=de(e,t,i,n);de(e,t,null,n)}else if(t==null||t===\"\"||!e.firstChild)e.appendChild(n);else e.replaceChild(n,e.firstChild);t=n}else console.warn(\"Unrecognized value. Skipped inserting\",n);return t}function an(e,n,t,i){let f=!1;for(let u=0,r=n.length;u=0;r--){let l=n[r];if(f!==l){let c=l.parentNode===e;if(!u&&!r)c?e.replaceChild(f,l):e.insertBefore(f,t);else c&&l.remove()}else u=!0}}else e.insertBefore(f,t);return[f]}var Xu=Symbol();var Vn=Symbol(\"store-raw\"),ye=Symbol(\"store-node\"),ee=Symbol(\"store-has\"),St=Symbol(\"store-self\"),At={onStoreNodeUpdate:null};function Vt(e){let n=e[X];if(!n){if(Object.defineProperty(e,X,{value:n=new Proxy(e,Nf)}),!Array.isArray(e)){let t=Object.keys(e),i=Object.getOwnPropertyDescriptors(e);for(let f=0,u=t.length;fe[X][n],t}function Nt(e){Ge()&&Ve(qe(e,ye),St)()}function Vf(e){return Nt(e),Reflect.ownKeys(e)}var Nf={get(e,n,t){if(n===Vn)return e;if(n===X)return t;if(n===cn)return Nt(e),t;let i=qe(e,ye),f=i[n],u=f?f():e[n];if(n===ye||n===ee||n===\"__proto__\")return u;if(!f){let r=Object.getOwnPropertyDescriptor(e,n);if(Ge()&&(typeof u!==\"function\"||e.hasOwnProperty(n))&&!(r&&r.get))u=Ve(i,n,u)()}return te(u)?Vt(u):u},has(e,n){if(n===Vn||n===X||n===cn||n===ye||n===ee||n===\"__proto__\")return!0;return Ge()&&Ve(qe(e,ee),n)(),n in e},set(){return console.warn(\"Cannot mutate a Store directly\"),!0},deleteProperty(){return console.warn(\"Cannot mutate a Store directly\"),!0},ownKeys:Vf,getOwnPropertyDescriptor:Sf};function J(e,n,t,i=!1){if(!i&&e[n]===t)return;let f=e[n],u=e.length;if(At.onStoreNodeUpdate&&At.onStoreNodeUpdate(e,n,t,f),t===void 0){if(delete e[n],e[ee]&&e[ee][n]&&f!==void 0)e[ee][n].$()}else if(e[n]=t,e[ee]&&e[ee][n]&&f===void 0)e[ee][n].$();let r=qe(e,ye),l;if(l=Ve(r,n,f))l.$(()=>t);if(Array.isArray(e)&&e.length!==u){for(let c=e.length;c1){i=n.shift();let r=typeof i,l=Array.isArray(e);if(Array.isArray(i)){for(let c=0;c1){Se(e[i],n,[i].concat(t));return}f=e[i],t=[i].concat(t)}let u=n[0];if(typeof u===\"function\"){if(u=u(f,t),u===f)return}if(i===void 0&&u==null)return;if(u=be(u),i===void 0||te(f)&&te(u)&&!Array.isArray(u))Pt(f,u);else J(e,i,u)}function Tt(...[e,n]){let t=be(e||{}),i=Array.isArray(t);if(typeof t!==\"object\"&&typeof t!==\"function\")throw Error(`Unexpected type ${typeof t} received when initializing 'createStore'. Expected an object.`);let f=Vt(t);bt.registerGraph({value:t,name:n&&n.name});function u(...r){Ce(()=>{i&&r.length===1?Pf(t,r[0]):Se(t,r)})}return[f,u]}var Nn=Symbol(\"store-root\");function ae(e,n,t,i,f){let u=n[t];if(e===u)return;let r=Array.isArray(e);if(t!==Nn&&(!te(e)||!te(u)||r!==Array.isArray(u)||f&&e[f]!==u[f])){J(n,t,e);return}if(r){if(e.length&&u.length&&(!i||f&&e[0]&&e[0][f]!=null)){let s,d,g,k,b,o,a,w;for(g=0,k=Math.min(u.length,e.length);g=g&&b>=g&&(u[k]===e[b]||f&&u[k]&&e[b]&&u[k][f]&&u[k][f]===e[b][f]);k--,b--)E[b]=u[k];if(g>b||g>k){for(d=g;d<=b;d++)J(u,d,e[d]);for(;de.length)J(u,\"length\",e.length);return}a=Array(b+1);for(d=b;d>=g;d--)o=e[d],w=f&&o?o[f]:o,s=N.get(w),a[d]=s===void 0?-1:s,N.set(w,d);for(s=g;s<=k;s++)if(o=u[s],w=f&&o?o[f]:o,d=N.get(w),d!==void 0&&d!==-1)E[d]=u[s],d=a[d],N.set(w,d);for(d=g;de.length)J(u,\"length\",e.length);return}let l=Object.keys(e);for(let s=0,d=l.length;s{if(!te(u)||!te(f))return f;let r=ae(f,{[Nn]:u},Nn,t,i);return r===void 0?u:r}}var Tf=/(?:|<(?:\"[^\"]*\"['\"]*|'[^']*'['\"]*|[^'\">])+>)/g,Uf=/(?:\\s(?[^/\\s><=]+?)(?=[\\s/>]))|(?:(?\\S+?)(?:\\s*=\\s*(?:(['\"])(?[\\s\\S]*?)\\3|(?[^\\s>]+))))/g,_f={area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,menuitem:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0};function vf(e){let n={type:\"tag\",name:\"\",voidElement:!1,attrs:[],children:[]},t=e.match(/<\\/?([^\\s]+?)[/\\s>]/);if(t){if(n.name=t[1],_f[t[1].toLowerCase()]||e.charAt(e.length-2)===\"/\")n.voidElement=!0;if(n.name.startsWith(\"!--\")){let f=e.indexOf(\"-->\");return{type:\"comment\",comment:f!==-1?e.slice(4,f):\"\"}}}let i=new RegExp(Uf);for(let f of e.matchAll(i))if((f[1]||f[2]).startsWith(\"use:\"))n.attrs.push({type:\"directive\",name:f[1]||f[2],value:f[4]||f[5]||\"\"});else n.attrs.push({type:\"attr\",name:f[1]||f[2],value:f[4]||f[5]||\"\"});return n}function _t(e,n,t){let i=n.indexOf(\"<\",t),f=n.slice(t,i===-1?void 0:i);if(!/^\\s*$/.test(f))e.push({type:\"text\",content:f})}function vt(e,n){let t=n.replace(\"\",\"\");if(!/^\\s*$/.test(t))e.push({type:\"comment\",content:t})}function Ff(e){let n=[],t=void 0,i=-1,f=[],u={};return e.replace(Tf,(r,l)=>{let c=r.charAt(1)!==\"/\",s=r.slice(0,4)===\"\"}}function Kf(e){return e.reduce(function(n,t){return n+Kt(\"\",t)},\"\")}var Ft=new Map,Wf=/^(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$/i,Je=\" \\\\f\\\\n\\\\r\\\\t\",Wt=\"[^\"+Je+`\\\\/>\"'=]+`,Tn=\"[ \"+Je+\"]+(?:use:|\"+Wt+\")\",Yt=\"<([A-Za-z$#]+[A-Za-z0-9:_-]*)((?:\",It=`(?:\\\\s*=\\\\s*(?:'[^']*?'|\"[^\"]*?\"|\\\\([^)]*?\\\\)|<[^>]*?>|`+Wt+\"))?)\",Yf=new RegExp(Yt+Tn+It+\"+)([ \"+Je+\"]*/?>)\",\"g\"),If=new RegExp(\"(\"+Tn+`\\\\s*=\\\\s*)(|['\"(]([\\\\w\\\\s]*[\\\\w\\\\s]*)*['\")])`,\"gi\"),jf=new RegExp(Yt+Tn+It+\"*)([ \"+Je+\"]*/>)\",\"g\"),zt=\"\",Gf=new Set([\"class\",\"on\",\"oncapture\",\"style\",\"use\",\"prop\",\"attr\"]);function Df(e,n,t,i){return\"<\"+n+t.replace(If,Hf)+i}function Hf(e,n,t){return n.replace(//g,\"###\")+(t[0]==='\"'||t[0]===\"'\"?t.replace(//g,\"###\"):'\"###\"')}function Zf(e,n,t){return Wf.test(n)?e:\"<\"+n+t+\">\"}function qf(e){return e.toLowerCase().replace(/-([a-z])/g,(n,t)=>t.toUpperCase())}function Pn(e,n,t,i){if(e===\"use:###\"&&n===\"###\"){let f=i.counter++;i.exprs.push(`typeof exprs[${f}] === \"function\" ? r.use(exprs[${f}], ${t}, exprs[${i.counter++}]) : (()=>{throw new Error(\"use:### must be a function\")})()`)}else throw Error(`Not support syntax ${e} must be use:{function}`)}function Jf(e,{delegateEvents:n=!0,functionBuilder:t=(...i)=>Function(...i)}={}){let i=1;e.wrapProps=(b)=>{let o=Object.getOwnPropertyDescriptors(b);for(let a in o)if(typeof o[a].value===\"function\"&&!o[a].value.length)e.dynamicProperty(b,a);return b};function f(b,o){let a=0,w=\"\";for(;a\";w=w+b[a],w=[[jf,Zf],[/<()/g,\"<###\"],[/\\.\\.\\.()/g,\"###\"],[Yf,Df],[/>\\n+\\s*/g,\">\"],[/\\n+\\s*\\s+/g,\"> \"]].reduce((T,_)=>{return T.replace(_[0],_[1])},w);let N=Ff(w),[A,C]=g(N,o.funcBuilder),V=[];for(let T=0;T-1){let m=K.split(zt).reduce((p,_e,ge)=>{return ge&&p.push(\"\"),p.push(_e),p},[]);_[T].firstChild.replaceWith(...m)}}}return V[0].create=C,Ft.set(b,V),V}function u(b,o,a,w,E,N,A){let C=w===\"###\"?`!doNotWrap ? exprs[${A.counter}]() : exprs[${A.counter++}]`:w.split(\"###\").map((K,m)=>m?` + (typeof exprs[${A.counter}] === \"function\" ? exprs[${A.counter}]() : exprs[${A.counter++}]) + \"${K}\"`:`\"${K}\"`).join(\"\"),V,T;if((V=a.split(\":\"))&&V[1]&&Gf.has(V[0]))a=V[1],T=V[0];let _=e.ChildProperties.has(a),j=e.Properties.has(a);if(a===\"style\"){let K=`_$v${i++}`;A.decl.push(`${K}={}`),A.exprs.push(`r.style(${o},${C},${K})`)}else if(a===\"classList\"){let K=`_$v${i++}`;A.decl.push(`${K}={}`),A.exprs.push(`r.classList(${o},${C},${K})`)}else if(T!==\"attr\"&&(_||!E&&(e.getPropAlias(a,b.name.toUpperCase())||j)||N||T===\"prop\")){if(N&&!_&&!j&&T!==\"prop\")a=qf(a);A.exprs.push(`${o}.${e.getPropAlias(a,b.name.toUpperCase())||a} = ${C}`)}else{let K=E&&a.indexOf(\":\")>-1&&e.SVGNamespace[a.split(\":\")[0]];if(K)A.exprs.push(`r.setAttributeNS(${o},\"${K}\",\"${a}\",${C})`);else A.exprs.push(`r.setAttribute(${o},\"${e.Aliases[a]||a}\",${C})`)}}function r(b,o,a,w,E,N,A){if(a.slice(0,2)===\"on\")if(!a.includes(\":\")){let C=a.slice(2).toLowerCase(),V=n&&e.DelegatedEvents.has(C);A.exprs.push(`r.addEventListener(${o},\"${C}\",exprs[${A.counter++}],${V})`),V&&A.delegatedEvents.add(C)}else{let C=a.startsWith(\"oncapture:\");A.exprs.push(`${o}.addEventListener(\"${a.slice(C?10:3)}\",exprs[${A.counter++}]${C?\",true\":\"\"})`)}else if(a===\"ref\")A.exprs.push(`exprs[${A.counter++}](${o})`);else{let C=Object.assign({},A,{exprs:[]}),V=A.counter;if(u(b,o,a,w,E,N,C),A.decl.push(`_fn${V} = (${w===\"###\"?\"doNotWrap\":\"\"}) => {\n${C.exprs.join(`;\n`)};\n}`),w===\"###\")A.exprs.push(`typeof exprs[${V}] === \"function\" ? r.effect(_fn${V}) : _fn${V}(true)`);else{let T=\"\";for(let _=V;_1)for(let E=0;E1?`r.mergeProps(${o.join(\",\")})`:o[0]}function s(b,o){let a=[],w=Object.keys(b.attrs),E=[a],N=o.counter++;for(let C=0;C exprs[${o.counter++}]`);else if(b.children.length){let C={type:\"fragment\",children:b.children},V=Object.assign({},o,{first:!0,decl:[],exprs:[],parent:!1});d(C,V),a.push(`children: () => { ${V.exprs.join(`;\n`)}}`),o.templateId=V.templateId,o.counter=V.counter}let A;if(o.multi)A=`_$el${i++}`,o.decl.push(`${A} = ${o.path}.${o.first?\"firstChild\":\"nextSibling\"}`);if(o.parent)o.exprs.push(`r.insert(${o.parent}, r.createComponent(exprs[${N}],${c(E)})${A?`, ${A}`:\"\"})`);else o.exprs.push(`${o.fragment?\"\":\"return \"}r.createComponent(exprs[${N}],${c(E)})`);o.path=A,o.first=!1}function d(b,o){if(b.type===\"fragment\"){let a=[];b.children.forEach((w)=>{if(w.type===\"tag\"){if(w.name===\"###\"){let A=Object.assign({},o,{first:!0,fragment:!0,decl:[],exprs:[]});s(w,A),a.push(A.exprs[0]),o.counter=A.counter,o.templateId=A.templateId;return}o.templateId++;let E=i,N=Object.assign({},o,{first:!0,decl:[],exprs:[]});o.templateNodes.push([w]),d(w,N),a.push(`function() { ${N.decl.join(`,\n`)+`;\n`+N.exprs.join(`;\n`)+`;\nreturn _$el${E};\n`}}()`),o.counter=N.counter,o.templateId=N.templateId}else if(w.type===\"text\")a.push(`\"${w.content}\"`);else if(w.type===\"comment\"){if(w.content===\"#\")a.push(`exprs[${o.counter++}]`);else if(w.content)for(let E=0;EC.name===\"is\");if(o.hasCustomElement=A,o.isImportNode=(b.name===\"img\"||b.name===\"iframe\")&&b.attrs.some((C)=>C.name===\"loading\"&&C.value===\"lazy\"),b.attrs.some((C)=>C.name===\"###\")){let C=[],V=\"\",T=[];for(let _=0;_({${V}})`),V=\"\";C.push(`exprs[${o.counter++}]`)}else T.push(b.attrs[_]);else if(j===\"directive\")Pn(K,m,a,o)}if(b.attrs=T,V.length)C.push(`()=>({${V}})`);o.exprs.push(`r.spread(${a},${C.length===1?`typeof ${C[0]} === \"function\" ? r.mergeProps(${C[0]}) : ${C[0]}`:`r.mergeProps(${C.join(\",\")})`},${N},${!!b.children.length})`)}else for(let C=0;C document.importNode(tmpls[${E}].content.firstChild, true))`:`const ${a} = tmpls[${E}].content.firstChild.cloneNode(true)`}else if(b.type===\"text\"){let a=`_$el${i++}`;o.decl.push(`${a} = ${o.path}.${o.first?\"firstChild\":\"nextSibling\"}`),o.path=a,o.first=!1}else if(b.type===\"comment\"){let a=`_$el${i++}`;if(o.decl.push(`${a} = ${o.path}.${o.first?\"firstChild\":\"nextSibling\"}`),b.content===\"#\")if(o.multi)o.exprs.push(`r.insert(${o.parent}, exprs[${o.counter++}], ${a})`);else o.exprs.push(`r.insert(${o.parent}, exprs[${o.counter++}])`);o.path=a,o.first=!1}}function g(b,o){let a={path:\"\",decl:[],exprs:[],delegatedEvents:new Set,counter:0,first:!0,multi:!1,templateId:0,templateNodes:[]},w=i,E=b,N;if(b.length>1)b=[{type:\"fragment\",children:b}];if(b[0].name===\"###\")N=!0,s(b[0],a);else d(b[0],a);return e.delegateEvents(Array.from(a.delegatedEvents)),[[E].concat(a.templateNodes).map((C)=>Kf(C)),o(\"tmpls\",\"exprs\",\"r\",a.decl.join(`,\n`)+`;\n`+a.exprs.join(`;\n`)+(N?\"\":`;\nreturn _$el${w};\n`))]}function k(b,...o){let a=Ft.get(b)||f(b,{funcBuilder:t});return a[0].create(a,o,e)}return k}var h=Jf({effect:M,style:An,insert:Sn,untrack:I,spread:Et,createComponent:on,delegateEvents:On,classList:Cn,mergeProps:hn,dynamicProperty:Ct,setAttribute:Ae,setAttributeNS:kn,addEventListener:En,Aliases:$n,getPropAlias:wn,Properties:yn,ChildProperties:bn,DelegatedEvents:gn,SVGElements:Ot,SVGNamespace:xn});function Ne(e){let n=Math.floor(e/3600),t=Math.floor(e%3600/60),i=e%60;if(n>0)return`${n}h ${t}m`;return t>0?`${t}m ${i}s`:`${i}s`}function Xe(e,n){if(!e)return 0;let t=new Date(e).getTime(),i=n?new Date(n).getTime():Date.now();return Math.round((i-t)/1000)}var _n=200,jt=300,vn=100;function Qe(e,n,t){if(t||e.length<=n)return{rows:e,total:e.length,capped:!1};return{rows:e.slice(0,n),total:e.length,capped:!0}}function Fn(e,n){return e.length===n.length&&e.every((t,i)=>t===n[i])}var Xf=new Set([\"loops\",\"groups\",\"findings\",\"plans\"]),Qf=new Set([\"overview\",\"timeline\",\"sections\",\"findings\",\"plan\",\"usage\"]);function ie(e){try{return decodeURIComponent(e)}catch{return e}}function Bf(){return{projectId:null,section:\"loops\",loopName:null,tab:\"overview\",groupId:null,statuses:[],query:\"\"}}function Un(e,n){if(!n)return;for(let t of n.split(\"&\")){if(!t)continue;let i=t.indexOf(\"=\");if(i===-1)continue;let f=ie(t.slice(0,i)),u=ie(t.slice(i+1));if(f===\"status\")e.statuses=u.split(\",\").map((r)=>r.trim()).filter((r)=>r!==\"\");else if(f===\"q\")e.query=u}}function Be(e){let n=Bf(),t=(e||\"\").replace(/^#/,\"\");if(!t)return n;let i=t.indexOf(\"?\"),f=i===-1?t:t.slice(0,i),u=i===-1?\"\":t.slice(i+1),r=f.indexOf(\"/\"),l,c;if(r===-1)l=f,c=\"\";else l=f.slice(0,r),c=f.slice(r+1);if(l)n.projectId=ie(l);if(!c)return Un(n,u),n;let s=c.split(\"/\").filter((g)=>g!==\"\");if(s.length===0)return Un(n,u),n;let d=ie(s[0]);if(d===\"loop\"){if(s.length>=2){if(n.loopName=ie(s[1]),s.length>=3){let g=ie(s[2]);if(Qf.has(g))n.tab=g}}}else if(Xf.has(d)){if(n.section=d,d===\"groups\"&&s.length>=2)n.groupId=ie(s[1])}else n.loopName=d;return Un(n,u),n}function Re(e){if(!e.projectId)return\"\";let n=\"#\"+encodeURIComponent(e.projectId);if(e.section===\"loops\"){if(e.loopName){if(n+=\"/loop/\"+encodeURIComponent(e.loopName),e.tab!==\"overview\")n+=\"/\"+e.tab}}else if(e.section===\"groups\"){if(n+=\"/groups\",e.groupId)n+=\"/\"+encodeURIComponent(e.groupId)}else n+=\"/\"+e.section;let t=[];if(e.statuses.length>0)t.push(\"status=\"+e.statuses.map((i)=>encodeURIComponent(i)).join(\",\"));if(e.query)t.push(\"q=\"+encodeURIComponent(e.query));if(t.length>0)n+=\"?\"+t.join(\"&\");return n}function Gt(e,n){let i=\"#\"+(location.hash||\"\").replace(/^#/,\"\"),f=\"#\"+e.replace(/^#/,\"\");if(i!==f)n.current=!0,location.hash=e}function D(e){if(!e||e===0)return\"\";let n=new Date(e),t=(c)=>c<10?\"0\"+c:String(c),i=t(n.getMonth()+1),f=t(n.getDate()),u=n.getFullYear(),r=n.getHours(),l=r>=12?\"PM\":\"AM\";if(r=r%12,r===0)r=12;return i+\"-\"+f+\"-\"+u+\" \"+r+\":\"+t(n.getMinutes())+\" \"+l}function fe(e){return\"status-badge status-\"+e}function zn(e){return\"section-status section-\"+e}function Dt(e){return\"feature-stage feature-stage-\"+e}function Ht(e,n,t,i){if(!(n.size===0||n.has(e.status)))return!1;if(!t)return!0;let u=t.toLowerCase(),r=e.worktreeBranch??\"\";return((e.loopName||\"\")+\" \"+r+\" \"+(i||\"\")).toLowerCase().indexOf(u)!==-1}function Zt(e,n){if(n===\"recent\")return[...e];let t=[...e];if(n===\"cost\")t.sort((i,f)=>{let u=i.usage?.totalCost??null,r=f.usage?.totalCost??null;if(u===null&&r===null)return 0;if(u===null)return 1;if(r===null)return-1;return r-u});else if(n===\"duration\")t.sort((i,f)=>{let u=Xe(i.loop.startedAt,i.loop.completedAt??void 0);return Xe(f.loop.startedAt,f.loop.completedAt??void 0)-u});else if(n===\"findings\")t.sort((i,f)=>f.findings.length-i.findings.length);return t}function qt(e){return JSON.stringify(e.projects)}function Jt(e){let n=new Map;for(let f of e){if(f===\"\"){n.set(f,[]);continue}let u=f.split(\"/\"),r=u.length;while(r>0&&u[r-1]===\"\")r--;n.set(f,r>0?u.slice(0,r):[f])}let t=new Map;for(let f of e){let u=n.get(f);t.set(f,u.length?u[u.length-1]:f)}let i=new Map;for(let f of e){let u=t.get(f),r=i.get(u);if(r)r.push(f);else i.set(u,[f])}for(let[,f]of i){let u=[...new Set(f)];if(u.length<2)continue;let r=1;while(r<1024){let l=new Set,c=!0,s=!0;for(let d of u){let g=n.get(d),k=Math.max(0,g.length-r),b=g.slice(k).join(\"/\");if(l.has(b))c=!1;else l.add(b);if(k>0)s=!1}if(c||s)break;r++}for(let l of u){let c=n.get(l),s=Math.max(0,c.length-r);t.set(l,c.slice(s).join(\"/\"))}}return t}function Kn(e){let n=[],t=[];for(let i of e)if(i.severity===\"bug\")n.push(i);else t.push(i);return{bugs:n,warnings:t}}function Xt(e){if(e.bugs.length>0)return\"bug\";if(e.warnings.length>0)return\"warn\";return\"clean\"}function $e(e,n){return e+\" \"+n+(e===1?\"\":\"s\")}function Wn(e,n){if(!n||n<=0)return 0;return Math.max(0,Math.min(100,e/n*100))}function Qt(e){let n=e.file+\":\"+e.line+\" — \"+e.description;if(e.scenario)n+=\" (\"+e.scenario+\")\";return n}function Bt(e,n){return\" \"+e+\": $\"+n.cost.toFixed(6)+\", \"+n.inputTokens+\" in / \"+n.outputTokens+\" out (reasoning: \"+n.reasoningTokens+\", cache R: \"+n.cacheReadTokens+\" W: \"+n.cacheWriteTokens+\"), messages: \"+n.messageCount}function Te(e){if(!Number.isFinite(e)||e<=0)return\"0\";if(e>=1e6)return(e/1e6).toFixed(1).replace(/\\.0$/,\"\")+\"M\";if(e>=1000)return(e/1000).toFixed(1).replace(/\\.0$/,\"\")+\"k\";return String(e)}function Le(e){if(!Number.isFinite(e)||e<=0)return\"$0\";return\"$\"+(e>=1?e.toFixed(2):e.toFixed(4))}function Rt(e){let n=[{label:\"Input\",value:e.totalInputTokens,color:\"var(--seg-input)\"},{label:\"Output\",value:e.totalOutputTokens,color:\"var(--seg-output)\"},{label:\"Reasoning\",value:e.totalReasoningTokens,color:\"var(--seg-reasoning)\"},{label:\"Cache R\",value:e.totalCacheReadTokens,color:\"var(--seg-cache-read)\"},{label:\"Cache W\",value:e.totalCacheWriteTokens,color:\"var(--seg-cache-write)\"}],t=n.reduce((i,f)=>i+Math.max(0,f.value),0);return n.map((i)=>({...i,pct:t>0?Math.max(0,i.value)/t*100:0}))}function Lt(e){let n={role:\"execution\",cost:0,messageCount:0,tokens:0,pct:0},t={role:\"auditor\",cost:0,messageCount:0,tokens:0,pct:0},i={role:\"other\",cost:0,messageCount:0,tokens:0,pct:0};for(let[r,l]of Object.entries(e.byRole??{})){let c=r===\"auditor\"?t:r===\"code\"?n:i;c.cost+=l.cost,c.messageCount+=l.messageCount,c.tokens+=(l.inputTokens??0)+(l.outputTokens??0)+(l.reasoningTokens??0)}let f=i.cost>0||i.messageCount>0?[n,t,i]:[n,t],u=f.reduce((r,l)=>Math.max(r,l.cost),0);for(let r of f)r.pct=u>0?r.cost/u*100:0;return f}function Mt(e){let n=Object.keys(e.byModel).map((i)=>({model:i,...e.byModel[i]})),t=n.reduce((i,f)=>Math.max(i,f.cost),0);return n.sort((i,f)=>f.cost-i.cost).map((i)=>({model:i.model,cost:i.cost,inputTokens:i.inputTokens,outputTokens:i.outputTokens,messageCount:i.messageCount,pct:t>0?i.cost/t*100:0}))}function mt(e,n){if(!e)return\"\";let t=Xe(e,n??void 0);return t>0?Ne(t):\"\"}function we(e){if(!e||e===0)return\"\";let n=Date.now()-e,t=Math.floor(n/1000);if(t<60)return t+\"s ago\";let i=Math.floor(t/60);if(i<60)return i+\"m ago\";let f=Math.floor(i/60);if(f<24)return f+\"h ago\";let u=Math.floor(f/24);if(u<30)return u+\"d ago\";return D(e)}var Rf=/([\\s\\S]*?)<\\/h\\1>/g;function pt(e){return e.replace(/<[^>]+>/g,\"\").replace(/&[a-z#0-9]+;/gi,\"\").trim()}function Lf(e){return pt(e).toLowerCase().replace(/[^\\w\\s-]/g,\"\").replace(/[\\s_-]+/g,\"-\").replace(/^-+|-+$/g,\"\")||\"heading\"}function Mf(e){if(!//.test(e))return{html:e,outline:[]};let n=[];return{html:e.replace(Rf,(i,f,u)=>{let r=Number(f),l=Lf(u);return n.push({depth:r,id:l,text:pt(u)}),`${u}`}),outline:n}}function ei(e){if(!e)return{html:\"\",outline:[]};let n=Pe.get(e);if(n!==void 0)return n;let t=globalThis.marked;if(!t)return{html:\"\",outline:[]};let{html:i,outline:f}=Mf(t.parse(e)),u={html:i,outline:f};if(Pe.size>=mf){let r=Pe.keys().next().value;if(r!==void 0)Pe.delete(r)}return Pe.set(e,u),u}function ni(e){return ei(e).html}function ti(e){return ei(e)}var Pe=new Map,mf=200;function Me(e){let n=[\"overview\",\"timeline\"];if(e.sectionCount>0)n.push(\"sections\");if(n.push(\"findings\"),e.hasPlan)n.push(\"plan\");return n.push(\"usage\"),n}function ii(e,n,t,i,f=\"coding\"){let u=[],r=e.findIndex((g)=>g.createdAt>=n),l=r===-1?[]:e.slice(r);if(t!==null)l=l.filter((g)=>g.createdAt<=t);let c;if(l.length>0&&l[0].eventType===\"restart\"&&l[0].toPhase!==null)c=l[0].toPhase;else if(l.length>0&&l[0].eventType!==\"restart\")c=l[0].fromPhase;else c=f;let s=n;for(let g of l){if(g.eventType===\"restart\"){if(g.toPhase!==null)c=g.toPhase;continue}if(c===\"\")c=g.fromPhase,s=Math.max(s,g.createdAt);let k=g.createdAt,b=Math.max(s,k);if(u.push({phase:c,startedAt:s,endedAt:b,durationMs:b-s,open:!1}),g.toPhase===null)c=\"\",s=b;else c=g.toPhase,s=b}if(c!==\"\"){let k=Math.max(s,t??i);u.push({phase:c,startedAt:s,endedAt:t,durationMs:k-s,open:t===null})}let d=e.length>0&&e[0].createdAt>=n&&e[0].createdAt>n+1000;if(d&&u.length>0)u[0]={...u[0],phase:\"\"};return{spans:u,truncated:d}}function fi(e){let n={};for(let t of e){if(!t.phase)continue;n[t.phase]=(n[t.phase]??0)+t.durationMs}return n}function ui(e,n,t){let i=[];for(let f=0;f=n)r=Math.max(0,u.createdAt-n);else if(u.createdAt=n)r=Math.max(0,u.createdAt-n);else r=null}i.push({transition:u,elapsedMs:r})}return i.reverse(),i}var pf=[{phase:\"coding\",label:\"coding\",cx:70,cy:60},{phase:\"auditing\",label:\"auditing\",cx:220,cy:60},{phase:\"final_auditing\",label:\"final_auditing\",cx:370,cy:60},{phase:\"post_action\",label:\"post_action\",cx:520,cy:60},{phase:\"final_audit_fix\",label:\"final_audit_fix\",cx:370,cy:160}],me=110,pe=36,ri=655,li=160,eu=[{key:\"coding→auditing\",d:\"M 125 60 L 165 60\",labelX:145,labelY:52},{key:\"auditing→final_auditing\",d:\"M 275 60 L 315 60\",labelX:295,labelY:52},{key:\"final_auditing→post_action\",d:\"M 425 60 L 465 60\",labelX:445,labelY:52},{key:\"auditing→coding\",d:\"M 220 42 Q 145 14 70 42\",labelX:145,labelY:18},{key:\"auditing→post_action\",d:\"M 220 42 Q 370 8 520 42\",labelX:370,labelY:16},{key:\"final_auditing→auditing\",d:\"M 315 42 Q 295 24 275 42\",labelX:295,labelY:30},{key:\"final_auditing→coding\",d:\"M 370 78 C 370 110 145 110 70 78\",labelX:220,labelY:102},{key:\"final_auditing→final_audit_fix\",d:\"M 370 78 L 370 142\",labelX:358,labelY:112},{key:\"final_audit_fix→final_auditing\",d:\"M 415 142 L 415 78\",labelX:442,labelY:116},{key:\"coding→final_audit_fix\",d:\"M 70 78 C 70 95 200 115 315 142\",labelX:175,labelY:118},{key:\"final_audit_fix→coding\",d:\"M 315 178 C 250 210 100 210 70 78\",labelX:175,labelY:205},{key:\"coding→terminal\",d:\"M 125 78 L 600 142\",labelX:240,labelY:90},{key:\"auditing→terminal\",d:\"M 275 78 L 600 142\",labelX:395,labelY:90},{key:\"final_auditing→terminal\",d:\"M 425 78 L 600 142\",labelX:505,labelY:90},{key:\"post_action→terminal\",d:\"M 520 78 L 600 142\",labelX:565,labelY:95},{key:\"final_audit_fix→terminal\",d:\"M 425 160 L 600 160\",labelX:512,labelY:152}];function nu(e){if(e.toPhase===null)return e.fromPhase+\"→terminal\";return e.fromPhase+\"→\"+e.toPhase}function tu(e){return e.toPhase??e.status??\"\"}function Yn(e){let n=Array.isArray(e)?e[0]:e,t=n instanceof SVGSVGElement?n.firstElementChild:null;if(!(t instanceof SVGElement))throw Error(\"Expected an SVG child\");return t}function ci(e){let n=()=>e.loop(),t=$(()=>n().status===\"running\"),i=$(()=>{let l=new Map;for(let c of e.transitions()){let s=nu(c);l.set(s,(l.get(s)??0)+1)}return l}),f=eu.map((l)=>Yn(h`\n \n \n ${()=>{let c=i().get(l.key);return c?String(c):\"\"}}\n \n `)),u=pf.map((l)=>{let c=l.cx-me/2,s=l.cy-pe/2;return Yn(h`\n \"mg-node\"+(n().phase===l.phase&&t()?\" mg-node-active\":\"\")}>\n \n ${l.label}\n \n `)}),r=Yn(h`\n \n \n ${()=>n().status}\n \n `);return h`
\n \n \n \n \n \n \n\n ${f}\n ${u}\n ${()=>t()?\"\":r}\n \n\n
\n
Transition history
\n
\n ${()=>e.transitions().slice(-20).reverse().map((l)=>h`
\n ${l.eventType}\n ${l.fromPhase} → ${tu(l)}\n ${we(l.createdAt)}\n
`)}\n
\n
\n
`}function hi(e){let[n,t]=U(e.query()),i=null,f=(r)=>{let l=r.target.value;if(t(l),i)clearTimeout(i);i=setTimeout(()=>{i=null,e.onChangeQuery(l)},250)};ce(()=>{if(i)clearTimeout(i),i=null}),R(()=>{e.externalNav();let r=I(()=>e.query());if(i!==null)clearTimeout(i),i=null;t(r)});let u=$(()=>{let r=e.counts();return[{label:\"Running\",key:\"running\",value:r.running},{label:\"Completed\",key:\"completed\",value:r.completed},{label:\"Cancelled\",key:\"cancelled\",value:r.cancelled},{label:\"Errored\",key:\"errored\",value:r.errored},{label:\"Stalled\",key:\"stalled\",value:r.stalled}]});return h`
\n ${()=>u().map(({label:r,key:l,value:c})=>{let d=`badge badge-filter${e.statuses().has(l)?\" badge-active\":\"\"}`;return h`e.onToggleStatus(l)}>${r}: ${c}`})}\n \n \n
`}function ai(e){return h`
Last updated: ${new Date(e.generatedAt).toLocaleString()}
`}function yi(e){let n=$(()=>e.entries().map((t)=>{let i=t.loops.some((r)=>r.loop.status===\"running\"),f=t.proj.projectDir||t.proj.projectId||\"\",u=e.labels().get(f)??f;return h`
e.onSelect(t.proj.projectId)}>\n ${i?h``:\"\"}\n ${u}\n ${t.loops.length}\n
`}));return h`
${()=>n()}
`}function iu(e,n){let t=0,i=0,f=0,u=[],r=[];for(let l of e){let c=l.proj.projectDir||l.proj.projectId||\"\",s=n.get(c)??c;for(let d of l.loops){i++;for(let b of d.findings)if(b.severity===\"bug\")f++;let g=d.loop.status,k=d.loop.completedAt||d.loop.startedAt||0;if(g===\"running\")t++,u.push({projectId:l.proj.projectId,label:s,loopName:d.loop.loopName,phase:d.loop.phase||\"\"});r.push({projectId:l.proj.projectId,label:s,loopName:d.loop.loopName,status:g,when:k})}}return r.sort((l,c)=>c.when-l.when),{runningCount:t,loopCount:i,bugCount:f,runningCards:u,recentRows:r.slice(0,8)}}function bi(e){let n=$(()=>iu(e.entries(),e.labels())),t=$(()=>n().runningCount),i=$(()=>n().loopCount),f=$(()=>n().bugCount),u=$(()=>n().runningCards),r=$(()=>n().recentRows);return h`
\n
\n

All repositories

\n
${()=>`${t()} running · ${i()} loops · ${f()} open bugs`}
\n
\n ${()=>u().length>0?h`
\n

Running

\n
\n ${u().map((l)=>h`
e.onOpenLoop(l.projectId,l.loopName)}>\n ${l.label}\n ${l.loopName}\n ${l.phase}\n
`)}\n
\n
`:\"\"}\n ${()=>r().length>0?h`
\n

Recent activity

\n
\n ${r().map((l)=>h`
e.onOpenLoop(l.projectId,l.loopName)}>\n fe(l.status)}>${l.status}\n ${l.label}\n ${l.loopName}\n ${D(l.when)}\n
`)}\n
\n
`:\"\"}\n
`}function fu(e){let[n,t]=U(!1),[i,f]=U(\"\"),[u,r]=U(0),l=$(()=>{let w=i().trim().toLowerCase(),E=e.loops();return w?E.filter((N)=>N.name.toLowerCase().indexOf(w)!==-1):E}),c=$(()=>Qe(l(),vn,!1)),s=()=>{if(n())return;f(\"\");let w=e.loops(),E=w.findIndex((C)=>C.name===e.loopName),N=w.slice(0,vn),A=E>=0&&E{t(!1),f(\"\"),r(0)},g=(w)=>{if(d(),a.blur(),w!==e.loopName)e.onSelect(w)},k=h`n()?\"display:block\":\"display:none\"}\n onmousedown=${(w)=>w.preventDefault()}\n >\n ${()=>c().rows.map((w,E)=>h`\"loop-picker-option\"+(E===u()?\" loop-picker-option-active\":\"\")+(w.name===e.loopName?\" loop-picker-option-current\":\"\")}\n onclick=${()=>g(w.name)}\n >\n ${w.name}\n ${w.whenLabel}\n `)}\n ${()=>c().total===0?h`
No matching loops
`:\"\"}\n ${()=>c().capped?h`
${()=>\"Showing \"+c().rows.length+\" of \"+c().total+\" — type to filter\"}
`:\"\"}\n `,b=()=>{k.querySelector(\".loop-picker-option-active\")?.scrollIntoView?.({block:\"nearest\"})},o=(w)=>{if(w.key===\"ArrowDown\"||w.key===\"ArrowUp\"){if(w.preventDefault(),!n()){s();return}let E=c().rows.length;if(E===0)return;let N=w.key===\"ArrowDown\"?1:-1;r((A)=>(A+N+E)%E),b();return}if(w.key===\"Enter\"){w.preventDefault();let E=c().rows[u()];if(E)g(E.name);return}if(w.key===\"Escape\")w.preventDefault(),d(),a.blur()},a=h`n()?i():e.loopName}\n onfocus=${s}\n oninput=${(w)=>{f(w.target.value),r(0),t(!0)}}\n onkeydown=${o}\n onblur=${d}\n />`;return h`\n ${a}\n {if(w.preventDefault(),n())d(),a.blur();else a.focus()}}>▾\n ${k}\n `}function $i(e){if(e.loopName)return h`
\n ← ${e.repoLabel}\n /\n ${fu({loopName:e.loopName,loops:e.loops,onSelect:e.onSelectLoop})}\n ${()=>{let n=e.loopNav();return n?h`\n \n ${n.index+1} of ${n.total}\n \n `:\"\"}}\n
`;return h`
\n ← Repositories\n /\n ${e.repoLabel}\n ${e.projectDir}\n
`}function wi(e){let n=[{key:\"loops\",label:\"Loops\",count:e.counts.loops},{key:\"groups\",label:\"Groups\",count:e.counts.groups},{key:\"findings\",label:\"Findings\",count:e.counts.findings},{key:\"plans\",label:\"Plans\",count:e.counts.plans}];return h`
\n ${n.map((t)=>{let f=`section-nav-item${t.key===e.section?\" section-nav-active\":\"\"}`;return h`
e.onNavigate(t.key)}>\n ${t.label}\n ${t.count}\n
`})}\n
`}function Gn(e){return h`
\n ${()=>\"Showing \"+e.shown()+\" of \"+e.total()+\" \"+e.noun}\n ${e.onShowAll?h``:\"\"}\n
`}function jn(e){let n=()=>Wn(e.current(),e.total());return h`\n \"width:\"+n()+\"%\"}>\n ${()=>e.current()}/${()=>e.total()}\n `}var uu={sm:4,md:14,lg:22};function Dn(e){let n=uu[e.variant],t=$(()=>e.spans().filter((u)=>u.phase!==\"\")),i=$(()=>Math.max(0,t().reduce((u,r)=>u+r.durationMs,0))),f=$(()=>e.spans().some((u)=>u.phase===\"\"));return h`
\"phase-bar phase-bar-\"+e.variant+(f()?\" phase-bar-trunc\":\"\")} data-truncated=${()=>f()?\"true\":\"false\"} style=${()=>\"height:\"+n+\"px\"}>\n ${()=>f()?h`\\u22ef`:\"\"}\n ${()=>{let u=t(),r=i();return u.map((l)=>{let c=r>0?l.durationMs/r*100:0,s=\"phase-seg\"+(l.open?\" phase-seg-open\":\"\");return h`
`})}}\n
`}function Hn(e,n){let t=e.loop;return ii(e.transitions??[],t.startedAt,t.completedAt,t.completedAt===null?n():0,t.phase)}function gi(e){let[n,t]=U(!1),i=$(()=>Qe(e.loops(),_n,n()));return h`
\n \n \n \n \n \n \n ${()=>i().rows.map((f)=>ru({dashLoop:f,now:e.now,onOpen:e.onOpen}))}\n \n
StatusLoopPhasePhase spanIterSectionsFindingsCostDurationUpdated
\n ${()=>i().capped?Gn({shown:()=>i().rows.length,total:()=>i().total,noun:\"loops\",onShowAll:()=>t(!0)}):\"\"}\n
`}function ru(e){let n=()=>e.dashLoop.loop,t=()=>e.dashLoop,i=$(()=>Kn(t().findings)),f=$(()=>Hn(t(),e.now).spans);return h`e.onOpen(e.dashLoop.loop.loopName)}>\n fe(n().status)}>${()=>n().status}\n ${()=>n().loopName}\n ${()=>n().phase}\n ${()=>Dn({spans:f,variant:\"sm\"})}\n ${jn({current:()=>n().iteration,total:()=>n().maxIterations})}\n ${()=>n().totalSections>0?jn({current:()=>n().currentSectionIndex,total:()=>n().totalSections}):h``}\n ${()=>{let u=i();if(u.bugs.length===0&&u.warnings.length===0)return h``;return h`\n ${u.bugs.length>0?h`${$e(u.bugs.length,\"bug\")}`:\"\"}\n ${u.bugs.length>0&&u.warnings.length>0?\" · \":\"\"}\n ${u.warnings.length>0?h`${$e(u.warnings.length,\"warn\")}`:\"\"}\n `}}\n ${()=>t().usage?Le(t().usage.totalCost):h``}\n ${()=>t().duration||\"\"}\n ${()=>D(n().completedAt||n().startedAt)}\n `}function Ue(e){let n=()=>typeof e.label===\"function\"?e.label():e.label,[t,i]=U(!1),f=()=>i((l)=>!l),u=$(()=>ti(e.src()||\"\")),r=$(()=>u().outline.filter((l)=>l.depth<=3));return h`
\n
\n t()?\"false\":\"true\"}\n onclick=${f}\n onkeydown=${(l)=>{if(l.key!==\"Enter\"&&l.key!==\" \")return;l.preventDefault(),f()}}\n >\n ${()=>t()?\"▸\":\"▾\"}\n

${()=>n()}

\n
\n \"Copy \"+n()+\" as markdown\"}\n onclick=${(l)=>{l.stopPropagation();let c=l.target,s=\"Copy\";navigator.clipboard.writeText(e.src()||\"\").then(()=>{c.textContent=\"Copied!\"},()=>{c.textContent=\"Failed\"}).then(()=>{setTimeout(()=>{c.textContent=s},2000)})}}\n >Copy\n
\n
t()?\"display:none\":\"display:block\"}>\n ${()=>{let l=r();return l.length>=2?lu({items:()=>l}):null}}\n
u().html}>
\n
\n `}function lu(e){let n=(t,i)=>{t.preventDefault();let f=t.currentTarget.closest(\".markdown-body\"),u=f?Array.from(f.querySelectorAll(\"h1,h2,h3,h4,h5,h6\")).find((r)=>r.id===i):document.getElementById(i);if(u&&typeof u.scrollIntoView===\"function\")u.scrollIntoView({behavior:\"smooth\",block:\"start\"})};return h``}function In(e){return h`
\n
${e.label}
\n
ni(e.value()||\"\")}>
\n
`}function cu(e){let n=e.sec,t=$(()=>!!n.startedAt||!!n.completedAt),i=$(()=>!!n.summaryDone),f=$(()=>!!n.summaryDeviations),u=$(()=>!!n.summaryFollowUps),r=$(()=>!!n.content),l=$(()=>t()||i()||f()||u()||r());return h`
\n ${()=>t()?h`
\n ${()=>n.startedAt?\"Started \"+D(n.startedAt):\"Not started\"}\n ${()=>n.completedAt?h` → Completed ${()=>D(n.completedAt)}`:\"\"}\n
`:\"\"}\n ${()=>i()?In({label:\"Done\",value:()=>n.summaryDone}):\"\"}\n ${()=>f()?In({label:\"Deviations\",value:()=>n.summaryDeviations}):\"\"}\n ${()=>u()?In({label:\"Follow-ups\",value:()=>n.summaryFollowUps}):\"\"}\n ${()=>r()?Ue({label:\"Section Plan\",src:()=>n.content}):\"\"}\n ${()=>l()?\"\":h`
No details captured for this section yet.
`}\n
`}function su(e){let n=e.sec,t=$(()=>mt(n.startedAt,n.completedAt));return h`
\"section-list-row section-item-\"+n.status} onclick=${e.onOpen}>\n #${n.sectionIndex}\n ${()=>n.title}\n zn(n.status)}>${()=>n.status}\n ${()=>t()?h`${()=>t()}`:\"\"}\n ${()=>n.attempts>0?h`${()=>n.attempts} attempts`:\"\"}\n \n
`}function du(e){let[n,t]=U(null),i=$(()=>{let f=n();if(f===null)return null;return e.sections().find((u)=>u.sectionIndex===f)??null});return h`
\n

Sections

\n ${()=>{let f=i();if(f)return h`
\n
t(null)}>← Back to sections
\n
\n #${f.sectionIndex}\n ${()=>f.title}\n zn(f.status)}>${()=>f.status}\n
\n ${cu({sec:f})}\n
`;return h`
\n ${e.sections().map((u)=>su({sec:u,onOpen:()=>t(u.sectionIndex)}))}\n
`}}\n
`}function H(e){return h`
\n ${e.label}\n ${()=>e.value()}\n
`}function si(e){let n=()=>Wn(e.current(),e.total());return h`
\n
\n ${e.label}\n ${()=>e.current()} / ${()=>e.total()}\n
\n
\n
\"width:\"+n()+\"%\"}>
\n
\n
`}function ou(e){let n=()=>e.dashLoop.loop,t=()=>e.dashLoop,i=$(()=>!!n().completedAt),f=$(()=>!!t().duration),u=$(()=>n().totalSections>0),r=$(()=>!!n().terminationReason),l=$(()=>Xt(e.split())),c=$(()=>!!e.dashLoop.usage);return h`
\n
\"ldh-findings ldh-findings-\"+l()}>\n ${()=>{let s=e.split();if(s.bugs.length===0&&s.warnings.length===0)return\"No findings\";let d=[];if(s.bugs.length>0)d.push($e(s.bugs.length,\"bug\"));if(s.warnings.length>0)d.push($e(s.warnings.length,\"warning\"));return d.join(\" · \")}}\n
\n
\n fe(n().status)}>${()=>n().status}\n

${()=>n().loopName}

\n ${()=>n().phase}\n
\n\n
\n ${H({label:\"Started\",value:()=>D(n().startedAt)})}\n ${()=>i()?H({label:\"Completed\",value:()=>D(n().completedAt)}):\"\"}\n ${()=>f()?H({label:\"Duration\",value:()=>t().duration||\"\"}):\"\"}\n ${H({label:\"Iteration\",value:()=>n().iteration+\" / \"+n().maxIterations})}\n ${()=>u()?H({label:\"Section\",value:()=>n().currentSectionIndex+\" / \"+n().totalSections}):\"\"}\n ${()=>c()?H({label:\"Messages\",value:()=>String(e.dashLoop.usage.totalMessageCount)}):\"\"}\n ${H({label:\"Execution model\",value:()=>n().executionModel??\"—\"})}\n ${H({label:\"Auditor model\",value:()=>n().auditorModel??\"—\"})}\n ${H({label:\"Branch\",value:()=>n().worktreeBranch??\"—\"})}\n ${H({label:\"Sandbox\",value:()=>n().sandbox?\"on\":\"off\"})}\n ${H({label:\"Kind\",value:()=>n().kind??\"—\"})}\n ${H({label:\"Audits\",value:()=>String(n().auditCount)})}\n ${H({label:\"Errors\",value:()=>String(n().errorCount)})}\n
\n\n
\n ${si({label:\"Iterations\",current:()=>n().iteration,total:()=>n().maxIterations})}\n ${()=>u()?si({label:\"Sections\",current:()=>n().currentSectionIndex,total:()=>n().totalSections}):\"\"}\n
\n\n ${()=>r()?h`
\"ldh-banner ldh-banner-\"+n().status}>${()=>n().terminationReason}
`:\"\"}\n
`}function hu(e){let n=()=>e.usage(),t=$(()=>Rt(n())),i=$(()=>Mt(n())),f=$(()=>i().length>0),u=$(()=>Lt(n()));return h`
\n

Usage

\n\n
\n
Role split
\n
\n ${()=>u().map((r)=>h`
r.role}>\n
\n r.role}>${r.role}\n ${()=>Le(r.cost)}\n
\n
\n
\"width:\"+r.pct+\"%\"}>
\n
\n
${()=>r.messageCount+\" msg · \"+Te(r.tokens)+\" tok\"}
\n
`)}\n
\n
\n\n
\n
Token composition
\n
\n ${()=>t().map((r)=>r.pct>0?h`
`:\"\")}\n
\n
\n ${()=>t().map((r)=>h`
\n \n ${r.label}\n ${Te(r.value)}\n
`)}\n
\n
\n\n ${()=>f()?h`
\n
Cost by model
\n
\n ${()=>i().map((r)=>h`
\n
\n ${r.model}\n ${Le(r.cost)}\n
\n
\n
\n
\n
\n ${Te(r.inputTokens)} in / ${Te(r.outputTokens)} out · ${r.messageCount} msg\n
\n
`)}\n
\n
`:\"\"}\n `}function di(e){try{return JSON.parse(e)}catch{return[]}}function oi(e){return\"#\"+e.index+\" \"+e.title}function au(e){let n=e.amendment,t=$(()=>di(n.sectionsBefore)),i=$(()=>di(n.sectionsAfter));return h`
\n
\n ${()=>we(n.createdAt)}\n applied @ section ${()=>n.appliedAtSection}\n ${()=>n.source}\n ${()=>n.rationale}\n ${()=>e.expanded()?\"▾\":\"▸\"}\n
\n
e.expanded()?\"display:block\":\"display:none\"}>\n
\n ${()=>t().length>0?h`
\n
Before
\n ${()=>t().map((f)=>h`
${()=>oi(f)}
`)}\n
`:\"\"}\n ${()=>i().length>0?h`
\n
After
\n ${()=>i().map((f)=>h`
${()=>oi(f)}
`)}\n
`:\"\"}\n
\n
\n
`}function yu(e){let n=()=>e.amendments(),[t,i]=U(null);return h`
\n

Plan amendments

\n
\n ${()=>n().map((f)=>au({amendment:f,expanded:()=>t()===f.id,onToggle:()=>i(t()===f.id?null:f.id)}))}\n
\n
`}var bu=[\"overview\",\"timeline\",\"sections\",\"findings\",\"plan\",\"usage\"],$u={overview:\"Overview\",timeline:\"Timeline\",sections:\"Sections\",findings:\"Findings\",plan:\"Plan\",usage:\"Usage\"};function wu(e){return h`
\n ${()=>e.tabs().map((n)=>{let t=$u[n];return h`\"tab-item\"+(e.activeTab()===n?\" tab-active\":\"\")} data-tab=\"${n}\" onclick=${()=>e.onSelect(n)}>${t}`})}\n
`}function gu(e){let n=()=>e.dashLoop,t=()=>n().loop,i=$(()=>!!n().goal),f=$(()=>n().postActionReport??t().completionSummary??null),u=$(()=>n().postActionReport?\"Post-Action Report\":\"Completion Summary\"),r=$(()=>!!f()),l=$(()=>!!n().lastAuditResult),c=$(()=>Hn(n(),e.now));return h`
\n ${()=>Dn({spans:()=>c().spans,variant:\"md\"})}\n ${()=>i()?Ue({label:\"Goal\",src:()=>n().goal}):\"\"}\n ${ou({dashLoop:e.dashLoop,split:e.split})}\n ${()=>r()?Ue({label:u,src:()=>f()}):\"\"}\n ${()=>l()?Ue({label:\"Last Audit Result\",src:()=>n().lastAuditResult}):\"\"}\n
`}function xu(e){let n=e.ev.transition,t=e.ev.elapsedMs,i=t===null?null:Math.floor(t/1000),f=n.toPhase===null?n.status??\"\":n.toPhase;return h`
\n ${we(n.createdAt)}\n ${n.fromPhase} → ${f}\n ${n.transitionKind}\n iter ${n.iteration}\n ${()=>n.sectionIndex===null?\"—\":\"sect \"+(n.sectionIndex+1)}\n ${()=>i===null?\"—\":i>0?Ne(i):\"0s\"}\n
`}function Ou(e){let n=()=>e.dashLoop,t=()=>n().loop,i=()=>n().transitions??[],f=$(()=>Hn(n(),e.now)),u=$(()=>f().spans),r=$(()=>f().truncated),l=$(()=>fi(u())),c=$(()=>u().filter((a)=>a.phase!==\"\").reduce((a,w)=>a+w.durationMs,0)),s=$(()=>{let a=l();return Object.keys(a).filter((w)=>w!==\"\").sort((w,E)=>a[E]-a[w])}),d=$(()=>ui(i(),t().startedAt,r())),[g,k]=U(!1),b=$(()=>{let a=d();return g()?a:a.slice(0,20)}),o=$(()=>g()?0:Math.max(0,d().length-20));return h`
\n ${ci({loop:()=>t(),transitions:i})}\n ${()=>Dn({spans:u,variant:\"lg\"})}\n ${()=>r()?h`
Earlier history was truncated by the 100-row fetch window.
`:\"\"}\n\n
\n ${()=>s().map((a)=>{let w=l()[a],E=c()>0?Math.round(w/c()*100):0;return h`
\n \n ${a}\n \n ${()=>w>=1000?Ne(Math.floor(w/1000)):w+\"ms\"} · ${E}%\n
`})}\n
\n\n
\n ${()=>b().map((a)=>xu({ev:a}))}\n ${()=>o()>0?h`
k(!0)}>[ ${()=>o()} earlier events ]
`:\"\"}\n
\n
`}function ku(e){return du({sections:()=>e.dashLoop.sections})}function Eu(e){let n=[],t=new Map;for(let i of e){let f=i.sectionIndex??null;if(!t.has(f))t.set(f,[]),n.push(f);t.get(f).push(i)}return n.sort((i,f)=>{if(i===null&&f===null)return 0;if(i===null)return 1;if(f===null)return-1;return i-f}),n.map((i)=>({key:i,label:i===null?\"cross-section\":\"sect \"+(i+1),rows:t.get(i)}))}function xi(e){return h`
\n ${e.rows.map((n)=>h`
\"finding finding-\"+n.severity}>\n ${()=>we(n.createdAt)}\n ${()=>Qt(n)}\n
`)}\n
`}function Oi(e){let n=()=>Re({projectId:e.projectId(),section:\"loops\",loopName:e.loopName,tab:\"overview\",groupId:null,statuses:[],query:\"\"});return h`{if(t.defaultPrevented||t.button!==0||t.metaKey||t.ctrlKey||t.shiftKey||t.altKey)return;t.preventDefault(),e.onOpen(e.loopName)}}>${e.loopName}`}function Cu(e){let n=()=>e.dashLoop,t=$(()=>n().findings.length>0),i=$(()=>Eu(n().findings));return h`
\n ${()=>t()?i().map((f)=>h`
f.key===null?\"cross\":String(f.key)}>\n
${f.label}
\n ${xi({rows:f.rows})}\n
`):h`
No findings recorded for this loop.
`}\n
`}function Au(e){let n=()=>e.dashLoop,t=$(()=>n().hasPlan),i=$(()=>!!n().amendments&&n().amendments.length>0);return h`
\n ${()=>t()?Ue({label:\"Plan\",src:()=>n().plan}):h`
No plan recorded for this loop.
`}\n ${()=>i()?yu({amendments:()=>n().amendments}):\"\"}\n
`}function Su(e){let n=()=>e.dashLoop,t=$(()=>!!n().usage);return h`
\n ${()=>t()?hu({usage:()=>n().usage}):h`
No usage recorded for this loop.
`}\n
`}function Vu(e){switch(e.tab){case\"overview\":return gu({dashLoop:e.dashLoop,split:e.split,now:e.now});case\"timeline\":return Ou({dashLoop:e.dashLoop,now:e.now});case\"sections\":return ku({dashLoop:e.dashLoop});case\"findings\":return Cu({dashLoop:e.dashLoop});case\"plan\":return Au({dashLoop:e.dashLoop});case\"usage\":return Su({dashLoop:e.dashLoop})}}function Nu(e){let n=$(()=>e.tabs().includes(e.tab)&&e.activeTab()===e.tab),t=De(),i=h`
n()?\"display:block\":\"display:none\"}>
`,f=!1;return R(()=>{if(!n()||f)return;f=!0;let u=sn(t,()=>Vu(e));if(u)i.appendChild(u)}),i}function Pu(e){return h`
\n ${bu.map((n)=>Nu({tab:n,dashLoop:e.dashLoop,split:e.split,now:e.now,tabs:e.tabs,activeTab:e.activeTab}))}\n
`}function ki(e){let n=()=>e.dashLoop,t=$(()=>Kn(n().findings)),i=$(()=>Me(n())),f=$(()=>{let u=e.routeTab();return i().includes(u)?u:\"overview\"});return h`
\n
\n ${wu({tabs:i,activeTab:f,onSelect:e.onSelectTab})}\n ${Pu({dashLoop:e.dashLoop,split:t,now:e.now,tabs:i,activeTab:f})}\n
\n
`}function Zn(){return h`
No loops match the current filters.
`}var Ei=new Set([\"completed\",\"cancelled\",\"errored\"]);function Ci(e){let n=e.length;return{completed:e.filter((i)=>i.stage===\"completed\").length,total:n}}function Tu(e){return\"group-row\"+(Ei.has(e)?\" group-row-terminal\":\" group-row-active\")}function Ai(e){return h`
\n

Groups

\n ${()=>{let n=e.groups();if(n.length===0)return h`
No feature groups for this repo.
`;return h`
\n ${n.map((t)=>{let i=t.group,f=$(()=>Ci(t.features));return h`
Tu(i.status)} data-group-status=${()=>i.status} onclick=${()=>e.onOpen(i.groupId)}>\n fe(i.status)}>${()=>i.status}\n ${()=>i.title}\n ${jn({current:()=>f().completed,total:()=>f().total})}\n max ${()=>i.maxConcurrent}\n \n Created ${()=>D(i.createdAt)}\n ${()=>i.completedAt?h`Completed ${()=>D(i.completedAt)}`:\"\"}\n \n
`})}\n
`}}\n
`}function Uu(e){return\"group-header\"+(Ei.has(e)?\" group-header-terminal\":\" group-header-active\")}function Si(e){return h`
\n
← Back to groups
\n ${()=>{let n=e.group();if(!n)return h`
Group not found.
`;let t=n.group,i=$(()=>Ci(n.features));return h`
\n
Uu(t.status)} data-group-status=${()=>t.status}>\n
\n fe(t.status)}>${()=>t.status}\n

${()=>t.title}

\n max ${()=>t.maxConcurrent}\n
\n
\n ${()=>\"Features \"+i().completed+\"/\"+i().total}\n Created ${()=>D(t.createdAt)}\n ${()=>t.completedAt?h`Completed ${()=>D(t.completedAt)}`:\"\"}\n ${()=>t.error?h`${()=>t.error}`:\"\"}\n
\n ${()=>t.prdPreview?h`
\n

PRD preview

\n
${()=>t.prdPreview||\"\"}
\n
`:\"\"}\n
\n
\n ${n.features.map((f)=>h`
f.stage}>\n #${()=>f.featureIndex}\n ${()=>f.title}\n Dt(f.stage)}>${()=>f.stage}\n ${()=>f.attempts} attempts\n ${()=>!!f.loopName&&e.loopNames().has(f.loopName)?Oi({className:\"feature-loop-link\",projectId:e.projectId,loopName:f.loopName,onOpen:e.onOpenLoop}):\"\"}\n ${()=>f.error?h`${()=>f.error}`:\"\"}\n
`)}\n
\n
`}}\n
`}function _u(e,n,t){let i=[],f=0,u=t?1/0:n;for(let r of e){if(r.findings.length===0)continue;if(f+=r.findings.length,u<=0)continue;let l=Math.min(u,r.findings.length);i.push({loopName:r.loop.loopName,rows:r.findings.slice(0,l)}),u-=l}return{groups:i,total:f,capped:!t&&f>n}}function Vi(e){let[n,t]=U(!1),i=$(()=>_u(e.loops(),jt,n()));return h`
\n

Findings ${()=>i().total}

\n ${()=>i().capped?Gn({shown:()=>i().groups.reduce((f,u)=>f+u.rows.length,0),total:()=>i().total,noun:\"findings\",onShowAll:()=>t(!0)}):\"\"}\n ${()=>{let f=i().groups;if(f.length===0)return h`
No findings recorded for this repo.
`;return h`
\n ${f.map((u)=>h`
u.loopName}>\n
\n ${Oi({className:\"findings-loop-link\",projectId:e.projectId,loopName:u.loopName,onOpen:e.onOpenLoop})}\n ${()=>u.rows.length}\n
\n ${xi({rows:u.rows})}\n
`)}\n
`}}\n
`}function Ni(e){let[n,t]=U(!1),i=$(()=>e.loops().filter((u)=>u.hasPlan)),f=$(()=>Qe(i(),_n,n()));return h`
\n

Plans ${()=>f().total}

\n ${()=>f().capped?Gn({shown:()=>f().rows.length,total:()=>f().total,noun:\"plans\",onShowAll:()=>t(!0)}):\"\"}\n ${()=>{let u=f().rows;if(u.length===0)return h`
No plans recorded for this repo.
`;return h`
\n ${u.map((r)=>h`
e.onOpenLoop(r.loop.loopName)}>\n fe(r.loop.status)}>${()=>r.loop.status}\n ${()=>r.loop.loopName}\n \n ${()=>$e(r.findings.length,\"finding\")}\n \n iter ${()=>r.loop.iteration}/${()=>r.loop.maxIterations}\n
`)}\n
`}}\n
`}function Pi(){let[e,n]=Tt({generatedAt:0,projects:[]}),[t,i]=U(!1),f=Be(typeof location<\"u\"?location.hash:\"\"),[u,r]=U(f.projectId),[l,c]=U(f.section),[s,d]=U(f.loopName),[g,k]=U(f.tab),[b,o]=U(f.groupId),[a,w]=U(f.statuses,{equals:Fn}),[E,N]=U(f.query),A=$(()=>({projectId:u(),section:l(),loopName:s(),tab:g(),groupId:b(),statuses:a(),query:E()})),[C,V]=U(null),[T,_]=U(\"recent\"),[j,K]=U(0),[m,p]=U(Date.now()),_e={current:\"\"},ge={current:!1},Ui=()=>{let y=I(A),x=new URLSearchParams;if(y.projectId)x.set(\"project\",y.projectId);if(y.loopName)x.set(\"loop\",y.loopName);let S=x.toString();return S?\"/api/data?\"+S:\"/api/data\"},ve=0,ue={url:null,gen:0},en=async()=>{let y=Ui();if(ue.url===y)return;let x=++ve;ue.url=y,ue.gen=x;try{let S=await fetch(y,{cache:\"no-store\"});if(x!==ve)return;let F=await S.json();if(x!==ve)return;let Z=qt(F);if(Z!==_e.current)_e.current=Z,n(Ut(F,{key:\"id\"})),i(!0),V(null);p(Date.now())}catch(S){if(x!==ve)return;let F=S instanceof Error?S.message:String(S);V(\"Failed to load dashboard data: \"+F)}finally{if(ue.gen===x)ue.url=null,ue.gen=0}},qn=(y)=>{Ce(()=>{r(y.projectId),c(y.section),d(y.loopName),k(y.tab),o(y.groupId),w(y.statuses),N(y.query)})},G=(y)=>{Ce(()=>{let x=y.projectId!==void 0&&y.projectId!==u();if(y.projectId!==void 0)r(y.projectId);if(y.section!==void 0)c(y.section);if(y.loopName!==void 0)d(y.loopName);if(y.tab!==void 0)k(y.tab);if(y.groupId!==void 0)o(y.groupId);if(y.statuses!==void 0)w(y.statuses);if(y.query!==void 0)N(y.query);if(x)w([]),N(\"\")})},_i=(y)=>{G({tab:y})},Fe=(y)=>{G({loopName:y,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})},vi=(y)=>{let x=new Set(a());if(x.has(y))x.delete(y);else x.add(y);G({statuses:[...x]})},Fi=(y)=>{G({query:y})},zi=(y)=>{_(y)},Jn=$(()=>new Set(a())),ze=$(()=>Jt(e.projects.map((y)=>y.projectDir||y.projectId||\"\"))),Ke=$(()=>{if(!t()||e.projects.length===0)return[];let y=Jn(),x=E(),S=ze(),F=[],Z=x.trim().toLowerCase();for(let re of e.projects){let fn=[],Ln=re.projectDir||re.projectId||\"\",Mn=S.get(Ln)??Ln;for(let mn of re.loops)if(Ht(mn.loop,y,x,Mn))fn.push(mn);let ef=(re.groups?.length??0)>0;if(fn.length>0)F.push({proj:re,loops:fn});else if(ef&&(!Z||Mn.toLowerCase().indexOf(Z)!==-1))F.push({proj:re,loops:[]})}return F}),Xn=$(()=>u()===null),Ki=$(()=>l()===\"groups\"),Qn=$(()=>l()===\"loops\"),Wi=$(()=>l()===\"findings\"),Yi=$(()=>l()===\"plans\"),Bn=$(()=>{let y=Ke(),x=u();if(!x)return null;return y.find((S)=>S.proj.projectId===x)??null}),L=$(()=>{let y=u();if(!y)return null;return e.projects.find((x)=>x.projectId===y)??null}),nn=$(()=>{let y=u(),x=s();if(!y||!x)return null;let S=L();if(!S)return null;return S.loops.find((F)=>F.loop.loopName===x)??null}),Ii=$(()=>{let y=L(),x=y?.groups?.length??0;if(!y)return{loops:0,groups:x,findings:0,plans:0};let S=0,F=0;for(let Z of y.loops)if(S+=Z.findings.length,Z.hasPlan)F++;return{loops:y.loops.length,groups:x,findings:S,plans:F}}),xe=$(()=>{let y=L();if(!y)return[];return y.loops.map((x)=>{let S=x.loop.completedAt||x.loop.startedAt||0;return{name:x.loop.loopName,when:S,whenLabel:D(S)}}).sort((x,S)=>S.when-x.when||x.name.localeCompare(S.name))}),tn=$(()=>{let y=s();if(!y)return-1;return xe().findIndex((x)=>x.name===y)}),ji=$(()=>{let y=tn();if(y<0)return null;let x=xe();if(x.length<=1)return null;return{index:y,total:x.length,onPrev:()=>{let S=tn(),F=xe();if(S>0)G({loopName:F[S-1].name})},onNext:()=>{let S=tn(),F=xe();if(Sy===x||!!y&&!!x&&y.index===x.index&&y.total===x.total}),Gi=$(()=>{let y=L(),x={running:0,completed:0,cancelled:0,errored:0,stalled:0};if(!y)return x;for(let S of y.loops){let F=S.loop.status;if(F===\"running\")x.running++;else if(F===\"completed\")x.completed++;else if(F===\"cancelled\")x.cancelled++;else if(F===\"errored\")x.errored++;else if(F===\"stalled\")x.stalled++}return x}),Di=$(()=>{let y=s();if(!u())return\"\";let x=L();if(!x)return\"\";let S=x.projectDir||x.projectId||\"\",F=ze().get(S)??S;return I(()=>$i({repoLabel:F,projectDir:S,loopName:y,loops:xe,loopNav:ji,onSelectLoop:(Z)=>G({loopName:Z}),onBackToRepos:()=>G({projectId:null,loopName:null,section:\"loops\"}),onBackToRepo:()=>G({loopName:null})}))}),Hi=$(()=>{let y=u(),x=s();if(!y||x)return\"\";return wi({section:l(),counts:Ii(),onNavigate:(S)=>G({section:S,loopName:null,groupId:null})})}),Zi=$(()=>{return u()!==null&&s()===null&&l()===\"loops\"}),qi=$(()=>{if(!Zi())return\"\";return I(()=>hi({counts:Gi,statuses:Jn,query:E,sortMode:T,onToggleStatus:vi,onChangeQuery:Fi,onChangeSort:zi,externalNav:j}))}),Ji=$(()=>{if(!Qn())return\"\";let y=nn();return y?ki({dashLoop:y,now:m,routeTab:g,onSelectTab:_i}):\"\"}),Xi=$(()=>{let y=Bn();return y?Zt(y.loops,T()):[]},[],{equals:Fn}),Qi=$(()=>{if(!Qn())return\"\";if(nn())return\"\";if(!u())return\"\";return h`
\n ${gi({loops:Xi,now:m,onOpen:(y)=>Fe(y)})}\n ${()=>Bn()?\"\":Zn()}\n
`}),Bi=$(()=>{if(l()!==\"groups\"||!b())return null;let y=L();if(!y)return null;return y.groups.find((x)=>x.group.groupId===b())??null}),Ri=$(()=>{let y=L();if(!y)return new Set;return new Set(y.loops.map((x)=>x.loop.loopName))}),Li=$(()=>{if(!Ki())return\"\";if(!u())return\"\";if(b())return Si({group:Bi,loopNames:Ri,projectId:()=>u(),onBack:()=>G({groupId:null}),onOpenLoop:Fe});return Ai({groups:()=>L()?.groups??[],onOpen:(x)=>G({groupId:x})})}),Rn=$(()=>{let y=L();return y?y.loops:[]}),Mi=$(()=>{if(!Wi())return\"\";if(!u())return\"\";return Vi({loops:Rn,projectId:()=>u(),onOpenLoop:Fe})}),mi=$(()=>{if(!Yi())return\"\";if(!u())return\"\";return Ni({loops:Rn,onOpenLoop:Fe})});R(()=>{if(!t())return;let y=u();if(y===null)return;if(!e.projects.some((S)=>S.projectId===y))r(null),d(null),c(\"loops\"),w([]),N(\"\")}),R(()=>{let y=u(),x=s();if(!y||!x)return;let S=L();if(!S)return;if(!S.loops.some((Z)=>Z.loop.loopName===x))d(null)}),R(()=>{let y=s(),x=g();if(!u()||!y||x===\"overview\")return;let S=nn();if(!S)return;if(!Me(S).includes(x))k(\"overview\")}),R(()=>{if(!t())return;Gt(Re(A()),ge)});let pi=$(()=>(u()??\"\")+\"\\x00\"+(s()??\"\"));return R(ct(pi,()=>{en()},{defer:!0})),st(()=>{qn(Be(location.hash)),en();let y=setInterval(()=>{en()},5000),x=()=>{if(ge.current){ge.current=!1;return}qn(Be(location.hash)),K((S)=>S+1)};window.addEventListener(\"hashchange\",x),ce(()=>{clearInterval(y),window.removeEventListener(\"hashchange\",x)})}),h`
\n

Forge Dashboard

\n\n ${()=>{let y=C();return y?h`
${y}
`:\"\"}}\n\n ${()=>{if(!t())return\"\";return h`
\n ${ai({generatedAt:e.generatedAt})}\n
`}}\n\n ${()=>{if(!t())return\"\";if(Xn()&&Ke().length===0)return Zn();return h`
\n ${()=>{if(Xn())return h`
\n ${yi({entries:()=>Ke(),labels:()=>ze(),onSelect:(y)=>G({projectId:y,loopName:null,section:\"loops\",groupId:null})})}\n ${bi({entries:()=>Ke(),labels:()=>ze(),onOpenLoop:(y,x)=>G({projectId:y,loopName:x,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})})}\n
`;return h`
\n ${Di}\n ${qi}\n ${Hi}\n ${Qi}\n ${Ji}\n ${Li}\n ${Mi}\n ${mi}\n
`}}\n
`}}\n
`}var Ti=document.getElementById(\"forge-app-root\");if(Ti)kt(Pi,Ti);\n"; +export const DASHBOARD_APP_SOURCE_HASH: string = "b476cccf339107331c33dd219ea3f7e4ffe10914ab5bb187e9172e9ef0ce29c7"; diff --git a/src/dashboard/app/app.ts b/src/dashboard/app/app.ts index d19b792361..2d5b9bd0b2 100644 --- a/src/dashboard/app/app.ts +++ b/src/dashboard/app/app.ts @@ -1,10 +1,9 @@ -import { createSignal, createMemo, createEffect, onMount, onCleanup, untrack } from 'solid-js' +import { createSignal, createMemo, createEffect, onMount, onCleanup, untrack, on, batch } from 'solid-js' import { createStore, reconcile } from 'solid-js/store' import html from 'solid-js/html' import type { DashboardPayload, DashboardProject, DashboardLoop, DashboardGroup } from './types' -import type { DashboardRoute, SortMode } from './helpers' -import { parseDashboardHash, buildDashboardHash, syncHash, dataHash, loopMatchesFilters, buildRepoLabels, sortLoops, tabsForLoop } from './helpers' -import type { LoopTab } from './helpers' +import type { DashboardRoute, SortMode, RepoSection, LoopTab } from './helpers' +import { parseDashboardHash, buildDashboardHash, syncHash, dataHash, loopMatchesFilters, buildRepoLabels, sortLoops, tabsForLoop, sameList, fmtTime } from './helpers' import { FilterBar, Timestamp, @@ -32,7 +31,26 @@ export function App() { projects: [], }) const [loaded, setLoaded] = createSignal(false) - const [route, setRoute] = createSignal(parseDashboardHash('')) + const initialRoute = parseDashboardHash(typeof location !== 'undefined' ? location.hash : '') + const [projectId, setProjectId] = createSignal(initialRoute.projectId) + const [section, setSection] = createSignal(initialRoute.section) + const [loopName, setLoopName] = createSignal(initialRoute.loopName) + const [tab, setTab] = createSignal(initialRoute.tab) + const [groupId, setGroupId] = createSignal(initialRoute.groupId) + const [statuses, setStatuses] = createSignal(initialRoute.statuses, { equals: sameList }) + const [query, setQuery] = createSignal(initialRoute.query) + // Rebuilt only for the hash-serialisation boundary and the scope key; the + // derived memos below read the individual fields so a tab change no longer + // invalidates the filter/match pass. + const route = createMemo(() => ({ + projectId: projectId(), + section: section(), + loopName: loopName(), + tab: tab(), + groupId: groupId(), + statuses: statuses(), + query: query(), + })) const [loadError, setLoadError] = createSignal(null) const [sortMode, setSortMode] = createSignal('recent') const [externalNav, setExternalNav] = createSignal(0) @@ -44,10 +62,40 @@ export function App() { // ── Data fetching ─────────────────────────────────────────────────────── + const scopedDataUrl = (): string => { + const r = untrack(route) + const params = new URLSearchParams() + if (r.projectId) params.set('project', r.projectId) + if (r.loopName) params.set('loop', r.loopName) + const qs = params.toString() + return qs ? '/api/data?' + qs : '/api/data' + } + + // Request coordination: every issued load() bumps the generation counter and + // records the scoped URL it was issued for. A response is accepted only if + // its generation still matches the latest issued request, so a late response + // from a superseded scope (a navigation mid-flight, or two rapid scope + // changes) can never clobber the current scope's data. While a request is in + // flight for the current scoped URL, subsequent calls (the 5s interval poll) + // are coalesced rather than bumping the generation — otherwise a slow + // /api/data response would be permanently invalidated by every tick and + // never render. A genuine scope change still issues a fresh request + // immediately because its URL differs from the in-flight URL. The hash check + // still dedups identical payloads. + let loadGen = 0 + const inFlight = { url: null as string | null, gen: 0 } + const load = async () => { + const url = scopedDataUrl() + if (inFlight.url === url) return // coalesce overlapping same-scope polls + const gen = ++loadGen + inFlight.url = url + inFlight.gen = gen try { - const res = await fetch('/api/data', { cache: 'no-store' }) + const res = await fetch(url, { cache: 'no-store' }) + if (gen !== loadGen) return const json: DashboardPayload = await res.json() + if (gen !== loadGen) return const hash = dataHash(json) if (hash !== lastDataHashRef.current) { lastDataHashRef.current = hash @@ -65,21 +113,45 @@ export function App() { } setNow(Date.now()) } catch (err: unknown) { + if (gen !== loadGen) return const msg = err instanceof Error ? err.message : String(err) setLoadError('Failed to load dashboard data: ' + msg) + } finally { + if (inFlight.gen === gen) { + inFlight.url = null + inFlight.gen = 0 + } } } // ── Navigation ────────────────────────────────────────────────────────── + const applyRoute = (next: DashboardRoute) => { + batch(() => { + setProjectId(next.projectId) + setSection(next.section) + setLoopName(next.loopName) + setTab(next.tab) + setGroupId(next.groupId) + setStatuses(next.statuses) + setQuery(next.query) + }) + } + const navigate = (patch: Partial) => { - setRoute(r => { - const next: DashboardRoute = { ...r, ...patch } - if (patch.projectId !== undefined && patch.projectId !== r.projectId) { - next.statuses = [] - next.query = '' + batch(() => { + const projectChanged = patch.projectId !== undefined && patch.projectId !== projectId() + if (patch.projectId !== undefined) setProjectId(patch.projectId) + if (patch.section !== undefined) setSection(patch.section) + if (patch.loopName !== undefined) setLoopName(patch.loopName) + if (patch.tab !== undefined) setTab(patch.tab) + if (patch.groupId !== undefined) setGroupId(patch.groupId) + if (patch.statuses !== undefined) setStatuses(patch.statuses) + if (patch.query !== undefined) setQuery(patch.query) + if (projectChanged) { + setStatuses([]) + setQuery('') } - return next }) } @@ -94,7 +166,7 @@ export function App() { // ── Event handlers ────────────────────────────────────────────────────── const toggleStatus = (key: string) => { - const next = new Set(route().statuses) + const next = new Set(statuses()) if (next.has(key)) next.delete(key) else next.add(key) navigate({ statuses: [...next] }) @@ -110,7 +182,7 @@ export function App() { // ── Derived memos ─────────────────────────────────────────────────────── - const activeStatusSet = createMemo(() => new Set(route().statuses)) + const activeStatusSet = createMemo(() => new Set(statuses())) const repoLabels = createMemo(() => buildRepoLabels(state.projects.map(p => p.projectDir || p.projectId || '')), @@ -119,7 +191,7 @@ export function App() { const matchedByProject = createMemo(() => { if (!loaded() || state.projects.length === 0) return [] const statuses = activeStatusSet() - const search = route().query + const search = query() const labels = repoLabels() const result: MatchedEntry[] = [] const q = search.trim().toLowerCase() @@ -142,34 +214,35 @@ export function App() { return result }) - const atRepoIndex = createMemo(() => route().projectId === null) + const atRepoIndex = createMemo(() => projectId() === null) - const atGroupsSection = createMemo(() => route().section === 'groups') - const atLoopsSection = createMemo(() => route().section === 'loops') - const atFindingsSection = createMemo(() => route().section === 'findings') - const atPlansSection = createMemo(() => route().section === 'plans') + const atGroupsSection = createMemo(() => section() === 'groups') + const atLoopsSection = createMemo(() => section() === 'loops') + const atFindingsSection = createMemo(() => section() === 'findings') + const atPlansSection = createMemo(() => section() === 'plans') const selectedEntry = createMemo(() => { const entries = matchedByProject() - const pid = route().projectId + const pid = projectId() if (!pid) return null return entries.find(e => e.proj.projectId === pid) ?? null }) - const activeLoop = createMemo(() => { - const r = route() - if (!r.projectId || !r.loopName) return null - const proj = selectedRepoProject() - if (!proj) return null - return proj.loops.find(l => l.loop.loopName === r.loopName) ?? null - }) - const selectedRepoProject = createMemo(() => { - const pid = route().projectId + const pid = projectId() if (!pid) return null return state.projects.find(p => p.projectId === pid) ?? null }) + const activeLoop = createMemo(() => { + const pid = projectId() + const name = loopName() + if (!pid || !name) return null + const proj = selectedRepoProject() + if (!proj) return null + return proj.loops.find(l => l.loop.loopName === name) ?? null + }) + const sectionCounts = createMemo(() => { const proj = selectedRepoProject() const groups = proj?.groups?.length ?? 0 @@ -178,7 +251,7 @@ export function App() { let plans = 0 for (const dl of proj.loops) { findings += dl.findings.length - if (dl.plan) plans++ + if (dl.hasPlan) plans++ } return { loops: proj.loops.length, groups, findings, plans } }) @@ -187,12 +260,15 @@ export function App() { const proj = selectedRepoProject() if (!proj) return [] return proj.loops - .map(dl => ({ name: dl.loop.loopName, when: dl.loop.completedAt || dl.loop.startedAt || 0 })) + .map(dl => { + const when = dl.loop.completedAt || dl.loop.startedAt || 0 + return { name: dl.loop.loopName, when, whenLabel: fmtTime(when) } + }) .sort((a, b) => (b.when - a.when) || a.name.localeCompare(b.name)) }) const currentLoopIndex = createMemo(() => { - const name = route().loopName + const name = loopName() if (!name) return -1 return repoLoopOptions().findIndex(o => o.name === name) }) @@ -202,12 +278,27 @@ export function App() { if (i < 0) return null const options = repoLoopOptions() if (options.length <= 1) return null + // The equality option below preserves the breadcrumb DOM when only the + // neighbour identities change. The callbacks therefore cannot close over + // `i`/`options` — a retained node must read the current index and option + // set at click time so a poll that re-orders neighbours navigates to the + // current adjacent loop, not the stale one captured at memo run. return { index: i, total: options.length, - onPrev: () => { if (i > 0) navigate({ loopName: options[i - 1].name }) }, - onNext: () => { if (i < options.length - 1) navigate({ loopName: options[i + 1].name }) }, + onPrev: () => { + const cur = currentLoopIndex() + const opts = repoLoopOptions() + if (cur > 0) navigate({ loopName: opts[cur - 1].name }) + }, + onNext: () => { + const cur = currentLoopIndex() + const opts = repoLoopOptions() + if (cur < opts.length - 1) navigate({ loopName: opts[cur + 1].name }) + }, } + }, null, { + equals: (a, b) => a === b || (!!a && !!b && a.index === b.index && a.total === b.total), }) const selectedRepoCounts = createMemo(() => { @@ -232,8 +323,8 @@ export function App() { // only the loop's fields update in place via the reactive reads inside it. const breadcrumbView = createMemo(() => { - const r = route() - if (!r.projectId) return '' + const name = loopName() + if (!projectId()) return '' const proj = selectedRepoProject() if (!proj) return '' const rawPath = proj.projectDir || proj.projectId || '' @@ -241,7 +332,7 @@ export function App() { return untrack(() => Breadcrumb({ repoLabel: label, projectDir: rawPath, - loopName: r.loopName, + loopName: name, loops: repoLoopOptions, loopNav, onSelectLoop: (name: string) => navigate({ loopName: name }), @@ -251,18 +342,18 @@ export function App() { }) const sectionNavView = createMemo(() => { - const r = route() - if (!r.projectId || r.loopName) return '' + const pid = projectId() + const name = loopName() + if (!pid || name) return '' return SectionNav({ - section: r.section, + section: section(), counts: sectionCounts(), onNavigate: (section) => navigate({ section, loopName: null, groupId: null }), }) as Node }) const filterBarVisible = createMemo(() => { - const r = route() - return r.projectId !== null && r.loopName === null && r.section === 'loops' + return projectId() !== null && loopName() === null && section() === 'loops' }) const filterBarView = createMemo(() => { @@ -270,7 +361,7 @@ export function App() { return untrack(() => FilterBar({ counts: selectedRepoCounts, statuses: activeStatusSet, - query: () => route().query, + query, sortMode, onToggleStatus: toggleStatus, onChangeQuery: handleChangeQuery, @@ -286,23 +377,24 @@ export function App() { ? (LoopDetail({ dashLoop: loop, now, - routeTab: () => route().tab, + routeTab: tab, onSelectTab: selectTab, }) as Node) : '' }) + const visibleLoops = createMemo(() => { + const e = selectedEntry() + return e ? sortLoops(e.loops, sortMode()) : [] + }, [], { equals: sameList }) + const listView = createMemo(() => { if (!atLoopsSection()) return '' if (activeLoop()) return '' - const r = route() - if (!r.projectId) return '' + if (!projectId()) return '' return html`
${LoopTable({ - loops: () => { - const e = selectedEntry() - return e ? sortLoops(e.loops, sortMode()) : [] - }, + loops: visibleLoops, now, onOpen: (name: string) => openLoop(name), })} @@ -311,11 +403,10 @@ export function App() { }) const selectedGroup = createMemo(() => { - const r = route() - if (r.section !== 'groups' || !r.groupId) return null + if (section() !== 'groups' || !groupId()) return null const proj = selectedRepoProject() if (!proj) return null - return proj.groups.find(g => g.group.groupId === r.groupId) ?? null + return proj.groups.find(g => g.group.groupId === groupId()) ?? null }) const repoLoopNames = createMemo(() => { @@ -326,13 +417,13 @@ export function App() { const groupsView = createMemo(() => { if (!atGroupsSection()) return '' - const r = route() - if (!r.projectId) return '' - if (r.groupId) { + const pid = projectId() + if (!pid) return '' + if (groupId()) { return GroupDetail({ group: selectedGroup, loopNames: repoLoopNames, - projectId: () => route().projectId, + projectId: () => projectId(), onBack: () => navigate({ groupId: null }), onOpenLoop: openLoop, }) as Node @@ -350,19 +441,19 @@ export function App() { const findingsView = createMemo(() => { if (!atFindingsSection()) return '' - const r = route() - if (!r.projectId) return '' + const pid = projectId() + if (!pid) return '' return FindingsPanel({ loops: repoAllLoops, - projectId: () => route().projectId, + projectId: () => projectId(), onOpenLoop: openLoop, }) as Node }) const plansView = createMemo(() => { if (!atPlansSection()) return '' - const r = route() - if (!r.projectId) return '' + const pid = projectId() + if (!pid) return '' return PlansPanel({ loops: repoAllLoops, onOpenLoop: openLoop, @@ -373,33 +464,39 @@ export function App() { createEffect(() => { if (!loaded()) return - const r = route() - if (r.projectId === null) return - const exists = state.projects.some(p => p.projectId === r.projectId) + const pid = projectId() + if (pid === null) return + const exists = state.projects.some(p => p.projectId === pid) if (!exists) { - setRoute(prev => ({ ...prev, projectId: null, loopName: null, section: 'loops', statuses: [], query: '' })) + setProjectId(null) + setLoopName(null) + setSection('loops') + setStatuses([]) + setQuery('') } }) createEffect(() => { - const r = route() - if (!r.projectId || !r.loopName) return + const pid = projectId() + const name = loopName() + if (!pid || !name) return const proj = selectedRepoProject() if (!proj) return - const exists = proj.loops.some(l => l.loop.loopName === r.loopName) + const exists = proj.loops.some(l => l.loop.loopName === name) if (!exists) { - setRoute(prev => ({ ...prev, loopName: null })) + setLoopName(null) } }) createEffect(() => { - const r = route() - if (!r.projectId || !r.loopName || r.tab === 'overview') return + const name = loopName() + const t = tab() + if (!projectId() || !name || t === 'overview') return const loop = activeLoop() if (!loop) return const tabs = tabsForLoop(loop) - if (!tabs.includes(r.tab)) { - setRoute(prev => ({ ...prev, tab: 'overview' })) + if (!tabs.includes(t)) { + setTab('overview') } }) @@ -408,12 +505,26 @@ export function App() { syncHash(buildDashboardHash(route()), suppressHashChangeRef) }) + // A scope change means the server owes us different detail; poll immediately + // rather than waiting up to 5s. The previous detail stays rendered until the + // response lands, so reconcile swaps content in place and the keep-mounted + // node identity contract is preserved. `on(..., { defer: true })` runs the + // effect only on subsequent scopeKey changes, never on first execution, so + // the initial mount issues exactly one request (via onMount's load()). + const scopeKey = createMemo(() => (projectId() ?? '') + '\u0000' + (loopName() ?? '')) + createEffect(on(scopeKey, () => { + void load() + }, { defer: true })) + // ── Lifecycle ─────────────────────────────────────────────────────────── onMount(() => { - setRoute(parseDashboardHash(location.hash)) + // Route was already adopted from `location.hash` at signal setup. Syncing + // here again is a no-op for scope (and thus for the deferred scope effect), + // so the explicit load() below is the one and only initial fetch. + applyRoute(parseDashboardHash(location.hash)) - // Initial load + poll + // Initial load + background poll. load() const id = setInterval(() => { load() @@ -425,7 +536,7 @@ export function App() { suppressHashChangeRef.current = false return } - setRoute(parseDashboardHash(location.hash)) + applyRoute(parseDashboardHash(location.hash)) setExternalNav(n => n + 1) } window.addEventListener('hashchange', onHashChange) diff --git a/src/dashboard/app/components.ts b/src/dashboard/app/components.ts index c9400d73c1..a1b468dce2 100644 --- a/src/dashboard/app/components.ts +++ b/src/dashboard/app/components.ts @@ -1,5 +1,5 @@ import html from 'solid-js/html' -import { createMemo, createSignal, createEffect, onCleanup, untrack } from 'solid-js' +import { createMemo, createSignal, createEffect, getOwner, runWithOwner, onCleanup, untrack } from 'solid-js' import type { DashboardLoop, DashboardProject, DashboardGroup } from './types' import type { GroupFeatureRow } from '../../storage' import type { RepoSection, SortMode } from './helpers' @@ -29,6 +29,10 @@ import { summarizePhaseTotals, computeTimelineEvents, buildDashboardHash, + capList, + MAX_RENDERED_LOOP_ROWS, + MAX_RENDERED_FINDING_ROWS, + MAX_RENDERED_PICKER_OPTIONS, } from './helpers' import type { LoopTab, PhaseSpan } from './helpers' import { formatDuration } from '../../utils/duration' @@ -226,7 +230,7 @@ export function RepoIndexPane(props: {
` } -export type LoopOption = { name: string; when: number } +export type LoopOption = { name: string; when: number; whenLabel: string } export type LoopNav = { index: number; total: number; onPrev: () => void; onNext: () => void } @@ -245,11 +249,16 @@ function LoopPicker(props: { return q ? all.filter(o => o.name.toLowerCase().indexOf(q) !== -1) : all }) + const view = createMemo(() => capList(matches(), MAX_RENDERED_PICKER_OPTIONS, false)) + const openMenu = () => { if (open()) return setQuery('') - const idx = props.loops().findIndex(o => o.name === props.loopName) - setActive(idx < 0 ? 0 : idx) + const all = props.loops() + const idx = all.findIndex(o => o.name === props.loopName) + const capped = all.slice(0, MAX_RENDERED_PICKER_OPTIONS) + const cappedIdx = idx >= 0 && idx < capped.length ? idx : -1 + setActive(cappedIdx < 0 ? 0 : cappedIdx) setOpen(true) } const close = () => { @@ -268,16 +277,19 @@ function LoopPicker(props: { style=${() => (open() ? 'display:block' : 'display:none')} onmousedown=${(e: MouseEvent) => e.preventDefault()} > - ${() => matches().map((o, i) => html`
view().rows.map((o, i) => html`
'loop-picker-option' + (i === active() ? ' loop-picker-option-active' : '') + (o.name === props.loopName ? ' loop-picker-option-current' : '')} onclick=${() => commit(o.name)} > ${o.name} - ${fmtTime(o.when)} + ${o.whenLabel}
`)} - ${() => (matches().length === 0 ? html`
No matching loops
` : '')} + ${() => (view().total === 0 ? html`
No matching loops
` : '')} + ${() => (view().capped + ? html`
${() => 'Showing ' + view().rows.length + ' of ' + view().total + ' — type to filter'}
` + : '')}
` as HTMLElement const scrollActiveIntoView = () => { @@ -292,7 +304,7 @@ function LoopPicker(props: { openMenu() return } - const total = matches().length + const total = view().rows.length if (total === 0) return const delta = e.key === 'ArrowDown' ? 1 : -1 setActive(i => (i + delta + total) % total) @@ -301,7 +313,7 @@ function LoopPicker(props: { } if (e.key === 'Enter') { e.preventDefault() - const target = matches()[active()] + const target = view().rows[active()] if (target) commit(target.name) return } @@ -406,6 +418,15 @@ export function SectionNav(props: { // ── LoopTable ───────────────────────────────────────────────────────────── +function ListCapNotice(props: { shown: () => number; total: () => number; noun: string; onShowAll?: () => void }) { + return html`
+ ${() => 'Showing ' + props.shown() + ' of ' + props.total() + ' ' + props.noun} + ${props.onShowAll + ? html`` + : ''} +
` +} + function MiniMeter(props: { current: () => number; total: () => number }) { const pct = () => clampPercent(props.current(), props.total()) return html` @@ -459,15 +480,22 @@ function loopPhaseState( } export function LoopTable(props: { loops: () => DashboardLoop[]; now: () => number; onOpen: (name: string) => void }) { - return html` - - - - - - ${() => props.loops().map((dl: DashboardLoop) => LoopTableRow({ dashLoop: dl, now: props.now, onOpen: props.onOpen }))} - -
StatusLoopPhasePhase spanIterSectionsFindingsCostDurationUpdated
` + const [showAll, setShowAll] = createSignal(false) + const view = createMemo(() => capList(props.loops(), MAX_RENDERED_LOOP_ROWS, showAll())) + return html`
+ + + + + + + ${() => view().rows.map((dl: DashboardLoop) => LoopTableRow({ dashLoop: dl, now: props.now, onOpen: props.onOpen }))} + +
StatusLoopPhasePhase spanIterSectionsFindingsCostDurationUpdated
+ ${() => (view().capped + ? ListCapNotice({ shown: () => view().rows.length, total: () => view().total, noun: 'loops', onShowAll: () => setShowAll(true) }) + : '')} +
` } function LoopTableRow(props: { dashLoop: DashboardLoop; now: () => number; onOpen: (name: string) => void }) { @@ -792,7 +820,7 @@ function LoopUsage(props: {
'width:' + r.pct + '%'}>
-
${() => r.messageCount + ' msg'}
+
${() => r.messageCount + ' msg · ' + formatTokenCount(r.tokens) + ' tok'}
`)} @@ -1122,7 +1150,7 @@ function FindingsTabBody(props: { dashLoop: DashboardLoop }) { function PlanTabBody(props: { dashLoop: DashboardLoop }) { const dl = () => props.dashLoop - const hasPlan = createMemo(() => !!dl().plan) + const hasPlan = createMemo(() => dl().hasPlan) const hasAmendments = createMemo(() => !!dl().amendments && dl().amendments.length > 0) return html`
${() => (hasPlan() @@ -1144,6 +1172,28 @@ function UsageTabBody(props: { dashLoop: DashboardLoop }) {
` } +function buildTabBody(props: { + tab: LoopTab + dashLoop: DashboardLoop + split: () => Split + now: () => number +}): Node { + switch (props.tab) { + case 'overview': return OverviewTabBody({ dashLoop: props.dashLoop, split: props.split, now: props.now }) as Node + case 'timeline': return TimelineTabBody({ dashLoop: props.dashLoop, now: props.now }) as Node + case 'sections': return SectionsTabBody({ dashLoop: props.dashLoop }) as Node + case 'findings': return FindingsTabBody({ dashLoop: props.dashLoop }) as Node + case 'plan': return PlanTabBody({ dashLoop: props.dashLoop }) as Node + case 'usage': return UsageTabBody({ dashLoop: props.dashLoop }) as Node + } +} + +// Tab bodies are built lazily on first activation but never torn down: opening +// a loop no longer parses every tab's markdown or builds the machine graph up +// front, while a tab that has been opened keeps its DOM (scroll, collapse, and +// resize state) for the lifetime of the selected loop. `runWithOwner` is +// required — building inside the effect's own computation would attach the +// subtree's memos to an owner that is disposed on the effect's next run. function TabBody(props: { tab: LoopTab dashLoop: DashboardLoop @@ -1153,19 +1203,16 @@ function TabBody(props: { activeTab: () => LoopTab }) { const visible = createMemo(() => props.tabs().includes(props.tab) && props.activeTab() === props.tab) - const body = (() => { - switch (props.tab) { - case 'overview': return OverviewTabBody({ dashLoop: props.dashLoop, split: props.split, now: props.now }) - case 'timeline': return TimelineTabBody({ dashLoop: props.dashLoop, now: props.now }) - case 'sections': return SectionsTabBody({ dashLoop: props.dashLoop }) - case 'findings': return FindingsTabBody({ dashLoop: props.dashLoop }) - case 'plan': return PlanTabBody({ dashLoop: props.dashLoop }) - case 'usage': return UsageTabBody({ dashLoop: props.dashLoop }) - } - })() - return html`
visible() ? 'display:block' : 'display:none'}> - ${body} -
` + const owner = getOwner() + const host = html`
visible() ? 'display:block' : 'display:none'}>
` as HTMLElement + let built = false + createEffect(() => { + if (!visible() || built) return + built = true + const node = runWithOwner(owner, () => buildTabBody(props)) + if (node) host.appendChild(node) + }) + return host } function LoopDetailTabs(props: { @@ -1313,18 +1360,23 @@ export function GroupDetail(props: { ` } -function findingsGroupsForLoops(loops: DashboardLoop[]): Array<{ loopName: string; rows: DashboardLoop['findings'] }> { - const order: string[] = [] - const map = new Map() +function findingsGroupsForLoops( + loops: DashboardLoop[], + max: number, + showAll: boolean, +): { groups: Array<{ loopName: string; rows: DashboardLoop['findings'] }>; total: number; capped: boolean } { + const groups: Array<{ loopName: string; rows: DashboardLoop['findings'] }> = [] + let total = 0 + let budget = showAll ? Infinity : max for (const dl of loops) { if (dl.findings.length === 0) continue - if (!map.has(dl.loop.loopName)) { - map.set(dl.loop.loopName, []) - order.push(dl.loop.loopName) - } - map.get(dl.loop.loopName)!.push(...dl.findings) + total += dl.findings.length + if (budget <= 0) continue + const take = Math.min(budget, dl.findings.length) + groups.push({ loopName: dl.loop.loopName, rows: dl.findings.slice(0, take) }) + budget -= take } - return order.map(name => ({ loopName: name, rows: map.get(name)! })) + return { groups, total, capped: !showAll && total > max } } export function FindingsPanel(props: { @@ -1332,12 +1384,15 @@ export function FindingsPanel(props: { projectId: () => string | null onOpenLoop: (loopName: string) => void }) { - const groups = createMemo(() => findingsGroupsForLoops(props.loops())) - const total = createMemo(() => groups().reduce((acc, g) => acc + g.rows.length, 0)) + const [showAll, setShowAll] = createSignal(false) + const groups = createMemo(() => findingsGroupsForLoops(props.loops(), MAX_RENDERED_FINDING_ROWS, showAll())) return html`
-

Findings ${() => total()}

+

Findings ${() => groups().total}

+ ${() => (groups().capped + ? ListCapNotice({ shown: () => groups().groups.reduce((acc, g) => acc + g.rows.length, 0), total: () => groups().total, noun: 'findings', onShowAll: () => setShowAll(true) }) + : '')} ${() => { - const gs = groups() + const gs = groups().groups if (gs.length === 0) return html`
No findings recorded for this repo.
` return html`
${gs.map(g => html`
g.loopName}> @@ -1356,11 +1411,16 @@ export function PlansPanel(props: { loops: () => DashboardLoop[] onOpenLoop: (loopName: string) => void }) { - const planLoops = createMemo(() => props.loops().filter(dl => !!dl.plan)) + const [showAll, setShowAll] = createSignal(false) + const planLoops = createMemo(() => props.loops().filter(dl => dl.hasPlan)) + const view = createMemo(() => capList(planLoops(), MAX_RENDERED_LOOP_ROWS, showAll())) return html`
-

Plans ${() => planLoops().length}

+

Plans ${() => view().total}

+ ${() => (view().capped + ? ListCapNotice({ shown: () => view().rows.length, total: () => view().total, noun: 'plans', onShowAll: () => setShowAll(true) }) + : '')} ${() => { - const list = planLoops() + const list = view().rows if (list.length === 0) return html`
No plans recorded for this repo.
` return html`
${list.map(dl => html`
props.onOpenLoop(dl.loop.loopName)}> diff --git a/src/dashboard/app/helpers.ts b/src/dashboard/app/helpers.ts index 65a109a551..bb9736edd3 100644 --- a/src/dashboard/app/helpers.ts +++ b/src/dashboard/app/helpers.ts @@ -4,6 +4,32 @@ import { formatDuration, computeElapsedSeconds } from '../../utils/duration' export type LoopTab = 'overview' | 'timeline' | 'sections' | 'findings' | 'plan' | 'usage' export type RepoSection = 'loops' | 'groups' | 'findings' | 'plans' +export const MAX_RENDERED_LOOP_ROWS = 200 +export const MAX_RENDERED_FINDING_ROWS = 300 +export const MAX_RENDERED_PICKER_OPTIONS = 100 + +export interface CappedList { + rows: T[] + total: number + capped: boolean +} + +/** + * Single capping path for every unbounded list render. `showAll` bypasses the + * cap so a user can always reach the full set; filtering and search remain the + * intended narrowing tools. The input array is returned by reference when + * uncapped, preserving Phase 3's `sameList` identity behaviour. + */ +export function capList(items: T[], max: number, showAll: boolean): CappedList { + if (showAll || items.length <= max) return { rows: items, total: items.length, capped: false } + return { rows: items.slice(0, max), total: items.length, capped: true } +} + +/** Element-wise equality for signal/memo `equals` options over small arrays. */ +export function sameList(a: readonly T[], b: readonly T[]): boolean { + return a.length === b.length && a.every((v, i) => v === b[i]) +} + export interface DashboardRoute { projectId: string | null section: RepoSection @@ -408,6 +434,7 @@ interface RoleUsageBar { role: 'execution' | 'auditor' | 'other' cost: number messageCount: number + tokens: number pct: number } @@ -418,13 +445,16 @@ interface RoleUsageBar { * anything else lands in "other" so the bars always reconcile with totalCost. */ export function roleUsageBars(u: NonNullable): RoleUsageBar[] { - const exec: RoleUsageBar = { role: 'execution', cost: 0, messageCount: 0, pct: 0 } - const audit: RoleUsageBar = { role: 'auditor', cost: 0, messageCount: 0, pct: 0 } - const other: RoleUsageBar = { role: 'other', cost: 0, messageCount: 0, pct: 0 } + const exec: RoleUsageBar = { role: 'execution', cost: 0, messageCount: 0, tokens: 0, pct: 0 } + const audit: RoleUsageBar = { role: 'auditor', cost: 0, messageCount: 0, tokens: 0, pct: 0 } + const other: RoleUsageBar = { role: 'other', cost: 0, messageCount: 0, tokens: 0, pct: 0 } for (const [role, agg] of Object.entries(u.byRole ?? {})) { const bar = role === 'auditor' ? audit : role === 'code' ? exec : other bar.cost += agg.cost bar.messageCount += agg.messageCount + // Token fields may be absent on aggregates persisted before this field was + // tracked, or on hand-built fixtures; guard so the meta never renders NaN. + bar.tokens += (agg.inputTokens ?? 0) + (agg.outputTokens ?? 0) + (agg.reasoningTokens ?? 0) } const bars = other.cost > 0 || other.messageCount > 0 ? [exec, audit, other] : [exec, audit] const max = bars.reduce((m, b) => Math.max(m, b.cost), 0) @@ -537,9 +567,9 @@ const MD_CACHE_MAX = 200 export function tabsForLoop(loop: DashboardLoop): LoopTab[] { const tabs: LoopTab[] = ['overview', 'timeline'] - if (loop.sections.length > 0) tabs.push('sections') + if (loop.sectionCount > 0) tabs.push('sections') tabs.push('findings') - if (loop.plan) tabs.push('plan') + if (loop.hasPlan) tabs.push('plan') tabs.push('usage') return tabs } diff --git a/src/dashboard/data.ts b/src/dashboard/data.ts index 32eecaf6d9..aec02a99d0 100644 --- a/src/dashboard/data.ts +++ b/src/dashboard/data.ts @@ -36,7 +36,11 @@ export interface DashboardLoop { postActionReport: string | null goal: string | null plan: string | null + /** Whether a plan row exists. Always populated; `plan` content ships only for the scoped loop. */ + hasPlan: boolean sections: SectionPlanRow[] + /** Number of section-plan rows. Always populated; `sections` rows ship only for the scoped loop. */ + sectionCount: number findings: ReviewFindingRow[] usage: LoopUsageAggregate | null duration: string | null @@ -58,6 +62,19 @@ export interface DashboardPayload { projects: DashboardProject[] } +/** + * The client's current view. Large per-loop text is only materialised for the + * scoped loop, and per-loop transitions only for the scoped project, because + * nothing else renders them. Both fields null means the repo-index view, which + * reads none of the scoped fields. + */ +export interface DashboardScope { + projectId: string | null + loopName: string | null +} + +const UNSCOPED: DashboardScope = { projectId: null, loopName: null } + /** Check whether *tbl* exists on this database. */ function hasTable(database: Database, tbl: string): boolean { const row = database @@ -118,7 +135,7 @@ function projectAmendmentSections(json: string): string { } } -export function collectDashboardData(db: Database): DashboardPayload { +export function collectDashboardData(db: Database, scope: DashboardScope = UNSCOPED): DashboardPayload { const { loopsRepo, plansRepo, reviewFindingsRepo, sectionPlansRepo, loopSessionUsageRepo, loopTransitionsRepo, amendmentsRepo, featureGroupsRepo } = reposFor(db) const loopProjectIds = db.prepare( @@ -154,28 +171,52 @@ export function collectDashboardData(db: Database): DashboardPayload { return b.startedAt - a.startedAt }) + const inScopedProject = scope.projectId !== null && projectId === scope.projectId + const planLoopNames = new Set(plansRepo.listLoopNames(projectId)) + const sectionCounts = sectionPlansRepo.countsByLoop(projectId) + const dashboardLoops: DashboardLoop[] = sortedLoops.map(loop => { const loopName = loop.loopName - const large = loopsRepo.getLarge(projectId, loopName) + const isScopedLoop = inScopedProject && loopName === scope.loopName + + const large = isScopedLoop ? loopsRepo.getLarge(projectId, loopName) : null const lastAuditResult = large?.lastAuditResult ?? null const postActionReport = large?.postActionReport ?? null const goal = large?.goal ?? null - const plan = plansRepo.getForLoop(projectId, loopName)?.content ?? null - const sections = sectionPlansRepo.list(projectId, loopName) + + const hasPlan = planLoopNames.has(loopName) + const plan = isScopedLoop ? (plansRepo.getForLoop(projectId, loopName)?.content ?? null) : null + + const sectionCount = sectionCounts.get(loopName) ?? 0 + const sections = isScopedLoop ? sectionPlansRepo.list(projectId, loopName) : [] + const findings = reviewFindingsRepo.listByLoopName(projectId, loopName) const usage = loopSessionUsageRepo.getAggregate(projectId, loopName) - const transitions = loopTransitionsRepo ? loopTransitionsRepo.listForLoop(projectId, loopName, 100) : [] - const amendments = amendmentsRepo + + const transitions = inScopedProject && loopTransitionsRepo + ? loopTransitionsRepo.listForLoop(projectId, loopName, 100) + : [] + + const amendments = isScopedLoop && amendmentsRepo ? amendmentsRepo.listForLoop(projectId, loopName).map((a) => ({ ...a, sectionsBefore: projectAmendmentSections(a.sectionsBefore), sectionsAfter: projectAmendmentSections(a.sectionsAfter), })) : [] + const elapsedSeconds = computeElapsedSeconds(loop.startedAt, loop.completedAt ?? undefined) const duration = elapsedSeconds > 0 ? formatDuration(elapsedSeconds) : null - return { id: loopName, loop, lastAuditResult, postActionReport, goal, plan, sections, findings, usage, duration, transitions, amendments } + const loopRow: LoopRow = isScopedLoop + ? loop + : { ...loop, completionSummary: null } + + return { + id: loopName, loop: loopRow, lastAuditResult, postActionReport, goal, + plan, hasPlan, sections, sectionCount, + findings, usage, duration, transitions, amendments, + } }) let groups: DashboardGroup[] = [] diff --git a/src/dashboard/render.ts b/src/dashboard/render.ts index 6cb2882177..7fecca09e9 100644 --- a/src/dashboard/render.ts +++ b/src/dashboard/render.ts @@ -174,6 +174,7 @@ export function renderDashboardHtml(): string { .loop-picker-option-current .loop-picker-option-name { color: var(--link); font-weight: 600; } .loop-picker-option-when { color: var(--fg-2); font-family: var(--mono); font-size: 0.72rem; white-space: nowrap; } .loop-picker-empty { padding: 6px 8px; color: var(--fg-2); font-size: 0.8rem; } + .loop-picker-cap { padding: 5px 8px; font-size: 0.76rem; color: var(--fg-dim); border-top: 1px solid var(--divider); } .breadcrumb-path { margin-left: auto; color: var(--fg-2); font-family: var(--mono); font-size: 0.78rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -365,6 +366,11 @@ export function renderDashboardHtml(): string { .markdown-content a:hover { text-decoration: underline; } .markdown-content img { max-width: 100%; border-radius: 4px; } .loop-table { width: 100%; border-collapse: collapse; font-size: 0.82rem; } + .loop-table-wrap { display: flex; flex-direction: column; gap: 6px; } + .list-cap-notice { display: flex; align-items: center; gap: 8px; padding: 6px 4px; font-size: 0.78rem; color: var(--fg-dim); } + .list-cap-text { color: var(--fg-dim); } + .list-cap-show-all { padding: 2px 8px; font: inherit; color: var(--fg-0); background: var(--panel); border: 1px solid var(--divider); border-radius: var(--r-1); cursor: pointer; } + .list-cap-show-all:hover { border-color: var(--fg-0); } .loop-table th { text-align: left; font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--cancelled); font-weight: 600; padding: 6px 10px; border-bottom: 1px solid var(--divider); white-space: nowrap; } .loop-table td { padding: 7px 10px; border-bottom: 1px solid var(--surface); vertical-align: middle; } diff --git a/src/dashboard/server.ts b/src/dashboard/server.ts index 51c2b9ec5f..ba032c63c8 100644 --- a/src/dashboard/server.ts +++ b/src/dashboard/server.ts @@ -31,7 +31,10 @@ export function createRequestHandler(deps: DashboardDeps): (req: Request) => Res } if (pathname === '/api/data') { - const data = collectDashboardData(deps.forgeDb) + const data = collectDashboardData(deps.forgeDb, { + projectId: url.searchParams.get('project'), + loopName: url.searchParams.get('loop'), + }) return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json; charset=utf-8', diff --git a/src/loop/token-usage.ts b/src/loop/token-usage.ts index d55b31580e..17a7623dd5 100644 --- a/src/loop/token-usage.ts +++ b/src/loop/token-usage.ts @@ -20,17 +20,36 @@ export interface ModelUsage { messageCount: number } +/** Loop-session role vocabulary persisted in `loop_session_usage.role`. */ +export type UsageRole = 'code' | 'auditor' | 'unknown' + +/** + * Deterministic render order for role splits: execution first, then audit, then + * unattributed. + */ +export const USAGE_ROLE_ORDER: readonly UsageRole[] = ['code', 'auditor', 'unknown'] + +/** Usage aggregated per loop role, mirroring `ModelUsage`'s shape. */ +export interface RoleUsage { + role: UsageRole + cost: number + tokens: TokenBreakdown + messageCount: number +} + /** Summary of loop usage, optionally attributed to a role */ export interface LoopUsageSummary { totalCost: number totalTokens: TokenBreakdown perModel: ModelUsage[] + /** Per-role split. Optional so hand-built summaries in existing tests stay valid; producers always populate it. */ + perRole?: RoleUsage[] attribution?: UsageAttribution } /** Attribution metadata for usage */ export interface UsageAttribution { - role: 'code' | 'auditor' | 'unknown' + role: UsageRole fallbackModel?: string } @@ -84,6 +103,33 @@ export function addTokens(a: TokenBreakdown, b: TokenBreakdown): TokenBreakdown } } +/** + * Merge per-role usage groups, summing cost/tokens/messageCount per role and + * returning entries in `USAGE_ROLE_ORDER`, omitting absent roles. Tolerates + * `undefined` inputs so callers can spread `summary.perRole` unconditionally. + */ +export function mergeRoleUsage(...groups: (RoleUsage[] | undefined)[]): RoleUsage[] { + const map = new Map() + for (const group of groups) { + for (const entry of group ?? []) { + const existing = map.get(entry.role) + if (existing) { + existing.cost += entry.cost + existing.tokens = addTokens(existing.tokens, entry.tokens) + existing.messageCount += entry.messageCount + } else { + map.set(entry.role, { + role: entry.role, + cost: entry.cost, + tokens: { ...entry.tokens }, + messageCount: entry.messageCount, + }) + } + } + } + return USAGE_ROLE_ORDER.filter(r => map.has(r)).map(r => map.get(r)!) +} + /** Default model label when no metadata or fallback is available */ const DEFAULT_MODEL_LABEL = 'default/session model' @@ -209,6 +255,14 @@ export function summarizeAssistantUsage( totalCost, totalTokens, perModel, + perRole: attribution + ? [{ + role: attribution.role, + cost: totalCost, + tokens: { ...totalTokens }, + messageCount: assistantMessages.length, + }] + : [], attribution, } } @@ -223,6 +277,7 @@ export function mergeUsageSummaries(...summaries: LoopUsageSummary[]): LoopUsage totalCost: 0, totalTokens: emptyTokenBreakdown(), perModel: [], + perRole: [], } } @@ -260,6 +315,7 @@ export function mergeUsageSummaries(...summaries: LoopUsageSummary[]): LoopUsage totalCost, totalTokens, perModel, + perRole: mergeRoleUsage(...summaries.map(s => s.perRole)), attribution, } } diff --git a/src/storage/repos/loop-session-usage-repo.ts b/src/storage/repos/loop-session-usage-repo.ts index 123c969e26..4dd439ffba 100644 --- a/src/storage/repos/loop-session-usage-repo.ts +++ b/src/storage/repos/loop-session-usage-repo.ts @@ -36,6 +36,11 @@ export interface LoopUsageAggregate { }> byRole: Partial> } @@ -219,8 +224,21 @@ export function createLoopSessionUsageRepo(db: Database): LoopSessionUsageRepo { model.messageCount += row.message_count const roleKey = row.role as LoopSessionUsageRow['role'] - const role = (aggregate.byRole[roleKey] ??= { cost: 0, messageCount: 0 }) + const role = (aggregate.byRole[roleKey] ??= { + cost: 0, + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + messageCount: 0, + }) role.cost += row.cost + role.inputTokens += row.input_tokens + role.outputTokens += row.output_tokens + role.reasoningTokens += row.reasoning_tokens + role.cacheReadTokens += row.cache_read_tokens + role.cacheWriteTokens += row.cache_write_tokens role.messageCount += row.message_count } diff --git a/src/storage/repos/plans-repo.ts b/src/storage/repos/plans-repo.ts index 4b156025f6..21578d386b 100644 --- a/src/storage/repos/plans-repo.ts +++ b/src/storage/repos/plans-repo.ts @@ -23,6 +23,8 @@ export interface PlansRepo { deleteForLoop(projectId: string, loopName: string): void listRecent(projectId: string, opts?: ListRecentPlansOptions): PlanRow[] searchRecent(projectId: string, pattern: RegExp, opts?: ListRecentPlansOptions): PlanRow[] + /** Loop names in this project that have a persisted plan row. Session-scoped plans (loop_name IS NULL) are excluded. */ + listLoopNames(projectId: string): string[] } export function createPlansRepo(db: Database): PlansRepo { @@ -84,6 +86,12 @@ export function createPlansRepo(db: Database): PlansRepo { LIMIT ? `) + const stmtListLoopNames = db.prepare(` + SELECT loop_name + FROM plans + WHERE project_id = ? AND loop_name IS NOT NULL + `) + function writeForSession(projectId: string, sessionId: string, content: string): void { stmtWriteForSession.run(projectId, sessionId, content, Date.now()) } @@ -146,6 +154,10 @@ export function createPlansRepo(db: Database): PlansRepo { return results } + function listLoopNames(projectId: string): string[] { + return (stmtListLoopNames.all(projectId) as { loop_name: string }[]).map(r => r.loop_name) + } + return { writeForSession, writeForLoop, @@ -157,5 +169,6 @@ export function createPlansRepo(db: Database): PlansRepo { deleteForLoop, listRecent, searchRecent, + listLoopNames, } } diff --git a/src/storage/repos/section-plans-repo.ts b/src/storage/repos/section-plans-repo.ts index 11da2b72ab..172faa44dc 100644 --- a/src/storage/repos/section-plans-repo.ts +++ b/src/storage/repos/section-plans-repo.ts @@ -32,6 +32,8 @@ export interface SectionPlansRepo { setCompletedAt(projectId: string, loopName: string, index: number, ms: number): void updateContent(projectId: string, loopName: string, sections: ParsedSection[]): { updated: number } count(projectId: string, loopName: string): number + /** Section-plan row count per loop for this project, in one scan. */ + countsByLoop(projectId: string): Map deleteAll(projectId: string, loopName: string): number restoreAll(rows: SectionPlanRow[]): void /** @@ -131,6 +133,13 @@ export function createSectionPlansRepo(db: Database, _logger?: Logger): SectionP SELECT COUNT(*) as count FROM section_plans WHERE project_id = ? AND loop_name = ? `) + const stmtCountsByLoop = db.prepare(` + SELECT loop_name, COUNT(*) AS cnt + FROM section_plans + WHERE project_id = ? + GROUP BY loop_name + `) + const stmtDeleteAll = db.prepare('DELETE FROM section_plans WHERE project_id = ? AND loop_name = ?') const stmtListForReplace = db.prepare(` @@ -243,6 +252,11 @@ export function createSectionPlansRepo(db: Database, _logger?: Logger): SectionP return result.count }, + countsByLoop(projectId) { + const rows = stmtCountsByLoop.all(projectId) as { loop_name: string; cnt: number }[] + return new Map(rows.map(r => [r.loop_name, r.cnt])) + }, + deleteAll(projectId, loopName) { const result = stmtDeleteAll.run(projectId, loopName) as unknown as { changes: number } return result.changes diff --git a/src/tools/loop.ts b/src/tools/loop.ts index ccd50eb93a..1b7d09fa84 100644 --- a/src/tools/loop.ts +++ b/src/tools/loop.ts @@ -486,19 +486,14 @@ export function createLoopTools(ctx: ToolContext): Record ` ${l}`)) - } + )) return statusLines.join('\n') } @@ -601,19 +596,14 @@ export function createLoopTools(ctx: ToolContext): Record ` ${l}`)) - } + )) statusLines.push( '', diff --git a/src/utils/loop-format.ts b/src/utils/loop-format.ts index 11929c50bd..5c0088c3a4 100644 --- a/src/utils/loop-format.ts +++ b/src/utils/loop-format.ts @@ -1,11 +1,11 @@ import { formatTokens, truncate } from './format' import type { LoopSessionOutput } from '../loop' -import type { LoopUsageSummary, TokenBreakdown } from '../loop/token-usage' +import type { LoopUsageSummary, RoleUsage, TokenBreakdown } from '../loop/token-usage' +import { USAGE_ROLE_ORDER, mergeUsageSummaries } from '../loop/token-usage' import type { LoopUsageAggregate, LoopSessionUsageRepo } from '../storage/repos/loop-session-usage-repo' -import { mergeUsageSummaries } from '../loop/token-usage' export { formatTokens } from './format' -export type { LoopUsageSummary } from '../loop/token-usage' +export type { LoopUsageSummary, RoleUsage } from '../loop/token-usage' /** * Build cumulative usage for a loop by merging persisted aggregate with live session output. @@ -49,6 +49,22 @@ export function buildCumulativeUsage( return persistedSummary ?? liveSummary } +/** + * The single render path for a loop's cumulative usage block. Returns [] when + * there is nothing to report so callers can spread unconditionally. + */ +export function formatCumulativeUsage( + loopSessionUsageRepo: LoopSessionUsageRepo | undefined, + projectId: string, + loopName: string, + currentSessionId: string, + sessionOutput: LoopSessionOutput | null, +): string[] { + const summary = buildCumulativeUsage(loopSessionUsageRepo, projectId, loopName, currentSessionId, sessionOutput) + if (!summary) return [] + return ['', 'Cumulative Usage:', ...formatUsageSummary(summary).map(l => ` ${l}`)] +} + /** Convert LoopUsageAggregate from database to LoopUsageSummary */ export function aggregateToUsageSummary(aggregate: LoopUsageAggregate): LoopUsageSummary { const totalTokens: TokenBreakdown = { @@ -72,10 +88,29 @@ export function aggregateToUsageSummary(aggregate: LoopUsageAggregate): LoopUsag messageCount: data.messageCount, })).sort((a, b) => a.model.localeCompare(b.model)) + const perRole: RoleUsage[] = USAGE_ROLE_ORDER + .filter(role => aggregate.byRole[role] !== undefined) + .map(role => { + const data = aggregate.byRole[role]! + return { + role, + cost: data.cost, + tokens: { + input: data.inputTokens, + output: data.outputTokens, + reasoning: data.reasoningTokens, + cacheRead: data.cacheReadTokens, + cacheWrite: data.cacheWriteTokens, + }, + messageCount: data.messageCount, + } + }) + return { totalCost: aggregate.totalCost, totalTokens, perModel, + perRole, } } @@ -98,6 +133,17 @@ export function formatUsageSummary(summary: LoopUsageSummary): string[] { } } + const perRole = summary.perRole ?? [] + if (perRole.length > 0) { + lines.push('Per-role usage:') + for (const roleUsage of perRole) { + const roleCost = `$${roleUsage.cost.toFixed(4)}` + const rt = roleUsage.tokens + const roleTokensStr = `${formatTokens(rt.input)} in / ${formatTokens(rt.output)} out / ${formatTokens(rt.reasoning)} reasoning / ${formatTokens(rt.cacheRead)} cache read / ${formatTokens(rt.cacheWrite)} cache write` + lines.push(` ${roleUsage.role}: ${roleCost} | ${roleTokensStr}`) + } + } + return lines } diff --git a/test/dashboard/app-dom.test.ts b/test/dashboard/app-dom.test.ts index 441e14ddfc..22bfbdcf63 100644 --- a/test/dashboard/app-dom.test.ts +++ b/test/dashboard/app-dom.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest' import { render } from 'solid-js/web' import { App } from '../../src/dashboard/app/app' -import { fmtTime } from '../../src/dashboard/app/helpers' +import { fmtTime, MAX_RENDERED_LOOP_ROWS, MAX_RENDERED_FINDING_ROWS, MAX_RENDERED_PICKER_OPTIONS } from '../../src/dashboard/app/helpers' // --------------------------------------------------------------------------- // Payload builders (minimal runtime shape; tests are not typechecked by tsc) @@ -17,7 +17,7 @@ function makeLoop(over: Record = {}): any { // preserves the proxy for each loop across polls. Defaults to the default // loopName unless overridden. const loopName = loopOver.loopName ?? 'loop-a' - return { + const dashLoop = { id: loopName, loop: { projectId: 'p1', @@ -60,6 +60,11 @@ function makeLoop(over: Record = {}): any { duration: null, ...over, } + return { + ...dashLoop, + hasPlan: over.hasPlan ?? !!dashLoop.plan, + sectionCount: over.sectionCount ?? dashLoop.sections.length, + } } function makePayload(over: Record = {}): any { @@ -177,6 +182,7 @@ describe('dashboard App loop list', () => { describe('dashboard App fine-grained reactivity', () => { test('renders the loop detail with markdown after initial load', async () => { + window.location.hash = '#p1/loop/loop-a/plan' dispose = render(() => App() as unknown as Element, container) await flush() @@ -186,6 +192,7 @@ describe('dashboard App fine-grained reactivity', () => { }) test('markdown content updates in place when plan changes on a poll', async () => { + window.location.hash = '#p1/loop/loop-a/plan' dispose = render(() => App() as unknown as Element, container) await flush() @@ -204,8 +211,14 @@ describe('dashboard App fine-grained reactivity', () => { }) test('status change keeps the markdown node (no full subtree rebuild) and updates the badge', async () => { + // The status badge lives in the Overview tab body (LoopDetailHeader); + // the markdown body lives in the Plan tab body. Activate Plan after + // mounting so both lazy bodies coexist in the DOM. + window.location.hash = '#p1/loop/loop-a' dispose = render(() => App() as unknown as Element, container) await flush() + ;(container.querySelector('.tab-item[data-tab="plan"]') as HTMLElement).click() + await flush() const body1 = container.querySelector('.markdown-body') as HTMLElement ;(body1 as any).__id = 'orig' @@ -225,6 +238,7 @@ describe('dashboard App fine-grained reactivity', () => { }) test('a markdown section collapses and expands, keeps Copy available, and survives a poll', async () => { + window.location.hash = '#p1/loop/loop-a/plan' dispose = render(() => App() as unknown as Element, container) await flush() @@ -255,6 +269,7 @@ describe('dashboard App fine-grained reactivity', () => { }) test('renders a floating table of contents when the plan has multiple headings', async () => { + window.location.hash = '#p1/loop/loop-a/plan' payload = makePayload({ dashLoop: { plan: '# Goal\n\nIntro paragraph.\n\n## Step One\n\nDo thing.\n\n## Step Two\n\nDo more.' }, }) @@ -283,8 +298,9 @@ describe('dashboard App fine-grained reactivity', () => { }) // Heading ids are slugs, so two markdown sections sharing a heading produce - // the same id twice in the document — and hidden tabs stay mounted. A toc - // jump must resolve inside its own section, not the first document match. + // the same id twice in the document — and hidden tab bodies keep their DOM + // once activated. A toc jump must resolve inside its own section, not the + // first document match. test('a toc jump resolves the heading in its own markdown section', async () => { const shared = '# Shared\n\ntext\n\n## Verification\n\ntext' window.location.hash = '#p1/loop/loop-a/plan' @@ -294,6 +310,16 @@ describe('dashboard App fine-grained reactivity', () => { dispose = render(() => App() as unknown as Element, container) await flush() + // Plan is the active tab on mount, so its body is built lazily. Activate + // Overview to mount its report/audit markdown bodies too, then return to + // Plan. Both bodies now coexist in the DOM with colliding heading ids. + const activateTab = async (tab: string) => { + ;(container.querySelector(`.tab-item[data-tab="${tab}"]`) as HTMLElement).click() + await flush() + } + await activateTab('overview') + await activateTab('plan') + const bodies = Array.from(container.querySelectorAll('.markdown-body')) as HTMLElement[] expect(bodies.length).toBeGreaterThan(1) // Every section injected the same ids, so getElementById would be ambiguous. @@ -316,6 +342,7 @@ describe('dashboard App fine-grained reactivity', () => { }) test('does not render a table of contents when the plan has fewer than two h1-h3 headings', async () => { + window.location.hash = '#p1/loop/loop-a/plan' payload = makePayload({ dashLoop: { plan: 'PLAN ONE' } }) dispose = render(() => App() as unknown as Element, container) await flush() @@ -326,6 +353,7 @@ describe('dashboard App fine-grained reactivity', () => { test('section drill-in: click a row to open details, back returns to list', async () => { + window.location.hash = '#p1/loop/loop-a/sections' payload = makePayload({ dashLoop: { sections: [ @@ -412,6 +440,7 @@ describe('dashboard App fine-grained reactivity', () => { }) test('renders usage graphs (stacked token bar + per-model cost bars)', async () => { + window.location.hash = '#p1/loop/loop-a/usage' payload = makePayload({ dashLoop: { usage: { @@ -498,7 +527,11 @@ describe('dashboard App fine-grained reactivity', () => { expect(stats.textContent).not.toContain('$') // Usage graphs (token composition + cost by model) live inside the Usage - // tab body, mounted at first paint even when the active tab is Overview. + // tab body, which on first paint is built only when the user activates + // the tab. + const usageTab = container.querySelector('.tab-item[data-tab="usage"]') as HTMLElement + usageTab.click() + await flush() const usage = container.querySelector('.tab-body[data-tab="usage"] .usage-group')! expect(usage).toBeTruthy() expect(usage.querySelector('.usage-stack')).toBeTruthy() @@ -536,6 +569,13 @@ describe('dashboard App fine-grained reactivity', () => { }) describe('dashboard App machine graph', () => { + beforeEach(() => { + // The machine graph lives in the Timeline tab body, which is built lazily + // on first activation. Deep-link to the Timeline tab so each test mounts + // the graph on first paint. + window.location.hash = '#p1/loop/loop-a/timeline' + }) + function phaseNodeByText(phase: string): HTMLElement | null { const nodes = container.querySelectorAll('g.mg-node') for (const n of nodes) { @@ -884,6 +924,10 @@ describe('dashboard App machine graph', () => { test('poll reordering of multiple loops preserves the selected loop SVG root identity', async () => { // Initial: loop-a running (sorts first), loop-b completed newer. // URL hash selects p1/loop-a so the detail view + machine graph render. + // Mount on Overview (so the LoopDetailHeader in the Overview body is + // built) and then activate the Timeline tab to build the machine graph + // body lazily; both bodies stay mounted for the lifetime of loop-a. + window.location.hash = '#p1/loop/loop-a' payload = makePayload({ loops: [ makeLoop({ @@ -908,6 +952,8 @@ describe('dashboard App machine graph', () => { }) dispose = render(() => App() as unknown as Element, container) await flush() + ;(container.querySelector('.tab-item[data-tab="timeline"]') as HTMLElement).click() + await flush() // Machine graph is mounted for loop-a; coding node is active. const svg1 = container.querySelector('svg.mg-svg') as SVGElement @@ -969,6 +1015,13 @@ describe('dashboard App machine graph', () => { }) describe('dashboard App plan amendments panel', () => { + beforeEach(() => { + // Amendments are rendered inside the Plan tab body, which is built lazily + // on first activation. Deep-link to the Plan tab so each test mounts it + // on first paint. + window.location.hash = '#p1/loop/loop-a/plan' + }) + function amendRow(overrides: Record = {}): any { return { id: 1, @@ -1671,7 +1724,7 @@ describe('dashboard App loop detail tabs', () => { }) } - test('opening a plan loop renders six tab-item elements with Overview active', async () => { + test('opening a plan loop builds only the active tab body and builds Usage on first activation', async () => { window.location.hash = '#p1/loop/loop-a' payload = planLoopFixture() dispose = render(() => App() as unknown as Element, container) @@ -1680,11 +1733,67 @@ describe('dashboard App loop detail tabs', () => { const items = Array.from(container.querySelectorAll('.tab-item')) as HTMLElement[] expect(items.length).toBe(6) expect(items.map(i => i.dataset.tab)).toEqual(['overview', 'timeline', 'sections', 'findings', 'plan', 'usage']) - const active = container.querySelector('.tab-item.tab-active') as HTMLElement - expect(active).toBeTruthy() - expect(active.dataset.tab).toBe('overview') - expect((container.querySelector('.tab-body[data-tab="overview"]') as HTMLElement).style.display).not.toBe('none') - expect((container.querySelector('.tab-body[data-tab="usage"]') as HTMLElement).style.display).toBe('none') + + // Every tab-body host exists on first paint with the active tab visible + // and the rest hidden. Only the active (Overview) host has been populated; + // the Usage host is empty until the user opens it. + const usageHost = container.querySelector('.tab-body[data-tab="usage"]') as HTMLElement + expect(usageHost).toBeTruthy() + expect(usageHost.style.display).toBe('none') + expect(usageHost.querySelector('.usage-tab')).toBeNull() + expect((container.querySelector('.tab-body[data-tab="overview"]') as HTMLElement).querySelector('.overview-tab, .markdown-section')).toBeTruthy() + + await clickTab('usage') + expect(usageHost.querySelector('.usage-tab')).toBeTruthy() + expect(usageHost.style.display).toBe('block') + }) + + test('the timeline machine graph is not built until the Timeline tab is activated', async () => { + window.location.hash = '#p1/loop/loop-a' + payload = planLoopFixture() + dispose = render(() => App() as unknown as Element, container) + await flush() + + // The machine graph (~70-node SVG) lives inside the Timeline tab body. On + // first paint with Overview active it must not be built. + expect(container.querySelector('.mg-graph')).toBeNull() + + await clickTab('timeline') + const graph = container.querySelector('.mg-graph') as HTMLElement + expect(graph).toBeTruthy() + expect(graph.querySelectorAll('g.mg-node').length).toBe(5) + }) + + test('a lazily built tab body keeps its node identity across a hide/show cycle and a poll', async () => { + window.location.hash = '#p1/loop/loop-a' + payload = planLoopFixture() + dispose = render(() => App() as unknown as Element, container) + await flush() + + // Plan is not the active tab on mount; activate it to build its body. + await clickTab('plan') + const planBody = container.querySelector('.tab-body[data-tab="plan"]') as HTMLElement + const planMd = planBody.querySelector('.markdown-content') as HTMLElement + expect(planMd).toBeTruthy() + ;(planMd as any).__id = 'plan-orig' + + // Switch to Overview (Plan body is hidden but kept) and back; the node + // identity must be preserved on the lazy path, just as it is on the eager + // path tested above. + await clickTab('overview') + expect(planBody.style.display).toBe('none') + await clickTab('plan') + + const planMd2 = planBody.querySelector('.markdown-content') as HTMLElement + expect(planMd2).toBe(planMd) + expect((planMd2 as any).__id).toBe('plan-orig') + + // A poll that mutates the plan content must update the same DOM node in + // place, not rebuild it. + await poll(makePayload({ dashLoop: { plan: 'PLAN TWO' } })) + const planMd3 = planBody.querySelector('.markdown-content') as HTMLElement + expect(planMd3).toBe(planMd) + expect(planMd3.innerHTML).toContain('PLAN TWO') }) test('opening a goal loop renders no Sections tab and its Overview contains the goal text', async () => { @@ -1762,7 +1871,7 @@ describe('dashboard App loop detail tabs', () => { }) test('tab identity is preserved across tab switch: Plan collapse state survives hide/show', async () => { - window.location.hash = '#p1/loop/loop-a' + window.location.hash = '#p1/loop/loop-a/plan' payload = planLoopFixture() dispose = render(() => App() as unknown as Element, container) await flush() @@ -1934,6 +2043,299 @@ describe('dashboard App loop detail tabs', () => { }) }) +describe('dashboard App scoped poll', () => { + async function clickTab(tab: string): Promise { + const item = container.querySelector(`.tab-item[data-tab="${tab}"]`) as HTMLElement + expect(item).toBeTruthy() + item.click() + await flush() + } + + function fetchCalls(): string[] { + return (globalThis.fetch as any).mock.calls.map((c: any[]) => c[0] as string) + } + + test('the initial poll requests /api/data scoped to the deep-linked project and loop', async () => { + window.location.hash = '#p1/loop/loop-a' + payload = makePayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(fetchCalls()[0]).toBe('/api/data?project=p1&loop=loop-a') + }) + + test('the repo index polls /api/data with no scope', async () => { + window.location.hash = '' + payload = makePayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(fetchCalls()[0]).toBe('/api/data') + }) + + test('mounting a deep-linked loop issues exactly one fetch (scope effect defers on first run)', async () => { + window.location.hash = '#p1/loop/loop-a' + payload = makePayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(fetchCalls().length).toBe(1) + expect(fetchCalls()[0]).toBe('/api/data?project=p1&loop=loop-a') + }) + + test('mounting at the repo index issues exactly one fetch', async () => { + window.location.hash = '' + payload = makePayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(fetchCalls().length).toBe(1) + expect(fetchCalls()[0]).toBe('/api/data') + }) + + test('an out-of-order scoped response cannot clobber a newer scoped response', async () => { + // Two loops exist; mount on loop-a. An in-flight interval poll for loop-a + // (request gen N) is superseded by a scoped navigation to loop-b (request + // gen N+1). Resolving the newer response first, then the older one, must + // not let the stale loop-a payload overwrite the loop-b detail. + window.location.hash = '#p1/loop/loop-a' + payload = makePayload({ + loops: [ + makeLoop({ loop: { loopName: 'loop-a', status: 'running', startedAt: 1700000000000 } }), + makeLoop({ loop: { loopName: 'loop-b', status: 'running', startedAt: 1700000100000 } }), + ], + totals: { loops: 2, running: 2 }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + // Fresh payload (newer): server scoped to loop-b; still contains both + // loops so the route canonicalization effect does not bounce off loop-b. + const freshPayload = makePayload({ + loops: [ + makeLoop({ loop: { loopName: 'loop-a', status: 'running', startedAt: 1700000000000 } }), + makeLoop({ loop: { loopName: 'loop-b', status: 'running', startedAt: 1700000100000 }, plan: 'FRESH' }), + ], + totals: { loops: 2, running: 2 }, + }) + // Stale payload (older): server scoped to loop-a; carries only loop-a. + // If applied after the fresh payload, reconcile drops loop-b from the + // project and the route canonicalizes away from loop-b's detail. + const stalePayload = makePayload({ + loops: [ + makeLoop({ loop: { loopName: 'loop-a', status: 'running', startedAt: 1700000000000 } }), + ], + totals: { loops: 1, running: 1 }, + }) + + const fetchMock = globalThis.fetch as any + let resolveStale!: () => void + let resolveFresh!: () => void + const stalePromise = new Promise((r) => { resolveStale = r }) + const freshPromise = new Promise((r) => { resolveFresh = r }) + fetchMock.mockImplementationOnce(async () => ({ + json: async () => { await stalePromise; return stalePayload }, + })) + fetchMock.mockImplementationOnce(async () => ({ + json: async () => { await freshPromise; return freshPayload }, + })) + + // Trigger the older in-flight poll, then navigate to loop-b which fires + // the newest scoped request. + void intervalFn!() + window.location.hash = '#p1/loop/loop-b' + window.dispatchEvent(new Event('hashchange')) + await flush() + + // Resolve the newer request first; loop-b detail is rendered. + resolveFresh() + await flush() + expect(container.querySelector('.ldh-name')?.textContent).toBe('loop-b') + + // Resolve the older stale request; loop-b detail must remain (the stale + // loop-a response was discarded by request generation). + resolveStale() + await flush() + expect(container.querySelector('.ldh-name')?.textContent).toBe('loop-b') + // The Plan tab body is built lazily; activate it to mount its markdown so + // the FRESH plan content carried by the loop-b payload is observable. + ;(container.querySelector('.tab-item[data-tab="plan"]') as HTMLElement).click() + await flush() + expect(container.querySelector('.tab-body[data-tab="plan"] .markdown-content')?.textContent).toContain('FRESH') + }) + + test('a slow same-scope poll survives interval ticks and still renders', async () => { + // Mount on loop-a; the initial load resolves with the default payload. + // Then an interval tick fires a slow same-scope poll whose response takes + // longer than one interval. A subsequent interval tick must NOT supersede + // it (no generation bump, no second fetch issued); the slow response must + // still be accepted and update the UI when it resolves. + window.location.hash = '#p1/loop/loop-a' + payload = makePayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + const slowPayload = makePayload({ dashLoop: { plan: 'PLAN TWO' } }) + const fetchMock = globalThis.fetch as any + let resolveSlow!: () => void + const slowPromise = new Promise((r) => { resolveSlow = r }) + fetchMock.mockImplementationOnce(async () => ({ + json: async () => { await slowPromise; return slowPayload }, + })) + + // Start the slow same-scope poll. + void intervalFn!() + await flush() + const callsAfterStart = fetchCalls() + + // While it is pending, another interval tick must coalesce rather than + // issue a competing fetch (no new fetch URL recorded). + void intervalFn!() + await flush() + expect(fetchCalls()).toEqual(callsAfterStart) + + // Resolving the slow response updates the UI even though an interval tick + // occurred while it was in flight. + resolveSlow() + await flush() + ;(container.querySelector('.tab-item[data-tab="plan"]') as HTMLElement).click() + await flush() + expect(container.querySelector('.tab-body[data-tab="plan"] .markdown-content')?.textContent).toContain('PLAN TWO') + }) + + test('selecting a loop from the table triggers an immediate scoped refetch', async () => { + window.location.hash = '#p1' + payload = makePayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + const before = fetchCalls().length + ;(container.querySelector('tr.lt-row') as HTMLElement).click() + await flush() + + const calls = fetchCalls() + expect(calls.length).toBe(before + 1) + expect(calls[calls.length - 1]).toBe('/api/data?project=p1&loop=loop-a') + }) + + test('switching tabs does not trigger a refetch', async () => { + window.location.hash = '#p1/loop/loop-a' + payload = makePayload({ + dashLoop: { + usage: { + loopName: 'loop-a', + totalCost: 0.4, + totalInputTokens: 100, + totalOutputTokens: 50, + totalReasoningTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + totalMessageCount: 1, + byModel: { + 'model-a': { cost: 0.4, inputTokens: 100, outputTokens: 50, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 1 }, + }, + }, + }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const before = fetchCalls().length + await clickTab('usage') + expect(fetchCalls().length).toBe(before) + }) + + test('a loop whose plan content has not arrived still offers the Plan tab', async () => { + window.location.hash = '#p1/loop/loop-a/plan' + payload = makePayload({ dashLoop: { hasPlan: true, plan: null } }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(container.querySelector('.tab-item[data-tab="plan"]')).toBeTruthy() + const planBody = container.querySelector('.tab-body[data-tab="plan"]') as HTMLElement + expect(planBody).toBeTruthy() + expect(planBody.textContent).not.toContain('No plan recorded for this loop.') + }) + + test('opening a loop directly from the repo index issues a single scoped fetch', async () => { + // Mount at the global repo index (projectId=null), then click a running + // loop card. The click navigates projectId and loopName together; without + // batching the scopeKey effect would fire twice — once for the project + // scope and once for the project+loop scope. + window.location.hash = '' + payload = makePayload({ + loops: [ + makeLoop({ loop: { loopName: 'loop-a', status: 'running', startedAt: 1700000000000 } }), + ], + totals: { loops: 1, running: 1 }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(fetchCalls()).toEqual(['/api/data']) + + const card = container.querySelector('.repo-running-card') as HTMLElement + expect(card).toBeTruthy() + card.click() + await flush() + + expect(fetchCalls()).toEqual(['/api/data', '/api/data?project=p1&loop=loop-a']) + }) + + test('a multi-field external hash navigation issues a single scoped fetch', async () => { + // Preload both projects so the route-canonicalisation effect does not + // bounce the new project out before its fetch resolves. Mount on a loop in + // p1, then an external hashchange lands on a loop in p2. Both projectId and + // loopName change in one applyRoute call; without batching the scopeKey + // effect would fire for the intermediate state (project=p2, loop=loop-a) + // and issue a stale /api/data?project=p2&loop=loop-a before the final + // project+loop request. + function twoProjectPayload(): any { + const p2 = makePayload({ + loops: [ + makeLoop({ loop: { loopName: 'loop-b', status: 'running', startedAt: 1700000000000 } }), + ], + totals: { loops: 1, running: 1 }, + }) + p2.projects[0].id = 'p2' + p2.projects[0].projectId = 'p2' + p2.projects[0].projectDir = '/proj/p2' + p2.projects[0].loops[0].id = 'loop-b' + p2.projects[0].loops[0].loop.projectId = 'p2' + + const base = makePayload({ + loops: [ + makeLoop({ loop: { loopName: 'loop-a', status: 'running', startedAt: 1700000000000 } }), + ], + totals: { loops: 1, running: 1 }, + }) + base.projects.push(p2.projects[0]) + base.totals.projects = 2 + base.totals.loops = 2 + base.totals.running = 2 + return base + } + window.location.hash = '#p1/loop/loop-a' + payload = twoProjectPayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(fetchCalls()).toEqual(['/api/data?project=p1&loop=loop-a']) + + // External hash navigation moves both projectId and loopName together. + const beforeCount = fetchCalls().length + window.location.hash = '#p2/loop/loop-b' + window.dispatchEvent(new Event('hashchange')) + await flush() + + // Exactly one new fetch, scoped to the final route — not an intermediate + // /api/data?project=p2&loop=loop-a. + expect(fetchCalls().length).toBe(beforeCount + 1) + expect(fetchCalls()[fetchCalls().length - 1]).toBe('/api/data?project=p2&loop=loop-b') + expect(fetchCalls()).not.toContain('/api/data?project=p2&loop=loop-a') + }) +}) + describe('dashboard App breadcrumb loop picker', () => { // completedAt || startedAt drives recency: beta-loop (running, 1700009000000), // gamma-run (1700004000000), alpha-loop (1700000600000). @@ -3536,9 +3938,9 @@ describe('dashboard App findings and plans sections', () => { 'unknown-m': { cost: 0.5, inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 1 }, }, byRole: { - code: { cost: 2, messageCount: 6 }, - auditor: { cost: 1, messageCount: 2 }, - unknown: { cost: 0.5, messageCount: 1 }, + code: { cost: 2, inputTokens: 4000, outputTokens: 2000, reasoningTokens: 400, cacheReadTokens: 80, cacheWriteTokens: 160, messageCount: 6 }, + auditor: { cost: 1, inputTokens: 1000, outputTokens: 500, reasoningTokens: 100, cacheReadTokens: 20, cacheWriteTokens: 40, messageCount: 2 }, + unknown: { cost: 0.5, inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 1 }, }, }, }, @@ -3556,5 +3958,300 @@ describe('dashboard App findings and plans sections', () => { const costs = rows.map(r => r.querySelector('.usage-model-cost')?.textContent) // The three bars reconcile with totalCost ($3.50) — no role's spend is dropped. expect(costs).toEqual(['$2.00', '$1.00', '$0.5000']) + // The execution meta reports both the message count and a token count. + const execRow = rows.find(r => r.querySelector('.usage-model-name')?.textContent === 'execution')! + const execMeta = execRow.querySelector('.usage-model-meta')?.textContent ?? '' + expect(execMeta).toContain('6 msg') + expect(execMeta).toContain('tok') + // 4000 + 2000 + 400 = 6400 → "6.4k" via formatTokenCount. + expect(execMeta).toContain('6.4k tok') + }) +}) + +describe('dashboard App render-stable node identity', () => { + function twoRunningLoopsPayload(): any { + return makePayload({ + loops: [ + makeLoop({ loop: { loopName: 'loop-a', status: 'running', startedAt: 1700000000000 } }), + makeLoop({ loop: { loopName: 'loop-b', status: 'running', startedAt: 1700000100000 } }), + ], + totals: { loops: 2, running: 2, completed: 0, cancelled: 0, errored: 0, stalled: 0 }, + }) + } + + async function clickChip(status: string): Promise { + const chips = Array.from(container.querySelectorAll('.filter-bar .badge-filter')) as HTMLElement[] + const chip = chips.find(c => c.textContent?.toLowerCase().startsWith(status)) + expect(chip).toBeTruthy() + chip!.click() + await flush() + } + + async function clickTab(tab: string): Promise { + const item = container.querySelector(`.tab-item[data-tab="${tab}"]`) as HTMLElement + expect(item).toBeTruthy() + item.click() + await flush() + } + + test('toggling a status filter off and back on preserves loop-table row node identity', async () => { + window.location.hash = '#p1' + payload = twoRunningLoopsPayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + const rowBefore = container.querySelectorAll('.lt-row')[0] as HTMLElement + expect(rowBefore).toBeTruthy() + + // Both loops are running, so filtering by `running` keeps the same visible + // set — only the filter value changed. With `sameList` on the visible-loops + // memo the array reference is stable and LoopTable does not rebuild rows. + await clickChip('running') + await clickChip('running') + + const rowAfter = container.querySelectorAll('.lt-row')[0] as HTMLElement + expect(rowAfter).toBe(rowBefore) + }) + + test('a poll that changes nothing preserves loop-table row node identity', async () => { + window.location.hash = '#p1' + payload = twoRunningLoopsPayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + const rowBefore = container.querySelectorAll('.lt-row')[0] as HTMLElement + expect(rowBefore).toBeTruthy() + + await poll(twoRunningLoopsPayload()) + + const rowAfter = container.querySelectorAll('.lt-row')[0] as HTMLElement + expect(rowAfter).toBe(rowBefore) + }) + + test('switching a loop tab preserves the breadcrumb loop-picker input node', async () => { + window.location.hash = '#p1/loop/alpha-loop' + payload = makePayload({ + loops: [ + makeLoop({ loop: { loopName: 'alpha-loop', status: 'completed', startedAt: 1700000000000, completedAt: 1700000600000 } }), + makeLoop({ loop: { loopName: 'beta-loop', status: 'running', startedAt: 1700009000000, completedAt: null } }), + makeLoop({ loop: { loopName: 'gamma-run', status: 'completed', startedAt: 1700003000000, completedAt: 1700004000000 } }), + ], + totals: { loops: 3, running: 1, completed: 2 }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const inputBefore = container.querySelector('.loop-picker-input') as HTMLInputElement + expect(inputBefore).toBeTruthy() + + await clickTab('usage') + + const inputAfter = container.querySelector('.loop-picker-input') as HTMLInputElement + expect(inputAfter).toBe(inputBefore) + }) + + test('a poll that reorders neighbours keeps loop-nav buttons but targets the current neighbours', async () => { + // Order (most-recent-first): beta, alpha, gamma. Active loop alpha is at + // index 1 with total 3; prev targets beta, next targets gamma. + const initial = makePayload({ + loops: [ + makeLoop({ loop: { loopName: 'beta', status: 'running', startedAt: 1700009000000, completedAt: null } }), + makeLoop({ loop: { loopName: 'alpha', status: 'running', startedAt: 1700005000000, completedAt: null } }), + makeLoop({ loop: { loopName: 'gamma', status: 'running', startedAt: 1700001000000, completedAt: null } }), + ], + totals: { loops: 3, running: 3 }, + }) + window.location.hash = '#p1/loop/alpha' + payload = initial + dispose = render(() => App() as unknown as Element, container) + await flush() + + const prevBefore = container.querySelector('.loopnav-prev') as HTMLElement + const nextBefore = container.querySelector('.loopnav-next') as HTMLElement + expect(prevBefore).toBeTruthy() + expect(nextBefore).toBeTruthy() + + // Poll swaps beta and gamma positions; alpha is still at index 1, total 3, + // so the loopNav memo's equality option preserves the same nav buttons. + // Their retained callbacks must read the current option set, not the stale + // one captured when the memo last ran. + await poll(makePayload({ + loops: [ + makeLoop({ loop: { loopName: 'gamma', status: 'running', startedAt: 1700009500000, completedAt: null } }), + makeLoop({ loop: { loopName: 'alpha', status: 'running', startedAt: 1700005000000, completedAt: null } }), + makeLoop({ loop: { loopName: 'beta', status: 'running', startedAt: 1700000500000, completedAt: null } }), + ], + totals: { loops: 3, running: 3 }, + })) + + expect(container.querySelector('.loopnav-prev')).toBe(prevBefore) + expect(container.querySelector('.loopnav-next')).toBe(nextBefore) + + // Current index 2 is now beta (was gamma). A stale closure would open + // gamma, which is no longer the adjacent loop. + nextBefore!.click() + await flush() + expect(container.querySelector('.ldh-name')?.textContent).toBe('beta') + + // Return to alpha via external hash navigation, then click prev. Current + // index 0 is now gamma (was beta). A stale closure would open beta. + window.location.hash = '#p1/loop/alpha' + window.dispatchEvent(new Event('hashchange')) + await flush() + const prevAgain = container.querySelector('.loopnav-prev') as HTMLElement + expect(prevAgain).toBeTruthy() + prevAgain!.click() + await flush() + expect(container.querySelector('.ldh-name')?.textContent).toBe('gamma') + }) +}) + +describe('dashboard App bounded list caps', () => { + function makeCappedLoopsPayload(): any { + const loops: any[] = [] + for (let i = 0; i < MAX_RENDERED_LOOP_ROWS + 5; i++) { + loops.push(makeLoop({ + loop: { + loopName: 'loop-' + i, + status: 'running', + startedAt: 1700000000000 + i * 1000, + completedAt: null, + }, + })) + } + return makePayload({ + loops, + totals: { loops: MAX_RENDERED_LOOP_ROWS + 5, running: MAX_RENDERED_LOOP_ROWS + 5 }, + }) + } + + test('a project with more loops than the cap renders exactly the cap and a notice with the true total', async () => { + window.location.hash = '#p1' + payload = makeCappedLoopsPayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + const rows = container.querySelectorAll('tr.lt-row') + expect(rows.length).toBe(MAX_RENDERED_LOOP_ROWS) + const notice = container.querySelector('.list-cap-notice') as HTMLElement + expect(notice).toBeTruthy() + const text = notice.querySelector('.list-cap-text')?.textContent ?? '' + expect(text).toContain(String(MAX_RENDERED_LOOP_ROWS)) + expect(text).toContain(String(MAX_RENDERED_LOOP_ROWS + 5)) + expect(notice.querySelector('.list-cap-show-all')).toBeTruthy() + }) + + test('clicking Show all renders every loop row and removes the notice', async () => { + window.location.hash = '#p1' + payload = makeCappedLoopsPayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(container.querySelectorAll('tr.lt-row').length).toBe(MAX_RENDERED_LOOP_ROWS) + + const showAll = container.querySelector('.list-cap-show-all') as HTMLButtonElement + expect(showAll).toBeTruthy() + showAll.click() + await flush() + + expect(container.querySelectorAll('tr.lt-row').length).toBe(MAX_RENDERED_LOOP_ROWS + 5) + expect(container.querySelector('.list-cap-notice')).toBeNull() + }) + + test('the findings panel caps total finding rows across loops and reports the true total', async () => { + window.location.hash = '#p1/findings' + const loops: any[] = [] + // Two loops, with enough findings together to exceed the cap. + const perLoop = Math.ceil((MAX_RENDERED_FINDING_ROWS + 10) / 2) + const total = perLoop * 2 + for (let i = 0; i < 2; i++) { + const findings: any[] = [] + for (let j = 0; j < perLoop; j++) { + findings.push({ + projectId: 'p1', + file: 'a.ts', + line: 1, + severity: 'bug', + description: 'desc', + scenario: null, + loopName: 'loop-' + i, + sectionIndex: 0, + createdAt: 1700000000000 + j * 1000, + }) + } + loops.push(makeLoop({ loop: { loopName: 'loop-' + i, status: 'completed' }, findings })) + } + payload = makePayload({ loops }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(container.querySelectorAll('.finding').length).toBe(MAX_RENDERED_FINDING_ROWS) + expect(container.querySelector('.findings-panel-count')?.textContent).toBe(String(total)) + const notice = container.querySelector('.findings-panel .list-cap-notice') as HTMLElement + expect(notice).toBeTruthy() + expect(notice.querySelector('.list-cap-text')?.textContent ?? '').toContain(String(total)) + }) + + test('the loop picker caps rendered options and ArrowUp from the first option wraps to the last rendered one', async () => { + window.location.hash = '#p1/loop/loop-0' + const loops: any[] = [] + // loop with the largest startedAt sorts first; give loop-0 the largest so + // it lands at index 0 of the capped view (initial active = 0). + for (let i = 0; i < MAX_RENDERED_PICKER_OPTIONS + 20; i++) { + loops.push(makeLoop({ + loop: { + loopName: 'loop-' + i, + // Earlier i gets a later startedAt so 'loop-0' is most recent. + startedAt: 1700000000000 + (MAX_RENDERED_PICKER_OPTIONS + 20 - i) * 1000, + completedAt: null, + status: 'running', + }, + })) + } + payload = makePayload({ + loops, + totals: { loops: MAX_RENDERED_PICKER_OPTIONS + 20, running: MAX_RENDERED_PICKER_OPTIONS + 20 }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const input = container.querySelector('.breadcrumb-loop') as HTMLInputElement + expect(input.value).toBe('loop-0') + input.focus() + await flush() + + const menu = container.querySelector('.loop-picker-menu') as HTMLElement + const options = Array.from(menu.querySelectorAll('.loop-picker-option')) as HTMLElement[] + expect(options.length).toBe(MAX_RENDERED_PICKER_OPTIONS) + expect(menu.querySelector('.loop-picker-cap')).toBeTruthy() + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })) + await flush() + + const active = menu.querySelector('.loop-picker-option-active') as HTMLElement + expect(active).toBeTruthy() + // Wrap landed on the last rendered option. + expect(active).toBe(options[options.length - 1]) + }) + + test('picker option times render from the precomputed label', async () => { + window.location.hash = '#p1/loop/loop-0' + const ts = 1700001234000 + payload = makePayload({ + loops: [ + makeLoop({ loop: { loopName: 'loop-0', status: 'running', startedAt: ts, completedAt: null } }), + ], + totals: { loops: 1, running: 1 }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const input = container.querySelector('.breadcrumb-loop') as HTMLInputElement + input.focus() + await flush() + + const when = container.querySelector('.loop-picker-option-when') as HTMLElement + expect(when).toBeTruthy() + expect(when.textContent).toBe(fmtTime(ts)) }) }) \ No newline at end of file diff --git a/test/dashboard/app-helpers.test.ts b/test/dashboard/app-helpers.test.ts index c4aa7d6d8c..6e0b3cac21 100644 --- a/test/dashboard/app-helpers.test.ts +++ b/test/dashboard/app-helpers.test.ts @@ -25,6 +25,7 @@ import { computePhaseSpans, summarizePhaseTotals, computeTimelineEvents, + capList, } from '../../src/dashboard/app/helpers' import type { LoopTab } from '../../src/dashboard/app/helpers' import type { DashboardPayload, DashboardProject, DashboardLoop, LoopTransitionRow } from '../../src/dashboard/app/types' @@ -84,7 +85,9 @@ function mockDashLoop(overrides: Partial = {}): DashboardLoop { loop: mockLoopRow(), lastAuditResult: null, plan: null, + hasPlan: false, sections: [], + sectionCount: 0, findings: [], usage: null, duration: null, @@ -363,25 +366,21 @@ describe('buildRepoLabels', () => { describe('tabsForLoop', () => { test('plan loop with sections and a plan returns all six tabs in order', () => { const loop = mockDashLoop({ - plan: '# Plan', - sections: [ - { projectId: 'p1', loopName: 'l', sectionIndex: 0, title: 'S', content: '', status: 'pending', attempts: 0, summaryDone: null, summaryDeviations: null, summaryFollowUps: null, startedAt: null, completedAt: null, createdAt: 0 }, - ], + hasPlan: true, + sectionCount: 1, }) expect(tabsForLoop(loop)).toEqual(['overview', 'timeline', 'sections', 'findings', 'plan', 'usage']) }) test('goal loop with zero sections omits the sections tab', () => { - const loop = mockDashLoop({ plan: null, sections: [] }) + const loop = mockDashLoop({ hasPlan: false, sectionCount: 0 }) expect(tabsForLoop(loop)).toEqual(['overview', 'timeline', 'findings', 'usage']) }) - test('loop with plan: null omits the plan tab', () => { + test('loop with hasPlan: false omits the plan tab', () => { const loop = mockDashLoop({ - plan: null, - sections: [ - { projectId: 'p1', loopName: 'l', sectionIndex: 0, title: 'S', content: '', status: 'pending', attempts: 0, summaryDone: null, summaryDeviations: null, summaryFollowUps: null, startedAt: null, completedAt: null, createdAt: 0 }, - ], + hasPlan: false, + sectionCount: 1, }) const tabs = tabsForLoop(loop) expect(tabs).not.toContain('plan') @@ -389,7 +388,7 @@ describe('tabsForLoop', () => { }) test('tabsForLoop never returns an empty array', () => { - const loop = mockDashLoop({ plan: null, sections: [] }) + const loop = mockDashLoop({ hasPlan: false, sectionCount: 0 }) const tabs = tabsForLoop(loop) expect(tabs.length).toBeGreaterThan(0) expect(tabs[0]).toBe('overview') @@ -1040,6 +1039,36 @@ describe('roleUsageBars', () => { expect(exec.pct).toBeCloseTo(100) expect(audit.pct).toBeCloseTo(50) }) + + test('role bars sum input, output and reasoning tokens per role', () => { + const withTokens: NonNullable = { + ...usage, + byRole: { + code: { cost: 2, inputTokens: 4000, outputTokens: 2000, reasoningTokens: 400, cacheReadTokens: 80, cacheWriteTokens: 160, messageCount: 6 }, + auditor: { cost: 1, inputTokens: 1000, outputTokens: 500, reasoningTokens: 100, cacheReadTokens: 20, cacheWriteTokens: 40, messageCount: 2 }, + }, + } + const bars = roleUsageBars(withTokens) + const exec = bars.find(b => b.role === 'execution')! + const audit = bars.find(b => b.role === 'auditor')! + // tokens field sums input + output + reasoning only. + expect(exec.tokens).toBe(4000 + 2000 + 400) + expect(audit.tokens).toBe(1000 + 500 + 100) + }) + + test('role bars report zero tokens for an aggregate whose byRole lacks token fields', () => { + // Aggregate persisted before token fields were tracked — only cost and + // messageCount present on byRole entries. + const legacy: NonNullable = { + ...usage, + byRole: { + code: { cost: 2, messageCount: 6 } as any, + auditor: { cost: 1, messageCount: 2 } as any, + }, + } + const bars = roleUsageBars(legacy) + expect(bars.every(b => b.tokens === 0)).toBe(true) + }) }) // --------------------------------------------------------------------------- @@ -1708,3 +1737,41 @@ describe('computeTimelineEvents', () => { expect(computeTimelineEvents([], 1700000000000, false)).toEqual([]) }) }) + +// --------------------------------------------------------------------------- +// capList +// --------------------------------------------------------------------------- + +describe('capList', () => { + test('returns the input array by reference when under the cap', () => { + const items = [1, 2, 3] + const out = capList(items, 10, false) + expect(out.rows).toBe(items) + expect(out.total).toBe(3) + expect(out.capped).toBe(false) + }) + + test('slices to the cap and reports the true total when over', () => { + const items = Array.from({ length: 25 }, (_, i) => i) + const out = capList(items, 10, false) + expect(out.rows).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + expect(out.total).toBe(25) + expect(out.capped).toBe(true) + }) + + test('showAll bypasses the cap and reports capped false', () => { + const items = Array.from({ length: 25 }, (_, i) => i) + const out = capList(items, 10, true) + expect(out.rows).toBe(items) + expect(out.total).toBe(25) + expect(out.capped).toBe(false) + }) + + test('an exactly-at-cap list is not reported as capped', () => { + const items = Array.from({ length: 10 }, (_, i) => i) + const out = capList(items, 10, false) + expect(out.rows).toBe(items) + expect(out.total).toBe(10) + expect(out.capped).toBe(false) + }) +}) diff --git a/test/dashboard/data.test.ts b/test/dashboard/data.test.ts index a16b960914..b40754ca3c 100644 --- a/test/dashboard/data.test.ts +++ b/test/dashboard/data.test.ts @@ -1,12 +1,14 @@ import { describe, test, expect, beforeEach, afterEach } from 'vitest' import { Database } from 'bun:sqlite' import { openForgeDatabase, closeDatabase } from '../../src/storage/database' -import { collectDashboardData, type DashboardPayload } from '../../src/dashboard/data' +import { collectDashboardData, type DashboardPayload, type DashboardScope } from '../../src/dashboard/data' import { createLoopsRepo, type LoopRow } from '../../src/storage' import { createPlansRepo } from '../../src/storage' import { createReviewFindingsRepo } from '../../src/storage' import { createSectionPlansRepo } from '../../src/storage' import { createLoopSessionUsageRepo, type LoopSessionUsageRow } from '../../src/storage' +import { createLoopTransitionsRepo } from '../../src/storage' +import { createPlanAmendmentsRepo } from '../../src/storage' import { createFeatureGroupsRepo } from '../../src/storage' function makeLoopRow(overrides?: Partial): LoopRow { @@ -141,7 +143,7 @@ describe('collectDashboardData', () => { capturedAt: Date.now(), }) - const payload = collectDashboardData(db!) + const payload = collectDashboardData(db!, { projectId, loopName }) expect(payload.projects).toHaveLength(1) expect(payload.projects[0].projectId).toBe(projectId) @@ -157,7 +159,15 @@ describe('collectDashboardData', () => { expect(dashLoop.findings).toHaveLength(1) expect(dashLoop.usage).not.toBeNull() expect(dashLoop.usage!.totalCost).toBe(0.005) - expect(dashLoop.usage!.byRole.code).toEqual({ cost: 0.005, messageCount: 10 }) + expect(dashLoop.usage!.byRole.code).toEqual({ + cost: 0.005, + inputTokens: 2000, + outputTokens: 1000, + reasoningTokens: 200, + cacheReadTokens: 300, + cacheWriteTokens: 400, + messageCount: 10, + }) expect(dashLoop.usage!.byRole.auditor).toBeUndefined() expect(payload.projects).toHaveLength(1) @@ -191,7 +201,7 @@ describe('collectDashboardData', () => { makeLoopRow({ projectId: 'p1', loopName: 'goal-loop', kind: 'goal' }), { lastAuditResult: null, goal: 'Ship the tabs refactor' }, ) - const payload = collectDashboardData(db!) + const payload = collectDashboardData(db!, { projectId: 'p1', loopName: 'goal-loop' }) expect(payload.projects[0].loops[0].goal).toBe('Ship the tabs refactor') }) @@ -201,7 +211,7 @@ describe('collectDashboardData', () => { makeLoopRow({ projectId: 'p1', loopName: 'no-goal' }), { lastAuditResult: null, goal: null }, ) - const payload = collectDashboardData(db!) + const payload = collectDashboardData(db!, { projectId: 'p1', loopName: 'no-goal' }) expect(payload.projects[0].loops[0].goal).toBeNull() }) @@ -519,4 +529,287 @@ describe('collectDashboardData', () => { expect(p2.groups).toHaveLength(1) expect(p2.groups[0].group.groupId).toBe('g-other') }) + + // ─── Cycle 9: query-scoped payload (Phase 1) ────────────────────────── + + test('unscoped payload reports hasPlan but omits plan content', () => { + const loopsRepo = createLoopsRepo(db!) + const plansRepo = createPlansRepo(db!) + loopsRepo.insert( + makeLoopRow({ projectId: 'p1', loopName: 'loop-a' }), + { lastAuditResult: null }, + ) + plansRepo.writeForLoop('p1', 'loop-a', 'plan-content-1') + + const payload = collectDashboardData(db!) + const dl = payload.projects[0].loops[0] + expect(dl.plan).toBeNull() + expect(dl.hasPlan).toBe(true) + }) + + test('scoping to a loop returns that loop\'s plan content', () => { + const loopsRepo = createLoopsRepo(db!) + const plansRepo = createPlansRepo(db!) + loopsRepo.insert( + makeLoopRow({ projectId: 'p1', loopName: 'loop-a' }), + { lastAuditResult: null }, + ) + plansRepo.writeForLoop('p1', 'loop-a', 'plan-content-1') + + const payload = collectDashboardData(db!, { projectId: 'p1', loopName: 'loop-a' }) + const dl = payload.projects[0].loops[0] + expect(dl.plan).toBe('plan-content-1') + expect(dl.hasPlan).toBe(true) + }) + + test('a sibling loop in the scoped project reports hasPlan without content', () => { + const loopsRepo = createLoopsRepo(db!) + const plansRepo = createPlansRepo(db!) + loopsRepo.insert( + makeLoopRow({ projectId: 'p1', loopName: 'loop-a' }), + { lastAuditResult: null }, + ) + loopsRepo.insert( + makeLoopRow({ projectId: 'p1', loopName: 'loop-b', currentSessionId: 'session-b' }), + { lastAuditResult: null }, + ) + plansRepo.writeForLoop('p1', 'loop-a', 'plan-content-a') + plansRepo.writeForLoop('p1', 'loop-b', 'plan-content-b') + + const scope: DashboardScope = { projectId: 'p1', loopName: 'loop-a' } + const payload = collectDashboardData(db!, scope) + const loops = payload.projects[0].loops + const a = loops.find(l => l.loop.loopName === 'loop-a')! + const b = loops.find(l => l.loop.loopName === 'loop-b')! + expect(a.plan).toBe('plan-content-a') + expect(a.hasPlan).toBe(true) + expect(b.plan).toBeNull() + expect(b.hasPlan).toBe(true) + }) + + test('sectionCount is populated while sections is empty when unscoped', () => { + const loopsRepo = createLoopsRepo(db!) + const sectionsRepo = createSectionPlansRepo(db!) + loopsRepo.insert( + makeLoopRow({ projectId: 'p1', loopName: 'loop-a' }), + { lastAuditResult: null }, + ) + sectionsRepo.bulkInsert({ + projectId: 'p1', + loopName: 'loop-a', + sections: [ + { index: 0, title: 'Section A', content: 'Content A' }, + { index: 1, title: 'Section B', content: 'Content B' }, + ], + }) + + const unscoped = collectDashboardData(db!) + const unscopedDl = unscoped.projects[0].loops[0] + expect(unscopedDl.sections).toHaveLength(0) + expect(unscopedDl.sectionCount).toBe(2) + + const scoped = collectDashboardData(db!, { projectId: 'p1', loopName: 'loop-a' }) + const scopedDl = scoped.projects[0].loops[0] + expect(scopedDl.sections).toHaveLength(2) + expect(scopedDl.sectionCount).toBe(2) + expect(scopedDl.sections.map(s => s.title)).toEqual(['Section A', 'Section B']) + }) + + test('goal, lastAuditResult and postActionReport are null unless the loop is scoped', () => { + const loopsRepo = createLoopsRepo(db!) + loopsRepo.insert( + makeLoopRow({ projectId: 'p1', loopName: 'loop-a', kind: 'goal' }), + { lastAuditResult: 'audit-x', postActionReport: 'report-x', goal: 'goal-x' }, + ) + + const unscoped = collectDashboardData(db!) + const unscopedDl = unscoped.projects[0].loops[0] + expect(unscopedDl.goal).toBeNull() + expect(unscopedDl.lastAuditResult).toBeNull() + expect(unscopedDl.postActionReport).toBeNull() + + const scoped = collectDashboardData(db!, { projectId: 'p1', loopName: 'loop-a' }) + const scopedDl = scoped.projects[0].loops[0] + expect(scopedDl.goal).toBe('goal-x') + expect(scopedDl.lastAuditResult).toBe('audit-x') + expect(scopedDl.postActionReport).toBe('report-x') + }) + + test('completionSummary is null unless the loop is scoped', () => { + const loopsRepo = createLoopsRepo(db!) + loopsRepo.insert( + makeLoopRow({ + projectId: 'p1', + loopName: 'loop-a', + status: 'completed', + completedAt: 1700000500000, + completionSummary: 'COMPLETION BODY', + }), + { lastAuditResult: null }, + ) + loopsRepo.insert( + makeLoopRow({ + projectId: 'p1', + loopName: 'loop-b', + currentSessionId: 'session-b', + status: 'completed', + completedAt: 1700000500000, + completionSummary: 'SIBLING BODY', + }), + { lastAuditResult: null }, + ) + + const unscoped = collectDashboardData(db!) + expect(unscoped.projects[0].loops[0].loop.completionSummary).toBeNull() + expect(unscoped.projects[0].loops[1].loop.completionSummary).toBeNull() + + const scopedSibling = collectDashboardData(db!, { projectId: 'p1', loopName: 'loop-b' }) + const scopedSiblingLoops = scopedSibling.projects[0].loops + const siblingB = scopedSiblingLoops.find(dl => dl.id === 'loop-b')! + const siblingA = scopedSiblingLoops.find(dl => dl.id === 'loop-a')! + expect(siblingB.loop.completionSummary).toBe('SIBLING BODY') + expect(siblingA.loop.completionSummary).toBeNull() + + const scoped = collectDashboardData(db!, { projectId: 'p1', loopName: 'loop-a' }) + const scopedA = scoped.projects[0].loops.find(dl => dl.id === 'loop-a')! + expect(scopedA.loop.completionSummary).toBe('COMPLETION BODY') + }) + + test('amendments ship only for the scoped loop', () => { + const loopsRepo = createLoopsRepo(db!) + const amendmentsRepo = createPlanAmendmentsRepo(db!) + loopsRepo.insert( + makeLoopRow({ projectId: 'p1', loopName: 'loop-a' }), + { lastAuditResult: null }, + ) + amendmentsRepo.insert({ + projectId: 'p1', + loopName: 'loop-a', + source: 'auditor', + rationale: 'trim sections', + appliedAtSection: 1, + sectionsBefore: JSON.stringify([{ index: 1, title: 'Old', content: 'c-old' }]), + sectionsAfter: JSON.stringify([{ index: 1, title: 'New', content: 'c-new' }]), + }) + + const unscoped = collectDashboardData(db!) + expect(unscoped.projects[0].loops[0].amendments).toEqual([]) + + const scoped = collectDashboardData(db!, { projectId: 'p1', loopName: 'loop-a' }) + const scopedDl = scoped.projects[0].loops[0] + expect(scopedDl.amendments).toHaveLength(1) + expect(scopedDl.amendments[0].rationale).toBe('trim sections') + expect(scopedDl.amendments[0].sectionsBefore).toBe(JSON.stringify([{ index: 1, title: 'Old' }])) + expect(scopedDl.amendments[0].sectionsAfter).toBe(JSON.stringify([{ index: 1, title: 'New' }])) + }) + + test('transitions ship for every loop in the scoped project and none outside it', () => { + const loopsRepo = createLoopsRepo(db!) + const transitionsRepo = createLoopTransitionsRepo(db!) + loopsRepo.insert( + makeLoopRow({ projectId: 'p1', loopName: 'loop-a' }), + { lastAuditResult: null }, + ) + loopsRepo.insert( + makeLoopRow({ projectId: 'p1', loopName: 'loop-c', currentSessionId: 'session-c' }), + { lastAuditResult: null }, + ) + loopsRepo.insert( + makeLoopRow({ projectId: 'p2', loopName: 'loop-b', currentSessionId: 'session-b' }), + { lastAuditResult: null }, + ) + for (const [pid, ln] of [['p1', 'loop-a'], ['p1', 'loop-c'], ['p2', 'loop-b']] as const) { + transitionsRepo.insert({ + projectId: pid, + loopName: ln, + eventType: 'phase-change', + transitionKind: 'next', + fromPhase: 'coding', + toPhase: 'audit', + status: null, + reason: 'done', + iteration: 1, + sectionIndex: null, + }) + } + + const unscoped = collectDashboardData(db!) + expect(unscoped.projects.find(p => p.projectId === 'p1')!.loops.find(l => l.id === 'loop-a')!.transitions).toEqual([]) + expect(unscoped.projects.find(p => p.projectId === 'p1')!.loops.find(l => l.id === 'loop-c')!.transitions).toEqual([]) + expect(unscoped.projects.find(p => p.projectId === 'p2')!.loops[0].transitions).toEqual([]) + + const scoped = collectDashboardData(db!, { projectId: 'p1', loopName: 'loop-a' }) + const p1 = scoped.projects.find(p => p.projectId === 'p1')! + expect(p1.loops.find(l => l.id === 'loop-a')!.transitions).toHaveLength(1) + expect(p1.loops.find(l => l.id === 'loop-c')!.transitions).toHaveLength(1) + expect(scoped.projects.find(p => p.projectId === 'p2')!.loops[0].transitions).toEqual([]) + }) + + test('findings and usage are populated regardless of scope', () => { + const loopsRepo = createLoopsRepo(db!) + const findingsRepo = createReviewFindingsRepo(db!) + const usageRepo = createLoopSessionUsageRepo(db!) + loopsRepo.insert( + makeLoopRow({ projectId: 'p1', loopName: 'loop-a' }), + { lastAuditResult: null }, + ) + findingsRepo.write({ + projectId: 'p1', + loopName: 'loop-a', + file: 'src/main.ts', + line: 10, + severity: 'warning', + description: 'w', + }) + usageRepo.upsertSessionUsage({ + projectId: 'p1', + loopName: 'loop-a', + sessionId: 'session-1', + role: 'code', + model: 'claude-sonnet-4-20250514', + cost: 0.01, + inputTokens: 100, + outputTokens: 50, + reasoningTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + messageCount: 3, + capturedAt: Date.now(), + }) + + const unscoped = collectDashboardData(db!) + const unscopedDl = unscoped.projects[0].loops[0] + expect(unscopedDl.findings).toHaveLength(1) + expect(unscopedDl.usage).not.toBeNull() + expect(unscopedDl.usage!.totalCost).toBe(0.01) + + const scoped = collectDashboardData(db!, { projectId: 'p1', loopName: 'loop-a' }) + const scopedDl = scoped.projects[0].loops[0] + expect(scopedDl.findings).toHaveLength(1) + expect(scopedDl.usage).not.toBeNull() + expect(scopedDl.usage!.totalCost).toBe(0.01) + }) + + test('a scope naming a project that does not exist behaves like no scope', () => { + const loopsRepo = createLoopsRepo(db!) + const plansRepo = createPlansRepo(db!) + loopsRepo.insert( + makeLoopRow({ projectId: 'p1', loopName: 'loop-a' }), + { lastAuditResult: 'audit-x', postActionReport: 'report-x', goal: 'goal-x' }, + ) + plansRepo.writeForLoop('p1', 'loop-a', 'plan-content-a') + + const scope: DashboardScope = { projectId: 'nope', loopName: 'nope' } + const payload = collectDashboardData(db!, scope) + expect(payload.projects).toHaveLength(1) + const dl = payload.projects[0].loops[0] + expect(dl.plan).toBeNull() + expect(dl.hasPlan).toBe(true) + expect(dl.goal).toBeNull() + expect(dl.lastAuditResult).toBeNull() + expect(dl.postActionReport).toBeNull() + expect(dl.sections).toEqual([]) + expect(dl.transitions).toEqual([]) + expect(dl.amendments).toEqual([]) + }) }) diff --git a/test/dashboard/server.test.ts b/test/dashboard/server.test.ts index 98bbed797c..c27430297d 100644 --- a/test/dashboard/server.test.ts +++ b/test/dashboard/server.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect, beforeEach, afterEach } from 'vitest' import { Database } from 'bun:sqlite' import { openForgeDatabase, closeDatabase } from '../../src/storage/database' import { createRequestHandler, type DashboardDeps } from '../../src/dashboard/server' -import { createLoopsRepo, createLoopTransitionsRepo, createPlanAmendmentsRepo, createFeatureGroupsRepo, type LoopRow, type LoopTransitionRow, type PlanAmendmentRow } from '../../src/storage' +import { createLoopsRepo, createLoopTransitionsRepo, createPlanAmendmentsRepo, createPlansRepo, createFeatureGroupsRepo, type LoopRow, type LoopTransitionRow, type PlanAmendmentRow } from '../../src/storage' function makeLoopRow(overrides?: Partial): LoopRow { return { @@ -175,7 +175,7 @@ describe('createRequestHandler', () => { } transitionsRepo.insert(transitionInput) - const res = await handler(new Request('http://localhost/api/data')) + const res = await handler(new Request('http://localhost/api/data?project=p1&loop=transitioned-loop')) const body = await res.json() expect(body.projects).toHaveLength(1) expect(body.projects[0].loops).toHaveLength(1) @@ -224,7 +224,7 @@ describe('createRequestHandler', () => { }) } - const res = await handler(new Request('http://localhost/api/data')) + const res = await handler(new Request('http://localhost/api/data?project=p1&loop=capped-loop')) const body = await res.json() const transitions = body.projects[0].loops[0].transitions expect(transitions).toHaveLength(100) @@ -266,7 +266,7 @@ describe('createRequestHandler', () => { ]), }) - const res = await handler(new Request('http://localhost/api/data')) + const res = await handler(new Request('http://localhost/api/data?project=p1&loop=amended-loop')) const body = await res.json() expect(body.projects).toHaveLength(1) expect(body.projects[0].loops).toHaveLength(1) @@ -302,7 +302,7 @@ describe('createRequestHandler', () => { { lastAuditResult: null }, ) - const res = await handler(new Request('http://localhost/api/data')) + const res = await handler(new Request('http://localhost/api/data?project=p1&loop=no-amendments-loop')) const body = await res.json() expect(body.projects).toHaveLength(1) expect(body.projects[0].loops).toHaveLength(1) @@ -351,4 +351,75 @@ describe('createRequestHandler', () => { expect(g.features).toHaveLength(2) expect(g.features.map((f: { featureIndex: number }) => f.featureIndex)).toEqual([0, 1]) }) + + // ─── Cycle 9: scoped /api/data query string ─────────────────────────── + + test('GET /api/data?project=p1&loop=loop-a returns plan content only for that loop', async () => { + const handler = createRequestHandler(makeDeps(db!)) + + const loopsRepo = createLoopsRepo(db!) + const plansRepo = createPlansRepo(db!) + loopsRepo.insert( + makeLoopRow({ projectId: 'p1', loopName: 'loop-a' }), + { lastAuditResult: null }, + ) + loopsRepo.insert( + makeLoopRow({ projectId: 'p1', loopName: 'loop-b', currentSessionId: 'session-b' }), + { lastAuditResult: null }, + ) + plansRepo.writeForLoop('p1', 'loop-a', 'plan-content-a') + plansRepo.writeForLoop('p1', 'loop-b', 'plan-content-b') + + const res = await handler(new Request('http://localhost/api/data?project=p1&loop=loop-a')) + const body = await res.json() + expect(body.projects).toHaveLength(1) + const loops = body.projects[0].loops + const a = loops.find((l: { loop: { loopName: string } }) => l.loop.loopName === 'loop-a') + const b = loops.find((l: { loop: { loopName: string } }) => l.loop.loopName === 'loop-b') + expect(a.plan).toBe('plan-content-a') + expect(a.hasPlan).toBe(true) + expect(b.plan).toBeNull() + expect(b.hasPlan).toBe(true) + }) + + test('GET /api/data scopes completionSummary to the requested loop', async () => { + const handler = createRequestHandler(makeDeps(db!)) + + const loopsRepo = createLoopsRepo(db!) + loopsRepo.insert( + makeLoopRow({ + projectId: 'p1', + loopName: 'loop-a', + status: 'completed', + completedAt: 1700000500000, + completionSummary: 'COMPLETION A', + }), + { lastAuditResult: null }, + ) + loopsRepo.insert( + makeLoopRow({ + projectId: 'p1', + loopName: 'loop-b', + currentSessionId: 'session-b', + status: 'completed', + completedAt: 1700000500000, + completionSummary: 'COMPLETION B', + }), + { lastAuditResult: null }, + ) + + const unscoped = await handler(new Request('http://localhost/api/data')) + const unscopedBody = await unscoped.json() + const unscopedLoops = unscopedBody.projects[0].loops + expect(unscopedLoops[0].loop.completionSummary).toBeNull() + expect(unscopedLoops[1].loop.completionSummary).toBeNull() + + const scoped = await handler(new Request('http://localhost/api/data?project=p1&loop=loop-a')) + const scopedBody = await scoped.json() + const scopedLoops = scopedBody.projects[0].loops + const a = scopedLoops.find((l: { loop: { loopName: string } }) => l.loop.loopName === 'loop-a') + const b = scopedLoops.find((l: { loop: { loopName: string } }) => l.loop.loopName === 'loop-b') + expect(a.loop.completionSummary).toBe('COMPLETION A') + expect(b.loop.completionSummary).toBeNull() + }) }) diff --git a/test/loop-format.test.ts b/test/loop-format.test.ts index 1c55a9c76e..68756e3218 100644 --- a/test/loop-format.test.ts +++ b/test/loop-format.test.ts @@ -1,7 +1,8 @@ import { describe, test, expect } from 'vitest' -import { formatTokens, formatSessionOutput, formatAuditResult, formatUsageSummary } from '../src/utils/loop-format' +import { formatTokens, formatSessionOutput, formatAuditResult, formatUsageSummary, formatCumulativeUsage, aggregateToUsageSummary } from '../src/utils/loop-format' import type { LoopSessionOutput } from '../src/loop/session-output' import type { LoopUsageSummary } from '../src/loop/token-usage' +import type { LoopUsageAggregate } from '../src/storage/repos/loop-session-usage-repo' describe('formatTokens', () => { test('numbers less than 1000 returned as string', () => { @@ -338,3 +339,130 @@ describe('formatUsageSummary', () => { expect(lines[0]).toContain('0 in') }) }) + +describe('aggregateToUsageSummary', () => { + function makeAggregate(overrides: Partial = {}): LoopUsageAggregate { + return { + loopName: 'loop', + totalCost: 0.05, + totalInputTokens: 5000, + totalOutputTokens: 2500, + totalReasoningTokens: 500, + totalCacheReadTokens: 100, + totalCacheWriteTokens: 200, + totalMessageCount: 10, + byModel: { + 'model-a': { cost: 0.03, inputTokens: 3000, outputTokens: 1500, reasoningTokens: 300, cacheReadTokens: 60, cacheWriteTokens: 120, messageCount: 6 }, + 'model-b': { cost: 0.02, inputTokens: 2000, outputTokens: 1000, reasoningTokens: 200, cacheReadTokens: 40, cacheWriteTokens: 80, messageCount: 4 }, + }, + byRole: { + code: { cost: 0.04, inputTokens: 4000, outputTokens: 2000, reasoningTokens: 400, cacheReadTokens: 80, cacheWriteTokens: 160, messageCount: 8 }, + auditor: { cost: 0.01, inputTokens: 1000, outputTokens: 500, reasoningTokens: 100, cacheReadTokens: 20, cacheWriteTokens: 40, messageCount: 2 }, + }, + ...overrides, + } + } + + test('derives perRole from byRole in code, auditor, unknown order', () => { + const agg = makeAggregate({ + byRole: { + unknown: { cost: 0.005, inputTokens: 500, outputTokens: 250, reasoningTokens: 50, cacheReadTokens: 10, cacheWriteTokens: 20, messageCount: 1 }, + auditor: { cost: 0.01, inputTokens: 1000, outputTokens: 500, reasoningTokens: 100, cacheReadTokens: 20, cacheWriteTokens: 40, messageCount: 2 }, + code: { cost: 0.04, inputTokens: 4000, outputTokens: 2000, reasoningTokens: 400, cacheReadTokens: 80, cacheWriteTokens: 160, messageCount: 8 }, + }, + }) + + const summary = aggregateToUsageSummary(agg) + expect(summary.perRole?.map(r => r.role)).toEqual(['code', 'auditor', 'unknown']) + const code = summary.perRole?.find(r => r.role === 'code')! + expect(code.cost).toBe(0.04) + expect(code.messageCount).toBe(8) + expect(code.tokens).toEqual({ input: 4000, output: 2000, reasoning: 400, cacheRead: 80, cacheWrite: 160 }) + }) + + test('omits absent roles from perRole', () => { + const summary = aggregateToUsageSummary(makeAggregate()) + expect(summary.perRole?.map(r => r.role)).toEqual(['code', 'auditor']) + }) +}) + +describe('formatUsageSummary per-role block', () => { + test('appends a Per-role usage block after the per-model rows', () => { + const summary: LoopUsageSummary = { + totalCost: 0.05, + totalTokens: { input: 5000, output: 2500, reasoning: 500, cacheRead: 100, cacheWrite: 200 }, + perModel: [ + { model: 'model-a', cost: 0.03, tokens: { input: 3000, output: 1500, reasoning: 300, cacheRead: 60, cacheWrite: 120 }, messageCount: 6 }, + { model: 'model-b', cost: 0.02, tokens: { input: 2000, output: 1000, reasoning: 200, cacheRead: 40, cacheWrite: 80 }, messageCount: 4 }, + ], + perRole: [ + { role: 'code', cost: 0.04, tokens: { input: 4000, output: 2000, reasoning: 400, cacheRead: 80, cacheWrite: 160 }, messageCount: 8 }, + { role: 'auditor', cost: 0.01, tokens: { input: 1000, output: 500, reasoning: 100, cacheRead: 20, cacheWrite: 40 }, messageCount: 2 }, + ], + } + + const lines = formatUsageSummary(summary) + const perModelIndex = lines.indexOf('Per-model usage:') + const perRoleIndex = lines.indexOf('Per-role usage:') + expect(perRoleIndex).toBeGreaterThan(perModelIndex) + const roleRows = lines.slice(perRoleIndex + 1) + expect(roleRows[0]).toContain('code:') + expect(roleRows[0]).toContain('$0.0400') + expect(roleRows[1]).toContain('auditor:') + expect(roleRows[1]).toContain('$0.0100') + // Per-model rows must remain exactly the two model rows immediately after + // 'Per-model usage:'. + const modelRows = lines.slice(perModelIndex + 1, perRoleIndex) + expect(modelRows).toHaveLength(2) + expect(modelRows[0]).toContain('model-a:') + expect(modelRows[1]).toContain('model-b:') + }) + + test('omits the role block when perRole is empty', () => { + const summary: LoopUsageSummary = { + totalCost: 0.01, + totalTokens: { input: 100, output: 50, reasoning: 10, cacheRead: 5, cacheWrite: 2 }, + perModel: [], + perRole: [], + } + expect(formatUsageSummary(summary).some(l => l.includes('Per-role usage:'))).toBe(false) + }) + + test('omits the role block when perRole is absent', () => { + const summary: LoopUsageSummary = { + totalCost: 0.01, + totalTokens: { input: 100, output: 50, reasoning: 10, cacheRead: 5, cacheWrite: 2 }, + perModel: [], + } + expect(formatUsageSummary(summary).some(l => l.includes('Per-role usage:'))).toBe(false) + }) +}) + +describe('formatCumulativeUsage', () => { + test('returns [] when there is no usage', () => { + expect(formatCumulativeUsage(undefined, 'p', 'loop', 'session', null)).toEqual([]) + }) + + test('returns a leading blank line plus heading when there is usage', () => { + const repo = { + getAggregate: () => ({ + loopName: 'loop', + totalCost: 0.05, + totalInputTokens: 5000, + totalOutputTokens: 2500, + totalReasoningTokens: 500, + totalCacheReadTokens: 100, + totalCacheWriteTokens: 200, + totalMessageCount: 10, + byModel: { 'model-a': { cost: 0.05, inputTokens: 5000, outputTokens: 2500, reasoningTokens: 500, cacheReadTokens: 100, cacheWriteTokens: 200, messageCount: 10 } }, + byRole: { code: { cost: 0.05, inputTokens: 5000, outputTokens: 2500, reasoningTokens: 500, cacheReadTokens: 100, cacheWriteTokens: 200, messageCount: 10 } }, + }), + hasSession: () => true, + } as any + const lines = formatCumulativeUsage(repo, 'p', 'loop', 'session', null) + expect(lines[0]).toBe('') + expect(lines[1]).toBe('Cumulative Usage:') + expect(lines.join('\n')).toContain('Total Cost: $0.0500') + expect(lines.join('\n')).toContain('Per-role usage:') + }) +}) diff --git a/test/loop-session-usage-repo.test.ts b/test/loop-session-usage-repo.test.ts index 83241b174d..c92c763c2a 100644 --- a/test/loop-session-usage-repo.test.ts +++ b/test/loop-session-usage-repo.test.ts @@ -276,9 +276,112 @@ describe('LoopSessionUsageRepo', () => { repo.upsertSessionUsage(row3) const agg = repo.getAggregate(projectId, loopName) - expect(agg?.byRole.code).toEqual({ cost: 2, messageCount: 6 }) - expect(agg?.byRole.auditor).toEqual({ cost: 1, messageCount: 2 }) - expect(agg?.byRole.unknown).toEqual({ cost: 0.5, messageCount: 1 }) + // Each role gets the default token breakdown from createUsageRow, plus the + // role-specific cost and messageCount overrides. + expect(agg?.byRole.code).toEqual({ + cost: 2, + inputTokens: 1000, + outputTokens: 500, + reasoningTokens: 100, + cacheReadTokens: 200, + cacheWriteTokens: 300, + messageCount: 6, + }) + expect(agg?.byRole.auditor).toEqual({ + cost: 1, + inputTokens: 1000, + outputTokens: 500, + reasoningTokens: 100, + cacheReadTokens: 200, + cacheWriteTokens: 300, + messageCount: 2, + }) + expect(agg?.byRole.unknown).toEqual({ + cost: 0.5, + inputTokens: 1000, + outputTokens: 500, + reasoningTokens: 100, + cacheReadTokens: 200, + cacheWriteTokens: 300, + messageCount: 1, + }) + }) + + test('byRole carries the full token breakdown per role', () => { + repo.upsertSessionUsage([ + createUsageRow({ + sessionId: 'session-code', + model: 'model-a', + role: 'code', + cost: 0.10, + inputTokens: 4000, + outputTokens: 2000, + reasoningTokens: 400, + cacheReadTokens: 150, + cacheWriteTokens: 250, + messageCount: 7, + }), + createUsageRow({ + sessionId: 'session-auditor', + model: 'model-b', + role: 'auditor', + cost: 0.05, + inputTokens: 8000, + outputTokens: 1000, + reasoningTokens: 600, + cacheReadTokens: 300, + cacheWriteTokens: 50, + messageCount: 3, + }), + ]) + + const agg = repo.getAggregate(projectId, loopName) + expect(agg?.byRole.code?.inputTokens).toBe(4000) + expect(agg?.byRole.code?.outputTokens).toBe(2000) + expect(agg?.byRole.code?.reasoningTokens).toBe(400) + expect(agg?.byRole.code?.cacheReadTokens).toBe(150) + expect(agg?.byRole.code?.cacheWriteTokens).toBe(250) + expect(agg?.byRole.auditor?.inputTokens).toBe(8000) + expect(agg?.byRole.auditor?.outputTokens).toBe(1000) + expect(agg?.byRole.auditor?.reasoningTokens).toBe(600) + expect(agg?.byRole.auditor?.cacheReadTokens).toBe(300) + expect(agg?.byRole.auditor?.cacheWriteTokens).toBe(50) + }) + + test('byRole token sums reconcile with the aggregate totals', () => { + repo.upsertSessionUsage([ + createUsageRow({ + sessionId: 'session-code', + model: 'model-a', + role: 'code', + inputTokens: 4000, + outputTokens: 2000, + reasoningTokens: 400, + cacheReadTokens: 150, + cacheWriteTokens: 250, + messageCount: 7, + }), + createUsageRow({ + sessionId: 'session-auditor', + model: 'model-b', + role: 'auditor', + inputTokens: 8000, + outputTokens: 1000, + reasoningTokens: 600, + cacheReadTokens: 300, + cacheWriteTokens: 50, + messageCount: 3, + }), + ]) + + const agg = repo.getAggregate(projectId, loopName) + const byRole = Object.values(agg!.byRole) + expect(byRole.reduce((s, r) => s + r.inputTokens, 0)).toBe(agg!.totalInputTokens) + expect(byRole.reduce((s, r) => s + r.outputTokens, 0)).toBe(agg!.totalOutputTokens) + expect(byRole.reduce((s, r) => s + r.reasoningTokens, 0)).toBe(agg!.totalReasoningTokens) + expect(byRole.reduce((s, r) => s + r.cacheReadTokens, 0)).toBe(agg!.totalCacheReadTokens) + expect(byRole.reduce((s, r) => s + r.cacheWriteTokens, 0)).toBe(agg!.totalCacheWriteTokens) + expect(byRole.reduce((s, r) => s + r.messageCount, 0)).toBe(agg!.totalMessageCount) }) }) diff --git a/test/loop-status-tool.test.ts b/test/loop-status-tool.test.ts index 33b2e1e636..12ccfbef9f 100644 --- a/test/loop-status-tool.test.ts +++ b/test/loop-status-tool.test.ts @@ -1282,6 +1282,87 @@ describe('loop-status cumulative usage', () => { expect(result).toContain('anthropic/claude-3-5-sonnet') }) + test('per-role totals appear in cumulative usage for an inactive loop', async () => { + const { client: forgeClient } = createFakeForgeClient() + const logger = createLogger({ enabled: false, file: '' }) + + const loopsRepo = createLoopsRepo(db) + const plansRepo = createPlansRepo(db) + const reviewFindingsRepo = createReviewFindingsRepo(db) + const loopSessionUsageRepo = createLoopSessionUsageRepo(db) + const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, logger) + + const worktreeDir = `${TEST_DIR}/worktree-usage-perrole-inactive` + mkdirSync(worktreeDir, { recursive: true }) + + loopService.setState(loopName, { + active: false, + sessionId: 'session-perrole', + loopName, + worktreeDir, + projectDir: TEST_DIR, + worktreeBranch: 'opencode/loop-test-perrole-inactive', + iteration: 2, + maxIterations: 5, + startedAt: new Date().toISOString(), + prompt: 'Test prompt per role inactive', + phase: 'coding', + errorCount: 0, + auditCount: 1, + status: 'completed', + worktree: true, + sandbox: false, + executionModel: 'test-model', + auditorModel: 'test-auditor', + workspaceId: 'ws-perrole-inactive', + hostSessionId: 'host-perrole-inactive', + currentSectionIndex: 0, + totalSections: 0, + finalAuditDone: false, + terminationReason: 'completed', + completedAt: new Date().toISOString(), + } as any) + + loopSessionUsageRepo.upsertSessionUsage([ + { + projectId, loopName, sessionId: 'session-perrole', role: 'code', model: 'anthropic/claude-3-5-sonnet', + cost: 0.0525, inputTokens: 5000, outputTokens: 2500, reasoningTokens: 500, + cacheReadTokens: 100, cacheWriteTokens: 200, messageCount: 10, capturedAt: Date.now(), + }, + { + projectId, loopName, sessionId: 'session-perrole', role: 'auditor', model: 'anthropic/claude-3-opus', + cost: 0.0275, inputTokens: 3000, outputTokens: 1500, reasoningTokens: 300, + cacheReadTokens: 60, cacheWriteTokens: 120, messageCount: 4, capturedAt: Date.now(), + }, + ]) + + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath, {}, undefined, undefined, undefined, loopSessionUsageRepo) + const tools = createLoopTools({ + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), + directory: TEST_DIR, + config: {}, + loopService, + loopHandler, + logger, + plansRepo, + loopsRepo, + projectId, + dataDir: dbPath, + loop: loopHandler.loop, + loopSessionUsageRepo, + } as any) + + const result = await tools['loop-status'].execute({ + name: loopName, + }, { sessionID: 'test-session' } as any) + + expect(result).toContain('Per-role usage:') + expect(result).toContain('code: $0.0525') + expect(result).toContain('auditor: $0.0275') + }) + test('inactive loop uses persisted-only when final session is already persisted', async () => { const { client: forgeClient } = createFakeForgeClient({ session: { @@ -1546,6 +1627,9 @@ describe('loop-status cumulative usage', () => { expect(result).toContain('Cumulative Usage:') expect(result).toContain('openai/gpt-4o') expect(result).not.toContain('default/session model') + // Live usage carries an auditor-role attribution through to the per-role block. + expect(result).toContain('Per-role usage:') + expect(result).toContain('auditor: $0.0250') }) }) diff --git a/test/loop/token-usage.test.ts b/test/loop/token-usage.test.ts index 7c02bd4763..d241e40997 100644 --- a/test/loop/token-usage.test.ts +++ b/test/loop/token-usage.test.ts @@ -6,6 +6,8 @@ import { modelLabelFromMessage, summarizeAssistantUsage, mergeUsageSummaries, + mergeRoleUsage, + USAGE_ROLE_ORDER, type TokenBreakdown, type ModelUsage, type LoopUsageSummary, @@ -369,4 +371,107 @@ describe('mergeUsageSummaries', () => { const merged = mergeUsageSummaries(summary1, summary2) expect(merged.perModel.map((m) => m.model)).toEqual(['a-model', 'z-model']) }) + + test('summarizeAssistantUsage populates a single perRole entry from the attribution role', () => { + const messages = [ + { info: { role: 'assistant', cost: 0.01, tokens: { input: 100, output: 50, reasoning: 10, cache: { read: 5, write: 2 } } } }, + { info: { role: 'assistant', cost: 0.02, tokens: { input: 200, output: 100, reasoning: 20, cache: { read: 10, write: 4 } } } }, + ] + + const summary = summarizeAssistantUsage(messages, { role: 'auditor' }) + expect(summary.perRole).toHaveLength(1) + expect(summary.perRole![0]).toEqual({ + role: 'auditor', + cost: 0.03, + tokens: { input: 300, output: 150, reasoning: 30, cacheRead: 15, cacheWrite: 6 }, + messageCount: 2, + }) + }) + + test('summarizeAssistantUsage returns an empty perRole when no attribution is supplied', () => { + const messages = [ + { info: { role: 'assistant', cost: 0.01, tokens: { input: 100, output: 50, reasoning: 10, cache: { read: 5, write: 2 } } } }, + ] + + const summary = summarizeAssistantUsage(messages) + expect(summary.perRole).toEqual([]) + }) + + test('mergeUsageSummaries sums perRole per role and orders code, auditor, unknown', () => { + const codeSummary: LoopUsageSummary = { + totalCost: 0.01, + totalTokens: { input: 100, output: 50, reasoning: 10, cacheRead: 5, cacheWrite: 2 }, + perModel: [], + perRole: [{ role: 'code', cost: 0.01, tokens: { input: 100, output: 50, reasoning: 10, cacheRead: 5, cacheWrite: 2 }, messageCount: 1 }], + } + const auditorSummary: LoopUsageSummary = { + totalCost: 0.02, + totalTokens: { input: 200, output: 100, reasoning: 20, cacheRead: 10, cacheWrite: 4 }, + perModel: [], + perRole: [{ role: 'auditor', cost: 0.02, tokens: { input: 200, output: 100, reasoning: 20, cacheRead: 10, cacheWrite: 4 }, messageCount: 1 }], + } + const unknownSummary: LoopUsageSummary = { + totalCost: 0.005, + totalTokens: { input: 50, output: 25, reasoning: 5, cacheRead: 2, cacheWrite: 1 }, + perModel: [], + perRole: [{ role: 'unknown', cost: 0.005, tokens: { input: 50, output: 25, reasoning: 5, cacheRead: 2, cacheWrite: 1 }, messageCount: 1 }], + } + + // Feed in reverse order to confirm output is normalized. + const merged = mergeUsageSummaries(unknownSummary, auditorSummary, codeSummary) + expect(merged.perRole?.map(r => r.role)).toEqual([...USAGE_ROLE_ORDER]) + const code = merged.perRole?.find(r => r.role === 'code')! + expect(code.cost).toBe(0.01) + expect(code.messageCount).toBe(1) + expect(code.tokens).toEqual({ input: 100, output: 50, reasoning: 10, cacheRead: 5, cacheWrite: 2 }) + }) + + test('mergeUsageSummaries merges same role across summaries', () => { + const a: LoopUsageSummary = { + totalCost: 0.01, + totalTokens: { input: 100, output: 50, reasoning: 10, cacheRead: 5, cacheWrite: 2 }, + perModel: [], + perRole: [{ role: 'code', cost: 0.01, tokens: { input: 100, output: 50, reasoning: 10, cacheRead: 5, cacheWrite: 2 }, messageCount: 1 }], + } + const b: LoopUsageSummary = { + totalCost: 0.02, + totalTokens: { input: 200, output: 100, reasoning: 20, cacheRead: 10, cacheWrite: 4 }, + perModel: [], + perRole: [{ role: 'code', cost: 0.02, tokens: { input: 200, output: 100, reasoning: 20, cacheRead: 10, cacheWrite: 4 }, messageCount: 2 }], + } + const merged = mergeUsageSummaries(a, b) + expect(merged.perRole).toHaveLength(1) + expect(merged.perRole![0]).toEqual({ + role: 'code', + cost: 0.03, + tokens: { input: 300, output: 150, reasoning: 30, cacheRead: 15, cacheWrite: 6 }, + messageCount: 3, + }) + }) + + test('mergeUsageSummaries tolerates summaries without perRole', () => { + const withoutPerRole: LoopUsageSummary = { + totalCost: 0.01, + totalTokens: { input: 100, output: 50, reasoning: 10, cacheRead: 5, cacheWrite: 2 }, + perModel: [], + } + const withPerRole: LoopUsageSummary = { + totalCost: 0.02, + totalTokens: { input: 200, output: 100, reasoning: 20, cacheRead: 10, cacheWrite: 4 }, + perModel: [], + perRole: [{ role: 'auditor', cost: 0.02, tokens: { input: 200, output: 100, reasoning: 20, cacheRead: 10, cacheWrite: 4 }, messageCount: 1 }], + } + + const merged = mergeUsageSummaries(withoutPerRole, withPerRole) + expect(merged.perRole).toEqual([ + { role: 'auditor', cost: 0.02, tokens: { input: 200, output: 100, reasoning: 20, cacheRead: 10, cacheWrite: 4 }, messageCount: 1 }, + ]) + }) +}) + +describe('mergeRoleUsage', () => { + test('returns [] for no inputs', () => { + expect(mergeRoleUsage()).toEqual([]) + expect(mergeRoleUsage(undefined, undefined)).toEqual([]) + }) }) From a3dfe7ee5e8e4468d78d672c7c6d10eff165aa25 Mon Sep 17 00:00:00 2001 From: Forge Date: Sun, 26 Jul 2026 20:19:01 -0400 Subject: [PATCH 2/3] refactor: unify forge-db path and active-loop guards, slim dashboard payload --- AGENTS.md | 2 + README.md | 4 +- docs/api/README.md | 6 +- docs/api/_media/loop-system.md | 4 +- docs/api/functions/createForgePlugin.md | 2 +- .../functions/createParentSessionLookup.md | 2 +- .../functions/createSessionDirectoryLookup.md | 2 +- docs/api/interfaces/CompactionConfig.md | 6 +- .../CreateParentSessionLookupOptions.md | 12 +-- .../CreateSessionDirectoryLookupOptions.md | 8 +- docs/api/interfaces/PluginConfig.md | 32 +++---- docs/api/variables/VERSION.md | 2 +- docs/api/variables/default.md | 2 +- docs/loop-system.md | 4 +- scripts/dashboard.ts | 7 +- src/dashboard/app-bundle.ts | 4 +- src/dashboard/app/app.ts | 15 ++-- src/dashboard/app/components.ts | 39 ++++---- src/dashboard/app/helpers.ts | 72 ++++++++++----- src/dashboard/data.ts | 19 ++-- src/dashboard/launch.ts | 11 +-- src/hooks/plan-approval.ts | 5 +- src/index.ts | 3 +- src/loop/service.ts | 13 +++ src/loop/token-usage.ts | 80 ++++++++--------- src/services/deterministic-decomposer.ts | 4 +- src/services/execution.ts | 4 +- src/storage/database.ts | 4 +- src/storage/index.ts | 2 +- src/storage/repos/loop-session-usage-repo.ts | 89 +++++++++---------- src/storage/repos/loop-transitions-repo.ts | 32 +++++++ src/storage/repos/review-findings-repo.ts | 18 ++++ src/tools/plan-authoring.ts | 13 +-- src/tui.tsx | 6 +- src/utils/format.ts | 13 +++ src/utils/logger.ts | 9 +- src/utils/loop-format.ts | 61 ++++++------- src/utils/opencode-paths.ts | 11 +++ src/utils/tui-client.ts | 4 +- src/utils/tui-loop-store.ts | 9 +- test/dashboard/app-dom.test.ts | 77 +++++----------- test/dashboard/app-helpers.test.ts | 28 ++---- test/dashboard/data.test.ts | 81 +++++++++-------- test/hooks/plan-approval-dedupe.test.ts | 2 +- .../plan-approval-worktree-timing.test.ts | 1 + test/loop-transitions-repo.test.ts | 35 ++++++++ test/plan-approval.test.ts | 10 +++ test/review-findings-repo.test.ts | 17 ++++ test/tools/plan-authoring.test.ts | 13 +++ test/utils/tui-client-stored-plan.test.ts | 1 + test/worktree-log.test.ts | 18 ++++ 51 files changed, 539 insertions(+), 379 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eeafd60631..b2fd7f7011 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,8 @@ - Keep the section-summary markers in `src/prompts/agents/auditor-loop-addendum.md` synchronized with the constants in `src/utils/section-summary.ts`. - `MAX_TOTAL_SECTIONS` in `src/constants/loop.ts` is the single section cap; the decomposer, section bootstrap, plan structure summary, TUI inline plan preview and `plan-adjust` all read it, and the architect system reminder in `src/index.ts` interpolates it. `src/prompts/agents/architect.md` is prose and repeats the number literally — update it when the cap changes. - `PLAN_AUTHORING_TOOL_NAMES` in `src/constants/loop.ts` is the single list of plan-authoring tools; the `code`, `auditor`, and `feature-splitter` tool-exclude lists and both permission rulesets derive their deny entries from it. +- `LoopService.resolveActiveLoopForSession` is the only correct "is this session inside a running loop" check. `resolveLoopName` matches terminated loops too, so using it as an activity guard blocks a session forever after its loop ends. +- `resolveForgeDbPath` in `src/utils/opencode-paths.ts` is the only place `/forge.db` is built; every entry point must route through it so a configured `dataDir` is honoured uniformly. ## Dashboard and storage gotchas diff --git a/README.md b/README.md index 1098be522e..a4e7da2479 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ All endpoints are read-only (non-GET requests return 404): | Endpoint | Description | |----------|-------------| | `GET /` | HTML page (inlined SolidJS app) | -| `GET /api/data` | JSON snapshot of Forge loop/project state. Accepts optional `project` and `loop` query parameters (`/api/data?project=&loop=`) to scope the payload: per-loop text (`plan`, `goal`, `lastAuditResult`, `postActionReport`, `sections`, `amendments`) is materialised only for the scoped loop, and `transitions` only for the scoped project; `findings`, `usage`, and `duration` remain populated for every loop. `hasPlan` and `sectionCount` are always populated so the tab set and section counts render correctly while detail is in flight. | +| `GET /api/data` | JSON snapshot of Forge loop/project state. Accepts optional `project` and `loop` query parameters (`/api/data?project=&loop=`) to scope the payload: per-loop text (`plan`, `goal`, `lastAuditResult`, `postActionReport`, `sections`, `amendments`) is materialised only for the scoped loop, and `findings` rows, `usage`, and `transitions` only for the scoped project. `duration`, `hasPlan`, `sectionCount`, and `bugCount` are always populated so the repo index, tab set, and section/bug counts render correctly while detail is in flight. | In the browser, the loop table, repo findings list, plans list, and loop picker cap their rendered rows behind a "Showing N of M" affordance (a "Show all" toggle expands the loop table, findings, and plans lists). @@ -405,7 +405,7 @@ Audit findings survive session rotation via the **review store**. The auditor st ### Usage Tracking -Loop sessions rotate between code and auditor work, so Forge persists per-session usage rows in `loop_session_usage` and merges them for `loop-status`. Detailed status includes cumulative cost, input/output/reasoning/cache token totals, per-model breakdowns, and live active-session output when available. +Loop sessions rotate between code and auditor work, so Forge persists per-session usage rows in `loop_session_usage` and merges them for `loop-status`. Detailed status includes cumulative cost, input/output/reasoning/cache token totals, per-model breakdowns, a per-role (`code`/`auditor`/`unknown`) breakdown, and live active-session output when available. ### Worktree Isolation diff --git a/docs/api/README.md b/docs/api/README.md index 9008e9181f..abb034f2e3 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -94,7 +94,9 @@ All endpoints are read-only (non-GET requests return 404): | Endpoint | Description | |----------|-------------| | `GET /` | HTML page (inlined SolidJS app) | -| `GET /api/data` | JSON snapshot of Forge loop/project state | +| `GET /api/data` | JSON snapshot of Forge loop/project state. Accepts optional `project` and `loop` query parameters (`/api/data?project=&loop=`) to scope the payload: per-loop text (`plan`, `goal`, `lastAuditResult`, `postActionReport`, `sections`, `amendments`) is materialised only for the scoped loop, and `findings` rows, `usage`, and `transitions` only for the scoped project. `duration`, `hasPlan`, `sectionCount`, and `bugCount` are always populated so the repo index, tab set, and section/bug counts render correctly while detail is in flight. | + +In the browser, the loop table, repo findings list, plans list, and loop picker cap their rendered rows behind a "Showing N of M" affordance (a "Show all" toggle expands the loop table, findings, and plans lists). ## Screenshots @@ -405,7 +407,7 @@ Audit findings survive session rotation via the **review store**. The auditor st ### Usage Tracking -Loop sessions rotate between code and auditor work, so Forge persists per-session usage rows in `loop_session_usage` and merges them for `loop-status`. Detailed status includes cumulative cost, input/output/reasoning/cache token totals, per-model breakdowns, and live active-session output when available. +Loop sessions rotate between code and auditor work, so Forge persists per-session usage rows in `loop_session_usage` and merges them for `loop-status`. Detailed status includes cumulative cost, input/output/reasoning/cache token totals, per-model breakdowns, a per-role (`code`/`auditor`/`unknown`) breakdown, and live active-session output when available. ### Worktree Isolation diff --git a/docs/api/_media/loop-system.md b/docs/api/_media/loop-system.md index 25db2c3f82..eccbbff22f 100644 --- a/docs/api/_media/loop-system.md +++ b/docs/api/_media/loop-system.md @@ -132,7 +132,7 @@ Loop usage is captured across rotated code and auditor sessions so `loop-status` - `loop-status ` merges persisted rows with the currently live session output while avoiding double-counting the active session. - When no loops are active, `loop-status` can still show cumulative usage for completed loops that have persisted usage data. -Tracked token buckets are input, output, reasoning, cache read, and cache write, plus cost and assistant message count. +Tracked token buckets are input, output, reasoning, cache read, and cache write, plus cost and assistant message count. Totals are reported three ways: overall, per model, and per role (`code`, `auditor`, `unknown`) derived from the `role` column, so execution and audit spend can be compared for the same loop. ## Stall Detection @@ -217,7 +217,7 @@ In user-facing language, a plan is decomposed into **milestones** — ordered un - `` markers in the architect plan output - `section-read` tool reads the current or specified milestone -Decomposition is a one-shot preprocessing step at loop start (`services/deterministic-decomposer.ts`), not a runtime loop phase. Once milestones exist, the loop advances through them via `advance-section` transitions inside the `auditing` phase. When the `final_auditing` phase reports outstanding bug findings, the loop rotates to a coding session in the persisted `final_audit_fix` phase — the code agent fixes the reported findings without rewinding to a specific section, and on idle the loop transitions straight back to `final_auditing` for re-verification. A loop stopped mid-fix restarts as a coding pass that re-sends the final-audit fix prompt (rebuilt from the persisted `lastAuditResult`). +Decomposition is a one-shot preprocessing step at loop start (`services/deterministic-decomposer.ts`), not a runtime loop phase. A plan is capped at `MAX_TOTAL_SECTIONS` (24) executed milestones; markers past the cap are dropped rather than merged, so the tail of an over-long plan is not executed. The same constant caps `plan-adjust` amendments, so a plan can never grow past it mid-loop. Once milestones exist, the loop advances through them via `advance-section` transitions inside the `auditing` phase. When the `final_auditing` phase reports outstanding bug findings, the loop rotates to a coding session in the persisted `final_audit_fix` phase — the code agent fixes the reported findings without rewinding to a specific section, and on idle the loop transitions straight back to `final_auditing` for re-verification. A loop stopped mid-fix restarts as a coding pass that re-sends the final-audit fix prompt (rebuilt from the persisted `lastAuditResult`). ### Plan Amendments diff --git a/docs/api/functions/createForgePlugin.md b/docs/api/functions/createForgePlugin.md index ea48b35a8f..4f110bac3d 100644 --- a/docs/api/functions/createForgePlugin.md +++ b/docs/api/functions/createForgePlugin.md @@ -8,7 +8,7 @@ > **createForgePlugin**(`config`): `Plugin` -Defined in: [index.ts:199](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L199) +Defined in: [index.ts:200](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/index.ts#L200) Creates an OpenCode plugin instance with loop management and sandboxing. diff --git a/docs/api/functions/createParentSessionLookup.md b/docs/api/functions/createParentSessionLookup.md index 4f45577320..f15b63ff89 100644 --- a/docs/api/functions/createParentSessionLookup.md +++ b/docs/api/functions/createParentSessionLookup.md @@ -8,7 +8,7 @@ > **createParentSessionLookup**(`__namedParameters`): (`sessionId`) => `Promise`\<`string` \| `null`\> -Defined in: [index.ts:53](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L53) +Defined in: [index.ts:54](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/index.ts#L54) ## Parameters diff --git a/docs/api/functions/createSessionDirectoryLookup.md b/docs/api/functions/createSessionDirectoryLookup.md index c436ab33dc..67314cf631 100644 --- a/docs/api/functions/createSessionDirectoryLookup.md +++ b/docs/api/functions/createSessionDirectoryLookup.md @@ -8,7 +8,7 @@ > **createSessionDirectoryLookup**(`__namedParameters`): (`sessionId`) => `Promise`\<`string` \| `null`\> -Defined in: [index.ts:134](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L134) +Defined in: [index.ts:135](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/index.ts#L135) ## Parameters diff --git a/docs/api/interfaces/CompactionConfig.md b/docs/api/interfaces/CompactionConfig.md index 3a14d2a71e..0a34cda4a9 100644 --- a/docs/api/interfaces/CompactionConfig.md +++ b/docs/api/interfaces/CompactionConfig.md @@ -6,7 +6,7 @@ # Interface: CompactionConfig -Defined in: [types.ts:152](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/types.ts#L152) +Defined in: [types.ts:152](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/types.ts#L152) Configuration for session compaction behavior. @@ -16,7 +16,7 @@ Configuration for session compaction behavior. > `optional` **customPrompt?**: `boolean` -Defined in: [types.ts:154](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/types.ts#L154) +Defined in: [types.ts:154](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/types.ts#L154) Use a custom compaction prompt. @@ -26,6 +26,6 @@ Use a custom compaction prompt. > `optional` **maxContextTokens?**: `number` -Defined in: [types.ts:156](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/types.ts#L156) +Defined in: [types.ts:156](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/types.ts#L156) Maximum context tokens for compaction. diff --git a/docs/api/interfaces/CreateParentSessionLookupOptions.md b/docs/api/interfaces/CreateParentSessionLookupOptions.md index 2b2ec6e6e0..fbc7619368 100644 --- a/docs/api/interfaces/CreateParentSessionLookupOptions.md +++ b/docs/api/interfaces/CreateParentSessionLookupOptions.md @@ -6,7 +6,7 @@ # Interface: CreateParentSessionLookupOptions -Defined in: [index.ts:43](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L43) +Defined in: [index.ts:44](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/index.ts#L44) ## Properties @@ -14,7 +14,7 @@ Defined in: [index.ts:43](https://github.com/chriswritescode-dev/opencode-forge/ > **client**: `ForgeClient` -Defined in: [index.ts:44](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L44) +Defined in: [index.ts:45](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/index.ts#L45) *** @@ -22,7 +22,7 @@ Defined in: [index.ts:44](https://github.com/chriswritescode-dev/opencode-forge/ > **directory**: `string` -Defined in: [index.ts:45](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L45) +Defined in: [index.ts:46](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/index.ts#L46) *** @@ -30,7 +30,7 @@ Defined in: [index.ts:45](https://github.com/chriswritescode-dev/opencode-forge/ > **logger**: `object` -Defined in: [index.ts:47](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L47) +Defined in: [index.ts:48](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/index.ts#L48) #### debug @@ -92,7 +92,7 @@ Defined in: [index.ts:47](https://github.com/chriswritescode-dev/opencode-forge/ > **loop**: `Loop` -Defined in: [index.ts:46](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L46) +Defined in: [index.ts:47](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/index.ts#L47) *** @@ -100,4 +100,4 @@ Defined in: [index.ts:46](https://github.com/chriswritescode-dev/opencode-forge/ > `optional` **negativeTtlMs?**: `number` -Defined in: [index.ts:48](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L48) +Defined in: [index.ts:49](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/index.ts#L49) diff --git a/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md b/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md index cd5333a67d..c5bbb7242c 100644 --- a/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md +++ b/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md @@ -6,7 +6,7 @@ # Interface: CreateSessionDirectoryLookupOptions -Defined in: [index.ts:128](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L128) +Defined in: [index.ts:129](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/index.ts#L129) ## Properties @@ -14,7 +14,7 @@ Defined in: [index.ts:128](https://github.com/chriswritescode-dev/opencode-forge > **client**: `ForgeClient` -Defined in: [index.ts:129](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L129) +Defined in: [index.ts:130](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/index.ts#L130) *** @@ -22,7 +22,7 @@ Defined in: [index.ts:129](https://github.com/chriswritescode-dev/opencode-forge > **directory**: `string` -Defined in: [index.ts:130](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L130) +Defined in: [index.ts:131](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/index.ts#L131) *** @@ -30,4 +30,4 @@ Defined in: [index.ts:130](https://github.com/chriswritescode-dev/opencode-forge > **loop**: `Loop` -Defined in: [index.ts:131](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L131) +Defined in: [index.ts:132](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/index.ts#L132) diff --git a/docs/api/interfaces/PluginConfig.md b/docs/api/interfaces/PluginConfig.md index 5fd4cb8344..1a8cc78405 100644 --- a/docs/api/interfaces/PluginConfig.md +++ b/docs/api/interfaces/PluginConfig.md @@ -6,7 +6,7 @@ # Interface: PluginConfig -Defined in: [types.ts:216](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/types.ts#L216) +Defined in: [types.ts:216](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/types.ts#L216) Complete plugin configuration for opencode-forge. @@ -16,7 +16,7 @@ Complete plugin configuration for opencode-forge. > `optional` **agents?**: `Record`\<`string`, `AgentOverrideConfig`\> -Defined in: [types.ts:244](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/types.ts#L244) +Defined in: [types.ts:244](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/types.ts#L244) Per-agent configuration overrides. @@ -26,7 +26,7 @@ Per-agent configuration overrides. > `optional` **auditorModel?**: `string` -Defined in: [types.ts:228](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/types.ts#L228) +Defined in: [types.ts:228](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/types.ts#L228) Model to use for code auditing. @@ -36,7 +36,7 @@ Model to use for code auditing. > `optional` **auditorVariant?**: `string` -Defined in: [types.ts:232](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/types.ts#L232) +Defined in: [types.ts:232](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/types.ts#L232) Default reasoning/thinking variant for the auditor model. @@ -46,7 +46,7 @@ Default reasoning/thinking variant for the auditor model. > `optional` **compaction?**: [`CompactionConfig`](CompactionConfig.md) -Defined in: [types.ts:222](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/types.ts#L222) +Defined in: [types.ts:222](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/types.ts#L222) Compaction behavior configuration. @@ -56,7 +56,7 @@ Compaction behavior configuration. > `optional` **completedLoopTtlMs?**: `number` -Defined in: [types.ts:240](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/types.ts#L240) +Defined in: [types.ts:240](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/types.ts#L240) TTL for completed/cancelled/errored/stalled loops before sweep. Default 7 days. @@ -66,7 +66,7 @@ TTL for completed/cancelled/errored/stalled loops before sweep. Default 7 days. > `optional` **dataDir?**: `string` -Defined in: [types.ts:218](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/types.ts#L218) +Defined in: [types.ts:218](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/types.ts#L218) Custom data directory for plugin storage. Defaults to platform data dir. @@ -76,7 +76,7 @@ Custom data directory for plugin storage. Defaults to platform data dir. > `optional` **executionModel?**: `string` -Defined in: [types.ts:226](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/types.ts#L226) +Defined in: [types.ts:226](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/types.ts#L226) Model to use for code execution. @@ -86,7 +86,7 @@ Model to use for code execution. > `optional` **executionVariant?**: `string` -Defined in: [types.ts:230](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/types.ts#L230) +Defined in: [types.ts:230](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/types.ts#L230) Default reasoning/thinking variant for the execution model. @@ -96,7 +96,7 @@ Default reasoning/thinking variant for the execution model. > `optional` **groupLaunch?**: `GroupLaunchConfig` -Defined in: [types.ts:236](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/types.ts#L236) +Defined in: [types.ts:236](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/types.ts#L236) Group launch configuration. @@ -106,7 +106,7 @@ Group launch configuration. > `optional` **logging?**: `LoggingConfig` -Defined in: [types.ts:220](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/types.ts#L220) +Defined in: [types.ts:220](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/types.ts#L220) Logging configuration. @@ -116,7 +116,7 @@ Logging configuration. > `optional` **loop?**: `LoopConfig` -Defined in: [types.ts:234](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/types.ts#L234) +Defined in: [types.ts:234](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/types.ts#L234) Loop behavior configuration. @@ -126,7 +126,7 @@ Loop behavior configuration. > `optional` **messagesTransform?**: `MessagesTransformConfig` -Defined in: [types.ts:224](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/types.ts#L224) +Defined in: [types.ts:224](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/types.ts#L224) Message transformation for architect agent. @@ -136,7 +136,7 @@ Message transformation for architect agent. > `optional` **remotes?**: `RemoteServerConfig`[] -Defined in: [types.ts:238](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/types.ts#L238) +Defined in: [types.ts:238](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/types.ts#L238) Remote opencode servers available as loop launch targets. @@ -146,7 +146,7 @@ Remote opencode servers available as loop launch targets. > `optional` **sandbox?**: `SandboxConfig` -Defined in: [types.ts:246](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/types.ts#L246) +Defined in: [types.ts:246](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/types.ts#L246) Sandbox execution configuration. @@ -156,6 +156,6 @@ Sandbox execution configuration. > `optional` **tui?**: `TuiConfig` -Defined in: [types.ts:242](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/types.ts#L242) +Defined in: [types.ts:242](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/types.ts#L242) TUI display configuration. diff --git a/docs/api/variables/VERSION.md b/docs/api/variables/VERSION.md index e8b3c00403..66694ff420 100644 --- a/docs/api/variables/VERSION.md +++ b/docs/api/variables/VERSION.md @@ -8,4 +8,4 @@ > `const` **VERSION**: `"0.7.1"` = `'0.7.1'` -Defined in: [version.ts:1](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/version.ts#L1) +Defined in: [version.ts:1](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/version.ts#L1) diff --git a/docs/api/variables/default.md b/docs/api/variables/default.md index 0f6f31a142..5d460aea01 100644 --- a/docs/api/variables/default.md +++ b/docs/api/variables/default.md @@ -8,7 +8,7 @@ > `const` **default**: `object` -Defined in: [index.ts:799](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L799) +Defined in: [index.ts:800](https://github.com/chriswritescode-dev/opencode-forge/blob/e558f4d5bbe13223fcc02388824eb728b08e9520/src/index.ts#L800) ## Type Declaration diff --git a/docs/loop-system.md b/docs/loop-system.md index 25db2c3f82..eccbbff22f 100644 --- a/docs/loop-system.md +++ b/docs/loop-system.md @@ -132,7 +132,7 @@ Loop usage is captured across rotated code and auditor sessions so `loop-status` - `loop-status ` merges persisted rows with the currently live session output while avoiding double-counting the active session. - When no loops are active, `loop-status` can still show cumulative usage for completed loops that have persisted usage data. -Tracked token buckets are input, output, reasoning, cache read, and cache write, plus cost and assistant message count. +Tracked token buckets are input, output, reasoning, cache read, and cache write, plus cost and assistant message count. Totals are reported three ways: overall, per model, and per role (`code`, `auditor`, `unknown`) derived from the `role` column, so execution and audit spend can be compared for the same loop. ## Stall Detection @@ -217,7 +217,7 @@ In user-facing language, a plan is decomposed into **milestones** — ordered un - `` markers in the architect plan output - `section-read` tool reads the current or specified milestone -Decomposition is a one-shot preprocessing step at loop start (`services/deterministic-decomposer.ts`), not a runtime loop phase. Once milestones exist, the loop advances through them via `advance-section` transitions inside the `auditing` phase. When the `final_auditing` phase reports outstanding bug findings, the loop rotates to a coding session in the persisted `final_audit_fix` phase — the code agent fixes the reported findings without rewinding to a specific section, and on idle the loop transitions straight back to `final_auditing` for re-verification. A loop stopped mid-fix restarts as a coding pass that re-sends the final-audit fix prompt (rebuilt from the persisted `lastAuditResult`). +Decomposition is a one-shot preprocessing step at loop start (`services/deterministic-decomposer.ts`), not a runtime loop phase. A plan is capped at `MAX_TOTAL_SECTIONS` (24) executed milestones; markers past the cap are dropped rather than merged, so the tail of an over-long plan is not executed. The same constant caps `plan-adjust` amendments, so a plan can never grow past it mid-loop. Once milestones exist, the loop advances through them via `advance-section` transitions inside the `auditing` phase. When the `final_auditing` phase reports outstanding bug findings, the loop rotates to a coding session in the persisted `final_audit_fix` phase — the code agent fixes the reported findings without rewinding to a specific section, and on idle the loop transitions straight back to `final_auditing` for re-verification. A loop stopped mid-fix restarts as a coding pass that re-sends the final-audit fix prompt (rebuilt from the persisted `lastAuditResult`). ### Plan Amendments diff --git a/scripts/dashboard.ts b/scripts/dashboard.ts index 600776330a..99a4b965b6 100644 --- a/scripts/dashboard.ts +++ b/scripts/dashboard.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun import { startDashboardServer } from '../src/dashboard/launch' +import { loadPluginConfig } from '../src/setup' interface Args { port?: number @@ -29,7 +30,11 @@ function main(): void { const args = parseArgs(process.argv) try { - const handle = startDashboardServer({ port: args.port, dbPath: args.dbPath }) + const handle = startDashboardServer({ + port: args.port, + dbPath: args.dbPath, + dataDir: loadPluginConfig().dataDir, + }) console.log(`Forge dashboard running: ${handle.url}`) const shutdown = () => { handle.stop() diff --git a/src/dashboard/app-bundle.ts b/src/dashboard/app-bundle.ts index 8af870aef7..b3049ac806 100644 --- a/src/dashboard/app-bundle.ts +++ b/src/dashboard/app-bundle.ts @@ -1,3 +1,3 @@ // Auto-generated from src/dashboard/app. Do not edit. -export const DASHBOARD_APP_BUNDLE: string = "var z={context:void 0,registry:void 0,effects:void 0,done:!1,getContextId(){return pn(this.context.count)},getNextContextId(){return pn(this.context.count++)}};function pn(e){let n=String(e),t=n.length-1;return z.context.id+(t?String.fromCharCode(96+t):\"\")+n}function rn(e){z.context=e}function nf(){return{...z.context,id:z.getNextContextId(),count:0}}var tf=!0,ff=(e,n)=>e===n,X=Symbol(\"solid-proxy\"),uf=typeof Proxy===\"function\",cn=Symbol(\"solid-track\"),ut=Symbol(\"solid-dev-component\"),Ye={equals:ff},et=null,rt=ht,q=1,Oe=2,rf={};var P=null,O=null,Ee=null,le=null,v=null,W=null,Y=null,je=0,Q={afterUpdate:null,afterCreateOwner:null,afterCreateSignal:null,afterRegisterGraph:null};function lt(e,n){let t=v,i=P,f=e.length===0,u=n===void 0?i:n,r=f?{owned:null,cleanups:null,context:null,owner:null}:{owned:null,cleanups:null,context:u?u.context:null,owner:u},l=f?()=>e(()=>{throw Error(\"Dispose method must be an explicit argument to createRoot function\")}):()=>e(()=>I(()=>ne(r)));Q.afterCreateOwner&&Q.afterCreateOwner(r),P=r,v=null;try{return B(l,!0)}finally{v=t,P=i}}function U(e,n){n=n?Object.assign({},Ye,n):Ye;let t={value:e,observers:null,observerSlots:null,comparator:n.equals||void 0};{if(n.name)t.name=n.name;if(n.internal)t.internal=!0;else if(dt(t),Q.afterCreateSignal)Q.afterCreateSignal(t)}let i=(f)=>{if(typeof f===\"function\")if(O&&O.running&&O.sources.has(t))f=f(t.tValue);else f=f(t.value);return dn(t,f)};return[ot.bind(t),i]}function M(e,n,t){let i=He(e,n,!1,q,t);if(Ee&&O&&O.running)W.push(i);else se(i)}function R(e,n,t){rt=of;let i=He(e,n,!1,q,t),f=ln&&cf(ln);if(f)i.suspense=f;if(!t||!t.render)i.user=!0;Y?Y.push(i):se(i)}function $(e,n,t){t=t?Object.assign({},Ye,t):Ye;let i=He(e,n,!0,0,t);if(i.observers=null,i.observerSlots=null,i.comparator=t.equals||void 0,Ee&&O&&O.running)i.tState=q,W.push(i);else se(i);return ot.bind(i)}function Ce(e){return B(e,!1)}function I(e){if(!le&&v===null)return e();let n=v;v=null;try{if(le)return le.untrack(e);return e()}finally{v=n}}function ct(e,n,t){let i=Array.isArray(e),f,u=t&&t.defer;return(r)=>{let l;if(i){l=Array(e.length);for(let s=0;sn(l,f,r));return f=l,c}}function st(e){R(()=>I(e))}function ce(e){if(P===null)console.warn(\"cleanups created outside a `createRoot` or `render` will never be run\");else if(P.cleanups===null)P.cleanups=[e];else P.cleanups.push(e);return e}function Ge(){return v}function De(){return P}function sn(e,n){let t=P,i=v;P=e,v=null;try{return B(n,!0)}catch(f){Ze(f)}finally{P=t,v=i}}function lf(e){if(O&&O.running)return e(),O.done;let n=v,t=P;return Promise.resolve().then(()=>{v=n,P=t;let i;if(Ee||ln)i=O||(O={sources:new Set,effects:[],promises:new Set,disposed:new Set,queue:new Set,running:!0}),i.done||(i.done=new Promise((f)=>i.resolve=f)),i.running=!0;return B(e,!1),v=P=null,i?i.done:void 0})}var[vu,nt]=U(!1);function tt(e,n){let t=He(()=>I(()=>{return Object.assign(e,{[ut]:!0}),e(n)}),void 0,!0,0);return t.props=n,t.observers=null,t.observerSlots=null,t.name=e.name,t.component=e,se(t),t.tValue!==void 0?t.tValue:t.value}function dt(e){if(P){if(P.sourceMap)P.sourceMap.push(e);else P.sourceMap=[e];e.graph=P}if(Q.afterRegisterGraph)Q.afterRegisterGraph(e)}function cf(e){let n;return P&&P.context&&(n=P.context[e.id])!==void 0?n:e.defaultValue}var ln;function ot(){let e=O&&O.running;if(this.sources&&(e?this.tState:this.state))if((e?this.tState:this.state)===q)se(this);else{let n=W;W=null,B(()=>Ie(this),!1),W=n}if(v){let n=this.observers?this.observers.length:0;if(!v.sources)v.sources=[this],v.sourceSlots=[n];else v.sources.push(this),v.sourceSlots.push(n);if(!this.observers)this.observers=[v],this.observerSlots=[v.sources.length-1];else this.observers.push(v),this.observerSlots.push(v.sources.length-1)}if(e&&O.sources.has(this))return this.tValue;return this.value}function dn(e,n,t){let i=O&&O.running&&O.sources.has(e)?e.tValue:e.value;if(!e.comparator||!e.comparator(i,n)){if(O){let f=O.running;if(f||!t&&O.sources.has(e))O.sources.add(e),e.tValue=n;if(!f)e.value=n}else e.value=n;if(e.observers&&e.observers.length)B(()=>{for(let f=0;f1e6){if(W=[],tf)throw Error(\"Potential Infinite Loop Detected.\");throw Error()}},!1)}return n}function se(e){if(!e.fn)return;ne(e);let n=je;if(it(e,O&&O.running&&O.sources.has(e)?e.tValue:e.value,n),O&&!O.running&&O.sources.has(e))queueMicrotask(()=>{B(()=>{O&&(O.running=!0),v=P=e,it(e,e.tValue,n),v=P=null},!1)})}function it(e,n,t){let i,f=P,u=v;v=P=e;try{i=e.fn(n)}catch(r){if(e.pure)if(O&&O.running)e.tState=q,e.tOwned&&e.tOwned.forEach(ne),e.tOwned=void 0;else e.state=q,e.owned&&e.owned.forEach(ne),e.owned=null;return e.updatedAt=t+1,Ze(r)}finally{v=u,P=f}if(!e.updatedAt||e.updatedAt<=t){if(e.updatedAt!=null&&\"observers\"in e)dn(e,i,!0);else if(O&&O.running&&e.pure){if(!O.sources.has(e))e.value=i;O.sources.add(e),e.tValue=i}else e.value=i;e.updatedAt=t}}function He(e,n,t,i=q,f){let u={fn:e,state:i,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:n,owner:P,context:P?P.context:null,pure:t};if(O&&O.running)u.state=0,u.tState=i;if(P===null)console.warn(\"computations created outside a `createRoot` or `render` will never be disposed\");else if(P!==rf)if(O&&O.running&&P.pure)if(!P.tOwned)P.tOwned=[u];else P.tOwned.push(u);else if(!P.owned)P.owned=[u];else P.owned.push(u);if(f&&f.name)u.name=f.name;if(le&&u.fn){let r=u.fn,[l,c]=U(void 0,{equals:!1}),s=le.factory(r,c);ce(()=>s.dispose());let d,g=()=>lf(c).then(()=>{if(d)d.dispose(),d=void 0});u.fn=(k)=>{if(l(),O&&O.running){if(!d)d=le.factory(r,g);return d.track(k)}return s.track(k)}}return Q.afterCreateOwner&&Q.afterCreateOwner(u),u}function ke(e){let n=O&&O.running;if((n?e.tState:e.state)===0)return;if((n?e.tState:e.state)===Oe)return Ie(e);if(e.suspense&&I(e.suspense.inFallback))return e.suspense.effects.push(e);let t=[e];while((e=e.owner)&&(!e.updatedAt||e.updatedAt=0;i--){if(e=t[i],n){let f=e,u=t[i+1];while((f=f.owner)&&f!==u)if(O.disposed.has(f))return}if((n?e.tState:e.state)===q)se(e);else if((n?e.tState:e.state)===Oe){let f=W;W=null,B(()=>Ie(e,t[0]),!1),W=f}}}function B(e,n){if(W)return e();let t=!1;if(!n)W=[];if(Y)t=!0;else Y=[];je++;try{let i=e();return sf(t),i}catch(i){if(!t)Y=null;W=null,Ze(i)}}function sf(e){if(W){if(Ee&&O&&O.running)df(W);else ht(W);W=null}if(e)return;let n;if(O){if(!O.promises.size&&!O.queue.size){let{sources:i,disposed:f}=O;Y.push.apply(Y,O.effects),n=O.resolve;for(let u of Y)\"tState\"in u&&(u.state=u.tState),delete u.tState;O=null,B(()=>{for(let u of f)ne(u);for(let u of i){if(u.value=u.tValue,u.owned)for(let r=0,l=u.owned.length;rrt(t),!1);else Q.afterUpdate&&Q.afterUpdate();if(n)n()}function ht(e){for(let n=0;n{i.delete(t),B(()=>{O.running=!0,ke(t)},!1),O&&(O.running=!1)})}}function of(e){let n,t=0;for(n=0;n=0;n--)ne(e.tOwned[n]);delete e.tOwned}if(O&&O.running&&e.pure)yt(e,!0);else if(e.owned){for(n=e.owned.length-1;n>=0;n--)ne(e.owned[n]);e.owned=null}if(e.cleanups){for(n=e.cleanups.length-1;n>=0;n--)e.cleanups[n]();e.cleanups=null}if(O&&O.running)e.tState=0;else e.state=0;delete e.sourceMap}function yt(e,n){if(!n)e.tState=0,O.disposed.add(e);if(e.owned)for(let t=0;t=0;l--){let c=un(e[l])[r];if(c!==void 0)return c}},has(r){for(let l=e.length-1;l>=0;l--)if(r in un(e[l]))return!0;return!1},keys(){let r=[];for(let l=0;l=0;r--){let l=e[r];if(!l)continue;let c=Object.getOwnPropertyNames(l);for(let s=c.length-1;s>=0;s--){let d=c[s];if(d===\"__proto__\"||d===\"constructor\")continue;let g=Object.getOwnPropertyDescriptor(l,d);if(!i[d])i[d]=g.get?{enumerable:!0,configurable:!0,get:bf.bind(t[d]=[g.get.bind(l)])}:g.value!==void 0?g:void 0;else{let k=t[d];if(k){if(g.get)k.push(g.get.bind(l));else if(g.value!==void 0)k.push(()=>g.value)}}}}let f={},u=Object.keys(i);for(let r=u.length-1;r>=0;r--){let l=u[r],c=i[l];if(c&&c.get)Object.defineProperty(f,l,c);else f[l]=c?c.value:void 0}return f}var bt={hooks:Q,writeSignal:dn,registerGraph:dt};if(globalThis)if(!globalThis.Solid$$)globalThis.Solid$$=!0;else console.warn(\"You appear to have multiple instances of Solid. This can lead to unexpected behavior.\");var $f=[\"allowfullscreen\",\"async\",\"alpha\",\"autofocus\",\"autoplay\",\"checked\",\"controls\",\"default\",\"disabled\",\"formnovalidate\",\"hidden\",\"indeterminate\",\"inert\",\"ismap\",\"loop\",\"multiple\",\"muted\",\"nomodule\",\"novalidate\",\"open\",\"playsinline\",\"readonly\",\"required\",\"reversed\",\"seamless\",\"selected\",\"adauctionheaders\",\"browsingtopics\",\"credentialless\",\"defaultchecked\",\"defaultmuted\",\"defaultselected\",\"defer\",\"disablepictureinpicture\",\"disableremoteplayback\",\"preservespitch\",\"shadowrootclonable\",\"shadowrootcustomelementregistry\",\"shadowrootdelegatesfocus\",\"shadowrootserializable\",\"sharedstoragewritable\"],yn=new Set([\"className\",\"value\",\"readOnly\",\"noValidate\",\"formNoValidate\",\"isMap\",\"noModule\",\"playsInline\",\"adAuctionHeaders\",\"allowFullscreen\",\"browsingTopics\",\"defaultChecked\",\"defaultMuted\",\"defaultSelected\",\"disablePictureInPicture\",\"disableRemotePlayback\",\"preservesPitch\",\"shadowRootClonable\",\"shadowRootCustomElementRegistry\",\"shadowRootDelegatesFocus\",\"shadowRootSerializable\",\"sharedStorageWritable\",...$f]),bn=new Set([\"innerHTML\",\"textContent\",\"innerText\",\"children\"]),$n=Object.assign(Object.create(null),{className:\"class\",htmlFor:\"for\"}),wf=Object.assign(Object.create(null),{class:\"className\",novalidate:{$:\"noValidate\",FORM:1},formnovalidate:{$:\"formNoValidate\",BUTTON:1,INPUT:1},ismap:{$:\"isMap\",IMG:1},nomodule:{$:\"noModule\",SCRIPT:1},playsinline:{$:\"playsInline\",VIDEO:1},readonly:{$:\"readOnly\",INPUT:1,TEXTAREA:1},adauctionheaders:{$:\"adAuctionHeaders\",IFRAME:1},allowfullscreen:{$:\"allowFullscreen\",IFRAME:1},browsingtopics:{$:\"browsingTopics\",IMG:1},defaultchecked:{$:\"defaultChecked\",INPUT:1},defaultmuted:{$:\"defaultMuted\",AUDIO:1,VIDEO:1},defaultselected:{$:\"defaultSelected\",OPTION:1},disablepictureinpicture:{$:\"disablePictureInPicture\",VIDEO:1},disableremoteplayback:{$:\"disableRemotePlayback\",AUDIO:1,VIDEO:1},preservespitch:{$:\"preservesPitch\",AUDIO:1,VIDEO:1},shadowrootclonable:{$:\"shadowRootClonable\",TEMPLATE:1},shadowrootdelegatesfocus:{$:\"shadowRootDelegatesFocus\",TEMPLATE:1},shadowrootserializable:{$:\"shadowRootSerializable\",TEMPLATE:1},sharedstoragewritable:{$:\"sharedStorageWritable\",IFRAME:1,IMG:1}});function wn(e,n){let t=wf[e];return typeof t===\"object\"?t[n]?t.$:void 0:t}var gn=new Set([\"beforeinput\",\"click\",\"dblclick\",\"contextmenu\",\"focusin\",\"focusout\",\"input\",\"keydown\",\"keyup\",\"mousedown\",\"mousemove\",\"mouseout\",\"mouseover\",\"mouseup\",\"pointerdown\",\"pointermove\",\"pointerout\",\"pointerover\",\"pointerup\",\"touchend\",\"touchmove\",\"touchstart\"]),Ot=new Set([\"altGlyph\",\"altGlyphDef\",\"altGlyphItem\",\"animate\",\"animateColor\",\"animateMotion\",\"animateTransform\",\"circle\",\"clipPath\",\"color-profile\",\"cursor\",\"defs\",\"desc\",\"ellipse\",\"feBlend\",\"feColorMatrix\",\"feComponentTransfer\",\"feComposite\",\"feConvolveMatrix\",\"feDiffuseLighting\",\"feDisplacementMap\",\"feDistantLight\",\"feDropShadow\",\"feFlood\",\"feFuncA\",\"feFuncB\",\"feFuncG\",\"feFuncR\",\"feGaussianBlur\",\"feImage\",\"feMerge\",\"feMergeNode\",\"feMorphology\",\"feOffset\",\"fePointLight\",\"feSpecularLighting\",\"feSpotLight\",\"feTile\",\"feTurbulence\",\"filter\",\"font\",\"font-face\",\"font-face-format\",\"font-face-name\",\"font-face-src\",\"font-face-uri\",\"foreignObject\",\"g\",\"glyph\",\"glyphRef\",\"hkern\",\"image\",\"line\",\"linearGradient\",\"marker\",\"mask\",\"metadata\",\"missing-glyph\",\"mpath\",\"path\",\"pattern\",\"polygon\",\"polyline\",\"radialGradient\",\"rect\",\"set\",\"stop\",\"svg\",\"switch\",\"symbol\",\"text\",\"textPath\",\"tref\",\"tspan\",\"use\",\"view\",\"vkern\"]),xn={xlink:\"http://www.w3.org/1999/xlink\",xml:\"http://www.w3.org/XML/1998/namespace\"};function gf(e,n,t){let i=t.length,f=n.length,u=i,r=0,l=0,c=n[f-1].nextSibling,s=null;while(rd-l){let o=n[r];while(l{f=u,n===document?e():Sn(n,e(),n.firstChild?null:void 0,t)},i.owner),()=>{f(),n.textContent=\"\"}}function On(e,n=window.document){let t=n[$t]||(n[$t]=new Set);for(let i=0,f=e.length;if.call(e,t[1],u))}else e.addEventListener(n,t,typeof t!==\"function\"&&t)}function Cn(e,n,t={}){let i=Object.keys(n||{}),f=Object.keys(t),u,r;for(u=0,r=f.length;uf.children=oe(e,n.children,f.children));return M(()=>typeof n.ref===\"function\"&&kf(n.ref,e)),M(()=>Ef(e,n,t,!0,f,!0)),f}function Ct(e,n){let t=e[n];return Object.defineProperty(e,n,{get(){return t()},enumerable:!0}),e}function kf(e,n,t){return I(()=>e(n,t))}function Sn(e,n,t,i){if(t!==void 0&&!i)i=[];if(typeof n!==\"function\")return oe(e,n,i,t);M((f)=>oe(e,n(),f,t),i)}function Ef(e,n,t,i,f={},u=!1){n||(n={});for(let r in f)if(!(r in n)){if(r===\"children\")continue;f[r]=gt(e,r,null,f[r],t,u,n)}for(let r in n){if(r===\"children\"){if(!i)oe(e,n.children);continue}let l=n[r];f[r]=gt(e,r,l,f[r],t,u,n)}}function he(e){return!!z.context&&!z.done&&(!e||e.isConnected)}function Cf(e){return e.toLowerCase().replace(/-([a-z])/g,(n,t)=>t.toUpperCase())}function wt(e,n,t){let i=n.trim().split(/\\s+/);for(let f=0,u=i.length;f-1&&xn[n.split(\":\")[0]];if(k)kn(e,k,n,t);else Ae(e,$n[n]||n,t)}return t}function Af(e){if(z.registry&&z.events){if(z.events.find(([c,s])=>s===e))return}let n=e.target,t=`$$${e.type}`,i=e.target,f=e.currentTarget,u=(c)=>Object.defineProperty(e,\"target\",{configurable:!0,value:c}),r=()=>{let c=n[t];if(c&&!n.disabled){let s=n[`${t}Data`];if(s!==void 0?c.call(n,s,e):c.call(n,e),e.cancelBubble)return}return n.host&&typeof n.host!==\"string\"&&!n.host._$host&&n.contains(e.target)&&u(n.host),!0},l=()=>{while(r()&&(n=n._$host||n.parentNode||n.host));};if(Object.defineProperty(e,\"currentTarget\",{configurable:!0,get(){return n||document}}),z.registry&&!z.done)z.done=_$HY.done=!0;if(e.composedPath){let c=e.composedPath();u(c[0]);for(let s=0;s{let c=n();while(typeof c===\"function\")c=c();t=oe(e,c,t,i)}),()=>t;else if(Array.isArray(n)){let c=[],s=t&&Array.isArray(t);if(an(c,n,t,f))return M(()=>t=oe(e,c,t,i,!0)),()=>t;if(u){if(!c.length)return t;if(i===void 0)return t=[...e.childNodes];let d=c[0];if(d.parentNode!==e)return t;let g=[d];while((d=d.nextSibling)!==i)g.push(d);return t=g}if(c.length===0){if(t=de(e,t,i),l)return t}else if(s)if(t.length===0)xt(e,c,i);else gf(e,t,c);else t&&de(e),xt(e,c);t=c}else if(n.nodeType){if(u&&n.parentNode)return t=l?[n]:n;if(Array.isArray(t)){if(l)return t=de(e,t,i,n);de(e,t,null,n)}else if(t==null||t===\"\"||!e.firstChild)e.appendChild(n);else e.replaceChild(n,e.firstChild);t=n}else console.warn(\"Unrecognized value. Skipped inserting\",n);return t}function an(e,n,t,i){let f=!1;for(let u=0,r=n.length;u=0;r--){let l=n[r];if(f!==l){let c=l.parentNode===e;if(!u&&!r)c?e.replaceChild(f,l):e.insertBefore(f,t);else c&&l.remove()}else u=!0}}else e.insertBefore(f,t);return[f]}var Xu=Symbol();var Vn=Symbol(\"store-raw\"),ye=Symbol(\"store-node\"),ee=Symbol(\"store-has\"),St=Symbol(\"store-self\"),At={onStoreNodeUpdate:null};function Vt(e){let n=e[X];if(!n){if(Object.defineProperty(e,X,{value:n=new Proxy(e,Nf)}),!Array.isArray(e)){let t=Object.keys(e),i=Object.getOwnPropertyDescriptors(e);for(let f=0,u=t.length;fe[X][n],t}function Nt(e){Ge()&&Ve(qe(e,ye),St)()}function Vf(e){return Nt(e),Reflect.ownKeys(e)}var Nf={get(e,n,t){if(n===Vn)return e;if(n===X)return t;if(n===cn)return Nt(e),t;let i=qe(e,ye),f=i[n],u=f?f():e[n];if(n===ye||n===ee||n===\"__proto__\")return u;if(!f){let r=Object.getOwnPropertyDescriptor(e,n);if(Ge()&&(typeof u!==\"function\"||e.hasOwnProperty(n))&&!(r&&r.get))u=Ve(i,n,u)()}return te(u)?Vt(u):u},has(e,n){if(n===Vn||n===X||n===cn||n===ye||n===ee||n===\"__proto__\")return!0;return Ge()&&Ve(qe(e,ee),n)(),n in e},set(){return console.warn(\"Cannot mutate a Store directly\"),!0},deleteProperty(){return console.warn(\"Cannot mutate a Store directly\"),!0},ownKeys:Vf,getOwnPropertyDescriptor:Sf};function J(e,n,t,i=!1){if(!i&&e[n]===t)return;let f=e[n],u=e.length;if(At.onStoreNodeUpdate&&At.onStoreNodeUpdate(e,n,t,f),t===void 0){if(delete e[n],e[ee]&&e[ee][n]&&f!==void 0)e[ee][n].$()}else if(e[n]=t,e[ee]&&e[ee][n]&&f===void 0)e[ee][n].$();let r=qe(e,ye),l;if(l=Ve(r,n,f))l.$(()=>t);if(Array.isArray(e)&&e.length!==u){for(let c=e.length;c1){i=n.shift();let r=typeof i,l=Array.isArray(e);if(Array.isArray(i)){for(let c=0;c1){Se(e[i],n,[i].concat(t));return}f=e[i],t=[i].concat(t)}let u=n[0];if(typeof u===\"function\"){if(u=u(f,t),u===f)return}if(i===void 0&&u==null)return;if(u=be(u),i===void 0||te(f)&&te(u)&&!Array.isArray(u))Pt(f,u);else J(e,i,u)}function Tt(...[e,n]){let t=be(e||{}),i=Array.isArray(t);if(typeof t!==\"object\"&&typeof t!==\"function\")throw Error(`Unexpected type ${typeof t} received when initializing 'createStore'. Expected an object.`);let f=Vt(t);bt.registerGraph({value:t,name:n&&n.name});function u(...r){Ce(()=>{i&&r.length===1?Pf(t,r[0]):Se(t,r)})}return[f,u]}var Nn=Symbol(\"store-root\");function ae(e,n,t,i,f){let u=n[t];if(e===u)return;let r=Array.isArray(e);if(t!==Nn&&(!te(e)||!te(u)||r!==Array.isArray(u)||f&&e[f]!==u[f])){J(n,t,e);return}if(r){if(e.length&&u.length&&(!i||f&&e[0]&&e[0][f]!=null)){let s,d,g,k,b,o,a,w;for(g=0,k=Math.min(u.length,e.length);g=g&&b>=g&&(u[k]===e[b]||f&&u[k]&&e[b]&&u[k][f]&&u[k][f]===e[b][f]);k--,b--)E[b]=u[k];if(g>b||g>k){for(d=g;d<=b;d++)J(u,d,e[d]);for(;de.length)J(u,\"length\",e.length);return}a=Array(b+1);for(d=b;d>=g;d--)o=e[d],w=f&&o?o[f]:o,s=N.get(w),a[d]=s===void 0?-1:s,N.set(w,d);for(s=g;s<=k;s++)if(o=u[s],w=f&&o?o[f]:o,d=N.get(w),d!==void 0&&d!==-1)E[d]=u[s],d=a[d],N.set(w,d);for(d=g;de.length)J(u,\"length\",e.length);return}let l=Object.keys(e);for(let s=0,d=l.length;s{if(!te(u)||!te(f))return f;let r=ae(f,{[Nn]:u},Nn,t,i);return r===void 0?u:r}}var Tf=/(?:|<(?:\"[^\"]*\"['\"]*|'[^']*'['\"]*|[^'\">])+>)/g,Uf=/(?:\\s(?[^/\\s><=]+?)(?=[\\s/>]))|(?:(?\\S+?)(?:\\s*=\\s*(?:(['\"])(?[\\s\\S]*?)\\3|(?[^\\s>]+))))/g,_f={area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,menuitem:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0};function vf(e){let n={type:\"tag\",name:\"\",voidElement:!1,attrs:[],children:[]},t=e.match(/<\\/?([^\\s]+?)[/\\s>]/);if(t){if(n.name=t[1],_f[t[1].toLowerCase()]||e.charAt(e.length-2)===\"/\")n.voidElement=!0;if(n.name.startsWith(\"!--\")){let f=e.indexOf(\"-->\");return{type:\"comment\",comment:f!==-1?e.slice(4,f):\"\"}}}let i=new RegExp(Uf);for(let f of e.matchAll(i))if((f[1]||f[2]).startsWith(\"use:\"))n.attrs.push({type:\"directive\",name:f[1]||f[2],value:f[4]||f[5]||\"\"});else n.attrs.push({type:\"attr\",name:f[1]||f[2],value:f[4]||f[5]||\"\"});return n}function _t(e,n,t){let i=n.indexOf(\"<\",t),f=n.slice(t,i===-1?void 0:i);if(!/^\\s*$/.test(f))e.push({type:\"text\",content:f})}function vt(e,n){let t=n.replace(\"\",\"\");if(!/^\\s*$/.test(t))e.push({type:\"comment\",content:t})}function Ff(e){let n=[],t=void 0,i=-1,f=[],u={};return e.replace(Tf,(r,l)=>{let c=r.charAt(1)!==\"/\",s=r.slice(0,4)===\"\"}}function Kf(e){return e.reduce(function(n,t){return n+Kt(\"\",t)},\"\")}var Ft=new Map,Wf=/^(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$/i,Je=\" \\\\f\\\\n\\\\r\\\\t\",Wt=\"[^\"+Je+`\\\\/>\"'=]+`,Tn=\"[ \"+Je+\"]+(?:use:|\"+Wt+\")\",Yt=\"<([A-Za-z$#]+[A-Za-z0-9:_-]*)((?:\",It=`(?:\\\\s*=\\\\s*(?:'[^']*?'|\"[^\"]*?\"|\\\\([^)]*?\\\\)|<[^>]*?>|`+Wt+\"))?)\",Yf=new RegExp(Yt+Tn+It+\"+)([ \"+Je+\"]*/?>)\",\"g\"),If=new RegExp(\"(\"+Tn+`\\\\s*=\\\\s*)(|['\"(]([\\\\w\\\\s]*[\\\\w\\\\s]*)*['\")])`,\"gi\"),jf=new RegExp(Yt+Tn+It+\"*)([ \"+Je+\"]*/>)\",\"g\"),zt=\"\",Gf=new Set([\"class\",\"on\",\"oncapture\",\"style\",\"use\",\"prop\",\"attr\"]);function Df(e,n,t,i){return\"<\"+n+t.replace(If,Hf)+i}function Hf(e,n,t){return n.replace(//g,\"###\")+(t[0]==='\"'||t[0]===\"'\"?t.replace(//g,\"###\"):'\"###\"')}function Zf(e,n,t){return Wf.test(n)?e:\"<\"+n+t+\">\"}function qf(e){return e.toLowerCase().replace(/-([a-z])/g,(n,t)=>t.toUpperCase())}function Pn(e,n,t,i){if(e===\"use:###\"&&n===\"###\"){let f=i.counter++;i.exprs.push(`typeof exprs[${f}] === \"function\" ? r.use(exprs[${f}], ${t}, exprs[${i.counter++}]) : (()=>{throw new Error(\"use:### must be a function\")})()`)}else throw Error(`Not support syntax ${e} must be use:{function}`)}function Jf(e,{delegateEvents:n=!0,functionBuilder:t=(...i)=>Function(...i)}={}){let i=1;e.wrapProps=(b)=>{let o=Object.getOwnPropertyDescriptors(b);for(let a in o)if(typeof o[a].value===\"function\"&&!o[a].value.length)e.dynamicProperty(b,a);return b};function f(b,o){let a=0,w=\"\";for(;a\";w=w+b[a],w=[[jf,Zf],[/<()/g,\"<###\"],[/\\.\\.\\.()/g,\"###\"],[Yf,Df],[/>\\n+\\s*/g,\">\"],[/\\n+\\s*\\s+/g,\"> \"]].reduce((T,_)=>{return T.replace(_[0],_[1])},w);let N=Ff(w),[A,C]=g(N,o.funcBuilder),V=[];for(let T=0;T-1){let m=K.split(zt).reduce((p,_e,ge)=>{return ge&&p.push(\"\"),p.push(_e),p},[]);_[T].firstChild.replaceWith(...m)}}}return V[0].create=C,Ft.set(b,V),V}function u(b,o,a,w,E,N,A){let C=w===\"###\"?`!doNotWrap ? exprs[${A.counter}]() : exprs[${A.counter++}]`:w.split(\"###\").map((K,m)=>m?` + (typeof exprs[${A.counter}] === \"function\" ? exprs[${A.counter}]() : exprs[${A.counter++}]) + \"${K}\"`:`\"${K}\"`).join(\"\"),V,T;if((V=a.split(\":\"))&&V[1]&&Gf.has(V[0]))a=V[1],T=V[0];let _=e.ChildProperties.has(a),j=e.Properties.has(a);if(a===\"style\"){let K=`_$v${i++}`;A.decl.push(`${K}={}`),A.exprs.push(`r.style(${o},${C},${K})`)}else if(a===\"classList\"){let K=`_$v${i++}`;A.decl.push(`${K}={}`),A.exprs.push(`r.classList(${o},${C},${K})`)}else if(T!==\"attr\"&&(_||!E&&(e.getPropAlias(a,b.name.toUpperCase())||j)||N||T===\"prop\")){if(N&&!_&&!j&&T!==\"prop\")a=qf(a);A.exprs.push(`${o}.${e.getPropAlias(a,b.name.toUpperCase())||a} = ${C}`)}else{let K=E&&a.indexOf(\":\")>-1&&e.SVGNamespace[a.split(\":\")[0]];if(K)A.exprs.push(`r.setAttributeNS(${o},\"${K}\",\"${a}\",${C})`);else A.exprs.push(`r.setAttribute(${o},\"${e.Aliases[a]||a}\",${C})`)}}function r(b,o,a,w,E,N,A){if(a.slice(0,2)===\"on\")if(!a.includes(\":\")){let C=a.slice(2).toLowerCase(),V=n&&e.DelegatedEvents.has(C);A.exprs.push(`r.addEventListener(${o},\"${C}\",exprs[${A.counter++}],${V})`),V&&A.delegatedEvents.add(C)}else{let C=a.startsWith(\"oncapture:\");A.exprs.push(`${o}.addEventListener(\"${a.slice(C?10:3)}\",exprs[${A.counter++}]${C?\",true\":\"\"})`)}else if(a===\"ref\")A.exprs.push(`exprs[${A.counter++}](${o})`);else{let C=Object.assign({},A,{exprs:[]}),V=A.counter;if(u(b,o,a,w,E,N,C),A.decl.push(`_fn${V} = (${w===\"###\"?\"doNotWrap\":\"\"}) => {\n${C.exprs.join(`;\n`)};\n}`),w===\"###\")A.exprs.push(`typeof exprs[${V}] === \"function\" ? r.effect(_fn${V}) : _fn${V}(true)`);else{let T=\"\";for(let _=V;_1)for(let E=0;E1?`r.mergeProps(${o.join(\",\")})`:o[0]}function s(b,o){let a=[],w=Object.keys(b.attrs),E=[a],N=o.counter++;for(let C=0;C exprs[${o.counter++}]`);else if(b.children.length){let C={type:\"fragment\",children:b.children},V=Object.assign({},o,{first:!0,decl:[],exprs:[],parent:!1});d(C,V),a.push(`children: () => { ${V.exprs.join(`;\n`)}}`),o.templateId=V.templateId,o.counter=V.counter}let A;if(o.multi)A=`_$el${i++}`,o.decl.push(`${A} = ${o.path}.${o.first?\"firstChild\":\"nextSibling\"}`);if(o.parent)o.exprs.push(`r.insert(${o.parent}, r.createComponent(exprs[${N}],${c(E)})${A?`, ${A}`:\"\"})`);else o.exprs.push(`${o.fragment?\"\":\"return \"}r.createComponent(exprs[${N}],${c(E)})`);o.path=A,o.first=!1}function d(b,o){if(b.type===\"fragment\"){let a=[];b.children.forEach((w)=>{if(w.type===\"tag\"){if(w.name===\"###\"){let A=Object.assign({},o,{first:!0,fragment:!0,decl:[],exprs:[]});s(w,A),a.push(A.exprs[0]),o.counter=A.counter,o.templateId=A.templateId;return}o.templateId++;let E=i,N=Object.assign({},o,{first:!0,decl:[],exprs:[]});o.templateNodes.push([w]),d(w,N),a.push(`function() { ${N.decl.join(`,\n`)+`;\n`+N.exprs.join(`;\n`)+`;\nreturn _$el${E};\n`}}()`),o.counter=N.counter,o.templateId=N.templateId}else if(w.type===\"text\")a.push(`\"${w.content}\"`);else if(w.type===\"comment\"){if(w.content===\"#\")a.push(`exprs[${o.counter++}]`);else if(w.content)for(let E=0;EC.name===\"is\");if(o.hasCustomElement=A,o.isImportNode=(b.name===\"img\"||b.name===\"iframe\")&&b.attrs.some((C)=>C.name===\"loading\"&&C.value===\"lazy\"),b.attrs.some((C)=>C.name===\"###\")){let C=[],V=\"\",T=[];for(let _=0;_({${V}})`),V=\"\";C.push(`exprs[${o.counter++}]`)}else T.push(b.attrs[_]);else if(j===\"directive\")Pn(K,m,a,o)}if(b.attrs=T,V.length)C.push(`()=>({${V}})`);o.exprs.push(`r.spread(${a},${C.length===1?`typeof ${C[0]} === \"function\" ? r.mergeProps(${C[0]}) : ${C[0]}`:`r.mergeProps(${C.join(\",\")})`},${N},${!!b.children.length})`)}else for(let C=0;C document.importNode(tmpls[${E}].content.firstChild, true))`:`const ${a} = tmpls[${E}].content.firstChild.cloneNode(true)`}else if(b.type===\"text\"){let a=`_$el${i++}`;o.decl.push(`${a} = ${o.path}.${o.first?\"firstChild\":\"nextSibling\"}`),o.path=a,o.first=!1}else if(b.type===\"comment\"){let a=`_$el${i++}`;if(o.decl.push(`${a} = ${o.path}.${o.first?\"firstChild\":\"nextSibling\"}`),b.content===\"#\")if(o.multi)o.exprs.push(`r.insert(${o.parent}, exprs[${o.counter++}], ${a})`);else o.exprs.push(`r.insert(${o.parent}, exprs[${o.counter++}])`);o.path=a,o.first=!1}}function g(b,o){let a={path:\"\",decl:[],exprs:[],delegatedEvents:new Set,counter:0,first:!0,multi:!1,templateId:0,templateNodes:[]},w=i,E=b,N;if(b.length>1)b=[{type:\"fragment\",children:b}];if(b[0].name===\"###\")N=!0,s(b[0],a);else d(b[0],a);return e.delegateEvents(Array.from(a.delegatedEvents)),[[E].concat(a.templateNodes).map((C)=>Kf(C)),o(\"tmpls\",\"exprs\",\"r\",a.decl.join(`,\n`)+`;\n`+a.exprs.join(`;\n`)+(N?\"\":`;\nreturn _$el${w};\n`))]}function k(b,...o){let a=Ft.get(b)||f(b,{funcBuilder:t});return a[0].create(a,o,e)}return k}var h=Jf({effect:M,style:An,insert:Sn,untrack:I,spread:Et,createComponent:on,delegateEvents:On,classList:Cn,mergeProps:hn,dynamicProperty:Ct,setAttribute:Ae,setAttributeNS:kn,addEventListener:En,Aliases:$n,getPropAlias:wn,Properties:yn,ChildProperties:bn,DelegatedEvents:gn,SVGElements:Ot,SVGNamespace:xn});function Ne(e){let n=Math.floor(e/3600),t=Math.floor(e%3600/60),i=e%60;if(n>0)return`${n}h ${t}m`;return t>0?`${t}m ${i}s`:`${i}s`}function Xe(e,n){if(!e)return 0;let t=new Date(e).getTime(),i=n?new Date(n).getTime():Date.now();return Math.round((i-t)/1000)}var _n=200,jt=300,vn=100;function Qe(e,n,t){if(t||e.length<=n)return{rows:e,total:e.length,capped:!1};return{rows:e.slice(0,n),total:e.length,capped:!0}}function Fn(e,n){return e.length===n.length&&e.every((t,i)=>t===n[i])}var Xf=new Set([\"loops\",\"groups\",\"findings\",\"plans\"]),Qf=new Set([\"overview\",\"timeline\",\"sections\",\"findings\",\"plan\",\"usage\"]);function ie(e){try{return decodeURIComponent(e)}catch{return e}}function Bf(){return{projectId:null,section:\"loops\",loopName:null,tab:\"overview\",groupId:null,statuses:[],query:\"\"}}function Un(e,n){if(!n)return;for(let t of n.split(\"&\")){if(!t)continue;let i=t.indexOf(\"=\");if(i===-1)continue;let f=ie(t.slice(0,i)),u=ie(t.slice(i+1));if(f===\"status\")e.statuses=u.split(\",\").map((r)=>r.trim()).filter((r)=>r!==\"\");else if(f===\"q\")e.query=u}}function Be(e){let n=Bf(),t=(e||\"\").replace(/^#/,\"\");if(!t)return n;let i=t.indexOf(\"?\"),f=i===-1?t:t.slice(0,i),u=i===-1?\"\":t.slice(i+1),r=f.indexOf(\"/\"),l,c;if(r===-1)l=f,c=\"\";else l=f.slice(0,r),c=f.slice(r+1);if(l)n.projectId=ie(l);if(!c)return Un(n,u),n;let s=c.split(\"/\").filter((g)=>g!==\"\");if(s.length===0)return Un(n,u),n;let d=ie(s[0]);if(d===\"loop\"){if(s.length>=2){if(n.loopName=ie(s[1]),s.length>=3){let g=ie(s[2]);if(Qf.has(g))n.tab=g}}}else if(Xf.has(d)){if(n.section=d,d===\"groups\"&&s.length>=2)n.groupId=ie(s[1])}else n.loopName=d;return Un(n,u),n}function Re(e){if(!e.projectId)return\"\";let n=\"#\"+encodeURIComponent(e.projectId);if(e.section===\"loops\"){if(e.loopName){if(n+=\"/loop/\"+encodeURIComponent(e.loopName),e.tab!==\"overview\")n+=\"/\"+e.tab}}else if(e.section===\"groups\"){if(n+=\"/groups\",e.groupId)n+=\"/\"+encodeURIComponent(e.groupId)}else n+=\"/\"+e.section;let t=[];if(e.statuses.length>0)t.push(\"status=\"+e.statuses.map((i)=>encodeURIComponent(i)).join(\",\"));if(e.query)t.push(\"q=\"+encodeURIComponent(e.query));if(t.length>0)n+=\"?\"+t.join(\"&\");return n}function Gt(e,n){let i=\"#\"+(location.hash||\"\").replace(/^#/,\"\"),f=\"#\"+e.replace(/^#/,\"\");if(i!==f)n.current=!0,location.hash=e}function D(e){if(!e||e===0)return\"\";let n=new Date(e),t=(c)=>c<10?\"0\"+c:String(c),i=t(n.getMonth()+1),f=t(n.getDate()),u=n.getFullYear(),r=n.getHours(),l=r>=12?\"PM\":\"AM\";if(r=r%12,r===0)r=12;return i+\"-\"+f+\"-\"+u+\" \"+r+\":\"+t(n.getMinutes())+\" \"+l}function fe(e){return\"status-badge status-\"+e}function zn(e){return\"section-status section-\"+e}function Dt(e){return\"feature-stage feature-stage-\"+e}function Ht(e,n,t,i){if(!(n.size===0||n.has(e.status)))return!1;if(!t)return!0;let u=t.toLowerCase(),r=e.worktreeBranch??\"\";return((e.loopName||\"\")+\" \"+r+\" \"+(i||\"\")).toLowerCase().indexOf(u)!==-1}function Zt(e,n){if(n===\"recent\")return[...e];let t=[...e];if(n===\"cost\")t.sort((i,f)=>{let u=i.usage?.totalCost??null,r=f.usage?.totalCost??null;if(u===null&&r===null)return 0;if(u===null)return 1;if(r===null)return-1;return r-u});else if(n===\"duration\")t.sort((i,f)=>{let u=Xe(i.loop.startedAt,i.loop.completedAt??void 0);return Xe(f.loop.startedAt,f.loop.completedAt??void 0)-u});else if(n===\"findings\")t.sort((i,f)=>f.findings.length-i.findings.length);return t}function qt(e){return JSON.stringify(e.projects)}function Jt(e){let n=new Map;for(let f of e){if(f===\"\"){n.set(f,[]);continue}let u=f.split(\"/\"),r=u.length;while(r>0&&u[r-1]===\"\")r--;n.set(f,r>0?u.slice(0,r):[f])}let t=new Map;for(let f of e){let u=n.get(f);t.set(f,u.length?u[u.length-1]:f)}let i=new Map;for(let f of e){let u=t.get(f),r=i.get(u);if(r)r.push(f);else i.set(u,[f])}for(let[,f]of i){let u=[...new Set(f)];if(u.length<2)continue;let r=1;while(r<1024){let l=new Set,c=!0,s=!0;for(let d of u){let g=n.get(d),k=Math.max(0,g.length-r),b=g.slice(k).join(\"/\");if(l.has(b))c=!1;else l.add(b);if(k>0)s=!1}if(c||s)break;r++}for(let l of u){let c=n.get(l),s=Math.max(0,c.length-r);t.set(l,c.slice(s).join(\"/\"))}}return t}function Kn(e){let n=[],t=[];for(let i of e)if(i.severity===\"bug\")n.push(i);else t.push(i);return{bugs:n,warnings:t}}function Xt(e){if(e.bugs.length>0)return\"bug\";if(e.warnings.length>0)return\"warn\";return\"clean\"}function $e(e,n){return e+\" \"+n+(e===1?\"\":\"s\")}function Wn(e,n){if(!n||n<=0)return 0;return Math.max(0,Math.min(100,e/n*100))}function Qt(e){let n=e.file+\":\"+e.line+\" — \"+e.description;if(e.scenario)n+=\" (\"+e.scenario+\")\";return n}function Bt(e,n){return\" \"+e+\": $\"+n.cost.toFixed(6)+\", \"+n.inputTokens+\" in / \"+n.outputTokens+\" out (reasoning: \"+n.reasoningTokens+\", cache R: \"+n.cacheReadTokens+\" W: \"+n.cacheWriteTokens+\"), messages: \"+n.messageCount}function Te(e){if(!Number.isFinite(e)||e<=0)return\"0\";if(e>=1e6)return(e/1e6).toFixed(1).replace(/\\.0$/,\"\")+\"M\";if(e>=1000)return(e/1000).toFixed(1).replace(/\\.0$/,\"\")+\"k\";return String(e)}function Le(e){if(!Number.isFinite(e)||e<=0)return\"$0\";return\"$\"+(e>=1?e.toFixed(2):e.toFixed(4))}function Rt(e){let n=[{label:\"Input\",value:e.totalInputTokens,color:\"var(--seg-input)\"},{label:\"Output\",value:e.totalOutputTokens,color:\"var(--seg-output)\"},{label:\"Reasoning\",value:e.totalReasoningTokens,color:\"var(--seg-reasoning)\"},{label:\"Cache R\",value:e.totalCacheReadTokens,color:\"var(--seg-cache-read)\"},{label:\"Cache W\",value:e.totalCacheWriteTokens,color:\"var(--seg-cache-write)\"}],t=n.reduce((i,f)=>i+Math.max(0,f.value),0);return n.map((i)=>({...i,pct:t>0?Math.max(0,i.value)/t*100:0}))}function Lt(e){let n={role:\"execution\",cost:0,messageCount:0,tokens:0,pct:0},t={role:\"auditor\",cost:0,messageCount:0,tokens:0,pct:0},i={role:\"other\",cost:0,messageCount:0,tokens:0,pct:0};for(let[r,l]of Object.entries(e.byRole??{})){let c=r===\"auditor\"?t:r===\"code\"?n:i;c.cost+=l.cost,c.messageCount+=l.messageCount,c.tokens+=(l.inputTokens??0)+(l.outputTokens??0)+(l.reasoningTokens??0)}let f=i.cost>0||i.messageCount>0?[n,t,i]:[n,t],u=f.reduce((r,l)=>Math.max(r,l.cost),0);for(let r of f)r.pct=u>0?r.cost/u*100:0;return f}function Mt(e){let n=Object.keys(e.byModel).map((i)=>({model:i,...e.byModel[i]})),t=n.reduce((i,f)=>Math.max(i,f.cost),0);return n.sort((i,f)=>f.cost-i.cost).map((i)=>({model:i.model,cost:i.cost,inputTokens:i.inputTokens,outputTokens:i.outputTokens,messageCount:i.messageCount,pct:t>0?i.cost/t*100:0}))}function mt(e,n){if(!e)return\"\";let t=Xe(e,n??void 0);return t>0?Ne(t):\"\"}function we(e){if(!e||e===0)return\"\";let n=Date.now()-e,t=Math.floor(n/1000);if(t<60)return t+\"s ago\";let i=Math.floor(t/60);if(i<60)return i+\"m ago\";let f=Math.floor(i/60);if(f<24)return f+\"h ago\";let u=Math.floor(f/24);if(u<30)return u+\"d ago\";return D(e)}var Rf=/([\\s\\S]*?)<\\/h\\1>/g;function pt(e){return e.replace(/<[^>]+>/g,\"\").replace(/&[a-z#0-9]+;/gi,\"\").trim()}function Lf(e){return pt(e).toLowerCase().replace(/[^\\w\\s-]/g,\"\").replace(/[\\s_-]+/g,\"-\").replace(/^-+|-+$/g,\"\")||\"heading\"}function Mf(e){if(!//.test(e))return{html:e,outline:[]};let n=[];return{html:e.replace(Rf,(i,f,u)=>{let r=Number(f),l=Lf(u);return n.push({depth:r,id:l,text:pt(u)}),`${u}`}),outline:n}}function ei(e){if(!e)return{html:\"\",outline:[]};let n=Pe.get(e);if(n!==void 0)return n;let t=globalThis.marked;if(!t)return{html:\"\",outline:[]};let{html:i,outline:f}=Mf(t.parse(e)),u={html:i,outline:f};if(Pe.size>=mf){let r=Pe.keys().next().value;if(r!==void 0)Pe.delete(r)}return Pe.set(e,u),u}function ni(e){return ei(e).html}function ti(e){return ei(e)}var Pe=new Map,mf=200;function Me(e){let n=[\"overview\",\"timeline\"];if(e.sectionCount>0)n.push(\"sections\");if(n.push(\"findings\"),e.hasPlan)n.push(\"plan\");return n.push(\"usage\"),n}function ii(e,n,t,i,f=\"coding\"){let u=[],r=e.findIndex((g)=>g.createdAt>=n),l=r===-1?[]:e.slice(r);if(t!==null)l=l.filter((g)=>g.createdAt<=t);let c;if(l.length>0&&l[0].eventType===\"restart\"&&l[0].toPhase!==null)c=l[0].toPhase;else if(l.length>0&&l[0].eventType!==\"restart\")c=l[0].fromPhase;else c=f;let s=n;for(let g of l){if(g.eventType===\"restart\"){if(g.toPhase!==null)c=g.toPhase;continue}if(c===\"\")c=g.fromPhase,s=Math.max(s,g.createdAt);let k=g.createdAt,b=Math.max(s,k);if(u.push({phase:c,startedAt:s,endedAt:b,durationMs:b-s,open:!1}),g.toPhase===null)c=\"\",s=b;else c=g.toPhase,s=b}if(c!==\"\"){let k=Math.max(s,t??i);u.push({phase:c,startedAt:s,endedAt:t,durationMs:k-s,open:t===null})}let d=e.length>0&&e[0].createdAt>=n&&e[0].createdAt>n+1000;if(d&&u.length>0)u[0]={...u[0],phase:\"\"};return{spans:u,truncated:d}}function fi(e){let n={};for(let t of e){if(!t.phase)continue;n[t.phase]=(n[t.phase]??0)+t.durationMs}return n}function ui(e,n,t){let i=[];for(let f=0;f=n)r=Math.max(0,u.createdAt-n);else if(u.createdAt=n)r=Math.max(0,u.createdAt-n);else r=null}i.push({transition:u,elapsedMs:r})}return i.reverse(),i}var pf=[{phase:\"coding\",label:\"coding\",cx:70,cy:60},{phase:\"auditing\",label:\"auditing\",cx:220,cy:60},{phase:\"final_auditing\",label:\"final_auditing\",cx:370,cy:60},{phase:\"post_action\",label:\"post_action\",cx:520,cy:60},{phase:\"final_audit_fix\",label:\"final_audit_fix\",cx:370,cy:160}],me=110,pe=36,ri=655,li=160,eu=[{key:\"coding→auditing\",d:\"M 125 60 L 165 60\",labelX:145,labelY:52},{key:\"auditing→final_auditing\",d:\"M 275 60 L 315 60\",labelX:295,labelY:52},{key:\"final_auditing→post_action\",d:\"M 425 60 L 465 60\",labelX:445,labelY:52},{key:\"auditing→coding\",d:\"M 220 42 Q 145 14 70 42\",labelX:145,labelY:18},{key:\"auditing→post_action\",d:\"M 220 42 Q 370 8 520 42\",labelX:370,labelY:16},{key:\"final_auditing→auditing\",d:\"M 315 42 Q 295 24 275 42\",labelX:295,labelY:30},{key:\"final_auditing→coding\",d:\"M 370 78 C 370 110 145 110 70 78\",labelX:220,labelY:102},{key:\"final_auditing→final_audit_fix\",d:\"M 370 78 L 370 142\",labelX:358,labelY:112},{key:\"final_audit_fix→final_auditing\",d:\"M 415 142 L 415 78\",labelX:442,labelY:116},{key:\"coding→final_audit_fix\",d:\"M 70 78 C 70 95 200 115 315 142\",labelX:175,labelY:118},{key:\"final_audit_fix→coding\",d:\"M 315 178 C 250 210 100 210 70 78\",labelX:175,labelY:205},{key:\"coding→terminal\",d:\"M 125 78 L 600 142\",labelX:240,labelY:90},{key:\"auditing→terminal\",d:\"M 275 78 L 600 142\",labelX:395,labelY:90},{key:\"final_auditing→terminal\",d:\"M 425 78 L 600 142\",labelX:505,labelY:90},{key:\"post_action→terminal\",d:\"M 520 78 L 600 142\",labelX:565,labelY:95},{key:\"final_audit_fix→terminal\",d:\"M 425 160 L 600 160\",labelX:512,labelY:152}];function nu(e){if(e.toPhase===null)return e.fromPhase+\"→terminal\";return e.fromPhase+\"→\"+e.toPhase}function tu(e){return e.toPhase??e.status??\"\"}function Yn(e){let n=Array.isArray(e)?e[0]:e,t=n instanceof SVGSVGElement?n.firstElementChild:null;if(!(t instanceof SVGElement))throw Error(\"Expected an SVG child\");return t}function ci(e){let n=()=>e.loop(),t=$(()=>n().status===\"running\"),i=$(()=>{let l=new Map;for(let c of e.transitions()){let s=nu(c);l.set(s,(l.get(s)??0)+1)}return l}),f=eu.map((l)=>Yn(h`\n \n \n ${()=>{let c=i().get(l.key);return c?String(c):\"\"}}\n \n `)),u=pf.map((l)=>{let c=l.cx-me/2,s=l.cy-pe/2;return Yn(h`\n \"mg-node\"+(n().phase===l.phase&&t()?\" mg-node-active\":\"\")}>\n \n ${l.label}\n \n `)}),r=Yn(h`\n \n \n ${()=>n().status}\n \n `);return h`
\n \n \n \n \n \n \n\n ${f}\n ${u}\n ${()=>t()?\"\":r}\n \n\n
\n
Transition history
\n
\n ${()=>e.transitions().slice(-20).reverse().map((l)=>h`
\n ${l.eventType}\n ${l.fromPhase} → ${tu(l)}\n ${we(l.createdAt)}\n
`)}\n
\n
\n
`}function hi(e){let[n,t]=U(e.query()),i=null,f=(r)=>{let l=r.target.value;if(t(l),i)clearTimeout(i);i=setTimeout(()=>{i=null,e.onChangeQuery(l)},250)};ce(()=>{if(i)clearTimeout(i),i=null}),R(()=>{e.externalNav();let r=I(()=>e.query());if(i!==null)clearTimeout(i),i=null;t(r)});let u=$(()=>{let r=e.counts();return[{label:\"Running\",key:\"running\",value:r.running},{label:\"Completed\",key:\"completed\",value:r.completed},{label:\"Cancelled\",key:\"cancelled\",value:r.cancelled},{label:\"Errored\",key:\"errored\",value:r.errored},{label:\"Stalled\",key:\"stalled\",value:r.stalled}]});return h`
\n ${()=>u().map(({label:r,key:l,value:c})=>{let d=`badge badge-filter${e.statuses().has(l)?\" badge-active\":\"\"}`;return h`e.onToggleStatus(l)}>${r}: ${c}`})}\n \n \n
`}function ai(e){return h`
Last updated: ${new Date(e.generatedAt).toLocaleString()}
`}function yi(e){let n=$(()=>e.entries().map((t)=>{let i=t.loops.some((r)=>r.loop.status===\"running\"),f=t.proj.projectDir||t.proj.projectId||\"\",u=e.labels().get(f)??f;return h`
e.onSelect(t.proj.projectId)}>\n ${i?h``:\"\"}\n ${u}\n ${t.loops.length}\n
`}));return h`
${()=>n()}
`}function iu(e,n){let t=0,i=0,f=0,u=[],r=[];for(let l of e){let c=l.proj.projectDir||l.proj.projectId||\"\",s=n.get(c)??c;for(let d of l.loops){i++;for(let b of d.findings)if(b.severity===\"bug\")f++;let g=d.loop.status,k=d.loop.completedAt||d.loop.startedAt||0;if(g===\"running\")t++,u.push({projectId:l.proj.projectId,label:s,loopName:d.loop.loopName,phase:d.loop.phase||\"\"});r.push({projectId:l.proj.projectId,label:s,loopName:d.loop.loopName,status:g,when:k})}}return r.sort((l,c)=>c.when-l.when),{runningCount:t,loopCount:i,bugCount:f,runningCards:u,recentRows:r.slice(0,8)}}function bi(e){let n=$(()=>iu(e.entries(),e.labels())),t=$(()=>n().runningCount),i=$(()=>n().loopCount),f=$(()=>n().bugCount),u=$(()=>n().runningCards),r=$(()=>n().recentRows);return h`
\n
\n

All repositories

\n
${()=>`${t()} running · ${i()} loops · ${f()} open bugs`}
\n
\n ${()=>u().length>0?h`
\n

Running

\n
\n ${u().map((l)=>h`
e.onOpenLoop(l.projectId,l.loopName)}>\n ${l.label}\n ${l.loopName}\n ${l.phase}\n
`)}\n
\n
`:\"\"}\n ${()=>r().length>0?h`
\n

Recent activity

\n
\n ${r().map((l)=>h`
e.onOpenLoop(l.projectId,l.loopName)}>\n fe(l.status)}>${l.status}\n ${l.label}\n ${l.loopName}\n ${D(l.when)}\n
`)}\n
\n
`:\"\"}\n
`}function fu(e){let[n,t]=U(!1),[i,f]=U(\"\"),[u,r]=U(0),l=$(()=>{let w=i().trim().toLowerCase(),E=e.loops();return w?E.filter((N)=>N.name.toLowerCase().indexOf(w)!==-1):E}),c=$(()=>Qe(l(),vn,!1)),s=()=>{if(n())return;f(\"\");let w=e.loops(),E=w.findIndex((C)=>C.name===e.loopName),N=w.slice(0,vn),A=E>=0&&E{t(!1),f(\"\"),r(0)},g=(w)=>{if(d(),a.blur(),w!==e.loopName)e.onSelect(w)},k=h`n()?\"display:block\":\"display:none\"}\n onmousedown=${(w)=>w.preventDefault()}\n >\n ${()=>c().rows.map((w,E)=>h`\"loop-picker-option\"+(E===u()?\" loop-picker-option-active\":\"\")+(w.name===e.loopName?\" loop-picker-option-current\":\"\")}\n onclick=${()=>g(w.name)}\n >\n ${w.name}\n ${w.whenLabel}\n
`)}\n ${()=>c().total===0?h`
No matching loops
`:\"\"}\n ${()=>c().capped?h`
${()=>\"Showing \"+c().rows.length+\" of \"+c().total+\" — type to filter\"}
`:\"\"}\n
`,b=()=>{k.querySelector(\".loop-picker-option-active\")?.scrollIntoView?.({block:\"nearest\"})},o=(w)=>{if(w.key===\"ArrowDown\"||w.key===\"ArrowUp\"){if(w.preventDefault(),!n()){s();return}let E=c().rows.length;if(E===0)return;let N=w.key===\"ArrowDown\"?1:-1;r((A)=>(A+N+E)%E),b();return}if(w.key===\"Enter\"){w.preventDefault();let E=c().rows[u()];if(E)g(E.name);return}if(w.key===\"Escape\")w.preventDefault(),d(),a.blur()},a=h`n()?i():e.loopName}\n onfocus=${s}\n oninput=${(w)=>{f(w.target.value),r(0),t(!0)}}\n onkeydown=${o}\n onblur=${d}\n />`;return h`\n ${a}\n {if(w.preventDefault(),n())d(),a.blur();else a.focus()}}>▾\n ${k}\n `}function $i(e){if(e.loopName)return h`
\n ← ${e.repoLabel}\n /\n ${fu({loopName:e.loopName,loops:e.loops,onSelect:e.onSelectLoop})}\n ${()=>{let n=e.loopNav();return n?h`\n \n ${n.index+1} of ${n.total}\n \n `:\"\"}}\n
`;return h`
\n ← Repositories\n /\n ${e.repoLabel}\n ${e.projectDir}\n
`}function wi(e){let n=[{key:\"loops\",label:\"Loops\",count:e.counts.loops},{key:\"groups\",label:\"Groups\",count:e.counts.groups},{key:\"findings\",label:\"Findings\",count:e.counts.findings},{key:\"plans\",label:\"Plans\",count:e.counts.plans}];return h`
\n ${n.map((t)=>{let f=`section-nav-item${t.key===e.section?\" section-nav-active\":\"\"}`;return h`
e.onNavigate(t.key)}>\n ${t.label}\n ${t.count}\n
`})}\n
`}function Gn(e){return h`
\n ${()=>\"Showing \"+e.shown()+\" of \"+e.total()+\" \"+e.noun}\n ${e.onShowAll?h``:\"\"}\n
`}function jn(e){let n=()=>Wn(e.current(),e.total());return h`\n \"width:\"+n()+\"%\"}>\n ${()=>e.current()}/${()=>e.total()}\n `}var uu={sm:4,md:14,lg:22};function Dn(e){let n=uu[e.variant],t=$(()=>e.spans().filter((u)=>u.phase!==\"\")),i=$(()=>Math.max(0,t().reduce((u,r)=>u+r.durationMs,0))),f=$(()=>e.spans().some((u)=>u.phase===\"\"));return h`
\"phase-bar phase-bar-\"+e.variant+(f()?\" phase-bar-trunc\":\"\")} data-truncated=${()=>f()?\"true\":\"false\"} style=${()=>\"height:\"+n+\"px\"}>\n ${()=>f()?h`\\u22ef`:\"\"}\n ${()=>{let u=t(),r=i();return u.map((l)=>{let c=r>0?l.durationMs/r*100:0,s=\"phase-seg\"+(l.open?\" phase-seg-open\":\"\");return h`
`})}}\n
`}function Hn(e,n){let t=e.loop;return ii(e.transitions??[],t.startedAt,t.completedAt,t.completedAt===null?n():0,t.phase)}function gi(e){let[n,t]=U(!1),i=$(()=>Qe(e.loops(),_n,n()));return h`
\n \n \n \n \n \n \n ${()=>i().rows.map((f)=>ru({dashLoop:f,now:e.now,onOpen:e.onOpen}))}\n \n
StatusLoopPhasePhase spanIterSectionsFindingsCostDurationUpdated
\n ${()=>i().capped?Gn({shown:()=>i().rows.length,total:()=>i().total,noun:\"loops\",onShowAll:()=>t(!0)}):\"\"}\n
`}function ru(e){let n=()=>e.dashLoop.loop,t=()=>e.dashLoop,i=$(()=>Kn(t().findings)),f=$(()=>Hn(t(),e.now).spans);return h`e.onOpen(e.dashLoop.loop.loopName)}>\n fe(n().status)}>${()=>n().status}\n ${()=>n().loopName}\n ${()=>n().phase}\n ${()=>Dn({spans:f,variant:\"sm\"})}\n ${jn({current:()=>n().iteration,total:()=>n().maxIterations})}\n ${()=>n().totalSections>0?jn({current:()=>n().currentSectionIndex,total:()=>n().totalSections}):h``}\n ${()=>{let u=i();if(u.bugs.length===0&&u.warnings.length===0)return h``;return h`\n ${u.bugs.length>0?h`${$e(u.bugs.length,\"bug\")}`:\"\"}\n ${u.bugs.length>0&&u.warnings.length>0?\" · \":\"\"}\n ${u.warnings.length>0?h`${$e(u.warnings.length,\"warn\")}`:\"\"}\n `}}\n ${()=>t().usage?Le(t().usage.totalCost):h``}\n ${()=>t().duration||\"\"}\n ${()=>D(n().completedAt||n().startedAt)}\n `}function Ue(e){let n=()=>typeof e.label===\"function\"?e.label():e.label,[t,i]=U(!1),f=()=>i((l)=>!l),u=$(()=>ti(e.src()||\"\")),r=$(()=>u().outline.filter((l)=>l.depth<=3));return h`
\n
\n t()?\"false\":\"true\"}\n onclick=${f}\n onkeydown=${(l)=>{if(l.key!==\"Enter\"&&l.key!==\" \")return;l.preventDefault(),f()}}\n >\n ${()=>t()?\"▸\":\"▾\"}\n

${()=>n()}

\n
\n \"Copy \"+n()+\" as markdown\"}\n onclick=${(l)=>{l.stopPropagation();let c=l.target,s=\"Copy\";navigator.clipboard.writeText(e.src()||\"\").then(()=>{c.textContent=\"Copied!\"},()=>{c.textContent=\"Failed\"}).then(()=>{setTimeout(()=>{c.textContent=s},2000)})}}\n >Copy\n
\n
t()?\"display:none\":\"display:block\"}>\n ${()=>{let l=r();return l.length>=2?lu({items:()=>l}):null}}\n
u().html}>
\n
\n
`}function lu(e){let n=(t,i)=>{t.preventDefault();let f=t.currentTarget.closest(\".markdown-body\"),u=f?Array.from(f.querySelectorAll(\"h1,h2,h3,h4,h5,h6\")).find((r)=>r.id===i):document.getElementById(i);if(u&&typeof u.scrollIntoView===\"function\")u.scrollIntoView({behavior:\"smooth\",block:\"start\"})};return h``}function In(e){return h`
\n
${e.label}
\n
ni(e.value()||\"\")}>
\n
`}function cu(e){let n=e.sec,t=$(()=>!!n.startedAt||!!n.completedAt),i=$(()=>!!n.summaryDone),f=$(()=>!!n.summaryDeviations),u=$(()=>!!n.summaryFollowUps),r=$(()=>!!n.content),l=$(()=>t()||i()||f()||u()||r());return h`
\n ${()=>t()?h`
\n ${()=>n.startedAt?\"Started \"+D(n.startedAt):\"Not started\"}\n ${()=>n.completedAt?h` → Completed ${()=>D(n.completedAt)}`:\"\"}\n
`:\"\"}\n ${()=>i()?In({label:\"Done\",value:()=>n.summaryDone}):\"\"}\n ${()=>f()?In({label:\"Deviations\",value:()=>n.summaryDeviations}):\"\"}\n ${()=>u()?In({label:\"Follow-ups\",value:()=>n.summaryFollowUps}):\"\"}\n ${()=>r()?Ue({label:\"Section Plan\",src:()=>n.content}):\"\"}\n ${()=>l()?\"\":h`
No details captured for this section yet.
`}\n
`}function su(e){let n=e.sec,t=$(()=>mt(n.startedAt,n.completedAt));return h`
\"section-list-row section-item-\"+n.status} onclick=${e.onOpen}>\n #${n.sectionIndex}\n ${()=>n.title}\n zn(n.status)}>${()=>n.status}\n ${()=>t()?h`${()=>t()}`:\"\"}\n ${()=>n.attempts>0?h`${()=>n.attempts} attempts`:\"\"}\n \n
`}function du(e){let[n,t]=U(null),i=$(()=>{let f=n();if(f===null)return null;return e.sections().find((u)=>u.sectionIndex===f)??null});return h`
\n

Sections

\n ${()=>{let f=i();if(f)return h`
\n
t(null)}>← Back to sections
\n
\n #${f.sectionIndex}\n ${()=>f.title}\n zn(f.status)}>${()=>f.status}\n
\n ${cu({sec:f})}\n
`;return h`
\n ${e.sections().map((u)=>su({sec:u,onOpen:()=>t(u.sectionIndex)}))}\n
`}}\n
`}function H(e){return h`
\n ${e.label}\n ${()=>e.value()}\n
`}function si(e){let n=()=>Wn(e.current(),e.total());return h`
\n
\n ${e.label}\n ${()=>e.current()} / ${()=>e.total()}\n
\n
\n
\"width:\"+n()+\"%\"}>
\n
\n
`}function ou(e){let n=()=>e.dashLoop.loop,t=()=>e.dashLoop,i=$(()=>!!n().completedAt),f=$(()=>!!t().duration),u=$(()=>n().totalSections>0),r=$(()=>!!n().terminationReason),l=$(()=>Xt(e.split())),c=$(()=>!!e.dashLoop.usage);return h`
\n
\"ldh-findings ldh-findings-\"+l()}>\n ${()=>{let s=e.split();if(s.bugs.length===0&&s.warnings.length===0)return\"No findings\";let d=[];if(s.bugs.length>0)d.push($e(s.bugs.length,\"bug\"));if(s.warnings.length>0)d.push($e(s.warnings.length,\"warning\"));return d.join(\" · \")}}\n
\n
\n fe(n().status)}>${()=>n().status}\n

${()=>n().loopName}

\n ${()=>n().phase}\n
\n\n
\n ${H({label:\"Started\",value:()=>D(n().startedAt)})}\n ${()=>i()?H({label:\"Completed\",value:()=>D(n().completedAt)}):\"\"}\n ${()=>f()?H({label:\"Duration\",value:()=>t().duration||\"\"}):\"\"}\n ${H({label:\"Iteration\",value:()=>n().iteration+\" / \"+n().maxIterations})}\n ${()=>u()?H({label:\"Section\",value:()=>n().currentSectionIndex+\" / \"+n().totalSections}):\"\"}\n ${()=>c()?H({label:\"Messages\",value:()=>String(e.dashLoop.usage.totalMessageCount)}):\"\"}\n ${H({label:\"Execution model\",value:()=>n().executionModel??\"—\"})}\n ${H({label:\"Auditor model\",value:()=>n().auditorModel??\"—\"})}\n ${H({label:\"Branch\",value:()=>n().worktreeBranch??\"—\"})}\n ${H({label:\"Sandbox\",value:()=>n().sandbox?\"on\":\"off\"})}\n ${H({label:\"Kind\",value:()=>n().kind??\"—\"})}\n ${H({label:\"Audits\",value:()=>String(n().auditCount)})}\n ${H({label:\"Errors\",value:()=>String(n().errorCount)})}\n
\n\n
\n ${si({label:\"Iterations\",current:()=>n().iteration,total:()=>n().maxIterations})}\n ${()=>u()?si({label:\"Sections\",current:()=>n().currentSectionIndex,total:()=>n().totalSections}):\"\"}\n
\n\n ${()=>r()?h`
\"ldh-banner ldh-banner-\"+n().status}>${()=>n().terminationReason}
`:\"\"}\n
`}function hu(e){let n=()=>e.usage(),t=$(()=>Rt(n())),i=$(()=>Mt(n())),f=$(()=>i().length>0),u=$(()=>Lt(n()));return h`
\n

Usage

\n\n
\n
Role split
\n
\n ${()=>u().map((r)=>h`
r.role}>\n
\n r.role}>${r.role}\n ${()=>Le(r.cost)}\n
\n
\n
\"width:\"+r.pct+\"%\"}>
\n
\n
${()=>r.messageCount+\" msg · \"+Te(r.tokens)+\" tok\"}
\n
`)}\n
\n
\n\n
\n
Token composition
\n
\n ${()=>t().map((r)=>r.pct>0?h`
`:\"\")}\n
\n
\n ${()=>t().map((r)=>h`
\n \n ${r.label}\n ${Te(r.value)}\n
`)}\n
\n
\n\n ${()=>f()?h`
\n
Cost by model
\n
\n ${()=>i().map((r)=>h`
\n
\n ${r.model}\n ${Le(r.cost)}\n
\n
\n
\n
\n
\n ${Te(r.inputTokens)} in / ${Te(r.outputTokens)} out · ${r.messageCount} msg\n
\n
`)}\n
\n
`:\"\"}\n
`}function di(e){try{return JSON.parse(e)}catch{return[]}}function oi(e){return\"#\"+e.index+\" \"+e.title}function au(e){let n=e.amendment,t=$(()=>di(n.sectionsBefore)),i=$(()=>di(n.sectionsAfter));return h`
\n
\n ${()=>we(n.createdAt)}\n applied @ section ${()=>n.appliedAtSection}\n ${()=>n.source}\n ${()=>n.rationale}\n ${()=>e.expanded()?\"▾\":\"▸\"}\n
\n
e.expanded()?\"display:block\":\"display:none\"}>\n
\n ${()=>t().length>0?h`
\n
Before
\n ${()=>t().map((f)=>h`
${()=>oi(f)}
`)}\n
`:\"\"}\n ${()=>i().length>0?h`
\n
After
\n ${()=>i().map((f)=>h`
${()=>oi(f)}
`)}\n
`:\"\"}\n
\n
\n
`}function yu(e){let n=()=>e.amendments(),[t,i]=U(null);return h`
\n

Plan amendments

\n
\n ${()=>n().map((f)=>au({amendment:f,expanded:()=>t()===f.id,onToggle:()=>i(t()===f.id?null:f.id)}))}\n
\n
`}var bu=[\"overview\",\"timeline\",\"sections\",\"findings\",\"plan\",\"usage\"],$u={overview:\"Overview\",timeline:\"Timeline\",sections:\"Sections\",findings:\"Findings\",plan:\"Plan\",usage:\"Usage\"};function wu(e){return h`
\n ${()=>e.tabs().map((n)=>{let t=$u[n];return h`\"tab-item\"+(e.activeTab()===n?\" tab-active\":\"\")} data-tab=\"${n}\" onclick=${()=>e.onSelect(n)}>${t}`})}\n
`}function gu(e){let n=()=>e.dashLoop,t=()=>n().loop,i=$(()=>!!n().goal),f=$(()=>n().postActionReport??t().completionSummary??null),u=$(()=>n().postActionReport?\"Post-Action Report\":\"Completion Summary\"),r=$(()=>!!f()),l=$(()=>!!n().lastAuditResult),c=$(()=>Hn(n(),e.now));return h`
\n ${()=>Dn({spans:()=>c().spans,variant:\"md\"})}\n ${()=>i()?Ue({label:\"Goal\",src:()=>n().goal}):\"\"}\n ${ou({dashLoop:e.dashLoop,split:e.split})}\n ${()=>r()?Ue({label:u,src:()=>f()}):\"\"}\n ${()=>l()?Ue({label:\"Last Audit Result\",src:()=>n().lastAuditResult}):\"\"}\n
`}function xu(e){let n=e.ev.transition,t=e.ev.elapsedMs,i=t===null?null:Math.floor(t/1000),f=n.toPhase===null?n.status??\"\":n.toPhase;return h`
\n ${we(n.createdAt)}\n ${n.fromPhase} → ${f}\n ${n.transitionKind}\n iter ${n.iteration}\n ${()=>n.sectionIndex===null?\"—\":\"sect \"+(n.sectionIndex+1)}\n ${()=>i===null?\"—\":i>0?Ne(i):\"0s\"}\n
`}function Ou(e){let n=()=>e.dashLoop,t=()=>n().loop,i=()=>n().transitions??[],f=$(()=>Hn(n(),e.now)),u=$(()=>f().spans),r=$(()=>f().truncated),l=$(()=>fi(u())),c=$(()=>u().filter((a)=>a.phase!==\"\").reduce((a,w)=>a+w.durationMs,0)),s=$(()=>{let a=l();return Object.keys(a).filter((w)=>w!==\"\").sort((w,E)=>a[E]-a[w])}),d=$(()=>ui(i(),t().startedAt,r())),[g,k]=U(!1),b=$(()=>{let a=d();return g()?a:a.slice(0,20)}),o=$(()=>g()?0:Math.max(0,d().length-20));return h`
\n ${ci({loop:()=>t(),transitions:i})}\n ${()=>Dn({spans:u,variant:\"lg\"})}\n ${()=>r()?h`
Earlier history was truncated by the 100-row fetch window.
`:\"\"}\n\n
\n ${()=>s().map((a)=>{let w=l()[a],E=c()>0?Math.round(w/c()*100):0;return h`
\n \n ${a}\n \n ${()=>w>=1000?Ne(Math.floor(w/1000)):w+\"ms\"} · ${E}%\n
`})}\n
\n\n
\n ${()=>b().map((a)=>xu({ev:a}))}\n ${()=>o()>0?h`
k(!0)}>[ ${()=>o()} earlier events ]
`:\"\"}\n
\n
`}function ku(e){return du({sections:()=>e.dashLoop.sections})}function Eu(e){let n=[],t=new Map;for(let i of e){let f=i.sectionIndex??null;if(!t.has(f))t.set(f,[]),n.push(f);t.get(f).push(i)}return n.sort((i,f)=>{if(i===null&&f===null)return 0;if(i===null)return 1;if(f===null)return-1;return i-f}),n.map((i)=>({key:i,label:i===null?\"cross-section\":\"sect \"+(i+1),rows:t.get(i)}))}function xi(e){return h`
\n ${e.rows.map((n)=>h`
\"finding finding-\"+n.severity}>\n ${()=>we(n.createdAt)}\n ${()=>Qt(n)}\n
`)}\n
`}function Oi(e){let n=()=>Re({projectId:e.projectId(),section:\"loops\",loopName:e.loopName,tab:\"overview\",groupId:null,statuses:[],query:\"\"});return h`{if(t.defaultPrevented||t.button!==0||t.metaKey||t.ctrlKey||t.shiftKey||t.altKey)return;t.preventDefault(),e.onOpen(e.loopName)}}>${e.loopName}`}function Cu(e){let n=()=>e.dashLoop,t=$(()=>n().findings.length>0),i=$(()=>Eu(n().findings));return h`
\n ${()=>t()?i().map((f)=>h`
f.key===null?\"cross\":String(f.key)}>\n
${f.label}
\n ${xi({rows:f.rows})}\n
`):h`
No findings recorded for this loop.
`}\n
`}function Au(e){let n=()=>e.dashLoop,t=$(()=>n().hasPlan),i=$(()=>!!n().amendments&&n().amendments.length>0);return h`
\n ${()=>t()?Ue({label:\"Plan\",src:()=>n().plan}):h`
No plan recorded for this loop.
`}\n ${()=>i()?yu({amendments:()=>n().amendments}):\"\"}\n
`}function Su(e){let n=()=>e.dashLoop,t=$(()=>!!n().usage);return h`
\n ${()=>t()?hu({usage:()=>n().usage}):h`
No usage recorded for this loop.
`}\n
`}function Vu(e){switch(e.tab){case\"overview\":return gu({dashLoop:e.dashLoop,split:e.split,now:e.now});case\"timeline\":return Ou({dashLoop:e.dashLoop,now:e.now});case\"sections\":return ku({dashLoop:e.dashLoop});case\"findings\":return Cu({dashLoop:e.dashLoop});case\"plan\":return Au({dashLoop:e.dashLoop});case\"usage\":return Su({dashLoop:e.dashLoop})}}function Nu(e){let n=$(()=>e.tabs().includes(e.tab)&&e.activeTab()===e.tab),t=De(),i=h`
n()?\"display:block\":\"display:none\"}>
`,f=!1;return R(()=>{if(!n()||f)return;f=!0;let u=sn(t,()=>Vu(e));if(u)i.appendChild(u)}),i}function Pu(e){return h`
\n ${bu.map((n)=>Nu({tab:n,dashLoop:e.dashLoop,split:e.split,now:e.now,tabs:e.tabs,activeTab:e.activeTab}))}\n
`}function ki(e){let n=()=>e.dashLoop,t=$(()=>Kn(n().findings)),i=$(()=>Me(n())),f=$(()=>{let u=e.routeTab();return i().includes(u)?u:\"overview\"});return h`
\n
\n ${wu({tabs:i,activeTab:f,onSelect:e.onSelectTab})}\n ${Pu({dashLoop:e.dashLoop,split:t,now:e.now,tabs:i,activeTab:f})}\n
\n
`}function Zn(){return h`
No loops match the current filters.
`}var Ei=new Set([\"completed\",\"cancelled\",\"errored\"]);function Ci(e){let n=e.length;return{completed:e.filter((i)=>i.stage===\"completed\").length,total:n}}function Tu(e){return\"group-row\"+(Ei.has(e)?\" group-row-terminal\":\" group-row-active\")}function Ai(e){return h`
\n

Groups

\n ${()=>{let n=e.groups();if(n.length===0)return h`
No feature groups for this repo.
`;return h`
\n ${n.map((t)=>{let i=t.group,f=$(()=>Ci(t.features));return h`
Tu(i.status)} data-group-status=${()=>i.status} onclick=${()=>e.onOpen(i.groupId)}>\n fe(i.status)}>${()=>i.status}\n ${()=>i.title}\n ${jn({current:()=>f().completed,total:()=>f().total})}\n max ${()=>i.maxConcurrent}\n \n Created ${()=>D(i.createdAt)}\n ${()=>i.completedAt?h`Completed ${()=>D(i.completedAt)}`:\"\"}\n \n
`})}\n
`}}\n
`}function Uu(e){return\"group-header\"+(Ei.has(e)?\" group-header-terminal\":\" group-header-active\")}function Si(e){return h`
\n
← Back to groups
\n ${()=>{let n=e.group();if(!n)return h`
Group not found.
`;let t=n.group,i=$(()=>Ci(n.features));return h`
\n
Uu(t.status)} data-group-status=${()=>t.status}>\n
\n fe(t.status)}>${()=>t.status}\n

${()=>t.title}

\n max ${()=>t.maxConcurrent}\n
\n
\n ${()=>\"Features \"+i().completed+\"/\"+i().total}\n Created ${()=>D(t.createdAt)}\n ${()=>t.completedAt?h`Completed ${()=>D(t.completedAt)}`:\"\"}\n ${()=>t.error?h`${()=>t.error}`:\"\"}\n
\n ${()=>t.prdPreview?h`
\n

PRD preview

\n
${()=>t.prdPreview||\"\"}
\n
`:\"\"}\n
\n
\n ${n.features.map((f)=>h`
f.stage}>\n #${()=>f.featureIndex}\n ${()=>f.title}\n Dt(f.stage)}>${()=>f.stage}\n ${()=>f.attempts} attempts\n ${()=>!!f.loopName&&e.loopNames().has(f.loopName)?Oi({className:\"feature-loop-link\",projectId:e.projectId,loopName:f.loopName,onOpen:e.onOpenLoop}):\"\"}\n ${()=>f.error?h`${()=>f.error}`:\"\"}\n
`)}\n
\n
`}}\n
`}function _u(e,n,t){let i=[],f=0,u=t?1/0:n;for(let r of e){if(r.findings.length===0)continue;if(f+=r.findings.length,u<=0)continue;let l=Math.min(u,r.findings.length);i.push({loopName:r.loop.loopName,rows:r.findings.slice(0,l)}),u-=l}return{groups:i,total:f,capped:!t&&f>n}}function Vi(e){let[n,t]=U(!1),i=$(()=>_u(e.loops(),jt,n()));return h`
\n

Findings ${()=>i().total}

\n ${()=>i().capped?Gn({shown:()=>i().groups.reduce((f,u)=>f+u.rows.length,0),total:()=>i().total,noun:\"findings\",onShowAll:()=>t(!0)}):\"\"}\n ${()=>{let f=i().groups;if(f.length===0)return h`
No findings recorded for this repo.
`;return h`
\n ${f.map((u)=>h`
u.loopName}>\n
\n ${Oi({className:\"findings-loop-link\",projectId:e.projectId,loopName:u.loopName,onOpen:e.onOpenLoop})}\n ${()=>u.rows.length}\n
\n ${xi({rows:u.rows})}\n
`)}\n
`}}\n
`}function Ni(e){let[n,t]=U(!1),i=$(()=>e.loops().filter((u)=>u.hasPlan)),f=$(()=>Qe(i(),_n,n()));return h`
\n

Plans ${()=>f().total}

\n ${()=>f().capped?Gn({shown:()=>f().rows.length,total:()=>f().total,noun:\"plans\",onShowAll:()=>t(!0)}):\"\"}\n ${()=>{let u=f().rows;if(u.length===0)return h`
No plans recorded for this repo.
`;return h`
\n ${u.map((r)=>h`
e.onOpenLoop(r.loop.loopName)}>\n fe(r.loop.status)}>${()=>r.loop.status}\n ${()=>r.loop.loopName}\n \n ${()=>$e(r.findings.length,\"finding\")}\n \n iter ${()=>r.loop.iteration}/${()=>r.loop.maxIterations}\n
`)}\n
`}}\n
`}function Pi(){let[e,n]=Tt({generatedAt:0,projects:[]}),[t,i]=U(!1),f=Be(typeof location<\"u\"?location.hash:\"\"),[u,r]=U(f.projectId),[l,c]=U(f.section),[s,d]=U(f.loopName),[g,k]=U(f.tab),[b,o]=U(f.groupId),[a,w]=U(f.statuses,{equals:Fn}),[E,N]=U(f.query),A=$(()=>({projectId:u(),section:l(),loopName:s(),tab:g(),groupId:b(),statuses:a(),query:E()})),[C,V]=U(null),[T,_]=U(\"recent\"),[j,K]=U(0),[m,p]=U(Date.now()),_e={current:\"\"},ge={current:!1},Ui=()=>{let y=I(A),x=new URLSearchParams;if(y.projectId)x.set(\"project\",y.projectId);if(y.loopName)x.set(\"loop\",y.loopName);let S=x.toString();return S?\"/api/data?\"+S:\"/api/data\"},ve=0,ue={url:null,gen:0},en=async()=>{let y=Ui();if(ue.url===y)return;let x=++ve;ue.url=y,ue.gen=x;try{let S=await fetch(y,{cache:\"no-store\"});if(x!==ve)return;let F=await S.json();if(x!==ve)return;let Z=qt(F);if(Z!==_e.current)_e.current=Z,n(Ut(F,{key:\"id\"})),i(!0),V(null);p(Date.now())}catch(S){if(x!==ve)return;let F=S instanceof Error?S.message:String(S);V(\"Failed to load dashboard data: \"+F)}finally{if(ue.gen===x)ue.url=null,ue.gen=0}},qn=(y)=>{Ce(()=>{r(y.projectId),c(y.section),d(y.loopName),k(y.tab),o(y.groupId),w(y.statuses),N(y.query)})},G=(y)=>{Ce(()=>{let x=y.projectId!==void 0&&y.projectId!==u();if(y.projectId!==void 0)r(y.projectId);if(y.section!==void 0)c(y.section);if(y.loopName!==void 0)d(y.loopName);if(y.tab!==void 0)k(y.tab);if(y.groupId!==void 0)o(y.groupId);if(y.statuses!==void 0)w(y.statuses);if(y.query!==void 0)N(y.query);if(x)w([]),N(\"\")})},_i=(y)=>{G({tab:y})},Fe=(y)=>{G({loopName:y,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})},vi=(y)=>{let x=new Set(a());if(x.has(y))x.delete(y);else x.add(y);G({statuses:[...x]})},Fi=(y)=>{G({query:y})},zi=(y)=>{_(y)},Jn=$(()=>new Set(a())),ze=$(()=>Jt(e.projects.map((y)=>y.projectDir||y.projectId||\"\"))),Ke=$(()=>{if(!t()||e.projects.length===0)return[];let y=Jn(),x=E(),S=ze(),F=[],Z=x.trim().toLowerCase();for(let re of e.projects){let fn=[],Ln=re.projectDir||re.projectId||\"\",Mn=S.get(Ln)??Ln;for(let mn of re.loops)if(Ht(mn.loop,y,x,Mn))fn.push(mn);let ef=(re.groups?.length??0)>0;if(fn.length>0)F.push({proj:re,loops:fn});else if(ef&&(!Z||Mn.toLowerCase().indexOf(Z)!==-1))F.push({proj:re,loops:[]})}return F}),Xn=$(()=>u()===null),Ki=$(()=>l()===\"groups\"),Qn=$(()=>l()===\"loops\"),Wi=$(()=>l()===\"findings\"),Yi=$(()=>l()===\"plans\"),Bn=$(()=>{let y=Ke(),x=u();if(!x)return null;return y.find((S)=>S.proj.projectId===x)??null}),L=$(()=>{let y=u();if(!y)return null;return e.projects.find((x)=>x.projectId===y)??null}),nn=$(()=>{let y=u(),x=s();if(!y||!x)return null;let S=L();if(!S)return null;return S.loops.find((F)=>F.loop.loopName===x)??null}),Ii=$(()=>{let y=L(),x=y?.groups?.length??0;if(!y)return{loops:0,groups:x,findings:0,plans:0};let S=0,F=0;for(let Z of y.loops)if(S+=Z.findings.length,Z.hasPlan)F++;return{loops:y.loops.length,groups:x,findings:S,plans:F}}),xe=$(()=>{let y=L();if(!y)return[];return y.loops.map((x)=>{let S=x.loop.completedAt||x.loop.startedAt||0;return{name:x.loop.loopName,when:S,whenLabel:D(S)}}).sort((x,S)=>S.when-x.when||x.name.localeCompare(S.name))}),tn=$(()=>{let y=s();if(!y)return-1;return xe().findIndex((x)=>x.name===y)}),ji=$(()=>{let y=tn();if(y<0)return null;let x=xe();if(x.length<=1)return null;return{index:y,total:x.length,onPrev:()=>{let S=tn(),F=xe();if(S>0)G({loopName:F[S-1].name})},onNext:()=>{let S=tn(),F=xe();if(Sy===x||!!y&&!!x&&y.index===x.index&&y.total===x.total}),Gi=$(()=>{let y=L(),x={running:0,completed:0,cancelled:0,errored:0,stalled:0};if(!y)return x;for(let S of y.loops){let F=S.loop.status;if(F===\"running\")x.running++;else if(F===\"completed\")x.completed++;else if(F===\"cancelled\")x.cancelled++;else if(F===\"errored\")x.errored++;else if(F===\"stalled\")x.stalled++}return x}),Di=$(()=>{let y=s();if(!u())return\"\";let x=L();if(!x)return\"\";let S=x.projectDir||x.projectId||\"\",F=ze().get(S)??S;return I(()=>$i({repoLabel:F,projectDir:S,loopName:y,loops:xe,loopNav:ji,onSelectLoop:(Z)=>G({loopName:Z}),onBackToRepos:()=>G({projectId:null,loopName:null,section:\"loops\"}),onBackToRepo:()=>G({loopName:null})}))}),Hi=$(()=>{let y=u(),x=s();if(!y||x)return\"\";return wi({section:l(),counts:Ii(),onNavigate:(S)=>G({section:S,loopName:null,groupId:null})})}),Zi=$(()=>{return u()!==null&&s()===null&&l()===\"loops\"}),qi=$(()=>{if(!Zi())return\"\";return I(()=>hi({counts:Gi,statuses:Jn,query:E,sortMode:T,onToggleStatus:vi,onChangeQuery:Fi,onChangeSort:zi,externalNav:j}))}),Ji=$(()=>{if(!Qn())return\"\";let y=nn();return y?ki({dashLoop:y,now:m,routeTab:g,onSelectTab:_i}):\"\"}),Xi=$(()=>{let y=Bn();return y?Zt(y.loops,T()):[]},[],{equals:Fn}),Qi=$(()=>{if(!Qn())return\"\";if(nn())return\"\";if(!u())return\"\";return h`
\n ${gi({loops:Xi,now:m,onOpen:(y)=>Fe(y)})}\n ${()=>Bn()?\"\":Zn()}\n
`}),Bi=$(()=>{if(l()!==\"groups\"||!b())return null;let y=L();if(!y)return null;return y.groups.find((x)=>x.group.groupId===b())??null}),Ri=$(()=>{let y=L();if(!y)return new Set;return new Set(y.loops.map((x)=>x.loop.loopName))}),Li=$(()=>{if(!Ki())return\"\";if(!u())return\"\";if(b())return Si({group:Bi,loopNames:Ri,projectId:()=>u(),onBack:()=>G({groupId:null}),onOpenLoop:Fe});return Ai({groups:()=>L()?.groups??[],onOpen:(x)=>G({groupId:x})})}),Rn=$(()=>{let y=L();return y?y.loops:[]}),Mi=$(()=>{if(!Wi())return\"\";if(!u())return\"\";return Vi({loops:Rn,projectId:()=>u(),onOpenLoop:Fe})}),mi=$(()=>{if(!Yi())return\"\";if(!u())return\"\";return Ni({loops:Rn,onOpenLoop:Fe})});R(()=>{if(!t())return;let y=u();if(y===null)return;if(!e.projects.some((S)=>S.projectId===y))r(null),d(null),c(\"loops\"),w([]),N(\"\")}),R(()=>{let y=u(),x=s();if(!y||!x)return;let S=L();if(!S)return;if(!S.loops.some((Z)=>Z.loop.loopName===x))d(null)}),R(()=>{let y=s(),x=g();if(!u()||!y||x===\"overview\")return;let S=nn();if(!S)return;if(!Me(S).includes(x))k(\"overview\")}),R(()=>{if(!t())return;Gt(Re(A()),ge)});let pi=$(()=>(u()??\"\")+\"\\x00\"+(s()??\"\"));return R(ct(pi,()=>{en()},{defer:!0})),st(()=>{qn(Be(location.hash)),en();let y=setInterval(()=>{en()},5000),x=()=>{if(ge.current){ge.current=!1;return}qn(Be(location.hash)),K((S)=>S+1)};window.addEventListener(\"hashchange\",x),ce(()=>{clearInterval(y),window.removeEventListener(\"hashchange\",x)})}),h`
\n

Forge Dashboard

\n\n ${()=>{let y=C();return y?h`
${y}
`:\"\"}}\n\n ${()=>{if(!t())return\"\";return h`
\n ${ai({generatedAt:e.generatedAt})}\n
`}}\n\n ${()=>{if(!t())return\"\";if(Xn()&&Ke().length===0)return Zn();return h`
\n ${()=>{if(Xn())return h`
\n ${yi({entries:()=>Ke(),labels:()=>ze(),onSelect:(y)=>G({projectId:y,loopName:null,section:\"loops\",groupId:null})})}\n ${bi({entries:()=>Ke(),labels:()=>ze(),onOpenLoop:(y,x)=>G({projectId:y,loopName:x,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})})}\n
`;return h`
\n ${Di}\n ${qi}\n ${Hi}\n ${Qi}\n ${Ji}\n ${Li}\n ${Mi}\n ${mi}\n
`}}\n
`}}\n
`}var Ti=document.getElementById(\"forge-app-root\");if(Ti)kt(Pi,Ti);\n"; -export const DASHBOARD_APP_SOURCE_HASH: string = "b476cccf339107331c33dd219ea3f7e4ffe10914ab5bb187e9172e9ef0ce29c7"; +export const DASHBOARD_APP_BUNDLE: string = "var v={context:void 0,registry:void 0,effects:void 0,done:!1,getContextId(){return nt(this.context.count)},getNextContextId(){return nt(this.context.count++)}};function nt(e){let n=String(e),t=n.length-1;return v.context.id+(t?String.fromCharCode(96+t):\"\")+n}function sn(e){v.context=e}function cf(){return{...v.context,id:v.getNextContextId(),count:0}}var lf=!0,sf=(e,n)=>e===n,J=Symbol(\"solid-proxy\"),df=typeof Proxy===\"function\",hn=Symbol(\"solid-track\"),ct=Symbol(\"solid-dev-component\"),Ue={equals:sf},tt=null,lt=$t,D=1,Ce=2,hf={};var k=null,O=null,Ae=null,re=null,W=null,_=null,T=null,Ze=0,Q={afterUpdate:null,afterCreateOwner:null,afterCreateSignal:null,afterRegisterGraph:null};function st(e,n){let t=W,i=k,f=e.length===0,u=n===void 0?i:n,r=f?{owned:null,cleanups:null,context:null,owner:null}:{owned:null,cleanups:null,context:u?u.context:null,owner:u},c=f?()=>e(()=>{throw Error(\"Dispose method must be an explicit argument to createRoot function\")}):()=>e(()=>H(()=>ne(r)));Q.afterCreateOwner&&Q.afterCreateOwner(r),k=r,W=null;try{return B(c,!0)}finally{W=t,k=i}}function K(e,n){n=n?Object.assign({},Ue,n):Ue;let t={value:e,observers:null,observerSlots:null,comparator:n.equals||void 0};{if(n.name)t.name=n.name;if(n.internal)t.internal=!0;else if(ot(t),Q.afterCreateSignal)Q.afterCreateSignal(t)}let i=(f)=>{if(typeof f===\"function\")if(O&&O.running&&O.sources.has(t))f=f(t.tValue);else f=f(t.value);return yn(t,f)};return[yt.bind(t),i]}function M(e,n,t){let i=Xe(e,n,!1,D,t);if(Ae&&O&&O.running)_.push(i);else le(i)}function R(e,n,t){lt=gf;let i=Xe(e,n,!1,D,t),f=dn&&yf(dn);if(f)i.suspense=f;if(!t||!t.render)i.user=!0;T?T.push(i):le(i)}function g(e,n,t){t=t?Object.assign({},Ue,t):Ue;let i=Xe(e,n,!0,0,t);if(i.observers=null,i.observerSlots=null,i.comparator=t.equals||void 0,Ae&&O&&O.running)i.tState=D,_.push(i);else le(i);return yt.bind(i)}function Ne(e){return B(e,!1)}function H(e){if(!re&&W===null)return e();let n=W;W=null;try{if(re)return re.untrack(e);return e()}finally{W=n}}function dt(e,n,t){let i=Array.isArray(e),f,u=t&&t.defer;return(r)=>{let c;if(i){c=Array(e.length);for(let s=0;sn(c,f,r));return f=c,l}}function ht(e){R(()=>H(e))}function ce(e){if(k===null)console.warn(\"cleanups created outside a `createRoot` or `render` will never be run\");else if(k.cleanups===null)k.cleanups=[e];else k.cleanups.push(e);return e}function qe(){return W}function De(){return k}function on(e,n){let t=k,i=W;k=e,W=null;try{return B(n,!0)}catch(f){Je(f)}finally{k=t,W=i}}function of(e){if(O&&O.running)return e(),O.done;let n=W,t=k;return Promise.resolve().then(()=>{W=n,k=t;let i;if(Ae||dn)i=O||(O={sources:new Set,effects:[],promises:new Set,disposed:new Set,queue:new Set,running:!0}),i.done||(i.done=new Promise((f)=>i.resolve=f)),i.running=!0;return B(e,!1),W=k=null,i?i.done:void 0})}var[_u,it]=K(!1);function ft(e,n){let t=Xe(()=>H(()=>{return Object.assign(e,{[ct]:!0}),e(n)}),void 0,!0,0);return t.props=n,t.observers=null,t.observerSlots=null,t.name=e.name,t.component=e,le(t),t.tValue!==void 0?t.tValue:t.value}function ot(e){if(k){if(k.sourceMap)k.sourceMap.push(e);else k.sourceMap=[e];e.graph=k}if(Q.afterRegisterGraph)Q.afterRegisterGraph(e)}function yf(e){let n;return k&&k.context&&(n=k.context[e.id])!==void 0?n:e.defaultValue}var dn;function yt(){let e=O&&O.running;if(this.sources&&(e?this.tState:this.state))if((e?this.tState:this.state)===D)le(this);else{let n=_;_=null,B(()=>je(this),!1),_=n}if(W){let n=this.observers?this.observers.length:0;if(!W.sources)W.sources=[this],W.sourceSlots=[n];else W.sources.push(this),W.sourceSlots.push(n);if(!this.observers)this.observers=[W],this.observerSlots=[W.sources.length-1];else this.observers.push(W),this.observerSlots.push(W.sources.length-1)}if(e&&O.sources.has(this))return this.tValue;return this.value}function yn(e,n,t){let i=O&&O.running&&O.sources.has(e)?e.tValue:e.value;if(!e.comparator||!e.comparator(i,n)){if(O){let f=O.running;if(f||!t&&O.sources.has(e))O.sources.add(e),e.tValue=n;if(!f)e.value=n}else e.value=n;if(e.observers&&e.observers.length)B(()=>{for(let f=0;f1e6){if(_=[],lf)throw Error(\"Potential Infinite Loop Detected.\");throw Error()}},!1)}return n}function le(e){if(!e.fn)return;ne(e);let n=Ze;if(ut(e,O&&O.running&&O.sources.has(e)?e.tValue:e.value,n),O&&!O.running&&O.sources.has(e))queueMicrotask(()=>{B(()=>{O&&(O.running=!0),W=k=e,ut(e,e.tValue,n),W=k=null},!1)})}function ut(e,n,t){let i,f=k,u=W;W=k=e;try{i=e.fn(n)}catch(r){if(e.pure)if(O&&O.running)e.tState=D,e.tOwned&&e.tOwned.forEach(ne),e.tOwned=void 0;else e.state=D,e.owned&&e.owned.forEach(ne),e.owned=null;return e.updatedAt=t+1,Je(r)}finally{W=u,k=f}if(!e.updatedAt||e.updatedAt<=t){if(e.updatedAt!=null&&\"observers\"in e)yn(e,i,!0);else if(O&&O.running&&e.pure){if(!O.sources.has(e))e.value=i;O.sources.add(e),e.tValue=i}else e.value=i;e.updatedAt=t}}function Xe(e,n,t,i=D,f){let u={fn:e,state:i,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:n,owner:k,context:k?k.context:null,pure:t};if(O&&O.running)u.state=0,u.tState=i;if(k===null)console.warn(\"computations created outside a `createRoot` or `render` will never be disposed\");else if(k!==hf)if(O&&O.running&&k.pure)if(!k.tOwned)k.tOwned=[u];else k.tOwned.push(u);else if(!k.owned)k.owned=[u];else k.owned.push(u);if(f&&f.name)u.name=f.name;if(re&&u.fn){let r=u.fn,[c,l]=K(void 0,{equals:!1}),s=re.factory(r,l);ce(()=>s.dispose());let h,x=()=>of(l).then(()=>{if(h)h.dispose(),h=void 0});u.fn=(E)=>{if(c(),O&&O.running){if(!h)h=re.factory(r,x);return h.track(E)}return s.track(E)}}return Q.afterCreateOwner&&Q.afterCreateOwner(u),u}function Se(e){let n=O&&O.running;if((n?e.tState:e.state)===0)return;if((n?e.tState:e.state)===Ce)return je(e);if(e.suspense&&H(e.suspense.inFallback))return e.suspense.effects.push(e);let t=[e];while((e=e.owner)&&(!e.updatedAt||e.updatedAt=0;i--){if(e=t[i],n){let f=e,u=t[i+1];while((f=f.owner)&&f!==u)if(O.disposed.has(f))return}if((n?e.tState:e.state)===D)le(e);else if((n?e.tState:e.state)===Ce){let f=_;_=null,B(()=>je(e,t[0]),!1),_=f}}}function B(e,n){if(_)return e();let t=!1;if(!n)_=[];if(T)t=!0;else T=[];Ze++;try{let i=e();return $f(t),i}catch(i){if(!t)T=null;_=null,Je(i)}}function $f(e){if(_){if(Ae&&O&&O.running)wf(_);else $t(_);_=null}if(e)return;let n;if(O){if(!O.promises.size&&!O.queue.size){let{sources:i,disposed:f}=O;T.push.apply(T,O.effects),n=O.resolve;for(let u of T)\"tState\"in u&&(u.state=u.tState),delete u.tState;O=null,B(()=>{for(let u of f)ne(u);for(let u of i){if(u.value=u.tValue,u.owned)for(let r=0,c=u.owned.length;rlt(t),!1);else Q.afterUpdate&&Q.afterUpdate();if(n)n()}function $t(e){for(let n=0;n{i.delete(t),B(()=>{O.running=!0,Se(t)},!1),O&&(O.running=!1)})}}function gf(e){let n,t=0;for(n=0;n=0;n--)ne(e.tOwned[n]);delete e.tOwned}if(O&&O.running&&e.pure)gt(e,!0);else if(e.owned){for(n=e.owned.length-1;n>=0;n--)ne(e.owned[n]);e.owned=null}if(e.cleanups){for(n=e.cleanups.length-1;n>=0;n--)e.cleanups[n]();e.cleanups=null}if(O&&O.running)e.tState=0;else e.state=0;delete e.sourceMap}function gt(e,n){if(!n)e.tState=0,O.disposed.add(e);if(e.owned)for(let t=0;t=0;c--){let l=ln(e[c])[r];if(l!==void 0)return l}},has(r){for(let c=e.length-1;c>=0;c--)if(r in ln(e[c]))return!0;return!1},keys(){let r=[];for(let c=0;c=0;r--){let c=e[r];if(!c)continue;let l=Object.getOwnPropertyNames(c);for(let s=l.length-1;s>=0;s--){let h=l[s];if(h===\"__proto__\"||h===\"constructor\")continue;let x=Object.getOwnPropertyDescriptor(c,h);if(!i[h])i[h]=x.get?{enumerable:!0,configurable:!0,get:Of.bind(t[h]=[x.get.bind(c)])}:x.value!==void 0?x:void 0;else{let E=t[h];if(E){if(x.get)E.push(x.get.bind(c));else if(x.value!==void 0)E.push(()=>x.value)}}}}let f={},u=Object.keys(i);for(let r=u.length-1;r>=0;r--){let c=u[r],l=i[c];if(l&&l.get)Object.defineProperty(f,c,l);else f[c]=l?l.value:void 0}return f}var bt={hooks:Q,writeSignal:yn,registerGraph:ot};if(globalThis)if(!globalThis.Solid$$)globalThis.Solid$$=!0;else console.warn(\"You appear to have multiple instances of Solid. This can lead to unexpected behavior.\");var Ef=[\"allowfullscreen\",\"async\",\"alpha\",\"autofocus\",\"autoplay\",\"checked\",\"controls\",\"default\",\"disabled\",\"formnovalidate\",\"hidden\",\"indeterminate\",\"inert\",\"ismap\",\"loop\",\"multiple\",\"muted\",\"nomodule\",\"novalidate\",\"open\",\"playsinline\",\"readonly\",\"required\",\"reversed\",\"seamless\",\"selected\",\"adauctionheaders\",\"browsingtopics\",\"credentialless\",\"defaultchecked\",\"defaultmuted\",\"defaultselected\",\"defer\",\"disablepictureinpicture\",\"disableremoteplayback\",\"preservespitch\",\"shadowrootclonable\",\"shadowrootcustomelementregistry\",\"shadowrootdelegatesfocus\",\"shadowrootserializable\",\"sharedstoragewritable\"],bn=new Set([\"className\",\"value\",\"readOnly\",\"noValidate\",\"formNoValidate\",\"isMap\",\"noModule\",\"playsInline\",\"adAuctionHeaders\",\"allowFullscreen\",\"browsingTopics\",\"defaultChecked\",\"defaultMuted\",\"defaultSelected\",\"disablePictureInPicture\",\"disableRemotePlayback\",\"preservesPitch\",\"shadowRootClonable\",\"shadowRootCustomElementRegistry\",\"shadowRootDelegatesFocus\",\"shadowRootSerializable\",\"sharedStorageWritable\",...Ef]),xn=new Set([\"innerHTML\",\"textContent\",\"innerText\",\"children\"]),an=Object.assign(Object.create(null),{className:\"class\",htmlFor:\"for\"}),Cf=Object.assign(Object.create(null),{class:\"className\",novalidate:{$:\"noValidate\",FORM:1},formnovalidate:{$:\"formNoValidate\",BUTTON:1,INPUT:1},ismap:{$:\"isMap\",IMG:1},nomodule:{$:\"noModule\",SCRIPT:1},playsinline:{$:\"playsInline\",VIDEO:1},readonly:{$:\"readOnly\",INPUT:1,TEXTAREA:1},adauctionheaders:{$:\"adAuctionHeaders\",IFRAME:1},allowfullscreen:{$:\"allowFullscreen\",IFRAME:1},browsingtopics:{$:\"browsingTopics\",IMG:1},defaultchecked:{$:\"defaultChecked\",INPUT:1},defaultmuted:{$:\"defaultMuted\",AUDIO:1,VIDEO:1},defaultselected:{$:\"defaultSelected\",OPTION:1},disablepictureinpicture:{$:\"disablePictureInPicture\",VIDEO:1},disableremoteplayback:{$:\"disableRemotePlayback\",AUDIO:1,VIDEO:1},preservespitch:{$:\"preservesPitch\",AUDIO:1,VIDEO:1},shadowrootclonable:{$:\"shadowRootClonable\",TEMPLATE:1},shadowrootdelegatesfocus:{$:\"shadowRootDelegatesFocus\",TEMPLATE:1},shadowrootserializable:{$:\"shadowRootSerializable\",TEMPLATE:1},sharedstoragewritable:{$:\"sharedStorageWritable\",IFRAME:1,IMG:1}});function On(e,n){let t=Cf[e];return typeof t===\"object\"?t[n]?t.$:void 0:t}var En=new Set([\"beforeinput\",\"click\",\"dblclick\",\"contextmenu\",\"focusin\",\"focusout\",\"input\",\"keydown\",\"keyup\",\"mousedown\",\"mousemove\",\"mouseout\",\"mouseover\",\"mouseup\",\"pointerdown\",\"pointermove\",\"pointerout\",\"pointerover\",\"pointerup\",\"touchend\",\"touchmove\",\"touchstart\"]),Ct=new Set([\"altGlyph\",\"altGlyphDef\",\"altGlyphItem\",\"animate\",\"animateColor\",\"animateMotion\",\"animateTransform\",\"circle\",\"clipPath\",\"color-profile\",\"cursor\",\"defs\",\"desc\",\"ellipse\",\"feBlend\",\"feColorMatrix\",\"feComponentTransfer\",\"feComposite\",\"feConvolveMatrix\",\"feDiffuseLighting\",\"feDisplacementMap\",\"feDistantLight\",\"feDropShadow\",\"feFlood\",\"feFuncA\",\"feFuncB\",\"feFuncG\",\"feFuncR\",\"feGaussianBlur\",\"feImage\",\"feMerge\",\"feMergeNode\",\"feMorphology\",\"feOffset\",\"fePointLight\",\"feSpecularLighting\",\"feSpotLight\",\"feTile\",\"feTurbulence\",\"filter\",\"font\",\"font-face\",\"font-face-format\",\"font-face-name\",\"font-face-src\",\"font-face-uri\",\"foreignObject\",\"g\",\"glyph\",\"glyphRef\",\"hkern\",\"image\",\"line\",\"linearGradient\",\"marker\",\"mask\",\"metadata\",\"missing-glyph\",\"mpath\",\"path\",\"pattern\",\"polygon\",\"polyline\",\"radialGradient\",\"rect\",\"set\",\"stop\",\"svg\",\"switch\",\"symbol\",\"text\",\"textPath\",\"tref\",\"tspan\",\"use\",\"view\",\"vkern\"]),Cn={xlink:\"http://www.w3.org/1999/xlink\",xml:\"http://www.w3.org/XML/1998/namespace\"};function Sf(e,n,t){let i=t.length,f=n.length,u=i,r=0,c=0,l=n[f-1].nextSibling,s=null;while(rh-c){let d=n[r];while(c{f=u,n===document?e():kn(n,e(),n.firstChild?null:void 0,t)},i.owner),()=>{f(),n.textContent=\"\"}}function Sn(e,n=window.document){let t=n[xt]||(n[xt]=new Set);for(let i=0,f=e.length;if.call(e,t[1],u))}else e.addEventListener(n,t,typeof t!==\"function\"&&t)}function Pn(e,n,t={}){let i=Object.keys(n||{}),f=Object.keys(t),u,r;for(u=0,r=f.length;uf.children=de(e,n.children,f.children));return M(()=>typeof n.ref===\"function\"&&Pf(n.ref,e)),M(()=>Vf(e,n,t,!0,f,!0)),f}function Nt(e,n){let t=e[n];return Object.defineProperty(e,n,{get(){return t()},enumerable:!0}),e}function Pf(e,n,t){return H(()=>e(n,t))}function kn(e,n,t,i){if(t!==void 0&&!i)i=[];if(typeof n!==\"function\")return de(e,n,i,t);M((f)=>de(e,n(),f,t),i)}function Vf(e,n,t,i,f={},u=!1){n||(n={});for(let r in f)if(!(r in n)){if(r===\"children\")continue;f[r]=Ot(e,r,null,f[r],t,u,n)}for(let r in n){if(r===\"children\"){if(!i)de(e,n.children);continue}let c=n[r];f[r]=Ot(e,r,c,f[r],t,u,n)}}function he(e){return!!v.context&&!v.done&&(!e||e.isConnected)}function kf(e){return e.toLowerCase().replace(/-([a-z])/g,(n,t)=>t.toUpperCase())}function at(e,n,t){let i=n.trim().split(/\\s+/);for(let f=0,u=i.length;f-1&&Cn[n.split(\":\")[0]];if(E)An(e,E,n,t);else Pe(e,an[n]||n,t)}return t}function If(e){if(v.registry&&v.events){if(v.events.find(([l,s])=>s===e))return}let n=e.target,t=`$$${e.type}`,i=e.target,f=e.currentTarget,u=(l)=>Object.defineProperty(e,\"target\",{configurable:!0,value:l}),r=()=>{let l=n[t];if(l&&!n.disabled){let s=n[`${t}Data`];if(s!==void 0?l.call(n,s,e):l.call(n,e),e.cancelBubble)return}return n.host&&typeof n.host!==\"string\"&&!n.host._$host&&n.contains(e.target)&&u(n.host),!0},c=()=>{while(r()&&(n=n._$host||n.parentNode||n.host));};if(Object.defineProperty(e,\"currentTarget\",{configurable:!0,get(){return n||document}}),v.registry&&!v.done)v.done=_$HY.done=!0;if(e.composedPath){let l=e.composedPath();u(l[0]);for(let s=0;s{let l=n();while(typeof l===\"function\")l=l();t=de(e,l,t,i)}),()=>t;else if(Array.isArray(n)){let l=[],s=t&&Array.isArray(t);if(gn(l,n,t,f))return M(()=>t=de(e,l,t,i,!0)),()=>t;if(u){if(!l.length)return t;if(i===void 0)return t=[...e.childNodes];let h=l[0];if(h.parentNode!==e)return t;let x=[h];while((h=h.nextSibling)!==i)x.push(h);return t=x}if(l.length===0){if(t=se(e,t,i),c)return t}else if(s)if(t.length===0)Et(e,l,i);else Sf(e,t,l);else t&&se(e),Et(e,l);t=l}else if(n.nodeType){if(u&&n.parentNode)return t=c?[n]:n;if(Array.isArray(t)){if(c)return t=se(e,t,i,n);se(e,t,null,n)}else if(t==null||t===\"\"||!e.firstChild)e.appendChild(n);else e.replaceChild(n,e.firstChild);t=n}else console.warn(\"Unrecognized value. Skipped inserting\",n);return t}function gn(e,n,t,i){let f=!1;for(let u=0,r=n.length;u=0;r--){let c=n[r];if(f!==c){let l=c.parentNode===e;if(!u&&!r)l?e.replaceChild(f,c):e.insertBefore(f,t);else l&&c.remove()}else u=!0}}else e.insertBefore(f,t);return[f]}var Lu=Symbol();var In=Symbol(\"store-raw\"),ye=Symbol(\"store-node\"),ee=Symbol(\"store-has\"),Vt=Symbol(\"store-self\"),Pt={onStoreNodeUpdate:null};function kt(e){let n=e[J];if(!n){if(Object.defineProperty(e,J,{value:n=new Proxy(e,Wf)}),!Array.isArray(e)){let t=Object.keys(e),i=Object.getOwnPropertyDescriptors(e);for(let f=0,u=t.length;fe[J][n],t}function It(e){qe()&&ke(Qe(e,ye),Vt)()}function zf(e){return It(e),Reflect.ownKeys(e)}var Wf={get(e,n,t){if(n===In)return e;if(n===J)return t;if(n===hn)return It(e),t;let i=Qe(e,ye),f=i[n],u=f?f():e[n];if(n===ye||n===ee||n===\"__proto__\")return u;if(!f){let r=Object.getOwnPropertyDescriptor(e,n);if(qe()&&(typeof u!==\"function\"||e.hasOwnProperty(n))&&!(r&&r.get))u=ke(i,n,u)()}return te(u)?kt(u):u},has(e,n){if(n===In||n===J||n===hn||n===ye||n===ee||n===\"__proto__\")return!0;return qe()&&ke(Qe(e,ee),n)(),n in e},set(){return console.warn(\"Cannot mutate a Store directly\"),!0},deleteProperty(){return console.warn(\"Cannot mutate a Store directly\"),!0},ownKeys:zf,getOwnPropertyDescriptor:Kf};function X(e,n,t,i=!1){if(!i&&e[n]===t)return;let f=e[n],u=e.length;if(Pt.onStoreNodeUpdate&&Pt.onStoreNodeUpdate(e,n,t,f),t===void 0){if(delete e[n],e[ee]&&e[ee][n]&&f!==void 0)e[ee][n].$()}else if(e[n]=t,e[ee]&&e[ee][n]&&f===void 0)e[ee][n].$();let r=Qe(e,ye),c;if(c=ke(r,n,f))c.$(()=>t);if(Array.isArray(e)&&e.length!==u){for(let l=e.length;l1){i=n.shift();let r=typeof i,c=Array.isArray(e);if(Array.isArray(i)){for(let l=0;l1){Ve(e[i],n,[i].concat(t));return}f=e[i],t=[i].concat(t)}let u=n[0];if(typeof u===\"function\"){if(u=u(f,t),u===f)return}if(i===void 0&&u==null)return;if(u=$e(u),i===void 0||te(f)&&te(u)&&!Array.isArray(u))Kt(f,u);else X(e,i,u)}function zt(...[e,n]){let t=$e(e||{}),i=Array.isArray(t);if(typeof t!==\"object\"&&typeof t!==\"function\")throw Error(`Unexpected type ${typeof t} received when initializing 'createStore'. Expected an object.`);let f=kt(t);bt.registerGraph({value:t,name:n&&n.name});function u(...r){Ne(()=>{i&&r.length===1?Yf(t,r[0]):Ve(t,r)})}return[f,u]}var Kn=Symbol(\"store-root\");function oe(e,n,t,i,f){let u=n[t];if(e===u)return;let r=Array.isArray(e);if(t!==Kn&&(!te(e)||!te(u)||r!==Array.isArray(u)||f&&e[f]!==u[f])){X(n,t,e);return}if(r){if(e.length&&u.length&&(!i||f&&e[0]&&e[0][f]!=null)){let s,h,x,E,w,d,y,b;for(x=0,E=Math.min(u.length,e.length);x=x&&w>=x&&(u[E]===e[w]||f&&u[E]&&e[w]&&u[E][f]&&u[E][f]===e[w][f]);E--,w--)C[w]=u[E];if(x>w||x>E){for(h=x;h<=w;h++)X(u,h,e[h]);for(;he.length)X(u,\"length\",e.length);return}y=Array(w+1);for(h=w;h>=x;h--)d=e[h],b=f&&d?d[f]:d,s=V.get(b),y[h]=s===void 0?-1:s,V.set(b,h);for(s=x;s<=E;s++)if(d=u[s],b=f&&d?d[f]:d,h=V.get(b),h!==void 0&&h!==-1)C[h]=u[s],h=y[h],V.set(b,h);for(h=x;he.length)X(u,\"length\",e.length);return}let c=Object.keys(e);for(let s=0,h=c.length;s{if(!te(u)||!te(f))return f;let r=oe(f,{[Kn]:u},Kn,t,i);return r===void 0?u:r}}var vf=/(?:|<(?:\"[^\"]*\"['\"]*|'[^']*'['\"]*|[^'\">])+>)/g,Ff=/(?:\\s(?[^/\\s><=]+?)(?=[\\s/>]))|(?:(?\\S+?)(?:\\s*=\\s*(?:(['\"])(?[\\s\\S]*?)\\3|(?[^\\s>]+))))/g,_f={area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,menuitem:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0};function Tf(e){let n={type:\"tag\",name:\"\",voidElement:!1,attrs:[],children:[]},t=e.match(/<\\/?([^\\s]+?)[/\\s>]/);if(t){if(n.name=t[1],_f[t[1].toLowerCase()]||e.charAt(e.length-2)===\"/\")n.voidElement=!0;if(n.name.startsWith(\"!--\")){let f=e.indexOf(\"-->\");return{type:\"comment\",comment:f!==-1?e.slice(4,f):\"\"}}}let i=new RegExp(Ff);for(let f of e.matchAll(i))if((f[1]||f[2]).startsWith(\"use:\"))n.attrs.push({type:\"directive\",name:f[1]||f[2],value:f[4]||f[5]||\"\"});else n.attrs.push({type:\"attr\",name:f[1]||f[2],value:f[4]||f[5]||\"\"});return n}function Yt(e,n,t){let i=n.indexOf(\"<\",t),f=n.slice(t,i===-1?void 0:i);if(!/^\\s*$/.test(f))e.push({type:\"text\",content:f})}function vt(e,n){let t=n.replace(\"\",\"\");if(!/^\\s*$/.test(t))e.push({type:\"comment\",content:t})}function Hf(e){let n=[],t=void 0,i=-1,f=[],u={};return e.replace(vf,(r,c)=>{let l=r.charAt(1)!==\"/\",s=r.slice(0,4)===\"\"}}function Uf(e){return e.reduce(function(n,t){return n+Tt(\"\",t)},\"\")}var Ft=new Map,jf=/^(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$/i,Be=\" \\\\f\\\\n\\\\r\\\\t\",Ht=\"[^\"+Be+`\\\\/>\"'=]+`,Wn=\"[ \"+Be+\"]+(?:use:|\"+Ht+\")\",Gt=\"<([A-Za-z$#]+[A-Za-z0-9:_-]*)((?:\",Ut=`(?:\\\\s*=\\\\s*(?:'[^']*?'|\"[^\"]*?\"|\\\\([^)]*?\\\\)|<[^>]*?>|`+Ht+\"))?)\",Zf=new RegExp(Gt+Wn+Ut+\"+)([ \"+Be+\"]*/?>)\",\"g\"),qf=new RegExp(\"(\"+Wn+`\\\\s*=\\\\s*)(|['\"(]([\\\\w\\\\s]*[\\\\w\\\\s]*)*['\")])`,\"gi\"),Df=new RegExp(Gt+Wn+Ut+\"*)([ \"+Be+\"]*/>)\",\"g\"),_t=\"\",Xf=new Set([\"class\",\"on\",\"oncapture\",\"style\",\"use\",\"prop\",\"attr\"]);function Jf(e,n,t,i){return\"<\"+n+t.replace(qf,Qf)+i}function Qf(e,n,t){return n.replace(//g,\"###\")+(t[0]==='\"'||t[0]===\"'\"?t.replace(//g,\"###\"):'\"###\"')}function Bf(e,n,t){return jf.test(n)?e:\"<\"+n+t+\">\"}function Rf(e){return e.toLowerCase().replace(/-([a-z])/g,(n,t)=>t.toUpperCase())}function zn(e,n,t,i){if(e===\"use:###\"&&n===\"###\"){let f=i.counter++;i.exprs.push(`typeof exprs[${f}] === \"function\" ? r.use(exprs[${f}], ${t}, exprs[${i.counter++}]) : (()=>{throw new Error(\"use:### must be a function\")})()`)}else throw Error(`Not support syntax ${e} must be use:{function}`)}function Lf(e,{delegateEvents:n=!0,functionBuilder:t=(...i)=>Function(...i)}={}){let i=1;e.wrapProps=(w)=>{let d=Object.getOwnPropertyDescriptors(w);for(let y in d)if(typeof d[y].value===\"function\"&&!d[y].value.length)e.dynamicProperty(w,y);return w};function f(w,d){let y=0,b=\"\";for(;y\";b=b+w[y],b=[[Df,Bf],[/<()/g,\"<###\"],[/\\.\\.\\.()/g,\"###\"],[Zf,Jf],[/>\\n+\\s*/g,\">\"],[/\\n+\\s*\\s+/g,\"> \"]].reduce((I,z)=>{return I.replace(z[0],z[1])},b);let V=Hf(b),[A,S]=x(V,d.funcBuilder),P=[];for(let I=0;I-1){let m=F.split(_t).reduce((p,ve,ae)=>{return ae&&p.push(\"\"),p.push(ve),p},[]);z[I].firstChild.replaceWith(...m)}}}return P[0].create=S,Ft.set(w,P),P}function u(w,d,y,b,C,V,A){let S=b===\"###\"?`!doNotWrap ? exprs[${A.counter}]() : exprs[${A.counter++}]`:b.split(\"###\").map((F,m)=>m?` + (typeof exprs[${A.counter}] === \"function\" ? exprs[${A.counter}]() : exprs[${A.counter++}]) + \"${F}\"`:`\"${F}\"`).join(\"\"),P,I;if((P=y.split(\":\"))&&P[1]&&Xf.has(P[0]))y=P[1],I=P[0];let z=e.ChildProperties.has(y),G=e.Properties.has(y);if(y===\"style\"){let F=`_$v${i++}`;A.decl.push(`${F}={}`),A.exprs.push(`r.style(${d},${S},${F})`)}else if(y===\"classList\"){let F=`_$v${i++}`;A.decl.push(`${F}={}`),A.exprs.push(`r.classList(${d},${S},${F})`)}else if(I!==\"attr\"&&(z||!C&&(e.getPropAlias(y,w.name.toUpperCase())||G)||V||I===\"prop\")){if(V&&!z&&!G&&I!==\"prop\")y=Rf(y);A.exprs.push(`${d}.${e.getPropAlias(y,w.name.toUpperCase())||y} = ${S}`)}else{let F=C&&y.indexOf(\":\")>-1&&e.SVGNamespace[y.split(\":\")[0]];if(F)A.exprs.push(`r.setAttributeNS(${d},\"${F}\",\"${y}\",${S})`);else A.exprs.push(`r.setAttribute(${d},\"${e.Aliases[y]||y}\",${S})`)}}function r(w,d,y,b,C,V,A){if(y.slice(0,2)===\"on\")if(!y.includes(\":\")){let S=y.slice(2).toLowerCase(),P=n&&e.DelegatedEvents.has(S);A.exprs.push(`r.addEventListener(${d},\"${S}\",exprs[${A.counter++}],${P})`),P&&A.delegatedEvents.add(S)}else{let S=y.startsWith(\"oncapture:\");A.exprs.push(`${d}.addEventListener(\"${y.slice(S?10:3)}\",exprs[${A.counter++}]${S?\",true\":\"\"})`)}else if(y===\"ref\")A.exprs.push(`exprs[${A.counter++}](${d})`);else{let S=Object.assign({},A,{exprs:[]}),P=A.counter;if(u(w,d,y,b,C,V,S),A.decl.push(`_fn${P} = (${b===\"###\"?\"doNotWrap\":\"\"}) => {\n${S.exprs.join(`;\n`)};\n}`),b===\"###\")A.exprs.push(`typeof exprs[${P}] === \"function\" ? r.effect(_fn${P}) : _fn${P}(true)`);else{let I=\"\";for(let z=P;z1)for(let C=0;C1?`r.mergeProps(${d.join(\",\")})`:d[0]}function s(w,d){let y=[],b=Object.keys(w.attrs),C=[y],V=d.counter++;for(let S=0;S exprs[${d.counter++}]`);else if(w.children.length){let S={type:\"fragment\",children:w.children},P=Object.assign({},d,{first:!0,decl:[],exprs:[],parent:!1});h(S,P),y.push(`children: () => { ${P.exprs.join(`;\n`)}}`),d.templateId=P.templateId,d.counter=P.counter}let A;if(d.multi)A=`_$el${i++}`,d.decl.push(`${A} = ${d.path}.${d.first?\"firstChild\":\"nextSibling\"}`);if(d.parent)d.exprs.push(`r.insert(${d.parent}, r.createComponent(exprs[${V}],${l(C)})${A?`, ${A}`:\"\"})`);else d.exprs.push(`${d.fragment?\"\":\"return \"}r.createComponent(exprs[${V}],${l(C)})`);d.path=A,d.first=!1}function h(w,d){if(w.type===\"fragment\"){let y=[];w.children.forEach((b)=>{if(b.type===\"tag\"){if(b.name===\"###\"){let A=Object.assign({},d,{first:!0,fragment:!0,decl:[],exprs:[]});s(b,A),y.push(A.exprs[0]),d.counter=A.counter,d.templateId=A.templateId;return}d.templateId++;let C=i,V=Object.assign({},d,{first:!0,decl:[],exprs:[]});d.templateNodes.push([b]),h(b,V),y.push(`function() { ${V.decl.join(`,\n`)+`;\n`+V.exprs.join(`;\n`)+`;\nreturn _$el${C};\n`}}()`),d.counter=V.counter,d.templateId=V.templateId}else if(b.type===\"text\")y.push(`\"${b.content}\"`);else if(b.type===\"comment\"){if(b.content===\"#\")y.push(`exprs[${d.counter++}]`);else if(b.content)for(let C=0;CS.name===\"is\");if(d.hasCustomElement=A,d.isImportNode=(w.name===\"img\"||w.name===\"iframe\")&&w.attrs.some((S)=>S.name===\"loading\"&&S.value===\"lazy\"),w.attrs.some((S)=>S.name===\"###\")){let S=[],P=\"\",I=[];for(let z=0;z({${P}})`),P=\"\";S.push(`exprs[${d.counter++}]`)}else I.push(w.attrs[z]);else if(G===\"directive\")zn(F,m,y,d)}if(w.attrs=I,P.length)S.push(`()=>({${P}})`);d.exprs.push(`r.spread(${y},${S.length===1?`typeof ${S[0]} === \"function\" ? r.mergeProps(${S[0]}) : ${S[0]}`:`r.mergeProps(${S.join(\",\")})`},${V},${!!w.children.length})`)}else for(let S=0;S document.importNode(tmpls[${C}].content.firstChild, true))`:`const ${y} = tmpls[${C}].content.firstChild.cloneNode(true)`}else if(w.type===\"text\"){let y=`_$el${i++}`;d.decl.push(`${y} = ${d.path}.${d.first?\"firstChild\":\"nextSibling\"}`),d.path=y,d.first=!1}else if(w.type===\"comment\"){let y=`_$el${i++}`;if(d.decl.push(`${y} = ${d.path}.${d.first?\"firstChild\":\"nextSibling\"}`),w.content===\"#\")if(d.multi)d.exprs.push(`r.insert(${d.parent}, exprs[${d.counter++}], ${y})`);else d.exprs.push(`r.insert(${d.parent}, exprs[${d.counter++}])`);d.path=y,d.first=!1}}function x(w,d){let y={path:\"\",decl:[],exprs:[],delegatedEvents:new Set,counter:0,first:!0,multi:!1,templateId:0,templateNodes:[]},b=i,C=w,V;if(w.length>1)w=[{type:\"fragment\",children:w}];if(w[0].name===\"###\")V=!0,s(w[0],y);else h(w[0],y);return e.delegateEvents(Array.from(y.delegatedEvents)),[[C].concat(y.templateNodes).map((S)=>Uf(S)),d(\"tmpls\",\"exprs\",\"r\",y.decl.join(`,\n`)+`;\n`+y.exprs.join(`;\n`)+(V?\"\":`;\nreturn _$el${b};\n`))]}function E(w,...d){let y=Ft.get(w)||f(w,{funcBuilder:t});return y[0].create(y,d,e)}return E}var o=Lf({effect:M,style:Vn,insert:kn,untrack:H,spread:At,createComponent:$n,delegateEvents:Sn,classList:Pn,mergeProps:wn,dynamicProperty:Nt,setAttribute:Pe,setAttributeNS:An,addEventListener:Nn,Aliases:an,getPropAlias:On,Properties:bn,ChildProperties:xn,DelegatedEvents:En,SVGElements:Ct,SVGNamespace:Cn});function Ie(e){let n=Math.floor(e/3600),t=Math.floor(e%3600/60),i=e%60;if(n>0)return`${n}h ${t}m`;return t>0?`${t}m ${i}s`:`${i}s`}function Re(e,n){if(!e)return 0;let t=new Date(e).getTime(),i=n?new Date(n).getTime():Date.now();return Math.round((i-t)/1000)}function jt(e){return e.toLowerCase().replace(/[^\\w\\s-]/g,\"\").trim().replace(/\\s+/g,\"-\").replace(/-+/g,\"-\")}var Zt=[\"code\",\"auditor\",\"unknown\"];var vn=[\"overview\",\"timeline\",\"sections\",\"findings\",\"plan\",\"usage\"],Fn=200,qt=300,Dt=100;function Le(e,n,t){if(t||e.length<=n)return{rows:e,total:e.length,capped:!1};return{rows:e.slice(0,n),total:e.length,capped:!0}}function _n(e,n){return e.length===n.length&&e.every((t,i)=>t===n[i])}var Mf=new Set([\"loops\",\"groups\",\"findings\",\"plans\"]),mf=new Set(vn);function ie(e){try{return decodeURIComponent(e)}catch{return e}}function pf(){return{projectId:null,section:\"loops\",loopName:null,tab:\"overview\",groupId:null,statuses:[],query:\"\"}}function Yn(e,n){if(!n)return;for(let t of n.split(\"&\")){if(!t)continue;let i=t.indexOf(\"=\");if(i===-1)continue;let f=ie(t.slice(0,i)),u=ie(t.slice(i+1));if(f===\"status\")e.statuses=u.split(\",\").map((r)=>r.trim()).filter((r)=>r!==\"\");else if(f===\"q\")e.query=u}}function Me(e){let n=pf(),t=(e||\"\").replace(/^#/,\"\");if(!t)return n;let i=t.indexOf(\"?\"),f=i===-1?t:t.slice(0,i),u=i===-1?\"\":t.slice(i+1),r=f.indexOf(\"/\"),c,l;if(r===-1)c=f,l=\"\";else c=f.slice(0,r),l=f.slice(r+1);if(c)n.projectId=ie(c);if(!l)return Yn(n,u),n;let s=l.split(\"/\").filter((x)=>x!==\"\");if(s.length===0)return Yn(n,u),n;let h=ie(s[0]);if(h===\"loop\"){if(s.length>=2){if(n.loopName=ie(s[1]),s.length>=3){let x=ie(s[2]);if(mf.has(x))n.tab=x}}}else if(Mf.has(h)){if(n.section=h,h===\"groups\"&&s.length>=2)n.groupId=ie(s[1])}else n.loopName=h;return Yn(n,u),n}function me(e){if(!e.projectId)return\"\";let n=\"#\"+encodeURIComponent(e.projectId);if(e.section===\"loops\"){if(e.loopName){if(n+=\"/loop/\"+encodeURIComponent(e.loopName),e.tab!==\"overview\")n+=\"/\"+e.tab}}else if(e.section===\"groups\"){if(n+=\"/groups\",e.groupId)n+=\"/\"+encodeURIComponent(e.groupId)}else n+=\"/\"+e.section;let t=[];if(e.statuses.length>0)t.push(\"status=\"+e.statuses.map((i)=>encodeURIComponent(i)).join(\",\"));if(e.query)t.push(\"q=\"+encodeURIComponent(e.query));if(t.length>0)n+=\"?\"+t.join(\"&\");return n}function Xt(e,n){let i=\"#\"+(location.hash||\"\").replace(/^#/,\"\"),f=\"#\"+e.replace(/^#/,\"\");if(i!==f)n.current=!0,location.hash=e}function j(e){if(!e||e===0)return\"\";let n=new Date(e),t=(l)=>l<10?\"0\"+l:String(l),i=t(n.getMonth()+1),f=t(n.getDate()),u=n.getFullYear(),r=n.getHours(),c=r>=12?\"PM\":\"AM\";if(r=r%12,r===0)r=12;return i+\"-\"+f+\"-\"+u+\" \"+r+\":\"+t(n.getMinutes())+\" \"+c}function fe(e){return\"status-badge status-\"+e}function Tn(e){return\"section-status section-\"+e}function Jt(e){return\"feature-stage feature-stage-\"+e}function Qt(e,n,t,i){if(!(n.size===0||n.has(e.status)))return!1;if(!t)return!0;let u=t.toLowerCase(),r=e.worktreeBranch??\"\";return((e.loopName||\"\")+\" \"+r+\" \"+(i||\"\")).toLowerCase().indexOf(u)!==-1}function Bt(e,n){if(n===\"recent\")return[...e];let t=[...e];if(n===\"cost\")t.sort((i,f)=>{let u=i.usage?.totalCost??null,r=f.usage?.totalCost??null;if(u===null&&r===null)return 0;if(u===null)return 1;if(r===null)return-1;return r-u});else if(n===\"duration\")t.sort((i,f)=>{let u=Re(i.loop.startedAt,i.loop.completedAt??void 0);return Re(f.loop.startedAt,f.loop.completedAt??void 0)-u});else if(n===\"findings\")t.sort((i,f)=>f.findings.length-i.findings.length);return t}function Rt(e){return JSON.stringify(e.projects)}function we(e){return e.projectDir||e.projectId||\"\"}function ge(e,n){let t=we(n);return e.get(t)??t}function ze(e){return e.completedAt||e.startedAt||0}function Lt(e){let n=new Map;for(let f of e){if(f===\"\"){n.set(f,[]);continue}let u=f.split(\"/\"),r=u.length;while(r>0&&u[r-1]===\"\")r--;n.set(f,r>0?u.slice(0,r):[f])}let t=new Map;for(let f of e){let u=n.get(f);t.set(f,u.length?u[u.length-1]:f)}let i=new Map;for(let f of e){let u=t.get(f),r=i.get(u);if(r)r.push(f);else i.set(u,[f])}for(let[,f]of i){let u=[...new Set(f)];if(u.length<2)continue;let r=1;while(r<1024){let c=new Set,l=!0,s=!0;for(let h of u){let x=n.get(h),E=Math.max(0,x.length-r),w=x.slice(E).join(\"/\");if(c.has(w))l=!1;else c.add(w);if(E>0)s=!1}if(l||s)break;r++}for(let c of u){let l=n.get(c),s=Math.max(0,l.length-r);t.set(c,l.slice(s).join(\"/\"))}}return t}function Hn(e){let n=[],t=[];for(let i of e)if(i.severity===\"bug\")n.push(i);else t.push(i);return{bugs:n,warnings:t}}function Mt(e){if(e.bugs.length>0)return\"bug\";if(e.warnings.length>0)return\"warn\";return\"clean\"}function be(e,n){return e+\" \"+n+(e===1?\"\":\"s\")}function Gn(e,n){if(!n||n<=0)return 0;return Math.max(0,Math.min(100,e/n*100))}function mt(e){let n=e.file+\":\"+e.line+\" — \"+e.description;if(e.scenario)n+=\" (\"+e.scenario+\")\";return n}function pt(e,n){return\" \"+e+\": $\"+n.cost.toFixed(6)+\", \"+n.inputTokens+\" in / \"+n.outputTokens+\" out (reasoning: \"+n.reasoningTokens+\", cache R: \"+n.cacheReadTokens+\" W: \"+n.cacheWriteTokens+\"), messages: \"+n.messageCount}function We(e){if(!Number.isFinite(e)||e<=0)return\"0\";if(e>=1e6)return(e/1e6).toFixed(1).replace(/\\.0$/,\"\")+\"M\";if(e>=1000)return(e/1000).toFixed(1).replace(/\\.0$/,\"\")+\"k\";return String(e)}function pe(e){if(!Number.isFinite(e)||e<=0)return\"$0\";return\"$\"+(e>=1?e.toFixed(2):e.toFixed(4))}function ei(e){let n=[{label:\"Input\",value:e.totalInputTokens,color:\"var(--seg-input)\"},{label:\"Output\",value:e.totalOutputTokens,color:\"var(--seg-output)\"},{label:\"Reasoning\",value:e.totalReasoningTokens,color:\"var(--seg-reasoning)\"},{label:\"Cache R\",value:e.totalCacheReadTokens,color:\"var(--seg-cache-read)\"},{label:\"Cache W\",value:e.totalCacheWriteTokens,color:\"var(--seg-cache-write)\"}],t=n.reduce((i,f)=>i+Math.max(0,f.value),0);return n.map((i)=>({...i,pct:t>0?Math.max(0,i.value)/t*100:0}))}var eu={code:\"execution\",auditor:\"auditor\",unknown:\"other\"};function ni(e){let t=Zt.map((f)=>{let u={role:eu[f],cost:0,messageCount:0,tokens:0,pct:0},r=e.byRole[f];if(!r)return u;return u.cost=r.cost,u.messageCount=r.messageCount,u.tokens=r.inputTokens+r.outputTokens+r.reasoningTokens,u}).filter((f)=>f.role!==\"other\"||f.cost>0||f.messageCount>0),i=t.reduce((f,u)=>Math.max(f,u.cost),0);for(let f of t)f.pct=i>0?f.cost/i*100:0;return t}function ti(e){let n=Object.keys(e.byModel).map((i)=>({model:i,...e.byModel[i]})),t=n.reduce((i,f)=>Math.max(i,f.cost),0);return n.sort((i,f)=>f.cost-i.cost).map((i)=>({model:i.model,cost:i.cost,inputTokens:i.inputTokens,outputTokens:i.outputTokens,messageCount:i.messageCount,pct:t>0?i.cost/t*100:0}))}function ii(e,n){if(!e)return\"\";let t=Re(e,n??void 0);return t>0?Ie(t):\"\"}function xe(e){if(!e||e===0)return\"\";let n=Date.now()-e,t=Math.floor(n/1000);if(t<60)return t+\"s ago\";let i=Math.floor(t/60);if(i<60)return i+\"m ago\";let f=Math.floor(i/60);if(f<24)return f+\"h ago\";let u=Math.floor(f/24);if(u<30)return u+\"d ago\";return j(e)}var nu=/([\\s\\S]*?)<\\/h\\1>/g;function fi(e){return e.replace(/<[^>]+>/g,\"\").replace(/&[a-z#0-9]+;/gi,\"\").trim()}function tu(e){return jt(fi(e)).replace(/[_-]+/g,\"-\").replace(/^-+|-+$/g,\"\")||\"heading\"}function iu(e){if(!//.test(e))return{html:e,outline:[]};let n=[];return{html:e.replace(nu,(i,f,u)=>{let r=Number(f),c=tu(u);return n.push({depth:r,id:c,text:fi(u)}),`${u}`}),outline:n}}function ui(e){if(!e)return{html:\"\",outline:[]};let n=Ke.get(e);if(n!==void 0)return n;let t=globalThis.marked;if(!t)return{html:\"\",outline:[]};let{html:i,outline:f}=iu(t.parse(e)),u={html:i,outline:f};if(Ke.size>=fu){let r=Ke.keys().next().value;if(r!==void 0)Ke.delete(r)}return Ke.set(e,u),u}function ri(e){return ui(e).html}function ci(e){return ui(e)}var Ke=new Map,fu=200;function en(e){let n=[\"overview\",\"timeline\"];if(e.sectionCount>0)n.push(\"sections\");if(n.push(\"findings\"),e.hasPlan)n.push(\"plan\");return n.push(\"usage\"),n}function li(e,n,t,i,f=\"coding\"){let u=[],r=e.findIndex((x)=>x.createdAt>=n),c=r===-1?[]:e.slice(r);if(t!==null)c=c.filter((x)=>x.createdAt<=t);let l;if(c.length>0&&c[0].eventType===\"restart\"&&c[0].toPhase!==null)l=c[0].toPhase;else if(c.length>0&&c[0].eventType!==\"restart\")l=c[0].fromPhase;else l=f;let s=n;for(let x of c){if(x.eventType===\"restart\"){if(x.toPhase!==null)l=x.toPhase;continue}if(l===\"\")l=x.fromPhase,s=Math.max(s,x.createdAt);let E=x.createdAt,w=Math.max(s,E);if(u.push({phase:l,startedAt:s,endedAt:w,durationMs:w-s,open:!1}),x.toPhase===null)l=\"\",s=w;else l=x.toPhase,s=w}if(l!==\"\"){let E=Math.max(s,t??i);u.push({phase:l,startedAt:s,endedAt:t,durationMs:E-s,open:t===null})}let h=e.length>0&&e[0].createdAt>=n&&e[0].createdAt>n+1000;if(h&&u.length>0)u[0]={...u[0],phase:\"\"};return{spans:u,truncated:h}}function si(e){let n={};for(let t of e){if(!t.phase)continue;n[t.phase]=(n[t.phase]??0)+t.durationMs}return n}function di(e,n,t){let i=[];for(let f=0;f=n)r=Math.max(0,u.createdAt-n);else if(u.createdAt=n)r=Math.max(0,u.createdAt-n);else r=null}i.push({transition:u,elapsedMs:r})}return i.reverse(),i}var uu=[{phase:\"coding\",label:\"coding\",cx:70,cy:60},{phase:\"auditing\",label:\"auditing\",cx:220,cy:60},{phase:\"final_auditing\",label:\"final_auditing\",cx:370,cy:60},{phase:\"post_action\",label:\"post_action\",cx:520,cy:60},{phase:\"final_audit_fix\",label:\"final_audit_fix\",cx:370,cy:160}],nn=110,tn=36,hi=655,oi=160,ru=[{key:\"coding→auditing\",d:\"M 125 60 L 165 60\",labelX:145,labelY:52},{key:\"auditing→final_auditing\",d:\"M 275 60 L 315 60\",labelX:295,labelY:52},{key:\"final_auditing→post_action\",d:\"M 425 60 L 465 60\",labelX:445,labelY:52},{key:\"auditing→coding\",d:\"M 220 42 Q 145 14 70 42\",labelX:145,labelY:18},{key:\"auditing→post_action\",d:\"M 220 42 Q 370 8 520 42\",labelX:370,labelY:16},{key:\"final_auditing→auditing\",d:\"M 315 42 Q 295 24 275 42\",labelX:295,labelY:30},{key:\"final_auditing→coding\",d:\"M 370 78 C 370 110 145 110 70 78\",labelX:220,labelY:102},{key:\"final_auditing→final_audit_fix\",d:\"M 370 78 L 370 142\",labelX:358,labelY:112},{key:\"final_audit_fix→final_auditing\",d:\"M 415 142 L 415 78\",labelX:442,labelY:116},{key:\"coding→final_audit_fix\",d:\"M 70 78 C 70 95 200 115 315 142\",labelX:175,labelY:118},{key:\"final_audit_fix→coding\",d:\"M 315 178 C 250 210 100 210 70 78\",labelX:175,labelY:205},{key:\"coding→terminal\",d:\"M 125 78 L 600 142\",labelX:240,labelY:90},{key:\"auditing→terminal\",d:\"M 275 78 L 600 142\",labelX:395,labelY:90},{key:\"final_auditing→terminal\",d:\"M 425 78 L 600 142\",labelX:505,labelY:90},{key:\"post_action→terminal\",d:\"M 520 78 L 600 142\",labelX:565,labelY:95},{key:\"final_audit_fix→terminal\",d:\"M 425 160 L 600 160\",labelX:512,labelY:152}];function cu(e){if(e.toPhase===null)return e.fromPhase+\"→terminal\";return e.fromPhase+\"→\"+e.toPhase}function lu(e){return e.toPhase??e.status??\"\"}function Un(e){let n=Array.isArray(e)?e[0]:e,t=n instanceof SVGSVGElement?n.firstElementChild:null;if(!(t instanceof SVGElement))throw Error(\"Expected an SVG child\");return t}function yi(e){let n=()=>e.loop(),t=g(()=>n().status===\"running\"),i=g(()=>{let c=new Map;for(let l of e.transitions()){let s=cu(l);c.set(s,(c.get(s)??0)+1)}return c}),f=ru.map((c)=>Un(o`\n \n \n ${()=>{let l=i().get(c.key);return l?String(l):\"\"}}\n \n `)),u=uu.map((c)=>{let l=c.cx-nn/2,s=c.cy-tn/2;return Un(o`\n \"mg-node\"+(n().phase===c.phase&&t()?\" mg-node-active\":\"\")}>\n \n ${c.label}\n \n `)}),r=Un(o`\n \n \n ${()=>n().status}\n \n `);return o`
\n \n \n \n \n \n \n\n ${f}\n ${u}\n ${()=>t()?\"\":r}\n \n\n
\n
Transition history
\n
\n ${()=>e.transitions().slice(-20).reverse().map((c)=>o`
\n ${c.eventType}\n ${c.fromPhase} → ${lu(c)}\n ${xe(c.createdAt)}\n
`)}\n
\n
\n
`}function bi(e){let[n,t]=K(e.query()),i=null,f=(r)=>{let c=r.target.value;if(t(c),i)clearTimeout(i);i=setTimeout(()=>{i=null,e.onChangeQuery(c)},250)};ce(()=>{if(i)clearTimeout(i),i=null}),R(()=>{e.externalNav();let r=H(()=>e.query());if(i!==null)clearTimeout(i),i=null;t(r)});let u=g(()=>{let r=e.counts();return[{label:\"Running\",key:\"running\",value:r.running},{label:\"Completed\",key:\"completed\",value:r.completed},{label:\"Cancelled\",key:\"cancelled\",value:r.cancelled},{label:\"Errored\",key:\"errored\",value:r.errored},{label:\"Stalled\",key:\"stalled\",value:r.stalled}]});return o`
\n ${()=>u().map(({label:r,key:c,value:l})=>{let h=`badge badge-filter${e.statuses().has(c)?\" badge-active\":\"\"}`;return o`e.onToggleStatus(c)}>${r}: ${l}`})}\n \n \n
`}function xi(e){return o`
Last updated: ${new Date(e.generatedAt).toLocaleString()}
`}function ai(e){let n=g(()=>e.entries().map((t)=>{let i=t.loops.some((r)=>r.loop.status===\"running\"),f=we(t.proj),u=ge(e.labels(),t.proj);return o`
e.onSelect(t.proj.projectId)}>\n ${i?o``:\"\"}\n ${u}\n ${t.loops.length}\n
`}));return o`
${()=>n()}
`}function su(e,n){let t=0,i=0,f=0,u=[],r=[];for(let c of e){let l=ge(n,c.proj);for(let s of c.loops){i++,f+=s.bugCount;let h=s.loop.status,x=ze(s.loop);if(h===\"running\")t++,u.push({projectId:c.proj.projectId,label:l,loopName:s.loop.loopName,phase:s.loop.phase||\"\"});r.push({projectId:c.proj.projectId,label:l,loopName:s.loop.loopName,status:h,when:x})}}return r.sort((c,l)=>l.when-c.when),{runningCount:t,loopCount:i,bugCount:f,runningCards:u,recentRows:r.slice(0,8)}}function Oi(e){let n=g(()=>su(e.entries(),e.labels())),t=g(()=>n().runningCount),i=g(()=>n().loopCount),f=g(()=>n().bugCount),u=g(()=>n().runningCards),r=g(()=>n().recentRows);return o`
\n
\n

All repositories

\n
${()=>`${t()} running · ${i()} loops · ${f()} open bugs`}
\n
\n ${()=>u().length>0?o`
\n

Running

\n
\n ${u().map((c)=>o`
e.onOpenLoop(c.projectId,c.loopName)}>\n ${c.label}\n ${c.loopName}\n ${c.phase}\n
`)}\n
\n
`:\"\"}\n ${()=>r().length>0?o`
\n

Recent activity

\n
\n ${r().map((c)=>o`
e.onOpenLoop(c.projectId,c.loopName)}>\n fe(c.status)}>${c.status}\n ${c.label}\n ${c.loopName}\n ${j(c.when)}\n
`)}\n
\n
`:\"\"}\n
`}function du(e){let[n,t]=K(!1),[i,f]=K(\"\"),[u,r]=K(0),c=g(()=>{let b=i().trim().toLowerCase(),C=e.loops();return b?C.filter((V)=>V.name.toLowerCase().indexOf(b)!==-1):C}),l=g(()=>Le(c(),Dt,!1)),s=()=>{if(n())return;f(\"\");let b=l().rows.findIndex((C)=>C.name===e.loopName);r(b<0?0:b),t(!0)},h=()=>{t(!1),f(\"\"),r(0)},x=(b)=>{if(h(),y.blur(),b!==e.loopName)e.onSelect(b)},E=o`n()?\"display:block\":\"display:none\"}\n onmousedown=${(b)=>b.preventDefault()}\n >\n ${()=>l().rows.map((b,C)=>o`\"loop-picker-option\"+(C===u()?\" loop-picker-option-active\":\"\")+(b.name===e.loopName?\" loop-picker-option-current\":\"\")}\n onclick=${()=>x(b.name)}\n >\n ${b.name}\n ${b.whenLabel}\n
`)}\n ${()=>l().total===0?o`
No matching loops
`:\"\"}\n ${()=>l().capped?o`
${()=>\"Showing \"+l().rows.length+\" of \"+l().total+\" — type to filter\"}
`:\"\"}\n
`,w=()=>{E.querySelector(\".loop-picker-option-active\")?.scrollIntoView?.({block:\"nearest\"})},d=(b)=>{if(b.key===\"ArrowDown\"||b.key===\"ArrowUp\"){if(b.preventDefault(),!n()){s();return}let C=l().rows.length;if(C===0)return;let V=b.key===\"ArrowDown\"?1:-1;r((A)=>(A+V+C)%C),w();return}if(b.key===\"Enter\"){b.preventDefault();let C=l().rows[u()];if(C)x(C.name);return}if(b.key===\"Escape\")b.preventDefault(),h(),y.blur()},y=o`n()?i():e.loopName}\n onfocus=${s}\n oninput=${(b)=>{f(b.target.value),r(0),t(!0)}}\n onkeydown=${d}\n onblur=${h}\n />`;return o`\n ${y}\n {if(b.preventDefault(),n())h(),y.blur();else y.focus()}}>▾\n ${E}\n `}function Ei(e){if(e.loopName)return o`
\n ← ${e.repoLabel}\n /\n ${du({loopName:e.loopName,loops:e.loops,onSelect:e.onSelectLoop})}\n ${()=>{let n=e.loopNav();return n?o`\n \n ${n.index+1} of ${n.total}\n \n `:\"\"}}\n
`;return o`
\n ← Repositories\n /\n ${e.repoLabel}\n ${e.projectDir}\n
`}function Ci(e){let n=[{key:\"loops\",label:\"Loops\",count:e.counts.loops},{key:\"groups\",label:\"Groups\",count:e.counts.groups},{key:\"findings\",label:\"Findings\",count:e.counts.findings},{key:\"plans\",label:\"Plans\",count:e.counts.plans}];return o`
\n ${n.map((t)=>{let f=`section-nav-item${t.key===e.section?\" section-nav-active\":\"\"}`;return o`
e.onNavigate(t.key)}>\n ${t.label}\n ${t.count}\n
`})}\n
`}function qn(e){return o`
\n ${()=>\"Showing \"+e.shown()+\" of \"+e.total()+\" \"+e.noun}\n ${e.onShowAll?o``:\"\"}\n
`}function Zn(e){let n=()=>Gn(e.current(),e.total());return o`\n \"width:\"+n()+\"%\"}>\n ${()=>e.current()}/${()=>e.total()}\n `}var hu={sm:4,md:14,lg:22};function Dn(e){let n=hu[e.variant],t=g(()=>e.spans().filter((u)=>u.phase!==\"\")),i=g(()=>Math.max(0,t().reduce((u,r)=>u+r.durationMs,0))),f=g(()=>e.spans().some((u)=>u.phase===\"\"));return o`
\"phase-bar phase-bar-\"+e.variant+(f()?\" phase-bar-trunc\":\"\")} data-truncated=${()=>f()?\"true\":\"false\"} style=${()=>\"height:\"+n+\"px\"}>\n ${()=>f()?o`\\u22ef`:\"\"}\n ${()=>{let u=t(),r=i();return u.map((c)=>{let l=r>0?c.durationMs/r*100:0,s=\"phase-seg\"+(c.open?\" phase-seg-open\":\"\");return o`
`})}}\n
`}function Xn(e,n){let t=e.loop;return li(e.transitions??[],t.startedAt,t.completedAt,t.completedAt===null?n():0,t.phase)}function Si(e){let[n,t]=K(!1),i=g(()=>Le(e.loops(),Fn,n()));return o`
\n \n \n \n \n \n \n ${()=>i().rows.map((f)=>ou({dashLoop:f,now:e.now,onOpen:e.onOpen}))}\n \n
StatusLoopPhasePhase spanIterSectionsFindingsCostDurationUpdated
\n ${()=>i().capped?qn({shown:()=>i().rows.length,total:()=>i().total,noun:\"loops\",onShowAll:()=>t(!0)}):\"\"}\n
`}function ou(e){let n=()=>e.dashLoop.loop,t=()=>e.dashLoop,i=g(()=>Hn(t().findings)),f=g(()=>Xn(t(),e.now).spans);return o`e.onOpen(e.dashLoop.loop.loopName)}>\n fe(n().status)}>${()=>n().status}\n ${()=>n().loopName}\n ${()=>n().phase}\n ${()=>Dn({spans:f,variant:\"sm\"})}\n ${Zn({current:()=>n().iteration,total:()=>n().maxIterations})}\n ${()=>n().totalSections>0?Zn({current:()=>n().currentSectionIndex,total:()=>n().totalSections}):o``}\n ${()=>{let u=i();if(u.bugs.length===0&&u.warnings.length===0)return o``;return o`\n ${u.bugs.length>0?o`${be(u.bugs.length,\"bug\")}`:\"\"}\n ${u.bugs.length>0&&u.warnings.length>0?\" · \":\"\"}\n ${u.warnings.length>0?o`${be(u.warnings.length,\"warn\")}`:\"\"}\n `}}\n ${()=>t().usage?pe(t().usage.totalCost):o``}\n ${()=>t().duration||\"\"}\n ${()=>j(ze(n()))}\n `}function Ye(e){let n=()=>typeof e.label===\"function\"?e.label():e.label,[t,i]=K(!1),f=()=>i((c)=>!c),u=g(()=>ci(e.src()||\"\")),r=g(()=>u().outline.filter((c)=>c.depth<=3));return o`
\n
\n t()?\"false\":\"true\"}\n onclick=${f}\n onkeydown=${(c)=>{if(c.key!==\"Enter\"&&c.key!==\" \")return;c.preventDefault(),f()}}\n >\n ${()=>t()?\"▸\":\"▾\"}\n

${()=>n()}

\n
\n \"Copy \"+n()+\" as markdown\"}\n onclick=${(c)=>{c.stopPropagation();let l=c.target,s=\"Copy\";navigator.clipboard.writeText(e.src()||\"\").then(()=>{l.textContent=\"Copied!\"},()=>{l.textContent=\"Failed\"}).then(()=>{setTimeout(()=>{l.textContent=s},2000)})}}\n >Copy\n
\n
t()?\"display:none\":\"display:block\"}>\n ${()=>{let c=r();return c.length>=2?yu({items:()=>c}):null}}\n
u().html}>
\n
\n `}function yu(e){let n=(t,i)=>{t.preventDefault();let f=t.currentTarget.closest(\".markdown-body\"),u=f?Array.from(f.querySelectorAll(\"h1,h2,h3,h4,h5,h6\")).find((r)=>r.id===i):document.getElementById(i);if(u&&typeof u.scrollIntoView===\"function\")u.scrollIntoView({behavior:\"smooth\",block:\"start\"})};return o``}function jn(e){return o`
\n
${e.label}
\n
ri(e.value()||\"\")}>
\n
`}function $u(e){let n=e.sec,t=g(()=>!!n.startedAt||!!n.completedAt),i=g(()=>!!n.summaryDone),f=g(()=>!!n.summaryDeviations),u=g(()=>!!n.summaryFollowUps),r=g(()=>!!n.content),c=g(()=>t()||i()||f()||u()||r());return o`
\n ${()=>t()?o`
\n ${()=>n.startedAt?\"Started \"+j(n.startedAt):\"Not started\"}\n ${()=>n.completedAt?o` → Completed ${()=>j(n.completedAt)}`:\"\"}\n
`:\"\"}\n ${()=>i()?jn({label:\"Done\",value:()=>n.summaryDone}):\"\"}\n ${()=>f()?jn({label:\"Deviations\",value:()=>n.summaryDeviations}):\"\"}\n ${()=>u()?jn({label:\"Follow-ups\",value:()=>n.summaryFollowUps}):\"\"}\n ${()=>r()?Ye({label:\"Section Plan\",src:()=>n.content}):\"\"}\n ${()=>c()?\"\":o`
No details captured for this section yet.
`}\n
`}function wu(e){let n=e.sec,t=g(()=>ii(n.startedAt,n.completedAt));return o`
\"section-list-row section-item-\"+n.status} onclick=${e.onOpen}>\n #${n.sectionIndex}\n ${()=>n.title}\n Tn(n.status)}>${()=>n.status}\n ${()=>t()?o`${()=>t()}`:\"\"}\n ${()=>n.attempts>0?o`${()=>n.attempts} attempts`:\"\"}\n \n
`}function gu(e){let[n,t]=K(null),i=g(()=>{let f=n();if(f===null)return null;return e.sections().find((u)=>u.sectionIndex===f)??null});return o`
\n

Sections

\n ${()=>{let f=i();if(f)return o`
\n
t(null)}>← Back to sections
\n
\n #${f.sectionIndex}\n ${()=>f.title}\n Tn(f.status)}>${()=>f.status}\n
\n ${$u({sec:f})}\n
`;return o`
\n ${e.sections().map((u)=>wu({sec:u,onOpen:()=>t(u.sectionIndex)}))}\n
`}}\n
`}function Z(e){return o`
\n ${e.label}\n ${()=>e.value()}\n
`}function $i(e){let n=()=>Gn(e.current(),e.total());return o`
\n
\n ${e.label}\n ${()=>e.current()} / ${()=>e.total()}\n
\n
\n
\"width:\"+n()+\"%\"}>
\n
\n
`}function bu(e){let n=()=>e.dashLoop.loop,t=()=>e.dashLoop,i=g(()=>!!n().completedAt),f=g(()=>!!t().duration),u=g(()=>n().totalSections>0),r=g(()=>!!n().terminationReason),c=g(()=>Mt(e.split())),l=g(()=>!!e.dashLoop.usage);return o`
\n
\"ldh-findings ldh-findings-\"+c()}>\n ${()=>{let s=e.split();if(s.bugs.length===0&&s.warnings.length===0)return\"No findings\";let h=[];if(s.bugs.length>0)h.push(be(s.bugs.length,\"bug\"));if(s.warnings.length>0)h.push(be(s.warnings.length,\"warning\"));return h.join(\" · \")}}\n
\n
\n fe(n().status)}>${()=>n().status}\n

${()=>n().loopName}

\n ${()=>n().phase}\n
\n\n
\n ${Z({label:\"Started\",value:()=>j(n().startedAt)})}\n ${()=>i()?Z({label:\"Completed\",value:()=>j(n().completedAt)}):\"\"}\n ${()=>f()?Z({label:\"Duration\",value:()=>t().duration||\"\"}):\"\"}\n ${Z({label:\"Iteration\",value:()=>n().iteration+\" / \"+n().maxIterations})}\n ${()=>u()?Z({label:\"Section\",value:()=>n().currentSectionIndex+\" / \"+n().totalSections}):\"\"}\n ${()=>l()?Z({label:\"Messages\",value:()=>String(e.dashLoop.usage.totalMessageCount)}):\"\"}\n ${Z({label:\"Execution model\",value:()=>n().executionModel??\"—\"})}\n ${Z({label:\"Auditor model\",value:()=>n().auditorModel??\"—\"})}\n ${Z({label:\"Branch\",value:()=>n().worktreeBranch??\"—\"})}\n ${Z({label:\"Sandbox\",value:()=>n().sandbox?\"on\":\"off\"})}\n ${Z({label:\"Kind\",value:()=>n().kind??\"—\"})}\n ${Z({label:\"Audits\",value:()=>String(n().auditCount)})}\n ${Z({label:\"Errors\",value:()=>String(n().errorCount)})}\n
\n\n
\n ${$i({label:\"Iterations\",current:()=>n().iteration,total:()=>n().maxIterations})}\n ${()=>u()?$i({label:\"Sections\",current:()=>n().currentSectionIndex,total:()=>n().totalSections}):\"\"}\n
\n\n ${()=>r()?o`
\"ldh-banner ldh-banner-\"+n().status}>${()=>n().terminationReason}
`:\"\"}\n
`}function xu(e){let n=()=>e.usage(),t=g(()=>ei(n())),i=g(()=>ti(n())),f=g(()=>i().length>0),u=g(()=>ni(n()));return o`
\n

Usage

\n\n
\n
Role split
\n
\n ${()=>u().map((r)=>o`
r.role}>\n
\n r.role}>${r.role}\n ${()=>pe(r.cost)}\n
\n
\n
\"width:\"+r.pct+\"%\"}>
\n
\n
${()=>r.messageCount+\" msg · \"+We(r.tokens)+\" tok\"}
\n
`)}\n
\n
\n\n
\n
Token composition
\n
\n ${()=>t().map((r)=>r.pct>0?o`
`:\"\")}\n
\n
\n ${()=>t().map((r)=>o`
\n \n ${r.label}\n ${We(r.value)}\n
`)}\n
\n
\n\n ${()=>f()?o`
\n
Cost by model
\n
\n ${()=>i().map((r)=>o`
\n
\n ${r.model}\n ${pe(r.cost)}\n
\n
\n
\n
\n
\n ${We(r.inputTokens)} in / ${We(r.outputTokens)} out · ${r.messageCount} msg\n
\n
`)}\n
\n
`:\"\"}\n `}function wi(e){try{return JSON.parse(e)}catch{return[]}}function gi(e){return\"#\"+e.index+\" \"+e.title}function au(e){let n=e.amendment,t=g(()=>wi(n.sectionsBefore)),i=g(()=>wi(n.sectionsAfter));return o`
\n
\n ${()=>xe(n.createdAt)}\n applied @ section ${()=>n.appliedAtSection}\n ${()=>n.source}\n ${()=>n.rationale}\n ${()=>e.expanded()?\"▾\":\"▸\"}\n
\n
e.expanded()?\"display:block\":\"display:none\"}>\n
\n ${()=>t().length>0?o`
\n
Before
\n ${()=>t().map((f)=>o`
${()=>gi(f)}
`)}\n
`:\"\"}\n ${()=>i().length>0?o`
\n
After
\n ${()=>i().map((f)=>o`
${()=>gi(f)}
`)}\n
`:\"\"}\n
\n
\n
`}function Ou(e){let n=()=>e.amendments(),[t,i]=K(null);return o`
\n

Plan amendments

\n
\n ${()=>n().map((f)=>au({amendment:f,expanded:()=>t()===f.id,onToggle:()=>i(t()===f.id?null:f.id)}))}\n
\n
`}var Eu={overview:\"Overview\",timeline:\"Timeline\",sections:\"Sections\",findings:\"Findings\",plan:\"Plan\",usage:\"Usage\"};function Cu(e){return o`
\n ${()=>e.tabs().map((n)=>{let t=Eu[n];return o`\"tab-item\"+(e.activeTab()===n?\" tab-active\":\"\")} data-tab=\"${n}\" onclick=${()=>e.onSelect(n)}>${t}`})}\n
`}function Su(e){let n=()=>e.dashLoop,t=()=>n().loop,i=g(()=>!!n().goal),f=g(()=>n().postActionReport??t().completionSummary??null),u=g(()=>n().postActionReport?\"Post-Action Report\":\"Completion Summary\"),r=g(()=>!!f()),c=g(()=>!!n().lastAuditResult),l=g(()=>Xn(n(),e.now));return o`
\n ${()=>Dn({spans:()=>l().spans,variant:\"md\"})}\n ${()=>i()?Ye({label:\"Goal\",src:()=>n().goal}):\"\"}\n ${bu({dashLoop:e.dashLoop,split:e.split})}\n ${()=>r()?Ye({label:u,src:()=>f()}):\"\"}\n ${()=>c()?Ye({label:\"Last Audit Result\",src:()=>n().lastAuditResult}):\"\"}\n
`}function Au(e){let n=e.ev.transition,t=e.ev.elapsedMs,i=t===null?null:Math.floor(t/1000),f=n.toPhase===null?n.status??\"\":n.toPhase;return o`
\n ${xe(n.createdAt)}\n ${n.fromPhase} → ${f}\n ${n.transitionKind}\n iter ${n.iteration}\n ${()=>n.sectionIndex===null?\"—\":\"sect \"+(n.sectionIndex+1)}\n ${()=>i===null?\"—\":i>0?Ie(i):\"0s\"}\n
`}function Nu(e){let n=()=>e.dashLoop,t=()=>n().loop,i=()=>n().transitions??[],f=g(()=>Xn(n(),e.now)),u=g(()=>f().spans),r=g(()=>f().truncated),c=g(()=>si(u())),l=g(()=>u().filter((y)=>y.phase!==\"\").reduce((y,b)=>y+b.durationMs,0)),s=g(()=>{let y=c();return Object.keys(y).filter((b)=>b!==\"\").sort((b,C)=>y[C]-y[b])}),h=g(()=>di(i(),t().startedAt,r())),[x,E]=K(!1),w=g(()=>{let y=h();return x()?y:y.slice(0,20)}),d=g(()=>x()?0:Math.max(0,h().length-20));return o`
\n ${yi({loop:()=>t(),transitions:i})}\n ${()=>Dn({spans:u,variant:\"lg\"})}\n ${()=>r()?o`
Earlier history was truncated by the 100-row fetch window.
`:\"\"}\n\n
\n ${()=>s().map((y)=>{let b=c()[y],C=l()>0?Math.round(b/l()*100):0;return o`
\n \n ${y}\n \n ${()=>b>=1000?Ie(Math.floor(b/1000)):b+\"ms\"} · ${C}%\n
`})}\n
\n\n
\n ${()=>w().map((y)=>Au({ev:y}))}\n ${()=>d()>0?o`
E(!0)}>[ ${()=>d()} earlier events ]
`:\"\"}\n
\n
`}function Pu(e){return gu({sections:()=>e.dashLoop.sections})}function Vu(e){let n=[],t=new Map;for(let i of e){let f=i.sectionIndex??null;if(!t.has(f))t.set(f,[]),n.push(f);t.get(f).push(i)}return n.sort((i,f)=>{if(i===null&&f===null)return 0;if(i===null)return 1;if(f===null)return-1;return i-f}),n.map((i)=>({key:i,label:i===null?\"cross-section\":\"sect \"+(i+1),rows:t.get(i)}))}function Ai(e){return o`
\n ${e.rows.map((n)=>o`
\"finding finding-\"+n.severity}>\n ${()=>xe(n.createdAt)}\n ${()=>mt(n)}\n
`)}\n
`}function Ni(e){let n=()=>me({projectId:e.projectId(),section:\"loops\",loopName:e.loopName,tab:\"overview\",groupId:null,statuses:[],query:\"\"});return o`{if(t.defaultPrevented||t.button!==0||t.metaKey||t.ctrlKey||t.shiftKey||t.altKey)return;t.preventDefault(),e.onOpen(e.loopName)}}>${e.loopName}`}function ku(e){let n=()=>e.dashLoop,t=g(()=>n().findings.length>0),i=g(()=>Vu(n().findings));return o`
\n ${()=>t()?i().map((f)=>o`
f.key===null?\"cross\":String(f.key)}>\n
${f.label}
\n ${Ai({rows:f.rows})}\n
`):o`
No findings recorded for this loop.
`}\n
`}function Iu(e){let n=()=>e.dashLoop,t=g(()=>n().hasPlan),i=g(()=>!!n().amendments&&n().amendments.length>0);return o`
\n ${()=>t()?Ye({label:\"Plan\",src:()=>n().plan}):o`
No plan recorded for this loop.
`}\n ${()=>i()?Ou({amendments:()=>n().amendments}):\"\"}\n
`}function Ku(e){let n=()=>e.dashLoop,t=g(()=>!!n().usage);return o`
\n ${()=>t()?xu({usage:()=>n().usage}):o`
No usage recorded for this loop.
`}\n
`}function zu(e){switch(e.tab){case\"overview\":return Su({dashLoop:e.dashLoop,split:e.split,now:e.now});case\"timeline\":return Nu({dashLoop:e.dashLoop,now:e.now});case\"sections\":return Pu({dashLoop:e.dashLoop});case\"findings\":return ku({dashLoop:e.dashLoop});case\"plan\":return Iu({dashLoop:e.dashLoop});case\"usage\":return Ku({dashLoop:e.dashLoop})}}function Wu(e){let n=g(()=>e.tabs().includes(e.tab)&&e.activeTab()===e.tab),t=De(),i=o`
n()?\"display:block\":\"display:none\"}>
`,f=!1;return R(()=>{if(!n()||f)return;f=!0;let u=on(t,()=>zu(e));if(u)i.appendChild(u)}),i}function Yu(e){return o`
\n ${vn.map((n)=>Wu({tab:n,dashLoop:e.dashLoop,split:e.split,now:e.now,tabs:e.tabs,activeTab:e.activeTab}))}\n
`}function Pi(e){let n=()=>e.dashLoop,t=g(()=>Hn(n().findings)),i=g(()=>en(n())),f=g(()=>{let u=e.routeTab();return i().includes(u)?u:\"overview\"});return o`
\n
\n ${Cu({tabs:i,activeTab:f,onSelect:e.onSelectTab})}\n ${Yu({dashLoop:e.dashLoop,split:t,now:e.now,tabs:i,activeTab:f})}\n
\n
`}function Jn(){return o`
No loops match the current filters.
`}var vu=new Set([\"completed\",\"cancelled\",\"errored\"]);function Vi(e){let n=e.length;return{completed:e.filter((i)=>i.stage===\"completed\").length,total:n}}function ki(e,n){let t=vu.has(n);return e+(t?` ${e}-terminal`:` ${e}-active`)}function Ii(e){return o`
\n

Groups

\n ${()=>{let n=e.groups();if(n.length===0)return o`
No feature groups for this repo.
`;return o`
\n ${n.map((t)=>{let i=t.group,f=g(()=>Vi(t.features));return o`
ki(\"group-row\",i.status)} data-group-status=${()=>i.status} onclick=${()=>e.onOpen(i.groupId)}>\n fe(i.status)}>${()=>i.status}\n ${()=>i.title}\n ${Zn({current:()=>f().completed,total:()=>f().total})}\n max ${()=>i.maxConcurrent}\n \n Created ${()=>j(i.createdAt)}\n ${()=>i.completedAt?o`Completed ${()=>j(i.completedAt)}`:\"\"}\n \n
`})}\n
`}}\n
`}function Ki(e){return o`
\n
← Back to groups
\n ${()=>{let n=e.group();if(!n)return o`
Group not found.
`;let t=n.group,i=g(()=>Vi(n.features));return o`
\n
ki(\"group-header\",t.status)} data-group-status=${()=>t.status}>\n
\n fe(t.status)}>${()=>t.status}\n

${()=>t.title}

\n max ${()=>t.maxConcurrent}\n
\n
\n ${()=>\"Features \"+i().completed+\"/\"+i().total}\n Created ${()=>j(t.createdAt)}\n ${()=>t.completedAt?o`Completed ${()=>j(t.completedAt)}`:\"\"}\n ${()=>t.error?o`${()=>t.error}`:\"\"}\n
\n ${()=>t.prdPreview?o`
\n

PRD preview

\n
${()=>t.prdPreview||\"\"}
\n
`:\"\"}\n
\n
\n ${n.features.map((f)=>o`
f.stage}>\n #${()=>f.featureIndex}\n ${()=>f.title}\n Jt(f.stage)}>${()=>f.stage}\n ${()=>f.attempts} attempts\n ${()=>!!f.loopName&&e.loopNames().has(f.loopName)?Ni({className:\"feature-loop-link\",projectId:e.projectId,loopName:f.loopName,onOpen:e.onOpenLoop}):\"\"}\n ${()=>f.error?o`${()=>f.error}`:\"\"}\n
`)}\n
\n
`}}\n
`}function Fu(e,n,t){let i=[],f=0,u=t?1/0:n;for(let r of e){if(r.findings.length===0)continue;if(f+=r.findings.length,u<=0)continue;let c=Math.min(u,r.findings.length);i.push({loopName:r.loop.loopName,rows:r.findings.slice(0,c)}),u-=c}return{groups:i,total:f,capped:!t&&f>n}}function zi(e){let[n,t]=K(!1),i=g(()=>Fu(e.loops(),qt,n()));return o`
\n

Findings ${()=>i().total}

\n ${()=>i().capped?qn({shown:()=>i().groups.reduce((f,u)=>f+u.rows.length,0),total:()=>i().total,noun:\"findings\",onShowAll:()=>t(!0)}):\"\"}\n ${()=>{let f=i().groups;if(f.length===0)return o`
No findings recorded for this repo.
`;return o`
\n ${f.map((u)=>o`
u.loopName}>\n
\n ${Ni({className:\"findings-loop-link\",projectId:e.projectId,loopName:u.loopName,onOpen:e.onOpenLoop})}\n ${()=>u.rows.length}\n
\n ${Ai({rows:u.rows})}\n
`)}\n
`}}\n
`}function Wi(e){let[n,t]=K(!1),i=g(()=>e.loops().filter((u)=>u.hasPlan)),f=g(()=>Le(i(),Fn,n()));return o`
\n

Plans ${()=>f().total}

\n ${()=>f().capped?qn({shown:()=>f().rows.length,total:()=>f().total,noun:\"plans\",onShowAll:()=>t(!0)}):\"\"}\n ${()=>{let u=f().rows;if(u.length===0)return o`
No plans recorded for this repo.
`;return o`
\n ${u.map((r)=>o`
e.onOpenLoop(r.loop.loopName)}>\n fe(r.loop.status)}>${()=>r.loop.status}\n ${()=>r.loop.loopName}\n \n ${()=>be(r.findings.length,\"finding\")}\n \n iter ${()=>r.loop.iteration}/${()=>r.loop.maxIterations}\n
`)}\n
`}}\n
`}function Yi(){let[e,n]=zt({generatedAt:0,projects:[]}),[t,i]=K(!1),f=Me(typeof location<\"u\"?location.hash:\"\"),[u,r]=K(f.projectId),[c,l]=K(f.section),[s,h]=K(f.loopName),[x,E]=K(f.tab),[w,d]=K(f.groupId),[y,b]=K(f.statuses,{equals:_n}),[C,V]=K(f.query),A=g(()=>({projectId:u(),section:c(),loopName:s(),tab:x(),groupId:w(),statuses:y(),query:C()})),[S,P]=K(null),[I,z]=K(\"recent\"),[G,F]=K(0),[m,p]=K(Date.now()),ve={current:\"\"},ae={current:!1},Fi=()=>{let $=H(A),a=new URLSearchParams;if($.projectId)a.set(\"project\",$.projectId);if($.loopName)a.set(\"loop\",$.loopName);let N=a.toString();return N?\"/api/data?\"+N:\"/api/data\"},Fe=0,ue={url:null,gen:0},fn=async()=>{let $=Fi();if(ue.url===$)return;let a=++Fe;ue.url=$,ue.gen=a;try{let N=await fetch($,{cache:\"no-store\"});if(a!==Fe)return;let Y=await N.json();if(a!==Fe)return;let q=Rt(Y);if(q!==ve.current)ve.current=q,n(Wt(Y,{key:\"id\"})),i(!0),P(null);p(Date.now())}catch(N){if(a!==Fe)return;let Y=N instanceof Error?N.message:String(N);P(\"Failed to load dashboard data: \"+Y)}finally{if(ue.gen===a)ue.url=null,ue.gen=0}},Qn=($)=>{Ne(()=>{r($.projectId),l($.section),h($.loopName),E($.tab),d($.groupId),b($.statuses),V($.query)})},U=($)=>{Ne(()=>{let a=$.projectId!==void 0&&$.projectId!==u();if($.projectId!==void 0)r($.projectId);if($.section!==void 0)l($.section);if($.loopName!==void 0)h($.loopName);if($.tab!==void 0)E($.tab);if($.groupId!==void 0)d($.groupId);if($.statuses!==void 0)b($.statuses);if($.query!==void 0)V($.query);if(a)b([]),V(\"\")})},_i=($)=>{U({tab:$})},_e=($)=>{U({loopName:$,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})},Ti=($)=>{let a=new Set(y());if(a.has($))a.delete($);else a.add($);U({statuses:[...a]})},Hi=($)=>{U({query:$})},Gi=($)=>{z($)},Bn=g(()=>new Set(y())),Te=g(()=>Lt(e.projects.map(we))),He=g(()=>{if(!t()||e.projects.length===0)return[];let $=Bn(),a=C(),N=Te(),Y=[],q=a.trim().toLowerCase();for(let Ee of e.projects){let cn=[],pn=ge(N,Ee);for(let et of Ee.loops)if(Qt(et.loop,$,a,pn))cn.push(et);let rf=(Ee.groups?.length??0)>0;if(cn.length>0)Y.push({proj:Ee,loops:cn});else if(rf&&(!q||pn.toLowerCase().indexOf(q)!==-1))Y.push({proj:Ee,loops:[]})}return Y}),Rn=g(()=>u()===null),Ui=g(()=>c()===\"groups\"),Ln=g(()=>c()===\"loops\"),ji=g(()=>c()===\"findings\"),Zi=g(()=>c()===\"plans\"),Mn=g(()=>{let $=He(),a=u();if(!a)return null;return $.find((N)=>N.proj.projectId===a)??null}),L=g(()=>{let $=u();if(!$)return null;return e.projects.find((a)=>a.projectId===$)??null}),un=g(()=>{let $=u(),a=s();if(!$||!a)return null;let N=L();if(!N)return null;return N.loops.find((Y)=>Y.loop.loopName===a)??null}),qi=g(()=>{let $=L(),a=$?.groups?.length??0;if(!$)return{loops:0,groups:a,findings:0,plans:0};let N=0,Y=0;for(let q of $.loops)if(N+=q.findings.length,q.hasPlan)Y++;return{loops:$.loops.length,groups:a,findings:N,plans:Y}}),Oe=g(()=>{let $=L();if(!$)return[];return $.loops.map((a)=>{let N=ze(a.loop);return{name:a.loop.loopName,when:N,whenLabel:j(N)}}).sort((a,N)=>N.when-a.when||a.name.localeCompare(N.name))}),rn=g(()=>{let $=s();if(!$)return-1;return Oe().findIndex((a)=>a.name===$)}),Di=g(()=>{let $=rn();if($<0)return null;let a=Oe();if(a.length<=1)return null;return{index:$,total:a.length,onPrev:()=>{let N=rn(),Y=Oe();if(N>0)U({loopName:Y[N-1].name})},onNext:()=>{let N=rn(),Y=Oe();if(N$===a||!!$&&!!a&&$.index===a.index&&$.total===a.total}),Xi=g(()=>{let $=L(),a={running:0,completed:0,cancelled:0,errored:0,stalled:0};if(!$)return a;for(let N of $.loops){let Y=N.loop.status;if(Y===\"running\")a.running++;else if(Y===\"completed\")a.completed++;else if(Y===\"cancelled\")a.cancelled++;else if(Y===\"errored\")a.errored++;else if(Y===\"stalled\")a.stalled++}return a}),Ji=g(()=>{let $=s();if(!u())return\"\";let a=L();if(!a)return\"\";let N=we(a),Y=ge(Te(),a);return H(()=>Ei({repoLabel:Y,projectDir:N,loopName:$,loops:Oe,loopNav:Di,onSelectLoop:(q)=>U({loopName:q}),onBackToRepos:()=>U({projectId:null,loopName:null,section:\"loops\"}),onBackToRepo:()=>U({loopName:null})}))}),Qi=g(()=>{let $=u(),a=s();if(!$||a)return\"\";return Ci({section:c(),counts:qi(),onNavigate:(N)=>U({section:N,loopName:null,groupId:null})})}),Bi=g(()=>{return u()!==null&&s()===null&&c()===\"loops\"}),Ri=g(()=>{if(!Bi())return\"\";return H(()=>bi({counts:Xi,statuses:Bn,query:C,sortMode:I,onToggleStatus:Ti,onChangeQuery:Hi,onChangeSort:Gi,externalNav:G}))}),Li=g(()=>{if(!Ln())return\"\";let $=un();return $?Pi({dashLoop:$,now:m,routeTab:x,onSelectTab:_i}):\"\"}),Mi=g(()=>{let $=Mn();return $?Bt($.loops,I()):[]},[],{equals:_n}),mi=g(()=>{if(!Ln())return\"\";if(un())return\"\";if(!u())return\"\";return o`
\n ${Si({loops:Mi,now:m,onOpen:($)=>_e($)})}\n ${()=>Mn()?\"\":Jn()}\n
`}),pi=g(()=>{if(c()!==\"groups\"||!w())return null;let $=L();if(!$)return null;return $.groups.find((a)=>a.group.groupId===w())??null}),ef=g(()=>{let $=L();if(!$)return new Set;return new Set($.loops.map((a)=>a.loop.loopName))}),nf=g(()=>{if(!Ui())return\"\";if(!u())return\"\";if(w())return Ki({group:pi,loopNames:ef,projectId:()=>u(),onBack:()=>U({groupId:null}),onOpenLoop:_e});return Ii({groups:()=>L()?.groups??[],onOpen:(a)=>U({groupId:a})})}),mn=g(()=>{let $=L();return $?$.loops:[]}),tf=g(()=>{if(!ji())return\"\";if(!u())return\"\";return zi({loops:mn,projectId:()=>u(),onOpenLoop:_e})}),ff=g(()=>{if(!Zi())return\"\";if(!u())return\"\";return Wi({loops:mn,onOpenLoop:_e})});R(()=>{if(!t())return;let $=u();if($===null)return;if(!e.projects.some((N)=>N.projectId===$))r(null),h(null),l(\"loops\"),b([]),V(\"\")}),R(()=>{let $=u(),a=s();if(!$||!a)return;let N=L();if(!N)return;if(!N.loops.some((q)=>q.loop.loopName===a))h(null)}),R(()=>{let $=s(),a=x();if(!u()||!$||a===\"overview\")return;let N=un();if(!N)return;if(!en(N).includes(a))E(\"overview\")}),R(()=>{if(!t())return;Xt(me(A()),ae)});let uf=g(()=>(u()??\"\")+\"\\x00\"+(s()??\"\"));return R(dt(uf,()=>{fn()},{defer:!0})),ht(()=>{Qn(Me(location.hash)),fn();let $=setInterval(()=>{fn()},5000),a=()=>{if(ae.current){ae.current=!1;return}Qn(Me(location.hash)),F((N)=>N+1)};window.addEventListener(\"hashchange\",a),ce(()=>{clearInterval($),window.removeEventListener(\"hashchange\",a)})}),o`
\n

Forge Dashboard

\n\n ${()=>{let $=S();return $?o`
${$}
`:\"\"}}\n\n ${()=>{if(!t())return\"\";return o`
\n ${xi({generatedAt:e.generatedAt})}\n
`}}\n\n ${()=>{if(!t())return\"\";if(Rn()&&He().length===0)return Jn();return o`
\n ${()=>{if(Rn())return o`
\n ${ai({entries:()=>He(),labels:()=>Te(),onSelect:($)=>U({projectId:$,loopName:null,section:\"loops\",groupId:null})})}\n ${Oi({entries:()=>He(),labels:()=>Te(),onOpenLoop:($,a)=>U({projectId:$,loopName:a,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})})}\n
`;return o`
\n ${Ji}\n ${Ri}\n ${Qi}\n ${mi}\n ${Li}\n ${nf}\n ${tf}\n ${ff}\n
`}}\n
`}}\n
`}var vi=document.getElementById(\"forge-app-root\");if(vi)St(Yi,vi);\n"; +export const DASHBOARD_APP_SOURCE_HASH: string = "e5bd222ee43b0b17441e696a223b68bae3e1571cae17c38ddfb81ff718292863"; diff --git a/src/dashboard/app/app.ts b/src/dashboard/app/app.ts index 2d5b9bd0b2..0acecada62 100644 --- a/src/dashboard/app/app.ts +++ b/src/dashboard/app/app.ts @@ -3,7 +3,7 @@ import { createStore, reconcile } from 'solid-js/store' import html from 'solid-js/html' import type { DashboardPayload, DashboardProject, DashboardLoop, DashboardGroup } from './types' import type { DashboardRoute, SortMode, RepoSection, LoopTab } from './helpers' -import { parseDashboardHash, buildDashboardHash, syncHash, dataHash, loopMatchesFilters, buildRepoLabels, sortLoops, tabsForLoop, sameList, fmtTime } from './helpers' +import { parseDashboardHash, buildDashboardHash, syncHash, dataHash, loopMatchesFilters, buildRepoLabels, repoRawPath, repoLabel, loopActivityAt, sortLoops, tabsForLoop, sameList, fmtTime } from './helpers' import { FilterBar, Timestamp, @@ -184,9 +184,7 @@ export function App() { const activeStatusSet = createMemo(() => new Set(statuses())) - const repoLabels = createMemo(() => - buildRepoLabels(state.projects.map(p => p.projectDir || p.projectId || '')), - ) + const repoLabels = createMemo(() => buildRepoLabels(state.projects.map(repoRawPath))) const matchedByProject = createMemo(() => { if (!loaded() || state.projects.length === 0) return [] @@ -197,8 +195,7 @@ export function App() { const q = search.trim().toLowerCase() for (const proj of state.projects) { const matched: DashboardLoop[] = [] - const rawPath = proj.projectDir || proj.projectId || '' - const label = labels.get(rawPath) ?? rawPath + const label = repoLabel(labels, proj) for (const dashLoop of proj.loops) { if (loopMatchesFilters(dashLoop.loop, statuses, search, label)) { matched.push(dashLoop) @@ -261,7 +258,7 @@ export function App() { if (!proj) return [] return proj.loops .map(dl => { - const when = dl.loop.completedAt || dl.loop.startedAt || 0 + const when = loopActivityAt(dl.loop) return { name: dl.loop.loopName, when, whenLabel: fmtTime(when) } }) .sort((a, b) => (b.when - a.when) || a.name.localeCompare(b.name)) @@ -327,8 +324,8 @@ export function App() { if (!projectId()) return '' const proj = selectedRepoProject() if (!proj) return '' - const rawPath = proj.projectDir || proj.projectId || '' - const label = repoLabels().get(rawPath) ?? rawPath + const rawPath = repoRawPath(proj) + const label = repoLabel(repoLabels(), proj) return untrack(() => Breadcrumb({ repoLabel: label, projectDir: rawPath, diff --git a/src/dashboard/app/components.ts b/src/dashboard/app/components.ts index a1b468dce2..ac4656a32e 100644 --- a/src/dashboard/app/components.ts +++ b/src/dashboard/app/components.ts @@ -30,6 +30,10 @@ import { computeTimelineEvents, buildDashboardHash, capList, + repoRawPath, + repoLabel, + loopActivityAt, + ALL_TABS, MAX_RENDERED_LOOP_ROWS, MAX_RENDERED_FINDING_ROWS, MAX_RENDERED_PICKER_OPTIONS, @@ -140,8 +144,8 @@ export function RepoMenu(props: { const items = createMemo(() => props.entries().map((entry: MatchedEntry) => { const hasRunning = entry.loops.some(dl => dl.loop.status === 'running') - const rawPath = entry.proj.projectDir || entry.proj.projectId || '' - const label = props.labels().get(rawPath) ?? rawPath + const rawPath = repoRawPath(entry.proj) + const label = repoLabel(props.labels(), entry.proj) return html`
props.onSelect(entry.proj.projectId)}> ${hasRunning ? html`` : ''} ${label} @@ -168,13 +172,12 @@ function deriveRepoIndex(entries: MatchedEntry[], labels: Map): const runningCards: RunningCard[] = [] const recent: RecentRow[] = [] for (const entry of entries) { - const rawPath = entry.proj.projectDir || entry.proj.projectId || '' - const label = labels.get(rawPath) ?? rawPath + const label = repoLabel(labels, entry.proj) for (const dl of entry.loops) { loopCount++ - for (const f of dl.findings) if (f.severity === 'bug') bugCount++ + bugCount += dl.bugCount const status = dl.loop.status - const when = dl.loop.completedAt || dl.loop.startedAt || 0 + const when = loopActivityAt(dl.loop) if (status === 'running') { runningCount++ runningCards.push({ projectId: entry.proj.projectId, label, loopName: dl.loop.loopName, phase: dl.loop.phase || '' }) @@ -254,11 +257,8 @@ function LoopPicker(props: { const openMenu = () => { if (open()) return setQuery('') - const all = props.loops() - const idx = all.findIndex(o => o.name === props.loopName) - const capped = all.slice(0, MAX_RENDERED_PICKER_OPTIONS) - const cappedIdx = idx >= 0 && idx < capped.length ? idx : -1 - setActive(cappedIdx < 0 ? 0 : cappedIdx) + const idx = view().rows.findIndex(o => o.name === props.loopName) + setActive(idx < 0 ? 0 : idx) setOpen(true) } const close = () => { @@ -523,7 +523,7 @@ function LoopTableRow(props: { dashLoop: DashboardLoop; now: () => number; onOpe }} ${() => (dl().usage ? formatUsageCost(dl().usage!.totalCost) : html``)} ${() => dl().duration || ''} - ${() => fmtTime(lp().completedAt || lp().startedAt)} + ${() => fmtTime(loopActivityAt(lp()))} ` } // ── MarkdownSection ─────────────────────────────────────────────────────── @@ -949,8 +949,6 @@ function AmendmentsPanel(props: {
` } -const ALL_TABS: LoopTab[] = ['overview', 'timeline', 'sections', 'findings', 'plan', 'usage'] - const TAB_LABELS: Record = { overview: 'Overview', timeline: 'Timeline', @@ -1270,9 +1268,9 @@ function groupFeaturesMeter(features: GroupFeatureRow[]): { completed: number; t return { completed, total } } -function groupRowClass(status: string): string { +function groupStatusClass(base: 'group-row' | 'group-header', status: string): string { const terminal = TERMINAL_GROUP_STATUSES.has(status) - return 'group-row' + (terminal ? ' group-row-terminal' : ' group-row-active') + return base + (terminal ? ` ${base}-terminal` : ` ${base}-active`) } export function GroupsPanel(props: { @@ -1288,7 +1286,7 @@ export function GroupsPanel(props: { ${groups.map(g => { const gr = g.group const meter = createMemo(() => groupFeaturesMeter(g.features)) - return html`
groupRowClass(gr.status)} data-group-status=${() => gr.status} onclick=${() => props.onOpen(gr.groupId)}> + return html`
groupStatusClass('group-row', gr.status)} data-group-status=${() => gr.status} onclick=${() => props.onOpen(gr.groupId)}> statusClass(gr.status)}>${() => gr.status} ${() => gr.title} ${MiniMeter({ current: () => meter().completed, total: () => meter().total })} @@ -1304,11 +1302,6 @@ export function GroupsPanel(props: {
` } -function groupHeaderClass(status: string): string { - const terminal = TERMINAL_GROUP_STATUSES.has(status) - return 'group-header' + (terminal ? ' group-header-terminal' : ' group-header-active') -} - export function GroupDetail(props: { group: () => DashboardGroup | null loopNames: () => Set @@ -1326,7 +1319,7 @@ export function GroupDetail(props: { // Single root element: solid-js/html generates invalid code for a // template whose top level is a fragment of sibling roots. return html`
-
groupHeaderClass(gr.status)} data-group-status=${() => gr.status}> +
groupStatusClass('group-header', gr.status)} data-group-status=${() => gr.status}>
statusClass(gr.status)}>${() => gr.status}

${() => gr.title}

diff --git a/src/dashboard/app/helpers.ts b/src/dashboard/app/helpers.ts index bb9736edd3..1e674e775f 100644 --- a/src/dashboard/app/helpers.ts +++ b/src/dashboard/app/helpers.ts @@ -1,9 +1,15 @@ -import type { DashboardPayload, DashboardLoop, LoopTransitionRow } from './types' +import type { DashboardPayload, DashboardProject, DashboardLoop, LoopTransitionRow } from './types' import { formatDuration, computeElapsedSeconds } from '../../utils/duration' +import { slugifyText } from '../../utils/format' +import { USAGE_ROLE_ORDER } from '../../loop/token-usage' +import type { UsageRole } from '../../loop/token-usage' export type LoopTab = 'overview' | 'timeline' | 'sections' | 'findings' | 'plan' | 'usage' export type RepoSection = 'loops' | 'groups' | 'findings' | 'plans' +/** Every loop tab, in render order. Also the set `parseDashboardHash` accepts. */ +export const ALL_TABS: readonly LoopTab[] = ['overview', 'timeline', 'sections', 'findings', 'plan', 'usage'] + export const MAX_RENDERED_LOOP_ROWS = 200 export const MAX_RENDERED_FINDING_ROWS = 300 export const MAX_RENDERED_PICKER_OPTIONS = 100 @@ -41,7 +47,7 @@ export interface DashboardRoute { } const SECTION_KEYWORDS: ReadonlySet = new Set(['loops', 'groups', 'findings', 'plans']) -const LOOP_TABS: ReadonlySet = new Set(['overview', 'timeline', 'sections', 'findings', 'plan', 'usage']) +const LOOP_TABS: ReadonlySet = new Set(ALL_TABS) function safeDecode(s: string): string { try { @@ -258,6 +264,22 @@ export function dataHash(data: DashboardPayload): string { return JSON.stringify(data.projects) } +/** The path a project is keyed by in the label map built by `buildRepoLabels`. */ +export function repoRawPath(proj: DashboardProject): string { + return proj.projectDir || proj.projectId || '' +} + +/** Lookup contract for `buildRepoLabels`: the raw path is the fallback label. */ +export function repoLabel(labels: Map, proj: DashboardProject): string { + const rawPath = repoRawPath(proj) + return labels.get(rawPath) ?? rawPath +} + +/** Timestamp a loop last moved: completion when finished, otherwise its start. */ +export function loopActivityAt(loop: DashboardLoop['loop']): number { + return loop.completedAt || loop.startedAt || 0 +} + export function buildRepoLabels(paths: string[]): Map { const segsByPath = new Map() for (const p of paths) { @@ -439,24 +461,32 @@ interface RoleUsageBar { } /** - * Cost/message split by loop role. The persisted vocabulary is - * `code | auditor | unknown` (loop_session_usage.role); `code` is surfaced as - * "execution" to match the `executionModel` label used elsewhere in the UI, and - * anything else lands in "other" so the bars always reconcile with totalCost. + * Display label per persisted role. `code` is surfaced as "execution" to match + * the `executionModel` label used elsewhere in the UI, and `unknown` as "other". + */ +const ROLE_BAR_LABELS: Record = { + code: 'execution', + auditor: 'auditor', + unknown: 'other', +} + +/** + * Cost/message split by loop role, in `USAGE_ROLE_ORDER`. Attributed roles + * always get a bar so the split is comparable across loops; the unattributed + * "other" bar appears only when usage landed there, and then the bars still + * reconcile with totalCost. */ export function roleUsageBars(u: NonNullable): RoleUsageBar[] { - const exec: RoleUsageBar = { role: 'execution', cost: 0, messageCount: 0, tokens: 0, pct: 0 } - const audit: RoleUsageBar = { role: 'auditor', cost: 0, messageCount: 0, tokens: 0, pct: 0 } - const other: RoleUsageBar = { role: 'other', cost: 0, messageCount: 0, tokens: 0, pct: 0 } - for (const [role, agg] of Object.entries(u.byRole ?? {})) { - const bar = role === 'auditor' ? audit : role === 'code' ? exec : other - bar.cost += agg.cost - bar.messageCount += agg.messageCount - // Token fields may be absent on aggregates persisted before this field was - // tracked, or on hand-built fixtures; guard so the meta never renders NaN. - bar.tokens += (agg.inputTokens ?? 0) + (agg.outputTokens ?? 0) + (agg.reasoningTokens ?? 0) - } - const bars = other.cost > 0 || other.messageCount > 0 ? [exec, audit, other] : [exec, audit] + const all = USAGE_ROLE_ORDER.map(role => { + const bar: RoleUsageBar = { role: ROLE_BAR_LABELS[role], cost: 0, messageCount: 0, tokens: 0, pct: 0 } + const agg = u.byRole[role] + if (!agg) return bar + bar.cost = agg.cost + bar.messageCount = agg.messageCount + bar.tokens = agg.inputTokens + agg.outputTokens + agg.reasoningTokens + return bar + }) + const bars = all.filter(b => b.role !== 'other' || b.cost > 0 || b.messageCount > 0) const max = bars.reduce((m, b) => Math.max(m, b.cost), 0) for (const bar of bars) bar.pct = max > 0 ? (bar.cost / max) * 100 : 0 return bars @@ -518,10 +548,8 @@ function stripHtml(html: string): string { } function slugifyHeading(htmlText: string): string { - const text = stripHtml(htmlText) - .toLowerCase() - .replace(/[^\w\s-]/g, '') - .replace(/[\s_-]+/g, '-') + const text = slugifyText(stripHtml(htmlText)) + .replace(/[_-]+/g, '-') .replace(/^-+|-+$/g, '') return text || 'heading' } diff --git a/src/dashboard/data.ts b/src/dashboard/data.ts index aec02a99d0..77e00369e8 100644 --- a/src/dashboard/data.ts +++ b/src/dashboard/data.ts @@ -41,7 +41,11 @@ export interface DashboardLoop { sections: SectionPlanRow[] /** Number of section-plan rows. Always populated; `sections` rows ship only for the scoped loop. */ sectionCount: number + /** Full findings rows; ship only for the scoped project. */ findings: ReviewFindingRow[] + /** Number of bug-severity findings. Always populated; `findings` rows ship only for the scoped project. */ + bugCount: number + /** Token/cost aggregate; ships only for the scoped project. */ usage: LoopUsageAggregate | null duration: string | null transitions: LoopTransitionRow[] @@ -174,6 +178,10 @@ export function collectDashboardData(db: Database, scope: DashboardScope = UNSCO const inScopedProject = scope.projectId !== null && projectId === scope.projectId const planLoopNames = new Set(plansRepo.listLoopNames(projectId)) const sectionCounts = sectionPlansRepo.countsByLoop(projectId) + const bugCounts = reviewFindingsRepo.bugCountsByLoop(projectId) + const transitionsByLoop = inScopedProject && loopTransitionsRepo + ? loopTransitionsRepo.listForProject(projectId, 100) + : null const dashboardLoops: DashboardLoop[] = sortedLoops.map(loop => { const loopName = loop.loopName @@ -190,12 +198,11 @@ export function collectDashboardData(db: Database, scope: DashboardScope = UNSCO const sectionCount = sectionCounts.get(loopName) ?? 0 const sections = isScopedLoop ? sectionPlansRepo.list(projectId, loopName) : [] - const findings = reviewFindingsRepo.listByLoopName(projectId, loopName) - const usage = loopSessionUsageRepo.getAggregate(projectId, loopName) + const bugCount = bugCounts.get(loopName) ?? 0 + const findings = inScopedProject ? reviewFindingsRepo.listByLoopName(projectId, loopName) : [] + const usage = inScopedProject ? loopSessionUsageRepo.getAggregate(projectId, loopName) : null - const transitions = inScopedProject && loopTransitionsRepo - ? loopTransitionsRepo.listForLoop(projectId, loopName, 100) - : [] + const transitions = transitionsByLoop?.get(loopName) ?? [] const amendments = isScopedLoop && amendmentsRepo ? amendmentsRepo.listForLoop(projectId, loopName).map((a) => ({ @@ -215,7 +222,7 @@ export function collectDashboardData(db: Database, scope: DashboardScope = UNSCO return { id: loopName, loop: loopRow, lastAuditResult, postActionReport, goal, plan, hasPlan, sections, sectionCount, - findings, usage, duration, transitions, amendments, + findings, bugCount, usage, duration, transitions, amendments, } }) diff --git a/src/dashboard/launch.ts b/src/dashboard/launch.ts index b7e40650b6..c07717ea73 100644 --- a/src/dashboard/launch.ts +++ b/src/dashboard/launch.ts @@ -1,8 +1,7 @@ import { Database } from 'bun:sqlite' import { existsSync } from 'fs' -import { join } from 'path' import { platform } from 'os' -import { resolveDataDir } from '../storage/database' +import { resolveForgeDbPath } from '../storage/database' import { createRequestHandler } from './server' export interface DashboardServerHandle { @@ -14,16 +13,18 @@ export interface DashboardServerHandle { export interface StartDashboardOptions { port?: number dbPath?: string + /** `PluginConfig.dataDir`, used when no explicit `dbPath`/`FORGE_DB` is given. */ + dataDir?: string maxAttempts?: number } const DEFAULT_PORT = 4747 const DEFAULT_MAX_ATTEMPTS = 10 -export function resolveDashboardDbPath(explicit?: string): string { +export function resolveDashboardDbPath(explicit?: string, configuredDataDir?: string): string { if (explicit) return explicit if (process.env.FORGE_DB) return process.env.FORGE_DB - return join(resolveDataDir(), 'forge.db') + return resolveForgeDbPath(configuredDataDir) } function isAddrInUse(err: unknown): boolean { @@ -39,7 +40,7 @@ function isAddrInUse(err: unknown): boolean { * `stop` releases both. */ export function startDashboardServer(options: StartDashboardOptions = {}): DashboardServerHandle { - const dbPath = resolveDashboardDbPath(options.dbPath) + const dbPath = resolveDashboardDbPath(options.dbPath, options.dataDir) if (!existsSync(dbPath)) { throw new Error( `Forge database not found at ${dbPath}. Run a loop first or pass a database path.` diff --git a/src/hooks/plan-approval.ts b/src/hooks/plan-approval.ts index 31d85ed3ec..8fd0f8bfeb 100644 --- a/src/hooks/plan-approval.ts +++ b/src/hooks/plan-approval.ts @@ -87,10 +87,7 @@ async function resolveBlockedLoopToolState( ): Promise<{ active?: boolean; loopName?: string; phase?: string } | null> { if (deps.resolveActiveLoopForSession) return deps.resolveActiveLoopForSession(sessionID) - const loopName = loop.service.resolveLoopName(sessionID) - const state = loopName ? loop.service.getActiveState(loopName) : null - if (state?.active && state.sessionId === sessionID) return state - return null + return loop.service.resolveActiveLoopForSession(sessionID) } function hashApprovalPlan(planText: string): string { diff --git a/src/index.ts b/src/index.ts index 408808a1e0..8c7a2f1345 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,7 @@ import { parseModelString } from './utils/model-fallback' import { parseFeatureList } from './utils/feature-list-parser' import { classifyArchitectOutput } from './utils/architect-auto-output' import { resolveSessionPlanOfRecord } from './services/plan-capture' +import { PLAN_CAPTURE_MESSAGE_LIMIT } from './utils/marked-plan-parser' import { createForgeExecutionService, type ForgeExecutionRequestContext } from './services/execution' export interface CreateParentSessionLookupOptions { @@ -498,7 +499,7 @@ export function createForgePlugin(config: PluginConfig): Plugin { // Returns the newest assistant text part across a session's messages, or null if none. async function findLatestAssistantText(sessionId: string): Promise { - const messages = await forgeClient.session.messages({ sessionID: sessionId, directory, limit: 20 }) + const messages = await forgeClient.session.messages({ sessionID: sessionId, directory, limit: PLAN_CAPTURE_MESSAGE_LIMIT }) const msgs = (messages ?? []) as Array<{ info: { role?: string }; parts: Array<{ type: string; text?: string }> }> for (let i = msgs.length - 1; i >= 0; i--) { const msg = msgs[i] diff --git a/src/loop/service.ts b/src/loop/service.ts index 14dec618d5..a708fd824c 100644 --- a/src/loop/service.ts +++ b/src/loop/service.ts @@ -47,6 +47,12 @@ export interface LoopService { deleteState(name: string): void registerLoopSession(sessionId: string, loopName: string): void resolveLoopName(sessionId: string): string | null + /** + * The loop this session is currently driving, or null. `resolveLoopName` + * matches terminated loops too, so every "is this session inside a running + * loop" check must go through here instead of testing loop-row existence. + */ + resolveActiveLoopForSession(sessionId: string): LoopState | null buildContinuationPrompt(state: LoopState, auditFindings?: string, outstandingBugs?: ReviewFindingRow[]): string buildAuditPrompt(state: LoopState): string listActive(): LoopState[] @@ -224,6 +230,12 @@ export function createLoopService( return loopsRepo.getBySessionId(projectId, sessionId)?.loopName ?? null } + function resolveActiveLoopForSession(sessionId: string): LoopState | null { + const loopName = resolveLoopName(sessionId) + const state = loopName ? getActiveState(loopName) : null + return state?.sessionId === sessionId ? state : null + } + function replaceSession(name: string, opts: { newSessionId: string; phase: LoopState['phase']; iteration?: number; resetError?: boolean; auditCount?: number; lastAuditResult?: string | null; executorSessionId?: string | null }): void { const state = getAnyState(name) loopsRepo.replaceSession(projectId, name, { @@ -812,6 +824,7 @@ export function createLoopService( deleteState, registerLoopSession, resolveLoopName, + resolveActiveLoopForSession, setStatus, buildContinuationPrompt, buildAuditPrompt, diff --git a/src/loop/token-usage.ts b/src/loop/token-usage.ts index 17a7623dd5..e8120adf8c 100644 --- a/src/loop/token-usage.ts +++ b/src/loop/token-usage.ts @@ -103,31 +103,45 @@ export function addTokens(a: TokenBreakdown, b: TokenBreakdown): TokenBreakdown } } +/** The cost/tokens/messageCount triple shared by per-model and per-role groups. */ +interface UsageTotals { + cost: number + tokens: TokenBreakdown + messageCount: number +} + +/** Sum a usage entry into a keyed group, copying tokens on first insert. */ +function accumulateUsage(map: Map, key: K, entry: UsageTotals): void { + const existing = map.get(key) + if (existing) { + existing.cost += entry.cost + existing.tokens = addTokens(existing.tokens, entry.tokens) + existing.messageCount += entry.messageCount + } else { + map.set(key, { cost: entry.cost, tokens: { ...entry.tokens }, messageCount: entry.messageCount }) + } +} + +/** Convert a model-keyed group to a model-sorted array for deterministic output. */ +function sortedModelUsage(map: Map): ModelUsage[] { + return Array.from(map.entries()) + .map(([model, data]) => ({ model, cost: data.cost, tokens: data.tokens, messageCount: data.messageCount })) + .sort((a, b) => a.model.localeCompare(b.model)) +} + /** * Merge per-role usage groups, summing cost/tokens/messageCount per role and * returning entries in `USAGE_ROLE_ORDER`, omitting absent roles. Tolerates * `undefined` inputs so callers can spread `summary.perRole` unconditionally. */ export function mergeRoleUsage(...groups: (RoleUsage[] | undefined)[]): RoleUsage[] { - const map = new Map() + const map = new Map() for (const group of groups) { for (const entry of group ?? []) { - const existing = map.get(entry.role) - if (existing) { - existing.cost += entry.cost - existing.tokens = addTokens(existing.tokens, entry.tokens) - existing.messageCount += entry.messageCount - } else { - map.set(entry.role, { - role: entry.role, - cost: entry.cost, - tokens: { ...entry.tokens }, - messageCount: entry.messageCount, - }) - } + accumulateUsage(map, entry.role, entry) } } - return USAGE_ROLE_ORDER.filter(r => map.has(r)).map(r => map.get(r)!) + return USAGE_ROLE_ORDER.filter(r => map.has(r)).map(r => ({ role: r, ...map.get(r)! })) } /** Default model label when no metadata or fallback is available */ @@ -222,7 +236,7 @@ export function summarizeAssistantUsage( }[], attribution?: UsageAttribution, ): LoopUsageSummary { - const modelMap = new Map() + const modelMap = new Map() let totalCost = 0 let totalTokens = emptyTokenBreakdown() @@ -236,25 +250,13 @@ export function summarizeAssistantUsage( totalCost += cost totalTokens = addTokens(totalTokens, tokens) - const existing = modelMap.get(model) - if (existing) { - existing.cost += cost - existing.tokens = addTokens(existing.tokens, tokens) - existing.messageCount += 1 - } else { - modelMap.set(model, { cost, tokens: { ...tokens }, messageCount: 1 }) - } + accumulateUsage(modelMap, model, { cost, tokens, messageCount: 1 }) } - // Convert map to sorted array for deterministic output - const perModel: ModelUsage[] = Array.from(modelMap.entries()) - .map(([model, data]) => ({ model, cost: data.cost, tokens: data.tokens, messageCount: data.messageCount })) - .sort((a, b) => a.model.localeCompare(b.model)) - return { totalCost, totalTokens, - perModel, + perModel: sortedModelUsage(modelMap), perRole: attribution ? [{ role: attribution.role, @@ -281,7 +283,7 @@ export function mergeUsageSummaries(...summaries: LoopUsageSummary[]): LoopUsage } } - const modelMap = new Map() + const modelMap = new Map() let totalCost = 0 let totalTokens = emptyTokenBreakdown() let attribution: UsageAttribution | undefined @@ -295,26 +297,14 @@ export function mergeUsageSummaries(...summaries: LoopUsageSummary[]): LoopUsage } for (const modelUsage of summary.perModel) { - const existing = modelMap.get(modelUsage.model) - if (existing) { - existing.cost += modelUsage.cost - existing.tokens = addTokens(existing.tokens, modelUsage.tokens) - existing.messageCount += modelUsage.messageCount - } else { - modelMap.set(modelUsage.model, { cost: modelUsage.cost, tokens: { ...modelUsage.tokens }, messageCount: modelUsage.messageCount }) - } + accumulateUsage(modelMap, modelUsage.model, modelUsage) } } - // Convert map to sorted array for deterministic output - const perModel: ModelUsage[] = Array.from(modelMap.entries()) - .map(([model, data]) => ({ model, cost: data.cost, tokens: data.tokens, messageCount: data.messageCount })) - .sort((a, b) => a.model.localeCompare(b.model)) - return { totalCost, totalTokens, - perModel, + perModel: sortedModelUsage(modelMap), perRole: mergeRoleUsage(...summaries.map(s => s.perRole)), attribution, } diff --git a/src/services/deterministic-decomposer.ts b/src/services/deterministic-decomposer.ts index 72b7bceade..6d09798c59 100644 --- a/src/services/deterministic-decomposer.ts +++ b/src/services/deterministic-decomposer.ts @@ -2,7 +2,7 @@ import type { ParsedSection } from '../utils/section-capture' import { computeFenceMask } from '../utils/markdown-fences' import { MAX_TOTAL_SECTIONS } from '../constants/loop' -export const SECTION_MARKER_REGEX = /^$/ +const SECTION_MARKER_REGEX = /^$/ const LEGACY_SECTION_PAIR_REGEX = /^$/ const STOP_HEADINGS = ['## Verification', '## Decisions', '## Conventions', '## Key Context'] const STRUCTURAL_TITLES = new Set(['verification', 'decisions', 'conventions', 'key context', 'objective', 'loop name']) @@ -16,7 +16,7 @@ interface SectionMarkerScan { /** * The canonical section-marker scan: strips the plan wrapper and legacy paired * markers, then locates every unfenced `` line. Both - * `decomposeDeterministically` and `countSectionMarkers` read it so a reported + * `decomposeDeterministically` and `decomposePlanSections` read it so a reported * marker count can never disagree with what the decomposer splits on. */ function scanSectionMarkers(planText: string): SectionMarkerScan { diff --git a/src/services/execution.ts b/src/services/execution.ts index abc6ab6bae..d56725f66f 100644 --- a/src/services/execution.ts +++ b/src/services/execution.ts @@ -24,7 +24,7 @@ import { findPartialMatch } from '../utils/partial-match' import { isSandboxEnabled } from '../sandbox/context' import { createLoopSessionWithWorkspace, publishWorkspaceDetachedToast } from '../utils/loop-session' import { aggregateToUsageSummary } from '../utils/loop-format' -import { join } from 'path' +import { resolveForgeDbPath } from '../utils/opencode-paths' import { existsSync } from 'fs' import { applyPlanDecomposition } from './section-bootstrap' import { sendLoopPrompt } from '../loop/send-loop-prompt' @@ -721,7 +721,7 @@ export async function attachLoopToSession( // Wait for sandbox readiness in worktree+sandbox mode (after persistence) if (sandboxEnabled && deps.sandboxManager && deps.dataDir) { - const dbPath = join(deps.dataDir, 'forge.db') + const dbPath = resolveForgeDbPath(deps.dataDir) if (existsSync(dbPath)) { const { waitForSandboxReady } = await import('../utils/sandbox-ready') const waitResult = await waitForSandboxReady({ diff --git a/src/storage/database.ts b/src/storage/database.ts index ad1c43715f..1aa540d4c7 100644 --- a/src/storage/database.ts +++ b/src/storage/database.ts @@ -3,6 +3,7 @@ import { mkdirSync, existsSync } from 'fs' import { openSqliteWithIntegrityGuard } from './sqlite-open' import { migrations } from './migrations' import { sweepExpiredLoops } from './sweep' +import { resolveForgeDbPath } from '../utils/opencode-paths' // Path resolvers live in a sqlite-free module so lightweight consumers (e.g. permission // rulesets) can import them without pulling in the storage layer. Re-exported here to keep @@ -12,6 +13,7 @@ export { resolveDataDir, resolveOpencodeToolOutputDir, resolveLogPath, + resolveForgeDbPath, } from '../utils/opencode-paths' const FORGE_PRAGMAS = [ @@ -89,7 +91,7 @@ export function initializeDatabase(dataDir: string, options?: ForgeDatabaseOptio mkdirSync(dataDir, { recursive: true }) } - const dbPath = `${dataDir}/forge.db` + const dbPath = resolveForgeDbPath(dataDir) const key = cacheKey(dbPath, options) const cached = dbCache.get(key) if (cached) { diff --git a/src/storage/index.ts b/src/storage/index.ts index 5ca285032f..65dd674c29 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -1,4 +1,4 @@ -export { initializeDatabase, closeDatabase, resolveDataDir, resolveLogPath, resolveOpencodeToolOutputDir } from './database' +export { initializeDatabase, closeDatabase, resolveDataDir, resolveLogPath, resolveOpencodeToolOutputDir, resolveForgeDbPath } from './database' export { createLoopsRepo } from './repos/loops-repo' export type { LoopRow } from './repos/loops-repo' diff --git a/src/storage/repos/loop-session-usage-repo.ts b/src/storage/repos/loop-session-usage-repo.ts index 4dd439ffba..251df9bd24 100644 --- a/src/storage/repos/loop-session-usage-repo.ts +++ b/src/storage/repos/loop-session-usage-repo.ts @@ -69,6 +69,47 @@ interface LoopSessionUsageRowRaw { captured_at: number } +type UsageBucket = LoopUsageAggregate['byModel'][string] + +interface AggregateGroupRow { + role: string + model: string + cost: number + input_tokens: number + output_tokens: number + reasoning_tokens: number + cache_read_tokens: number + cache_write_tokens: number + message_count: number +} + +function emptyUsageBucket(): UsageBucket { + return { + cost: 0, + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + messageCount: 0, + } +} + +function accumulateUsageBucket( + buckets: Partial>, + key: K, + row: AggregateGroupRow, +): void { + const bucket = (buckets[key] ??= emptyUsageBucket()) + bucket.cost += row.cost + bucket.inputTokens += row.input_tokens + bucket.outputTokens += row.output_tokens + bucket.reasoningTokens += row.reasoning_tokens + bucket.cacheReadTokens += row.cache_read_tokens + bucket.cacheWriteTokens += row.cache_write_tokens + bucket.messageCount += row.message_count +} + function mapRow(row: LoopSessionUsageRowRaw): LoopSessionUsageRow { return { projectId: row.project_id, @@ -170,17 +211,7 @@ export function createLoopSessionUsageRepo(db: Database): LoopSessionUsageRepo { }, getAggregate(projectId: string, loopName: string): LoopUsageAggregate | null { - const rows = getAggregateStmt.all(projectId, loopName) as Array<{ - role: string - model: string - cost: number - input_tokens: number - output_tokens: number - reasoning_tokens: number - cache_read_tokens: number - cache_write_tokens: number - message_count: number - }> + const rows = getAggregateStmt.all(projectId, loopName) as AggregateGroupRow[] if (rows.length === 0) return null @@ -206,40 +237,8 @@ export function createLoopSessionUsageRepo(db: Database): LoopSessionUsageRepo { aggregate.totalCacheWriteTokens += row.cache_write_tokens aggregate.totalMessageCount += row.message_count - const model = (aggregate.byModel[row.model] ??= { - cost: 0, - inputTokens: 0, - outputTokens: 0, - reasoningTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - messageCount: 0, - }) - model.cost += row.cost - model.inputTokens += row.input_tokens - model.outputTokens += row.output_tokens - model.reasoningTokens += row.reasoning_tokens - model.cacheReadTokens += row.cache_read_tokens - model.cacheWriteTokens += row.cache_write_tokens - model.messageCount += row.message_count - - const roleKey = row.role as LoopSessionUsageRow['role'] - const role = (aggregate.byRole[roleKey] ??= { - cost: 0, - inputTokens: 0, - outputTokens: 0, - reasoningTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - messageCount: 0, - }) - role.cost += row.cost - role.inputTokens += row.input_tokens - role.outputTokens += row.output_tokens - role.reasoningTokens += row.reasoning_tokens - role.cacheReadTokens += row.cache_read_tokens - role.cacheWriteTokens += row.cache_write_tokens - role.messageCount += row.message_count + accumulateUsageBucket(aggregate.byModel, row.model, row) + accumulateUsageBucket(aggregate.byRole, row.role as LoopSessionUsageRow['role'], row) } return aggregate diff --git a/src/storage/repos/loop-transitions-repo.ts b/src/storage/repos/loop-transitions-repo.ts index 9fef705ba4..e722c46eb4 100644 --- a/src/storage/repos/loop-transitions-repo.ts +++ b/src/storage/repos/loop-transitions-repo.ts @@ -19,6 +19,12 @@ export interface LoopTransitionRow { export interface LoopTransitionsRepo { insert(row: Omit): void listForLoop(projectId: string, loopName: string, limit?: number): LoopTransitionRow[] + /** + * Newest-N-then-ascending per loop for the whole project, in one scan; loops + * with no transitions are absent from the map. Same window semantics as + * `listForLoop`, without the per-loop query. + */ + listForProject(projectId: string, perLoopLimit: number): Map } export function createLoopTransitionsRepo(db: Database, _logger?: Logger): LoopTransitionsRepo { @@ -49,6 +55,20 @@ export function createLoopTransitionsRepo(db: Database, _logger?: Logger): LoopT ORDER BY id ASC `) + const stmtListForProject = db.prepare(` + SELECT id, project_id, loop_name, event_type, transition_kind, from_phase, to_phase, + status, reason, iteration, section_index, created_at + FROM ( + SELECT id, project_id, loop_name, event_type, transition_kind, from_phase, to_phase, + status, reason, iteration, section_index, created_at, + ROW_NUMBER() OVER (PARTITION BY loop_name ORDER BY id DESC) AS rn + FROM loop_transitions + WHERE project_id = ? + ) + WHERE rn <= ? + ORDER BY loop_name ASC, id ASC + `) + function mapRow(row: Record): LoopTransitionRow { return { id: row.id as number, @@ -87,5 +107,17 @@ export function createLoopTransitionsRepo(db: Database, _logger?: Logger): LoopT const rows = stmtList.all(projectId, loopName, limit) as Array> return rows.map(mapRow) }, + + listForProject(projectId, perLoopLimit) { + const rows = stmtListForProject.all(projectId, perLoopLimit) as Array> + const byLoop = new Map() + for (const raw of rows) { + const row = mapRow(raw) + const bucket = byLoop.get(row.loopName) + if (bucket) bucket.push(row) + else byLoop.set(row.loopName, [row]) + } + return byLoop + }, } } diff --git a/src/storage/repos/review-findings-repo.ts b/src/storage/repos/review-findings-repo.ts index dcfe5e9d6a..de40138690 100644 --- a/src/storage/repos/review-findings-repo.ts +++ b/src/storage/repos/review-findings-repo.ts @@ -23,6 +23,11 @@ export interface ReviewFindingsRepo { write(row: Omit & { scenario?: string | null; sectionIndex?: number | null }): WriteFindingResult listAll(projectId: string, sectionIndex?: number | null): ReviewFindingRow[] listByLoopName(projectId: string, loopName: string | null, sectionIndex?: number | null): ReviewFindingRow[] + /** + * Bug-severity finding count per loop for this project, in one scan. Keyed by + * the stored loop name, so findings written without a loop key on `''`. + */ + bugCountsByLoop(projectId: string): Map listByFile(projectId: string, file: string): ReviewFindingRow[] delete(projectId: string, file: string, line: number, scope?: DeleteScope): boolean deleteByLoopName(projectId: string, loopName: string): number @@ -67,6 +72,13 @@ export function createReviewFindingsRepo(db: Database): ReviewFindingsRepo { ORDER BY file, line `) + const stmtBugCountsByLoop = db.prepare(` + SELECT loop_name, COUNT(*) AS cnt + FROM review_findings + WHERE project_id = ? AND severity = 'bug' + GROUP BY loop_name + `) + const stmtListByFile = db.prepare(` SELECT ${SELECT_COLS} FROM review_findings @@ -163,6 +175,11 @@ export function createReviewFindingsRepo(db: Database): ReviewFindingsRepo { return mapped } + function bugCountsByLoop(projectId: string): Map { + const rows = stmtBugCountsByLoop.all(projectId) as { loop_name: string; cnt: number }[] + return new Map(rows.map(r => [r.loop_name, r.cnt])) + } + function listByFile(projectId: string, file: string): ReviewFindingRow[] { const rows = stmtListByFile.all(projectId, file) as Array<{ project_id: string; file: string; line: number; severity: 'bug' | 'warning'; @@ -201,6 +218,7 @@ export function createReviewFindingsRepo(db: Database): ReviewFindingsRepo { write, listAll, listByLoopName, + bugCountsByLoop, listByFile, delete: deleteFinding, deleteByLoopName, diff --git a/src/tools/plan-authoring.ts b/src/tools/plan-authoring.ts index e14b2762ce..7235ce4b42 100644 --- a/src/tools/plan-authoring.ts +++ b/src/tools/plan-authoring.ts @@ -7,15 +7,16 @@ import { formatPlanStructureSummary, summarizePlanStructure } from '../utils/pla const z = tool.schema /** - * Returns an error message when the session owns a running loop, else null. - * The stored plan for a running loop is amended only via `plan-adjust` during - * a section audit, so direct authoring from inside such a session is blocked. + * Returns an error message when the session is currently driving a running + * loop, else null. The stored plan for a running loop is amended only via + * `plan-adjust` during a section audit, so direct authoring from inside such a + * session is blocked. Sessions whose loop has already terminated stay writable. */ function assertWritableSession(ctx: ToolContext, sessionID: string): string | null { - const loopName = ctx.loop.service.resolveLoopName(sessionID) - if (loopName) { + const state = ctx.loop.service.resolveActiveLoopForSession(sessionID) + if (state) { return ( - `Cannot modify the plan from an active loop session (loop: ${loopName}). ` + + `Cannot modify the plan from an active loop session (loop: ${state.loopName}). ` + `The stored plan for a running loop is amended with plan-adjust during a section audit.` ) } diff --git a/src/tui.tsx b/src/tui.tsx index 5b8bcd5023..5a125bcb75 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -3,6 +3,7 @@ import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from '@opencode-ai/plug import { createEffect, createMemo, createSignal, Show, untrack } from 'solid-js' import { VERSION } from './version' import { loadPluginConfig, resolveBundledContainerDir } from './setup' +import { resolveForgeDbPath } from './storage' import type { ExecutionContextCache } from './utils/tui-execution-context-cache' import { createExecutionContextCache } from './utils/tui-execution-context-cache' import type { PluginConfig } from './types' @@ -13,7 +14,6 @@ import { ExecutePlanPanel, type ExecutePlanPanelProps } from './tui/execute-plan import { attachLoopSessionFollower, getCurrentRouteSessionId } from './tui/session-follow' import { openInBrowser, startDashboardServer, type DashboardServerHandle } from './dashboard/launch' import { normalizePastedPlanText } from './utils/marked-plan-parser' -import { join } from 'path' type TuiKeybinds = { executePlan: string @@ -256,8 +256,8 @@ const tui: TuiPlugin = async (api) => { const directory = api.state.path.directory // Every TUI reader of the forge database resolves it here so a configured // `dataDir` cannot leave the dashboard and the execute-plan dialog pointed at - // different databases. `undefined` keeps each consumer's own default. - const forgeDbPath = pluginConfig.dataDir ? join(pluginConfig.dataDir, 'forge.db') : undefined + // different databases. + const forgeDbPath = resolveForgeDbPath(pluginConfig.dataDir) const opts: TuiOptions = { sidebar: tuiConfig?.sidebar ?? true, showVersion: tuiConfig?.showVersion ?? true, diff --git a/src/utils/format.ts b/src/utils/format.ts index 29b79509d9..9e6e980aa8 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -6,3 +6,16 @@ export function truncate(str: string, maxLen: number): string { export function formatTokens(n: number): string { return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}` } + +/** + * Shared slug core: lowercase, drop punctuation, collapse whitespace runs to + * single dashes. Callers add their own tail (length cap, edge trimming). + */ +export function slugifyText(text: string): string { + return text + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .trim() + .replace(/\s+/g, '-') + .replace(/-+/g, '-') +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts index eb23d7098c..6659db6e11 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,18 +1,13 @@ import { appendFileSync, existsSync, mkdirSync, renameSync, statSync, writeFileSync } from 'fs' import { dirname } from 'path' import type { LoggingConfig } from '../types' +import { slugifyText } from './format' const PREFIX = '[OpenCodeForge]' const MAX_LOG_FILE_SIZE = 10 * 1024 * 1024 export function slugify(text: string): string { - return text - .toLowerCase() - .replace(/[^\w\s-]/g, '') - .trim() - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .substring(0, 50) + return slugifyText(text).substring(0, 50) } function ensureLogDir(filePath: string): void { diff --git a/src/utils/loop-format.ts b/src/utils/loop-format.ts index 5c0088c3a4..ea875d039a 100644 --- a/src/utils/loop-format.ts +++ b/src/utils/loop-format.ts @@ -5,7 +5,7 @@ import { USAGE_ROLE_ORDER, mergeUsageSummaries } from '../loop/token-usage' import type { LoopUsageAggregate, LoopSessionUsageRepo } from '../storage/repos/loop-session-usage-repo' export { formatTokens } from './format' -export type { LoopUsageSummary, RoleUsage } from '../loop/token-usage' +export type { LoopUsageSummary } from '../loop/token-usage' /** * Build cumulative usage for a loop by merging persisted aggregate with live session output. @@ -17,7 +17,7 @@ export type { LoopUsageSummary, RoleUsage } from '../loop/token-usage' * @param currentSessionId - Current session ID to check for persistence * @param sessionOutput - Live session output (may be null if worktree unavailable) */ -export function buildCumulativeUsage( +function buildCumulativeUsage( loopSessionUsageRepo: LoopSessionUsageRepo | undefined, projectId: string, loopName: string, @@ -65,6 +65,17 @@ export function formatCumulativeUsage( return ['', 'Cumulative Usage:', ...formatUsageSummary(summary).map(l => ` ${l}`)] } +/** Project a persisted per-model or per-role usage bucket onto a TokenBreakdown. */ +function tokensFromAggregate(data: LoopUsageAggregate['byModel'][string]): TokenBreakdown { + return { + input: data.inputTokens, + output: data.outputTokens, + reasoning: data.reasoningTokens, + cacheRead: data.cacheReadTokens, + cacheWrite: data.cacheWriteTokens, + } +} + /** Convert LoopUsageAggregate from database to LoopUsageSummary */ export function aggregateToUsageSummary(aggregate: LoopUsageAggregate): LoopUsageSummary { const totalTokens: TokenBreakdown = { @@ -78,13 +89,7 @@ export function aggregateToUsageSummary(aggregate: LoopUsageAggregate): LoopUsag const perModel = Object.entries(aggregate.byModel).map(([model, data]) => ({ model, cost: data.cost, - tokens: { - input: data.inputTokens, - output: data.outputTokens, - reasoning: data.reasoningTokens, - cacheRead: data.cacheReadTokens, - cacheWrite: data.cacheWriteTokens, - }, + tokens: tokensFromAggregate(data), messageCount: data.messageCount, })).sort((a, b) => a.model.localeCompare(b.model)) @@ -95,13 +100,7 @@ export function aggregateToUsageSummary(aggregate: LoopUsageAggregate): LoopUsag return { role, cost: data.cost, - tokens: { - input: data.inputTokens, - output: data.outputTokens, - reasoning: data.reasoningTokens, - cacheRead: data.cacheReadTokens, - cacheWrite: data.cacheWriteTokens, - }, + tokens: tokensFromAggregate(data), messageCount: data.messageCount, } }) @@ -114,22 +113,26 @@ export function aggregateToUsageSummary(aggregate: LoopUsageAggregate): LoopUsag } } +/** The single token-line rendering: ` in / out / ... cache write`. */ +function formatTokenBreakdown(tokens: TokenBreakdown): string { + return `${formatTokens(tokens.input)} in / ${formatTokens(tokens.output)} out / ${formatTokens(tokens.reasoning)} reasoning / ${formatTokens(tokens.cacheRead)} cache read / ${formatTokens(tokens.cacheWrite)} cache write` +} + +/** The single cost rendering: fixed 4-decimal dollars. */ +function formatCost(cost: number): string { + return `$${cost.toFixed(4)}` +} + /** Format a LoopUsageSummary into deterministic total and per-model output */ export function formatUsageSummary(summary: LoopUsageSummary): string[] { const lines: string[] = [] - const costStr = `$${summary.totalCost.toFixed(4)}` - const t = summary.totalTokens - const tokensStr = `${formatTokens(t.input)} in / ${formatTokens(t.output)} out / ${formatTokens(t.reasoning)} reasoning / ${formatTokens(t.cacheRead)} cache read / ${formatTokens(t.cacheWrite)} cache write` - lines.push(`Total Cost: ${costStr} | Tokens: ${tokensStr}`) + lines.push(`Total Cost: ${formatCost(summary.totalCost)} | Tokens: ${formatTokenBreakdown(summary.totalTokens)}`) if (summary.perModel.length > 0) { lines.push('Per-model usage:') for (const modelUsage of summary.perModel) { - const modelCost = `$${modelUsage.cost.toFixed(4)}` - const mt = modelUsage.tokens - const modelTokensStr = `${formatTokens(mt.input)} in / ${formatTokens(mt.output)} out / ${formatTokens(mt.reasoning)} reasoning / ${formatTokens(mt.cacheRead)} cache read / ${formatTokens(mt.cacheWrite)} cache write` - lines.push(` ${modelUsage.model}: ${modelCost} | ${modelTokensStr}`) + lines.push(` ${modelUsage.model}: ${formatCost(modelUsage.cost)} | ${formatTokenBreakdown(modelUsage.tokens)}`) } } @@ -137,10 +140,7 @@ export function formatUsageSummary(summary: LoopUsageSummary): string[] { if (perRole.length > 0) { lines.push('Per-role usage:') for (const roleUsage of perRole) { - const roleCost = `$${roleUsage.cost.toFixed(4)}` - const rt = roleUsage.tokens - const roleTokensStr = `${formatTokens(rt.input)} in / ${formatTokens(rt.output)} out / ${formatTokens(rt.reasoning)} reasoning / ${formatTokens(rt.cacheRead)} cache read / ${formatTokens(rt.cacheWrite)} cache write` - lines.push(` ${roleUsage.role}: ${roleCost} | ${roleTokensStr}`) + lines.push(` ${roleUsage.role}: ${formatCost(roleUsage.cost)} | ${formatTokenBreakdown(roleUsage.tokens)}`) } } @@ -169,10 +169,7 @@ export function formatSessionOutput( } } else { // Fallback to inline formatting for backward compatibility - const costStr = `$${sessionOutput.totalCost.toFixed(4)}` - const t = sessionOutput.totalTokens - const tokensStr = `${formatTokens(t.input)} in / ${formatTokens(t.output)} out / ${formatTokens(t.reasoning)} reasoning / ${formatTokens(t.cacheRead)} cache read / ${formatTokens(t.cacheWrite)} cache write` - lines.push(` Cost: ${costStr} | Tokens: ${tokensStr}`) + lines.push(` Cost: ${formatCost(sessionOutput.totalCost)} | Tokens: ${formatTokenBreakdown(sessionOutput.totalTokens)}`) } if (sessionOutput.fileChanges) { diff --git a/src/utils/opencode-paths.ts b/src/utils/opencode-paths.ts index dfa0e5944d..1495726ed6 100644 --- a/src/utils/opencode-paths.ts +++ b/src/utils/opencode-paths.ts @@ -31,6 +31,17 @@ export function resolveLogPath(): string { return join(resolveDataDir(), 'logs', 'forge.log') } +/** + * The single builder for the forge database path. `configuredDataDir` is + * `PluginConfig.dataDir`; every entry point (plugin, TUI, dashboard, loop + * execution) resolves through here so a configured `dataDir` cannot be honoured + * by some readers and ignored by others. + */ +export function resolveForgeDbPath(configuredDataDir?: string): string { + const trimmed = configuredDataDir?.trim() + return join(trimmed && trimmed.length > 0 ? trimmed : resolveDataDir(), 'forge.db') +} + /** * Default absolute path for the shared loop scratch/temp directory. Used identically on the host * (worktree-only loops) and inside the sandbox container (bind-mounted at the same path), so diff --git a/src/utils/tui-client.ts b/src/utils/tui-client.ts index a4ff6eb46a..6d535bbfd3 100644 --- a/src/utils/tui-client.ts +++ b/src/utils/tui-client.ts @@ -13,7 +13,7 @@ import { deriveExecutionPreferencesFromWorkspaces } from './tui-execution-prefer import { parseModelString } from './model-fallback' import { listConnectedWorkspaces } from './workspace-listing' import { type ForgeLoopExtra } from '../services/execution' -import { buildLoopPermissionRuleset, MAX_TOTAL_SECTIONS } from '../constants/loop' +import { buildLoopPermissionRuleset } from '../constants/loop' import { getForgeWorkspaceLoopName, removeExistingForgeLoopWorkspaces, getWorktreeProjectPreconditionError } from '../workspace/forge-worktree' import { classifyWorkspaceCreateThrow } from '../workspace/workspace-create-error' import { fetchLoopsList, fetchStoredSessionPlan } from './tui-loop-store' @@ -235,7 +235,7 @@ async function waitForWorkspacePluginSettle(workspaceId: string): Promise } function buildTuiLoopInitialPrompt(planText: string): string { - const sections = decomposeDeterministically(planText, { maxSections: MAX_TOTAL_SECTIONS }) + const sections = decomposeDeterministically(planText) const firstSection = sections[0] if (!firstSection) return planText diff --git a/src/utils/tui-loop-store.ts b/src/utils/tui-loop-store.ts index 520af9b117..d99e0452c9 100644 --- a/src/utils/tui-loop-store.ts +++ b/src/utils/tui-loop-store.ts @@ -7,24 +7,19 @@ import { Database } from 'bun:sqlite' import { existsSync } from 'fs' -import { join } from 'path' -import { resolveDataDir } from '../storage' +import { resolveForgeDbPath } from '../storage' import { createLoopsRepo } from '../storage/repos/loops-repo' import { createPlansRepo } from '../storage/repos/plans-repo' import { createSectionPlansRepo } from '../storage/repos/section-plans-repo' import type { LoopInfo } from './tui-models' -function getDbPath(): string { - return join(resolveDataDir(), 'forge.db') -} - /** * Opens the forge database read-only, runs `read`, and always closes the * handle. Returns `fallback` when the file is missing or any step throws: the * TUI renders whatever it can rather than surfacing a database error. */ function withReadOnlyForgeDb(dbPathOverride: string | undefined, fallback: T, read: (db: Database) => T): T { - const dbPath = dbPathOverride || getDbPath() + const dbPath = dbPathOverride || resolveForgeDbPath() if (!existsSync(dbPath)) return fallback let db: Database | null = null diff --git a/test/dashboard/app-dom.test.ts b/test/dashboard/app-dom.test.ts index 22bfbdcf63..10ab2a90ea 100644 --- a/test/dashboard/app-dom.test.ts +++ b/test/dashboard/app-dom.test.ts @@ -64,13 +64,13 @@ function makeLoop(over: Record = {}): any { ...dashLoop, hasPlan: over.hasPlan ?? !!dashLoop.plan, sectionCount: over.sectionCount ?? dashLoop.sections.length, + bugCount: over.bugCount ?? dashLoop.findings.filter((f: any) => f.severity === 'bug').length, } } function makePayload(over: Record = {}): any { const loopOver = over.loop || {} const dashLoopOver = over.dashLoop || {} - const totalsOver = over.totals || {} // `loops` lets tests supply an explicit array (multi-loop scenarios); // otherwise the single-loop default applies. The project carries `id` // for keyed reconcile. @@ -89,16 +89,6 @@ function makePayload(over: Record = {}): any { groups, }, ], - totals: { - projects: 1, - loops: 1, - running: 1, - completed: 0, - cancelled: 0, - errored: 0, - stalled: 0, - ...totalsOver, - }, } } @@ -227,7 +217,6 @@ describe('dashboard App fine-grained reactivity', () => { await poll( makePayload({ loop: { status: 'completed', completedAt: 1700000500000 }, - totals: { running: 0, completed: 1 }, }), ) @@ -456,6 +445,9 @@ describe('dashboard App fine-grained reactivity', () => { 'model-a': { cost: 1.0, inputTokens: 40, outputTokens: 20, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 3 }, 'model-b': { cost: 0.5, inputTokens: 10, outputTokens: 10, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 1 }, }, + byRole: { + code: { cost: 1.5, inputTokens: 50, outputTokens: 30, reasoningTokens: 10, cacheReadTokens: 8, cacheWriteTokens: 2, messageCount: 4 }, + }, }, }, }) @@ -506,6 +498,9 @@ describe('dashboard App fine-grained reactivity', () => { 'gpt-4': { cost: 0.3, inputTokens: 3000, outputTokens: 2000, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 4 }, 'gpt-3.5': { cost: 0.12, inputTokens: 2000, outputTokens: 1000, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 3 }, }, + byRole: { + code: { cost: 0.42, inputTokens: 5000, outputTokens: 3000, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 7 }, + }, }, }, }) @@ -561,7 +556,7 @@ describe('dashboard App fine-grained reactivity', () => { expect(container.querySelector('.filter-bar')?.textContent).toContain('Running: 1') - await poll(makePayload({ loop: { status: 'completed' }, totals: { running: 0, completed: 1 } })) + await poll(makePayload({ loop: { status: 'completed' } })) expect(container.querySelector('.filter-bar')?.textContent).toContain('Running: 0') expect(container.querySelector('.filter-bar')?.textContent).toContain('Completed: 1') @@ -948,7 +943,6 @@ describe('dashboard App machine graph', () => { }, }), ], - totals: { loops: 2, running: 1, completed: 1 }, }) dispose = render(() => App() as unknown as Element, container) await flush() @@ -988,7 +982,6 @@ describe('dashboard App machine graph', () => { }, }), ], - totals: { loops: 2, running: 0, completed: 2 }, }), ) @@ -1223,7 +1216,6 @@ describe('dashboard App three-level shell', () => { await poll( makePayload({ loop: { status: 'completed', completedAt: 1700000500000 }, - totals: { running: 0, completed: 1 }, }), ) @@ -1269,15 +1261,6 @@ describe('dashboard App three-level shell', () => { ], }, ], - totals: { - projects: 2, - loops: 2, - running: 1, - completed: 1, - cancelled: 0, - errored: 0, - stalled: 0, - }, } window.location.hash = '' dispose = render(() => App() as unknown as Element, container) @@ -1318,7 +1301,6 @@ describe('dashboard App three-level shell', () => { }, }), ], - totals: { loops: 2, running: 1, completed: 1 }, }) window.location.hash = '' dispose = render(() => App() as unknown as Element, container) @@ -1359,7 +1341,6 @@ describe('dashboard App three-level shell', () => { }, }), ], - totals: { loops: 2, running: 0, completed: 2 }, }), ) @@ -1396,7 +1377,6 @@ describe('dashboard App status filters and search', () => { ], }, ], - totals: { projects: 1, loops: 2, running: 1, completed: 0, cancelled: 0, errored: 1, stalled: 0 }, } } @@ -1577,7 +1557,6 @@ describe('dashboard App status filters and search', () => { makeLoop({ loop: { loopName: 'loop-errored', status: 'errored', startedAt: 1700000100000 } }), makeLoop({ loop: { loopName: 'loop-completed', status: 'completed', startedAt: 1700000200000 } }), ], - totals: { projects: 1, loops: 3, running: 1, completed: 1, errored: 1 }, }), ) @@ -1826,6 +1805,9 @@ describe('dashboard App loop detail tabs', () => { byModel: { 'model-a': { cost: 1.0, inputTokens: 40, outputTokens: 20, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 3 }, }, + byRole: { + code: { cost: 1.5, inputTokens: 50, outputTokens: 30, reasoningTokens: 10, cacheReadTokens: 8, cacheWriteTokens: 2, messageCount: 4 }, + }, }, }, }) @@ -1858,6 +1840,9 @@ describe('dashboard App loop detail tabs', () => { byModel: { 'model-a': { cost: 0.4, inputTokens: 100, outputTokens: 50, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 1 }, }, + byRole: { + code: { cost: 0.4, inputTokens: 100, outputTokens: 50, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 1 }, + }, }, }, }) @@ -1911,6 +1896,9 @@ describe('dashboard App loop detail tabs', () => { byModel: { 'model-a': { cost: 0.4, inputTokens: 100, outputTokens: 50, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 1 }, }, + byRole: { + code: { cost: 0.4, inputTokens: 100, outputTokens: 50, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 1 }, + }, }, }, }) @@ -1922,7 +1910,6 @@ describe('dashboard App loop detail tabs', () => { await poll(makePayload({ loop: { status: 'completed', completedAt: 1700000500000 }, - totals: { running: 0, completed: 1 }, })) expect(location.hash).toBe('#p1/loop/loop-a/usage') @@ -1983,7 +1970,6 @@ describe('dashboard App loop detail tabs', () => { plan: 'PLAN B', }), ], - totals: { loops: 2, running: 2 }, }) dispose = render(() => App() as unknown as Element, container) await flush() @@ -2023,7 +2009,6 @@ describe('dashboard App loop detail tabs', () => { plan: 'PLAN B', }), ], - totals: { loops: 2, running: 2 }, }) dispose = render(() => App() as unknown as Element, container) await flush() @@ -2104,7 +2089,6 @@ describe('dashboard App scoped poll', () => { makeLoop({ loop: { loopName: 'loop-a', status: 'running', startedAt: 1700000000000 } }), makeLoop({ loop: { loopName: 'loop-b', status: 'running', startedAt: 1700000100000 } }), ], - totals: { loops: 2, running: 2 }, }) dispose = render(() => App() as unknown as Element, container) await flush() @@ -2116,7 +2100,6 @@ describe('dashboard App scoped poll', () => { makeLoop({ loop: { loopName: 'loop-a', status: 'running', startedAt: 1700000000000 } }), makeLoop({ loop: { loopName: 'loop-b', status: 'running', startedAt: 1700000100000 }, plan: 'FRESH' }), ], - totals: { loops: 2, running: 2 }, }) // Stale payload (older): server scoped to loop-a; carries only loop-a. // If applied after the fresh payload, reconcile drops loop-b from the @@ -2125,7 +2108,6 @@ describe('dashboard App scoped poll', () => { loops: [ makeLoop({ loop: { loopName: 'loop-a', status: 'running', startedAt: 1700000000000 } }), ], - totals: { loops: 1, running: 1 }, }) const fetchMock = globalThis.fetch as any @@ -2234,6 +2216,9 @@ describe('dashboard App scoped poll', () => { byModel: { 'model-a': { cost: 0.4, inputTokens: 100, outputTokens: 50, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 1 }, }, + byRole: { + code: { cost: 0.4, inputTokens: 100, outputTokens: 50, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 1 }, + }, }, }, }) @@ -2267,7 +2252,6 @@ describe('dashboard App scoped poll', () => { loops: [ makeLoop({ loop: { loopName: 'loop-a', status: 'running', startedAt: 1700000000000 } }), ], - totals: { loops: 1, running: 1 }, }) dispose = render(() => App() as unknown as Element, container) await flush() @@ -2295,7 +2279,6 @@ describe('dashboard App scoped poll', () => { loops: [ makeLoop({ loop: { loopName: 'loop-b', status: 'running', startedAt: 1700000000000 } }), ], - totals: { loops: 1, running: 1 }, }) p2.projects[0].id = 'p2' p2.projects[0].projectId = 'p2' @@ -2307,12 +2290,8 @@ describe('dashboard App scoped poll', () => { loops: [ makeLoop({ loop: { loopName: 'loop-a', status: 'running', startedAt: 1700000000000 } }), ], - totals: { loops: 1, running: 1 }, }) base.projects.push(p2.projects[0]) - base.totals.projects = 2 - base.totals.loops = 2 - base.totals.running = 2 return base } window.location.hash = '#p1/loop/loop-a' @@ -2346,7 +2325,6 @@ describe('dashboard App breadcrumb loop picker', () => { makeLoop({ loop: { loopName: 'beta-loop', status: 'running', startedAt: 1700009000000, completedAt: null } }), makeLoop({ loop: { loopName: 'gamma-run', status: 'completed', startedAt: 1700003000000, completedAt: 1700004000000 } }), ], - totals: { loops: 3, running: 1, completed: 2 }, }) } @@ -2652,8 +2630,8 @@ describe('dashboard App overview tab content', () => { 'audit-m': { cost: 1, inputTokens: 20, outputTokens: 5, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 2 }, }, byRole: { - code: { cost: 2, messageCount: 6 }, - auditor: { cost: 1, messageCount: 2 }, + code: { cost: 2, inputTokens: 80, outputTokens: 15, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 6 }, + auditor: { cost: 1, inputTokens: 20, outputTokens: 5, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 2 }, }, }, }, @@ -2702,8 +2680,8 @@ describe('dashboard App overview tab content', () => { 'shared-m': { cost: 3, inputTokens: 100, outputTokens: 20, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 8 }, }, byRole: { - code: { cost: 2, messageCount: 6 }, - auditor: { cost: 1, messageCount: 2 }, + code: { cost: 2, inputTokens: 80, outputTokens: 15, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 6 }, + auditor: { cost: 1, inputTokens: 20, outputTokens: 5, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 2 }, }, }, }, @@ -3975,7 +3953,6 @@ describe('dashboard App render-stable node identity', () => { makeLoop({ loop: { loopName: 'loop-a', status: 'running', startedAt: 1700000000000 } }), makeLoop({ loop: { loopName: 'loop-b', status: 'running', startedAt: 1700000100000 } }), ], - totals: { loops: 2, running: 2, completed: 0, cancelled: 0, errored: 0, stalled: 0 }, }) } @@ -4036,7 +4013,6 @@ describe('dashboard App render-stable node identity', () => { makeLoop({ loop: { loopName: 'beta-loop', status: 'running', startedAt: 1700009000000, completedAt: null } }), makeLoop({ loop: { loopName: 'gamma-run', status: 'completed', startedAt: 1700003000000, completedAt: 1700004000000 } }), ], - totals: { loops: 3, running: 1, completed: 2 }, }) dispose = render(() => App() as unknown as Element, container) await flush() @@ -4059,7 +4035,6 @@ describe('dashboard App render-stable node identity', () => { makeLoop({ loop: { loopName: 'alpha', status: 'running', startedAt: 1700005000000, completedAt: null } }), makeLoop({ loop: { loopName: 'gamma', status: 'running', startedAt: 1700001000000, completedAt: null } }), ], - totals: { loops: 3, running: 3 }, }) window.location.hash = '#p1/loop/alpha' payload = initial @@ -4081,7 +4056,6 @@ describe('dashboard App render-stable node identity', () => { makeLoop({ loop: { loopName: 'alpha', status: 'running', startedAt: 1700005000000, completedAt: null } }), makeLoop({ loop: { loopName: 'beta', status: 'running', startedAt: 1700000500000, completedAt: null } }), ], - totals: { loops: 3, running: 3 }, })) expect(container.querySelector('.loopnav-prev')).toBe(prevBefore) @@ -4121,7 +4095,6 @@ describe('dashboard App bounded list caps', () => { } return makePayload({ loops, - totals: { loops: MAX_RENDERED_LOOP_ROWS + 5, running: MAX_RENDERED_LOOP_ROWS + 5 }, }) } @@ -4210,7 +4183,6 @@ describe('dashboard App bounded list caps', () => { } payload = makePayload({ loops, - totals: { loops: MAX_RENDERED_PICKER_OPTIONS + 20, running: MAX_RENDERED_PICKER_OPTIONS + 20 }, }) dispose = render(() => App() as unknown as Element, container) await flush() @@ -4241,7 +4213,6 @@ describe('dashboard App bounded list caps', () => { loops: [ makeLoop({ loop: { loopName: 'loop-0', status: 'running', startedAt: ts, completedAt: null } }), ], - totals: { loops: 1, running: 1 }, }) dispose = render(() => App() as unknown as Element, container) await flush() diff --git a/test/dashboard/app-helpers.test.ts b/test/dashboard/app-helpers.test.ts index 6e0b3cac21..e0ffe5f314 100644 --- a/test/dashboard/app-helpers.test.ts +++ b/test/dashboard/app-helpers.test.ts @@ -949,8 +949,8 @@ describe('roleUsageBars', () => { 'audit-model': { cost: 1, inputTokens: 50, outputTokens: 10, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 2 }, }, byRole: { - code: { cost: 2, messageCount: 6 }, - auditor: { cost: 1, messageCount: 2 }, + code: { cost: 2, inputTokens: 100, outputTokens: 20, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 6 }, + auditor: { cost: 1, inputTokens: 50, outputTokens: 10, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 2 }, }, } @@ -978,7 +978,7 @@ describe('roleUsageBars', () => { ...usage, byRole: { ...usage.byRole, - unknown: { cost: 0.5, messageCount: 1 }, + unknown: { cost: 0.5, inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 1 }, }, } const bars = roleUsageBars(extra) @@ -1003,8 +1003,8 @@ describe('roleUsageBars', () => { const zero: NonNullable = { ...usage, byRole: { - code: { cost: 0, messageCount: 0 }, - auditor: { cost: 0, messageCount: 0 }, + code: { cost: 0, inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 0 }, + auditor: { cost: 0, inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 0 }, }, } const bars = roleUsageBars(zero) @@ -1025,8 +1025,8 @@ describe('roleUsageBars', () => { 'shared-model': { cost: 3, inputTokens: 150, outputTokens: 30, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 8 }, }, byRole: { - code: { cost: 2, messageCount: 6 }, - auditor: { cost: 1, messageCount: 2 }, + code: { cost: 2, inputTokens: 100, outputTokens: 20, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 6 }, + auditor: { cost: 1, inputTokens: 50, outputTokens: 10, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 2 }, }, } const bars = roleUsageBars(sameModel) @@ -1055,20 +1055,6 @@ describe('roleUsageBars', () => { expect(exec.tokens).toBe(4000 + 2000 + 400) expect(audit.tokens).toBe(1000 + 500 + 100) }) - - test('role bars report zero tokens for an aggregate whose byRole lacks token fields', () => { - // Aggregate persisted before token fields were tracked — only cost and - // messageCount present on byRole entries. - const legacy: NonNullable = { - ...usage, - byRole: { - code: { cost: 2, messageCount: 6 } as any, - auditor: { cost: 1, messageCount: 2 } as any, - }, - } - const bars = roleUsageBars(legacy) - expect(bars.every(b => b.tokens === 0)).toBe(true) - }) }) // --------------------------------------------------------------------------- diff --git a/test/dashboard/data.test.ts b/test/dashboard/data.test.ts index b40754ca3c..3b746ae856 100644 --- a/test/dashboard/data.test.ts +++ b/test/dashboard/data.test.ts @@ -745,49 +745,56 @@ describe('collectDashboardData', () => { expect(scoped.projects.find(p => p.projectId === 'p2')!.loops[0].transitions).toEqual([]) }) - test('findings and usage are populated regardless of scope', () => { + test('bugCount is always populated while findings rows and usage ship only for the scoped project', () => { const loopsRepo = createLoopsRepo(db!) const findingsRepo = createReviewFindingsRepo(db!) const usageRepo = createLoopSessionUsageRepo(db!) - loopsRepo.insert( - makeLoopRow({ projectId: 'p1', loopName: 'loop-a' }), - { lastAuditResult: null }, - ) - findingsRepo.write({ - projectId: 'p1', - loopName: 'loop-a', - file: 'src/main.ts', - line: 10, - severity: 'warning', - description: 'w', - }) - usageRepo.upsertSessionUsage({ - projectId: 'p1', - loopName: 'loop-a', - sessionId: 'session-1', - role: 'code', - model: 'claude-sonnet-4-20250514', - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - reasoningTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - messageCount: 3, - capturedAt: Date.now(), - }) + const loops = [['p1', 'loop-a'], ['p1', 'loop-c'], ['p2', 'loop-b']] as const + for (const [pid, ln] of loops) { + loopsRepo.insert( + makeLoopRow({ projectId: pid, loopName: ln, currentSessionId: `session-${ln}` }), + { lastAuditResult: null }, + ) + findingsRepo.write({ projectId: pid, loopName: ln, file: 'src/main.ts', line: 10, severity: 'bug', description: 'b' }) + findingsRepo.write({ projectId: pid, loopName: ln, file: 'src/main.ts', line: 20, severity: 'warning', description: 'w' }) + usageRepo.upsertSessionUsage({ + projectId: pid, + loopName: ln, + sessionId: `session-${ln}`, + role: 'code', + model: 'claude-sonnet-4-20250514', + cost: 0.01, + inputTokens: 100, + outputTokens: 50, + reasoningTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + messageCount: 3, + capturedAt: Date.now(), + }) + } const unscoped = collectDashboardData(db!) - const unscopedDl = unscoped.projects[0].loops[0] - expect(unscopedDl.findings).toHaveLength(1) - expect(unscopedDl.usage).not.toBeNull() - expect(unscopedDl.usage!.totalCost).toBe(0.01) + for (const project of unscoped.projects) { + for (const dl of project.loops) { + expect(dl.findings).toEqual([]) + expect(dl.usage).toBeNull() + expect(dl.bugCount).toBe(1) + } + } const scoped = collectDashboardData(db!, { projectId: 'p1', loopName: 'loop-a' }) - const scopedDl = scoped.projects[0].loops[0] - expect(scopedDl.findings).toHaveLength(1) - expect(scopedDl.usage).not.toBeNull() - expect(scopedDl.usage!.totalCost).toBe(0.01) + // Every loop of the scoped project ships rows, not just the scoped loop. + for (const dl of scoped.projects.find(p => p.projectId === 'p1')!.loops) { + expect(dl.findings).toHaveLength(2) + expect(dl.bugCount).toBe(1) + expect(dl.usage!.totalCost).toBe(0.01) + } + + const outOfScope = scoped.projects.find(p => p.projectId === 'p2')!.loops[0] + expect(outOfScope.findings).toEqual([]) + expect(outOfScope.usage).toBeNull() + expect(outOfScope.bugCount).toBe(1) }) test('a scope naming a project that does not exist behaves like no scope', () => { @@ -809,6 +816,8 @@ describe('collectDashboardData', () => { expect(dl.lastAuditResult).toBeNull() expect(dl.postActionReport).toBeNull() expect(dl.sections).toEqual([]) + expect(dl.findings).toEqual([]) + expect(dl.usage).toBeNull() expect(dl.transitions).toEqual([]) expect(dl.amendments).toEqual([]) }) diff --git a/test/hooks/plan-approval-dedupe.test.ts b/test/hooks/plan-approval-dedupe.test.ts index 4cd35e4663..cc89983831 100644 --- a/test/hooks/plan-approval-dedupe.test.ts +++ b/test/hooks/plan-approval-dedupe.test.ts @@ -59,7 +59,7 @@ function buildToolContext(opts: { db: undefined, dataDir: '/tmp', loopHandler: undefined, - loop: { service: { resolveLoopName: () => null, getActiveState: () => null } }, + loop: { service: { resolveLoopName: () => null, getActiveState: () => null, resolveActiveLoopForSession: () => null } }, cleanup: async () => {}, sandboxManager: null, reviewFindingsRepo: {}, diff --git a/test/hooks/plan-approval-worktree-timing.test.ts b/test/hooks/plan-approval-worktree-timing.test.ts index 73d3e99bf6..71407be0f9 100644 --- a/test/hooks/plan-approval-worktree-timing.test.ts +++ b/test/hooks/plan-approval-worktree-timing.test.ts @@ -84,6 +84,7 @@ function buildToolContext(opts: { service: { resolveLoopName: () => null, getActiveState: () => null, + resolveActiveLoopForSession: () => null, }, generateUniqueLoopName: (name: string) => `${name}-unique`, }, diff --git a/test/loop-transitions-repo.test.ts b/test/loop-transitions-repo.test.ts index 7c66e2b335..eb5a629ab1 100644 --- a/test/loop-transitions-repo.test.ts +++ b/test/loop-transitions-repo.test.ts @@ -154,6 +154,41 @@ describe('LoopTransitionsRepo', () => { }) }) + describe('listForProject', () => { + test('buckets each loop of the project, keeping the newest rows ascending', () => { + const otherLoop = 'other-loop' + const otherProject = 'other-project' + db.run(`INSERT INTO loops (project_id, loop_name) VALUES (?, ?)`, [projectId, otherLoop]) + db.run(`INSERT INTO loops (project_id, loop_name) VALUES (?, ?)`, [otherProject, loopName]) + for (let i = 0; i < 5; i++) { + repo.insert(baseRow({ iteration: i })) + repo.insert(baseRow({ loopName: otherLoop, iteration: i * 10 })) + repo.insert(baseRow({ projectId: otherProject, iteration: i })) + } + + const byLoop = repo.listForProject(projectId, 3) + + expect([...byLoop.keys()].sort()).toEqual([loopName, otherLoop].sort()) + // Newest-3 per loop, returned oldest-to-newest — same window as listForLoop. + expect(byLoop.get(loopName)!.map(r => r.iteration)).toEqual([2, 3, 4]) + expect(byLoop.get(otherLoop)!.map(r => r.iteration)).toEqual([20, 30, 40]) + expect(byLoop.get(loopName)!.map(r => r.iteration)).toEqual(repo.listForLoop(projectId, loopName, 3).map(r => r.iteration)) + // Other projects never leak into the bucket. + for (const rows of byLoop.values()) { + expect(rows.every(r => r.projectId === projectId)).toBe(true) + } + }) + + test('omits loops with no transitions and returns an empty map for an unknown project', () => { + db.run(`INSERT INTO loops (project_id, loop_name) VALUES (?, ?)`, [projectId, 'silent-loop']) + repo.insert(baseRow()) + + const byLoop = repo.listForProject(projectId, 100) + expect([...byLoop.keys()]).toEqual([loopName]) + expect(repo.listForProject('nope', 100).size).toBe(0) + }) + }) + describe('FK cascade on loop delete', () => { test('deleting the loop row cascades to loop_transitions', () => { repo.insert(baseRow({ eventType: 'a', iteration: 0 })) diff --git a/test/plan-approval.test.ts b/test/plan-approval.test.ts index 8e04a7393e..b2508dbd8e 100644 --- a/test/plan-approval.test.ts +++ b/test/plan-approval.test.ts @@ -320,6 +320,7 @@ describe('Plan Approval Tool Interception', () => { service: { resolveLoopName: () => 'test-loop', getActiveState: () => null, + resolveActiveLoopForSession: () => null, }, }, logger: createMockLogger(), @@ -401,6 +402,7 @@ describe('Plan Approval Tool Interception', () => { service: { resolveLoopName: () => 'test-loop', getActiveState: () => null, + resolveActiveLoopForSession: () => null, }, }, logger: createMockLogger(), @@ -482,6 +484,7 @@ describe('Plan Approval Tool Interception', () => { service: { resolveLoopName: () => 'test-loop', getActiveState: () => null, + resolveActiveLoopForSession: () => null, }, }, logger: createMockLogger(), @@ -562,6 +565,7 @@ describe('Plan Approval Tool Interception', () => { service: { resolveLoopName: () => 'test-loop', getActiveState: () => null, + resolveActiveLoopForSession: () => null, }, }, logger: createMockLogger(), @@ -644,6 +648,7 @@ describe('Plan Approval Tool Interception', () => { service: { resolveLoopName: () => 'test-loop', getActiveState: () => null, + resolveActiveLoopForSession: () => null, }, }, logger: createMockLogger(), @@ -725,6 +730,7 @@ describe('Plan Approval Tool Interception', () => { service: { resolveLoopName: () => 'test-loop', getActiveState: () => null, + resolveActiveLoopForSession: () => null, }, }, logger: createMockLogger(), @@ -989,6 +995,8 @@ describe('Tool blocking hook', () => { service: { resolveLoopName: () => state ? loopName : null, getActiveState: () => state, + resolveActiveLoopForSession: (sid: string) => + state?.active && state.sessionId === sid ? { ...state, loopName } : null, }, }, logger: createMockLogger(), @@ -2359,6 +2367,7 @@ describe('Fire-and-forget dispatch behavior', () => { service: { resolveLoopName: () => 'test-loop', getActiveState: () => null, + resolveActiveLoopForSession: () => null, }, }, logger: createMockLogger(), @@ -2444,6 +2453,7 @@ describe('Fire-and-forget dispatch behavior', () => { service: { resolveLoopName: () => 'test-loop', getActiveState: () => null, + resolveActiveLoopForSession: () => null, }, }, logger: { diff --git a/test/review-findings-repo.test.ts b/test/review-findings-repo.test.ts index fcdbfd73ab..687211bd03 100644 --- a/test/review-findings-repo.test.ts +++ b/test/review-findings-repo.test.ts @@ -300,6 +300,23 @@ describe('ReviewFindingsRepo', () => { expect(repo.listAll(projectId)).toHaveLength(2) }) + test('bugCountsByLoop counts only bugs, per loop, scoped to the project', () => { + repo.write({ projectId, file: 'src/a.ts', line: 1, severity: 'bug', description: 'b1', loopName: 'alpha' }) + repo.write({ projectId, file: 'src/a.ts', line: 2, severity: 'bug', description: 'b2', loopName: 'alpha' }) + repo.write({ projectId, file: 'src/a.ts', line: 3, severity: 'warning', description: 'w1', loopName: 'alpha' }) + repo.write({ projectId, file: 'src/b.ts', line: 1, severity: 'warning', description: 'w2', loopName: 'beta' }) + repo.write({ projectId, file: 'src/c.ts', line: 1, severity: 'bug', description: 'b3', loopName: null }) + repo.write({ projectId: 'other-project', file: 'src/a.ts', line: 1, severity: 'bug', description: 'b4', loopName: 'alpha' }) + + const counts = repo.bugCountsByLoop(projectId) + expect(counts.get('alpha')).toBe(2) + // A warning-only loop has no entry, and a loopless finding keys on ''. + expect(counts.has('beta')).toBe(false) + expect(counts.get('')).toBe(1) + expect(repo.bugCountsByLoop('other-project').get('alpha')).toBe(1) + expect(repo.bugCountsByLoop('nope').size).toBe(0) + }) + test('delete with null sectionIndex removes sentinel rows', () => { repo.write({ projectId, diff --git a/test/tools/plan-authoring.test.ts b/test/tools/plan-authoring.test.ts index 2f507c6ffd..5951737f1c 100644 --- a/test/tools/plan-authoring.test.ts +++ b/test/tools/plan-authoring.test.ts @@ -212,6 +212,19 @@ ${body} expect(row!.content).toBe('pre-existing content') }) + test('from a session whose loop already completed writes normally', async () => { + insertRunningLoop(loopsRepo, 'done-loop', 'sess-1') + loopsRepo.setStatus('test-project', 'done-loop', 'completed') + + const result = await tools['plan-write'].execute( + { content: '# after the loop\n' }, + { sessionID: 'sess-1', directory: TEST_DIR } as any, + ) + + expect(result).not.toContain('Cannot modify the plan') + expect(plansRepo.getForSession('test-project', 'sess-1')!.content).toBe('# after the loop') + }) + test('unbalanced plan marker performs no write and returns failure message', async () => { const content = ` # Plan diff --git a/test/utils/tui-client-stored-plan.test.ts b/test/utils/tui-client-stored-plan.test.ts index 7807547d1d..4fd0fb0a10 100644 --- a/test/utils/tui-client-stored-plan.test.ts +++ b/test/utils/tui-client-stored-plan.test.ts @@ -16,6 +16,7 @@ vi.mock('../../src/utils/workspace-listing', () => ({ vi.mock('../../src/storage', () => ({ resolveLogPath: vi.fn().mockReturnValue('/tmp/forge-test.log'), resolveDataDir: vi.fn().mockReturnValue('/tmp/forge-test-data-dir'), + resolveForgeDbPath: vi.fn((dataDir?: string) => `${dataDir ?? '/tmp/forge-test-data-dir'}/forge.db`), })) vi.mock('../../src/services/execution', () => ({ diff --git a/test/worktree-log.test.ts b/test/worktree-log.test.ts index c3da5c95f5..e47240d2d9 100644 --- a/test/worktree-log.test.ts +++ b/test/worktree-log.test.ts @@ -661,6 +661,24 @@ describe('usage in worktree completion logs', () => { expect(result).toContain('Test plan') }) + test('formatWorktreeCompletionEntry includes the per-role usage block', () => { + const usage: LoopUsageSummary = { + ...sampleUsage, + perRole: [ + { role: 'code', cost: 0.0800, tokens: { input: 800, output: 400, reasoning: 150, cacheRead: 80, cacheWrite: 40 }, messageCount: 5 }, + { role: 'auditor', cost: 0.0434, tokens: { input: 200, output: 100, reasoning: 50, cacheRead: 20, cacheWrite: 10 }, messageCount: 3 }, + ], + } + const options = { + projectDir: '/path/to/project', + loopName: 'test-loop', + completionTimestamp: new Date('2024-01-15T10:30:00Z'), + iteration: 3, + } + + expect(formatWorktreeCompletionEntry(options, 'Test plan', usage)).toContain(formatUsageSummary(usage).slice(-3).join('\n')) + }) + test('formatWorktreeCompletionEntry omits Usage section when usage is null', () => { const timestamp = new Date('2024-01-15T10:30:00Z') const options = { From 5ae8a068394a45711b5ec1f6aefc6701e4d73993 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:40:53 -0400 Subject: [PATCH 3/3] Update test/utils/tui-client-stored-plan.test.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- test/utils/tui-client-stored-plan.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/utils/tui-client-stored-plan.test.ts b/test/utils/tui-client-stored-plan.test.ts index 4fd0fb0a10..62652bcb2d 100644 --- a/test/utils/tui-client-stored-plan.test.ts +++ b/test/utils/tui-client-stored-plan.test.ts @@ -16,7 +16,10 @@ vi.mock('../../src/utils/workspace-listing', () => ({ vi.mock('../../src/storage', () => ({ resolveLogPath: vi.fn().mockReturnValue('/tmp/forge-test.log'), resolveDataDir: vi.fn().mockReturnValue('/tmp/forge-test-data-dir'), - resolveForgeDbPath: vi.fn((dataDir?: string) => `${dataDir ?? '/tmp/forge-test-data-dir'}/forge.db`), + resolveForgeDbPath: vi.fn((dataDir?: string) => { + const trimmed = dataDir?.trim() + return `${trimmed && trimmed.length > 0 ? trimmed : '/tmp/forge-test-data-dir'}/forge.db` + }), })) vi.mock('../../src/services/execution', () => ({