From 245a67d1b060a44e263f6d776a450bd3a1cd686d Mon Sep 17 00:00:00 2001 From: Forge Date: Sun, 26 Jul 2026 09:51:10 -0400 Subject: [PATCH 1/7] feat(dashboard): add multi-level repo shell with searchable loop navigation Replace the flat dashboard loop list with a three-level shell: a repository index, a per-repo view with Loops/Groups/Findings/Plans sections, and a per-loop detail with tabs. Add a searchable loop picker in the breadcrumb that lists every repo loop sorted most recent first with a timestamp, supports type-to-filter and arrow/Enter/Escape keyboard navigation, and preserves the active tab on jump. The recency-ordered options drive both the picker and the prev/next pager so they agree on order. Hide the repo-level section-nav inside a loop detail, leaving only the loop tabs. --- AGENTS.md | 4 +- src/dashboard/app-bundle.ts | 4 +- src/dashboard/app/app.ts | 409 ++- src/dashboard/app/components.ts | 986 ++++++- src/dashboard/app/helpers.ts | 450 +++- src/dashboard/app/machine-graph.ts | 2 +- src/dashboard/app/types.ts | 4 +- src/dashboard/data.ts | 47 +- src/dashboard/render.ts | 666 +++-- src/storage/repos/loop-session-usage-repo.ts | 28 + test/dashboard/app-dom.test.ts | 2514 +++++++++++++++++- test/dashboard/app-helpers.test.ts | 1173 +++++++- test/dashboard/data.test.ts | 163 ++ test/dashboard/render.test.ts | 27 +- test/dashboard/server.test.ts | 43 +- test/loop-session-usage-repo.test.ts | 15 + 16 files changed, 6025 insertions(+), 510 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e2930d3d1f..5e78acc4b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,8 +4,8 @@ - Install with `pnpm install --frozen-lockfile`; `pnpm-lock.yaml` is canonical. Bun is still required because build, setup, and dashboard scripts run through it—do not substitute `bun install`. - Full source verification: `pnpm typecheck && pnpm lint && pnpm test && pnpm build`. -- Focus a Node test with `pnpm test -- --project node test/path.test.ts`; add `-t "test name"` for one case. -- Run dashboard DOM tests with `pnpm test -- --project dom test/dashboard/app-dom.test.ts`. The `dom` project is the only happy-dom/browser-conditions suite; all other tests use the `node` project and a `bun:sqlite` shim. +- Focus a Node test with `pnpm test --project node test/path.test.ts`; add `-t "test name"` for one case. +- Run dashboard DOM tests with `pnpm test --project dom test/dashboard/app-dom.test.ts`. The `dom` project is the only happy-dom/browser-conditions suite; all other tests use the `node` project and a `bun:sqlite` shim. - `pnpm typecheck` covers `src/`, not tests or scripts. ESLint also ignores tests and generated dashboard files, so run the relevant Vitest project after changing them. ## Runtime boundaries diff --git a/src/dashboard/app-bundle.ts b/src/dashboard/app-bundle.ts index d3ff223f21..1290691f23 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 T={context:void 0,registry:void 0,effects:void 0,done:!1,getContextId(){return at(this.context.count)},getNextContextId(){return at(this.context.count++)}};function at(e){let t=String(e),n=t.length-1;return T.context.id+(n?String.fromCharCode(96+n):\"\")+t}function Te(e){T.context=e}function Pn(){return{...T.context,id:T.getNextContextId(),count:0}}var vn=!0,pn=(e,t)=>e===t,G=Symbol(\"solid-proxy\"),Tn=typeof Proxy===\"function\",Ie=Symbol(\"solid-track\"),wt=Symbol(\"solid-dev-component\"),be={equals:pn},dt=null,$t=St,q=1,le=2,Nn={};var C=null,g=null,fe=null,X=null,N=null,V=null,D=null,we=0,U={afterUpdate:null,afterCreateOwner:null,afterCreateSignal:null,afterRegisterGraph:null};function xt(e,t){let n=N,r=C,i=e.length===0,s=t===void 0?r:t,u=i?{owned:null,cleanups:null,context:null,owner:null}:{owned:null,cleanups:null,context:s?s.context:null,owner:s},l=i?()=>e(()=>{throw Error(\"Dispose method must be an explicit argument to createRoot function\")}):()=>e(()=>W(()=>z(u)));U.afterCreateOwner&&U.afterCreateOwner(u),C=u,N=null;try{return M(l,!0)}finally{N=n,C=r}}function _(e,t){t=t?Object.assign({},be,t):be;let n={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0};{if(t.name)n.name=t.name;if(t.internal)n.internal=!0;else if(At(n),U.afterCreateSignal)U.afterCreateSignal(n)}let r=(i)=>{if(typeof i===\"function\")if(g&&g.running&&g.sources.has(n))i=i(n.tValue);else i=i(n.value);return Ve(n,i)};return[Et.bind(n),r]}function K(e,t,n){let r=me(e,t,!1,q,n);if(fe&&g&&g.running)V.push(r);else Q(r)}function Z(e,t,n){$t=_n;let r=me(e,t,!1,q,n),i=Ne&&Vn(Ne);if(i)r.suspense=i;if(!n||!n.render)r.user=!0;D?D.push(r):Q(r)}function E(e,t,n){n=n?Object.assign({},be,n):be;let r=me(e,t,!0,0,n);if(r.observers=null,r.observerSlots=null,r.comparator=n.equals||void 0,fe&&g&&g.running)r.tState=q,V.push(r);else Q(r);return Et.bind(r)}function mt(e){return M(e,!1)}function W(e){if(!X&&N===null)return e();let t=N;N=null;try{if(X)return X.untrack(e);return e()}finally{N=t}}function Ot(e){Z(()=>W(e))}function $e(e){if(C===null)console.warn(\"cleanups created outside a `createRoot` or `render` will never be run\");else if(C.cleanups===null)C.cleanups=[e];else C.cleanups.push(e);return e}function xe(){return N}function In(e){if(g&&g.running)return e(),g.done;let t=N,n=C;return Promise.resolve().then(()=>{N=t,C=n;let r;if(fe||Ne)r=g||(g={sources:new Set,effects:[],promises:new Set,disposed:new Set,queue:new Set,running:!0}),r.done||(r.done=new Promise((i)=>r.resolve=i)),r.running=!0;return M(e,!1),N=C=null,r?r.done:void 0})}var[Nr,ht]=_(!1);function gt(e,t){let n=me(()=>W(()=>{return Object.assign(e,{[wt]:!0}),e(t)}),void 0,!0,0);return n.props=t,n.observers=null,n.observerSlots=null,n.name=e.name,n.component=e,Q(n),n.tValue!==void 0?n.tValue:n.value}function At(e){if(C){if(C.sourceMap)C.sourceMap.push(e);else C.sourceMap=[e];e.graph=C}if(U.afterRegisterGraph)U.afterRegisterGraph(e)}function Vn(e){let t;return C&&C.context&&(t=C.context[e.id])!==void 0?t:e.defaultValue}var Ne;function Et(){let e=g&&g.running;if(this.sources&&(e?this.tState:this.state))if((e?this.tState:this.state)===q)Q(this);else{let t=V;V=null,M(()=>ye(this),!1),V=t}if(N){let t=this.observers?this.observers.length:0;if(!N.sources)N.sources=[this],N.sourceSlots=[t];else N.sources.push(this),N.sourceSlots.push(t);if(!this.observers)this.observers=[N],this.observerSlots=[N.sources.length-1];else this.observers.push(N),this.observerSlots.push(N.sources.length-1)}if(e&&g.sources.has(this))return this.tValue;return this.value}function Ve(e,t,n){let r=g&&g.running&&g.sources.has(e)?e.tValue:e.value;if(!e.comparator||!e.comparator(r,t)){if(g){let i=g.running;if(i||!n&&g.sources.has(e))g.sources.add(e),e.tValue=t;if(!i)e.value=t}else e.value=t;if(e.observers&&e.observers.length)M(()=>{for(let i=0;i1e6){if(V=[],vn)throw Error(\"Potential Infinite Loop Detected.\");throw Error()}},!1)}return t}function Q(e){if(!e.fn)return;z(e);let t=we;if(bt(e,g&&g.running&&g.sources.has(e)?e.tValue:e.value,t),g&&!g.running&&g.sources.has(e))queueMicrotask(()=>{M(()=>{g&&(g.running=!0),N=C=e,bt(e,e.tValue,t),N=C=null},!1)})}function bt(e,t,n){let r,i=C,s=N;N=C=e;try{r=e.fn(t)}catch(u){if(e.pure)if(g&&g.running)e.tState=q,e.tOwned&&e.tOwned.forEach(z),e.tOwned=void 0;else e.state=q,e.owned&&e.owned.forEach(z),e.owned=null;return e.updatedAt=n+1,je(u)}finally{N=s,C=i}if(!e.updatedAt||e.updatedAt<=n){if(e.updatedAt!=null&&\"observers\"in e)Ve(e,r,!0);else if(g&&g.running&&e.pure){if(!g.sources.has(e))e.value=r;g.sources.add(e),e.tValue=r}else e.value=r;e.updatedAt=n}}function me(e,t,n,r=q,i){let s={fn:e,state:r,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:C,context:C?C.context:null,pure:n};if(g&&g.running)s.state=0,s.tState=r;if(C===null)console.warn(\"computations created outside a `createRoot` or `render` will never be disposed\");else if(C!==Nn)if(g&&g.running&&C.pure)if(!C.tOwned)C.tOwned=[s];else C.tOwned.push(s);else if(!C.owned)C.owned=[s];else C.owned.push(s);if(i&&i.name)s.name=i.name;if(X&&s.fn){let u=s.fn,[l,f]=_(void 0,{equals:!1}),d=X.factory(u,f);$e(()=>d.dispose());let o,b=()=>In(f).then(()=>{if(o)o.dispose(),o=void 0});s.fn=(x)=>{if(l(),g&&g.running){if(!o)o=X.factory(u,b);return o.track(x)}return d.track(x)}}return U.afterCreateOwner&&U.afterCreateOwner(s),s}function ue(e){let t=g&&g.running;if((t?e.tState:e.state)===0)return;if((t?e.tState:e.state)===le)return ye(e);if(e.suspense&&W(e.suspense.inFallback))return e.suspense.effects.push(e);let n=[e];while((e=e.owner)&&(!e.updatedAt||e.updatedAt=0;r--){if(e=n[r],t){let i=e,s=n[r+1];while((i=i.owner)&&i!==s)if(g.disposed.has(i))return}if((t?e.tState:e.state)===q)Q(e);else if((t?e.tState:e.state)===le){let i=V;V=null,M(()=>ye(e,n[0]),!1),V=i}}}function M(e,t){if(V)return e();let n=!1;if(!t)V=[];if(D)n=!0;else D=[];we++;try{let r=e();return jn(n),r}catch(r){if(!n)D=null;V=null,je(r)}}function jn(e){if(V){if(fe&&g&&g.running)Dn(V);else St(V);V=null}if(e)return;let t;if(g){if(!g.promises.size&&!g.queue.size){let{sources:r,disposed:i}=g;D.push.apply(D,g.effects),t=g.resolve;for(let s of D)\"tState\"in s&&(s.state=s.tState),delete s.tState;g=null,M(()=>{for(let s of i)z(s);for(let s of r){if(s.value=s.tValue,s.owned)for(let u=0,l=s.owned.length;u$t(n),!1);else U.afterUpdate&&U.afterUpdate();if(t)t()}function St(e){for(let t=0;t{r.delete(n),M(()=>{g.running=!0,ue(n)},!1),g&&(g.running=!1)})}}function _n(e){let t,n=0;for(t=0;t=0;t--)z(e.tOwned[t]);delete e.tOwned}if(g&&g.running&&e.pure)Ct(e,!0);else if(e.owned){for(t=e.owned.length-1;t>=0;t--)z(e.owned[t]);e.owned=null}if(e.cleanups){for(t=e.cleanups.length-1;t>=0;t--)e.cleanups[t]();e.cleanups=null}if(g&&g.running)e.tState=0;else e.state=0;delete e.sourceMap}function Ct(e,t){if(!t)e.tState=0,g.disposed.add(e);if(e.owned)for(let n=0;n=0;l--){let f=pe(e[l])[u];if(f!==void 0)return f}},has(u){for(let l=e.length-1;l>=0;l--)if(u in pe(e[l]))return!0;return!1},keys(){let u=[];for(let l=0;l=0;u--){let l=e[u];if(!l)continue;let f=Object.getOwnPropertyNames(l);for(let d=f.length-1;d>=0;d--){let o=f[d];if(o===\"__proto__\"||o===\"constructor\")continue;let b=Object.getOwnPropertyDescriptor(l,o);if(!r[o])r[o]=b.get?{enumerable:!0,configurable:!0,get:Ln.bind(n[o]=[b.get.bind(l)])}:b.value!==void 0?b:void 0;else{let x=n[o];if(x){if(b.get)x.push(b.get.bind(l));else if(b.value!==void 0)x.push(()=>b.value)}}}}let i={},s=Object.keys(r);for(let u=s.length-1;u>=0;u--){let l=s[u],f=r[l];if(f&&f.get)Object.defineProperty(i,l,f);else i[l]=f?f.value:void 0}return i}var Pt={hooks:U,writeSignal:Ve,registerGraph:At};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 Un=[\"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\"],Re=new Set([\"className\",\"value\",\"readOnly\",\"noValidate\",\"formNoValidate\",\"isMap\",\"noModule\",\"playsInline\",\"adAuctionHeaders\",\"allowFullscreen\",\"browsingTopics\",\"defaultChecked\",\"defaultMuted\",\"defaultSelected\",\"disablePictureInPicture\",\"disableRemotePlayback\",\"preservesPitch\",\"shadowRootClonable\",\"shadowRootCustomElementRegistry\",\"shadowRootDelegatesFocus\",\"shadowRootSerializable\",\"sharedStorageWritable\",...Un]),qe=new Set([\"innerHTML\",\"textContent\",\"innerText\",\"children\"]),Le=Object.assign(Object.create(null),{className:\"class\",htmlFor:\"for\"}),Mn=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 Ge(e,t){let n=Mn[e];return typeof n===\"object\"?n[t]?n.$:void 0:n}var Ue=new Set([\"beforeinput\",\"click\",\"dblclick\",\"contextmenu\",\"focusin\",\"focusout\",\"input\",\"keydown\",\"keyup\",\"mousedown\",\"mousemove\",\"mouseout\",\"mouseover\",\"mouseup\",\"pointerdown\",\"pointermove\",\"pointerout\",\"pointerover\",\"pointerup\",\"touchend\",\"touchmove\",\"touchstart\"]),It=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\"]),Me={xlink:\"http://www.w3.org/1999/xlink\",xml:\"http://www.w3.org/XML/1998/namespace\"};function Kn(e,t,n){let r=n.length,i=t.length,s=r,u=0,l=0,f=t[i-1].nextSibling,d=null;while(uo-l){let c=t[u];while(l{i=s,t===document?e():Ye(t,e(),t.firstChild?null:void 0,n)},r.owner),()=>{i(),t.textContent=\"\"}}function Ke(e,t=window.document){let n=t[vt]||(t[vt]=new Set);for(let r=0,i=e.length;ri.call(e,n[1],s))}else e.addEventListener(t,n,typeof n!==\"function\"&&n)}function ze(e,t,n={}){let r=Object.keys(t||{}),i=Object.keys(n),s,u;for(s=0,u=i.length;si.children=ee(e,t.children,i.children));return K(()=>typeof t.ref===\"function\"&&zn(t.ref,e)),K(()=>Bn(e,t,n,!0,i,!0)),i}function Dt(e,t){let n=e[t];return Object.defineProperty(e,t,{get(){return n()},enumerable:!0}),e}function zn(e,t,n){return W(()=>e(t,n))}function Ye(e,t,n,r){if(n!==void 0&&!r)r=[];if(typeof t!==\"function\")return ee(e,t,r,n);K((i)=>ee(e,t(),i,n),r)}function Bn(e,t,n,r,i={},s=!1){t||(t={});for(let u in i)if(!(u in t)){if(u===\"children\")continue;i[u]=Tt(e,u,null,i[u],n,s,t)}for(let u in t){if(u===\"children\"){if(!r)ee(e,t.children);continue}let l=t[u];i[u]=Tt(e,u,l,i[u],n,s,t)}}function te(e){return!!T.context&&!T.done&&(!e||e.isConnected)}function Yn(e){return e.toLowerCase().replace(/-([a-z])/g,(t,n)=>n.toUpperCase())}function pt(e,t,n){let r=t.trim().split(/\\s+/);for(let i=0,s=r.length;i-1&&Me[t.split(\":\")[0]];if(x)We(e,x,t,n);else ce(e,Le[t]||t,n)}return n}function Xn(e){if(T.registry&&T.events){if(T.events.find(([f,d])=>d===e))return}let t=e.target,n=`$$${e.type}`,r=e.target,i=e.currentTarget,s=(f)=>Object.defineProperty(e,\"target\",{configurable:!0,value:f}),u=()=>{let f=t[n];if(f&&!t.disabled){let d=t[`${n}Data`];if(d!==void 0?f.call(t,d,e):f.call(t,e),e.cancelBubble)return}return t.host&&typeof t.host!==\"string\"&&!t.host._$host&&t.contains(e.target)&&s(t.host),!0},l=()=>{while(u()&&(t=t._$host||t.parentNode||t.host));};if(Object.defineProperty(e,\"currentTarget\",{configurable:!0,get(){return t||document}}),T.registry&&!T.done)T.done=_$HY.done=!0;if(e.composedPath){let f=e.composedPath();s(f[0]);for(let d=0;d{let f=t();while(typeof f===\"function\")f=f();n=ee(e,f,n,r)}),()=>n;else if(Array.isArray(t)){let f=[],d=n&&Array.isArray(n);if(Fe(f,t,n,i))return K(()=>n=ee(e,f,n,r,!0)),()=>n;if(s){if(!f.length)return n;if(r===void 0)return n=[...e.childNodes];let o=f[0];if(o.parentNode!==e)return n;let b=[o];while((o=o.nextSibling)!==r)b.push(o);return n=b}if(f.length===0){if(n=J(e,n,r),l)return n}else if(d)if(n.length===0)Nt(e,f,r);else Kn(e,n,f);else n&&J(e),Nt(e,f);n=f}else if(t.nodeType){if(s&&t.parentNode)return n=l?[t]:t;if(Array.isArray(n)){if(l)return n=J(e,n,r,t);J(e,n,null,t)}else if(n==null||n===\"\"||!e.firstChild)e.appendChild(t);else e.replaceChild(t,e.firstChild);n=t}else console.warn(\"Unrecognized value. Skipped inserting\",t);return n}function Fe(e,t,n,r){let i=!1;for(let s=0,u=t.length;s=0;u--){let l=t[u];if(i!==l){let f=l.parentNode===e;if(!s&&!u)f?e.replaceChild(i,l):e.insertBefore(i,n);else f&&l.remove()}else s=!0}}else e.insertBefore(i,n);return[i]}var Hr=Symbol();var Xe=Symbol(\"store-raw\"),re=Symbol(\"store-node\"),H=Symbol(\"store-has\"),Ft=Symbol(\"store-self\"),_t={onStoreNodeUpdate:null};function Rt(e){let t=e[G];if(!t){if(Object.defineProperty(e,G,{value:t=new Proxy(e,Jn)}),!Array.isArray(e)){let n=Object.keys(e),r=Object.getOwnPropertyDescriptors(e);for(let i=0,s=n.length;ie[G][t],n}function qt(e){xe()&&ae(Oe(e,re),Ft)()}function Qn(e){return qt(e),Reflect.ownKeys(e)}var Jn={get(e,t,n){if(t===Xe)return e;if(t===G)return n;if(t===Ie)return qt(e),n;let r=Oe(e,re),i=r[t],s=i?i():e[t];if(t===re||t===H||t===\"__proto__\")return s;if(!i){let u=Object.getOwnPropertyDescriptor(e,t);if(xe()&&(typeof s!==\"function\"||e.hasOwnProperty(t))&&!(u&&u.get))s=ae(r,t,s)()}return B(s)?Rt(s):s},has(e,t){if(t===Xe||t===G||t===Ie||t===re||t===H||t===\"__proto__\")return!0;return xe()&&ae(Oe(e,H),t)(),t in e},set(){return console.warn(\"Cannot mutate a Store directly\"),!0},deleteProperty(){return console.warn(\"Cannot mutate a Store directly\"),!0},ownKeys:Qn,getOwnPropertyDescriptor:Zn};function L(e,t,n,r=!1){if(!r&&e[t]===n)return;let i=e[t],s=e.length;if(_t.onStoreNodeUpdate&&_t.onStoreNodeUpdate(e,t,n,i),n===void 0){if(delete e[t],e[H]&&e[H][t]&&i!==void 0)e[H][t].$()}else if(e[t]=n,e[H]&&e[H][t]&&i===void 0)e[H][t].$();let u=Oe(e,re),l;if(l=ae(u,t,i))l.$(()=>n);if(Array.isArray(e)&&e.length!==s){for(let f=e.length;f1){r=t.shift();let u=typeof r,l=Array.isArray(e);if(Array.isArray(r)){for(let f=0;f1){oe(e[r],t,[r].concat(n));return}i=e[r],n=[r].concat(n)}let s=t[0];if(typeof s===\"function\"){if(s=s(i,n),s===i)return}if(r===void 0&&s==null)return;if(s=ie(s),r===void 0||B(i)&&B(s)&&!Array.isArray(s))Lt(i,s);else L(e,r,s)}function Gt(...[e,t]){let n=ie(e||{}),r=Array.isArray(n);if(typeof n!==\"object\"&&typeof n!==\"function\")throw Error(`Unexpected type ${typeof n} received when initializing 'createStore'. Expected an object.`);let i=Rt(n);Pt.registerGraph({value:n,name:t&&t.name});function s(...u){mt(()=>{r&&u.length===1?er(n,u[0]):oe(n,u)})}return[i,s]}var Ze=Symbol(\"store-root\");function ne(e,t,n,r,i){let s=t[n];if(e===s)return;let u=Array.isArray(e);if(n!==Ze&&(!B(e)||!B(s)||u!==Array.isArray(s)||i&&e[i]!==s[i])){L(t,n,e);return}if(u){if(e.length&&s.length&&(!r||i&&e[0]&&e[0][i]!=null)){let d,o,b,x,h,c,a,m;for(b=0,x=Math.min(s.length,e.length);b=b&&h>=b&&(s[x]===e[h]||i&&s[x]&&e[h]&&s[x][i]&&s[x][i]===e[h][i]);x--,h--)S[h]=s[x];if(b>h||b>x){for(o=b;o<=h;o++)L(s,o,e[o]);for(;oe.length)L(s,\"length\",e.length);return}a=Array(h+1);for(o=h;o>=b;o--)c=e[o],m=i&&c?c[i]:c,d=P.get(m),a[o]=d===void 0?-1:d,P.set(m,o);for(d=b;d<=x;d++)if(c=s[d],m=i&&c?c[i]:c,o=P.get(m),o!==void 0&&o!==-1)S[o]=s[d],o=a[o],P.set(m,o);for(o=b;oe.length)L(s,\"length\",e.length);return}let l=Object.keys(e);for(let d=0,o=l.length;d{if(!B(s)||!B(i))return i;let u=ne(i,{[Ze]:s},Ze,n,r);return u===void 0?s:u}}var tr=/(?:|<(?:\"[^\"]*\"['\"]*|'[^']*'['\"]*|[^'\">])+>)/g,nr=/(?:\\s(?[^/\\s><=]+?)(?=[\\s/>]))|(?:(?\\S+?)(?:\\s*=\\s*(?:(['\"])(?[\\s\\S]*?)\\3|(?[^\\s>]+))))/g,rr={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 ir(e){let t={type:\"tag\",name:\"\",voidElement:!1,attrs:[],children:[]},n=e.match(/<\\/?([^\\s]+?)[/\\s>]/);if(n){if(t.name=n[1],rr[n[1].toLowerCase()]||e.charAt(e.length-2)===\"/\")t.voidElement=!0;if(t.name.startsWith(\"!--\")){let i=e.indexOf(\"-->\");return{type:\"comment\",comment:i!==-1?e.slice(4,i):\"\"}}}let r=new RegExp(nr);for(let i of e.matchAll(r))if((i[1]||i[2]).startsWith(\"use:\"))t.attrs.push({type:\"directive\",name:i[1]||i[2],value:i[4]||i[5]||\"\"});else t.attrs.push({type:\"attr\",name:i[1]||i[2],value:i[4]||i[5]||\"\"});return t}function Mt(e,t,n){let r=t.indexOf(\"<\",n),i=t.slice(n,r===-1?void 0:r);if(!/^\\s*$/.test(i))e.push({type:\"text\",content:i})}function Kt(e,t){let n=t.replace(\"\",\"\");if(!/^\\s*$/.test(n))e.push({type:\"comment\",content:n})}function sr(e){let t=[],n=void 0,r=-1,i=[],s={};return e.replace(tr,(u,l)=>{let f=u.charAt(1)!==\"/\",d=u.slice(0,4)===\"\"}}function ur(e){return e.reduce(function(t,n){return t+zt(\"\",n)},\"\")}var Wt=new Map,fr=/^(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$/i,Ae=\" \\\\f\\\\n\\\\r\\\\t\",Bt=\"[^\"+Ae+`\\\\/>\"'=]+`,Je=\"[ \"+Ae+\"]+(?:use:|\"+Bt+\")\",Yt=\"<([A-Za-z$#]+[A-Za-z0-9:_-]*)((?:\",Xt=`(?:\\\\s*=\\\\s*(?:'[^']*?'|\"[^\"]*?\"|\\\\([^)]*?\\\\)|<[^>]*?>|`+Bt+\"))?)\",cr=new RegExp(Yt+Je+Xt+\"+)([ \"+Ae+\"]*/?>)\",\"g\"),or=new RegExp(\"(\"+Je+`\\\\s*=\\\\s*)(|['\"(]([\\\\w\\\\s]*[\\\\w\\\\s]*)*['\")])`,\"gi\"),ar=new RegExp(Yt+Je+Xt+\"*)([ \"+Ae+\"]*/>)\",\"g\"),Ht=\"\",dr=new Set([\"class\",\"on\",\"oncapture\",\"style\",\"use\",\"prop\",\"attr\"]);function hr(e,t,n,r){return\"<\"+t+n.replace(or,gr)+r}function gr(e,t,n){return t.replace(//g,\"###\")+(n[0]==='\"'||n[0]===\"'\"?n.replace(//g,\"###\"):'\"###\"')}function br(e,t,n){return fr.test(t)?e:\"<\"+t+n+\">\"}function yr(e){return e.toLowerCase().replace(/-([a-z])/g,(t,n)=>n.toUpperCase())}function Qe(e,t,n,r){if(e===\"use:###\"&&t===\"###\"){let i=r.counter++;r.exprs.push(`typeof exprs[${i}] === \"function\" ? r.use(exprs[${i}], ${n}, exprs[${r.counter++}]) : (()=>{throw new Error(\"use:### must be a function\")})()`)}else throw Error(`Not support syntax ${e} must be use:{function}`)}function wr(e,{delegateEvents:t=!0,functionBuilder:n=(...r)=>Function(...r)}={}){let r=1;e.wrapProps=(h)=>{let c=Object.getOwnPropertyDescriptors(h);for(let a in c)if(typeof c[a].value===\"function\"&&!c[a].value.length)e.dynamicProperty(h,a);return h};function i(h,c){let a=0,m=\"\";for(;a\";m=m+h[a],m=[[ar,br],[/<()/g,\"<###\"],[/\\.\\.\\.()/g,\"###\"],[cr,hr],[/>\\n+\\s*/g,\">\"],[/\\n+\\s*\\s+/g,\"> \"]].reduce((v,p)=>{return v.replace(p[0],p[1])},m);let P=sr(m),[O,w]=b(P,c.funcBuilder),A=[];for(let v=0;v-1){let $=I.split(Ht).reduce((k,j,R)=>{return R&&k.push(\"\"),k.push(j),k},[]);p[v].firstChild.replaceWith(...$)}}}return A[0].create=w,Wt.set(h,A),A}function s(h,c,a,m,S,P,O){let w=m===\"###\"?`!doNotWrap ? exprs[${O.counter}]() : exprs[${O.counter++}]`:m.split(\"###\").map((I,$)=>$?` + (typeof exprs[${O.counter}] === \"function\" ? exprs[${O.counter}]() : exprs[${O.counter++}]) + \"${I}\"`:`\"${I}\"`).join(\"\"),A,v;if((A=a.split(\":\"))&&A[1]&&dr.has(A[0]))a=A[1],v=A[0];let p=e.ChildProperties.has(a),F=e.Properties.has(a);if(a===\"style\"){let I=`_$v${r++}`;O.decl.push(`${I}={}`),O.exprs.push(`r.style(${c},${w},${I})`)}else if(a===\"classList\"){let I=`_$v${r++}`;O.decl.push(`${I}={}`),O.exprs.push(`r.classList(${c},${w},${I})`)}else if(v!==\"attr\"&&(p||!S&&(e.getPropAlias(a,h.name.toUpperCase())||F)||P||v===\"prop\")){if(P&&!p&&!F&&v!==\"prop\")a=yr(a);O.exprs.push(`${c}.${e.getPropAlias(a,h.name.toUpperCase())||a} = ${w}`)}else{let I=S&&a.indexOf(\":\")>-1&&e.SVGNamespace[a.split(\":\")[0]];if(I)O.exprs.push(`r.setAttributeNS(${c},\"${I}\",\"${a}\",${w})`);else O.exprs.push(`r.setAttribute(${c},\"${e.Aliases[a]||a}\",${w})`)}}function u(h,c,a,m,S,P,O){if(a.slice(0,2)===\"on\")if(!a.includes(\":\")){let w=a.slice(2).toLowerCase(),A=t&&e.DelegatedEvents.has(w);O.exprs.push(`r.addEventListener(${c},\"${w}\",exprs[${O.counter++}],${A})`),A&&O.delegatedEvents.add(w)}else{let w=a.startsWith(\"oncapture:\");O.exprs.push(`${c}.addEventListener(\"${a.slice(w?10:3)}\",exprs[${O.counter++}]${w?\",true\":\"\"})`)}else if(a===\"ref\")O.exprs.push(`exprs[${O.counter++}](${c})`);else{let w=Object.assign({},O,{exprs:[]}),A=O.counter;if(s(h,c,a,m,S,P,w),O.decl.push(`_fn${A} = (${m===\"###\"?\"doNotWrap\":\"\"}) => {\n${w.exprs.join(`;\n`)};\n}`),m===\"###\")O.exprs.push(`typeof exprs[${A}] === \"function\" ? r.effect(_fn${A}) : _fn${A}(true)`);else{let v=\"\";for(let p=A;p1)for(let S=0;S1?`r.mergeProps(${c.join(\",\")})`:c[0]}function d(h,c){let a=[],m=Object.keys(h.attrs),S=[a],P=c.counter++;for(let w=0;w exprs[${c.counter++}]`);else if(h.children.length){let w={type:\"fragment\",children:h.children},A=Object.assign({},c,{first:!0,decl:[],exprs:[],parent:!1});o(w,A),a.push(`children: () => { ${A.exprs.join(`;\n`)}}`),c.templateId=A.templateId,c.counter=A.counter}let O;if(c.multi)O=`_$el${r++}`,c.decl.push(`${O} = ${c.path}.${c.first?\"firstChild\":\"nextSibling\"}`);if(c.parent)c.exprs.push(`r.insert(${c.parent}, r.createComponent(exprs[${P}],${f(S)})${O?`, ${O}`:\"\"})`);else c.exprs.push(`${c.fragment?\"\":\"return \"}r.createComponent(exprs[${P}],${f(S)})`);c.path=O,c.first=!1}function o(h,c){if(h.type===\"fragment\"){let a=[];h.children.forEach((m)=>{if(m.type===\"tag\"){if(m.name===\"###\"){let O=Object.assign({},c,{first:!0,fragment:!0,decl:[],exprs:[]});d(m,O),a.push(O.exprs[0]),c.counter=O.counter,c.templateId=O.templateId;return}c.templateId++;let S=r,P=Object.assign({},c,{first:!0,decl:[],exprs:[]});c.templateNodes.push([m]),o(m,P),a.push(`function() { ${P.decl.join(`,\n`)+`;\n`+P.exprs.join(`;\n`)+`;\nreturn _$el${S};\n`}}()`),c.counter=P.counter,c.templateId=P.templateId}else if(m.type===\"text\")a.push(`\"${m.content}\"`);else if(m.type===\"comment\"){if(m.content===\"#\")a.push(`exprs[${c.counter++}]`);else if(m.content)for(let S=0;Sw.name===\"is\");if(c.hasCustomElement=O,c.isImportNode=(h.name===\"img\"||h.name===\"iframe\")&&h.attrs.some((w)=>w.name===\"loading\"&&w.value===\"lazy\"),h.attrs.some((w)=>w.name===\"###\")){let w=[],A=\"\",v=[];for(let p=0;p({${A}})`),A=\"\";w.push(`exprs[${c.counter++}]`)}else v.push(h.attrs[p]);else if(F===\"directive\")Qe(I,$,a,c)}if(h.attrs=v,A.length)w.push(`()=>({${A}})`);c.exprs.push(`r.spread(${a},${w.length===1?`typeof ${w[0]} === \"function\" ? r.mergeProps(${w[0]}) : ${w[0]}`:`r.mergeProps(${w.join(\",\")})`},${P},${!!h.children.length})`)}else for(let w=0;w document.importNode(tmpls[${S}].content.firstChild, true))`:`const ${a} = tmpls[${S}].content.firstChild.cloneNode(true)`}else if(h.type===\"text\"){let a=`_$el${r++}`;c.decl.push(`${a} = ${c.path}.${c.first?\"firstChild\":\"nextSibling\"}`),c.path=a,c.first=!1}else if(h.type===\"comment\"){let a=`_$el${r++}`;if(c.decl.push(`${a} = ${c.path}.${c.first?\"firstChild\":\"nextSibling\"}`),h.content===\"#\")if(c.multi)c.exprs.push(`r.insert(${c.parent}, exprs[${c.counter++}], ${a})`);else c.exprs.push(`r.insert(${c.parent}, exprs[${c.counter++}])`);c.path=a,c.first=!1}}function b(h,c){let a={path:\"\",decl:[],exprs:[],delegatedEvents:new Set,counter:0,first:!0,multi:!1,templateId:0,templateNodes:[]},m=r,S=h,P;if(h.length>1)h=[{type:\"fragment\",children:h}];if(h[0].name===\"###\")P=!0,d(h[0],a);else o(h[0],a);return e.delegateEvents(Array.from(a.delegatedEvents)),[[S].concat(a.templateNodes).map((w)=>ur(w)),c(\"tmpls\",\"exprs\",\"r\",a.decl.join(`,\n`)+`;\n`+a.exprs.join(`;\n`)+(P?\"\":`;\nreturn _$el${m};\n`))]}function x(h,...c){let a=Wt.get(h)||i(h,{funcBuilder:n});return a[0].create(a,c,e)}return x}var y=wr({effect:K,style:Be,insert:Ye,untrack:W,spread:jt,createComponent:De,delegateEvents:Ke,classList:ze,mergeProps:_e,dynamicProperty:Dt,setAttribute:ce,setAttributeNS:We,addEventListener:He,Aliases:Le,getPropAlias:Ge,Properties:Re,ChildProperties:qe,DelegatedEvents:Ue,SVGElements:It,SVGNamespace:Me});function Zt(e){let t=Math.floor(e/3600),n=Math.floor(e%3600/60),r=e%60;if(t>0)return`${t}h ${n}m`;return n>0?`${n}m ${r}s`:`${r}s`}function Qt(e,t){if(!e)return 0;let n=new Date(e).getTime(),r=t?new Date(t).getTime():Date.now();return Math.round((r-n)/1000)}function et(e){let t={projectId:null,loopName:null},n=(e||\"\").replace(/^#/,\"\");if(!n)return t;let r=n.indexOf(\"/\");if(r===-1)return t.projectId=decodeURIComponent(n),t;t.projectId=decodeURIComponent(n.slice(0,r));let i=n.slice(r+1);return t.loopName=i?decodeURIComponent(i):null,t}function Jt(e,t){if(!e)return\"\";let n=\"#\"+encodeURIComponent(e);if(t)n+=\"/\"+encodeURIComponent(t);return n}function en(e,t){let r=\"#\"+(location.hash||\"\").replace(/^#/,\"\"),i=\"#\"+e.replace(/^#/,\"\");if(r!==i)t.current=!0,location.hash=e}function Y(e){if(!e||e===0)return\"\";let t=new Date(e),n=(f)=>f<10?\"0\"+f:String(f),r=n(t.getMonth()+1),i=n(t.getDate()),s=t.getFullYear(),u=t.getHours(),l=u>=12?\"PM\":\"AM\";if(u=u%12,u===0)u=12;return r+\"-\"+i+\"-\"+s+\" \"+u+\":\"+n(t.getMinutes())+\" \"+l}function tt(e){return\"status-badge status-\"+e}function nt(e){return\"section-status section-\"+e}function tn(e,t,n,r){if(!(n.size===0||n.has(e.status)))return!1;if(!r)return!0;return((e.loopName||\"\")+\" \"+(t.projectDir||t.projectId||\"\")).toLowerCase().indexOf(r)!==-1}function nn(e){return JSON.stringify(e,(t,n)=>{if(t===\"generatedAt\")return;return n})}function rn(e){let t=e.split(\"/\").filter(Boolean);return t.length?t[t.length-1]:e}function rt(e){let t=[],n=[];for(let r of e)if(r.severity===\"bug\")t.push(r);else n.push(r);return{bugs:t,warnings:n}}function sn(e){if(e.bugs.length>0)return\"bug\";if(e.warnings.length>0)return\"warn\";return\"clean\"}function Ee(e,t){return e+\" \"+t+(e===1?\"\":\"s\")}function it(e,t){if(!t||t<=0)return 0;return Math.max(0,Math.min(100,e/t*100))}function st(e){let t=e.file+\":\"+e.line+\" — \"+e.description;if(e.scenario)t+=\" (\"+e.scenario+\")\";return t}function ln(e,t){return\" \"+e+\": $\"+t.cost.toFixed(6)+\", \"+t.inputTokens+\" in / \"+t.outputTokens+\" out (reasoning: \"+t.reasoningTokens+\", cache R: \"+t.cacheReadTokens+\" W: \"+t.cacheWriteTokens+\"), messages: \"+t.messageCount}function Se(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 lt(e){if(!Number.isFinite(e)||e<=0)return\"$0\";return\"$\"+(e>=1?e.toFixed(2):e.toFixed(4))}function un(e){let t=[{label:\"Input\",value:e.totalInputTokens,color:\"#1f6feb\"},{label:\"Output\",value:e.totalOutputTokens,color:\"#3fb950\"},{label:\"Reasoning\",value:e.totalReasoningTokens,color:\"#a371f7\"},{label:\"Cache R\",value:e.totalCacheReadTokens,color:\"#d29922\"},{label:\"Cache W\",value:e.totalCacheWriteTokens,color:\"#db61a2\"}],n=t.reduce((r,i)=>r+Math.max(0,i.value),0);return t.map((r)=>({...r,pct:n>0?Math.max(0,r.value)/n*100:0}))}function fn(e){let t=Object.keys(e.byModel).map((r)=>({model:r,...e.byModel[r]})),n=t.reduce((r,i)=>Math.max(r,i.cost),0);return t.sort((r,i)=>i.cost-r.cost).map((r)=>({model:r.model,cost:r.cost,inputTokens:r.inputTokens,outputTokens:r.outputTokens,messageCount:r.messageCount,pct:n>0?r.cost/n*100:0}))}function cn(e,t){if(!e)return\"\";let n=Qt(e,t??void 0);return n>0?Zt(n):\"\"}function ke(e){if(!e||e===0)return\"\";let t=Date.now()-e,n=Math.floor(t/1000);if(n<60)return n+\"s ago\";let r=Math.floor(n/60);if(r<60)return r+\"m ago\";let i=Math.floor(r/60);if(i<24)return i+\"h ago\";let s=Math.floor(i/24);if(s<30)return s+\"d ago\";return Y(e)}var de=new Map,$r=200;function ut(e){if(!e)return\"\";let t=de.get(e);if(t!==void 0)return t;let n=globalThis.marked;if(!n)return\"\";let r=n.parse(e);if(r){if(de.size>=$r){let i=de.keys().next().value;if(i!==void 0)de.delete(i)}de.set(e,r)}return r}var xr=[{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}],Ce=110,Pe=36,on=655,an=160,mr=[{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 Or(e){if(e.toPhase===null)return e.fromPhase+\"→terminal\";return e.fromPhase+\"→\"+e.toPhase}function Ar(e){return e.toPhase??e.status??\"\"}function ft(e){let t=Array.isArray(e)?e[0]:e,n=t instanceof SVGSVGElement?t.firstElementChild:null;if(!(n instanceof SVGElement))throw Error(\"Expected an SVG child\");return n}function dn(e){let t=()=>e.loop(),n=E(()=>t().status===\"running\"),r=E(()=>{let l=new Map;for(let f of e.transitions()){let d=Or(f);l.set(d,(l.get(d)??0)+1)}return l}),i=mr.map((l)=>ft(y`\n \n \n ${()=>{let f=r().get(l.key);return f?String(f):\"\"}}\n \n `)),s=xr.map((l)=>{let f=l.cx-Ce/2,d=l.cy-Pe/2;return ft(y`\n \"mg-node\"+(t().phase===l.phase&&n()?\" mg-node-active\":\"\")}>\n \n ${l.label}\n \n `)}),u=ft(y`\n \n \n ${()=>t().status}\n \n `);return y`
\n \n \n \n \n \n \n\n ${i}\n ${s}\n ${()=>n()?\"\":u}\n \n\n
\n
Transition history
\n
\n ${()=>e.transitions().slice(-20).reverse().map((l)=>y`
\n ${l.eventType}\n ${l.fromPhase} → ${Ar(l)}\n ${ke(l.createdAt)}\n
`)}\n
\n
\n
`}function wn(e){let t=[{label:\"Projects\",key:null,value:e.totals.projects},{label:\"Loops\",key:null,value:e.totals.loops},{label:\"Running\",key:\"running\",value:e.totals.running},{label:\"Completed\",key:\"completed\",value:e.totals.completed},{label:\"Cancelled\",key:\"cancelled\",value:e.totals.cancelled},{label:\"Errored\",key:\"errored\",value:e.totals.errored},{label:\"Stalled\",key:\"stalled\",value:e.totals.stalled}];return y`
\n ${t.map(({label:n,key:r,value:i})=>{if(!r)return y`${n}: ${i}`;let u=`badge badge-filter${e.activeStatuses.has(r)?\" badge-active\":\"\"}`;return y`e.onToggle(r)}>${n}: ${i}`})}\n
`}function $n(e){return y``}function xn(e){return y`
Last updated: ${new Date(e.generatedAt).toLocaleString()}
`}function mn(e){return y`
\n ${e.entries.map((t)=>{let n=t.proj.projectId===e.selectedProjectId,r=t.loops.some((l)=>l.loop.status===\"running\"),i=t.proj.projectDir||t.proj.projectId||\"\",s=rn(i);return y`
e.onSelect(t.proj.projectId,null)}>\n ${r?y``:\"\"}\n ${s}\n ${t.loops.length}\n
`})}\n
`}function hn(e){let t=()=>it(e.current(),e.total());return y`\n \"width:\"+t()+\"%\"}>\n ${()=>e.current()}/${()=>e.total()}\n `}function On(e){return y`\n \n \n \n \n \n ${e.loops.map((t)=>Er({dashLoop:t,onOpen:e.onOpen}))}\n \n
StatusLoopPhaseIterSectionsFindingsCostDurationUpdated
`}function Er(e){let t=()=>e.dashLoop.loop,n=()=>e.dashLoop,r=E(()=>rt(n().findings));return y`e.onOpen(e.dashLoop.loop.loopName)}>\n tt(t().status)}>${()=>t().status}\n ${()=>t().loopName}\n ${()=>t().phase}\n ${hn({current:()=>t().iteration,total:()=>t().maxIterations})}\n ${()=>t().totalSections>0?hn({current:()=>t().currentSectionIndex,total:()=>t().totalSections}):y``}\n ${()=>{let i=r();if(i.bugs.length===0&&i.warnings.length===0)return y``;return y`\n ${i.bugs.length>0?y`${Ee(i.bugs.length,\"bug\")}`:\"\"}\n ${i.bugs.length>0&&i.warnings.length>0?\" · \":\"\"}\n ${i.warnings.length>0?y`${i.warnings.length} warn`:\"\"}\n `}}\n ${()=>n().usage?lt(n().usage.totalCost):y``}\n ${()=>n().duration||\"\"}\n ${()=>Y(t().completedAt||t().startedAt)}\n `}function he(e){return y`
\n
\n

${e.label}

\n {t.stopPropagation();let n=t.target,r=\"Copy\";navigator.clipboard.writeText(e.src()||\"\").then(()=>{n.textContent=\"Copied!\"},()=>{n.textContent=\"Failed\"}).then(()=>{setTimeout(()=>{n.textContent=r},2000)})}}\n >Copy\n
\n
\n
ut(e.src()||\"\")}>
\n
\n
`}function ct(e){return y`
\n
${e.label}
\n
ut(e.value()||\"\")}>
\n
`}function Sr(e){let t=e.sec,n=E(()=>!!t.startedAt||!!t.completedAt),r=E(()=>!!t.summaryDone),i=E(()=>!!t.summaryDeviations),s=E(()=>!!t.summaryFollowUps),u=E(()=>!!t.content),l=E(()=>n()||r()||i()||s()||u());return y`
\n ${()=>n()?y`
\n ${()=>t.startedAt?\"Started \"+Y(t.startedAt):\"Not started\"}\n ${()=>t.completedAt?y` → Completed ${()=>Y(t.completedAt)}`:\"\"}\n
`:\"\"}\n ${()=>r()?ct({label:\"Done\",value:()=>t.summaryDone}):\"\"}\n ${()=>i()?ct({label:\"Deviations\",value:()=>t.summaryDeviations}):\"\"}\n ${()=>s()?ct({label:\"Follow-ups\",value:()=>t.summaryFollowUps}):\"\"}\n ${()=>u()?he({label:\"Section Plan\",src:()=>t.content}):\"\"}\n ${()=>l()?\"\":y`
No details captured for this section yet.
`}\n
`}function kr(e){let t=e.sec,n=E(()=>cn(t.startedAt,t.completedAt));return y`
\"section-list-row section-item-\"+t.status} onclick=${e.onOpen}>\n #${t.sectionIndex}\n ${()=>t.title}\n nt(t.status)}>${()=>t.status}\n ${()=>n()?y`${()=>n()}`:\"\"}\n ${()=>t.attempts>0?y`${()=>t.attempts} attempts`:\"\"}\n \n
`}function Cr(e){let[t,n]=_(null),r=E(()=>{let i=t();if(i===null)return null;return e.sections().find((s)=>s.sectionIndex===i)??null});return y`
\n

Sections

\n ${()=>{let i=r();if(i)return y`
\n
n(null)}>← Back to sections
\n
\n #${i.sectionIndex}\n ${()=>i.title}\n nt(i.status)}>${()=>i.status}\n
\n ${Sr({sec:i})}\n
`;return y`
\n ${e.sections().map((s)=>kr({sec:s,onOpen:()=>n(s.sectionIndex)}))}\n
`}}\n
`}function se(e){return y`
\n ${e.label}\n ${()=>e.value()}\n
`}function gn(e){let t=()=>it(e.current(),e.total());return y`
\n
\n ${e.label}\n ${()=>e.current()} / ${()=>e.total()}\n
\n
\n
\"width:\"+t()+\"%\"}>
\n
\n
`}function Pr(e){let t=()=>e.dashLoop.loop,n=()=>e.dashLoop,r=E(()=>!!t().completedAt),i=E(()=>!!n().duration),s=E(()=>t().totalSections>0),u=E(()=>!!t().terminationReason),l=E(()=>sn(e.split())),f=E(()=>!!e.dashLoop.usage);return y`
\n
\"ldh-findings ldh-findings-\"+l()}>\n ${()=>{let d=e.split();if(d.bugs.length===0&&d.warnings.length===0)return\"No findings\";let o=[];if(d.bugs.length>0)o.push(Ee(d.bugs.length,\"bug\"));if(d.warnings.length>0)o.push(Ee(d.warnings.length,\"warning\"));return o.join(\" · \")}}\n
\n
\n tt(t().status)}>${()=>t().status}\n

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

\n ${()=>t().phase}\n
\n\n
\n ${se({label:\"Started\",value:()=>Y(t().startedAt)})}\n ${()=>r()?se({label:\"Completed\",value:()=>Y(t().completedAt)}):\"\"}\n ${()=>i()?se({label:\"Duration\",value:()=>n().duration||\"\"}):\"\"}\n ${se({label:\"Iteration\",value:()=>t().iteration+\" / \"+t().maxIterations})}\n ${()=>s()?se({label:\"Section\",value:()=>t().currentSectionIndex+\" / \"+t().totalSections}):\"\"}\n ${()=>f()?se({label:\"Messages\",value:()=>String(e.dashLoop.usage.totalMessageCount)}):\"\"}\n
\n\n
\n ${gn({label:\"Iterations\",current:()=>t().iteration,total:()=>t().maxIterations})}\n ${()=>s()?gn({label:\"Sections\",current:()=>t().currentSectionIndex,total:()=>t().totalSections}):\"\"}\n
\n\n ${()=>f()?vr({usage:()=>e.dashLoop.usage}):\"\"}\n\n ${()=>u()?y`
\"ldh-banner ldh-banner-\"+t().status}>${()=>t().terminationReason}
`:\"\"}\n
`}function vr(e){let t=()=>e.usage(),n=E(()=>un(t())),r=E(()=>fn(t())),i=E(()=>r().length>0);return y`
\n

Usage

\n\n
\n
Token composition
\n
\n ${()=>n().map((s)=>s.pct>0?y`
`:\"\")}\n
\n
\n ${()=>n().map((s)=>y`
\n \n ${s.label}\n ${Se(s.value)}\n
`)}\n
\n
\n\n ${()=>i()?y`
\n
Cost by model
\n
\n ${()=>r().map((s)=>y`
\n
\n ${s.model}\n ${lt(s.cost)}\n
\n
\n
\n
\n
\n ${Se(s.inputTokens)} in / ${Se(s.outputTokens)} out · ${s.messageCount} msg\n
\n
`)}\n
\n
`:\"\"}\n `}function bn(e){try{return JSON.parse(e)}catch{return[]}}function yn(e){return\"#\"+e.index+\" \"+e.title}function pr(e){let t=e.amendment,n=E(()=>bn(t.sectionsBefore)),r=E(()=>bn(t.sectionsAfter));return y`
\n
\n ${()=>ke(t.createdAt)}\n applied @ section ${()=>t.appliedAtSection}\n ${()=>t.source}\n ${()=>t.rationale}\n ${()=>e.expanded()?\"▾\":\"▸\"}\n
\n
e.expanded()?\"display:block\":\"display:none\"}>\n
\n ${()=>n().length>0?y`
\n
Before
\n ${()=>n().map((i)=>y`
${()=>yn(i)}
`)}\n
`:\"\"}\n ${()=>r().length>0?y`
\n
After
\n ${()=>r().map((i)=>y`
${()=>yn(i)}
`)}\n
`:\"\"}\n
\n
\n
`}function Tr(e){let t=()=>e.amendments(),[n,r]=_(null);return y`
\n

Plan amendments

\n
\n ${()=>t().map((i)=>pr({amendment:i,expanded:()=>n()===i.id,onToggle:()=>r(n()===i.id?null:i.id)}))}\n
\n
`}function An(e){let t=()=>e.dashLoop,n=E(()=>rt(t().findings)),r=E(()=>!!t().loop.completionSummary),i=E(()=>!!t().postActionReport),s=E(()=>!!t().lastAuditResult),u=E(()=>!!t().plan),l=E(()=>!!t().sections&&t().sections.length>0),f=E(()=>!!t().findings&&t().findings.length>0),d=E(()=>n().bugs.length>0),o=E(()=>n().warnings.length>0),b=E(()=>!!t().amendments&&t().amendments.length>0);return y`
\n \n
\n ← Back to loops\n
\n\n \n ${Pr({dashLoop:e.dashLoop,split:n})}\n\n \n ${dn({loop:()=>t().loop,transitions:()=>t().transitions??[]})}\n\n \n
\n \n ${()=>i()?he({label:\"Post-Action Report\",src:()=>t().postActionReport}):\"\"}\n ${()=>!i()&&r()?he({label:\"Completion Summary\",src:()=>t().loop.completionSummary}):\"\"}\n\n \n ${()=>l()?Cr({sections:()=>t().sections}):\"\"}\n\n \n ${()=>f()?y`
\n

${()=>\"Findings (\"+t().findings.length+\")\"}

\n
\n ${()=>d()?y`
Bugs:
`:\"\"}\n ${()=>n().bugs.map((x)=>y`
${()=>st(x)}
`)}\n ${()=>o()?y`
Warnings:
`:\"\"}\n ${()=>n().warnings.map((x)=>y`
${()=>st(x)}
`)}\n
\n
`:\"\"}\n\n ${()=>s()?he({label:\"Last Audit Result\",src:()=>t().lastAuditResult}):\"\"}\n ${()=>u()?he({label:\"Plan\",src:()=>t().plan}):\"\"}\n ${()=>b()?Tr({amendments:()=>t().amendments}):\"\"}\n
\n
`}function En(){return y`
No loops match the current filters.
`}function Sn(e,t,n){en(Jt(e,t),n)}function kn(){let[e,t]=Gt({generatedAt:0,projects:[],totals:{projects:0,loops:0,running:0,completed:0,cancelled:0,errored:0,stalled:0}}),[n,r]=_(!1),[i,s]=_(new Set),[u,l]=_(\"\"),[f,d]=_(null),[o,b]=_(null),[x,h]=_(null),c={current:\"\"},a={current:!1},m=async()=>{try{let k=await(await fetch(\"/api/data\",{cache:\"no-store\"})).json(),j=nn(k);if(j!==c.current)c.current=j,t(Ut(k,{key:\"id\"})),r(!0),h(null)}catch($){let k=$ instanceof Error?$.message:String($);h(\"Failed to load dashboard data: \"+k)}},S=($,k)=>{d($),b(k),Sn($,k,a)},P=($)=>{let k=new Set(i());if(k.has($))k.delete($);else k.add($);s(k)},O=($)=>{l($.target.value.trim().toLowerCase())},w=E(()=>{if(!n()||e.projects.length===0)return[];let $=i(),k=u(),j=[];for(let R of e.projects){let ve=[];for(let ot of R.loops)if(tn(ot.loop,R,$,k))ve.push(ot);if(ve.length>0)j.push({proj:R,loops:ve})}return j}),A=E(()=>{let $=w(),k=f();if(!$.length)return null;return $.find((R)=>R.proj.projectId===k)??$[0]}),v=E(()=>{let $=A(),k=o();if(!$||!k)return null;return $.loops.find((j)=>j.loop.loopName===k)??null}),p=E(()=>mn({entries:w(),selectedProjectId:f(),onSelect:S})),F=E(()=>{let $=v();return $?An({dashLoop:$,onBack:()=>S(f(),null)}):\"\"}),I=E(()=>{if(v())return\"\";let $=A();if(!$)return\"\";let k=f();return On({loops:$.loops,onOpen:(j)=>S(k,j)})});return Z(()=>{if(!n())return;let $=w();if($.length===0){d(null),b(null);return}let k=f();if(!(k!==null&&$.some((R)=>R.proj.projectId===k)))d($[0].proj.projectId),b(null)}),Z(()=>{let $=A(),k=o();if(!$||!k)return;if(!$.loops.some((R)=>R.loop.loopName===k))b(null)}),Z(()=>{if(!n())return;Sn(f(),o(),a)}),Ot(()=>{let $=et(location.hash);if($.projectId)d($.projectId);if($.loopName)b($.loopName);m();let k=setInterval(()=>{m()},5000),j=()=>{if(a.current){a.current=!1;return}let R=et(location.hash);d(R.projectId),b(R.loopName)};window.addEventListener(\"hashchange\",j),$e(()=>{clearInterval(k),window.removeEventListener(\"hashchange\",j)})}),y`
\n

Forge Dashboard

\n\n ${()=>{let $=x();return $?y`
${$}
`:\"\"}}\n\n ${$n({onInput:O})}\n\n ${()=>{if(!n())return\"\";return y`
\n ${wn({totals:e.totals,activeStatuses:i(),onToggle:P})}\n ${xn({generatedAt:e.generatedAt})}\n
`}}\n\n ${()=>{if(!n())return\"\";if(w().length===0)return En();let $=A();if(!$)return\"\";return y`\n
\n ${p}\n
\n
\n
${$.proj.projectDir||$.proj.projectId}
\n ${I}\n ${F}\n
\n
\n
\n `}}\n
`}var Cn=document.getElementById(\"forge-app-root\");if(Cn)Vt(kn,Cn);\n"; -export const DASHBOARD_APP_SOURCE_HASH: string = "4cefa9903bbc10c01b61fd7400f11a819aa9b23ea24637e7fe3841d558790c03"; +export const DASHBOARD_APP_BUNDLE: string = "var _={context:void 0,registry:void 0,effects:void 0,done:!1,getContextId(){return Un(this.context.count)},getNextContextId(){return Un(this.context.count++)}};function Un(e){let n=String(e),t=n.length-1;return _.context.id+(t?String.fromCharCode(96+t):\"\")+n}function me(e){_.context=e}function Ei(){return{..._.context,id:_.getNextContextId(),count:0}}var ki=!0,Si=(e,n)=>e===n,q=Symbol(\"solid-proxy\"),Ai=typeof Proxy===\"function\",pe=Symbol(\"solid-track\"),Yn=Symbol(\"solid-dev-component\"),Ve={equals:Si},Gn=null,Hn=Jn,Y=1,we=2,Ni={};var C=null,O=null,xe=null,re=null,D=null,G=null,W=null,je=0,X={afterUpdate:null,afterCreateOwner:null,afterCreateSignal:null,afterRegisterGraph:null};function qn(e,n){let t=D,i=C,f=e.length===0,r=n===void 0?i:n,u=f?{owned:null,cleanups:null,context:null,owner:null}:{owned:null,cleanups:null,context:r?r.context:null,owner:r},l=f?()=>e(()=>{throw Error(\"Dispose method must be an explicit argument to createRoot function\")}):()=>e(()=>v(()=>M(u)));X.afterCreateOwner&&X.afterCreateOwner(u),C=u,D=null;try{return L(l,!0)}finally{D=t,C=i}}function U(e,n){n=n?Object.assign({},Ve,n):Ve;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(Ln(t),X.afterCreateSignal)X.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 en(t,f)};return[Rn.bind(t),i]}function R(e,n,t){let i=De(e,n,!1,Y,t);if(xe&&O&&O.running)G.push(i);else le(i)}function B(e,n,t){Hn=Ti;let i=De(e,n,!1,Y,t),f=Me&&Ii(Me);if(f)i.suspense=f;if(!t||!t.render)i.user=!0;W?W.push(i):le(i)}function g(e,n,t){t=t?Object.assign({},Ve,t):Ve;let i=De(e,n,!0,0,t);if(i.observers=null,i.observerSlots=null,i.comparator=t.equals||void 0,xe&&O&&O.running)i.tState=Y,G.push(i);else le(i);return Rn.bind(i)}function Xn(e){return L(e,!1)}function v(e){if(!re&&D===null)return e();let n=D;D=null;try{if(re)return re.untrack(e);return e()}finally{D=n}}function Zn(e){B(()=>v(e))}function ue(e){if(C===null)console.warn(\"cleanups created outside a `createRoot` or `render` will never be run\");else if(C.cleanups===null)C.cleanups=[e];else C.cleanups.push(e);return e}function _e(){return D}function Ci(e){if(O&&O.running)return e(),O.done;let n=D,t=C;return Promise.resolve().then(()=>{D=n,C=t;let i;if(xe||Me)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 L(e,!1),D=C=null,i?i.done:void 0})}var[Qf,Kn]=U(!1);function Wn(e,n){let t=De(()=>v(()=>{return Object.assign(e,{[Yn]:!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 Ln(e){if(C){if(C.sourceMap)C.sourceMap.push(e);else C.sourceMap=[e];e.graph=C}if(X.afterRegisterGraph)X.afterRegisterGraph(e)}function Ii(e){let n;return C&&C.context&&(n=C.context[e.id])!==void 0?n:e.defaultValue}var Me;function Rn(){let e=O&&O.running;if(this.sources&&(e?this.tState:this.state))if((e?this.tState:this.state)===Y)le(this);else{let n=G;G=null,L(()=>Te(this),!1),G=n}if(D){let n=this.observers?this.observers.length:0;if(!D.sources)D.sources=[this],D.sourceSlots=[n];else D.sources.push(this),D.sourceSlots.push(n);if(!this.observers)this.observers=[D],this.observerSlots=[D.sources.length-1];else this.observers.push(D),this.observerSlots.push(D.sources.length-1)}if(e&&O.sources.has(this))return this.tValue;return this.value}function en(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)L(()=>{for(let f=0;f1e6){if(G=[],ki)throw Error(\"Potential Infinite Loop Detected.\");throw Error()}},!1)}return n}function le(e){if(!e.fn)return;M(e);let n=je;if(vn(e,O&&O.running&&O.sources.has(e)?e.tValue:e.value,n),O&&!O.running&&O.sources.has(e))queueMicrotask(()=>{L(()=>{O&&(O.running=!0),D=C=e,vn(e,e.tValue,n),D=C=null},!1)})}function vn(e,n,t){let i,f=C,r=D;D=C=e;try{i=e.fn(n)}catch(u){if(e.pure)if(O&&O.running)e.tState=Y,e.tOwned&&e.tOwned.forEach(M),e.tOwned=void 0;else e.state=Y,e.owned&&e.owned.forEach(M),e.owned=null;return e.updatedAt=t+1,nn(u)}finally{D=r,C=f}if(!e.updatedAt||e.updatedAt<=t){if(e.updatedAt!=null&&\"observers\"in e)en(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 De(e,n,t,i=Y,f){let r={fn:e,state:i,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:n,owner:C,context:C?C.context:null,pure:t};if(O&&O.running)r.state=0,r.tState=i;if(C===null)console.warn(\"computations created outside a `createRoot` or `render` will never be disposed\");else if(C!==Ni)if(O&&O.running&&C.pure)if(!C.tOwned)C.tOwned=[r];else C.tOwned.push(r);else if(!C.owned)C.owned=[r];else C.owned.push(r);if(f&&f.name)r.name=f.name;if(re&&r.fn){let u=r.fn,[l,c]=U(void 0,{equals:!1}),a=re.factory(u,c);ue(()=>a.dispose());let d,w=()=>Ci(c).then(()=>{if(d)d.dispose(),d=void 0});r.fn=(E)=>{if(l(),O&&O.running){if(!d)d=re.factory(u,w);return d.track(E)}return a.track(E)}}return X.afterCreateOwner&&X.afterCreateOwner(r),r}function $e(e){let n=O&&O.running;if((n?e.tState:e.state)===0)return;if((n?e.tState:e.state)===we)return Te(e);if(e.suspense&&v(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,r=t[i+1];while((f=f.owner)&&f!==r)if(O.disposed.has(f))return}if((n?e.tState:e.state)===Y)le(e);else if((n?e.tState:e.state)===we){let f=G;G=null,L(()=>Te(e,t[0]),!1),G=f}}}function L(e,n){if(G)return e();let t=!1;if(!n)G=[];if(W)t=!0;else W=[];je++;try{let i=e();return Pi(t),i}catch(i){if(!t)W=null;G=null,nn(i)}}function Pi(e){if(G){if(xe&&O&&O.running)Vi(G);else Jn(G);G=null}if(e)return;let n;if(O){if(!O.promises.size&&!O.queue.size){let{sources:i,disposed:f}=O;W.push.apply(W,O.effects),n=O.resolve;for(let r of W)\"tState\"in r&&(r.state=r.tState),delete r.tState;O=null,L(()=>{for(let r of f)M(r);for(let r of i){if(r.value=r.tValue,r.owned)for(let u=0,l=r.owned.length;uHn(t),!1);else X.afterUpdate&&X.afterUpdate();if(n)n()}function Jn(e){for(let n=0;n{i.delete(t),L(()=>{O.running=!0,$e(t)},!1),O&&(O.running=!1)})}}function Ti(e){let n,t=0;for(n=0;n=0;n--)M(e.tOwned[n]);delete e.tOwned}if(O&&O.running&&e.pure)Bn(e,!0);else if(e.owned){for(n=e.owned.length-1;n>=0;n--)M(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 Bn(e,n){if(!n)e.tState=0,O.disposed.add(e);if(e.owned)for(let t=0;t=0;l--){let c=Be(e[l])[u];if(c!==void 0)return c}},has(u){for(let l=e.length-1;l>=0;l--)if(u in Be(e[l]))return!0;return!1},keys(){let u=[];for(let l=0;l=0;u--){let l=e[u];if(!l)continue;let c=Object.getOwnPropertyNames(l);for(let a=c.length-1;a>=0;a--){let d=c[a];if(d===\"__proto__\"||d===\"constructor\")continue;let w=Object.getOwnPropertyDescriptor(l,d);if(!i[d])i[d]=w.get?{enumerable:!0,configurable:!0,get:Fi.bind(t[d]=[w.get.bind(l)])}:w.value!==void 0?w:void 0;else{let E=t[d];if(E){if(w.get)E.push(w.get.bind(l));else if(w.value!==void 0)E.push(()=>w.value)}}}}let f={},r=Object.keys(i);for(let u=r.length-1;u>=0;u--){let l=r[u],c=i[l];if(c&&c.get)Object.defineProperty(f,l,c);else f[l]=c?c.value:void 0}return f}var mn={hooks:X,writeSignal:en,registerGraph:Ln};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 Gi=[\"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\"],un=new Set([\"className\",\"value\",\"readOnly\",\"noValidate\",\"formNoValidate\",\"isMap\",\"noModule\",\"playsInline\",\"adAuctionHeaders\",\"allowFullscreen\",\"browsingTopics\",\"defaultChecked\",\"defaultMuted\",\"defaultSelected\",\"disablePictureInPicture\",\"disableRemotePlayback\",\"preservesPitch\",\"shadowRootClonable\",\"shadowRootCustomElementRegistry\",\"shadowRootDelegatesFocus\",\"shadowRootSerializable\",\"sharedStorageWritable\",...Gi]),ln=new Set([\"innerHTML\",\"textContent\",\"innerText\",\"children\"]),cn=Object.assign(Object.create(null),{className:\"class\",htmlFor:\"for\"}),Ki=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 sn(e,n){let t=Ki[e];return typeof t===\"object\"?t[n]?t.$:void 0:t}var on=new Set([\"beforeinput\",\"click\",\"dblclick\",\"contextmenu\",\"focusin\",\"focusout\",\"input\",\"keydown\",\"keyup\",\"mousedown\",\"mousemove\",\"mouseout\",\"mouseover\",\"mouseup\",\"pointerdown\",\"pointermove\",\"pointerout\",\"pointerover\",\"pointerup\",\"touchend\",\"touchmove\",\"touchstart\"]),tt=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\"]),dn={xlink:\"http://www.w3.org/1999/xlink\",xml:\"http://www.w3.org/XML/1998/namespace\"};function Wi(e,n,t){let i=t.length,f=n.length,r=i,u=0,l=0,c=n[f-1].nextSibling,a=null;while(ud-l){let o=n[u];while(l{f=r,n===document?e():wn(n,e(),n.firstChild?null:void 0,t)},i.owner),()=>{f(),n.textContent=\"\"}}function an(e,n=window.document){let t=n[Mn]||(n[Mn]=new Set);for(let i=0,f=e.length;if.call(e,t[1],r))}else e.addEventListener(n,t,typeof t!==\"function\"&&t)}function bn(e,n,t={}){let i=Object.keys(n||{}),f=Object.keys(t),r,u;for(r=0,u=f.length;rf.children=se(e,n.children,f.children));return R(()=>typeof n.ref===\"function\"&&Yi(n.ref,e)),R(()=>Hi(e,n,t,!0,f,!0)),f}function rt(e,n){let t=e[n];return Object.defineProperty(e,n,{get(){return t()},enumerable:!0}),e}function Yi(e,n,t){return v(()=>e(n,t))}function wn(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 Hi(e,n,t,i,f={},r=!1){n||(n={});for(let u in f)if(!(u in n)){if(u===\"children\")continue;f[u]=et(e,u,null,f[u],t,r,n)}for(let u in n){if(u===\"children\"){if(!i)se(e,n.children);continue}let l=n[u];f[u]=et(e,u,l,f[u],t,r,n)}}function oe(e){return!!_.context&&!_.done&&(!e||e.isConnected)}function qi(e){return e.toLowerCase().replace(/-([a-z])/g,(n,t)=>t.toUpperCase())}function pn(e,n,t){let i=n.trim().split(/\\s+/);for(let f=0,r=i.length;f-1&&dn[n.split(\":\")[0]];if(E)hn(e,E,n,t);else Oe(e,cn[n]||n,t)}return t}function Xi(e){if(_.registry&&_.events){if(_.events.find(([c,a])=>a===e))return}let n=e.target,t=`$$${e.type}`,i=e.target,f=e.currentTarget,r=(c)=>Object.defineProperty(e,\"target\",{configurable:!0,value:c}),u=()=>{let c=n[t];if(c&&!n.disabled){let a=n[`${t}Data`];if(a!==void 0?c.call(n,a,e):c.call(n,e),e.cancelBubble)return}return n.host&&typeof n.host!==\"string\"&&!n.host._$host&&n.contains(e.target)&&r(n.host),!0},l=()=>{while(u()&&(n=n._$host||n.parentNode||n.host));};if(Object.defineProperty(e,\"currentTarget\",{configurable:!0,get(){return n||document}}),_.registry&&!_.done)_.done=_$HY.done=!0;if(e.composedPath){let c=e.composedPath();r(c[0]);for(let a=0;a{let c=n();while(typeof c===\"function\")c=c();t=se(e,c,t,i)}),()=>t;else if(Array.isArray(n)){let c=[],a=t&&Array.isArray(t);if(rn(c,n,t,f))return R(()=>t=se(e,c,t,i,!0)),()=>t;if(r){if(!c.length)return t;if(i===void 0)return t=[...e.childNodes];let d=c[0];if(d.parentNode!==e)return t;let w=[d];while((d=d.nextSibling)!==i)w.push(d);return t=w}if(c.length===0){if(t=ce(e,t,i),l)return t}else if(a)if(t.length===0)nt(e,c,i);else Wi(e,t,c);else t&&ce(e),nt(e,c);t=c}else if(n.nodeType){if(r&&n.parentNode)return t=l?[n]:n;if(Array.isArray(t)){if(l)return t=ce(e,t,i,n);ce(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 rn(e,n,t,i){let f=!1;for(let r=0,u=n.length;r=0;u--){let l=n[u];if(f!==l){let c=l.parentNode===e;if(!r&&!u)c?e.replaceChild(f,l):e.insertBefore(f,t);else c&&l.remove()}else r=!0}}else e.insertBefore(f,t);return[f]}var or=Symbol();var $n=Symbol(\"store-raw\"),ae=Symbol(\"store-node\"),m=Symbol(\"store-has\"),lt=Symbol(\"store-self\"),ut={onStoreNodeUpdate:null};function ct(e){let n=e[q];if(!n){if(Object.defineProperty(e,q,{value:n=new Proxy(e,Ri)}),!Array.isArray(e)){let t=Object.keys(e),i=Object.getOwnPropertyDescriptors(e);for(let f=0,r=t.length;fe[q][n],t}function st(e){_e()&&ke(Fe(e,ae),lt)()}function Li(e){return st(e),Reflect.ownKeys(e)}var Ri={get(e,n,t){if(n===$n)return e;if(n===q)return t;if(n===pe)return st(e),t;let i=Fe(e,ae),f=i[n],r=f?f():e[n];if(n===ae||n===m||n===\"__proto__\")return r;if(!f){let u=Object.getOwnPropertyDescriptor(e,n);if(_e()&&(typeof r!==\"function\"||e.hasOwnProperty(n))&&!(u&&u.get))r=ke(i,n,r)()}return p(r)?ct(r):r},has(e,n){if(n===$n||n===q||n===pe||n===ae||n===m||n===\"__proto__\")return!0;return _e()&&ke(Fe(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:Li,getOwnPropertyDescriptor:Zi};function H(e,n,t,i=!1){if(!i&&e[n]===t)return;let f=e[n],r=e.length;if(ut.onStoreNodeUpdate&&ut.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 u=Fe(e,ae),l;if(l=ke(u,n,f))l.$(()=>t);if(Array.isArray(e)&&e.length!==r){for(let c=e.length;c1){i=n.shift();let u=typeof i,l=Array.isArray(e);if(Array.isArray(i)){for(let c=0;c1){Ee(e[i],n,[i].concat(t));return}f=e[i],t=[i].concat(t)}let r=n[0];if(typeof r===\"function\"){if(r=r(f,t),r===f)return}if(i===void 0&&r==null)return;if(r=he(r),i===void 0||p(f)&&p(r)&&!Array.isArray(r))ot(f,r);else H(e,i,r)}function dt(...[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=ct(t);mn.registerGraph({value:t,name:n&&n.name});function r(...u){Xn(()=>{i&&u.length===1?Ji(t,u[0]):Ee(t,u)})}return[f,r]}var xn=Symbol(\"store-root\");function de(e,n,t,i,f){let r=n[t];if(e===r)return;let u=Array.isArray(e);if(t!==xn&&(!p(e)||!p(r)||u!==Array.isArray(r)||f&&e[f]!==r[f])){H(n,t,e);return}if(u){if(e.length&&r.length&&(!i||f&&e[0]&&e[0][f]!=null)){let a,d,w,E,b,o,s,x;for(w=0,E=Math.min(r.length,e.length);w=w&&b>=w&&(r[E]===e[b]||f&&r[E]&&e[b]&&r[E][f]&&r[E][f]===e[b][f]);E--,b--)k[b]=r[E];if(w>b||w>E){for(d=w;d<=b;d++)H(r,d,e[d]);for(;de.length)H(r,\"length\",e.length);return}s=Array(b+1);for(d=b;d>=w;d--)o=e[d],x=f&&o?o[f]:o,a=I.get(x),s[d]=a===void 0?-1:a,I.set(x,d);for(a=w;a<=E;a++)if(o=r[a],x=f&&o?o[f]:o,d=I.get(x),d!==void 0&&d!==-1)k[d]=r[a],d=s[d],I.set(x,d);for(d=w;de.length)H(r,\"length\",e.length);return}let l=Object.keys(e);for(let a=0,d=l.length;a{if(!p(r)||!p(f))return f;let u=de(f,{[xn]:r},xn,t,i);return u===void 0?r:u}}var Qi=/(?:|<(?:\"[^\"]*\"['\"]*|'[^']*'['\"]*|[^'\">])+>)/g,Bi=/(?:\\s(?[^/\\s><=]+?)(?=[\\s/>]))|(?:(?\\S+?)(?:\\s*=\\s*(?:(['\"])(?[\\s\\S]*?)\\3|(?[^\\s>]+))))/g,mi={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 Mi(e){let n={type:\"tag\",name:\"\",voidElement:!1,attrs:[],children:[]},t=e.match(/<\\/?([^\\s]+?)[/\\s>]/);if(t){if(n.name=t[1],mi[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(Bi);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 ht(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 yt(e,n){let t=n.replace(\"\",\"\");if(!/^\\s*$/.test(t))e.push({type:\"comment\",content:t})}function pi(e){let n=[],t=void 0,i=-1,f=[],r={};return e.replace(Qi,(u,l)=>{let c=u.charAt(1)!==\"/\",a=u.slice(0,4)===\"\"}}function nf(e){return e.reduce(function(n,t){return n+wt(\"\",t)},\"\")}var bt=new Map,tf=/^(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$/i,Ue=\" \\\\f\\\\n\\\\r\\\\t\",$t=\"[^\"+Ue+`\\\\/>\"'=]+`,En=\"[ \"+Ue+\"]+(?:use:|\"+$t+\")\",xt=\"<([A-Za-z$#]+[A-Za-z0-9:_-]*)((?:\",Ot=`(?:\\\\s*=\\\\s*(?:'[^']*?'|\"[^\"]*?\"|\\\\([^)]*?\\\\)|<[^>]*?>|`+$t+\"))?)\",ff=new RegExp(xt+En+Ot+\"+)([ \"+Ue+\"]*/?>)\",\"g\"),rf=new RegExp(\"(\"+En+`\\\\s*=\\\\s*)(|['\"(]([\\\\w\\\\s]*[\\\\w\\\\s]*)*['\")])`,\"gi\"),uf=new RegExp(xt+En+Ot+\"*)([ \"+Ue+\"]*/>)\",\"g\"),gt=\"\",lf=new Set([\"class\",\"on\",\"oncapture\",\"style\",\"use\",\"prop\",\"attr\"]);function cf(e,n,t,i){return\"<\"+n+t.replace(rf,sf)+i}function sf(e,n,t){return n.replace(//g,\"###\")+(t[0]==='\"'||t[0]===\"'\"?t.replace(//g,\"###\"):'\"###\"')}function of(e,n,t){return tf.test(n)?e:\"<\"+n+t+\">\"}function df(e){return e.toLowerCase().replace(/-([a-z])/g,(n,t)=>t.toUpperCase())}function On(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 af(e,{delegateEvents:n=!0,functionBuilder:t=(...i)=>Function(...i)}={}){let i=1;e.wrapProps=(b)=>{let o=Object.getOwnPropertyDescriptors(b);for(let s in o)if(typeof o[s].value===\"function\"&&!o[s].value.length)e.dynamicProperty(b,s);return b};function f(b,o){let s=0,x=\"\";for(;s\";x=x+b[s],x=[[uf,of],[/<()/g,\"<###\"],[/\\.\\.\\.()/g,\"###\"],[ff,cf],[/>\\n+\\s*/g,\">\"],[/\\n+\\s*\\s+/g,\"> \"]].reduce((V,T)=>{return V.replace(T[0],T[1])},x);let I=pi(x),[A,S]=w(I,o.funcBuilder),N=[];for(let V=0;V-1){let J=j.split(gt).reduce((Q,Ie,Le)=>{return Le&&Q.push(\"\"),Q.push(Ie),Q},[]);T[V].firstChild.replaceWith(...J)}}}return N[0].create=S,bt.set(b,N),N}function r(b,o,s,x,k,I,A){let S=x===\"###\"?`!doNotWrap ? exprs[${A.counter}]() : exprs[${A.counter++}]`:x.split(\"###\").map((j,J)=>J?` + (typeof exprs[${A.counter}] === \"function\" ? exprs[${A.counter}]() : exprs[${A.counter++}]) + \"${j}\"`:`\"${j}\"`).join(\"\"),N,V;if((N=s.split(\":\"))&&N[1]&&lf.has(N[0]))s=N[1],V=N[0];let T=e.ChildProperties.has(s),K=e.Properties.has(s);if(s===\"style\"){let j=`_$v${i++}`;A.decl.push(`${j}={}`),A.exprs.push(`r.style(${o},${S},${j})`)}else if(s===\"classList\"){let j=`_$v${i++}`;A.decl.push(`${j}={}`),A.exprs.push(`r.classList(${o},${S},${j})`)}else if(V!==\"attr\"&&(T||!k&&(e.getPropAlias(s,b.name.toUpperCase())||K)||I||V===\"prop\")){if(I&&!T&&!K&&V!==\"prop\")s=df(s);A.exprs.push(`${o}.${e.getPropAlias(s,b.name.toUpperCase())||s} = ${S}`)}else{let j=k&&s.indexOf(\":\")>-1&&e.SVGNamespace[s.split(\":\")[0]];if(j)A.exprs.push(`r.setAttributeNS(${o},\"${j}\",\"${s}\",${S})`);else A.exprs.push(`r.setAttribute(${o},\"${e.Aliases[s]||s}\",${S})`)}}function u(b,o,s,x,k,I,A){if(s.slice(0,2)===\"on\")if(!s.includes(\":\")){let S=s.slice(2).toLowerCase(),N=n&&e.DelegatedEvents.has(S);A.exprs.push(`r.addEventListener(${o},\"${S}\",exprs[${A.counter++}],${N})`),N&&A.delegatedEvents.add(S)}else{let S=s.startsWith(\"oncapture:\");A.exprs.push(`${o}.addEventListener(\"${s.slice(S?10:3)}\",exprs[${A.counter++}]${S?\",true\":\"\"})`)}else if(s===\"ref\")A.exprs.push(`exprs[${A.counter++}](${o})`);else{let S=Object.assign({},A,{exprs:[]}),N=A.counter;if(r(b,o,s,x,k,I,S),A.decl.push(`_fn${N} = (${x===\"###\"?\"doNotWrap\":\"\"}) => {\n${S.exprs.join(`;\n`)};\n}`),x===\"###\")A.exprs.push(`typeof exprs[${N}] === \"function\" ? r.effect(_fn${N}) : _fn${N}(true)`);else{let V=\"\";for(let T=N;T1)for(let k=0;k1?`r.mergeProps(${o.join(\",\")})`:o[0]}function a(b,o){let s=[],x=Object.keys(b.attrs),k=[s],I=o.counter++;for(let S=0;S exprs[${o.counter++}]`);else if(b.children.length){let S={type:\"fragment\",children:b.children},N=Object.assign({},o,{first:!0,decl:[],exprs:[],parent:!1});d(S,N),s.push(`children: () => { ${N.exprs.join(`;\n`)}}`),o.templateId=N.templateId,o.counter=N.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[${I}],${c(k)})${A?`, ${A}`:\"\"})`);else o.exprs.push(`${o.fragment?\"\":\"return \"}r.createComponent(exprs[${I}],${c(k)})`);o.path=A,o.first=!1}function d(b,o){if(b.type===\"fragment\"){let s=[];b.children.forEach((x)=>{if(x.type===\"tag\"){if(x.name===\"###\"){let A=Object.assign({},o,{first:!0,fragment:!0,decl:[],exprs:[]});a(x,A),s.push(A.exprs[0]),o.counter=A.counter,o.templateId=A.templateId;return}o.templateId++;let k=i,I=Object.assign({},o,{first:!0,decl:[],exprs:[]});o.templateNodes.push([x]),d(x,I),s.push(`function() { ${I.decl.join(`,\n`)+`;\n`+I.exprs.join(`;\n`)+`;\nreturn _$el${k};\n`}}()`),o.counter=I.counter,o.templateId=I.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;kS.name===\"is\");if(o.hasCustomElement=A,o.isImportNode=(b.name===\"img\"||b.name===\"iframe\")&&b.attrs.some((S)=>S.name===\"loading\"&&S.value===\"lazy\"),b.attrs.some((S)=>S.name===\"###\")){let S=[],N=\"\",V=[];for(let T=0;T({${N}})`),N=\"\";S.push(`exprs[${o.counter++}]`)}else V.push(b.attrs[T]);else if(K===\"directive\")On(j,J,s,o)}if(b.attrs=V,N.length)S.push(`()=>({${N}})`);o.exprs.push(`r.spread(${s},${S.length===1?`typeof ${S[0]} === \"function\" ? r.mergeProps(${S[0]}) : ${S[0]}`:`r.mergeProps(${S.join(\",\")})`},${I},${!!b.children.length})`)}else for(let S=0;S document.importNode(tmpls[${k}].content.firstChild, true))`:`const ${s} = tmpls[${k}].content.firstChild.cloneNode(true)`}else if(b.type===\"text\"){let s=`_$el${i++}`;o.decl.push(`${s} = ${o.path}.${o.first?\"firstChild\":\"nextSibling\"}`),o.path=s,o.first=!1}else if(b.type===\"comment\"){let s=`_$el${i++}`;if(o.decl.push(`${s} = ${o.path}.${o.first?\"firstChild\":\"nextSibling\"}`),b.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 w(b,o){let s={path:\"\",decl:[],exprs:[],delegatedEvents:new Set,counter:0,first:!0,multi:!1,templateId:0,templateNodes:[]},x=i,k=b,I;if(b.length>1)b=[{type:\"fragment\",children:b}];if(b[0].name===\"###\")I=!0,a(b[0],s);else d(b[0],s);return e.delegateEvents(Array.from(s.delegatedEvents)),[[k].concat(s.templateNodes).map((S)=>nf(S)),o(\"tmpls\",\"exprs\",\"r\",s.decl.join(`,\n`)+`;\n`+s.exprs.join(`;\n`)+(I?\"\":`;\nreturn _$el${x};\n`))]}function E(b,...o){let s=bt.get(b)||f(b,{funcBuilder:t});return s[0].create(s,o,e)}return E}var h=af({effect:R,style:gn,insert:wn,untrack:v,spread:ft,createComponent:tn,delegateEvents:an,classList:bn,mergeProps:fn,dynamicProperty:rt,setAttribute:Oe,setAttributeNS:hn,addEventListener:yn,Aliases:cn,getPropAlias:sn,Properties:un,ChildProperties:ln,DelegatedEvents:on,SVGElements:tt,SVGNamespace:dn});function Se(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 Et(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 hf=new Set([\"loops\",\"groups\",\"findings\",\"plans\"]),yf=new Set([\"overview\",\"timeline\",\"sections\",\"findings\",\"plan\",\"usage\"]);function ne(e){try{return decodeURIComponent(e)}catch{return e}}function bf(){return{projectId:null,section:\"loops\",loopName:null,tab:\"overview\",groupId:null,statuses:[],query:\"\"}}function kn(e,n){if(!n)return;for(let t of n.split(\"&\")){if(!t)continue;let i=t.indexOf(\"=\");if(i===-1)continue;let f=ne(t.slice(0,i)),r=ne(t.slice(i+1));if(f===\"status\")e.statuses=r.split(\",\").map((u)=>u.trim()).filter((u)=>u!==\"\");else if(f===\"q\")e.query=r}}function Ge(e){let n=bf(),t=(e||\"\").replace(/^#/,\"\");if(!t)return n;let i=t.indexOf(\"?\"),f=i===-1?t:t.slice(0,i),r=i===-1?\"\":t.slice(i+1),u=f.indexOf(\"/\"),l,c;if(u===-1)l=f,c=\"\";else l=f.slice(0,u),c=f.slice(u+1);if(l)n.projectId=ne(l);if(!c)return kn(n,r),n;let a=c.split(\"/\").filter((w)=>w!==\"\");if(a.length===0)return kn(n,r),n;let d=ne(a[0]);if(d===\"loop\"){if(a.length>=2){if(n.loopName=ne(a[1]),a.length>=3){let w=ne(a[2]);if(yf.has(w))n.tab=w}}}else if(hf.has(d)){if(n.section=d,d===\"groups\"&&a.length>=2)n.groupId=ne(a[1])}else n.loopName=d;return kn(n,r),n}function Ne(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 St(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=(c)=>c<10?\"0\"+c:String(c),i=t(n.getMonth()+1),f=t(n.getDate()),r=n.getFullYear(),u=n.getHours(),l=u>=12?\"PM\":\"AM\";if(u=u%12,u===0)u=12;return i+\"-\"+f+\"-\"+r+\" \"+u+\":\"+t(n.getMinutes())+\" \"+l}function ye(e){return\"status-badge status-\"+e}function Ke(e){return\"section-status section-\"+e}function At(e,n,t,i){if(!(n.size===0||n.has(e.status)))return!1;if(!t)return!0;let r=t.toLowerCase(),u=e.worktreeBranch??\"\";return((e.loopName||\"\")+\" \"+u+\" \"+(i||\"\")).toLowerCase().indexOf(r)!==-1}function kt(e){if(!e)return 0;let n=0,t=e.match(/(\\d+)\\s*h/i);if(t)n+=parseInt(t[1],10)*3600;let i=e.match(/(\\d+)\\s*m(?!s)/i);if(i)n+=parseInt(i[1],10)*60;let f=e.match(/(\\d+)\\s*s/i);if(f)n+=parseInt(f[1],10);return n}function Nt(e,n){if(n===\"recent\")return[...e];let t=[...e];if(n===\"cost\")t.sort((i,f)=>{let r=i.usage?.totalCost??null,u=f.usage?.totalCost??null;if(r===null&&u===null)return 0;if(r===null)return 1;if(u===null)return-1;return u-r});else if(n===\"duration\")t.sort((i,f)=>{let r=i.duration?kt(i.duration):0;return(f.duration?kt(f.duration):0)-r});else if(n===\"findings\")t.sort((i,f)=>f.findings.length-i.findings.length);return t}function Ct(e){return JSON.stringify(e,(n,t)=>{if(n===\"generatedAt\")return;return t})}function It(e){let n=new Map;for(let f of e){if(f===\"\"){n.set(f,[]);continue}let r=f.split(\"/\"),u=r.length;while(u>0&&r[u-1]===\"\")u--;n.set(f,u>0?r.slice(0,u):[f])}let t=new Map;for(let f of e){let r=n.get(f);t.set(f,r.length?r[r.length-1]:f)}let i=new Map;for(let f of e){let r=t.get(f),u=i.get(r);if(u)u.push(f);else i.set(r,[f])}for(let[,f]of i){let r=[...new Set(f)];if(r.length<2)continue;let u=1;while(u<1024){let l=new Set,c=!0,a=!0;for(let d of r){let w=n.get(d),E=Math.max(0,w.length-u),b=w.slice(E).join(\"/\");if(l.has(b))c=!1;else l.add(b);if(E>0)a=!1}if(c||a)break;u++}for(let l of r){let c=n.get(l),a=Math.max(0,c.length-u);t.set(l,c.slice(a).join(\"/\"))}}return t}function Sn(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 Pt(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 We(e,n){if(!n||n<=0)return 0;return Math.max(0,Math.min(100,e/n*100))}function An(e){let n=e.file+\":\"+e.line+\" — \"+e.description;if(e.scenario)n+=\" (\"+e.scenario+\")\";return n}function Vt(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 ve(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 ze(e){if(!Number.isFinite(e)||e<=0)return\"$0\";return\"$\"+(e>=1?e.toFixed(2):e.toFixed(4))}function Tt(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 jt(e){let n={role:\"execution\",cost:0,messageCount:0,pct:0},t={role:\"auditor\",cost:0,messageCount:0,pct:0};for(let[f,r]of Object.entries(e.byRole??{}))if(f===\"auditor\")t.cost+=r.cost,t.messageCount+=r.messageCount;else if(f===\"code\")n.cost+=r.cost,n.messageCount+=r.messageCount;let i=Math.max(n.cost,t.cost);return n.pct=i>0?n.cost/i*100:0,t.pct=i>0?t.cost/i*100:0,[n,t]}function _t(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 Dt(e,n){if(!e)return\"\";let t=Et(e,n??void 0);return t>0?Se(t):\"\"}function te(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 r=Math.floor(f/24);if(r<30)return r+\"d ago\";return z(e)}function Nn(e){if(!e)return\"\";let n=Ae.get(e);if(n!==void 0)return n;let t=globalThis.marked;if(!t)return\"\";let i=t.parse(e);if(i){if(Ae.size>=gf){let f=Ae.keys().next().value;if(f!==void 0)Ae.delete(f)}Ae.set(e,i)}return i}var Ae=new Map,gf=200;function Ye(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 He(e,n,t,i,f=\"coding\"){let r=[],u=e.findIndex((w)=>w.createdAt>=n),l=u===-1?[]:e.slice(u);if(t!==null)l=l.filter((w)=>w.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 a=n;for(let w of l){if(w.eventType===\"restart\"){if(w.toPhase!==null)c=w.toPhase;continue}if(c===\"\")c=w.fromPhase,a=Math.max(a,w.createdAt);let E=w.createdAt,b=Math.max(a,E);if(r.push({phase:c,startedAt:a,endedAt:b,durationMs:b-a,open:!1}),w.toPhase===null)c=\"\",a=b;else c=w.toPhase,a=b}if(c!==\"\"){let E=Math.max(a,t??i);r.push({phase:c,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&&r.length>0)r[0]={...r[0],phase:\"\"};return{spans:r,truncated:d}}function Ft(e){let n={};for(let t of e){if(!t.phase)continue;n[t.phase]=(n[t.phase]??0)+t.durationMs}return n}function Ut(e,n,t){let i=[];for(let f=0;f=n)u=Math.max(0,r.createdAt-n);else if(r.createdAt=n)u=Math.max(0,r.createdAt-n);else u=null}i.push({transition:r,elapsedMs:u})}return i.reverse(),i}var wf=[{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}],qe=110,Xe=36,Gt=655,Kt=160,$f=[{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 xf(e){if(e.toPhase===null)return e.fromPhase+\"→terminal\";return e.fromPhase+\"→\"+e.toPhase}function Of(e){return e.toPhase??e.status??\"\"}function Cn(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 Wt(e){let n=()=>e.loop(),t=g(()=>n().status===\"running\"),i=g(()=>{let l=new Map;for(let c of e.transitions()){let a=xf(c);l.set(a,(l.get(a)??0)+1)}return l}),f=$f.map((l)=>Cn(h`\n \n \n ${()=>{let c=i().get(l.key);return c?String(c):\"\"}}\n \n `)),r=wf.map((l)=>{let c=l.cx-qe/2,a=l.cy-Xe/2;return Cn(h`\n \"mg-node\"+(n().phase===l.phase&&t()?\" mg-node-active\":\"\")}>\n \n ${l.label}\n \n `)}),u=Cn(h`\n \n \n ${()=>n().status}\n \n `);return h`
\n \n \n \n \n \n \n\n ${f}\n ${r}\n ${()=>t()?\"\":u}\n \n\n
\n
Transition history
\n
\n ${()=>e.transitions().slice(-20).reverse().map((l)=>h`
\n ${l.eventType}\n ${l.fromPhase} → ${Of(l)}\n ${te(l.createdAt)}\n
`)}\n
\n
\n
`}function qt(e){let[n,t]=U(e.query()),i=null,f=(u)=>{let l=u.target.value;if(t(l),i)clearTimeout(i);i=setTimeout(()=>{i=null,e.onChangeQuery(l)},250)};ue(()=>{if(i)clearTimeout(i),i=null}),B(()=>{e.externalNav();let u=v(()=>e.query());if(i!==null)clearTimeout(i),i=null;t(u)});let r=g(()=>{let u=e.counts();return[{label:\"Running\",key:\"running\",value:u.running},{label:\"Completed\",key:\"completed\",value:u.completed},{label:\"Cancelled\",key:\"cancelled\",value:u.cancelled},{label:\"Errored\",key:\"errored\",value:u.errored},{label:\"Stalled\",key:\"stalled\",value:u.stalled}]});return h`
\n ${()=>r().map(({label:u,key:l,value:c})=>{let d=`badge badge-filter${e.statuses().has(l)?\" badge-active\":\"\"}`;return h`e.onToggleStatus(l)}>${u}: ${c}`})}\n \n \n
`}function Xt(e){return h`
Last updated: ${new Date(e.generatedAt).toLocaleString()}
`}function Zt(e){let n=g(()=>e.entries().map((t)=>{let i=t.loops.some((u)=>u.loop.status===\"running\"),f=t.proj.projectDir||t.proj.projectId||\"\",r=e.labels().get(f)??f;return h`
e.onSelect(t.proj.projectId)}>\n ${i?h``:\"\"}\n ${r}\n ${t.loops.length}\n
`}));return h`
${()=>n()}
`}function Ef(e,n){let t=0,i=0,f=0,r=[],u=[];for(let l of e){let c=l.proj.projectDir||l.proj.projectId||\"\",a=n.get(c)??c;for(let d of l.loops){i++;for(let b of d.findings)if(b.severity===\"bug\")f++;let w=d.loop.status,E=d.loop.completedAt||d.loop.startedAt||0;if(w===\"running\")t++,r.push({projectId:l.proj.projectId,label:a,loopName:d.loop.loopName,phase:d.loop.phase||\"\"});u.push({projectId:l.proj.projectId,label:a,loopName:d.loop.loopName,status:w,when:E})}}return u.sort((l,c)=>c.when-l.when),{runningCount:t,loopCount:i,bugCount:f,runningCards:r,recentRows:u.slice(0,8)}}function Lt(e){let n=g(()=>Ef(e.entries(),e.labels())),t=g(()=>n().runningCount),i=g(()=>n().loopCount),f=g(()=>n().bugCount),r=g(()=>n().runningCards),u=g(()=>n().recentRows);return h`
\n
\n

All repositories

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

Running

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

Recent activity

\n
\n ${u().map((l)=>h`
e.onOpenLoop(l.projectId,l.loopName)}>\n \"status-badge status-\"+l.status}>${l.status}\n ${l.label}\n ${l.loopName}\n ${z(l.when)}\n
`)}\n
\n
`:\"\"}\n
`}function kf(e){let[n,t]=U(!1),[i,f]=U(\"\"),[r,u]=U(0),l=g(()=>{let s=i().trim().toLowerCase(),x=e.loops();return s?x.filter((k)=>k.name.toLowerCase().indexOf(s)!==-1):x}),c=()=>{if(n())return;f(\"\");let s=e.loops().findIndex((x)=>x.name===e.loopName);u(s<0?0:s),t(!0)},a=()=>{t(!1),f(\"\"),u(0)},d=(s)=>{if(a(),o.blur(),s!==e.loopName)e.onSelect(s)},w=h`n()?\"display:block\":\"display:none\"}\n onmousedown=${(s)=>s.preventDefault()}\n >\n ${()=>l().map((s,x)=>h`\"loop-picker-option\"+(x===r()?\" 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 ${()=>l().length===0?h`
No matching loops
`:\"\"}\n `,E=()=>{w.querySelector(\".loop-picker-option-active\")?.scrollIntoView?.({block:\"nearest\"})},b=(s)=>{if(s.key===\"ArrowDown\"||s.key===\"ArrowUp\"){if(s.preventDefault(),!n()){c();return}let x=l().length;if(x===0)return;let k=s.key===\"ArrowDown\"?1:-1;u((I)=>(I+k+x)%x),E();return}if(s.key===\"Enter\"){s.preventDefault();let x=l()[r()];if(x)d(x.name);return}if(s.key===\"Escape\")s.preventDefault(),a(),o.blur()},o=h`n()?i():e.loopName}\n onfocus=${c}\n oninput=${(s)=>{f(s.target.value),u(0),t(!0)}}\n onkeydown=${b}\n onblur=${a}\n />`;return h`\n ${o}\n {if(s.preventDefault(),n())a(),o.blur();else o.focus()}}>▾\n ${w}\n `}function Rt(e){if(e.loopName)return h`
\n ← ${e.repoLabel}\n /\n ${kf({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 Jt(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 vt(e){let n=()=>We(e.current(),e.total());return h`\n \"width:\"+n()+\"%\"}>\n ${()=>e.current()}/${()=>e.total()}\n `}var Sf={sm:4,md:14,lg:22};function Pn(e){let n=Sf[e.variant],t=g(()=>e.spans().filter((r)=>r.phase!==\"\")),i=g(()=>Math.max(0,t().reduce((r,u)=>r+u.durationMs,0))),f=g(()=>e.spans().some((r)=>r.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 r=t(),u=i();return r.map((l)=>{let c=u>0?l.durationMs/u*100:0,a=\"phase-seg\"+(l.open?\" phase-seg-open\":\"\");return h`
`})}}\n
`}function Af(e,n){return He(e.transitions??[],e.loop.startedAt,e.loop.completedAt,n,e.loop.phase).spans}function Qt(e){return h`\n \n \n \n \n \n ${()=>e.loops().map((n)=>Nf({dashLoop:n,now:e.now,onOpen:e.onOpen}))}\n \n
StatusLoopPhasePhase spanIterSectionsFindingsCostDurationUpdated
`}function Nf(e){let n=()=>e.dashLoop.loop,t=()=>e.dashLoop,i=g(()=>Sn(t().findings)),f=g(()=>Af(t(),e.now()));return h`e.onOpen(e.dashLoop.loop.loopName)}>\n ye(n().status)}>${()=>n().status}\n ${()=>n().loopName}\n ${()=>n().phase}\n ${()=>Pn({spans:f,variant:\"sm\",now:e.now})}\n ${vt({current:()=>n().iteration,total:()=>n().maxIterations})}\n ${()=>n().totalSections>0?vt({current:()=>n().currentSectionIndex,total:()=>n().totalSections}):h``}\n ${()=>{let r=i();if(r.bugs.length===0&&r.warnings.length===0)return h``;return h`\n ${r.bugs.length>0?h`${be(r.bugs.length,\"bug\")}`:\"\"}\n ${r.bugs.length>0&&r.warnings.length>0?\" · \":\"\"}\n ${r.warnings.length>0?h`${r.warnings.length} warn`:\"\"}\n `}}\n ${()=>t().usage?ze(t().usage.totalCost):h``}\n ${()=>t().duration||\"\"}\n ${()=>z(n().completedAt||n().startedAt)}\n `}function Ce(e){let n=()=>typeof e.label===\"function\"?e.label():e.label;return h`
\n
\n

${()=>n()}

\n \"Copy \"+n()+\" as markdown\"}\n onclick=${(t)=>{t.stopPropagation();let i=t.target,f=\"Copy\";navigator.clipboard.writeText(e.src()||\"\").then(()=>{i.textContent=\"Copied!\"},()=>{i.textContent=\"Failed\"}).then(()=>{setTimeout(()=>{i.textContent=f},2000)})}}\n >Copy\n
\n
\n
Nn(e.src()||\"\")}>
\n
\n
`}function In(e){return h`
\n
${e.label}
\n
Nn(e.value()||\"\")}>
\n
`}function Cf(e){let n=e.sec,t=g(()=>!!n.startedAt||!!n.completedAt),i=g(()=>!!n.summaryDone),f=g(()=>!!n.summaryDeviations),r=g(()=>!!n.summaryFollowUps),u=g(()=>!!n.content),l=g(()=>t()||i()||f()||r()||u());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()?In({label:\"Done\",value:()=>n.summaryDone}):\"\"}\n ${()=>f()?In({label:\"Deviations\",value:()=>n.summaryDeviations}):\"\"}\n ${()=>r()?In({label:\"Follow-ups\",value:()=>n.summaryFollowUps}):\"\"}\n ${()=>u()?Ce({label:\"Section Plan\",src:()=>n.content}):\"\"}\n ${()=>l()?\"\":h`
No details captured for this section yet.
`}\n
`}function If(e){let n=e.sec,t=g(()=>Dt(n.startedAt,n.completedAt));return h`
\"section-list-row section-item-\"+n.status} onclick=${e.onOpen}>\n #${n.sectionIndex}\n ${()=>n.title}\n Ke(n.status)}>${()=>n.status}\n ${()=>t()?h`${()=>t()}`:\"\"}\n ${()=>n.attempts>0?h`${()=>n.attempts} attempts`:\"\"}\n \n
`}function Pf(e){let[n,t]=U(null),i=g(()=>{let f=n();if(f===null)return null;return e.sections().find((r)=>r.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 Ke(f.status)}>${()=>f.status}\n
\n ${Cf({sec:f})}\n
`;return h`
\n ${e.sections().map((r)=>If({sec:r,onOpen:()=>t(r.sectionIndex)}))}\n
`}}\n
`}function ge(e){return h`
\n ${e.label}\n ${()=>e.value()}\n
`}function zt(e){let n=()=>We(e.current(),e.total());return h`
\n
\n ${e.label}\n ${()=>e.current()} / ${()=>e.total()}\n
\n
\n
\"width:\"+n()+\"%\"}>
\n
\n
`}function Vf(e){let n=()=>e.dashLoop.loop,t=()=>e.dashLoop,i=g(()=>!!n().completedAt),f=g(()=>!!t().duration),r=g(()=>n().totalSections>0),u=g(()=>!!n().terminationReason),l=g(()=>Pt(e.split())),c=g(()=>!!e.dashLoop.usage);return h`
\n
\"ldh-findings ldh-findings-\"+l()}>\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(be(a.bugs.length,\"bug\"));if(a.warnings.length>0)d.push(be(a.warnings.length,\"warning\"));return d.join(\" · \")}}\n
\n
\n ye(n().status)}>${()=>n().status}\n

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

\n ${()=>n().phase}\n
\n\n
\n ${ge({label:\"Started\",value:()=>z(n().startedAt)})}\n ${()=>i()?ge({label:\"Completed\",value:()=>z(n().completedAt)}):\"\"}\n ${()=>f()?ge({label:\"Duration\",value:()=>t().duration||\"\"}):\"\"}\n ${ge({label:\"Iteration\",value:()=>n().iteration+\" / \"+n().maxIterations})}\n ${()=>r()?ge({label:\"Section\",value:()=>n().currentSectionIndex+\" / \"+n().totalSections}):\"\"}\n ${()=>c()?ge({label:\"Messages\",value:()=>String(e.dashLoop.usage.totalMessageCount)}):\"\"}\n
\n\n
\n ${zt({label:\"Iterations\",current:()=>n().iteration,total:()=>n().maxIterations})}\n ${()=>r()?zt({label:\"Sections\",current:()=>n().currentSectionIndex,total:()=>n().totalSections}):\"\"}\n
\n\n ${()=>u()?h`
\"ldh-banner ldh-banner-\"+n().status}>${()=>n().terminationReason}
`:\"\"}\n
`}function Tf(e){let n=()=>e.usage(),t=g(()=>Tt(n())),i=g(()=>_t(n())),f=g(()=>i().length>0),r=g(()=>jt(n()));return h`
\n

Usage

\n\n
\n
Role split
\n
\n ${()=>r().map((u)=>h`
u.role}>\n
\n u.role}>${u.role}\n ${()=>ze(u.cost)}\n
\n
\n
\"width:\"+u.pct+\"%\"}>
\n
\n
${()=>u.messageCount+\" msg\"}
\n
`)}\n
\n
\n\n
\n
Token composition
\n
\n ${()=>t().map((u)=>u.pct>0?h`
`:\"\")}\n
\n
\n ${()=>t().map((u)=>h`
\n \n ${u.label}\n ${ve(u.value)}\n
`)}\n
\n
\n\n ${()=>f()?h`
\n
Cost by model
\n
\n ${()=>i().map((u)=>h`
\n
\n ${u.model}\n ${ze(u.cost)}\n
\n
\n
\n
\n
\n ${ve(u.inputTokens)} in / ${ve(u.outputTokens)} out · ${u.messageCount} msg\n
\n
`)}\n
\n
`:\"\"}\n `}function Yt(e){try{return JSON.parse(e)}catch{return[]}}function Ht(e){return\"#\"+e.index+\" \"+e.title}function jf(e){let n=e.amendment,t=g(()=>Yt(n.sectionsBefore)),i=g(()=>Yt(n.sectionsAfter));return h`
\n
\n ${()=>te(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`
${()=>Ht(f)}
`)}\n
`:\"\"}\n ${()=>i().length>0?h`
\n
After
\n ${()=>i().map((f)=>h`
${()=>Ht(f)}
`)}\n
`:\"\"}\n
\n
\n
`}function _f(e){let n=()=>e.amendments(),[t,i]=U(null);return h`
\n

Plan amendments

\n
\n ${()=>n().map((f)=>jf({amendment:f,expanded:()=>t()===f.id,onToggle:()=>i(t()===f.id?null:f.id)}))}\n
\n
`}var Df=[\"overview\",\"timeline\",\"sections\",\"findings\",\"plan\",\"usage\"],Ff={overview:\"Overview\",timeline:\"Timeline\",sections:\"Sections\",findings:\"Findings\",plan:\"Plan\",usage:\"Usage\"};function Uf(e){return h`
\n ${()=>e.tabs().map((n)=>{let t=Ff[n];return h`\"tab-item\"+(e.activeTab()===n?\" tab-active\":\"\")} data-tab=\"${n}\" onclick=${()=>e.onSelect(n)}>${t}`})}\n
`}function ie(e){return h`
\n ${e.label}\n ${()=>e.value()??\"—\"}\n
`}function Gf(e){let n=()=>e.dashLoop,t=()=>n().loop,i=()=>e.split(),f=g(()=>!!n().goal),r=g(()=>i().bugs.length>0),u=g(()=>i().warnings.length>0),l=g(()=>r()||u()),c=g(()=>n().postActionReport??t().completionSummary??null),a=g(()=>n().postActionReport?\"Post-Action Report\":\"Completion Summary\"),d=g(()=>!!c()),w=g(()=>!!n().lastAuditResult),E=g(()=>He(n().transitions??[],t().startedAt,t().completedAt,e.now(),t().phase));return h`
\n ${()=>Pn({spans:()=>E().spans,variant:\"md\",now:e.now})}\n ${()=>f()?Ce({label:\"Goal\",src:()=>n().goal}):\"\"}\n ${Vf({dashLoop:e.dashLoop,split:e.split})}\n
\n ${ie({label:\"Execution model\",value:()=>t().executionModel})}\n ${ie({label:\"Auditor model\",value:()=>t().auditorModel})}\n ${ie({label:\"Branch\",value:()=>t().worktreeBranch})}\n ${ie({label:\"Sandbox\",value:()=>t().sandbox?\"on\":\"off\"})}\n ${ie({label:\"Kind\",value:()=>t().kind})}\n ${ie({label:\"Audits\",value:()=>String(t().auditCount)})}\n ${ie({label:\"Errors\",value:()=>String(t().errorCount)})}\n
\n
\n ${()=>l()?h`
\n ${()=>r()?h`${()=>be(i().bugs.length,\"bug\")}`:\"\"}\n ${()=>u()?h`${()=>be(i().warnings.length,\"warning\")}`:\"\"}\n
`:h`No findings`}\n
\n ${()=>d()?Ce({label:a,src:()=>c()}):\"\"}\n ${()=>w()?Ce({label:\"Last Audit Result\",src:()=>n().lastAuditResult}):\"\"}\n
`}function Kf(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 ${te(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?Se(i):\"0s\"}\n
`}function Wf(e){let n=()=>e.dashLoop,t=()=>n().loop,i=()=>n().transitions??[],f=g(()=>He(i(),t().startedAt,t().completedAt,e.now(),t().phase)),r=g(()=>f().spans),u=g(()=>f().truncated),l=g(()=>Ft(r())),c=g(()=>r().filter((s)=>s.phase!==\"\").reduce((s,x)=>s+x.durationMs,0)),a=g(()=>{let s=l();return Object.keys(s).filter((x)=>x!==\"\").sort((x,k)=>s[k]-s[x])}),d=g(()=>Ut(i(),t().startedAt,u())),[w,E]=U(!1),b=g(()=>{let s=d();return w()?s:s.slice(0,20)}),o=g(()=>w()?0:Math.max(0,d().length-20));return h`
\n ${()=>Pn({spans:r,variant:\"lg\",now:e.now})}\n ${()=>u()?h`
Earlier history was truncated by the 100-row fetch window.
`:\"\"}\n\n
\n ${()=>a().map((s)=>{let x=l()[s],k=c()>0?Math.round(x/c()*100):0;return h`
\n \n ${s}\n \n ${()=>x>=1000?Se(Math.floor(x/1000)):x+\"ms\"} · ${k}%\n
`})}\n
\n\n
\n ${()=>b().map((s)=>Kf({ev:s}))}\n ${()=>o()>0?h`
E(!0)}>[ ${()=>o()} earlier events ]
`:\"\"}\n
\n\n
\n State machine graph\n ${Wt({loop:()=>t(),transitions:i})}\n
\n
`}function vf(e){return Pf({sections:()=>e.dashLoop.sections})}function zf(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 Yf(e){let n=()=>e.dashLoop,t=g(()=>n().findings.length>0),i=g(()=>zf(n().findings));return h`
\n ${()=>t()?i().map((f)=>h`
f.key===null?\"cross\":String(f.key)}>\n
${f.label}
\n
\n ${f.rows.map((r)=>h`
\"finding finding-\"+r.severity}>\n ${()=>te(r.createdAt)}\n ${()=>An(r)}\n
`)}\n
\n
`):h`
No findings recorded for this loop.
`}\n
`}function Hf(e){let n=()=>e.dashLoop,t=g(()=>!!n().plan),i=g(()=>!!n().amendments&&n().amendments.length>0);return h`
\n ${()=>t()?Ce({label:\"Plan\",src:()=>n().plan}):h`
No plan recorded for this loop.
`}\n ${()=>i()?_f({amendments:()=>n().amendments}):\"\"}\n
`}function qf(e){let n=()=>e.dashLoop,t=g(()=>!!n().usage);return h`
\n ${()=>t()?Tf({usage:()=>n().usage}):h`
No usage recorded for this loop.
`}\n
`}function Xf(e){let n=g(()=>e.tabs().includes(e.tab)&&e.activeTab()===e.tab),t=(()=>{switch(e.tab){case\"overview\":return Gf({dashLoop:e.dashLoop,split:e.split,now:e.now});case\"timeline\":return Wf({dashLoop:e.dashLoop,now:e.now});case\"sections\":return vf({dashLoop:e.dashLoop});case\"findings\":return Yf({dashLoop:e.dashLoop});case\"plan\":return Hf({dashLoop:e.dashLoop});case\"usage\":return qf({dashLoop:e.dashLoop})}})();return h`
n()?\"display:block\":\"display:none\"}>\n ${t}\n
`}function Zf(e){return h`
\n ${Df.map((n)=>Xf({tab:n,dashLoop:e.dashLoop,split:e.split,now:e.now,tabs:e.tabs,activeTab:e.activeTab}))}\n
`}function Bt(e){let n=()=>e.dashLoop,t=g(()=>Sn(n().findings)),i=g(()=>Ye(n())),f=g(()=>{let r=e.routeTab();return i().includes(r)?r:\"overview\"});return h`
\n
\n ${Uf({tabs:i,activeTab:f,onSelect:e.onSelectTab})}\n ${Zf({dashLoop:e.dashLoop,split:t,now:e.now,tabs:i,activeTab:f})}\n
\n
`}function Vn(){return h`
No loops match the current filters.
`}var mt=new Set([\"completed\",\"cancelled\",\"errored\"]);function Ze(e){let n=e.length;return{completed:e.filter((i)=>i.stage===\"completed\").length,total:n}}function Lf(e){return\"group-row\"+(mt.has(e)?\" group-row-terminal\":\" group-row-active\")}function Mt(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;return h`
Lf(i.status)} data-group-status=${()=>i.status} onclick=${()=>e.onOpen(i.groupId)}>\n ye(i.status)}>${()=>i.status}\n ${()=>i.title}\n \n \"width:\"+We(Ze(t.features).completed,Ze(t.features).total)+\"%\"}>\n ${()=>{let f=Ze(t.features);return f.completed+\"/\"+f.total}}\n \n max ${()=>i.maxConcurrent}\n \n Created ${()=>z(i.createdAt)}\n ${()=>i.completedAt?h`Completed ${()=>z(i.completedAt)}`:\"\"}\n \n
`})}\n
`}}\n
`}function Rf(e){return\"group-header\"+(mt.has(e)?\" group-header-terminal\":\" group-header-active\")}function pt(e){return h`
\n
← Back to groups
\n ${()=>{let n=e.group();if(!n)return h`
Group not found.
`;let t=n.group;return h`
Rf(t.status)} data-group-status=${()=>t.status}>\n
\n ye(t.status)}>${()=>t.status}\n

${()=>t.title}

\n max ${()=>t.maxConcurrent}\n
\n
\n ${()=>{let i=Ze(n.features);return\"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((i)=>{return h`
i.stage}>\n #${()=>i.featureIndex}\n ${()=>i.title}\n Ke(i.stage)}>${()=>i.stage}\n ${()=>i.attempts} attempts\n ${()=>!!i.loopName&&e.loopNames().has(i.loopName)?h`Ne({projectId:e.projectId(),section:\"loops\",loopName:i.loopName,tab:\"overview\",groupId:null,statuses:[],query:\"\"})} onclick=${(f)=>{if(f.defaultPrevented||f.button!==0||f.metaKey||f.ctrlKey||f.shiftKey||f.altKey)return;f.preventDefault(),e.onOpenLoop(i.loopName)}}>${()=>i.loopName}`:\"\"}\n ${()=>i.error?h`${()=>i.error}`:\"\"}\n
`})}\n
`}}\n
`}function Jf(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 ei(e){let n=g(()=>Jf(e.loops())),t=g(()=>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
`}function ni(e){let n=g(()=>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 ye(i.loop.status)}>${()=>i.loop.status}\n ${()=>i.loop.loopName}\n \n ${()=>i.findings.length+\" finding\"+(i.findings.length===1?\"\":\"s\")}\n \n iter ${()=>i.loop.iteration}/${()=>i.loop.maxIterations}\n
`)}\n
`}}\n
`}function ti(){let[e,n]=dt({generatedAt:0,projects:[],totals:{projects:0,loops:0,running:0,completed:0,cancelled:0,errored:0,stalled:0}}),[t,i]=U(!1),[f,r]=U(Ge(\"\")),[u,l]=U(null),[c,a]=U(\"recent\"),[d,w]=U(0),[E,b]=U(Date.now()),o={current:\"\"},s={current:!1},x=async()=>{try{let $=await(await fetch(\"/api/data\",{cache:\"no-store\"})).json(),P=Ct($);if(P!==o.current)o.current=P,n(at($,{key:\"id\"})),i(!0),l(null);b(Date.now())}catch(y){let $=y instanceof Error?y.message:String(y);l(\"Failed to load dashboard data: \"+$)}},k=(y)=>{r(($)=>{let P={...$,...y};if(y.projectId!==void 0&&y.projectId!==$.projectId)P.statuses=[],P.query=\"\";return P})},I=(y)=>{k({tab:y})},A=(y)=>{k({loopName:y,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})},S=(y)=>{let $=new Set(f().statuses);if($.has(y))$.delete(y);else $.add(y);k({statuses:[...$]})},N=(y)=>{k({query:y})},V=(y)=>{a(y)},T=g(()=>new Set(f().statuses)),K=g(()=>It(e.projects.map((y)=>y.projectDir||y.projectId||\"\"))),j=g(()=>{if(!t()||e.projects.length===0)return[];let y=T(),$=f().query,P=K(),F=[],ee=$.trim().toLowerCase();for(let fe of e.projects){let Qe=[],_n=fe.projectDir||fe.projectId||\"\",Dn=P.get(_n)??_n;for(let Fn of fe.loops)if(At(Fn.loop,y,$,Dn))Qe.push(Fn);let Oi=(fe.groups?.length??0)>0;if(Qe.length>0)F.push({proj:fe,loops:Qe});else if(Oi&&(!ee||Dn.toLowerCase().indexOf(ee)!==-1))F.push({proj:fe,loops:[]})}return F}),J=g(()=>f().projectId===null),Q=g(()=>f().section===\"groups\"),Ie=g(()=>f().section===\"loops\"),Le=g(()=>f().section===\"findings\"),fi=g(()=>f().section===\"plans\"),Tn=g(()=>{let y=j(),$=f().projectId;if(!$)return null;return y.find((P)=>P.proj.projectId===$)??null}),Re=g(()=>{let y=f();if(!y.projectId||!y.loopName)return null;let $=Z();if(!$)return null;return $.loops.find((P)=>P.loop.loopName===y.loopName)??null}),Z=g(()=>{let y=f().projectId;if(!y)return null;return e.projects.find(($)=>$.projectId===y)??null}),ri=g(()=>{let y=Z(),$=y?.groups?.length??0;if(!y)return{loops:0,groups:$,findings:0,plans:0};let P=0,F=0;for(let ee of y.loops)if(P+=ee.findings.length,ee.plan)F++;return{loops:y.loops.length,groups:$,findings:P,plans:F}}),Je=g(()=>{let y=Z();if(!y)return[];return y.loops.map(($)=>({name:$.loop.loopName,when:$.loop.completedAt||$.loop.startedAt||0})).sort(($,P)=>P.when-$.when||$.name.localeCompare(P.name))}),ui=g(()=>{let y=f().loopName;if(!y)return-1;return Je().findIndex(($)=>$.name===y)}),li=g(()=>{let y=ui();if(y<0)return null;let $=Je();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})}}}),ci=g(()=>{let y=Z(),$={running:0,completed:0,cancelled:0,errored:0,stalled:0};if(!y)return $;for(let P of y.loops){let F=P.loop.status;if(F===\"running\")$.running++;else if(F===\"completed\")$.completed++;else if(F===\"cancelled\")$.cancelled++;else if(F===\"errored\")$.errored++;else if(F===\"stalled\")$.stalled++}return $}),si=g(()=>{let y=f();if(!y.projectId)return\"\";let $=Z();if(!$)return\"\";let P=$.projectDir||$.projectId||\"\",F=K().get(P)??P;return v(()=>Rt({repoLabel:F,projectDir:P,loopName:y.loopName,loops:Je,loopNav:li,onSelectLoop:(ee)=>k({loopName:ee}),onBackToRepos:()=>k({projectId:null,loopName:null,section:\"loops\"}),onBackToRepo:()=>k({loopName:null})}))}),oi=g(()=>{let y=f();if(!y.projectId||y.loopName)return\"\";return Jt({section:y.section,counts:ri(),onNavigate:($)=>k({section:$,loopName:null,groupId:null})})}),di=g(()=>{let y=f();return y.projectId!==null&&y.loopName===null&&y.section===\"loops\"}),ai=g(()=>{if(!di())return\"\";return v(()=>qt({counts:ci,statuses:T,query:()=>f().query,sortMode:c,onToggleStatus:S,onChangeQuery:N,onChangeSort:V,externalNav:d}))}),hi=g(()=>{if(!Ie())return\"\";let y=Re();return y?Bt({dashLoop:y,now:E,routeTab:()=>f().tab,onSelectTab:I}):\"\"}),yi=g(()=>{if(!Ie())return\"\";if(Re())return\"\";if(!f().projectId)return\"\";return h`
\n ${Qt({loops:()=>{let $=Tn();return $?Nt($.loops,c()):[]},now:E,onOpen:($)=>A($)})}\n ${()=>Tn()?\"\":Vn()}\n
`}),bi=g(()=>{let y=f();if(y.section!==\"groups\"||!y.groupId)return null;let $=Z();if(!$)return null;return $.groups.find((P)=>P.group.groupId===y.groupId)??null}),gi=g(()=>{let y=Z();if(!y)return new Set;return new Set(y.loops.map(($)=>$.loop.loopName))}),wi=g(()=>{if(!Q())return\"\";let y=f();if(!y.projectId)return\"\";if(y.groupId)return pt({group:bi,loopNames:gi,projectId:()=>f().projectId,onBack:()=>k({groupId:null}),onOpenLoop:A});return Mt({groups:()=>Z()?.groups??[],onOpen:($)=>k({groupId:$})})}),jn=g(()=>{let y=Z();return y?y.loops:[]}),$i=g(()=>{if(!Le())return\"\";if(!f().projectId)return\"\";return ei({loops:jn,projectId:()=>f().projectId,onOpenLoop:A})}),xi=g(()=>{if(!fi())return\"\";if(!f().projectId)return\"\";return ni({loops:jn,onOpenLoop:A})});return B(()=>{if(!t())return;let y=f();if(y.projectId===null)return;if(!e.projects.some((P)=>P.projectId===y.projectId))r((P)=>({...P,projectId:null,loopName:null,section:\"loops\",statuses:[],query:\"\"}))}),B(()=>{let y=f();if(!y.projectId||!y.loopName)return;let $=Z();if(!$)return;if(!$.loops.some((F)=>F.loop.loopName===y.loopName))r((F)=>({...F,loopName:null}))}),B(()=>{let y=f();if(!y.projectId||!y.loopName||y.tab===\"overview\")return;let $=Re();if(!$)return;if(!Ye($).includes(y.tab))r((F)=>({...F,tab:\"overview\"}))}),B(()=>{if(!t())return;St(Ne(f()),s)}),Zn(()=>{r(Ge(location.hash)),x();let y=setInterval(()=>{x()},5000),$=()=>{if(s.current){s.current=!1;return}r(Ge(location.hash)),w((P)=>P+1)};window.addEventListener(\"hashchange\",$),ue(()=>{clearInterval(y),window.removeEventListener(\"hashchange\",$)})}),h`
\n

Forge Dashboard

\n\n ${()=>{let y=u();return y?h`
${y}
`:\"\"}}\n\n ${()=>{if(!t())return\"\";return h`
\n ${Xt({generatedAt:e.generatedAt})}\n
`}}\n\n ${()=>{if(!t())return\"\";if(J()&&j().length===0)return Vn();return h`
\n ${()=>{if(J())return h`
\n ${Zt({entries:()=>j(),labels:()=>K(),onSelect:(y)=>k({projectId:y,loopName:null,section:\"loops\",groupId:null})})}\n ${Lt({entries:()=>j(),labels:()=>K(),onOpenLoop:(y,$)=>k({projectId:y,loopName:$,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})})}\n
`;return h`
\n ${si}\n ${ai}\n ${oi}\n ${yi}\n ${hi}\n ${wi}\n ${$i}\n ${xi}\n
`}}\n
`}}\n
`}var ii=document.getElementById(\"forge-app-root\");if(ii)it(ti,ii);\n"; +export const DASHBOARD_APP_SOURCE_HASH: string = "335749099c1ed3ca7731af7876959768d19503df26fbf6d17173949dc89fa1ef"; diff --git a/src/dashboard/app/app.ts b/src/dashboard/app/app.ts index 49efed548c..99beb84221 100644 --- a/src/dashboard/app/app.ts +++ b/src/dashboard/app/app.ts @@ -1,23 +1,29 @@ -import { createSignal, createMemo, createEffect, onMount, onCleanup } from 'solid-js' +import { createSignal, createMemo, createEffect, onMount, onCleanup, untrack } from 'solid-js' import { createStore, reconcile } from 'solid-js/store' import html from 'solid-js/html' -import type { DashboardPayload, DashboardLoop } from './types' -import { parseLoopHash, buildLoopHash, syncHash, dataHash, loopMatchesFilters } from './helpers' +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 { - TotalsBar, - SearchInput, + FilterBar, Timestamp, - Sidebar, + RepoMenu, + RepoIndexPane, + Breadcrumb, + SectionNav, LoopTable, LoopDetail, EmptyState, + GroupsPanel, + GroupDetail, + FindingsPanel, + PlansPanel, type MatchedEntry, + type LoopOption, + type LoopNav, } from './components' -function syncHashTo(projectId: string | null, loopName: string | null, suppressRef: { current: boolean }) { - syncHash(buildLoopHash(projectId, loopName), suppressRef) -} - export function App() { // ── Reactive state ────────────────────────────────────────────────────── @@ -27,11 +33,11 @@ export function App() { totals: { projects: 0, loops: 0, running: 0, completed: 0, cancelled: 0, errored: 0, stalled: 0 }, }) const [loaded, setLoaded] = createSignal(false) - const [activeStatuses, setActiveStatuses] = createSignal>(new Set()) - const [searchText, setSearchText] = createSignal('') - const [selectedProjectId, setSelectedProjectId] = createSignal(null) - const [selectedLoopName, setSelectedLoopName] = createSignal(null) + const [route, setRoute] = createSignal(parseDashboardHash('')) const [loadError, setLoadError] = createSignal(null) + const [sortMode, setSortMode] = createSignal('recent') + const [externalNav, setExternalNav] = createSignal(0) + const [now, setNow] = createSignal(Date.now()) // Non-reactive refs const lastDataHashRef = { current: '' } @@ -58,6 +64,7 @@ export function App() { setLoaded(true) setLoadError(null) } + setNow(Date.now()) } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err) setLoadError('Failed to load dashboard data: ' + msg) @@ -66,59 +73,157 @@ export function App() { // ── Navigation ────────────────────────────────────────────────────────── - const navigate = (projectId: string | null, loopName: string | null) => { - setSelectedProjectId(projectId) - setSelectedLoopName(loopName) - syncHashTo(projectId, loopName, suppressHashChangeRef) + const navigate = (patch: Partial) => { + setRoute(r => { + const next: DashboardRoute = { ...r, ...patch } + if (patch.projectId !== undefined && patch.projectId !== r.projectId) { + next.statuses = [] + next.query = '' + } + return next + }) + } + + const selectTab = (t: LoopTab) => { + navigate({ tab: t }) + } + + const openLoop = (name: string) => { + navigate({ loopName: name, section: 'loops', groupId: null, statuses: [], query: '', tab: 'overview' }) } // ── Event handlers ────────────────────────────────────────────────────── const toggleStatus = (key: string) => { - const next = new Set(activeStatuses()) + const next = new Set(route().statuses) if (next.has(key)) next.delete(key) else next.add(key) - setActiveStatuses(next) + navigate({ statuses: [...next] }) } - const handleSearch = (e: Event) => { - setSearchText((e.target as HTMLInputElement).value.trim().toLowerCase()) + const handleChangeQuery = (q: string) => { + navigate({ query: q }) + } + + const handleChangeSort = (m: SortMode) => { + setSortMode(m) } // ── Derived memos ─────────────────────────────────────────────────────── + const activeStatusSet = createMemo(() => new Set(route().statuses)) + + const repoLabels = createMemo(() => + buildRepoLabels(state.projects.map(p => p.projectDir || p.projectId || '')), + ) + const matchedByProject = createMemo(() => { if (!loaded() || state.projects.length === 0) return [] - const statuses = activeStatuses() - const search = searchText() + const statuses = activeStatusSet() + const search = route().query + const labels = repoLabels() const result: MatchedEntry[] = [] + 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 for (const dashLoop of proj.loops) { - if (loopMatchesFilters(dashLoop.loop, proj, statuses, search)) { + if (loopMatchesFilters(dashLoop.loop, statuses, search, label)) { matched.push(dashLoop) } } + const hasGroups = (proj.groups?.length ?? 0) > 0 if (matched.length > 0) { result.push({ proj, loops: matched }) + } else if (hasGroups && (!q || label.toLowerCase().indexOf(q) !== -1)) { + result.push({ proj, loops: [] }) } } return result }) + const atRepoIndex = createMemo(() => route().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 selectedEntry = createMemo(() => { const entries = matchedByProject() - const pid = selectedProjectId() - if (!entries.length) return null - const found = entries.find(e => e.proj.projectId === pid) - return found ?? entries[0] + const pid = route().projectId + if (!pid) return null + return entries.find(e => e.proj.projectId === pid) ?? null }) const activeLoop = createMemo(() => { - const entry = selectedEntry() - const name = selectedLoopName() - if (!entry || !name) return null - return entry.loops.find(l => l.loop.loopName === name) ?? null + 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 + if (!pid) return null + return state.projects.find(p => p.projectId === pid) ?? null + }) + + const sectionCounts = createMemo(() => { + const proj = selectedRepoProject() + const groups = proj?.groups?.length ?? 0 + if (!proj) return { loops: 0, groups, findings: 0, plans: 0 } + let findings = 0 + let plans = 0 + for (const dl of proj.loops) { + findings += dl.findings.length + if (dl.plan) plans++ + } + return { loops: proj.loops.length, groups, findings, plans } + }) + + const repoLoopOptions = createMemo(() => { + const proj = selectedRepoProject() + if (!proj) return [] + return proj.loops + .map(dl => ({ name: dl.loop.loopName, when: dl.loop.completedAt || dl.loop.startedAt || 0 })) + .sort((a, b) => (b.when - a.when) || a.name.localeCompare(b.name)) + }) + + const currentLoopIndex = createMemo(() => { + const name = route().loopName + if (!name) return -1 + return repoLoopOptions().findIndex(o => o.name === name) + }) + + const loopNav = createMemo(() => { + const i = currentLoopIndex() + if (i < 0) return null + const options = repoLoopOptions() + if (options.length <= 1) return null + 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 }) }, + } + }) + + const selectedRepoCounts = createMemo(() => { + const proj = selectedRepoProject() + const c = { running: 0, completed: 0, cancelled: 0, errored: 0, stalled: 0 } + if (!proj) return c + for (const dl of proj.loops) { + const s = dl.loop.status + if (s === 'running') c.running++ + else if (s === 'completed') c.completed++ + else if (s === 'cancelled') c.cancelled++ + else if (s === 'errored') c.errored++ + else if (s === 'stalled') c.stalled++ + } + return c }) // ── View nodes ────────────────────────────────────────────────────────── @@ -127,72 +232,187 @@ export function App() { // reference) and survives polls, preserving markdown scroll / resize state; // only the loop's fields update in place via the reactive reads inside it. - const sidebarView = createMemo(() => - Sidebar({ - entries: matchedByProject(), - selectedProjectId: selectedProjectId(), - onSelect: navigate, - }), - ) + const breadcrumbView = createMemo(() => { + const r = route() + if (!r.projectId) return '' + const proj = selectedRepoProject() + if (!proj) return '' + const rawPath = proj.projectDir || proj.projectId || '' + const label = repoLabels().get(rawPath) ?? rawPath + return untrack(() => Breadcrumb({ + repoLabel: label, + projectDir: rawPath, + loopName: r.loopName, + loops: repoLoopOptions, + loopNav, + onSelectLoop: (name: string) => navigate({ loopName: name }), + onBackToRepos: () => navigate({ projectId: null, loopName: null, section: 'loops' }), + onBackToRepo: () => navigate({ loopName: null }), + })) as Node + }) + + const sectionNavView = createMemo(() => { + const r = route() + if (!r.projectId || r.loopName) return '' + return SectionNav({ + section: r.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' + }) + + const filterBarView = createMemo(() => { + if (!filterBarVisible()) return '' + return untrack(() => FilterBar({ + counts: selectedRepoCounts, + statuses: activeStatusSet, + query: () => route().query, + sortMode, + onToggleStatus: toggleStatus, + onChangeQuery: handleChangeQuery, + onChangeSort: handleChangeSort, + externalNav, + }) as Node) + }) const detailView = createMemo(() => { + if (!atLoopsSection()) return '' const loop = activeLoop() return loop - ? (LoopDetail({ dashLoop: loop, onBack: () => navigate(selectedProjectId(), null) }) as Node) + ? (LoopDetail({ + dashLoop: loop, + now, + routeTab: () => route().tab, + onSelectTab: selectTab, + }) as Node) : '' }) const listView = createMemo(() => { + if (!atLoopsSection()) return '' if (activeLoop()) return '' - const e = selectedEntry() - if (!e) return '' - const pid = selectedProjectId() - return LoopTable({ loops: e.loops, onOpen: (name: string) => navigate(pid, name) }) as Node + const r = route() + if (!r.projectId) return '' + return html`
+ ${LoopTable({ + loops: () => { + const e = selectedEntry() + return e ? sortLoops(e.loops, sortMode()) : [] + }, + now, + onOpen: (name: string) => openLoop(name), + })} + ${() => (selectedEntry() ? '' : EmptyState() as Node)} +
` as Node + }) + + const selectedGroup = createMemo(() => { + const r = route() + if (r.section !== 'groups' || !r.groupId) return null + const proj = selectedRepoProject() + if (!proj) return null + return proj.groups.find(g => g.group.groupId === r.groupId) ?? null + }) + + const repoLoopNames = createMemo(() => { + const proj = selectedRepoProject() + if (!proj) return new Set() + return new Set(proj.loops.map(l => l.loop.loopName)) + }) + + const groupsView = createMemo(() => { + if (!atGroupsSection()) return '' + const r = route() + if (!r.projectId) return '' + if (r.groupId) { + return GroupDetail({ + group: selectedGroup, + loopNames: repoLoopNames, + projectId: () => route().projectId, + onBack: () => navigate({ groupId: null }), + onOpenLoop: openLoop, + }) as Node + } + return GroupsPanel({ + groups: () => selectedRepoProject()?.groups ?? [], + onOpen: (groupId: string) => navigate({ groupId }), + }) as Node + }) + + const repoAllLoops = createMemo(() => { + const proj = selectedRepoProject() + return proj ? proj.loops : [] + }) + + const findingsView = createMemo(() => { + if (!atFindingsSection()) return '' + const r = route() + if (!r.projectId) return '' + return FindingsPanel({ + loops: repoAllLoops, + projectId: () => route().projectId, + onOpenLoop: openLoop, + }) as Node + }) + + const plansView = createMemo(() => { + if (!atPlansSection()) return '' + const r = route() + if (!r.projectId) return '' + return PlansPanel({ + loops: repoAllLoops, + onOpenLoop: openLoop, + }) as Node }) // ── Effects ───────────────────────────────────────────────────────────── - // Sync selected project when data or filters change createEffect(() => { if (!loaded()) return - const entries = matchedByProject() - if (entries.length === 0) { - setSelectedProjectId(null) - setSelectedLoopName(null) - return - } - const pid = selectedProjectId() - const exists = pid !== null && entries.some(e => e.proj.projectId === pid) + const r = route() + if (r.projectId === null) return + const exists = state.projects.some(p => p.projectId === r.projectId) if (!exists) { - setSelectedProjectId(entries[0].proj.projectId) - setSelectedLoopName(null) + setRoute(prev => ({ ...prev, projectId: null, loopName: null, section: 'loops', statuses: [], query: '' })) } }) - // Clear invalid loop name when selection changes createEffect(() => { - const entry = selectedEntry() - const name = selectedLoopName() - if (!entry || !name) return - const exists = entry.loops.some(l => l.loop.loopName === name) + const r = route() + if (!r.projectId || !r.loopName) return + const proj = selectedRepoProject() + if (!proj) return + const exists = proj.loops.some(l => l.loop.loopName === r.loopName) if (!exists) { - setSelectedLoopName(null) + setRoute(prev => ({ ...prev, loopName: null })) + } + }) + + createEffect(() => { + const r = route() + if (!r.projectId || !r.loopName || r.tab === 'overview') return + const loop = activeLoop() + if (!loop) return + const tabs = tabsForLoop(loop) + if (!tabs.includes(r.tab)) { + setRoute(prev => ({ ...prev, tab: 'overview' })) } }) - // Sync hash to match current selection whenever it changes (but only after data loaded) createEffect(() => { if (!loaded()) return - syncHashTo(selectedProjectId(), selectedLoopName(), suppressHashChangeRef) + syncHash(buildDashboardHash(route()), suppressHashChangeRef) }) // ── Lifecycle ─────────────────────────────────────────────────────────── onMount(() => { - // Seed initial selection from URL hash - const parsed = parseLoopHash(location.hash) - if (parsed.projectId) setSelectedProjectId(parsed.projectId) - if (parsed.loopName) setSelectedLoopName(parsed.loopName) + setRoute(parseDashboardHash(location.hash)) // Initial load + poll load() @@ -206,9 +426,8 @@ export function App() { suppressHashChangeRef.current = false return } - const parsed = parseLoopHash(location.hash) - setSelectedProjectId(parsed.projectId) - setSelectedLoopName(parsed.loopName) + setRoute(parseDashboardHash(location.hash)) + setExternalNav(n => n + 1) } window.addEventListener('hashchange', onHashChange) @@ -231,33 +450,45 @@ export function App() { return err ? html`
${err}
` : '' }} - ${SearchInput({ onInput: handleSearch })} - ${() => { if (!loaded()) return '' return html`
- ${TotalsBar({ totals: state.totals, activeStatuses: activeStatuses(), onToggle: toggleStatus })} ${Timestamp({ generatedAt: state.generatedAt })}
` }} ${() => { if (!loaded()) return '' - if (matchedByProject().length === 0) return EmptyState() - const selEntry = selectedEntry() - if (!selEntry) return '' - return html` -
- ${sidebarView} -
-
-
${selEntry.proj.projectDir || selEntry.proj.projectId}
- ${listView} - ${detailView} -
-
-
- ` + if (atRepoIndex() && matchedByProject().length === 0) return EmptyState() + return html`
+ ${() => { + if (atRepoIndex()) { + return html`
+ ${RepoMenu({ + entries: () => matchedByProject(), + labels: () => repoLabels(), + onSelect: (projectId: string) => navigate({ projectId, loopName: null, section: 'loops', groupId: null }), + })} + ${RepoIndexPane({ + entries: () => matchedByProject(), + labels: () => repoLabels(), + onOpenLoop: (projectId: string, loopName: string) => + navigate({ projectId, loopName, section: 'loops', groupId: null, statuses: [], query: '', tab: 'overview' }), + })} +
` + } + return html`
+ ${breadcrumbView} + ${filterBarView} + ${sectionNavView} + ${listView} + ${detailView} + ${groupsView} + ${findingsView} + ${plansView} +
` + }} +
` }} ` } diff --git a/src/dashboard/app/components.ts b/src/dashboard/app/components.ts index 667a526b12..4c4a455e36 100644 --- a/src/dashboard/app/components.ts +++ b/src/dashboard/app/components.ts @@ -1,11 +1,12 @@ import html from 'solid-js/html' -import { createMemo, createSignal } from 'solid-js' -import type { DashboardLoop, DashboardTotals, DashboardProject } from './types' +import { createMemo, createSignal, createEffect, onCleanup, untrack } from 'solid-js' +import type { DashboardLoop, DashboardProject, DashboardGroup } from './types' +import type { GroupFeatureRow } from '../../storage' +import type { RepoSection, SortMode } from './helpers' import { statusClass, sectionStatusClass, fmtTime, - deriveSidebarLabel, formatSectionDuration, splitFindings, findingsLevel, @@ -17,9 +18,17 @@ import { formatUsageCost, tokenBreakdownSegments, modelUsageBars, + roleUsageBars, renderMarkdown, formatRelativeTime, + tabsForLoop, + computePhaseSpans, + summarizePhaseTotals, + computeTimelineEvents, + buildDashboardHash, } from './helpers' +import type { LoopTab, PhaseSpan } from './helpers' +import { formatDuration } from '../../utils/duration' import { LoopMachineGraph } from './machine-graph' type DashboardSection = NonNullable[number] @@ -33,83 +42,366 @@ type DashboardSection = NonNullable[number] // ── Shared types ────────────────────────────────────────────────────────── -export type { DashboardTotals } - export interface MatchedEntry { proj: DashboardProject loops: DashboardLoop[] } -// ── TotalsBar ───────────────────────────────────────────────────────────── +type RepoCounts = { running: number; completed: number; cancelled: number; errored: number; stalled: number } -export function TotalsBar(props: { - totals: DashboardTotals - activeStatuses: Set - onToggle: (key: string) => void +export function FilterBar(props: { + counts: () => RepoCounts + statuses: () => Set + query: () => string + sortMode: () => SortMode + onToggleStatus: (key: string) => void + onChangeQuery: (q: string) => void + onChangeSort: (m: SortMode) => void + externalNav: () => number }) { - const entries: { label: string; key: string | null; value: number }[] = [ - { label: 'Projects', key: null, value: props.totals.projects }, - { label: 'Loops', key: null, value: props.totals.loops }, - { label: 'Running', key: 'running', value: props.totals.running }, - { label: 'Completed', key: 'completed', value: props.totals.completed }, - { label: 'Cancelled', key: 'cancelled', value: props.totals.cancelled }, - { label: 'Errored', key: 'errored', value: props.totals.errored }, - { label: 'Stalled', key: 'stalled', value: props.totals.stalled }, - ] - - return html`
- ${entries.map(({ label, key, value }) => { - if (!key) { - return html`${label}: ${value}` - } - const active = props.activeStatuses.has(key) + const [local, setLocal] = createSignal(props.query()) + let timer: ReturnType | null = null + const onInput = (e: Event) => { + const v = (e.target as HTMLInputElement).value + setLocal(v) + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + timer = null + props.onChangeQuery(v) + }, 250) + } + onCleanup(() => { + if (timer) { + clearTimeout(timer) + timer = null + } + }) + createEffect(() => { + props.externalNav() + const q = untrack(() => props.query()) + if (timer !== null) { + clearTimeout(timer) + timer = null + } + setLocal(q) + }) + const chips = createMemo(() => { + const c = props.counts() + return [ + { label: 'Running', key: 'running', value: c.running }, + { label: 'Completed', key: 'completed', value: c.completed }, + { label: 'Cancelled', key: 'cancelled', value: c.cancelled }, + { label: 'Errored', key: 'errored', value: c.errored }, + { label: 'Stalled', key: 'stalled', value: c.stalled }, + ] + }) + return html`
+ ${() => chips().map(({ label, key, value }) => { + const active = props.statuses().has(key) const cls = `badge badge-filter${active ? ' badge-active' : ''}` - return html` props.onToggle(key!)}>${label}: ${value}` + return html` props.onToggleStatus(key)}>${label}: ${value}` })} + +
` } -// ── SearchInput ─────────────────────────────────────────────────────────── - -export function SearchInput(props: { onInput: (e: Event) => void }) { - return html`` -} - // ── Timestamp ───────────────────────────────────────────────────────────── export function Timestamp(props: { generatedAt: number }) { return html`
Last updated: ${new Date(props.generatedAt).toLocaleString()}
` } -// ── Sidebar ─────────────────────────────────────────────────────────────── - -export function Sidebar(props: { - entries: MatchedEntry[] - selectedProjectId: string | null - onSelect: (projectId: string | null, loopName: string | null) => void +export function RepoMenu(props: { + entries: () => MatchedEntry[] + labels: () => Map + onSelect: (projectId: string) => void }) { - return html`
- ${props.entries.map((entry: MatchedEntry) => { - const isSelected = entry.proj.projectId === props.selectedProjectId + 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 = deriveSidebarLabel(rawPath) - const cls = `project-nav-item${isSelected ? ' selected' : ''}` - return html`
props.onSelect(entry.proj.projectId, null)}> - ${hasRunning ? html`` : ''} - ${label} - ${entry.loops.length} + const label = props.labels().get(rawPath) ?? rawPath + return html`
props.onSelect(entry.proj.projectId)}> + ${hasRunning ? html`` : ''} + ${label} + ${entry.loops.length} +
` + }), + ) + return html`
${() => items()}
` +} + +type RunningCard = { projectId: string; label: string; loopName: string; phase: string } +type RecentRow = { projectId: string; label: string; loopName: string; status: string; when: number } + +function deriveRepoIndex(entries: MatchedEntry[], labels: Map): { + runningCount: number + loopCount: number + bugCount: number + runningCards: RunningCard[] + recentRows: RecentRow[] +} { + let runningCount = 0 + let loopCount = 0 + let bugCount = 0 + 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 + for (const dl of entry.loops) { + loopCount++ + for (const f of dl.findings) if (f.severity === 'bug') bugCount++ + const status = dl.loop.status + const when = dl.loop.completedAt || dl.loop.startedAt || 0 + if (status === 'running') { + runningCount++ + runningCards.push({ projectId: entry.proj.projectId, label, loopName: dl.loop.loopName, phase: dl.loop.phase || '' }) + } + recent.push({ projectId: entry.proj.projectId, label, loopName: dl.loop.loopName, status, when }) + } + } + recent.sort((a, b) => b.when - a.when) + return { runningCount, loopCount, bugCount, runningCards, recentRows: recent.slice(0, 8) } +} + +export function RepoIndexPane(props: { + entries: () => MatchedEntry[] + labels: () => Map + onOpenLoop: (projectId: string, loopName: string) => void +}) { + const view = createMemo(() => deriveRepoIndex(props.entries(), props.labels())) + const runningCount = createMemo(() => view().runningCount) + const loopCount = createMemo(() => view().loopCount) + const bugCount = createMemo(() => view().bugCount) + const runningCards = createMemo(() => view().runningCards) + const recentRows = createMemo(() => view().recentRows) + return html`
+
+

All repositories

+
${() => `${runningCount()} running · ${loopCount()} loops · ${bugCount()} open bugs`}
+
+ ${() => (runningCards().length > 0 + ? html`
+

Running

+
+ ${runningCards().map(c => html`
props.onOpenLoop(c.projectId, c.loopName)}> + + ${c.label} + ${c.loopName} + ${c.phase} +
`)} +
+
` + : '')} + ${() => (recentRows().length > 0 + ? html`
+

Recent activity

+
+ ${recentRows().map(r => html`
props.onOpenLoop(r.projectId, r.loopName)}> + 'status-badge status-' + r.status}>${r.status} + ${r.label} + ${r.loopName} + ${fmtTime(r.when)} +
`)} +
+
` + : '')} +
` +} + +export type LoopOption = { name: string; when: number } + +export type LoopNav = { index: number; total: number; onPrev: () => void; onNext: () => void } + +function LoopPicker(props: { + loopName: string + loops: () => LoopOption[] + onSelect: (name: string) => void +}) { + const [open, setOpen] = createSignal(false) + const [query, setQuery] = createSignal('') + const [active, setActive] = createSignal(0) + + const matches = createMemo(() => { + const q = query().trim().toLowerCase() + const all = props.loops() + return q ? all.filter(o => o.name.toLowerCase().indexOf(q) !== -1) : all + }) + + const openMenu = () => { + if (open()) return + setQuery('') + const idx = props.loops().findIndex(o => o.name === props.loopName) + setActive(idx < 0 ? 0 : idx) + setOpen(true) + } + const close = () => { + setOpen(false) + setQuery('') + setActive(0) + } + const commit = (name: string) => { + close() + input.blur() + if (name !== props.loopName) props.onSelect(name) + } + + const menu = html`
(open() ? 'display:block' : 'display:none')} + onmousedown=${(e: MouseEvent) => e.preventDefault()} + > + ${() => matches().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)} +
`)} + ${() => (matches().length === 0 ? html`
No matching loops
` : '')} +
` as HTMLElement + + const scrollActiveIntoView = () => { + const el = menu.querySelector('.loop-picker-option-active') as HTMLElement | null + el?.scrollIntoView?.({ block: 'nearest' }) + } + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault() + if (!open()) { + openMenu() + return + } + const total = matches().length + if (total === 0) return + const delta = e.key === 'ArrowDown' ? 1 : -1 + setActive(i => (i + delta + total) % total) + scrollActiveIntoView() + return + } + if (e.key === 'Enter') { + e.preventDefault() + const target = matches()[active()] + if (target) commit(target.name) + return + } + if (e.key === 'Escape') { + e.preventDefault() + close() + input.blur() + } + } + + const input = html` (open() ? query() : props.loopName)} + onfocus=${openMenu} + oninput=${(e: Event) => { + setQuery((e.target as HTMLInputElement).value) + setActive(0) + setOpen(true) + }} + onkeydown=${onKeyDown} + onblur=${close} + />` as HTMLInputElement + + return html` + ${input} + { + e.preventDefault() + if (open()) { + close() + input.blur() + } else { + input.focus() + } + }}>▾ + ${menu} + ` +} + +export function Breadcrumb(props: { + repoLabel: string + projectDir: string + loopName: string | null + loops: () => LoopOption[] + loopNav: () => LoopNav | null + onSelectLoop: (name: string) => void + onBackToRepos: () => void + onBackToRepo: () => void +}) { + if (props.loopName) { + return html`` + } + return html`` +} + +export function SectionNav(props: { + section: RepoSection + counts: { loops: number; groups: number; findings: number; plans: number } + onNavigate: (section: RepoSection) => void +}) { + const items: { key: RepoSection; label: string; count: number }[] = [ + { key: 'loops', label: 'Loops', count: props.counts.loops }, + { key: 'groups', label: 'Groups', count: props.counts.groups }, + { key: 'findings', label: 'Findings', count: props.counts.findings }, + { key: 'plans', label: 'Plans', count: props.counts.plans }, + ] + return html`
+ ${items.map(item => { + const active = item.key === props.section + const cls = `section-nav-item${active ? ' section-nav-active' : ''}` + return html`
props.onNavigate(item.key)}> + ${item.label} + ${item.count}
` })}
` } + // ── LoopTable ───────────────────────────────────────────────────────────── function MiniMeter(props: { current: () => number; total: () => number }) { @@ -120,26 +412,65 @@ function MiniMeter(props: { current: () => number; total: () => number }) { ` } -export function LoopTable(props: { loops: DashboardLoop[]; onOpen: (name: string) => void }) { +type PhaseBarVariant = 'sm' | 'md' | 'lg' + +const PHASE_BAR_HEIGHTS: Record = { sm: 4, md: 14, lg: 22 } + +function PhaseBar(props: { + spans: () => PhaseSpan[] + variant: PhaseBarVariant + now: () => number +}) { + const height = PHASE_BAR_HEIGHTS[props.variant] + const visible = createMemo(() => props.spans().filter(s => s.phase !== '')) + const total = createMemo(() => Math.max(0, visible().reduce((acc, s) => acc + s.durationMs, 0))) + const truncated = createMemo(() => props.spans().some(s => s.phase === '')) + return html`
'phase-bar phase-bar-' + props.variant + (truncated() ? ' phase-bar-trunc' : '')} data-truncated=${() => (truncated() ? 'true' : 'false')} style=${() => 'height:' + height + 'px'}> + ${() => (truncated() ? html`\u22ef` : '')} + ${() => { + const s = visible() + const tot = total() + return s.map(sp => { + const pct = tot > 0 ? (sp.durationMs / tot) * 100 : 0 + const cls = 'phase-seg' + (sp.open ? ' phase-seg-open' : '') + return html`
` + }) + }} +
` +} + +function loopPhaseSpans(loop: DashboardLoop, now: number): PhaseSpan[] { + return computePhaseSpans( + loop.transitions ?? [], + loop.loop.startedAt, + loop.loop.completedAt, + now, + loop.loop.phase, + ).spans +} + +export function LoopTable(props: { loops: () => DashboardLoop[]; now: () => number; onOpen: (name: string) => void }) { return html` - + - ${props.loops.map((dl: DashboardLoop) => LoopTableRow({ dashLoop: dl, onOpen: props.onOpen }))} + ${() => props.loops().map((dl: DashboardLoop) => LoopTableRow({ dashLoop: dl, now: props.now, onOpen: props.onOpen }))}
StatusLoopPhaseIterSectionsStatusLoopPhasePhase spanIterSections FindingsCostDurationUpdated
` } -function LoopTableRow(props: { dashLoop: DashboardLoop; onOpen: (name: string) => void }) { +function LoopTableRow(props: { dashLoop: DashboardLoop; now: () => number; onOpen: (name: string) => void }) { const lp = () => props.dashLoop.loop const dl = () => props.dashLoop const counts = createMemo(() => splitFindings(dl().findings)) + const spans = createMemo(() => loopPhaseSpans(dl(), props.now())) return html` props.onOpen(props.dashLoop.loop.loopName)}> statusClass(lp().status)}>${() => lp().status} ${() => lp().loopName} ${() => lp().phase} + ${() => PhaseBar({ spans, variant: 'sm', now: props.now })} ${MiniMeter({ current: () => lp().iteration, total: () => lp().maxIterations })} ${() => (lp().totalSections > 0 ? MiniMeter({ current: () => lp().currentSectionIndex, total: () => lp().totalSections }) @@ -164,13 +495,14 @@ function LoopTableRow(props: { dashLoop: DashboardLoop; onOpen: (name: string) = // scroll box persists across polls and only innerHTML updates in place. The // single root element is required — a template that is only `${...}` generates // invalid code in solid-js/html. -export function MarkdownSection(props: { label: string; src: () => string | null | undefined }) { +function MarkdownSection(props: { label: string | (() => string); src: () => string | null | undefined }) { + const label = () => (typeof props.label === 'function' ? props.label() : props.label) return html`
- +
- ${() => (hasUsage() ? LoopUsage({ usage: () => props.dashLoop.usage! }) : '')} - ${() => hasReason() ? html`
'ldh-banner ldh-banner-' + lp().status}>${() => lp().terminationReason}
` @@ -369,15 +699,34 @@ export function LoopDetailHeader(props: { // per-model cost bars. The full precise numbers remain available as hover // tooltips via the existing format helpers. Usage has no scroll/resize state // to preserve, so re-mapping on poll updates is fine. -export function LoopUsage(props: { usage: () => NonNullable }) { +function LoopUsage(props: { + usage: () => NonNullable +}) { const u = () => props.usage() const segments = createMemo(() => tokenBreakdownSegments(u())) const models = createMemo(() => modelUsageBars(u())) const hasModels = createMemo(() => models().length > 0) + const roles = createMemo(() => roleUsageBars(u())) return html`

Usage

+
+
Role split
+
+ ${() => roles().map(r => html`
r.role}> +
+ r.role}>${r.role} + ${() => formatUsageCost(r.cost)} +
+
+
'width:' + r.pct + '%'}>
+
+
${() => r.messageCount + ' msg'}
+
`)} +
+
+
Token composition
@@ -484,7 +833,7 @@ function AmendmentRow(props: { // Renders an "Plan amendments" panel only when the loop has amendments (gate // via boolean memo so the block's identity persists across polls). -export function AmendmentsPanel(props: { +function AmendmentsPanel(props: { amendments: () => NonNullable }) { const list = () => props.amendments() @@ -502,70 +851,294 @@ export function AmendmentsPanel(props: {
` } -// ── LoopDetail ──────────────────────────────────────────────────────────── +const ALL_TABS: LoopTab[] = ['overview', 'timeline', 'sections', 'findings', 'plan', 'usage'] + +const TAB_LABELS: Record = { + overview: 'Overview', + timeline: 'Timeline', + sections: 'Sections', + findings: 'Findings', + plan: 'Plan', + usage: 'Usage', +} + +function TabBar(props: { + tabs: () => LoopTab[] + activeTab: () => LoopTab + onSelect: (t: LoopTab) => void +}) { + return html`
+ ${() => props.tabs().map((t: LoopTab) => { + const label = TAB_LABELS[t] + const cls = () => 'tab-item' + (props.activeTab() === t ? ' tab-active' : '') + return html` props.onSelect(t)}>${label}` + })} +
` +} + +function OverviewMetaCell(props: { label: string; value: () => string | null | undefined }) { + return html`
+ ${props.label} + ${() => props.value() ?? '—'} +
` +} -export function LoopDetail(props: { dashLoop: DashboardLoop; onBack: () => void }) { +function OverviewTabBody(props: { dashLoop: DashboardLoop; split: () => Split; now: () => number }) { const dl = () => props.dashLoop - const split = createMemo(() => splitFindings(dl().findings)) - // Boolean memos gate each group: they only flip when a group appears or - // disappears, so unchanged groups (and their scroll/resize state) are not - // rebuilt when the loop's data updates on a poll. - const hasCompletion = createMemo(() => !!dl().loop.completionSummary) - const hasPostActionReport = createMemo(() => !!dl().postActionReport) - const hasAudit = createMemo(() => !!dl().lastAuditResult) - const hasPlan = createMemo(() => !!dl().plan) - const hasSections = createMemo(() => !!dl().sections && dl().sections!.length > 0) - const hasFindings = createMemo(() => !!dl().findings && dl().findings.length > 0) + const lp = () => dl().loop + const split = () => props.split() + const hasGoal = createMemo(() => !!dl().goal) const hasBugs = createMemo(() => split().bugs.length > 0) const hasWarnings = createMemo(() => split().warnings.length > 0) - const hasAmendments = createMemo(() => !!dl().amendments && dl().amendments!.length > 0) - - return html`
- -
- ← Back to loops + const hasFindings = createMemo(() => hasBugs() || hasWarnings()) + const reportSrc = createMemo(() => dl().postActionReport ?? lp().completionSummary ?? null) + const reportLabel = createMemo(() => dl().postActionReport ? 'Post-Action Report' : 'Completion Summary') + const hasReport = createMemo(() => !!reportSrc()) + const hasAudit = createMemo(() => !!dl().lastAuditResult) + const phaseState = createMemo(() => + computePhaseSpans(dl().transitions ?? [], lp().startedAt, lp().completedAt, props.now(), lp().phase), + ) + return html`
+ ${() => PhaseBar({ spans: () => phaseState().spans, variant: 'md', now: props.now })} + ${() => (hasGoal() ? MarkdownSection({ label: 'Goal', src: () => dl().goal }) : '')} + ${LoopDetailHeader({ dashLoop: props.dashLoop, split: props.split })} +
+ ${OverviewMetaCell({ label: 'Execution model', value: () => lp().executionModel })} + ${OverviewMetaCell({ label: 'Auditor model', value: () => lp().auditorModel })} + ${OverviewMetaCell({ label: 'Branch', value: () => lp().worktreeBranch })} + ${OverviewMetaCell({ label: 'Sandbox', value: () => (lp().sandbox ? 'on' : 'off') })} + ${OverviewMetaCell({ label: 'Kind', value: () => lp().kind })} + ${OverviewMetaCell({ label: 'Audits', value: () => String(lp().auditCount) })} + ${OverviewMetaCell({ label: 'Errors', value: () => String(lp().errorCount) })}
+
+ ${() => (hasFindings() + ? html`
+ ${() => (hasBugs() ? html`${() => formatFindingCount(split().bugs.length, 'bug')}` : '')} + ${() => (hasWarnings() ? html`${() => formatFindingCount(split().warnings.length, 'warning')}` : '')} +
` + : html`No findings`)} +
+ ${() => (hasReport() ? MarkdownSection({ label: reportLabel, src: () => reportSrc() }) : '')} + ${() => (hasAudit() ? MarkdownSection({ label: 'Last Audit Result', src: () => dl().lastAuditResult }) : '')} +
` +} - - ${LoopDetailHeader({ dashLoop: props.dashLoop, split })} +function TimelineEventRow(props: { + ev: { transition: NonNullable[number]; elapsedMs: number | null } +}) { + const t = props.ev.transition + const elapsedMs = props.ev.elapsedMs + const elapsedSec = elapsedMs === null ? null : Math.floor(elapsedMs / 1000) + const flowTarget = t.toPhase === null ? (t.status ?? '') : t.toPhase + return html`
+ ${formatRelativeTime(t.createdAt)} + ${t.fromPhase} → ${flowTarget} + ${t.transitionKind} + iter ${t.iteration} + ${() => (t.sectionIndex === null ? '—' : 'sect ' + (t.sectionIndex + 1))} + ${() => (elapsedSec === null ? '—' : elapsedSec > 0 ? formatDuration(elapsedSec) : '0s')} +
` +} - - ${LoopMachineGraph({ loop: () => dl().loop, transitions: () => dl().transitions ?? [] })} +function TimelineTabBody(props: { dashLoop: DashboardLoop; now: () => number }) { + const dl = () => props.dashLoop + const lp = () => dl().loop + const transitions = () => dl().transitions ?? [] + const phaseState = createMemo(() => + computePhaseSpans(transitions(), lp().startedAt, lp().completedAt, props.now(), lp().phase), + ) + const spans = createMemo(() => phaseState().spans) + const truncated = createMemo(() => phaseState().truncated) + const totals = createMemo(() => summarizePhaseTotals(spans())) + const totalMs = createMemo(() => spans().filter(s => s.phase !== '').reduce((acc, s) => acc + s.durationMs, 0)) - -
- - ${() => (hasPostActionReport() ? MarkdownSection({ label: 'Post-Action Report', src: () => dl().postActionReport }) : '')} - ${() => (!hasPostActionReport() && hasCompletion() ? MarkdownSection({ label: 'Completion Summary', src: () => dl().loop.completionSummary }) : '')} + const sortedPhases = createMemo(() => { + const t = totals() + return Object.keys(t) + .filter(p => p !== '') + .sort((a, b) => t[b] - t[a]) + }) + + const eventsNewestFirst = createMemo(() => + computeTimelineEvents(transitions(), lp().startedAt, truncated()), + ) - - ${() => (hasSections() ? SectionsPanel({ sections: () => dl().sections! }) : '')} + const [showAll, setShowAll] = createSignal(false) + const visibleEvents = createMemo(() => { + const all = eventsNewestFirst() + return showAll() ? all : all.slice(0, 20) + }) + const hiddenCount = createMemo(() => (showAll() ? 0 : Math.max(0, eventsNewestFirst().length - 20))) + + return html`
+ ${() => PhaseBar({ spans, variant: 'lg', now: props.now })} + ${() => (truncated() + ? html`
Earlier history was truncated by the 100-row fetch window.
` + : '')} - +
${() => - hasFindings() - ? html`
-

${() => 'Findings (' + dl().findings.length + ')'}

-
- ${() => (hasBugs() ? html`
Bugs:
` : '')} - ${() => - split().bugs.map( - (f: DashboardLoop['findings'][number]) => - html`
${() => formatFinding(f)}
`, - )} - ${() => (hasWarnings() ? html`
Warnings:
` : '')} - ${() => - split().warnings.map( - (f: DashboardLoop['findings'][number]) => - html`
${() => formatFinding(f)}
`, - )} -
-
` - : ''} + sortedPhases().map(phase => { + const ms = totals()[phase] + const pct = totalMs() > 0 ? Math.round((ms / totalMs()) * 100) : 0 + return html`
+ + ${phase} + + ${() => (ms >= 1000 ? formatDuration(Math.floor(ms / 1000)) : ms + 'ms')} · ${pct}% +
` + })} +
- ${() => (hasAudit() ? MarkdownSection({ label: 'Last Audit Result', src: () => dl().lastAuditResult }) : '')} - ${() => (hasPlan() ? MarkdownSection({ label: 'Plan', src: () => dl().plan }) : '')} - ${() => (hasAmendments() ? AmendmentsPanel({ amendments: () => dl().amendments! }) : '')} +
+ ${() => visibleEvents().map(ev => TimelineEventRow({ ev }))} + ${() => (hiddenCount() > 0 + ? html`
setShowAll(true)}>[ ${() => hiddenCount()} earlier events ]
` + : '')} +
+ +
+ State machine graph + ${LoopMachineGraph({ loop: () => lp(), transitions })} +
+
` +} + +function SectionsTabBody(props: { dashLoop: DashboardLoop }) { + return SectionsPanel({ sections: () => props.dashLoop.sections }) +} + +type Split = { bugs: DashboardLoop['findings']; warnings: DashboardLoop['findings'] } + +function groupFindingsBySection(findings: DashboardLoop['findings']): Array<{ key: number | null; label: string; rows: DashboardLoop['findings'] }> { + const order: (number | null)[] = [] + const map = new Map() + for (const f of findings) { + const k = f.sectionIndex ?? null + if (!map.has(k)) { + map.set(k, []) + order.push(k) + } + map.get(k)!.push(f) + } + order.sort((a, b) => { + if (a === null && b === null) return 0 + if (a === null) return 1 + if (b === null) return -1 + return a - b + }) + return order.map(k => ({ + key: k, + label: k === null ? 'cross-section' : 'sect ' + (k + 1), + rows: map.get(k)!, + })) +} + +function FindingsTabBody(props: { dashLoop: DashboardLoop }) { + const dl = () => props.dashLoop + const hasFindings = createMemo(() => dl().findings.length > 0) + const groups = createMemo(() => groupFindingsBySection(dl().findings)) + return html`
+ ${() => (hasFindings() + ? groups().map(g => html`
g.key === null ? 'cross' : String(g.key)}> +
${g.label}
+
+ ${g.rows.map(f => html`
'finding finding-' + f.severity}> + ${() => formatRelativeTime(f.createdAt)} + ${() => formatFinding(f)} +
`)} +
+
`) + : html`
No findings recorded for this loop.
`)} +
` +} + +function PlanTabBody(props: { dashLoop: DashboardLoop }) { + const dl = () => props.dashLoop + const hasPlan = createMemo(() => !!dl().plan) + const hasAmendments = createMemo(() => !!dl().amendments && dl().amendments.length > 0) + return html`
+ ${() => (hasPlan() + ? MarkdownSection({ label: 'Plan', src: () => dl().plan }) + : html`
No plan recorded for this loop.
`)} + ${() => (hasAmendments() ? AmendmentsPanel({ amendments: () => dl().amendments! }) : '')} +
` +} + +function UsageTabBody(props: { dashLoop: DashboardLoop }) { + const dl = () => props.dashLoop + const hasUsage = createMemo(() => !!dl().usage) + return html`
+ ${() => (hasUsage() + ? LoopUsage({ + usage: () => dl().usage!, + }) + : html`
No usage recorded for this loop.
`)} +
` +} + +function TabBody(props: { + tab: LoopTab + dashLoop: DashboardLoop + split: () => Split + now: () => number + tabs: () => LoopTab[] + 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} +
` +} + +function LoopDetailTabs(props: { + dashLoop: DashboardLoop + split: () => Split + now: () => number + tabs: () => LoopTab[] + activeTab: () => LoopTab +}) { + return html`
+ ${ALL_TABS.map((t: LoopTab) => TabBody({ + tab: t, + dashLoop: props.dashLoop, + split: props.split, + now: props.now, + tabs: props.tabs, + activeTab: props.activeTab, + }))} +
` +} + +export function LoopDetail(props: { + dashLoop: DashboardLoop + now: () => number + routeTab: () => LoopTab + onSelectTab: (t: LoopTab) => void +}) { + const dl = () => props.dashLoop + const split = createMemo(() => splitFindings(dl().findings)) + const tabs = createMemo(() => tabsForLoop(dl())) + const activeTab = createMemo(() => { + const t = props.routeTab() + return tabs().includes(t) ? t : 'overview' + }) + + return html`
+
+ ${TabBar({ tabs, activeTab, onSelect: props.onSelectTab })} + ${LoopDetailTabs({ dashLoop: props.dashLoop, split, now: props.now, tabs, activeTab })}
` } @@ -575,3 +1148,176 @@ export function LoopDetail(props: { dashLoop: DashboardLoop; onBack: () => void export function EmptyState() { return html`
No loops match the current filters.
` } + +const TERMINAL_GROUP_STATUSES = new Set(['completed', 'cancelled', 'errored']) + +function groupFeaturesMeter(features: GroupFeatureRow[]): { completed: number; total: number } { + const total = features.length + const completed = features.filter(f => f.stage === 'completed').length + return { completed, total } +} + +function groupRowClass(status: string): string { + const terminal = TERMINAL_GROUP_STATUSES.has(status) + return 'group-row' + (terminal ? ' group-row-terminal' : ' group-row-active') +} + +export function GroupsPanel(props: { + groups: () => DashboardGroup[] + onOpen: (groupId: string) => void +}) { + return html`
+

Groups

+ ${() => { + const groups = props.groups() + if (groups.length === 0) return html`
No feature groups for this repo.
` + return html`
+ ${groups.map(g => { + const gr = g.group + return html`
groupRowClass(gr.status)} data-group-status=${() => gr.status} onclick=${() => props.onOpen(gr.groupId)}> + statusClass(gr.status)}>${() => gr.status} + ${() => gr.title} + + 'width:' + clampPercent(groupFeaturesMeter(g.features).completed, groupFeaturesMeter(g.features).total) + '%'}> + ${() => { const m = groupFeaturesMeter(g.features); return m.completed + '/' + m.total }} + + max ${() => gr.maxConcurrent} + + Created ${() => fmtTime(gr.createdAt)} + ${() => (gr.completedAt ? html`Completed ${() => fmtTime(gr.completedAt)}` : '')} + +
` + })} +
` + }} +
` +} + +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 + projectId: () => string | null + onBack: () => void + onOpenLoop: (loopName: string) => void +}) { + return html`
+
← Back to groups
+ ${() => { + const dg = props.group() + if (!dg) return html`
Group not found.
` + const gr = dg.group + return html`
groupHeaderClass(gr.status)} data-group-status=${() => gr.status}> +
+ statusClass(gr.status)}>${() => gr.status} +

${() => gr.title}

+ max ${() => gr.maxConcurrent} +
+
+ ${() => { const m = groupFeaturesMeter(dg.features); return 'Features ' + m.completed + '/' + m.total }} + Created ${() => fmtTime(gr.createdAt)} + ${() => (gr.completedAt ? html`Completed ${() => fmtTime(gr.completedAt)}` : '')} + ${() => (gr.error ? html`${() => gr.error}` : '')} +
+ ${() => (gr.prdPreview ? html`
+
+
${() => gr.prdPreview || ''}
+
` : '')} +
+
+ ${dg.features.map(f => { + return html`
f.stage}> + #${() => f.featureIndex} + ${() => f.title} + sectionStatusClass(f.stage)}>${() => f.stage} + ${() => f.attempts} attempts + ${() => (!!f.loopName && props.loopNames().has(f.loopName) + ? html` buildDashboardHash({ projectId: props.projectId(), section: 'loops', loopName: f.loopName, tab: 'overview', groupId: null, statuses: [], query: '' })} onclick=${(e: MouseEvent) => { + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return + e.preventDefault() + props.onOpenLoop(f.loopName!) + }}>${() => f.loopName}` + : '')} + ${() => (f.error ? html`${() => f.error}` : '')} +
` + })} +
` + }} +
` +} + +function findingsGroupsForLoops(loops: DashboardLoop[]): Array<{ loopName: string; rows: DashboardLoop['findings'] }> { + const order: string[] = [] + const map = new Map() + 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) + } + return order.map(name => ({ loopName: name, rows: map.get(name)! })) +} + +export function FindingsPanel(props: { + loops: () => DashboardLoop[] + 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)) + return html`
+

Findings ${() => total()}

+ ${() => { + const gs = groups() + if (gs.length === 0) return html`
No findings recorded for this repo.
` + return html`` + }} +
` +} + +export function PlansPanel(props: { + loops: () => DashboardLoop[] + onOpenLoop: (loopName: string) => void +}) { + const planLoops = createMemo(() => props.loops().filter(dl => !!dl.plan)) + return html`
+

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

+ ${() => { + const list = planLoops() + if (list.length === 0) return html`
No plans recorded for this repo.
` + return html`
+ ${list.map(dl => html`
props.onOpenLoop(dl.loop.loopName)}> + statusClass(dl.loop.status)}>${() => dl.loop.status} + ${() => dl.loop.loopName} + + ${() => dl.findings.length + ' finding' + (dl.findings.length === 1 ? '' : 's')} + + iter ${() => dl.loop.iteration}/${() => dl.loop.maxIterations} +
`)} +
` + }} +
` +} diff --git a/src/dashboard/app/helpers.ts b/src/dashboard/app/helpers.ts index 19a852f0ec..f9691a7eeb 100644 --- a/src/dashboard/app/helpers.ts +++ b/src/dashboard/app/helpers.ts @@ -1,25 +1,136 @@ -import type { DashboardPayload, DashboardProject, DashboardLoop } from './types' +import type { DashboardPayload, DashboardLoop, LoopTransitionRow } from './types' import { formatDuration, computeElapsedSeconds } from '../../utils/duration' -export function parseLoopHash(hash: string): { projectId: string | null; loopName: string | null } { - const out = { projectId: null as string | null, loopName: null as string | null } +export type LoopTab = 'overview' | 'timeline' | 'sections' | 'findings' | 'plan' | 'usage' +export type RepoSection = 'loops' | 'groups' | 'findings' | 'plans' + +export interface DashboardRoute { + projectId: string | null + section: RepoSection + loopName: string | null + tab: LoopTab + groupId: string | null + statuses: string[] + query: string +} + +const SECTION_KEYWORDS: ReadonlySet = new Set(['loops', 'groups', 'findings', 'plans']) +const LOOP_TABS: ReadonlySet = new Set(['overview', 'timeline', 'sections', 'findings', 'plan', 'usage']) + +function safeDecode(s: string): string { + try { + return decodeURIComponent(s) + } catch { + return s + } +} + +function emptyRoute(): DashboardRoute { + return { + projectId: null, + section: 'loops', + loopName: null, + tab: 'overview', + groupId: null, + statuses: [], + query: '', + } +} + +function applyQuery(route: DashboardRoute, queryPart: string): void { + if (!queryPart) return + for (const pair of queryPart.split('&')) { + if (!pair) continue + const eq = pair.indexOf('=') + if (eq === -1) continue + const key = safeDecode(pair.slice(0, eq)) + const val = safeDecode(pair.slice(eq + 1)) + if (key === 'status') { + route.statuses = val.split(',').map(s => s.trim()).filter(s => s !== '') + } else if (key === 'q') { + route.query = val + } + } +} + +export function parseDashboardHash(hash: string): DashboardRoute { + const route = emptyRoute() const raw = (hash || '').replace(/^#/, '') - if (!raw) return out - const slash = raw.indexOf('/') + if (!raw) return route + + const qIdx = raw.indexOf('?') + const pathPart = qIdx === -1 ? raw : raw.slice(0, qIdx) + const queryPart = qIdx === -1 ? '' : raw.slice(qIdx + 1) + + const slash = pathPart.indexOf('/') + let projectIdEnc: string + let rest: string if (slash === -1) { - out.projectId = decodeURIComponent(raw) - return out + projectIdEnc = pathPart + rest = '' + } else { + projectIdEnc = pathPart.slice(0, slash) + rest = pathPart.slice(slash + 1) } - out.projectId = decodeURIComponent(raw.slice(0, slash)) - const lp = raw.slice(slash + 1) - out.loopName = lp ? decodeURIComponent(lp) : null - return out + if (projectIdEnc) route.projectId = safeDecode(projectIdEnc) + if (!rest) { + applyQuery(route, queryPart) + return route + } + + const segs = rest.split('/').filter(s => s !== '') + if (segs.length === 0) { + applyQuery(route, queryPart) + return route + } + + const first = safeDecode(segs[0]) + if (first === 'loop') { + if (segs.length >= 2) { + route.loopName = safeDecode(segs[1]) + if (segs.length >= 3) { + const tab = safeDecode(segs[2]) + if (LOOP_TABS.has(tab)) route.tab = tab as LoopTab + } + } + } else if (SECTION_KEYWORDS.has(first)) { + route.section = first as RepoSection + if (first === 'groups' && segs.length >= 2) { + route.groupId = safeDecode(segs[1]) + } + } else { + route.loopName = first + } + + applyQuery(route, queryPart) + return route } -export function buildLoopHash(projectId: string | null, loopName: string | null): string { - if (!projectId) return '' - let h = '#' + encodeURIComponent(projectId) - if (loopName) h += '/' + encodeURIComponent(loopName) +export function buildDashboardHash(route: DashboardRoute): string { + if (!route.projectId) return '' + let h = '#' + encodeURIComponent(route.projectId) + + if (route.section === 'loops') { + if (route.loopName) { + h += '/loop/' + encodeURIComponent(route.loopName) + if (route.tab !== 'overview') h += '/' + route.tab + } + } else if (route.section === 'groups') { + h += '/groups' + if (route.groupId) h += '/' + encodeURIComponent(route.groupId) + } else { + h += '/' + route.section + } + + const params: string[] = [] + if (route.statuses.length > 0) { + params.push('status=' + route.statuses.map(s => encodeURIComponent(s)).join(',')) + } + if (route.query) { + params.push('q=' + encodeURIComponent(route.query)) + } + if (params.length > 0) h += '?' + params.join('&') + return h } @@ -60,17 +171,59 @@ export function sectionStatusClass(s: string): string { return 'section-status section-' + s } +export type SortMode = 'recent' | 'cost' | 'duration' | 'findings' + export function loopMatchesFilters( loop: DashboardLoop['loop'], - project: DashboardProject, - activeStatuses: Set, + statuses: Set, searchText: string, + repoLabel: string, ): boolean { - const statusOk = activeStatuses.size === 0 || activeStatuses.has(loop.status) + const statusOk = statuses.size === 0 || statuses.has(loop.status) if (!statusOk) return false if (!searchText) return true - const hay = ((loop.loopName || '') + ' ' + (project.projectDir || project.projectId || '')).toLowerCase() - return hay.indexOf(searchText) !== -1 + const q = searchText.toLowerCase() + const branch = loop.worktreeBranch ?? '' + const hay = ( + (loop.loopName || '') + ' ' + branch + ' ' + (repoLabel || '') + ).toLowerCase() + return hay.indexOf(q) !== -1 +} + +function parseDurationSeconds(s: string): number { + if (!s) return 0 + let total = 0 + const h = s.match(/(\d+)\s*h/i) + if (h) total += parseInt(h[1], 10) * 3600 + const m = s.match(/(\d+)\s*m(?!s)/i) + if (m) total += parseInt(m[1], 10) * 60 + const sec = s.match(/(\d+)\s*s/i) + if (sec) total += parseInt(sec[1], 10) + return total +} + +export function sortLoops(loops: DashboardLoop[], mode: SortMode): DashboardLoop[] { + if (mode === 'recent') return [...loops] + const sorted = [...loops] + if (mode === 'cost') { + sorted.sort((a, b) => { + const ac = a.usage?.totalCost ?? null + const bc = b.usage?.totalCost ?? null + if (ac === null && bc === null) return 0 + if (ac === null) return 1 + if (bc === null) return -1 + return bc - ac + }) + } else if (mode === 'duration') { + sorted.sort((a, b) => { + const ad = a.duration ? parseDurationSeconds(a.duration) : 0 + const bd = b.duration ? parseDurationSeconds(b.duration) : 0 + return bd - ad + }) + } else if (mode === 'findings') { + sorted.sort((a, b) => b.findings.length - a.findings.length) + } + return sorted } export function dataHash(data: DashboardPayload): string { @@ -80,9 +233,62 @@ export function dataHash(data: DashboardPayload): string { }) } -export function deriveSidebarLabel(rawPath: string): string { - const rawSegments = rawPath.split('/').filter(Boolean) - return rawSegments.length ? rawSegments[rawSegments.length - 1] : rawPath +export function buildRepoLabels(paths: string[]): Map { + const segsByPath = new Map() + for (const p of paths) { + if (p === '') { + segsByPath.set(p, []) + continue + } + const segs = p.split('/') + let end = segs.length + while (end > 0 && segs[end - 1] === '') end-- + segsByPath.set(p, end > 0 ? segs.slice(0, end) : [p]) + } + + const result = new Map() + for (const p of paths) { + const segs = segsByPath.get(p)! + result.set(p, segs.length ? segs[segs.length - 1] : p) + } + + const groups = new Map() + for (const p of paths) { + const label = result.get(p)! + const bucket = groups.get(label) + if (bucket) bucket.push(p) + else groups.set(label, [p]) + } + + for (const [, group] of groups) { + const unique = [...new Set(group)] + if (unique.length < 2) continue + + let depth = 1 + while (depth < 1024) { + const seen = new Set() + let allDistinct = true + let allExhausted = true + for (const p of unique) { + const segs = segsByPath.get(p)! + const start = Math.max(0, segs.length - depth) + const label = segs.slice(start).join('/') + if (seen.has(label)) allDistinct = false + else seen.add(label) + if (start > 0) allExhausted = false + } + if (allDistinct || allExhausted) break + depth++ + } + + for (const p of unique) { + const segs = segsByPath.get(p)! + const start = Math.max(0, segs.length - depth) + result.set(p, segs.slice(start).join('/')) + } + } + + return result } export function splitFindings( @@ -164,7 +370,7 @@ export function formatUsageCost(cost: number): string { return '$' + (cost >= 1 ? cost.toFixed(2) : cost.toFixed(4)) } -export interface UsageSegment { +interface UsageSegment { label: string value: number color: string @@ -177,11 +383,11 @@ export interface UsageSegment { */ export function tokenBreakdownSegments(u: NonNullable): UsageSegment[] { const raw: Omit[] = [ - { label: 'Input', value: u.totalInputTokens, color: '#1f6feb' }, - { label: 'Output', value: u.totalOutputTokens, color: '#3fb950' }, - { label: 'Reasoning', value: u.totalReasoningTokens, color: '#a371f7' }, - { label: 'Cache R', value: u.totalCacheReadTokens, color: '#d29922' }, - { label: 'Cache W', value: u.totalCacheWriteTokens, color: '#db61a2' }, + { label: 'Input', value: u.totalInputTokens, color: 'var(--seg-input)' }, + { label: 'Output', value: u.totalOutputTokens, color: 'var(--seg-output)' }, + { label: 'Reasoning', value: u.totalReasoningTokens, color: 'var(--seg-reasoning)' }, + { label: 'Cache R', value: u.totalCacheReadTokens, color: 'var(--seg-cache-read)' }, + { label: 'Cache W', value: u.totalCacheWriteTokens, color: 'var(--seg-cache-write)' }, ] const total = raw.reduce((sum, seg) => sum + Math.max(0, seg.value), 0) return raw.map(seg => ({ @@ -190,7 +396,7 @@ export function tokenBreakdownSegments(u: NonNullable): })) } -export interface ModelUsageBar { +interface ModelUsageBar { model: string cost: number inputTokens: number @@ -199,10 +405,31 @@ export interface ModelUsageBar { pct: number } -/** - * Per-model bars sorted by cost desc. `pct` is each model's cost relative to the - * most expensive model so the widest bar always fills the track. - */ +interface RoleUsageBar { + role: 'execution' | 'auditor' + cost: number + messageCount: number + pct: number +} + +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 } + for (const [role, agg] of Object.entries(u.byRole ?? {})) { + if (role === 'auditor') { + audit.cost += agg.cost + audit.messageCount += agg.messageCount + } else if (role === 'code') { + exec.cost += agg.cost + exec.messageCount += agg.messageCount + } + } + const max = Math.max(exec.cost, audit.cost) + exec.pct = max > 0 ? (exec.cost / max) * 100 : 0 + audit.pct = max > 0 ? (audit.cost / max) * 100 : 0 + return [exec, audit] +} + export function modelUsageBars(u: NonNullable): ModelUsageBar[] { const entries = Object.keys(u.byModel).map(model => ({ model, ...u.byModel[model] })) const maxCost = entries.reduce((max, e) => Math.max(max, e.cost), 0) @@ -246,9 +473,6 @@ export function formatRelativeTime(ts: number | null | undefined): string { return fmtTime(ts) } -const markdownCache = new Map() -const MD_CACHE_MAX = 200 - export function renderMarkdown(src: string): string { if (!src) return '' const cached = markdownCache.get(src) @@ -266,3 +490,155 @@ export function renderMarkdown(src: string): string { } return result } + +const markdownCache = new Map() +const MD_CACHE_MAX = 200 + +export function tabsForLoop(loop: DashboardLoop): LoopTab[] { + const tabs: LoopTab[] = ['overview', 'timeline'] + if (loop.sections.length > 0) tabs.push('sections') + tabs.push('findings') + if (loop.plan) tabs.push('plan') + tabs.push('usage') + return tabs +} + +export interface PhaseSpan { + phase: string + startedAt: number + endedAt: number | null + durationMs: number + open: boolean +} + +export function computePhaseSpans( + transitions: LoopTransitionRow[], + startedAt: number, + completedAt: number | null, + now: number, + initialPhase: string = 'coding', +): { spans: PhaseSpan[]; truncated: boolean } { + const spans: PhaseSpan[] = [] + const firstCurrentIdx = transitions.findIndex(t => t.createdAt >= startedAt) + let currentTransitions = firstCurrentIdx === -1 ? [] : transitions.slice(firstCurrentIdx) + if (completedAt !== null) { + currentTransitions = currentTransitions.filter(t => t.createdAt <= completedAt) + } + + let currentPhase: string + if (currentTransitions.length > 0 && currentTransitions[0].eventType === 'restart' && currentTransitions[0].toPhase !== null) { + currentPhase = currentTransitions[0].toPhase + } else if (currentTransitions.length > 0 && currentTransitions[0].eventType !== 'restart') { + currentPhase = currentTransitions[0].fromPhase + } else { + currentPhase = initialPhase + } + let currentStart = startedAt + + for (const t of currentTransitions) { + if (t.eventType === 'restart') { + if (t.toPhase !== null) currentPhase = t.toPhase + continue + } + if (currentPhase === '') { + currentPhase = t.fromPhase + currentStart = Math.max(currentStart, t.createdAt) + } + const end = t.createdAt + const spanEnd = Math.max(currentStart, end) + spans.push({ + phase: currentPhase, + startedAt: currentStart, + endedAt: spanEnd, + durationMs: spanEnd - currentStart, + open: false, + }) + if (t.toPhase === null) { + currentPhase = '' + currentStart = spanEnd + } else { + currentPhase = t.toPhase + currentStart = spanEnd + } + } + + if (currentPhase !== '') { + const end = completedAt ?? now + const spanEnd = Math.max(currentStart, end) + spans.push({ + phase: currentPhase, + startedAt: currentStart, + endedAt: completedAt, + durationMs: spanEnd - currentStart, + open: completedAt === null, + }) + } + + const truncated = transitions.length > 0 + && transitions[0].createdAt >= startedAt + && transitions[0].createdAt > startedAt + 1000 + if (truncated && spans.length > 0) { + spans[0] = { ...spans[0], phase: '' } + } + return { spans, truncated } +} + +export function summarizePhaseTotals(spans: PhaseSpan[]): Record { + const out: Record = {} + for (const s of spans) { + if (!s.phase) continue + out[s.phase] = (out[s.phase] ?? 0) + s.durationMs + } + return out +} + +interface TimelineEvent { + transition: LoopTransitionRow + elapsedMs: number | null +} + +export function computeTimelineEvents( + transitions: LoopTransitionRow[], + startedAt: number, + truncated: boolean, +): TimelineEvent[] { + const out: TimelineEvent[] = [] + for (let i = 0; i < transitions.length; i++) { + const t = transitions[i] + let elapsedMs: number | null + if (i === 0) { + if (t.eventType === 'restart' || truncated || t.createdAt < startedAt) { + elapsedMs = null + } else { + elapsedMs = Math.max(0, t.createdAt - startedAt) + } + } else { + const prev = transitions[i - 1] + if (prev.eventType === 'restart' && prev.toPhase !== null && prev.toPhase === t.fromPhase) { + if (prev.createdAt >= startedAt) { + elapsedMs = Math.max(0, t.createdAt - startedAt) + } else if (t.createdAt < startedAt) { + elapsedMs = Math.max(0, t.createdAt - prev.createdAt) + } else { + elapsedMs = null + } + } else if (prev.toPhase !== null && prev.toPhase === t.fromPhase) { + elapsedMs = Math.max(0, t.createdAt - prev.createdAt) + } else if ( + t.eventType !== 'restart' + && prev.toPhase === null + && t.fromPhase !== null + && t.fromPhase === prev.fromPhase + && prev.createdAt < startedAt + && t.createdAt >= startedAt + ) { + elapsedMs = Math.max(0, t.createdAt - startedAt) + } else { + elapsedMs = null + } + } + out.push({ transition: t, elapsedMs }) + } + out.reverse() + return out +} diff --git a/src/dashboard/app/machine-graph.ts b/src/dashboard/app/machine-graph.ts index 30e198a429..517770298a 100644 --- a/src/dashboard/app/machine-graph.ts +++ b/src/dashboard/app/machine-graph.ts @@ -219,7 +219,7 @@ export function LoopMachineGraph(props: { - + diff --git a/src/dashboard/app/types.ts b/src/dashboard/app/types.ts index 99809181cc..d7dbb702dc 100644 --- a/src/dashboard/app/types.ts +++ b/src/dashboard/app/types.ts @@ -1,3 +1,3 @@ -import type { DashboardPayload, DashboardProject, DashboardLoop, DashboardTotals, LoopRow, LoopTransitionRow } from '../data' +import type { DashboardPayload, DashboardProject, DashboardLoop, DashboardGroup, LoopRow, LoopTransitionRow } from '../data' -export type { DashboardPayload, DashboardProject, DashboardLoop, DashboardTotals, LoopRow, LoopTransitionRow } +export type { DashboardPayload, DashboardProject, DashboardLoop, DashboardGroup, LoopRow, LoopTransitionRow } diff --git a/src/dashboard/data.ts b/src/dashboard/data.ts index 86063374e9..09846fc27f 100644 --- a/src/dashboard/data.ts +++ b/src/dashboard/data.ts @@ -7,6 +7,7 @@ import { createLoopSessionUsageRepo, createLoopTransitionsRepo, createPlanAmendmentsRepo, + createFeatureGroupsRepo, } from '../storage' import type { LoopRow } from '../storage' import type { SectionPlanRow } from '../storage' @@ -14,16 +15,26 @@ import type { ReviewFindingRow } from '../storage' import type { LoopUsageAggregate } from '../storage' import type { LoopTransitionRow } from '../storage' import type { PlanAmendmentRow } from '../storage' +import type { FeatureGroupRow, GroupFeatureRow } from '../storage' import { formatDuration, computeElapsedSeconds } from '../utils/loop-helpers' export type { LoopRow, LoopTransitionRow } +const PRD_PREVIEW_MAX = 400 + +export interface DashboardGroup { + id: string + group: Omit & { prdPreview: string | null } + features: GroupFeatureRow[] +} + export interface DashboardLoop { /** Stable identity for keyed store reconciliation (unique within a project's loop set). */ id: string loop: LoopRow lastAuditResult: string | null postActionReport: string | null + goal: string | null plan: string | null sections: SectionPlanRow[] findings: ReviewFindingRow[] @@ -39,6 +50,7 @@ export interface DashboardProject { projectId: string projectDir: string | null loops: DashboardLoop[] + groups: DashboardGroup[] } export interface DashboardTotals { @@ -75,6 +87,7 @@ interface DashboardRepos { loopSessionUsageRepo: ReturnType loopTransitionsRepo: ReturnType | null amendmentsRepo: ReturnType | null + featureGroupsRepo: ReturnType | null } // The dashboard poll handler calls collectDashboardData every 5s per open tab; @@ -95,6 +108,7 @@ function reposFor(db: Database): DashboardRepos { // Detect availability so the dashboard serves 200 with empty collections instead of failing. loopTransitionsRepo: hasTable(db, 'loop_transitions') ? createLoopTransitionsRepo(db) : null, amendmentsRepo: hasTable(db, 'plan_amendments') ? createPlanAmendmentsRepo(db) : null, + featureGroupsRepo: hasTable(db, 'feature_groups') ? createFeatureGroupsRepo(db) : null, } repoCache.set(db, repos) } @@ -116,13 +130,24 @@ function projectAmendmentSections(json: string): string { } export function collectDashboardData(db: Database): DashboardPayload { - const { loopsRepo, plansRepo, reviewFindingsRepo, sectionPlansRepo, loopSessionUsageRepo, loopTransitionsRepo, amendmentsRepo } = reposFor(db) + const { loopsRepo, plansRepo, reviewFindingsRepo, sectionPlansRepo, loopSessionUsageRepo, loopTransitionsRepo, amendmentsRepo, featureGroupsRepo } = reposFor(db) - const projectIdRows = db.prepare( + const loopProjectIds = db.prepare( 'SELECT DISTINCT project_id FROM loops ORDER BY project_id' ).all() as { project_id: string }[] - const projectIds = projectIdRows.map(r => r.project_id) + // A project may have a feature group persisted before any feature loop launches + // (group is in `extracting` or `planning`). Discover projects from the union of + // loop projects and feature-group projects so group-only projects still appear. + const groupProjectIds = featureGroupsRepo + ? (db.prepare( + 'SELECT DISTINCT project_id FROM feature_groups ORDER BY project_id' + ).all() as { project_id: string }[]) + : [] + + const projectIds = Array.from( + new Set([...loopProjectIds.map(r => r.project_id), ...groupProjectIds.map(r => r.project_id)]) + ).sort() const projects: DashboardProject[] = [] const totals: DashboardTotals = { projects: 0, @@ -154,6 +179,7 @@ export function collectDashboardData(db: Database): DashboardPayload { const large = loopsRepo.getLarge(projectId, loopName) 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 findings = reviewFindingsRepo.listByLoopName(projectId, loopName) @@ -169,10 +195,21 @@ export function collectDashboardData(db: Database): DashboardPayload { const elapsedSeconds = computeElapsedSeconds(loop.startedAt, loop.completedAt ?? undefined) const duration = elapsedSeconds > 0 ? formatDuration(elapsedSeconds) : null - return { id: loopName, loop, lastAuditResult, postActionReport, plan, sections, findings, usage, duration, transitions, amendments } + return { id: loopName, loop, lastAuditResult, postActionReport, goal, plan, sections, findings, usage, duration, transitions, amendments } }) - projects.push({ id: projectId, projectId, projectDir, loops: dashboardLoops }) + const groups: DashboardGroup[] = featureGroupsRepo + ? featureGroupsRepo.listGroups(projectId).map(g => { + const { prdText, ...rest } = g + return { + id: g.groupId, + group: { ...rest, prdPreview: prdText ? prdText.slice(0, PRD_PREVIEW_MAX) : null }, + features: featureGroupsRepo.listFeatures(projectId, g.groupId), + } + }) + : [] + + projects.push({ id: projectId, projectId, projectDir, loops: dashboardLoops, groups }) // Accumulate totals totals.projects = projectIds.length diff --git a/src/dashboard/render.ts b/src/dashboard/render.ts index ad6fcc8820..be9df63814 100644 --- a/src/dashboard/render.ts +++ b/src/dashboard/render.ts @@ -9,215 +9,376 @@ export function renderDashboardHtml(): string { Forge Dashboard diff --git a/src/storage/repos/loop-session-usage-repo.ts b/src/storage/repos/loop-session-usage-repo.ts index 222f9d94e3..90949f9cbd 100644 --- a/src/storage/repos/loop-session-usage-repo.ts +++ b/src/storage/repos/loop-session-usage-repo.ts @@ -34,6 +34,10 @@ export interface LoopUsageAggregate { cacheWriteTokens: number messageCount: number }> + byRole: Partial> } export interface LoopSessionUsageRepo { @@ -122,6 +126,16 @@ export function createLoopSessionUsageRepo(db: Database): LoopSessionUsageRepo { GROUP BY model `) + const getByRoleStmt = db.prepare(` + SELECT + role, + SUM(cost) as cost, + SUM(message_count) as message_count + FROM loop_session_usage + WHERE project_id = ? AND loop_name = ? + GROUP BY role + `) + const listSessionUsageStmt = db.prepare(` SELECT project_id, loop_name, session_id, role, model, cost, input_tokens, output_tokens, reasoning_tokens, @@ -209,6 +223,19 @@ export function createLoopSessionUsageRepo(db: Database): LoopSessionUsageRepo { } } + const byRoleRows = getByRoleStmt.all(projectId, loopName) as Array<{ + role: string + cost: number + message_count: number + }> + const byRole: LoopUsageAggregate['byRole'] = {} + for (const row of byRoleRows) { + byRole[row.role as LoopSessionUsageRow['role']] = { + cost: row.cost, + messageCount: row.message_count, + } + } + return { loopName: aggregate.loop_name, totalCost: aggregate.total_cost, @@ -219,6 +246,7 @@ export function createLoopSessionUsageRepo(db: Database): LoopSessionUsageRepo { totalCacheWriteTokens: aggregate.total_cache_write_tokens, totalMessageCount: aggregate.total_message_count, byModel, + byRole, } }, diff --git a/test/dashboard/app-dom.test.ts b/test/dashboard/app-dom.test.ts index 7589f9146e..5dcd872f12 100644 --- a/test/dashboard/app-dom.test.ts +++ b/test/dashboard/app-dom.test.ts @@ -2,6 +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' // --------------------------------------------------------------------------- // Payload builders (minimal runtime shape; tests are not typechecked by tsc) @@ -70,6 +71,8 @@ function makePayload(over: Record = {}): any { // for keyed reconcile. const loops = over.loops delete over.loops + const groups = over.groups ?? [] + delete over.groups return { generatedAt: Date.now(), projects: [ @@ -78,6 +81,7 @@ function makePayload(over: Record = {}): any { projectId: 'p1', projectDir: '/proj/p1', loops: loops ?? [makeLoop({ ...dashLoopOver, loop: loopOver })], + groups, }, ], totals: { @@ -341,12 +345,14 @@ describe('dashboard App fine-grained reactivity', () => { expect(legend!.textContent).toContain('Cache W') // Per-model bars sorted by cost desc; widest is the most expensive model - const fills = container.querySelectorAll('.usage-model-fill') + const modelBlock = Array.from(container.querySelectorAll('.usage-block')).find(b => + b.querySelector('.usage-block-title')?.textContent === 'Cost by model')! as HTMLElement + const fills = Array.from(modelBlock.querySelectorAll('.usage-model-fill')) as HTMLElement[] expect(fills.length).toBe(2) - expect((fills[0] as HTMLElement).style.width).toBe('100%') - expect((fills[1] as HTMLElement).style.width).toBe('50%') + expect(fills[0].style.width).toBe('100%') + expect(fills[1].style.width).toBe('50%') - const names = Array.from(container.querySelectorAll('.usage-model-name')).map(n => n.textContent) + const names = Array.from(modelBlock.querySelectorAll('.usage-model-name')).map(n => n.textContent) expect(names).toEqual(['model-a', 'model-b']) }) @@ -390,15 +396,15 @@ describe('dashboard App fine-grained reactivity', () => { expect(stats.textContent).not.toContain('Total Tokens') expect(stats.textContent).not.toContain('$') - // Usage graphs (token composition + cost by model) live inside the header, - // and are the single place token/cost figures are shown. - const usage = container.querySelector('.loop-detail-header .usage-group')! + // Usage graphs (token composition + cost by model) live inside the Usage + // tab body, mounted at first paint even when the active tab is Overview. + const usage = container.querySelector('.tab-body[data-tab="usage"] .usage-group')! expect(usage).toBeTruthy() expect(usage.querySelector('.usage-stack')).toBeTruthy() expect(usage.querySelector('.usage-legend')).toBeTruthy() expect(usage.querySelector('.usage-model-fill')).toBeTruthy() expect(usage.textContent).toContain('$') - // No standalone Usage block remains outside the header. + // No standalone Usage block remains a direct child of .loop-detail. expect(container.querySelector('.loop-detail > .usage-group')).toBeFalsy() }) @@ -413,16 +419,18 @@ describe('dashboard App fine-grained reactivity', () => { expect(banner.classList.contains('ldh-findings-clean')).toBe(true) }) - test('totals bar reflects updated counts after a poll', async () => { + test('filter bar reflects updated per-repo counts after a poll', async () => { + window.location.hash = '#p1' + payload = makePayload() dispose = render(() => App() as unknown as Element, container) await flush() - expect(container.querySelector('.totals')?.textContent).toContain('Running: 1') + expect(container.querySelector('.filter-bar')?.textContent).toContain('Running: 1') await poll(makePayload({ loop: { status: 'completed' }, totals: { running: 0, completed: 1 } })) - expect(container.querySelector('.totals')?.textContent).toContain('Running: 0') - expect(container.querySelector('.totals')?.textContent).toContain('Completed: 1') + expect(container.querySelector('.filter-bar')?.textContent).toContain('Running: 0') + expect(container.querySelector('.filter-bar')?.textContent).toContain('Completed: 1') }) }) @@ -963,3 +971,2485 @@ describe('dashboard App plan amendments panel', () => { expect(container.querySelector('.amendment-head .amendment-caret')!.textContent).toBe('▾') }) }) + +describe('dashboard App three-level shell', () => { + test('level 0 shows repo-menu and no section-nav', async () => { + window.location.hash = '' + payload = makePayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(container.querySelector('.repo-menu')).toBeTruthy() + expect(container.querySelector('.section-nav')).toBeFalsy() + }) + + test('level 1 shows section-nav and no repo-menu', async () => { + window.location.hash = '#p1' + payload = makePayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(container.querySelector('.section-nav')).toBeTruthy() + expect(container.querySelector('.repo-menu')).toBeFalsy() + }) + + test('level 2 hides the repo section-nav, leaving only the loop tabs', async () => { + window.location.hash = '#p1/loop/loop-a' + payload = makePayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(container.querySelector('.loop-detail-header')).toBeTruthy() + expect(container.querySelector('.section-nav')).toBeFalsy() + expect(container.querySelector('.tab-bar')).toBeTruthy() + + // Backing out to the repo restores it. + ;(container.querySelector('.breadcrumb-back') as HTMLElement).click() + await flush() + expect(container.querySelector('.section-nav')).toBeTruthy() + }) + + test('breadcrumb-path shows the full projectDir at level 1', async () => { + window.location.hash = '#p1' + payload = makePayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(container.querySelector('.breadcrumb-path')!.textContent).toBe('/proj/p1') + }) + + test('breadcrumb label is the short basename, not the full path', async () => { + window.location.hash = '#p1' + payload = makePayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(container.querySelector('.breadcrumb-label')!.textContent).toBe('p1') + }) + + test('clicking a repo-menu-item at level 0 sets the hash and removes the menu', async () => { + window.location.hash = '' + payload = makePayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(container.querySelector('.repo-menu')).toBeTruthy() + ;(container.querySelector('.repo-menu-item') as HTMLElement).click() + await flush() + + expect(location.hash).toBe('#p1') + expect(container.querySelector('.repo-menu')).toBeFalsy() + }) + + test('breadcrumb back link returns to level 0 and restores the repo-menu', async () => { + window.location.hash = '#p1' + payload = makePayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(container.querySelector('.repo-menu')).toBeFalsy() + ;(container.querySelector('.breadcrumb-back') as HTMLElement).click() + await flush() + + expect(container.querySelector('.repo-menu')).toBeTruthy() + expect(container.querySelector('.breadcrumb')).toBeFalsy() + }) + + test('loop-table node identity survives a status-change poll at level 1', async () => { + window.location.hash = '#p1' + payload = makePayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + const table1 = container.querySelector('table.loop-table') as HTMLElement + expect(table1).toBeTruthy() + ;(table1 as any).__id = 'orig' + expect(table1.querySelector('.status-badge')!.textContent).toBe('running') + + await poll( + makePayload({ + loop: { status: 'completed', completedAt: 1700000500000 }, + totals: { running: 0, completed: 1 }, + }), + ) + + const table2 = container.querySelector('table.loop-table') as HTMLElement + expect(table2).toBe(table1) + expect((table2 as any).__id).toBe('orig') + expect(table2.querySelector('.status-badge')!.textContent).toBe('completed') + }) + + test('two projects sharing a basename show disambiguated labels in repo-menu', async () => { + payload = { + generatedAt: Date.now(), + projects: [ + { + id: 'p1', + projectId: 'p1', + projectDir: '/a/p1', + loops: [ + makeLoop({ + loop: { + projectId: 'p1', + projectDir: '/a/p1', + loopName: 'loop-a', + status: 'running', + }, + }), + ], + }, + { + id: 'p2', + projectId: 'p2', + projectDir: '/b/p1', + loops: [ + makeLoop({ + loop: { + projectId: 'p2', + projectDir: '/b/p1', + loopName: 'loop-b', + status: 'completed', + completedAt: 1700000500000, + }, + }), + ], + }, + ], + 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) + await flush() + + const names = Array.from(container.querySelectorAll('.repo-menu-name')).map( + n => n.textContent, + ) + expect(names).toContain('a/p1') + expect(names).toContain('b/p1') + // The raw six-segment path must not leak into the visible labels. + expect(names.some(n => n!.startsWith('/'))).toBe(false) + }) + + test('poll at level 0 updates the running count, summary, and running card without remount', async () => { + // Two loops in one repo: one running (renders a running card), one + // completed. The level-0 index pane must recompute its running count, + // bug count, running card list, and recent activity when the underlying + // loop data changes via reconciled poll — without unmounting the pane. + payload = makePayload({ + loops: [ + makeLoop({ + loop: { + loopName: 'loop-a', + status: 'running', + phase: 'coding', + startedAt: 1700000000000, + }, + findings: [{ id: 'f1', severity: 'bug', description: 'b1', sectionIndex: 0, createdAt: 1700000000000 }], + }), + makeLoop({ + loop: { + loopName: 'loop-b', + status: 'completed', + phase: 'post_action', + startedAt: 1700000100000, + completedAt: 1700000200000, + }, + }), + ], + totals: { loops: 2, running: 1, completed: 1 }, + }) + window.location.hash = '' + dispose = render(() => App() as unknown as Element, container) + await flush() + + const pane = container.querySelector('.repo-index-pane') as HTMLElement + expect(pane).toBeTruthy() + ;(pane as any).__id = 'orig' + expect(container.querySelector('.repo-index-summary')!.textContent).toBe('1 running · 2 loops · 1 open bugs') + expect(container.querySelectorAll('.repo-running-card').length).toBe(1) + expect(container.querySelector('.repo-running-name')!.textContent).toBe('loop-a') + + // Poll: the running loop completes (no running card), takes on a bug + // finding, and the recent row reflects its new status. + await poll( + makePayload({ + loops: [ + makeLoop({ + loop: { + loopName: 'loop-a', + status: 'completed', + phase: 'post_action', + startedAt: 1700000000000, + completedAt: 1700000500000, + }, + findings: [ + { id: 'f1', severity: 'bug', description: 'b1', sectionIndex: 0, createdAt: 1700000000000 }, + { id: 'f2', severity: 'bug', description: 'b2', sectionIndex: 1, createdAt: 1700000100000 }, + ], + }), + makeLoop({ + loop: { + loopName: 'loop-b', + status: 'completed', + phase: 'post_action', + startedAt: 1700000100000, + completedAt: 1700000200000, + }, + }), + ], + totals: { loops: 2, running: 0, completed: 2 }, + }), + ) + + // Same pane node — no subtree teardown. + const pane2 = container.querySelector('.repo-index-pane') as HTMLElement + expect(pane2).toBe(pane) + expect((pane2 as any).__id).toBe('orig') + // Counts and card list reactively updated. + expect(container.querySelector('.repo-index-summary')!.textContent).toBe('0 running · 2 loops · 2 open bugs') + expect(container.querySelectorAll('.repo-running-card').length).toBe(0) + // Recent activity still renders both loops; loop-a's status row updated. + const recentNames = Array.from(container.querySelectorAll('.repo-recent-name')).map(n => n.textContent) + expect(recentNames).toContain('loop-a') + expect(recentNames).toContain('loop-b') + }) +}) + +describe('dashboard App status filters and search', () => { + function multiLoopPayload(): any { + return { + generatedAt: Date.now(), + projects: [ + { + id: 'p1', + projectId: 'p1', + projectDir: '/proj/p1', + loops: [ + makeLoop({ + loop: { loopName: 'loop-running', status: 'running', startedAt: 1700000000000 }, + }), + makeLoop({ + loop: { loopName: 'loop-errored', status: 'errored', startedAt: 1700000100000 }, + }), + ], + }, + ], + totals: { projects: 1, loops: 2, running: 1, completed: 0, cancelled: 0, errored: 1, 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() + } + + test('at level 1, clicking the errored status chip writes ?status=errored and filters the table', async () => { + window.location.hash = '#p1' + payload = multiLoopPayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(container.querySelectorAll('tr.lt-row').length).toBe(2) + + await clickChip('errored') + + expect(location.hash).toBe('#p1?status=errored') + const rows = Array.from(container.querySelectorAll('tr.lt-row')) as HTMLElement[] + expect(rows.length).toBe(1) + expect(rows[0].textContent).toContain('loop-errored') + }) + + test('clicking an active chip clears it and the hash returns to #p1', async () => { + window.location.hash = '#p1' + payload = multiLoopPayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + await clickChip('errored') + expect(location.hash).toBe('#p1?status=errored') + + await clickChip('errored') + expect(location.hash).toBe('#p1') + expect(container.querySelectorAll('tr.lt-row').length).toBe(2) + }) + + test('loading directly at ?status=running,errored renders both chips active on first paint', async () => { + window.location.hash = '#p1?status=running,errored' + payload = multiLoopPayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + const active = Array.from(container.querySelectorAll('.filter-bar .badge-active')) as HTMLElement[] + const activeLabels = active.map(a => a.textContent?.toLowerCase() ?? '') + expect(activeLabels.some(l => l.startsWith('running'))).toBe(true) + expect(activeLabels.some(l => l.startsWith('errored'))).toBe(true) + // Both loops match the union filter. + expect(container.querySelectorAll('tr.lt-row').length).toBe(2) + }) + + test('filter chips are absent at level 0 and level 2', async () => { + // Level 0: no project selected. + window.location.hash = '' + payload = multiLoopPayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + expect(container.querySelector('.filter-bar')).toBeFalsy() + + // Level 2: a loop is open. + window.location.hash = '#p1' + dispose?.() + container.remove() + container = document.createElement('div') + document.body.appendChild(container) + dispose = render(() => App() as unknown as Element, container) + await flush() + ;(container.querySelector('tr.lt-row') as HTMLElement).click() + await flush() + expect(container.querySelector('.filter-bar')).toBeFalsy() + }) + + test('a poll arriving while a status filter is active does not reset the filter', async () => { + window.location.hash = '#p1?status=errored' + payload = multiLoopPayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(location.hash).toBe('#p1?status=errored') + expect(container.querySelectorAll('tr.lt-row').length).toBe(1) + + // Poll with updated data; the route is unaffected, so the filter persists. + await poll(multiLoopPayload()) + + expect(location.hash).toBe('#p1?status=errored') + expect(container.querySelectorAll('tr.lt-row').length).toBe(1) + // Active chip still reflects the errored filter. + const active = Array.from(container.querySelectorAll('.filter-bar .badge-active')) as HTMLElement[] + expect(active.some(a => (a.textContent ?? '').toLowerCase().startsWith('errored'))).toBe(true) + }) + + test('a filter with zero matching loops keeps the repo shell mounted at level 1', async () => { + window.location.hash = '#p1' + payload = multiLoopPayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + // Search for a substring matching neither loop name, branch, nor label. + let input = container.querySelector('#loop-search') as HTMLInputElement + input.value = 'nomatch-xyz' + input.dispatchEvent(new Event('input', { bubbles: true })) + + // Wait past the 250ms debounce trailing edge, then settle effects. + await new Promise((r) => setTimeout(r, 350)) + await flush() + + // The hash carries the query and we stay at level 1. + expect(location.hash).toBe('#p1?q=nomatch-xyz') + // Level-1 shell remains fully mounted: filter bar, breadcrumb, section nav. + expect(container.querySelector('.filter-bar')).toBeTruthy() + expect(container.querySelector('.breadcrumb')).toBeTruthy() + expect(container.querySelector('.section-nav')).toBeTruthy() + // Level 0 menu is NOT shown. + expect(container.querySelector('.repo-menu')).toBeFalsy() + // No loop rows match; the in-repo empty state is rendered. + expect(container.querySelectorAll('tr.lt-row').length).toBe(0) + expect(container.querySelector('.empty-state')).toBeTruthy() + + // Clearing the filter restores the loop list without leaving level 1. + // (Re-query the input: the debounce commit recreates FilterBar.) + input = container.querySelector('#loop-search') as HTMLInputElement + input.value = '' + input.dispatchEvent(new Event('input', { bubbles: true })) + await new Promise((r) => setTimeout(r, 350)) + await flush() + + expect(location.hash).toBe('#p1') + expect(container.querySelectorAll('tr.lt-row').length).toBe(2) + expect(container.querySelector('.empty-state')).toBeFalsy() + }) + + test('returning to repositories clears repo-scoped filters from memory', async () => { + window.location.hash = '#p1?status=errored' + payload = multiLoopPayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(location.hash).toBe('#p1?status=errored') + expect(container.querySelector('.filter-bar')).toBeTruthy() + expect(container.querySelectorAll('tr.lt-row').length).toBe(1) + + // Back to repositories — filters must be cleared from the route. + ;(container.querySelector('.breadcrumb-back') as HTMLElement).click() + await flush() + + expect(container.querySelector('.repo-menu')).toBeTruthy() + expect(location.hash).toBe('') + + // Select the same repo again — it must start unfiltered. + ;(container.querySelector('.repo-menu-item') as HTMLElement).click() + await flush() + + expect(location.hash).toBe('#p1') + expect(container.querySelectorAll('tr.lt-row').length).toBe(2) + expect(container.querySelectorAll('.filter-bar .badge-active').length).toBe(0) + }) + + test('a count-changing poll during the debounce window does not discard the pending query', async () => { + window.location.hash = '#p1' + payload = multiLoopPayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + const input = container.querySelector('#loop-search') as HTMLInputElement + input.value = 'loop-running' + input.dispatchEvent(new Event('input', { bubbles: true })) + + // Within the 250ms window, a count-changing poll arrives. FilterBar must + // persist rather than be recreated, so its pending debounce survives. + await poll( + makePayload({ + loops: [ + makeLoop({ loop: { loopName: 'loop-running', status: 'running', startedAt: 1700000000000 } }), + 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 }, + }), + ) + + // The input retains the typed text despite the count update. + expect((container.querySelector('#loop-search') as HTMLInputElement).value).toBe('loop-running') + + // Wait past the 250ms debounce trailing edge, then settle effects. + await new Promise((r) => setTimeout(r, 350)) + await flush() + + // The pending query reached the hash despite the intervening poll. + expect(location.hash).toBe('#p1?q=loop-running') + const rows = Array.from(container.querySelectorAll('tr.lt-row')) as HTMLElement[] + expect(rows.length).toBe(1) + expect(rows[0].textContent).toContain('loop-running') + }) + + test('typing a query then toggling a status within the debounce window still commits the query', async () => { + window.location.hash = '#p1' + payload = multiLoopPayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(container.querySelectorAll('tr.lt-row').length).toBe(2) + + const input = container.querySelector('#loop-search') as HTMLInputElement + input.value = 'loop-running' + input.dispatchEvent(new Event('input', { bubbles: true })) + + // Within the 250ms window, toggle a status chip. FilterBar must persist + // so its pending debounce timer survives the reactive update. + await clickChip('running') + + // The status toggle lands immediately; the query is still pending commit. + expect(location.hash).toBe('#p1?status=running') + // The input retains the typed text — no reset from the status update. + expect((container.querySelector('#loop-search') as HTMLInputElement).value).toBe('loop-running') + + // Wait past the 250ms debounce trailing edge, then settle effects. + await new Promise((r) => setTimeout(r, 350)) + await flush() + + // The typed query reaches the hash despite the intervening status toggle. + expect(location.hash).toBe('#p1?status=running&q=loop-running') + const rows = Array.from(container.querySelectorAll('tr.lt-row')) as HTMLElement[] + expect(rows.length).toBe(1) + expect(rows[0].textContent).toContain('loop-running') + }) + + test('filter bar cancels pending search debounce when disposed before the trailing edge', async () => { + window.location.hash = '#p1' + payload = multiLoopPayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + // Type a query — schedules the debounce timer but does not navigate yet. + const input = container.querySelector('#loop-search') as HTMLInputElement + input.value = 'nomatch-xyz' + input.dispatchEvent(new Event('input', { bubbles: true })) + + // Within the 250ms window, open a loop (disposing the filter bar). + ;(container.querySelector('tr.lt-row') as HTMLElement).click() + await flush() + + // Navigated to the loop route; no query yet. + expect(location.hash).toBe('#p1/loop/loop-running') + + // Wait well past the debounce trailing edge. + await new Promise((r) => setTimeout(r, 350)) + await flush() + + // The stale timer was cancelled on disposal — the discarded query never + // landed in the hash. + expect(location.hash).toBe('#p1/loop/loop-running') + expect(location.hash).not.toContain('q=') + }) + + test('external hash navigation during the debounce window cancels the pending query commit', async () => { + window.location.hash = '#p1' + payload = multiLoopPayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + const input = container.querySelector('#loop-search') as HTMLInputElement + input.value = 'stale-query' + input.dispatchEvent(new Event('input', { bubbles: true })) + + window.location.hash = '#p1?status=running' + window.dispatchEvent(new Event('hashchange')) + await flush() + + expect(location.hash).toBe('#p1?status=running') + expect((container.querySelector('#loop-search') as HTMLInputElement).value).toBe('') + + await new Promise((r) => setTimeout(r, 350)) + await flush() + + expect(location.hash).toBe('#p1?status=running') + expect(location.hash).not.toContain('q=stale-query') + }) +}) + +describe('dashboard App loop detail tabs', () => { + 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 planLoopFixture(): any { + return makePayload({ + dashLoop: { + plan: 'PLAN BODY', + sections: [ + { + projectId: 'p1', + loopName: 'loop-a', + sectionIndex: 0, + title: 'Section A', + content: 'SECTION PLAN BODY', + status: 'completed', + attempts: 1, + summaryDone: 'did it', + summaryDeviations: null, + summaryFollowUps: null, + startedAt: 1700000000000, + completedAt: 1700000500000, + createdAt: 1700000000000, + }, + ], + }, + }) + } + + function goalLoopFixture(): any { + return makePayload({ + dashLoop: { + goal: 'GOAL TEXT', + plan: null, + sections: [], + loop: { kind: 'goal', totalSections: 0 }, + }, + }) + } + + test('opening a plan loop renders six tab-item elements with Overview active', async () => { + window.location.hash = '#p1/loop/loop-a' + payload = planLoopFixture() + dispose = render(() => App() as unknown as Element, container) + await flush() + + 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') + }) + + test('opening a goal loop renders no Sections tab and its Overview contains the goal text', async () => { + window.location.hash = '#p1/loop/loop-a' + payload = goalLoopFixture() + dispose = render(() => App() as unknown as Element, container) + await flush() + + const tabs = Array.from(container.querySelectorAll('.tab-item')).map((i: HTMLElement) => i.dataset.tab) + expect(tabs).not.toContain('sections') + // Overview is active and its body contains the goal markdown. + const overviewBody = container.querySelector('.tab-body[data-tab="overview"]') as HTMLElement + expect(overviewBody.style.display).not.toBe('none') + expect(overviewBody.querySelector('.markdown-content')?.innerHTML).toContain('GOAL TEXT') + }) + + test('clicking the Usage tab writes #p1/loop/loop-a/usage and shows the usage body', async () => { + window.location.hash = '#p1/loop/loop-a' + payload = makePayload({ + dashLoop: { + usage: { + loopName: 'loop-a', + totalCost: 1.5, + totalInputTokens: 50, + totalOutputTokens: 30, + totalReasoningTokens: 10, + totalCacheReadTokens: 8, + totalCacheWriteTokens: 2, + totalMessageCount: 4, + byModel: { + 'model-a': { cost: 1.0, inputTokens: 40, outputTokens: 20, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 3 }, + }, + }, + }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + await clickTab('usage') + + expect(location.hash).toBe('#p1/loop/loop-a/usage') + const usageBody = container.querySelector('.tab-body[data-tab="usage"]') as HTMLElement + expect(usageBody.style.display).not.toBe('none') + expect(usageBody.querySelector('.usage-group')).toBeTruthy() + // Overview is now hidden. + expect((container.querySelector('.tab-body[data-tab="overview"]') as HTMLElement).style.display).toBe('none') + }) + + test('loading directly at #p1/loop/loop-a/usage opens on the Usage tab on first paint', async () => { + window.location.hash = '#p1/loop/loop-a/usage' + 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 active = container.querySelector('.tab-item.tab-active') as HTMLElement + expect(active?.dataset.tab).toBe('usage') + expect((container.querySelector('.tab-body[data-tab="usage"]') as HTMLElement).style.display).not.toBe('none') + expect((container.querySelector('.tab-body[data-tab="overview"]') as HTMLElement).style.display).toBe('none') + }) + + test('tab identity is preserved across tab switch: scrollTop survives Plan tab hide/show', async () => { + window.location.hash = '#p1/loop/loop-a' + payload = planLoopFixture() + dispose = render(() => App() as unknown as Element, container) + await flush() + + const planScroll = container.querySelector('.tab-body[data-tab="plan"] .markdown-scrollable') as HTMLElement + expect(planScroll).toBeTruthy() + planScroll.scrollTop = 200 + ;(planScroll as any).__id = 'plan-orig' + + await clickTab('usage') + const planBodyAfterHide = container.querySelector('.tab-body[data-tab="plan"]') as HTMLElement + expect(planBodyAfterHide.style.display).toBe('none') + + await clickTab('plan') + const planScroll2 = container.querySelector('.tab-body[data-tab="plan"] .markdown-scrollable') as HTMLElement + expect(planScroll2).toBe(planScroll) + expect((planScroll2 as any).__id).toBe('plan-orig') + expect(planScroll2.scrollTop).toBe(200) + }) + + test('a poll changing loop status while on the Usage tab leaves the active tab unchanged', 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() + + await clickTab('usage') + expect(location.hash).toBe('#p1/loop/loop-a/usage') + + await poll(makePayload({ + loop: { status: 'completed', completedAt: 1700000500000 }, + totals: { running: 0, completed: 1 }, + })) + + expect(location.hash).toBe('#p1/loop/loop-a/usage') + const active = container.querySelector('.tab-item.tab-active') as HTMLElement + expect(active?.dataset.tab).toBe('usage') + expect((container.querySelector('.tab-body[data-tab="usage"]') as HTMLElement).style.display).not.toBe('none') + }) + + test('Findings tab groups four findings under sect 1 and two under cross-section', async () => { + window.location.hash = '#p1/loop/loop-a/findings' + payload = makePayload({ + dashLoop: { + findings: [ + { severity: 'bug', file: 'a.ts', line: 1, description: 'b1', scenario: null, sectionIndex: 0, createdAt: 100 }, + { severity: 'bug', file: 'b.ts', line: 2, description: 'b2', scenario: null, sectionIndex: 0, createdAt: 200 }, + { severity: 'warning', file: 'c.ts', line: 3, description: 'w1', scenario: null, sectionIndex: 0, createdAt: 300 }, + { severity: 'warning', file: 'd.ts', line: 4, description: 'w2', scenario: null, sectionIndex: 0, createdAt: 400 }, + { severity: 'bug', file: 'e.ts', line: 5, description: 'b3', scenario: null, sectionIndex: null, createdAt: 500 }, + { severity: 'warning', file: 'f.ts', line: 6, description: 'w3', scenario: null, sectionIndex: null, createdAt: 600 }, + ], + }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const findingsBody = container.querySelector('.tab-body[data-tab="findings"]') as HTMLElement + expect(findingsBody.style.display).not.toBe('none') + + const groups = Array.from(findingsBody.querySelectorAll('.findings-group')) as HTMLElement[] + expect(groups.length).toBe(2) + const labels = groups.map(g => g.querySelector('.findings-group-label')?.textContent) + expect(labels).toContain('sect 1') + expect(labels).toContain('cross-section') + const sectGroup = groups.find(g => g.querySelector('.findings-group-label')?.textContent === 'sect 1')! + expect(sectGroup.querySelectorAll('.finding').length).toBe(4) + const crossGroup = groups.find(g => g.querySelector('.findings-group-label')?.textContent === 'cross-section')! + expect(crossGroup.querySelectorAll('.finding').length).toBe(2) + }) + + test('prev/next navigation preserves the active tab when the next loop supports it', async () => { + // Two loops, both plan loops with sections+plan (so all six tabs are + // supported on each). Switch to the Timeline tab on loop-a, then step + // forward to loop-b: the active tab must stay on Timeline, not reset to + // Overview. Unsupported tabs canonicalize separately, so any tab the + // next loop renders is fine. loop-a is the more recent loop, so it sits + // first in the recency-ordered pager and loop-b is its "next". + window.location.hash = '#p1/loop/loop-a' + payload = makePayload({ + loops: [ + makeLoop({ + loop: { loopName: 'loop-a', status: 'running', phase: 'coding', startedAt: 1700000100000 }, + sections: [{ index: 0, title: 's0', status: 'in_progress', startedAt: 1700000100000, completedAt: null, durationMs: null, iteration: 0, auditCount: 0, errorCount: 0, completionSummary: null }], + plan: 'PLAN A', + }), + makeLoop({ + loop: { loopName: 'loop-b', status: 'running', phase: 'coding', startedAt: 1700000000000 }, + sections: [{ index: 0, title: 's0', status: 'in_progress', startedAt: 1700000000000, completedAt: null, durationMs: null, iteration: 0, auditCount: 0, errorCount: 0, completionSummary: null }], + plan: 'PLAN B', + }), + ], + totals: { loops: 2, running: 2 }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + await clickTab('timeline') + expect(location.hash).toBe('#p1/loop/loop-a/timeline') + + const next = container.querySelector('.loopnav-next') as HTMLElement + expect(next).toBeTruthy() + next.click() + await flush() + + // Moved to loop-b while preserving the Timeline tab. + expect(location.hash).toBe('#p1/loop/loop-b/timeline') + const active = container.querySelector('.tab-item.tab-active') as HTMLElement + expect(active?.dataset.tab).toBe('timeline') + expect((container.querySelector('.tab-body[data-tab="timeline"]') as HTMLElement).style.display).not.toBe('none') + // The breadcrumb reflects loop-b as the current loop. + expect((container.querySelector('.breadcrumb-loop') as HTMLInputElement).value).toBe('loop-b') + }) + + test('opening a loop from the loops table resets the active tab to overview', async () => { + // Switching tabs to timeline, then clicking a different loop's table row + // must return to Overview — entry points from lists/cards are the explicit + // reset points, in contrast to prev/next which preserve the tab. + window.location.hash = '#p1/loop/loop-a/timeline' + payload = makePayload({ + loops: [ + makeLoop({ + loop: { loopName: 'loop-a', status: 'running', phase: 'coding', startedAt: 1700000000000 }, + sections: [{ index: 0, title: 's0', status: 'in_progress', startedAt: 1700000000000, completedAt: null, durationMs: null, iteration: 0, auditCount: 0, errorCount: 0, completionSummary: null }], + plan: 'PLAN A', + }), + makeLoop({ + loop: { loopName: 'loop-b', status: 'running', phase: 'coding', startedAt: 1700000100000 }, + sections: [{ index: 0, title: 's0', status: 'in_progress', startedAt: 1700000100000, completedAt: null, durationMs: null, iteration: 0, auditCount: 0, errorCount: 0, completionSummary: null }], + plan: 'PLAN B', + }), + ], + totals: { loops: 2, running: 2 }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + // Back out to the loops list (loopName null) and open loop-b via the row. + window.location.hash = '#p1' + await flush() + const rows = Array.from(container.querySelectorAll('table.loop-table tbody tr')) as HTMLElement[] + const loopBRow = rows.find(r => (r.querySelector('.lt-name') as HTMLElement)?.textContent === 'loop-b')! + expect(loopBRow).toBeTruthy() + loopBRow.click() + await flush() + + expect(location.hash).toBe('#p1/loop/loop-b') + const active = container.querySelector('.tab-item.tab-active') as HTMLElement + expect(active?.dataset.tab).toBe('overview') + }) +}) + +describe('dashboard App breadcrumb loop picker', () => { + // completedAt || startedAt drives recency: beta-loop (running, 1700009000000), + // gamma-run (1700004000000), alpha-loop (1700000600000). + function pickerPayload(): any { + return 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 }, + }) + } + + function optionNames(menu: HTMLElement): (string | null)[] { + return Array.from(menu.querySelectorAll('.loop-picker-option-name')).map(n => n.textContent) + } + + test('lists every repo loop most recent first with a timestamp, and opens the clicked loop', async () => { + window.location.hash = '#p1/loop/alpha-loop' + payload = pickerPayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + const input = container.querySelector('.breadcrumb-loop') as HTMLInputElement + expect(input.value).toBe('alpha-loop') + const menu = container.querySelector('.loop-picker-menu') as HTMLElement + expect(menu.style.display).toBe('none') + + input.focus() + await flush() + expect(menu.style.display).toBe('block') + + expect(optionNames(menu)).toEqual(['beta-loop', 'gamma-run', 'alpha-loop']) + expect(Array.from(menu.querySelectorAll('.loop-picker-option-when')).map(n => n.textContent)).toEqual([ + fmtTime(1700009000000), + fmtTime(1700004000000), + fmtTime(1700000600000), + ]) + const options = Array.from(menu.querySelectorAll('.loop-picker-option')) as HTMLElement[] + expect(options[2].classList.contains('loop-picker-option-current')).toBe(true) + // The pager count covers the same list the picker offers. + expect(container.querySelector('.loopnav-count')!.textContent).toBe('3 of 3') + + options[0].click() + await flush() + expect(location.hash).toBe('#p1/loop/beta-loop') + expect((container.querySelector('.breadcrumb-loop') as HTMLInputElement).value).toBe('beta-loop') + }) + + test('typing filters the options and Enter opens the highlighted loop', async () => { + window.location.hash = '#p1/loop/alpha-loop' + payload = pickerPayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + const input = container.querySelector('.breadcrumb-loop') as HTMLInputElement + input.focus() + await flush() + input.value = 'gam' + input.dispatchEvent(new Event('input', { bubbles: true })) + await flush() + + const menu = container.querySelector('.loop-picker-menu') as HTMLElement + expect(optionNames(menu)).toEqual(['gamma-run']) + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + await flush() + + expect(location.hash).toBe('#p1/loop/gamma-run') + expect((container.querySelector('.breadcrumb-loop') as HTMLInputElement).value).toBe('gamma-run') + }) + + test('a query with no match shows the empty row and Enter is a no-op', async () => { + window.location.hash = '#p1/loop/alpha-loop' + payload = pickerPayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + const input = container.querySelector('.breadcrumb-loop') as HTMLInputElement + input.focus() + await flush() + input.value = 'nope' + input.dispatchEvent(new Event('input', { bubbles: true })) + await flush() + + const menu = container.querySelector('.loop-picker-menu') as HTMLElement + expect(optionNames(menu)).toEqual([]) + expect(menu.querySelector('.loop-picker-empty')).toBeTruthy() + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + await flush() + expect(location.hash).toBe('#p1/loop/alpha-loop') + }) + + test('an open picker survives a poll that updates loop data', async () => { + window.location.hash = '#p1/loop/alpha-loop' + payload = pickerPayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + const input = container.querySelector('.breadcrumb-loop') as HTMLInputElement + input.focus() + await flush() + const menu = container.querySelector('.loop-picker-menu') as HTMLElement + expect(menu.style.display).toBe('block') + + const next = pickerPayload() + next.projects[0].loops[1].loop.iteration = 7 + await poll(next) + + expect(container.querySelector('.loop-picker-menu')).toBe(menu) + expect(menu.style.display).toBe('block') + }) +}) + +describe('dashboard App overview tab content', () => { + test('overview renders a metadata strip with models, branch, sandbox, kind, audits, and errors', async () => { + window.location.hash = '#p1/loop/loop-a' + payload = makePayload({ + loop: { + executionModel: 'exec-m', + auditorModel: 'audit-m', + worktreeBranch: 'feat/branch-x', + sandbox: true, + kind: 'goal', + auditCount: 3, + errorCount: 2, + }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const overviewBody = container.querySelector('.tab-body[data-tab="overview"]') as HTMLElement + expect(overviewBody.style.display).not.toBe('none') + + const meta = overviewBody.querySelector('.overview-meta') as HTMLElement + expect(meta).toBeTruthy() + const text = meta.textContent ?? '' + expect(text).toContain('exec-m') + expect(text).toContain('audit-m') + expect(text).toContain('feat/branch-x') + expect(text).toContain('on') + expect(text).toContain('goal') + expect(text).toContain('Audits') + expect(text).toContain('3') + expect(text).toContain('Errors') + expect(text).toContain('2') + }) + + test('overview renders findings summary chips with bug and warning counts', async () => { + window.location.hash = '#p1/loop/loop-a' + payload = makePayload({ + dashLoop: { + findings: [ + { severity: 'bug', file: 'a.ts', line: 1, description: 'b1', scenario: null, createdAt: 100 }, + { severity: 'bug', file: 'b.ts', line: 2, description: 'b2', scenario: null, createdAt: 200 }, + { severity: 'warning', file: 'c.ts', line: 3, description: 'w1', scenario: null, createdAt: 300 }, + ], + }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const overviewBody = container.querySelector('.tab-body[data-tab="overview"]') as HTMLElement + const chips = Array.from(overviewBody.querySelectorAll('.overview-chip')) as HTMLElement[] + const bugChip = chips.find(c => c.classList.contains('overview-chip-bug'))! + const warnChip = chips.find(c => c.classList.contains('overview-chip-warning'))! + expect(bugChip).toBeTruthy() + expect(bugChip.textContent).toContain('2 bugs') + expect(warnChip).toBeTruthy() + expect(warnChip.textContent).toContain('1 warning') + }) + + test('overview with no findings renders a clean chip', async () => { + window.location.hash = '#p1/loop/loop-a' + payload = makePayload({ findings: [] }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const overviewBody = container.querySelector('.tab-body[data-tab="overview"]') as HTMLElement + const chip = overviewBody.querySelector('.overview-chip-clean') as HTMLElement + expect(chip).toBeTruthy() + expect(chip.textContent).toContain('No findings') + }) + + test('overview with warnings only renders the warning chip and no bug chip', async () => { + window.location.hash = '#p1/loop/loop-a' + payload = makePayload({ + dashLoop: { + findings: [ + { severity: 'warning', file: 'c.ts', line: 3, description: 'w1', scenario: null, createdAt: 300 }, + ], + }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const overviewBody = container.querySelector('.tab-body[data-tab="overview"]') as HTMLElement + const chips = Array.from(overviewBody.querySelectorAll('.overview-chip')) as HTMLElement[] + const bugChip = chips.find(c => c.classList.contains('overview-chip-bug')) + const warnChip = chips.find(c => c.classList.contains('overview-chip-warning'))! + expect(bugChip).toBeUndefined() + expect(warnChip).toBeTruthy() + expect(warnChip.textContent).toContain('1 warning') + expect(overviewBody.textContent).not.toContain('0 bug') + }) + + test('overview goal markdown renders before the loop-detail-header', async () => { + window.location.hash = '#p1/loop/loop-a' + payload = makePayload({ + dashLoop: { goal: 'GOAL TEXT', plan: null, sections: [], loop: { kind: 'goal', totalSections: 0 } }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const overviewBody = container.querySelector('.tab-body[data-tab="overview"]') as HTMLElement + const goalSection = Array.from(overviewBody.querySelectorAll('.markdown-section')) + .find(s => s.querySelector('.section-label')?.textContent === 'Goal') as HTMLElement + const header = overviewBody.querySelector('.loop-detail-header') as HTMLElement + expect(goalSection).toBeTruthy() + expect(header).toBeTruthy() + // The goal section precedes the header in document order. + const pos = goalSection.compareDocumentPosition(header) + expect(pos & Node.DOCUMENT_POSITION_FOLLOWING).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + }) + + test('overview renders postActionReport when present, taking precedence over completionSummary', async () => { + window.location.hash = '#p1/loop/loop-a' + payload = makePayload({ + dashLoop: { postActionReport: 'PAR BODY' }, + loop: { completionSummary: 'COMPLETION BODY', status: 'completed', completedAt: 1700000500000 }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const overviewBody = container.querySelector('.tab-body[data-tab="overview"]') as HTMLElement + const labels = Array.from(overviewBody.querySelectorAll('.markdown-section .section-label')).map(l => l.textContent) + expect(labels).toContain('Post-Action Report') + expect(labels).not.toContain('Completion Summary') + expect(overviewBody.textContent).toContain('PAR BODY') + expect(overviewBody.textContent).not.toContain('COMPLETION BODY') + }) + + test('overview falls back to completionSummary when postActionReport is absent', async () => { + window.location.hash = '#p1/loop/loop-a' + payload = makePayload({ + dashLoop: { postActionReport: null }, + loop: { completionSummary: 'COMPLETION BODY', status: 'completed', completedAt: 1700000500000 }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const overviewBody = container.querySelector('.tab-body[data-tab="overview"]') as HTMLElement + const labels = Array.from(overviewBody.querySelectorAll('.markdown-section .section-label')).map(l => l.textContent) + expect(labels).toContain('Completion Summary') + expect(overviewBody.textContent).toContain('COMPLETION BODY') + }) + + test('polling from completionSummary to postActionReport updates the heading and body', async () => { + window.location.hash = '#p1/loop/loop-a' + payload = makePayload({ + dashLoop: { postActionReport: null }, + loop: { completionSummary: 'COMPLETION BODY', status: 'completed', completedAt: 1700000500000 }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const overviewBody = container.querySelector('.tab-body[data-tab="overview"]') as HTMLElement + let labels = Array.from(overviewBody.querySelectorAll('.markdown-section .section-label')).map(l => l.textContent) + expect(labels).toContain('Completion Summary') + expect(overviewBody.textContent).toContain('COMPLETION BODY') + + await poll(makePayload({ + dashLoop: { postActionReport: 'PAR BODY' }, + loop: { completionSummary: 'COMPLETION BODY', status: 'completed', completedAt: 1700000500000 }, + })) + + labels = Array.from(overviewBody.querySelectorAll('.markdown-section .section-label')).map(l => l.textContent) + expect(labels).toContain('Post-Action Report') + expect(labels).not.toContain('Completion Summary') + expect(overviewBody.textContent).toContain('PAR BODY') + expect(overviewBody.textContent).not.toContain('COMPLETION BODY') + }) + + test('overview exposes lastAuditResult as its own markdown section', async () => { + window.location.hash = '#p1/loop/loop-a' + payload = makePayload({ + dashLoop: { lastAuditResult: 'AUDIT BODY' }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const overviewBody = container.querySelector('.tab-body[data-tab="overview"]') as HTMLElement + const labels = Array.from(overviewBody.querySelectorAll('.markdown-section .section-label')).map(l => l.textContent) + expect(labels).toContain('Last Audit Result') + expect(overviewBody.textContent).toContain('AUDIT BODY') + }) + + test('usage tab renders a role split bar with execution and auditor rows', async () => { + window.location.hash = '#p1/loop/loop-a/usage' + payload = makePayload({ + loop: { executionModel: 'exec-m', auditorModel: 'audit-m' }, + dashLoop: { + usage: { + loopName: 'loop-a', + totalCost: 3, + totalInputTokens: 100, + totalOutputTokens: 20, + totalReasoningTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + totalMessageCount: 8, + byModel: { + 'exec-m': { cost: 2, inputTokens: 80, outputTokens: 15, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 6 }, + '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 }, + }, + }, + }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const usageBody = container.querySelector('.tab-body[data-tab="usage"]') as HTMLElement + const roleBlock = Array.from(usageBody.querySelectorAll('.usage-block')).find(b => + b.querySelector('.usage-block-title')?.textContent === 'Role split')! as HTMLElement + const rows = Array.from(roleBlock.querySelectorAll('.usage-model-row')) as HTMLElement[] + expect(rows.length).toBe(2) + const names = rows.map(r => r.querySelector('.usage-model-name')?.textContent) + expect(names).toContain('execution') + expect(names).toContain('auditor') + // Wider bar = larger role. Execution cost (2) > auditor cost (1). + const execRow = rows.find(r => r.querySelector('.usage-model-name')?.textContent === 'execution')! + const auditRow = rows.find(r => r.querySelector('.usage-model-name')?.textContent === 'auditor')! + // Role bars reuse the styled model track/fill classes so they remain visible. + expect(execRow.querySelector('.usage-model-track')).toBeTruthy() + expect(execRow.querySelector('.usage-model-fill')).toBeTruthy() + const execFill = (execRow.querySelector('.usage-model-fill') as HTMLElement).style.width + const auditFill = (auditRow.querySelector('.usage-model-fill') as HTMLElement).style.width + expect(execFill).toBe('100%') + expect(auditFill).toBe('50%') + // Cost labels expose the dollar amounts. + expect(execRow.querySelector('.usage-model-cost')?.textContent).toBe('$2.00') + expect(auditRow.querySelector('.usage-model-cost')?.textContent).toBe('$1.00') + }) + + test('usage role split uses recorded roles when execution and auditor share a model', async () => { + window.location.hash = '#p1/loop/loop-a/usage' + payload = makePayload({ + loop: { executionModel: 'shared-m', auditorModel: 'shared-m' }, + dashLoop: { + usage: { + loopName: 'loop-a', + totalCost: 3, + totalInputTokens: 100, + totalOutputTokens: 20, + totalReasoningTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + totalMessageCount: 8, + byModel: { + '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 }, + }, + }, + }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const usageBody = container.querySelector('.tab-body[data-tab="usage"]') as HTMLElement + const roleBlock = Array.from(usageBody.querySelectorAll('.usage-block')).find(b => + b.querySelector('.usage-block-title')?.textContent === 'Role split')! as HTMLElement + const rows = Array.from(roleBlock.querySelectorAll('.usage-model-row')) as HTMLElement[] + const execRow = rows.find(r => r.querySelector('.usage-model-name')?.textContent === 'execution')! + const auditRow = rows.find(r => r.querySelector('.usage-model-name')?.textContent === 'auditor')! + expect(execRow.querySelector('.usage-model-cost')?.textContent).toBe('$2.00') + expect(auditRow.querySelector('.usage-model-cost')?.textContent).toBe('$1.00') + expect((execRow.querySelector('.usage-model-fill') as HTMLElement).style.width).toBe('100%') + expect((auditRow.querySelector('.usage-model-fill') as HTMLElement).style.width).toBe('50%') + }) + + test('a goal loop loaded at /plan canonicalizes the hash to overview', async () => { + window.location.hash = '#p1/loop/loop-a/plan' + payload = makePayload({ + dashLoop: { goal: 'GOAL TEXT', plan: null, sections: [], loop: { kind: 'goal', totalSections: 0 } }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + // Plan tab is unavailable for a goal loop with no plan; URL canonicalizes + // to the loop route (overview is the default and omitted from the hash). + expect(location.hash).toBe('#p1/loop/loop-a') + const active = container.querySelector('.tab-item.tab-active') as HTMLElement + expect(active?.dataset.tab).toBe('overview') + expect(container.querySelector('.tab-item[data-tab="plan"]')).toBeFalsy() + }) + + test('a poll that removes the active conditional tab canonicalizes back to overview', async () => { + window.location.hash = '#p1/loop/loop-a' + payload = makePayload({ + dashLoop: { plan: 'PLAN BODY' }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const planTab = container.querySelector('.tab-item[data-tab="plan"]') as HTMLElement + expect(planTab).toBeTruthy() + planTab.click() + await flush() + expect(location.hash).toBe('#p1/loop/loop-a/plan') + + // Poll mutates the loop so plan becomes null → plan tab disappears while + // the route still says /plan. The canonicalization effect must revert the + // route to overview and rewrite the hash. + await poll(makePayload({ dashLoop: { plan: null } })) + + expect(location.hash).toBe('#p1/loop/loop-a') + const active = container.querySelector('.tab-item.tab-active') as HTMLElement + expect(active?.dataset.tab).toBe('overview') + expect(container.querySelector('.tab-item[data-tab="plan"]')).toBeFalsy() + }) +}) + +describe('dashboard App timeline tab', () => { + function transitionFixture(over: Record[] = []): any[] { + return over.map((t, i) => ({ + id: i + 1, + projectId: 'p1', + loopName: 'loop-a', + eventType: 'evt-' + i, + transitionKind: 'phase', + fromPhase: 'coding', + toPhase: 'auditing', + status: null, + reason: null, + iteration: 1, + sectionIndex: null, + createdAt: 1700000010000 + i * 1000, + ...t, + })) + } + + test('timeline renders one .phase-seg per span, each carrying data-phase', async () => { + window.location.hash = '#p1/loop/loop-a/timeline' + payload = makePayload({ + dashLoop: { + transitions: transitionFixture([ + { fromPhase: 'coding', toPhase: 'auditing', createdAt: 1700000000500 }, + { fromPhase: 'auditing', toPhase: 'final_auditing', createdAt: 1700000001500 }, + ]), + }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const body = container.querySelector('.tab-body[data-tab="timeline"]') as HTMLElement + const segs = Array.from(body.querySelectorAll('.phase-seg')) as HTMLElement[] + expect(segs.length).toBeGreaterThan(0) + expect(segs.every(s => s.dataset.phase !== '')).toBe(true) + // Two spans closed by transitions plus one open trailing span. + expect(segs.map(s => s.dataset.phase)).toEqual(['coding', 'auditing', 'final_auditing']) + }) + + test('a running loop\'s final .phase-seg carries the phase-seg-open class', async () => { + window.location.hash = '#p1/loop/loop-a/timeline' + payload = makePayload({ + dashLoop: { + transitions: transitionFixture([ + { fromPhase: 'coding', toPhase: 'auditing', createdAt: 1700000000500 }, + ]), + }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const body = container.querySelector('.tab-body[data-tab="timeline"]') as HTMLElement + const segs = Array.from(body.querySelectorAll('.phase-seg')) as HTMLElement[] + expect(segs.length).toBe(2) + expect(segs[segs.length - 1].classList.contains('phase-seg-open')).toBe(true) + }) + + test('a same-phase restarted loop displays its open current-phase span', async () => { + window.location.hash = '#p1/loop/loop-a/timeline' + // The loop row carries the new restart startedAt and the preserved phase + // (final_auditing). The prior terminal row from the dead lifecycle sits + // before the new startedAt; no restart row was recorded because the phase + // was preserved. + const restartStartedAt = 1700000100000 + payload = makePayload({ + dashLoop: { + transitions: transitionFixture([ + { fromPhase: 'final_auditing', toPhase: null, transitionKind: 'terminate', createdAt: restartStartedAt - 5000 }, + ]), + }, + loop: { startedAt: restartStartedAt, phase: 'final_auditing' }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const body = container.querySelector('.tab-body[data-tab="timeline"]') as HTMLElement + const segs = Array.from(body.querySelectorAll('.phase-seg')) as HTMLElement[] + // One open current-phase span from the new startedAt; no closed span is + // attributed to the dead lifecycle. + expect(segs.length).toBe(1) + expect(segs[0].dataset.phase).toBe('final_auditing') + expect(segs[0].classList.contains('phase-seg-open')).toBe(true) + // Truncation indicator must not appear: the prior terminal proves the + // pre-startedAt history exists, not that it was dropped. + expect(body.querySelector('.phase-truncated')).toBeNull() + const totalsRows = Array.from(body.querySelectorAll('.phase-totals-row')) as HTMLElement[] + expect(totalsRows.length).toBe(1) + expect(totalsRows[0].querySelector('.phase-totals-dot')?.getAttribute('data-phase')).toBe('final_auditing') + }) + + test('timeline event list renders 20 rows plus an expander when 100 transitions are present', async () => { + window.location.hash = '#p1/loop/loop-a/timeline' + const transitions: any[] = [] + for (let i = 0; i < 100; i++) { + transitions.push({ + id: i + 1, + projectId: 'p1', + loopName: 'loop-a', + eventType: 'evt-' + i, + transitionKind: 'phase', + fromPhase: 'coding', + toPhase: 'auditing', + status: null, + reason: null, + iteration: i + 1, + sectionIndex: null, + createdAt: 1700000010000 + i * 1000, + }) + } + payload = makePayload({ dashLoop: { transitions } }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const body = container.querySelector('.tab-body[data-tab="timeline"]') as HTMLElement + const rows = body.querySelectorAll('.tl-event') + expect(rows.length).toBe(20) + const expander = body.querySelector('.tl-event-expand') as HTMLElement + expect(expander).toBeTruthy() + expect(expander.textContent).toContain('80 earlier events') + + expander.click() + await flush() + expect(body.querySelectorAll('.tl-event').length).toBe(100) + expect(body.querySelector('.tl-event-expand')).toBeNull() + }) + + test('the machine graph lives inside a collapsed
on the Timeline tab and keeps five phase nodes', async () => { + window.location.hash = '#p1/loop/loop-a/timeline' + payload = makePayload() + dispose = render(() => App() as unknown as Element, container) + await flush() + + const body = container.querySelector('.tab-body[data-tab="timeline"]') as HTMLElement + const details = body.querySelector('details') as HTMLDetailsElement + expect(details).toBeTruthy() + // Collapsed by default. + expect(details.open).toBe(false) + const summary = details.querySelector('summary') as HTMLElement + expect(summary?.textContent).toContain('State machine graph') + // Machine graph still renders five phase nodes inside the details block. + expect(details.querySelectorAll('g.mg-node').length).toBe(5) + }) + + test('the SVG root survives a poll that changes the active phase', async () => { + window.location.hash = '#p1/loop/loop-a/timeline' + payload = makePayload({ loop: { phase: 'coding', status: 'running' } }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const svg1 = container.querySelector('svg.mg-svg') as SVGElement + expect(svg1).toBeTruthy() + ;(svg1 as any).__id = 'orig' + + await poll(makePayload({ loop: { phase: 'auditing', status: 'running' } })) + + const svg2 = container.querySelector('svg.mg-svg') as SVGElement + expect(svg2).toBe(svg1) + expect((svg2 as any).__id).toBe('orig') + }) + + test('advancing the dashboard clock during an unchanged poll updates the open phase duration', async () => { + window.location.hash = '#p1/loop/loop-a/timeline' + const startedAt = 1700000000000 + const initialNow = startedAt + 8000 + payload = makePayload({ + dashLoop: { + transitions: transitionFixture([ + { fromPhase: 'coding', toPhase: 'auditing', createdAt: startedAt + 3000 }, + ]), + }, + loop: { startedAt, phase: 'auditing', status: 'running', completedAt: null }, + }) + const spy = vi.spyOn(Date, 'now').mockReturnValue(initialNow) + try { + dispose = render(() => App() as unknown as Element, container) + await flush() + + const body = container.querySelector('.tab-body[data-tab="timeline"]') as HTMLElement + const valueBefore = (body.querySelector('.phase-totals-value') as HTMLElement)?.textContent ?? '' + // Open auditing span = 8s - 3s = 5s. + expect(valueBefore).toContain('5s') + + const svgBefore = container.querySelector('svg.mg-svg') as SVGElement + expect(svgBefore).toBeTruthy() + + // Advance the dashboard clock by 10s with no payload changes; the poll + // would normally be skipped because dataHash is unchanged, but the + // shared reactive clock must still drive phase-bar recomputation. + spy.mockReturnValue(startedAt + 18000) + await poll(payload) + + const valueAfter = (body.querySelector('.phase-totals-value') as HTMLElement)?.textContent ?? '' + // Open auditing span now = 18s - 3s = 15s. + expect(valueAfter).toContain('15s') + expect(valueAfter).not.toBe(valueBefore) + + const svgAfter = container.querySelector('svg.mg-svg') as SVGElement + expect(svgAfter).toBe(svgBefore) + } finally { + spy.mockRestore() + } + }) + + test('a truncated loop marks the timeline bar and excludes the unknown leading span from totals and segs', async () => { + window.location.hash = '#p1/loop/loop-a/timeline' + const startedAt = 1700000000000 + payload = makePayload({ + dashLoop: { + transitions: transitionFixture([ + { fromPhase: 'coding', toPhase: 'auditing', createdAt: startedAt + 5000 }, + { fromPhase: 'auditing', toPhase: 'final_auditing', createdAt: startedAt + 8000 }, + ]), + }, + loop: { startedAt, phase: 'final_auditing', status: 'running', completedAt: null }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const body = container.querySelector('.tab-body[data-tab="timeline"]') as HTMLElement + + // The truncation warning is shown. + expect(body.querySelector('.phase-truncated')).toBeTruthy() + + // The phase bar carries the truncation indicator and a marker glyph. + const bar = body.querySelector('.phase-bar') as HTMLElement + expect(bar.classList.contains('phase-bar-trunc')).toBe(true) + expect(bar.dataset.truncated).toBe('true') + expect(bar.querySelector('.phase-bar-trunc-mark')).toBeTruthy() + + // Only the two known spans are rendered as segs; the unknown leading + // coding interval is not attributed to the coding phase. + const segs = Array.from(body.querySelectorAll('.phase-seg')) as HTMLElement[] + expect(segs.map(s => s.dataset.phase)).toEqual(['auditing', 'final_auditing']) + expect(segs.every(s => s.dataset.phase !== '' && s.dataset.phase !== 'coding')).toBe(true) + + // Totals exclude coding entirely: the only rows are auditing and final_auditing. + const totalLabels = Array.from(body.querySelectorAll('.phase-totals-label')).map(el => el.textContent?.trim() ?? '') + expect(totalLabels).not.toContain('coding') + expect(totalLabels).toContain('auditing') + expect(totalLabels).toContain('final_auditing') + }) + + test('a truncated loop shows the truncation indicator on the overview and table phase bars', async () => { + window.location.hash = '#p1/loop/loop-a/overview' + const startedAt = 1700000000000 + payload = makePayload({ + dashLoop: { + transitions: transitionFixture([ + { fromPhase: 'coding', toPhase: 'auditing', createdAt: startedAt + 5000 }, + { fromPhase: 'auditing', toPhase: 'final_auditing', createdAt: startedAt + 8000 }, + ]), + }, + loop: { startedAt, phase: 'final_auditing', status: 'running', completedAt: null }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + // Overview bar marks truncation and excludes the unknown leading span. + const overviewBody = container.querySelector('.tab-body[data-tab="overview"]') as HTMLElement + const overviewBar = overviewBody.querySelector('.phase-bar') as HTMLElement + expect(overviewBar.classList.contains('phase-bar-trunc')).toBe(true) + expect(Array.from(overviewBar.querySelectorAll('.phase-seg')).map(s => (s as HTMLElement).dataset.phase)) + .toEqual(['auditing', 'final_auditing']) + + // Back at level 1, the loop-table row's compact bar also marks truncation. + window.location.hash = '#p1' + await flush() + const table = container.querySelector('table.loop-table') as HTMLElement + const rowBar = table.querySelector('.phase-bar') as HTMLElement + expect(rowBar).toBeTruthy() + expect(rowBar.classList.contains('phase-bar-trunc')).toBe(true) + expect(Array.from(rowBar.querySelectorAll('.phase-seg')).map(s => (s as HTMLElement).dataset.phase)) + .toEqual(['auditing', 'final_auditing']) + }) + + test('event elapsed renders unavailable for truncated oldest and post-terminal rows', async () => { + window.location.hash = '#p1/loop/loop-a/timeline' + const startedAt = 1700000000000 + payload = makePayload({ + dashLoop: { + transitions: transitionFixture([ + { fromPhase: 'coding', toPhase: null, transitionKind: 'terminate', createdAt: startedAt + 500 }, + { fromPhase: 'coding', toPhase: 'auditing', createdAt: startedAt + 10000 }, + { fromPhase: 'auditing', toPhase: 'final_auditing', createdAt: startedAt + 15000 }, + ]), + }, + loop: { startedAt, phase: 'final_auditing', status: 'running', completedAt: null }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const body = container.querySelector('.tab-body[data-tab="timeline"]') as HTMLElement + const rows = Array.from(body.querySelectorAll('.tl-event')) as HTMLElement[] + // Newest first: t3 (final_auditing), t2 (auditing), t1 (terminal coding). + expect(rows).toHaveLength(3) + const elapsed = rows.map(r => (r.querySelector('.tl-event-elapsed') as HTMLElement)?.textContent?.trim() ?? '') + // t3 follows a contiguous boundary at t2 → 5s. + expect(elapsed[0]).toBe('5s') + // t2 follows a terminal row → unavailable. + expect(elapsed[1]).toBe('—') + // t1 is the oldest row; not truncated (first transition < 1s after start) + // → 500ms in coding before terminating. + expect(elapsed[2]).toBe('0s') + }) + + test('event elapsed renders unavailable for the oldest row of a truncated window', async () => { + window.location.hash = '#p1/loop/loop-a/timeline' + const startedAt = 1700000000000 + payload = makePayload({ + dashLoop: { + transitions: transitionFixture([ + { fromPhase: 'coding', toPhase: 'auditing', createdAt: startedAt + 5000 }, + { fromPhase: 'auditing', toPhase: 'final_auditing', createdAt: startedAt + 8000 }, + ]), + }, + loop: { startedAt, phase: 'final_auditing', status: 'running', completedAt: null }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const body = container.querySelector('.tab-body[data-tab="timeline"]') as HTMLElement + const rows = Array.from(body.querySelectorAll('.tl-event')) as HTMLElement[] + // Newest first: t2 (auditing→final_auditing), t1 (coding→auditing). + expect(rows).toHaveLength(2) + const elapsed = rows.map(r => (r.querySelector('.tl-event-elapsed') as HTMLElement)?.textContent?.trim() ?? '') + // t2 follows a contiguous boundary at t1 → 3s. + expect(elapsed[0]).toBe('3s') + // t1 is the oldest row of a truncated window → unavailable. + expect(elapsed[1]).toBe('—') + }) + + test('a same-phase restart followed by a transition renders startedAt-relative elapsed', async () => { + // Loop terminates in final_auditing, restarts in that same phase (no + // restart row emitted because the phase did not change), then transitions + // ten minutes later. The first current-lifecycle transition's elapsed + // must be measured from the new startedAt, not rendered as unavailable. + window.location.hash = '#p1/loop/loop-a/timeline' + const startedAt = 1700000100000 + payload = makePayload({ + dashLoop: { + transitions: transitionFixture([ + { fromPhase: 'final_auditing', toPhase: null, transitionKind: 'terminate', createdAt: startedAt - 5000 }, + { fromPhase: 'final_auditing', toPhase: 'coding', createdAt: startedAt + 600000 }, + ]), + }, + loop: { startedAt, phase: 'coding', status: 'running', completedAt: null }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const body = container.querySelector('.tab-body[data-tab="timeline"]') as HTMLElement + const rows = Array.from(body.querySelectorAll('.tl-event')) as HTMLElement[] + // Newest first: t2 (final_auditing → coding), t1 (terminal). + expect(rows).toHaveLength(2) + const elapsed = rows.map(r => (r.querySelector('.tl-event-elapsed') as HTMLElement)?.textContent?.trim() ?? '') + // t2's elapsed is measured from the current lifecycle's startedAt: 10m. + expect(elapsed[0]).toBe('10m 0s') + // t1 is the oldest row predating startedAt; unavailable. + expect(elapsed[1]).toBe('—') + }) + + test('a transition after a phase-changing restart marker renders startedAt-relative elapsed, not marker-relative', async () => { + // Realistic timing: the loop row restores the new phase and a fresh + // startedAt BEFORE the restart transition row is recorded, so the marker + // lands 100ms after startedAt. The successor transition at 5s must render + // its elapsed from startedAt (5s), not from the marker timestamp (4.9s). + window.location.hash = '#p1/loop/loop-a/timeline' + const startedAt = 1700000100000 + payload = makePayload({ + dashLoop: { + transitions: transitionFixture([ + { fromPhase: 'final_audit_fix', toPhase: null, transitionKind: 'terminate', createdAt: startedAt - 5000 }, + { fromPhase: 'final_audit_fix', toPhase: 'coding', eventType: 'restart', transitionKind: 'phase', createdAt: startedAt + 100 }, + { fromPhase: 'coding', toPhase: 'auditing', createdAt: startedAt + 5000 }, + ]), + }, + loop: { startedAt, phase: 'auditing', status: 'running', completedAt: null }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const body = container.querySelector('.tab-body[data-tab="timeline"]') as HTMLElement + const rows = Array.from(body.querySelectorAll('.tl-event')) as HTMLElement[] + // Newest first: t3 (coding→auditing), t2 (restart), t1 (terminal). + expect(rows).toHaveLength(3) + const elapsed = rows.map(r => (r.querySelector('.tl-event-elapsed') as HTMLElement)?.textContent?.trim() ?? '') + // t3 measures elapsed from the current lifecycle's startedAt: 5s. + expect(elapsed[0]).toBe('5s') + // t2 is a restart marker (lifecycle boundary); elapsed unavailable. + expect(elapsed[1]).toBe('—') + // t1 predates startedAt; elapsed unavailable. + expect(elapsed[2]).toBe('—') + }) + + test('a dense compact phase bar preserves zero-duration spans at width:0% without overflowing', async () => { + // 100 transitions sharing the same createdAt produce one positive closed + // span (startedAt → burstAt) followed by 99 zero-duration spans and one + // open trailing span. Zero-duration known spans must render as width:0% + // segments (one .phase-seg per known span, ordering preserved) without + // consuming visible width, so the compact 80px table bar cannot clip the + // open current-phase seg. + window.location.hash = '#p1' + const startedAt = 1700000000000 + const burstAt = startedAt + 1000 + const transitions: any[] = [] + for (let i = 0; i < 100; i++) { + transitions.push({ + id: i + 1, + projectId: 'p1', + loopName: 'loop-a', + eventType: 'evt-' + i, + transitionKind: 'phase', + fromPhase: i % 2 === 0 ? 'coding' : 'auditing', + toPhase: i % 2 === 0 ? 'auditing' : 'coding', + status: null, + reason: null, + iteration: 1, + sectionIndex: null, + createdAt: burstAt, + }) + } + payload = makePayload({ + dashLoop: { transitions }, + loop: { startedAt, phase: 'coding', status: 'running', completedAt: null }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const table = container.querySelector('table.loop-table') as HTMLElement + const rowBar = table.querySelector('.phase-bar') as HTMLElement + expect(rowBar).toBeTruthy() + const segs = Array.from(rowBar.querySelectorAll('.phase-seg')) as HTMLElement[] + // 101 known spans render: one positive coding span (startedAt → burstAt), + // 99 closed zero-duration spans (alternating auditing/coding), and one + // open trailing coding span. Only the unknown truncated-history marker + // (empty phase) is excluded; none exists here. + expect(segs.length).toBe(101) + expect(segs.every(s => s.style.width.endsWith('%'))).toBe(true) + // Zero-duration known spans render at width:0% so they consume no bar + // width; the positive and open spans carry the only nonzero widths. + const widths = segs.map(s => parseFloat(s.style.width)) + expect(widths[0]).toBeGreaterThan(0) + expect(widths[widths.length - 1]).toBeGreaterThan(0) + for (let i = 1; i < widths.length - 1; i++) { + expect(widths[i]).toBe(0) + } + expect(segs[0].dataset.phase).toBe('coding') + expect(segs[0].classList.contains('phase-seg-open')).toBe(false) + expect(segs[segs.length - 1].dataset.phase).toBe('coding') + expect(segs[segs.length - 1].classList.contains('phase-seg-open')).toBe(true) + // Rendered widths sum to 100% (zero-duration segs contribute nothing) and + // the bar does not grow wider than its 80px column. + const totalPct = widths.reduce((acc, w) => acc + w, 0) + expect(totalPct).toBeCloseTo(100, 5) + const barRect = rowBar.getBoundingClientRect() + expect(barRect.width).toBeLessThanOrEqual(80) + }) +}) + +describe('dashboard App groups section', () => { + function makeGroup(over: Record = {}): any { + const groupOver = over.group || {} + delete over.group + return { + id: groupOver.groupId ?? 'g-abc', + group: { + projectId: 'p1', + groupId: 'g-abc', + title: 'Group ABC', + status: 'running', + maxConcurrent: 3, + executionModel: null, + auditorModel: null, + splitterSessionId: null, + hostSessionId: null, + error: null, + createdAt: 1700000000000, + updatedAt: 1700000000000, + completedAt: null, + prdPreview: null, + ...groupOver, + }, + features: [], + ...over, + } + } + + function makeFeature(over: Record = {}): any { + return { + projectId: 'p1', + groupId: 'g-abc', + featureIndex: 0, + title: 'Feature', + description: 'd', + stage: 'pending', + architectSessionId: null, + loopName: null, + error: null, + attempts: 0, + createdAt: 1700000000000, + updatedAt: 1700000000000, + ...over, + } + } + + test('#p1/groups renders one .group-row per group with a features meter', async () => { + window.location.hash = '#p1/groups' + payload = makePayload({ + groups: [ + makeGroup({ group: { groupId: 'g-a', title: 'A' } }), + makeGroup({ group: { groupId: 'g-b', title: 'B' } }), + ], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const rows = Array.from(container.querySelectorAll('.group-row')) as HTMLElement[] + expect(rows.length).toBe(2) + expect(rows.every(r => r.querySelector('.group-row-meter'))).toBe(true) + // Loop table is hidden on the groups section. + expect(container.querySelector('table.loop-table')).toBeFalsy() + }) + + test('#p1/groups/g-abc renders one .feature-row per feature', async () => { + window.location.hash = '#p1/groups/g-abc' + payload = makePayload({ + groups: [ + makeGroup({ + features: [ + makeFeature({ featureIndex: 0, title: 'F0' }), + makeFeature({ featureIndex: 1, title: 'F1' }), + makeFeature({ featureIndex: 2, title: 'F2' }), + ], + }), + ], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const rows = Array.from(container.querySelectorAll('.feature-row')) as HTMLElement[] + expect(rows.length).toBe(3) + expect(rows.map(r => r.querySelector('.feature-index')?.textContent)).toEqual(['#0', '#1', '#2']) + }) + + test('a feature with a loopName in the payload renders a link; one without renders its stage as text', async () => { + window.location.hash = '#p1/groups/g-abc' + payload = makePayload({ + loops: [ + makeLoop({ loop: { loopName: 'loop-linked', status: 'running' } }), + ], + groups: [ + makeGroup({ + features: [ + makeFeature({ featureIndex: 0, title: 'Linked', stage: 'running', loopName: 'loop-linked' }), + makeFeature({ featureIndex: 1, title: 'Pending', stage: 'pending' }), + ], + }), + ], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const rows = Array.from(container.querySelectorAll('.feature-row')) as HTMLElement[] + expect(rows.length).toBe(2) + const linked = rows[0] + const pending = rows[1] + expect(linked.querySelector('.feature-loop-link')).toBeTruthy() + expect(linked.querySelector('.feature-loop-link')!.textContent).toBe('loop-linked') + // The anchor targets the canonical project-aware loop hash, not the index. + expect(linked.querySelector('.feature-loop-link')!.getAttribute('href')).toBe('#p1/loop/loop-linked') + // Pending feature has no loop link, but renders its stage as a status badge. + expect(pending.querySelector('.feature-loop-link')).toBeFalsy() + expect(pending.querySelector('.section-status')!.textContent).toBe('pending') + }) + + test('a feature loop link encodes its loop name and primary clicks still navigate', async () => { + window.location.hash = '#p1/groups/g-abc' + payload = makePayload({ + loops: [ + makeLoop({ loop: { loopName: 'loop with space/Slash', status: 'running' } }), + ], + groups: [ + makeGroup({ + features: [ + makeFeature({ featureIndex: 0, title: 'Linked', stage: 'running', loopName: 'loop with space/Slash' }), + ], + }), + ], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const anchor = container.querySelector('.feature-loop-link') as HTMLAnchorElement + expect(anchor).toBeTruthy() + expect(anchor.getAttribute('href')).toBe('#p1/loop/loop%20with%20space%2FSlash') + // Primary click navigates via the SPA handler without a full reload. + anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + await flush() + expect(location.hash).toBe('#p1/loop/loop%20with%20space%2FSlash') + }) + + test('modified primary clicks on a feature loop link preserve native anchor behavior', async () => { + window.location.hash = '#p1/groups/g-abc' + payload = makePayload({ + loops: [ + makeLoop({ loop: { loopName: 'loop-linked', status: 'running' } }), + ], + groups: [ + makeGroup({ + features: [ + makeFeature({ featureIndex: 0, title: 'Linked', stage: 'running', loopName: 'loop-linked' }), + ], + }), + ], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const anchor = container.querySelector('.feature-loop-link') as HTMLAnchorElement + expect(anchor).toBeTruthy() + // Unmodified primary click → SPA intercepts and prevents the anchor default. + const primaryEvt = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }) + anchor.dispatchEvent(primaryEvt) + await flush() + expect(primaryEvt.defaultPrevented).toBe(true) + expect(location.hash).toBe('#p1/loop/loop-linked') + + // Restore the group route before verifying modified clicks. + window.location.hash = '#p1/groups/g-abc' + await flush() + for (const init of [ + { metaKey: true }, { ctrlKey: true }, { shiftKey: true }, { altKey: true }, + ]) { + const evt = new MouseEvent('click', { bubbles: true, cancelable: true, ...init }) + anchor.dispatchEvent(evt) + await flush() + // SPA handler must not intercept; the browser keeps native anchor behavior. + expect(evt.defaultPrevented).toBe(false) + } + }) + + test('a feature with stage failed renders its error text', async () => { + window.location.hash = '#p1/groups/g-abc' + payload = makePayload({ + groups: [ + makeGroup({ + features: [ + makeFeature({ featureIndex: 0, title: 'Boom', stage: 'failed', error: 'explosion' }), + ], + }), + ], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const row = container.querySelector('.feature-row') as HTMLElement + expect(row).toBeTruthy() + expect(row.querySelector('.feature-error')?.textContent).toContain('explosion') + }) + + test('a completed group row exposes both created and completed timestamps; an active row exposes only created', async () => { + window.location.hash = '#p1/groups' + payload = makePayload({ + groups: [ + makeGroup({ id: 'g-active', group: { groupId: 'g-active', title: 'Active', status: 'running', createdAt: 1700000000000 } }), + makeGroup({ id: 'g-done', group: { groupId: 'g-done', title: 'Completed', status: 'completed', createdAt: 1700000000000, completedAt: 1700000500000 } }), + ], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const rows = Array.from(container.querySelectorAll('.group-row')) as HTMLElement[] + const active = rows.find(r => r.querySelector('.group-row-title')?.textContent === 'Active')! + const completed = rows.find(r => r.querySelector('.group-row-title')?.textContent === 'Completed')! + expect(active).toBeTruthy() + expect(completed).toBeTruthy() + expect(active.querySelector('.group-row-created')).toBeTruthy() + expect(active.querySelector('.group-row-completed')).toBeFalsy() + expect(completed.querySelector('.group-row-created')).toBeTruthy() + expect(completed.querySelector('.group-row-completed')).toBeTruthy() + }) + + test('an interrupted group carries a distinct class from a completed one', async () => { + window.location.hash = '#p1/groups' + payload = makePayload({ + groups: [ + makeGroup({ id: 'g-int', group: { groupId: 'g-int', title: 'Interrupted', status: 'interrupted' } }), + makeGroup({ id: 'g-done', group: { groupId: 'g-done', title: 'Completed', status: 'completed', completedAt: 1700000500000 } }), + ], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const rows = Array.from(container.querySelectorAll('.group-row')) as HTMLElement[] + expect(rows.length).toBe(2) + const interrupted = rows.find(r => r.querySelector('.group-row-title')?.textContent === 'Interrupted')! + const completed = rows.find(r => r.querySelector('.group-row-title')?.textContent === 'Completed')! + expect(interrupted).toBeTruthy() + expect(completed).toBeTruthy() + // Distinct classes: interrupted carries group-row-active (not terminal), + // completed carries group-row-terminal; status is reflected on data attr. + expect(interrupted.classList.contains('group-row-terminal')).toBe(false) + expect(completed.classList.contains('group-row-terminal')).toBe(true) + expect(interrupted.getAttribute('data-group-status')).toBe('interrupted') + expect(completed.getAttribute('data-group-status')).toBe('completed') + }) + + test('the Groups count in .section-nav matches the number of groups', async () => { + window.location.hash = '#p1/groups' + payload = makePayload({ + groups: [ + makeGroup({ id: 'g-a', group: { groupId: 'g-a', title: 'A' } }), + makeGroup({ id: 'g-b', group: { groupId: 'g-b', title: 'B' } }), + makeGroup({ id: 'g-c', group: { groupId: 'g-c', title: 'C' } }), + ], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const navItems = Array.from(container.querySelectorAll('.section-nav-item')) as HTMLElement[] + const groupsItem = navItems.find(i => i.textContent?.startsWith('Groups'))! + expect(groupsItem).toBeTruthy() + expect(groupsItem.querySelector('.section-nav-count')?.textContent).toBe('3') + }) + + test('the Groups count stays at the project total when loop filters match zero loops', async () => { + window.location.hash = '#p1/groups?q=nomatch-xyz' + payload = makePayload({ + loops: [ + makeLoop({ loop: { loopName: 'loop-a', status: 'running' } }), + makeLoop({ loop: { loopName: 'loop-b', status: 'completed' } }), + ], + groups: [ + makeGroup({ id: 'g-a', group: { groupId: 'g-a', title: 'A' } }), + makeGroup({ id: 'g-b', group: { groupId: 'g-b', title: 'B' } }), + ], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + // The hash carries the query (matching no loops), but the Groups count + // reflects the full project, not the filtered loop set. + expect(location.hash).toBe('#p1/groups?q=nomatch-xyz') + const navItems = Array.from(container.querySelectorAll('.section-nav-item')) as HTMLElement[] + const groupsItem = navItems.find(i => i.textContent?.startsWith('Groups'))! + expect(groupsItem).toBeTruthy() + expect(groupsItem.querySelector('.section-nav-count')?.textContent).toBe('2') + // Group rows still render. + expect(container.querySelectorAll('.group-row').length).toBe(2) + }) + + test('a project with groups but no loops is discoverable in the repo menu and navigable', async () => { + window.location.hash = '' + payload = makePayload({ + loops: [], + groups: [ + makeGroup({ id: 'g-only', group: { groupId: 'g-only', title: 'GroupOnly' } }), + ], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + // No empty state: the project is discoverable via its group. + expect(container.querySelector('.repo-menu')).toBeTruthy() + const menuItems = Array.from(container.querySelectorAll('.repo-menu-item')) as HTMLElement[] + expect(menuItems.length).toBe(1) + + // Selecting the project navigates to the (empty) loops section. + menuItems[0].click() + await flush() + expect(location.hash.startsWith('#p1/loops') || location.hash.startsWith('#p1')).toBe(true) + + // The Groups section is reachable from the group-only project and renders. + window.location.hash = '#p1/groups' + await flush() + const rows = Array.from(container.querySelectorAll('.group-row')) as HTMLElement[] + expect(rows.length).toBe(1) + expect(rows[0].querySelector('.group-row-title')?.textContent).toBe('GroupOnly') + }) + + test('a PRD preview with embedded HTML event handlers is rendered as inert text', async () => { + window.location.hash = '#p1/groups/g-abc' + const malicious = 'text' + payload = makePayload({ + groups: [ + makeGroup({ group: { groupId: 'g-abc', prdPreview: malicious } }), + ], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const preview = container.querySelector('.prd-preview') as HTMLElement + expect(preview).toBeTruthy() + // No img element is created from the raw HTML. + expect(preview.querySelector('img')).toBeNull() + // The payload is rendered as plain text (escaped), not parsed as HTML. + expect(preview.textContent).toBe(malicious) + // The event handler never executes. + expect((window as unknown as { __boom?: number }).__boom).toBeUndefined() + }) + + test('polling a feature completion updates the groups meter without rebuilding rows', async () => { + window.location.hash = '#p1/groups' + payload = makePayload({ + groups: [ + makeGroup({ + id: 'g-abc', + group: { groupId: 'g-abc', title: 'Metered' }, + features: [ + makeFeature({ featureIndex: 0, stage: 'completed' }), + makeFeature({ featureIndex: 1, stage: 'running' }), + ], + }), + ], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const row = container.querySelector('.group-row') as HTMLElement + expect(row).toBeTruthy() + expect(row.querySelector('.lt-meter-text')!.textContent).toBe('1/2') + const fillBefore = row.querySelector('.lt-meter-fill') as HTMLElement + expect(fillBefore.getAttribute('style')).toContain('50%') + + // Same group identity, second feature now completed. + await poll(makePayload({ + groups: [ + makeGroup({ + id: 'g-abc', + group: { groupId: 'g-abc', title: 'Metered', completedAt: 1700000500000 }, + features: [ + makeFeature({ featureIndex: 0, stage: 'completed' }), + makeFeature({ featureIndex: 1, stage: 'completed' }), + ], + }), + ], + })) + + // Same DOM node (keyed reconcile preserves identity). + const rowAfter = container.querySelector('.group-row') as HTMLElement + expect(rowAfter).toBe(row) + expect(rowAfter.querySelector('.lt-meter-text')!.textContent).toBe('2/2') + const fillAfter = rowAfter.querySelector('.lt-meter-fill') as HTMLElement + expect(fillAfter.getAttribute('style')).toContain('100%') + }) + + test('polling a running-to-errored group switches status class and data-group-status', async () => { + window.location.hash = '#p1/groups' + payload = makePayload({ + groups: [ + makeGroup({ + id: 'g-abc', + group: { groupId: 'g-abc', title: 'Boom', status: 'running' }, + features: [ + makeFeature({ featureIndex: 0, stage: 'running' }), + ], + }), + ], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const row = container.querySelector('.group-row') as HTMLElement + expect(row).toBeTruthy() + expect(row.classList.contains('group-row-active')).toBe(true) + expect(row.classList.contains('group-row-terminal')).toBe(false) + expect(row.getAttribute('data-group-status')).toBe('running') + + // Same identity, now errored. + await poll(makePayload({ + groups: [ + makeGroup({ + id: 'g-abc', + group: { groupId: 'g-abc', title: 'Boom', status: 'errored', error: 'kaboom' }, + features: [ + makeFeature({ featureIndex: 0, stage: 'running' }), + ], + }), + ], + })) + + const rowAfter = container.querySelector('.group-row') as HTMLElement + expect(rowAfter).toBe(row) + expect(rowAfter.classList.contains('group-row-terminal')).toBe(true) + expect(rowAfter.classList.contains('group-row-active')).toBe(false) + expect(rowAfter.getAttribute('data-group-status')).toBe('errored') + }) + + test('polling a feature stage and error updates the group detail row in place', async () => { + window.location.hash = '#p1/groups/g-abc' + payload = makePayload({ + groups: [ + makeGroup({ + id: 'g-abc', + group: { groupId: 'g-abc', title: 'Detail' }, + features: [ + makeFeature({ featureIndex: 0, title: 'F0', stage: 'running' }), + ], + }), + ], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const row = container.querySelector('.feature-row') as HTMLElement + expect(row).toBeTruthy() + expect(row.getAttribute('data-stage')).toBe('running') + expect(row.querySelector('.feature-error')).toBeNull() + + await poll(makePayload({ + groups: [ + makeGroup({ + id: 'g-abc', + group: { groupId: 'g-abc', title: 'Detail' }, + features: [ + makeFeature({ featureIndex: 0, title: 'F0', stage: 'failed', error: 'crashed' }), + ], + }), + ], + })) + + const rowAfter = container.querySelector('.feature-row') as HTMLElement + expect(rowAfter).toBe(row) + expect(rowAfter.getAttribute('data-stage')).toBe('failed') + expect(rowAfter.querySelector('.feature-error')?.textContent).toContain('crashed') + }) + + test('primary-clicking a group feature loop link clears filters from the hash', async () => { + window.location.hash = '#p1/groups/g-abc?status=errored&q=bug' + payload = makePayload({ + loops: [makeLoop({ loop: { loopName: 'loop-linked', status: 'errored' } })], + groups: [ + makeGroup({ + features: [ + makeFeature({ featureIndex: 0, title: 'Linked', stage: 'running', loopName: 'loop-linked' }), + ], + }), + ], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const anchor = container.querySelector('.feature-loop-link') as HTMLAnchorElement + expect(anchor).toBeTruthy() + expect(anchor.getAttribute('href')).toBe('#p1/loop/loop-linked') + anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + await flush() + expect(location.hash).toBe('#p1/loop/loop-linked') + expect(location.hash).not.toContain('status=') + expect(location.hash).not.toContain('q=') + }) +}) + +describe('dashboard App findings and plans sections', () => { + function makeFinding(over: Record = {}): any { + return { + projectId: 'p1', + file: 'a.ts', + line: 1, + severity: 'bug', + description: 'desc', + scenario: null, + loopName: 'loop-a', + sectionIndex: 0, + createdAt: 1700000000000, + ...over, + } + } + + test('#p1/findings does not render the loops table and renders the findings panel', async () => { + window.location.hash = '#p1/findings' + payload = makePayload({ + loops: [ + makeLoop({ loop: { loopName: 'loop-a', status: 'completed' }, findings: [ + makeFinding({ description: 'a1' }), + makeFinding({ severity: 'warning', description: 'a2' }), + ] }), + makeLoop({ loop: { loopName: 'loop-b', status: 'running' } }), + ], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(container.querySelector('table.loop-table')).toBeFalsy() + expect(container.querySelector('.findings-panel')).toBeTruthy() + // One block per loop that has findings; loop-b has none so it is omitted. + const blocks = Array.from(container.querySelectorAll('.findings-loop-block')) as HTMLElement[] + expect(blocks.length).toBe(1) + expect(blocks[0].getAttribute('data-loop')).toBe('loop-a') + expect(blocks[0].querySelectorAll('.finding').length).toBe(2) + // The panel header count totals findings across loops. + expect(container.querySelector('.findings-panel-count')?.textContent).toBe('2') + }) + + test('clicking a findings-group loop link navigates to the loop detail', async () => { + window.location.hash = '#p1/findings' + payload = makePayload({ + loops: [ + makeLoop({ loop: { loopName: 'loop-a', status: 'completed' }, findings: [makeFinding()] }), + ], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const link = container.querySelector('.findings-loop-link') as HTMLAnchorElement + expect(link).toBeTruthy() + expect(link.getAttribute('href')).toBe('#p1/loop/loop-a') + link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + await flush() + expect(location.hash).toBe('#p1/loop/loop-a') + expect(container.querySelector('.loop-detail-header')).toBeTruthy() + }) + + test('#p1/findings with no findings shows an empty state, not the loops table', async () => { + window.location.hash = '#p1/findings' + payload = makePayload({ + loops: [makeLoop({ loop: { loopName: 'loop-a', status: 'completed' } })], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(container.querySelector('table.loop-table')).toBeFalsy() + expect(container.querySelector('.findings-panel')).toBeTruthy() + expect(container.querySelector('.findings-panel .tab-empty')?.textContent).toContain('No findings') + }) + + test('#p1/plans does not render the loops table and lists loops with plans', async () => { + window.location.hash = '#p1/plans' + payload = makePayload({ + loops: [ + makeLoop({ loop: { loopName: 'loop-a', status: 'completed' }, plan: 'PLAN A' }), + makeLoop({ loop: { loopName: 'loop-b', status: 'running' }, plan: null }), + ], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(container.querySelector('table.loop-table')).toBeFalsy() + expect(container.querySelector('.plans-panel')).toBeTruthy() + const rows = Array.from(container.querySelectorAll('.plan-row')) as HTMLElement[] + expect(rows.length).toBe(1) + expect(rows[0].querySelector('.plan-row-name')?.textContent).toBe('loop-a') + expect(container.querySelector('.plans-panel-count')?.textContent).toBe('1') + }) + + test('clicking a plan row navigates to the loop detail', async () => { + window.location.hash = '#p1/plans' + payload = makePayload({ + loops: [makeLoop({ loop: { loopName: 'loop-a', status: 'completed' }, plan: 'PLAN A' })], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const row = container.querySelector('.plan-row') as HTMLElement + expect(row).toBeTruthy() + row.click() + await flush() + expect(location.hash).toBe('#p1/loop/loop-a') + expect(container.querySelector('.loop-detail-header')).toBeTruthy() + }) + + test('#p1/plans with no plans shows an empty state, not the loops table', async () => { + window.location.hash = '#p1/plans' + payload = makePayload({ + loops: [makeLoop({ loop: { loopName: 'loop-a', status: 'completed' }, plan: null })], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(container.querySelector('table.loop-table')).toBeFalsy() + expect(container.querySelector('.plans-panel')).toBeTruthy() + expect(container.querySelector('.plans-panel .tab-empty')?.textContent).toContain('No plans') + }) + + test('navigating from loops to findings via section nav hides the loops table', async () => { + window.location.hash = '#p1' + payload = makePayload({ + loops: [makeLoop({ loop: { loopName: 'loop-a', status: 'completed' } })], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + expect(container.querySelector('table.loop-table')).toBeTruthy() + + const navItems = Array.from(container.querySelectorAll('.section-nav-item')) as HTMLElement[] + const findingsItem = navItems.find(i => i.textContent?.startsWith('Findings'))! + expect(findingsItem).toBeTruthy() + findingsItem.click() + await flush() + expect(location.hash).toBe('#p1/findings') + expect(container.querySelector('table.loop-table')).toBeFalsy() + expect(container.querySelector('.findings-panel')).toBeTruthy() + }) + + test('section-nav findings and plans counts stay at the project total when loop filters match zero', async () => { + window.location.hash = '#p1/findings?q=nomatch-xyz' + payload = makePayload({ + loops: [ + makeLoop({ loop: { loopName: 'loop-a', status: 'completed' }, findings: [makeFinding({ description: 'a1' })], plan: 'PLAN A' }), + makeLoop({ loop: { loopName: 'loop-b', status: 'running' }, findings: [makeFinding({ description: 'b1' }), makeFinding({ description: 'b2' })], plan: 'PLAN B' }), + ], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(location.hash).toBe('#p1/findings?q=nomatch-xyz') + const navItems = Array.from(container.querySelectorAll('.section-nav-item')) as HTMLElement[] + const findingsItem = navItems.find(i => i.textContent?.startsWith('Findings'))! + const plansItem = navItems.find(i => i.textContent?.startsWith('Plans'))! + expect(findingsItem.querySelector('.section-nav-count')?.textContent).toBe('3') + expect(plansItem.querySelector('.section-nav-count')?.textContent).toBe('2') + expect(container.querySelectorAll('.findings-loop-block').length).toBe(2) + }) + + test('primary-clicking a findings loop link clears filters from the hash', async () => { + window.location.hash = '#p1/findings?status=errored&q=bug' + payload = makePayload({ + loops: [makeLoop({ loop: { loopName: 'loop-a', status: 'errored' }, findings: [makeFinding()] })], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const link = container.querySelector('.findings-loop-link') as HTMLAnchorElement + expect(link).toBeTruthy() + expect(link.getAttribute('href')).toBe('#p1/loop/loop-a') + link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + await flush() + expect(location.hash).toBe('#p1/loop/loop-a') + expect(location.hash).not.toContain('status=') + expect(location.hash).not.toContain('q=') + expect(container.querySelector('.loop-detail-header')).toBeTruthy() + }) + + test('primary-clicking a plan row clears filters from the hash', async () => { + window.location.hash = '#p1/plans?status=completed&q=bug' + payload = makePayload({ + loops: [makeLoop({ loop: { loopName: 'loop-a', status: 'completed' }, plan: 'PLAN A' })], + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const row = container.querySelector('.plan-row') as HTMLElement + expect(row).toBeTruthy() + row.click() + await flush() + expect(location.hash).toBe('#p1/loop/loop-a') + expect(location.hash).not.toContain('status=') + expect(location.hash).not.toContain('q=') + }) + + test('role split excludes unknown role from the execution and auditor bars', async () => { + window.location.hash = '#p1/loop/loop-a/usage' + payload = makePayload({ + dashLoop: { + usage: { + loopName: 'loop-a', + totalCost: 3.5, + totalInputTokens: 100, + totalOutputTokens: 20, + totalReasoningTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + totalMessageCount: 9, + byModel: { + 'exec-m': { cost: 2, inputTokens: 80, outputTokens: 15, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 6 }, + 'audit-m': { cost: 1, inputTokens: 20, outputTokens: 5, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 2 }, + '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 }, + }, + }, + }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const usageBody = container.querySelector('.tab-body[data-tab="usage"]') as HTMLElement + const roleBlock = Array.from(usageBody.querySelectorAll('.usage-block')).find(b => + b.querySelector('.usage-block-title')?.textContent === 'Role split')! as HTMLElement + const rows = Array.from(roleBlock.querySelectorAll('.usage-model-row')) as HTMLElement[] + expect(rows.length).toBe(2) + const names = rows.map(r => r.querySelector('.usage-model-name')?.textContent) + expect(names).not.toContain('unknown') + const execRow = rows.find(r => r.querySelector('.usage-model-name')?.textContent === 'execution')! + const auditRow = rows.find(r => r.querySelector('.usage-model-name')?.textContent === 'auditor')! + expect(execRow.querySelector('.usage-model-cost')?.textContent).toBe('$2.00') + expect(auditRow.querySelector('.usage-model-cost')?.textContent).toBe('$1.00') + }) +}) \ No newline at end of file diff --git a/test/dashboard/app-helpers.test.ts b/test/dashboard/app-helpers.test.ts index 39bbcd3a46..2fe4ed3541 100644 --- a/test/dashboard/app-helpers.test.ts +++ b/test/dashboard/app-helpers.test.ts @@ -1,13 +1,14 @@ import { describe, test, expect, beforeEach } from 'vitest' import { - parseLoopHash, - buildLoopHash, + parseDashboardHash, + buildDashboardHash, fmtTime, statusClass, sectionStatusClass, loopMatchesFilters, + sortLoops, dataHash, - deriveSidebarLabel, + buildRepoLabels, splitFindings, findingsLevel, formatFindingCount, @@ -18,9 +19,15 @@ import { formatUsageCost, tokenBreakdownSegments, modelUsageBars, + roleUsageBars, renderMarkdown, + tabsForLoop, + computePhaseSpans, + summarizePhaseTotals, + computeTimelineEvents, } from '../../src/dashboard/app/helpers' -import type { DashboardPayload, DashboardProject, DashboardLoop } from '../../src/dashboard/app/types' +import type { LoopTab } from '../../src/dashboard/app/helpers' +import type { DashboardPayload, DashboardProject, DashboardLoop, LoopTransitionRow } from '../../src/dashboard/app/types' import type { ReviewFindingRow } from '../../src/storage' // --------------------------------------------------------------------------- @@ -101,77 +108,292 @@ function mockFinding(overrides: Partial = {}): ReviewFindingRo } // --------------------------------------------------------------------------- -// parseLoopHash / buildLoopHash +// parseDashboardHash / buildDashboardHash // --------------------------------------------------------------------------- -describe('parseLoopHash / buildLoopHash', () => { - test('round-trip with project and loop name', () => { - const hash = buildLoopHash('/Users/x/proj', 'my-loop') - expect(hash).toBe('#' + encodeURIComponent('/Users/x/proj') + '/' + encodeURIComponent('my-loop')) - expect(parseLoopHash(hash)).toEqual({ projectId: '/Users/x/proj', loopName: 'my-loop' }) +describe('parseDashboardHash / buildDashboardHash', () => { + test('empty hash returns the default route', () => { + expect(parseDashboardHash('')).toEqual({ + projectId: null, + section: 'loops', + loopName: null, + tab: 'overview', + groupId: null, + statuses: [], + query: '', + }) }) - test('round-trip with URL-encoded special characters', () => { - const hash = buildLoopHash('my project', 'loop name') - expect(parseLoopHash(hash)).toEqual({ projectId: 'my project', loopName: 'loop name' }) + test('project-only hash yields projectId with default loops section', () => { + const r = parseDashboardHash('#p1') + expect(r.projectId).toBe('p1') + expect(r.section).toBe('loops') + expect(r.loopName).toBeNull() + expect(r.tab).toBe('overview') }) - test('empty hash returns null projectId and loopName', () => { - expect(parseLoopHash('')).toEqual({ projectId: null, loopName: null }) + test('groups section keyword sets section groups', () => { + const r = parseDashboardHash('#p1/groups') + expect(r.projectId).toBe('p1') + expect(r.section).toBe('groups') + expect(r.groupId).toBeNull() }) - test('hash with only hash prefix returns null', () => { - expect(parseLoopHash('#')).toEqual({ projectId: null, loopName: null }) + test('group detail sets groupId', () => { + const r = parseDashboardHash('#p1/groups/g-abc') + expect(r.section).toBe('groups') + expect(r.groupId).toBe('g-abc') }) - test('project-only hash (no slash)', () => { - expect(parseLoopHash('#proj-only')).toEqual({ projectId: 'proj-only', loopName: null }) + test('canonical loop route sets loopName with default overview tab', () => { + const r = parseDashboardHash('#p1/loop/my-loop') + expect(r.projectId).toBe('p1') + expect(r.section).toBe('loops') + expect(r.loopName).toBe('my-loop') + expect(r.tab).toBe('overview') }) - test('project with trailing slash but no loop name', () => { - expect(parseLoopHash('#proj/')).toEqual({ projectId: 'proj', loopName: null }) + test('explicit tab is parsed', () => { + const r = parseDashboardHash('#p1/loop/my-loop/timeline') + expect(r.loopName).toBe('my-loop') + expect(r.tab).toBe('timeline') }) - test('buildLoopHash with null projectId returns empty string', () => { - expect(buildLoopHash(null, null)).toBe('') - expect(buildLoopHash(null, 'loop')).toBe('') + test('unknown tab falls back to overview rather than throwing', () => { + const r = parseDashboardHash('#p1/loop/x/bogus') + expect(r.loopName).toBe('x') + expect(r.tab).toBe('overview') }) - test('buildLoopHash with empty-string projectId returns empty string', () => { - expect(buildLoopHash('', 'loop')).toBe('') + test('legacy second segment is treated as a loop name', () => { + const r = parseDashboardHash('#p1/my-loop') + expect(r.section).toBe('loops') + expect(r.loopName).toBe('my-loop') + expect(r.tab).toBe('overview') }) - test('buildLoopHash with projectId but no loopName omits trailing slash', () => { - const hash = buildLoopHash('proj-id', null) - expect(hash).toBe('#proj-id') - expect(parseLoopHash(hash)).toEqual({ projectId: 'proj-id', loopName: null }) + test('legacy collision guard: section keyword wins over loop name', () => { + const r = parseDashboardHash('#p1/loops') + expect(r.section).toBe('loops') + expect(r.loopName).toBeNull() + }) + + test('filters parse status and query', () => { + const r = parseDashboardHash('#p1?status=running,errored&q=post') + expect(r.statuses).toEqual(['running', 'errored']) + expect(r.query).toBe('post') + }) + + test('filters combine with a loop route', () => { + const r = parseDashboardHash('#p1/loop/x/usage?status=running') + expect(r.loopName).toBe('x') + expect(r.tab).toBe('usage') + expect(r.statuses).toEqual(['running']) + expect(r.query).toBe('') + }) + + test('percent-encoding of slashes and spaces round-trips', () => { + const h = '#' + encodeURIComponent('proj x/y') + '/loop/' + encodeURIComponent('loop a/b') + const r = parseDashboardHash(h) + expect(r.projectId).toBe('proj x/y') + expect(r.loopName).toBe('loop a/b') + expect(buildDashboardHash(r)).toBe(h) + }) + + test('buildDashboardHash omits defaults for a project-only route', () => { + expect( + buildDashboardHash({ + projectId: 'p1', + section: 'loops', + loopName: null, + tab: 'overview', + groupId: null, + statuses: [], + query: '', + }), + ).toBe('#p1') + }) + + test('round-trip property for canonical hashes', () => { + // Legacy (#p1/my-loop), section-keyword-only (#p1/loops), and unknown-tab + // (#p1/loop/x/bogus) inputs intentionally canonicalize on build per case + // 13, so they are excluded from the literal round-trip set below. + const hashes = [ + '#p1', + '#p1/groups', + '#p1/groups/g-abc', + '#p1/loop/my-loop', + '#p1/loop/my-loop/timeline', + '#p1?status=running,errored&q=post', + '#p1/loop/x/usage?status=running', + '#' + encodeURIComponent('proj x/y') + '/loop/' + encodeURIComponent('loop a/b'), + ] + for (const h of hashes) { + expect(buildDashboardHash(parseDashboardHash(h))).toBe(h) + } + }) + + test('legacy and unknown-tab inputs canonicalize via parse/build', () => { + // Although not literal round-trips, these inputs preserve their semantic + // route through a parse -> build -> parse cycle. + const normalize: [string, string][] = [ + ['#p1/my-loop', '#p1/loop/my-loop'], + ['#p1/loops', '#p1'], + ['#p1/loop/x/bogus', '#p1/loop/x'], + ] + for (const [input, canonical] of normalize) { + const built = buildDashboardHash(parseDashboardHash(input)) + expect(built).toBe(canonical) + const reparsed = parseDashboardHash(built) + expect(parseDashboardHash(canonical)).toEqual(reparsed) + } + }) + + test('malformed percent escape in project id does not throw', () => { + const r = parseDashboardHash('#%') + expect(r).toEqual({ + projectId: '%', + section: 'loops', + loopName: null, + tab: 'overview', + groupId: null, + statuses: [], + query: '', + }) + }) + + test('malformed percent escape in loop segment does not throw', () => { + const r = parseDashboardHash('#p1/loop/%') + expect(r.projectId).toBe('p1') + expect(r.loopName).toBe('%') + expect(r.tab).toBe('overview') + }) + + test('malformed percent escape in groups segment does not throw', () => { + const r = parseDashboardHash('#p1/groups/%GG') + expect(r.section).toBe('groups') + expect(r.groupId).toBe('%GG') + }) + + test('malformed percent escape in query value does not throw', () => { + const r = parseDashboardHash('#p1?q=%GG') + expect(r.projectId).toBe('p1') + expect(r.query).toBe('%GG') + }) + + test('malformed percent escape in query key does not throw', () => { + const r = parseDashboardHash('#p1?%GG=post') + expect(r.projectId).toBe('p1') + // Unknown key is ignored; route is otherwise valid. + expect(r.statuses).toEqual([]) + expect(r.query).toBe('') + }) + + test('mixed malformed escapes across path and query do not throw', () => { + const r = parseDashboardHash('#%ZZ/loop/%/timeline?status=%&q=%GG') + expect(r.projectId).toBe('%ZZ') + expect(r.loopName).toBe('%') + expect(r.tab).toBe('timeline') + expect(r.statuses).toEqual(['%']) + expect(r.query).toBe('%GG') }) }) // --------------------------------------------------------------------------- -// deriveSidebarLabel +// buildRepoLabels // --------------------------------------------------------------------------- -describe('deriveSidebarLabel', () => { - test('returns last segment of a path', () => { - expect(deriveSidebarLabel('/Users/chris/development/opencode-forge')).toBe('opencode-forge') +describe('buildRepoLabels', () => { + test('single path returns the bare basename', () => { + const labels = buildRepoLabels(['/Users/chris/development/oc-manager']) + expect(labels.get('/Users/chris/development/oc-manager')).toBe('oc-manager') + }) + + test('two non-colliding paths both return bare basenames', () => { + const labels = buildRepoLabels(['/a/alpha', '/b/beta']) + expect(labels.get('/a/alpha')).toBe('alpha') + expect(labels.get('/b/beta')).toBe('beta') + }) + + test('two colliding basenames resolve to parent/segment', () => { + const labels = buildRepoLabels(['/a/b/boatshare', '/x/y/boatshare']) + expect(labels.get('/a/b/boatshare')).toBe('b/boatshare') + expect(labels.get('/x/y/boatshare')).toBe('y/boatshare') + }) + + test('non-colliding sibling stays bare when only two of three collide', () => { + const labels = buildRepoLabels(['/a/b/boatshare', '/x/y/boatshare', '/u/v/other']) + expect(labels.get('/a/b/boatshare')).toBe('b/boatshare') + expect(labels.get('/x/y/boatshare')).toBe('y/boatshare') + expect(labels.get('/u/v/other')).toBe('other') + }) + + test('parent also collides — keep prepending until unique', () => { + const labels = buildRepoLabels(['/p/q/api', '/r/q/api']) + expect(labels.get('/p/q/api')).toBe('p/q/api') + expect(labels.get('/r/q/api')).toBe('r/q/api') + }) + + test('path with no separator returns itself', () => { + const labels = buildRepoLabels(['myrepo']) + expect(labels.get('myrepo')).toBe('myrepo') + }) + + test('empty string returns itself', () => { + const labels = buildRepoLabels(['']) + expect(labels.get('')).toBe('') + }) + + test('trailing slash uses the last non-empty segment', () => { + const labels = buildRepoLabels(['/a/b/repo/']) + expect(labels.get('/a/b/repo/')).toBe('repo') + }) + + test('duplicate raw paths do not inflate collision depth', () => { + const labels = buildRepoLabels(['/a/repo', '/a/repo', '/b/repo']) + expect(labels.get('/a/repo')).toBe('a/repo') + expect(labels.get('/b/repo')).toBe('b/repo') }) +}) - test('returns the input when it has no slashes', () => { - expect(deriveSidebarLabel('simple-id')).toBe('simple-id') +// --------------------------------------------------------------------------- +// tabsForLoop +// --------------------------------------------------------------------------- + +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 }, + ], + }) + expect(tabsForLoop(loop)).toEqual(['overview', 'timeline', 'sections', 'findings', 'plan', 'usage']) }) - test('returns empty string for empty input', () => { - expect(deriveSidebarLabel('')).toBe('') + test('goal loop with zero sections omits the sections tab', () => { + const loop = mockDashLoop({ plan: null, sections: [] }) + expect(tabsForLoop(loop)).toEqual(['overview', 'timeline', 'findings', 'usage']) }) - test('handles trailing slash', () => { - expect(deriveSidebarLabel('/foo/bar/')).toBe('bar') + test('loop with plan: null 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 }, + ], + }) + const tabs = tabsForLoop(loop) + expect(tabs).not.toContain('plan') + expect(tabs).toContain('sections') }) - test('handles root path — falls back to rawPath when no segments', () => { - // Original render.ts: rawSegments.length ? last : rawPath - expect(deriveSidebarLabel('/')).toBe('/') + test('tabsForLoop never returns an empty array', () => { + const loop = mockDashLoop({ plan: null, sections: [] }) + const tabs = tabsForLoop(loop) + expect(tabs.length).toBeGreaterThan(0) + expect(tabs[0]).toBe('overview') + expect(tabs[tabs.length - 1]).toBe('usage') }) }) @@ -180,47 +402,134 @@ describe('deriveSidebarLabel', () => { // --------------------------------------------------------------------------- describe('loopMatchesFilters', () => { - const loop = mockLoopRow({ status: 'completed', loopName: 'feature-x' }) - const project = mockProject({ projectDir: '/projects/my-app', projectId: 'p1' }) + const loop = mockLoopRow({ + status: 'completed', + loopName: 'feature-x', + worktreeBranch: 'feat/branch-1', + }) + const label = 'my-app' - test('empty activeStatuses matches all loops', () => { - expect(loopMatchesFilters(loop, project, new Set(), '')).toBe(true) + test('empty activeStatuses means no status filtering (matches all)', () => { + expect(loopMatchesFilters(loop, new Set(), '', label)).toBe(true) }) test('status membership filter — matching status', () => { - expect(loopMatchesFilters(loop, project, new Set(['completed']), '')).toBe(true) + expect(loopMatchesFilters(loop, new Set(['completed']), '', label)).toBe(true) }) test('status membership filter — non-matching status', () => { - expect(loopMatchesFilters(loop, project, new Set(['running']), '')).toBe(false) + expect(loopMatchesFilters(loop, new Set(['running']), '', label)).toBe(false) + }) + + test('matches on loop name substring', () => { + expect(loopMatchesFilters(loop, new Set(), 'feature', label)).toBe(true) }) - test('case-insensitive search matches loopName', () => { - // searchText must be pre-lowercased (matching original closure behavior) - expect(loopMatchesFilters(loop, project, new Set(), 'feature')).toBe(true) + test('matches on worktreeBranch substring', () => { + expect(loopMatchesFilters(loop, new Set(), 'branch-1', label)).toBe(true) }) - test('case-insensitive search matches projectDir', () => { - expect(loopMatchesFilters(loop, project, new Set(), 'my-app')).toBe(true) + test('matches on short repo label', () => { + expect(loopMatchesFilters(loop, new Set(), 'my-app', label)).toBe(true) }) - test('case-insensitive search matches projectId', () => { - const projNoDir = mockProject({ projectDir: null, projectId: 'fallback-id' }) - expect(loopMatchesFilters(loop, projNoDir, new Set(), 'fallback')).toBe(true) + test('does not match on a path segment absent from the label', () => { + // Full repo path would be /projects/my-app; the parent segment "projects" + // is intentionally excluded from the short label, so it must not match. + expect(loopMatchesFilters(loop, new Set(), 'projects', label)).toBe(false) }) test('non-matching search returns false', () => { - expect(loopMatchesFilters(loop, project, new Set(), 'nonexistent')).toBe(false) + expect(loopMatchesFilters(loop, new Set(), 'nonexistent', label)).toBe(false) }) test('search AND status filter combine correctly', () => { - expect(loopMatchesFilters(loop, project, new Set(['running']), 'feature')).toBe(false) - expect(loopMatchesFilters(loop, project, new Set(['completed']), 'feature')).toBe(true) + expect(loopMatchesFilters(loop, new Set(['running']), 'feature', label)).toBe(false) + expect(loopMatchesFilters(loop, new Set(['completed']), 'feature', label)).toBe(true) + }) + + test('loop with null worktreeBranch matches on other fields', () => { + const l = mockLoopRow({ loopName: 'feature-x', worktreeBranch: null }) + expect(loopMatchesFilters(l, new Set(), 'feature', label)).toBe(true) + }) + + test('empty label does not cause a false positive on whitespace', () => { + expect(loopMatchesFilters(loop, new Set(), '', '')).toBe(true) }) +}) + +// --------------------------------------------------------------------------- +// sortLoops +// --------------------------------------------------------------------------- + +describe('sortLoops', () => { + function makeDash(over: Partial = {}): DashboardLoop { + return { + loop: mockLoopRow(), + lastAuditResult: null, + plan: null, + sections: [], + findings: [], + usage: null, + duration: null, + ...over, + } + } - test('loop with null projectDir falls back to projectId for search', () => { - const projNoDir = mockProject({ projectDir: null, projectId: 'my-project' }) - expect(loopMatchesFilters(loop, projNoDir, new Set(), 'my-project')).toBe(true) + test('by cost puts loops with null usage last, not first', () => { + const loops = [ + makeDash({ + loop: mockLoopRow({ loopName: 'a' }), + usage: null, + }), + makeDash({ + loop: mockLoopRow({ loopName: 'b' }), + usage: { + loopName: 'b', + totalCost: 2, + totalInputTokens: 0, + totalOutputTokens: 0, + totalReasoningTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + totalMessageCount: 0, + byModel: {}, + byRole: {}, + }, + }), + makeDash({ + loop: mockLoopRow({ loopName: 'c' }), + usage: { + loopName: 'c', + totalCost: 5, + totalInputTokens: 0, + totalOutputTokens: 0, + totalReasoningTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + totalMessageCount: 0, + byModel: {}, + byRole: {}, + }, + }), + ] + const sorted = sortLoops(loops, 'cost') + expect(sorted.map(dl => dl.loop.loopName)).toEqual(['c', 'b', 'a']) + }) + + test('by recent preserves the running-first ordering', () => { + // Input mirrors the backend's running-first ordering (running loops first, + // then by startedAt desc). `recent` must not reorder. + const loops = [ + makeDash({ loop: mockLoopRow({ loopName: 'r1', status: 'running', startedAt: 1700000500000 }) }), + makeDash({ loop: mockLoopRow({ loopName: 'r2', status: 'running', startedAt: 1700000400000 }) }), + makeDash({ loop: mockLoopRow({ loopName: 'c1', status: 'completed', startedAt: 1700000300000 }) }), + makeDash({ loop: mockLoopRow({ loopName: 'c2', status: 'completed', startedAt: 1700000100000 }) }), + ] + const sorted = sortLoops(loops, 'recent') + expect(sorted.map(dl => dl.loop.loopName)).toEqual(['r1', 'r2', 'c1', 'c2']) + // Original array is not mutated. + expect(loops.map(dl => dl.loop.loopName)).toEqual(['r1', 'r2', 'c1', 'c2']) }) }) @@ -562,6 +871,7 @@ describe('tokenBreakdownSegments', () => { totalCacheWriteTokens: 2, totalMessageCount: 4, byModel: {}, + byRole: {}, } test('returns five labeled segments', () => { @@ -603,6 +913,7 @@ describe('modelUsageBars', () => { 'cheap-model': { cost: 0.5, inputTokens: 100, outputTokens: 20, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 2 }, 'pricey-model': { cost: 2, inputTokens: 400, outputTokens: 80, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 6 }, }, + byRole: {}, } test('sorts by cost descending', () => { @@ -621,6 +932,110 @@ describe('modelUsageBars', () => { }) }) +// --------------------------------------------------------------------------- +// roleUsageBars +// --------------------------------------------------------------------------- + +describe('roleUsageBars', () => { + const usage: NonNullable = { + loopName: 'test-loop', + totalCost: 3, + totalInputTokens: 0, + totalOutputTokens: 0, + totalReasoningTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + totalMessageCount: 8, + byModel: { + 'exec-model': { cost: 2, inputTokens: 100, outputTokens: 20, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, messageCount: 6 }, + '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 }, + }, + } + + test('splits cost into execution and auditor roles by recorded role', () => { + const bars = roleUsageBars(usage) + expect(bars).toHaveLength(2) + const exec = bars.find(b => b.role === 'execution')! + const audit = bars.find(b => b.role === 'auditor')! + expect(exec.cost).toBeCloseTo(2) + expect(audit.cost).toBeCloseTo(1) + expect(exec.messageCount).toBe(6) + expect(audit.messageCount).toBe(2) + }) + + test('pct is relative to the larger role cost', () => { + const bars = roleUsageBars(usage) + const exec = bars.find(b => b.role === 'execution')! + const audit = bars.find(b => b.role === 'auditor')! + expect(exec.pct).toBeCloseTo(100) + expect(audit.pct).toBeCloseTo(50) + }) + + test('unknown roles are excluded from the execution and auditor comparison', () => { + const extra: NonNullable = { + ...usage, + byRole: { + ...usage.byRole, + unknown: { cost: 0.5, messageCount: 1 }, + }, + } + const bars = roleUsageBars(extra) + const exec = bars.find(b => b.role === 'execution')! + const audit = bars.find(b => b.role === 'auditor')! + expect(exec.cost).toBeCloseTo(2) + expect(audit.cost).toBeCloseTo(1) + expect(exec.messageCount).toBe(6) + expect(audit.messageCount).toBe(2) + expect(bars).toHaveLength(2) + expect(bars.some(b => (b as { role: string }).role === 'unknown')).toBe(false) + }) + + test('zero-cost usage yields zero-width bars', () => { + const zero: NonNullable = { + ...usage, + byRole: { + code: { cost: 0, messageCount: 0 }, + auditor: { cost: 0, messageCount: 0 }, + }, + } + const bars = roleUsageBars(zero) + expect(bars.every(b => b.pct === 0)).toBe(true) + }) + + test('identical execution and auditor models keep their actual role costs', () => { + const sameModel: NonNullable = { + loopName: 'same-model-loop', + totalCost: 3, + totalInputTokens: 150, + totalOutputTokens: 30, + totalReasoningTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + totalMessageCount: 8, + byModel: { + '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 }, + }, + } + const bars = roleUsageBars(sameModel) + const exec = bars.find(b => b.role === 'execution')! + const audit = bars.find(b => b.role === 'auditor')! + expect(exec.cost).toBeCloseTo(2) + expect(audit.cost).toBeCloseTo(1) + expect(exec.messageCount).toBe(6) + expect(audit.messageCount).toBe(2) + expect(exec.pct).toBeCloseTo(100) + expect(audit.pct).toBeCloseTo(50) + }) +}) + // --------------------------------------------------------------------------- // renderMarkdown // --------------------------------------------------------------------------- @@ -655,3 +1070,635 @@ describe('renderMarkdown', () => { expect(renderMarkdown('')).toBe('') }) }) + +// --------------------------------------------------------------------------- +// computePhaseSpans / summarizePhaseTotals +// --------------------------------------------------------------------------- + +describe('computePhaseSpans', () => { + function makeTransition(overrides: Partial = {}): LoopTransitionRow { + return { + id: 1, + projectId: 'p1', + loopName: 'loop-a', + eventType: 'phase', + transitionKind: 'phase', + fromPhase: 'coding', + toPhase: 'auditing', + status: null, + reason: null, + iteration: 1, + sectionIndex: null, + createdAt: 0, + ...overrides, + } + } + + test('empty transitions on a running loop yield one open span ending at now', () => { + const startedAt = 1700000000000 + const now = startedAt + 5000 + const { spans, truncated } = computePhaseSpans([], startedAt, null, now) + expect(spans).toHaveLength(1) + expect(spans[0].open).toBe(true) + expect(spans[0].phase).toBe('coding') + expect(spans[0].endedAt).toBeNull() + expect(spans[0].durationMs).toBe(5000) + expect(truncated).toBe(false) + }) + + test('empty transitions on a completed loop yield one closed span ending at completedAt', () => { + const startedAt = 1700000000000 + const completedAt = startedAt + 7000 + const { spans, truncated } = computePhaseSpans([], startedAt, completedAt, completedAt + 1000) + expect(spans).toHaveLength(1) + expect(spans[0].open).toBe(false) + expect(spans[0].endedAt).toBe(completedAt) + expect(spans[0].durationMs).toBe(7000) + expect(truncated).toBe(false) + }) + + test('a single coding → auditing transition on a running loop yields a closed coding span and an open auditing span', () => { + const startedAt = 1700000000000 + const t1 = makeTransition({ createdAt: startedAt + 500, fromPhase: 'coding', toPhase: 'auditing' }) + const now = startedAt + 10000 + const { spans, truncated } = computePhaseSpans([t1], startedAt, null, now) + expect(spans).toHaveLength(2) + expect(spans[0].phase).toBe('coding') + expect(spans[0].open).toBe(false) + expect(spans[0].durationMs).toBe(500) + expect(spans[0].endedAt).toBe(t1.createdAt) + expect(spans[1].phase).toBe('auditing') + expect(spans[1].open).toBe(true) + expect(spans[1].durationMs).toBe(now - t1.createdAt) + expect(truncated).toBe(false) + }) + + test('a terminal transition closes the prior span and emits no trailing span', () => { + const startedAt = 1700000000000 + const t1 = makeTransition({ createdAt: startedAt + 500, fromPhase: 'coding', toPhase: null, transitionKind: 'terminate' }) + const { spans, truncated } = computePhaseSpans([t1], startedAt, null, startedAt + 9000) + expect(spans).toHaveLength(1) + expect(spans.every(s => !s.open)).toBe(true) + expect(spans[0].phase).toBe('coding') + expect(spans[0].endedAt).toBe(t1.createdAt) + expect(truncated).toBe(false) + }) + + test('three transitions with a terminal last yield three spans summing to total elapsed', () => { + const startedAt = 1700000000000 + const t1 = makeTransition({ id: 1, createdAt: startedAt + 1000, fromPhase: 'coding', toPhase: 'auditing' }) + const t2 = makeTransition({ id: 2, createdAt: startedAt + 3000, fromPhase: 'auditing', toPhase: 'final_auditing' }) + const t3 = makeTransition({ id: 3, createdAt: startedAt + 6000, fromPhase: 'final_auditing', toPhase: null, transitionKind: 'terminate' }) + const { spans } = computePhaseSpans([t1, t2, t3], startedAt, null, startedAt + 9000) + expect(spans).toHaveLength(3) + const total = spans.reduce((acc, s) => acc + s.durationMs, 0) + expect(total).toBe(t3.createdAt - startedAt) + }) + + test('duplicate timestamps clamp to a zero-duration span with no negative value', () => { + const startedAt = 1700000000000 + const t1 = makeTransition({ id: 1, createdAt: startedAt + 2000, fromPhase: 'coding', toPhase: 'auditing' }) + const t2 = makeTransition({ id: 2, createdAt: startedAt + 2000, fromPhase: 'auditing', toPhase: 'final_auditing' }) + const { spans } = computePhaseSpans([t1, t2], startedAt, null, startedAt + 5000) + expect(spans.every(s => s.durationMs >= 0)).toBe(true) + expect(spans[1].durationMs).toBe(0) + }) + + test('out-of-order timestamps clamp to non-negative durations', () => { + const startedAt = 1700000000000 + const t1 = makeTransition({ id: 1, createdAt: startedAt + 5000, fromPhase: 'coding', toPhase: 'auditing' }) + // t2 has a createdAt earlier than t1 — would produce a negative raw duration. + const t2 = makeTransition({ id: 2, createdAt: startedAt + 1000, fromPhase: 'auditing', toPhase: 'final_auditing' }) + const { spans } = computePhaseSpans([t1, t2], startedAt, null, startedAt + 7000) + expect(spans.every(s => s.durationMs >= 0)).toBe(true) + expect(spans[1].durationMs).toBe(0) + }) + + test('truncated is true when the first transition landed more than one second after startedAt', () => { + const startedAt = 1700000000000 + const t1 = makeTransition({ createdAt: startedAt + 5000 }) + const { truncated } = computePhaseSpans([t1], startedAt, null, startedAt + 10000) + expect(truncated).toBe(true) + }) + + test('truncated is false when the first transition lands within one second of startedAt', () => { + const startedAt = 1700000000000 + const t1 = makeTransition({ createdAt: startedAt + 500 }) + const { truncated } = computePhaseSpans([t1], startedAt, null, startedAt + 10000) + expect(truncated).toBe(false) + }) + + test('summarizePhaseTotals folds repeated visits to the same phase into one total', () => { + const startedAt = 1700000000000 + const t1 = makeTransition({ id: 1, createdAt: startedAt + 1000, fromPhase: 'coding', toPhase: 'auditing' }) + const t2 = makeTransition({ id: 2, createdAt: startedAt + 2000, fromPhase: 'auditing', toPhase: 'coding' }) + const t3 = makeTransition({ id: 3, createdAt: startedAt + 6000, fromPhase: 'coding', toPhase: 'auditing' }) + const { spans } = computePhaseSpans([t1, t2, t3], startedAt, null, startedAt + 8000) + const totals = summarizePhaseTotals(spans) + // coding visited twice: span0 (1000ms) + span2 (4000ms) = 5000ms. + expect(totals['coding']).toBe(5000) + // auditing visited twice: span1 (1000ms) + trailing open span (2000ms) = 3000ms. + expect(totals['auditing']).toBe(3000) + }) + + test('a truncated run marks the leading unknown span with an empty phase', () => { + const startedAt = 1700000000000 + const t1 = makeTransition({ id: 1, createdAt: startedAt + 5000, fromPhase: 'coding', toPhase: 'auditing' }) + const t2 = makeTransition({ id: 2, createdAt: startedAt + 8000, fromPhase: 'auditing', toPhase: 'final_auditing' }) + const now = startedAt + 12000 + const { spans, truncated } = computePhaseSpans([t1, t2], startedAt, null, now) + expect(truncated).toBe(true) + expect(spans).toHaveLength(3) + // The leading span covers unknown pre-window history; its phase is cleared + // so consumers can drop it from bars and totals without misattribution. + expect(spans[0].phase).toBe('') + expect(spans[0].durationMs).toBe(t1.createdAt - startedAt) + expect(spans[1].phase).toBe('auditing') + expect(spans[2].phase).toBe('final_auditing') + expect(spans[2].open).toBe(true) + }) + + test('summarizePhaseTotals excludes the leading unknown span of a truncated run', () => { + const startedAt = 1700000000000 + const t1 = makeTransition({ id: 1, createdAt: startedAt + 5000, fromPhase: 'coding', toPhase: 'auditing' }) + const t2 = makeTransition({ id: 2, createdAt: startedAt + 8000, fromPhase: 'auditing', toPhase: 'final_auditing' }) + const now = startedAt + 12000 + const { spans } = computePhaseSpans([t1, t2], startedAt, null, now) + const totals = summarizePhaseTotals(spans) + // coding only appears in the unknown leading span, so it must not be totalled. + expect(totals['coding']).toBeUndefined() + // Known spans: auditing (3000ms) + final_auditing open (4000ms). + expect(totals['auditing']).toBe(3000) + expect(totals['final_auditing']).toBe(4000) + const known = spans.filter(s => s.phase !== '').reduce((acc, s) => acc + s.durationMs, 0) + expect(known).toBe(now - t1.createdAt) + }) + + test('a terminal row followed by a restart continues processing and excludes inactive downtime', () => { + const startedAt = 1700000000000 + const t1 = makeTransition({ id: 1, createdAt: startedAt + 500, fromPhase: 'coding', toPhase: null, transitionKind: 'terminate' }) + const t2 = makeTransition({ id: 2, createdAt: startedAt + 10000, fromPhase: 'coding', toPhase: 'auditing' }) + const t3 = makeTransition({ id: 3, createdAt: startedAt + 15000, fromPhase: 'auditing', toPhase: 'final_auditing' }) + const completedAt = startedAt + 20000 + const { spans, truncated } = computePhaseSpans([t1, t2, t3], startedAt, completedAt, completedAt + 1000) + // Pre-restart coding (500ms) + restart fromPhase zero at t2 + auditing (5s) + trailing final_auditing (5s). + expect(spans).toHaveLength(4) + expect(spans.every(s => !s.open)).toBe(true) + expect(spans.map(s => s.phase)).toEqual(['coding', 'coding', 'auditing', 'final_auditing']) + expect(truncated).toBe(false) + // Restart fromPhase span begins at t2.createdAt and closes immediately. + expect(spans[1].startedAt).toBe(t2.createdAt) + expect(spans[1].endedAt).toBe(t2.createdAt) + expect(spans[1].durationMs).toBe(0) + // Total represented duration excludes the ~9.5s inactive gap. + const total = spans.reduce((acc, s) => acc + s.durationMs, 0) + expect(total).toBe(10500) + }) + + test('out-of-order timestamps keep effective boundaries monotonic and totals within the lifetime', () => { + const startedAt = 1700000000000 + const t1 = makeTransition({ id: 1, createdAt: startedAt + 5000, fromPhase: 'coding', toPhase: 'auditing' }) + const t2 = makeTransition({ id: 2, createdAt: startedAt + 1000, fromPhase: 'auditing', toPhase: 'final_auditing' }) + const now = startedAt + 7000 + const { spans } = computePhaseSpans([t1, t2], startedAt, null, now) + expect(spans.every(s => s.durationMs >= 0)).toBe(true) + expect(spans.filter(s => !s.open).every(s => s.startedAt <= s.endedAt!)).toBe(true) + // Out-of-order t2 must not move currentStart backwards: the auditing span + // starts at t1.createdAt (the effective boundary) and is zero-duration. + expect(spans[1].phase).toBe('auditing') + expect(spans[1].startedAt).toBe(t1.createdAt) + expect(spans[1].endedAt).toBe(t1.createdAt) + // Total cannot exceed the represented elapsed lifetime. + const total = spans.reduce((acc, s) => acc + s.durationMs, 0) + expect(total).toBeLessThanOrEqual(now - startedAt) + }) + + test('a terminal row followed by an out-of-order restart stays within the represented lifetime', () => { + const startedAt = 1700000000000 + const t1 = makeTransition({ id: 1, createdAt: startedAt + 5000, fromPhase: 'coding', toPhase: null, transitionKind: 'terminate' }) + const t2 = makeTransition({ id: 2, createdAt: startedAt + 1000, fromPhase: 'coding', toPhase: 'auditing' }) + const now = startedAt + 7000 + const { spans } = computePhaseSpans([t1, t2], startedAt, null, now) + expect(spans.every(s => s.durationMs >= 0)).toBe(true) + expect(spans.filter(s => !s.open).every(s => s.startedAt <= s.endedAt!)).toBe(true) + // The resumed coding span must clamp against the terminal's effective + // boundary (t1.createdAt) rather than jump back to t2.createdAt. + expect(spans[1].phase).toBe('coding') + expect(spans[1].startedAt).toBe(t1.createdAt) + expect(spans[1].endedAt).toBe(t1.createdAt) + expect(spans[1].durationMs).toBe(0) + // Boundaries stay monotonic: every closed span ends no later than the next + // span starts, and the trailing open span starts at the prior boundary. + for (let i = 1; i < spans.length; i++) { + expect(spans[i].startedAt).toBeGreaterThanOrEqual(spans[i - 1].startedAt) + } + // Summed duration cannot exceed the represented active lifetime. + const total = spans.reduce((acc, s) => acc + s.durationMs, 0) + expect(total).toBeLessThanOrEqual(now - startedAt) + expect(total).toBe(now - startedAt) + }) + + test('a same-phase restarted running loop emits an open current-phase span from the new startedAt', () => { + // Restart preserves the loop phase (final_auditing) and writes a new + // startedAt; the prior terminal row from the dead lifecycle survives in + // transition history with createdAt < startedAt. No restart row is + // recorded because the phase did not change. + const startedAt = 1700000100000 + const t1 = makeTransition({ id: 1, createdAt: startedAt - 5000, fromPhase: 'final_auditing', toPhase: null, transitionKind: 'terminate' }) + const now = startedAt + 10000 + const { spans, truncated } = computePhaseSpans([t1], startedAt, null, now, 'final_auditing') + expect(truncated).toBe(false) + expect(spans).toHaveLength(1) + expect(spans[0].phase).toBe('final_auditing') + expect(spans[0].startedAt).toBe(startedAt) + expect(spans[0].endedAt).toBeNull() + expect(spans[0].durationMs).toBe(10000) + expect(spans[0].open).toBe(true) + }) + + test('a same-phase restart with subsequent current-lifecycle transitions excludes inactive downtime', () => { + const startedAt = 1700000100000 + const t1 = makeTransition({ id: 1, createdAt: startedAt - 5000, fromPhase: 'final_auditing', toPhase: null, transitionKind: 'terminate' }) + const t2 = makeTransition({ id: 2, createdAt: startedAt + 2000, fromPhase: 'final_auditing', toPhase: 'coding' }) + const t3 = makeTransition({ id: 3, createdAt: startedAt + 6000, fromPhase: 'coding', toPhase: 'final_auditing' }) + const now = startedAt + 10000 + const { spans, truncated } = computePhaseSpans([t1, t2, t3], startedAt, null, now, 'final_auditing') + expect(truncated).toBe(false) + expect(spans.map(s => s.phase)).toEqual(['final_auditing', 'coding', 'final_auditing']) + expect(spans.every(s => s.durationMs >= 0)).toBe(true) + // The 5s pre-restart downtime is excluded; only current-lifecycle elapsed + // time is represented. + const total = spans.reduce((acc, s) => acc + s.durationMs, 0) + expect(total).toBe(10000) + expect(spans[2].open).toBe(true) + expect(spans[0].startedAt).toBe(startedAt) + }) + + test('a phase-changing restart records a current-lifecycle row that overrides the prior terminal', () => { + const startedAt = 1700000100000 + const t1 = makeTransition({ id: 1, createdAt: startedAt - 5000, fromPhase: 'auditing', toPhase: null, transitionKind: 'terminate' }) + const t2 = makeTransition({ id: 2, createdAt: startedAt, fromPhase: 'auditing', toPhase: 'coding', eventType: 'restart', transitionKind: 'phase' }) + const now = startedAt + 8000 + const { spans, truncated } = computePhaseSpans([t1, t2], startedAt, null, now, 'coding') + expect(truncated).toBe(false) + // The restart row is a lifecycle-boundary marker, not a transition in the + // new lifecycle: the loop was already persisted as 'coding' at the new + // startedAt before the row landed. The current lifecycle begins at + // startedAt in the destination phase with no old-phase span. + expect(spans.map(s => s.phase)).toEqual(['coding']) + expect(spans[0].startedAt).toBe(startedAt) + expect(spans[0].open).toBe(true) + expect(spans[0].durationMs).toBe(8000) + }) + + test('a phase-changing restart row recorded after startedAt still begins the current lifecycle in the destination phase', () => { + // Realistic timing: the loop row is restored (persisting the new phase and + // a fresh startedAt) BEFORE the restart transition row is recorded, so the + // row lands a few ms after startedAt. The current lifecycle must begin at + // startedAt in 'coding'; no 'final_audit_fix' span may be emitted for the + // post-startedAt window between startedAt and the restart row. + const startedAt = 1700000100000 + const t1 = makeTransition({ id: 1, createdAt: startedAt - 5000, fromPhase: 'final_audit_fix', toPhase: null, transitionKind: 'terminate' }) + const t2 = makeTransition({ id: 2, createdAt: startedAt + 100, fromPhase: 'final_audit_fix', toPhase: 'coding', eventType: 'restart', transitionKind: 'phase' }) + const t3 = makeTransition({ id: 3, createdAt: startedAt + 5000, fromPhase: 'coding', toPhase: 'auditing' }) + const now = startedAt + 10000 + const { spans, truncated } = computePhaseSpans([t1, t2, t3], startedAt, null, now, 'coding') + expect(truncated).toBe(false) + expect(spans.map(s => s.phase)).toEqual(['coding', 'auditing']) + expect(spans.every(s => s.durationMs >= 0)).toBe(true) + // No span is attributed to the pre-restart phase after startedAt. + expect(spans.some(s => s.phase === 'final_audit_fix')).toBe(false) + // The coding span begins at startedAt (not at t2.createdAt) and runs to + // the next transition; the trailing auditing span is open. + expect(spans[0].phase).toBe('coding') + expect(spans[0].startedAt).toBe(startedAt) + expect(spans[0].endedAt).toBe(t3.createdAt) + expect(spans[0].durationMs).toBe(5000) + expect(spans[1].phase).toBe('auditing') + expect(spans[1].open).toBe(true) + expect(spans[1].durationMs).toBe(now - t3.createdAt) + // Totals reflect only the current lifecycle, with no old-phase duration. + const totals = summarizePhaseTotals(spans) + expect(totals['final_audit_fix']).toBeUndefined() + expect(totals['coding']).toBe(5000) + expect(totals['auditing']).toBe(now - t3.createdAt) + }) + + test('a normal transitioned loop derives the initial phase from the first transition, not the persisted latest phase', () => { + // The loop row persists the latest phase (`final_auditing`), but the loop + // actually started in `coding`. Using the persisted phase as the initial + // phase mislabels the first span and drops coding time from totals. + const startedAt = 1700000000000 + const t1 = makeTransition({ id: 1, createdAt: startedAt + 1000, fromPhase: 'coding', toPhase: 'auditing' }) + const t2 = makeTransition({ id: 2, createdAt: startedAt + 4000, fromPhase: 'auditing', toPhase: 'final_auditing' }) + const now = startedAt + 10000 + const { spans, truncated } = computePhaseSpans([t1, t2], startedAt, null, now, 'final_auditing') + expect(truncated).toBe(false) + expect(spans.map(s => s.phase)).toEqual(['coding', 'auditing', 'final_auditing']) + expect(spans[0].startedAt).toBe(startedAt) + expect(spans[0].endedAt).toBe(t1.createdAt) + expect(spans[0].durationMs).toBe(1000) + expect(spans[1].durationMs).toBe(t2.createdAt - t1.createdAt) + expect(spans[2].open).toBe(true) + expect(spans[2].durationMs).toBe(now - t2.createdAt) + const totals = summarizePhaseTotals(spans) + expect(totals['coding']).toBe(1000) + expect(totals['auditing']).toBe(3000) + expect(totals['final_auditing']).toBe(now - t2.createdAt) + }) + + test('a phase-changing restart derives the initial phase from the restart toPhase even when persisted phase advanced past it', () => { + // The loop row persists the latest phase. After later transitions, the + // persisted phase is `auditing` (post-restart); the restart marker still + // records `final_audit_fix → coding`. The current lifecycle must begin in + // `coding` (the restart destination), not `auditing` (the persisted value). + const startedAt = 1700000100000 + const t1 = makeTransition({ id: 1, createdAt: startedAt - 5000, fromPhase: 'final_audit_fix', toPhase: null, transitionKind: 'terminate' }) + const t2 = makeTransition({ id: 2, createdAt: startedAt + 100, fromPhase: 'final_audit_fix', toPhase: 'coding', eventType: 'restart', transitionKind: 'phase' }) + const t3 = makeTransition({ id: 3, createdAt: startedAt + 5000, fromPhase: 'coding', toPhase: 'auditing' }) + const now = startedAt + 10000 + const { spans, truncated } = computePhaseSpans([t1, t2, t3], startedAt, null, now, 'auditing') + expect(truncated).toBe(false) + expect(spans.map(s => s.phase)).toEqual(['coding', 'auditing']) + expect(spans[0].startedAt).toBe(startedAt) + expect(spans[0].durationMs).toBe(t3.createdAt - startedAt) + expect(spans[1].open).toBe(true) + expect(spans[1].durationMs).toBe(now - t3.createdAt) + }) + + test('a phase-changing restart with no later transitions opens the destination phase from startedAt regardless of persisted phase', () => { + const startedAt = 1700000100000 + const t1 = makeTransition({ id: 1, createdAt: startedAt - 5000, fromPhase: 'final_audit_fix', toPhase: null, transitionKind: 'terminate' }) + const t2 = makeTransition({ id: 2, createdAt: startedAt + 100, fromPhase: 'final_audit_fix', toPhase: 'coding', eventType: 'restart', transitionKind: 'phase' }) + const now = startedAt + 10000 + const { spans, truncated } = computePhaseSpans([t1, t2], startedAt, null, now, 'auditing') + expect(truncated).toBe(false) + expect(spans.map(s => s.phase)).toEqual(['coding']) + expect(spans[0].startedAt).toBe(startedAt) + expect(spans[0].open).toBe(true) + expect(spans[0].durationMs).toBe(now - startedAt) + }) + + test('a failed inactive restart after completion does not extend spans past the restored completedAt', () => { + // Mirrors the inactive-loop prompt-failure rollback sequence in + // test/services/execution-restart.test.ts: a stalled loop completed + // hours ago, an inactive restart writes a `restart` phase row plus a + // `restart_prompt_failed` rollback row, and the loop row is restored to + // its original startedAt/completedAt. Spans must stay bounded to the + // restored lifecycle and not attribute the inactive downtime or original + // lifetime to the failed restart's coding phase. + const startedAt = 1700000000000 + const completedAt = startedAt + 8000 + const restartAt = completedAt + 3_600_000 + const t1 = makeTransition({ id: 1, createdAt: startedAt + 1000, fromPhase: 'coding', toPhase: 'auditing' }) + const t2 = makeTransition({ id: 2, createdAt: completedAt, fromPhase: 'auditing', toPhase: null, transitionKind: 'terminate' }) + const t3 = makeTransition({ id: 3, createdAt: restartAt, fromPhase: 'auditing', toPhase: 'coding', eventType: 'restart', transitionKind: 'phase' }) + const t4 = makeTransition({ id: 4, createdAt: restartAt + 50, fromPhase: 'coding', toPhase: 'auditing', eventType: 'restart_prompt_failed', transitionKind: 'rollback' }) + const { spans, truncated } = computePhaseSpans([t1, t2, t3, t4], startedAt, completedAt, restartAt + 1000, 'auditing') + expect(truncated).toBe(false) + expect(spans.every(s => !s.open)).toBe(true) + expect(spans.every(s => s.durationMs >= 0)).toBe(true) + for (const s of spans) { + expect(s.startedAt).toBeGreaterThanOrEqual(startedAt) + expect(s.endedAt!).toBeLessThanOrEqual(completedAt) + } + expect(spans.map(s => s.phase)).toEqual(['coding', 'auditing']) + const total = spans.reduce((acc, s) => acc + s.durationMs, 0) + expect(total).toBe(completedAt - startedAt) + expect(spans.some(s => s.phase === 'coding' && s.startedAt >= restartAt)).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// computeTimelineEvents +// --------------------------------------------------------------------------- + +describe('computeTimelineEvents', () => { + function makeTransition(overrides: Partial = {}): LoopTransitionRow { + return { + id: 1, + projectId: 'p1', + loopName: 'loop-a', + eventType: 'phase', + transitionKind: 'phase', + fromPhase: 'coding', + toPhase: 'auditing', + status: null, + reason: null, + iteration: 1, + sectionIndex: null, + createdAt: 0, + ...overrides, + } + } + + test('returns events newest first', () => { + const startedAt = 1700000000000 + const t1 = makeTransition({ id: 1, createdAt: startedAt + 1000 }) + const t2 = makeTransition({ id: 2, createdAt: startedAt + 3000 }) + const events = computeTimelineEvents([t1, t2], startedAt, false) + expect(events.map(e => e.transition.id)).toEqual([2, 1]) + }) + + test('a contiguous chain retains correct elapsed values per row', () => { + const startedAt = 1700000000000 + const t1 = makeTransition({ id: 1, createdAt: startedAt + 1000, fromPhase: 'coding', toPhase: 'auditing' }) + const t2 = makeTransition({ id: 2, createdAt: startedAt + 3000, fromPhase: 'auditing', toPhase: 'final_auditing' }) + const t3 = makeTransition({ id: 3, createdAt: startedAt + 6000, fromPhase: 'final_auditing', toPhase: null }) + const events = computeTimelineEvents([t1, t2, t3], startedAt, false) + // Newest first: t3, t2, t1. + expect(events[0].transition.id).toBe(3) + expect(events[0].elapsedMs).toBe(t3.createdAt - t2.createdAt) + expect(events[1].transition.id).toBe(2) + expect(events[1].elapsedMs).toBe(t2.createdAt - t1.createdAt) + expect(events[2].transition.id).toBe(1) + expect(events[2].elapsedMs).toBe(t1.createdAt - startedAt) + }) + + test('the oldest row of a truncated window has null elapsed', () => { + const startedAt = 1700000000000 + const t1 = makeTransition({ id: 1, createdAt: startedAt + 10000, fromPhase: 'coding', toPhase: 'auditing' }) + const t2 = makeTransition({ id: 2, createdAt: startedAt + 13000, fromPhase: 'auditing', toPhase: 'final_auditing' }) + const events = computeTimelineEvents([t1, t2], startedAt, true) + // Newest first: t2, t1. t1 is the oldest row and its prior boundary is + // omitted history, so its elapsed must be null rather than 10s. + expect(events[1].transition.id).toBe(1) + expect(events[1].elapsedMs).toBeNull() + // t2 still has a known contiguous boundary at t1. + expect(events[0].transition.id).toBe(2) + expect(events[0].elapsedMs).toBe(t2.createdAt - t1.createdAt) + }) + + test('the oldest row being a restart marker has null elapsed regardless of startedAt proximity', () => { + // A restart marker is a lifecycle boundary, not a real transition in the + // current lifecycle: its predecessor phase predates the new startedAt, so + // measuring elapsed from startedAt would attribute time to a phase that + // had already ended. The boundary is unavailable. + const startedAt = 1700000100000 + const restart = makeTransition({ id: 1, createdAt: startedAt + 100, fromPhase: 'final_audit_fix', toPhase: 'coding', eventType: 'restart', transitionKind: 'phase' }) + const next = makeTransition({ id: 2, createdAt: startedAt + 5000, fromPhase: 'coding', toPhase: 'auditing' }) + const events = computeTimelineEvents([restart, next], startedAt, false) + // Newest first: next, restart. + expect(events[0].transition.id).toBe(2) + expect(events[0].elapsedMs).toBe(next.createdAt - startedAt) + expect(events[1].transition.id).toBe(1) + expect(events[1].elapsedMs).toBeNull() + }) + + test('a row following a terminal transition has null elapsed', () => { + const startedAt = 1700000000000 + const t1 = makeTransition({ id: 1, createdAt: startedAt + 500, fromPhase: 'coding', toPhase: null, transitionKind: 'terminate' }) + const t2 = makeTransition({ id: 2, createdAt: startedAt + 10000, fromPhase: 'coding', toPhase: 'auditing' }) + const t3 = makeTransition({ id: 3, createdAt: startedAt + 15000, fromPhase: 'auditing', toPhase: 'final_auditing' }) + const events = computeTimelineEvents([t1, t2, t3], startedAt, false) + // Newest first: t3, t2, t1. + // t1 is the oldest row, not truncated, so its elapsed is t1 - startedAt. + expect(events[2].transition.id).toBe(1) + expect(events[2].elapsedMs).toBe(t1.createdAt - startedAt) + // t2 follows a terminal row: the loop was inactive, elapsed unavailable. + expect(events[1].transition.id).toBe(2) + expect(events[1].elapsedMs).toBeNull() + // t3 follows a normal contiguous boundary at t2. + expect(events[0].transition.id).toBe(3) + expect(events[0].elapsedMs).toBe(t3.createdAt - t2.createdAt) + }) + + test('a preserved pre-restart oldest row predating startedAt has null elapsed, and the first same-phase transition uses startedAt', () => { + const startedAt = 1700000000000 + const t1 = makeTransition({ id: 1, createdAt: startedAt - 5000, fromPhase: 'coding', toPhase: null, transitionKind: 'terminate' }) + const t2 = makeTransition({ id: 2, createdAt: startedAt + 1000, fromPhase: 'coding', toPhase: 'auditing' }) + const t3 = makeTransition({ id: 3, createdAt: startedAt + 4000, fromPhase: 'auditing', toPhase: 'final_auditing' }) + // Not truncated (only 3 rows fit well under the cap), but t1 predates + // the restarted loop's startedAt; its boundary is unavailable. + const events = computeTimelineEvents([t1, t2, t3], startedAt, false) + // Newest first: t3, t2, t1. + expect(events[2].transition.id).toBe(1) + expect(events[2].elapsedMs).toBeNull() + // t2 follows a terminal row predating startedAt, but the loop restarted + // in the same phase (coding) at startedAt, so startedAt is the known + // phase boundary for the current lifecycle. + expect(events[1].transition.id).toBe(2) + expect(events[1].elapsedMs).toBe(t2.createdAt - startedAt) + // t3 follows a contiguous boundary at t2. + expect(events[0].transition.id).toBe(3) + expect(events[0].elapsedMs).toBe(t3.createdAt - t2.createdAt) + }) + + test('a same-phase restart followed by a transition uses startedAt, not the terminal createdAt', () => { + // Loop terminates in final_auditing, restarts in that same phase (no + // restart row emitted because the phase did not change), then transitions + // ten minutes later. The transition's elapsed must be measured from the + // current lifecycle's startedAt, not display as unavailable. + const startedAt = 1700000100000 + const t1 = makeTransition({ id: 1, createdAt: startedAt - 5000, fromPhase: 'final_auditing', toPhase: null, transitionKind: 'terminate' }) + const t2 = makeTransition({ id: 2, createdAt: startedAt + 600000, fromPhase: 'final_auditing', toPhase: 'coding' }) + const events = computeTimelineEvents([t1, t2], startedAt, false) + // Newest first: t2, t1. + expect(events[0].transition.id).toBe(2) + expect(events[0].elapsedMs).toBe(t2.createdAt - startedAt) + expect(events[1].transition.id).toBe(1) + expect(events[1].elapsedMs).toBeNull() + }) + + test('a transition following a post-startedAt terminal still has null elapsed', () => { + // A terminal that lands within the current lifecycle (after startedAt) + // is not a same-phase restart boundary; the next transition's prior + // boundary is unavailable. + const startedAt = 1700000000000 + const t1 = makeTransition({ id: 1, createdAt: startedAt + 500, fromPhase: 'coding', toPhase: null, transitionKind: 'terminate' }) + const t2 = makeTransition({ id: 2, createdAt: startedAt + 10000, fromPhase: 'coding', toPhase: 'auditing' }) + const t3 = makeTransition({ id: 3, createdAt: startedAt + 15000, fromPhase: 'auditing', toPhase: 'final_auditing' }) + const events = computeTimelineEvents([t1, t2, t3], startedAt, false) + // Newest first: t3, t2, t1. + expect(events[2].transition.id).toBe(1) + expect(events[2].elapsedMs).toBe(t1.createdAt - startedAt) + expect(events[1].transition.id).toBe(2) + expect(events[1].elapsedMs).toBeNull() + expect(events[0].transition.id).toBe(3) + expect(events[0].elapsedMs).toBe(t3.createdAt - t2.createdAt) + }) + + test('a phase-changing restart row keeps its elapsed null rather than treating the terminal as the boundary', () => { + // The restart row itself follows a pre-lifecycle terminal, but it is a + // lifecycle-boundary marker, not a real transition; its elapsed must + // remain unavailable. + const startedAt = 1700000100000 + const t1 = makeTransition({ id: 1, createdAt: startedAt - 5000, fromPhase: 'auditing', toPhase: null, transitionKind: 'terminate' }) + const t2 = makeTransition({ id: 2, createdAt: startedAt, fromPhase: 'auditing', toPhase: 'coding', eventType: 'restart', transitionKind: 'phase' }) + const t3 = makeTransition({ id: 3, createdAt: startedAt + 5000, fromPhase: 'coding', toPhase: 'auditing' }) + const events = computeTimelineEvents([t1, t2, t3], startedAt, false) + // Newest first: t3, t2, t1. + expect(events[1].transition.id).toBe(2) + expect(events[1].elapsedMs).toBeNull() + expect(events[0].transition.id).toBe(3) + expect(events[0].elapsedMs).toBe(t3.createdAt - t2.createdAt) + }) + + test('a transition following a phase-changing restart marker measures elapsed from startedAt, not the marker timestamp', () => { + // Realistic timing: the loop row is restored (persisting the new phase and + // a fresh startedAt) BEFORE the restart transition row is recorded, so the + // marker lands a few ms after startedAt. The successor transition's + // elapsed must be measured from startedAt, not the marker timestamp; + // otherwise the window between startedAt and the marker is silently lost. + const startedAt = 1700000100000 + const t1 = makeTransition({ id: 1, createdAt: startedAt - 5000, fromPhase: 'final_audit_fix', toPhase: null, transitionKind: 'terminate' }) + const t2 = makeTransition({ id: 2, createdAt: startedAt + 100, fromPhase: 'final_audit_fix', toPhase: 'coding', eventType: 'restart', transitionKind: 'phase' }) + const t3 = makeTransition({ id: 3, createdAt: startedAt + 5000, fromPhase: 'coding', toPhase: 'auditing' }) + const events = computeTimelineEvents([t1, t2, t3], startedAt, false) + // Newest first: t3, t2, t1. + expect(events[0].transition.id).toBe(3) + expect(events[0].elapsedMs).toBe(t3.createdAt - startedAt) + expect(events[1].transition.id).toBe(2) + expect(events[1].elapsedMs).toBeNull() + }) + + test('a historical restart marker predating startedAt measures its successor from the marker boundary, not startedAt', () => { + // After multiple restarts, an older restart marker and its successor can + // both predate the latest startedAt. Measuring the successor's elapsed + // from startedAt would underflow and clamp to a fabricated 0s; the + // contiguous marker boundary is the correct reference instead. + const startedAt = 1700000100000 + const oldRestart = makeTransition({ id: 1, createdAt: startedAt - 100000, fromPhase: 'final_audit_fix', toPhase: 'coding', eventType: 'restart', transitionKind: 'phase' }) + const oldSuccessor = makeTransition({ id: 2, createdAt: startedAt - 50000, fromPhase: 'coding', toPhase: 'auditing' }) + const restart = makeTransition({ id: 3, createdAt: startedAt + 100, fromPhase: 'final_audit_fix', toPhase: 'coding', eventType: 'restart', transitionKind: 'phase' }) + const successor = makeTransition({ id: 4, createdAt: startedAt + 5000, fromPhase: 'coding', toPhase: 'auditing' }) + const events = computeTimelineEvents([oldRestart, oldSuccessor, restart, successor], startedAt, false) + // Newest first: 4, 3, 2, 1. + const byId = new Map(events.map(e => [e.transition.id, e.elapsedMs])) + expect(byId.get(4)).toBe(successor.createdAt - startedAt) + expect(byId.get(3)).toBeNull() + expect(byId.get(2)).toBe(oldSuccessor.createdAt - oldRestart.createdAt) + expect(byId.get(1)).toBeNull() + }) + + test('a row whose predecessor flowed into a different phase has null elapsed', () => { + const startedAt = 1700000000000 + const t1 = makeTransition({ id: 1, createdAt: startedAt + 1000, fromPhase: 'coding', toPhase: 'auditing' }) + // t2 claims a fromPhase that t1 did not flow into (missing row). + const t2 = makeTransition({ id: 2, createdAt: startedAt + 3000, fromPhase: 'final_auditing', toPhase: 'plan' }) + const t3 = makeTransition({ id: 3, createdAt: startedAt + 6000, fromPhase: 'plan', toPhase: null }) + const events = computeTimelineEvents([t1, t2, t3], startedAt, false) + // Newest first: t3, t2, t1. + expect(events[0].transition.id).toBe(3) + expect(events[0].elapsedMs).toBe(t3.createdAt - t2.createdAt) + expect(events[1].transition.id).toBe(2) + expect(events[1].elapsedMs).toBeNull() + expect(events[2].transition.id).toBe(1) + expect(events[2].elapsedMs).toBe(t1.createdAt - startedAt) + }) + + test('no event ever carries a negative elapsed value', () => { + const startedAt = 1700000000000 + const t1 = makeTransition({ id: 1, createdAt: startedAt + 5000 }) + const t2 = makeTransition({ id: 2, createdAt: startedAt + 1000 }) + const t3 = makeTransition({ id: 3, createdAt: startedAt + 4000 }) + const events = computeTimelineEvents([t1, t2, t3], startedAt, false) + for (const e of events) { + expect(e.elapsedMs === null || e.elapsedMs >= 0).toBe(true) + } + }) + + test('empty transitions yield an empty event list', () => { + expect(computeTimelineEvents([], 1700000000000, false)).toEqual([]) + }) +}) diff --git a/test/dashboard/data.test.ts b/test/dashboard/data.test.ts index 9c67b269e4..9dbf463578 100644 --- a/test/dashboard/data.test.ts +++ b/test/dashboard/data.test.ts @@ -7,6 +7,7 @@ import { createPlansRepo } from '../../src/storage' import { createReviewFindingsRepo } from '../../src/storage' import { createSectionPlansRepo } from '../../src/storage' import { createLoopSessionUsageRepo, type LoopSessionUsageRow } from '../../src/storage' +import { createFeatureGroupsRepo } from '../../src/storage' function makeLoopRow(overrides?: Partial): LoopRow { return { @@ -163,6 +164,8 @@ 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.auditor).toBeUndefined() expect(payload.totals.projects).toBe(1) expect(payload.totals.loops).toBe(1) @@ -189,6 +192,26 @@ describe('collectDashboardData', () => { expect(payload.projects[0].loops[0].duration).toBe('2m 5s') }) + test('a loop with goal in loop_large_fields exposes goal on the dashboard loop', () => { + const loopsRepo = createLoopsRepo(db!) + loopsRepo.insert( + makeLoopRow({ projectId: 'p1', loopName: 'goal-loop', kind: 'goal' }), + { lastAuditResult: null, goal: 'Ship the tabs refactor' }, + ) + const payload = collectDashboardData(db!) + expect(payload.projects[0].loops[0].goal).toBe('Ship the tabs refactor') + }) + + test('a loop with no large-fields goal exposes goal as null', () => { + const loopsRepo = createLoopsRepo(db!) + loopsRepo.insert( + makeLoopRow({ projectId: 'p1', loopName: 'no-goal' }), + { lastAuditResult: null, goal: null }, + ) + const payload = collectDashboardData(db!) + expect(payload.projects[0].loops[0].goal).toBeNull() + }) + // ─── Cycle 3: running-first ordering ──────────────────────────────── test('running loop sorts before completed (running-first ordering)', () => { @@ -364,4 +387,144 @@ describe('collectDashboardData', () => { expect(payload.totals.errored).toBe(1) expect(payload.totals.stalled).toBe(1) }) + + // ─── Cycle 5: feature groups ────────────────────────────────────────── + + test('a project with no groups exposes groups: []', () => { + const loopsRepo = createLoopsRepo(db!) + loopsRepo.insert( + makeLoopRow({ projectId: 'p1', loopName: 'l1' }), + { lastAuditResult: null }, + ) + const payload = collectDashboardData(db!) + expect(payload.projects).toHaveLength(1) + expect(payload.projects[0].groups).toEqual([]) + }) + + test('a group with three features exposes them ordered by featureIndex', () => { + const loopsRepo = createLoopsRepo(db!) + const groupsRepo = createFeatureGroupsRepo(db!) + loopsRepo.insert( + makeLoopRow({ projectId: 'p1', loopName: 'l1' }), + { lastAuditResult: null }, + ) + groupsRepo.createGroup({ + projectId: 'p1', + groupId: 'g-1', + title: 'Group One', + status: 'running', + maxConcurrent: 2, + }) + // Insert features with explicit feature_index values whose physical INSERT + // (rowid) order differs from feature_index order. A regression that drops + // ORDER BY feature_index ASC would return rows in rowid order instead. + const ts = Date.now() + const insertFeature = db!.prepare(` + INSERT INTO group_features ( + project_id, group_id, feature_index, title, description, stage, + architect_session_id, loop_name, error, attempts, created_at, updated_at + ) VALUES ('p1', 'g-1', ?, ?, ?, 'pending', NULL, NULL, NULL, 0, ?, ?) + `) + insertFeature.run(2, 'Feature C', 'c', ts, ts) + insertFeature.run(0, 'Feature A', 'a', ts, ts) + insertFeature.run(1, 'Feature B', 'b', ts, ts) + + const payload = collectDashboardData(db!) + expect(payload.projects[0].groups).toHaveLength(1) + const g = payload.projects[0].groups[0] + expect(g.id).toBe('g-1') + expect(g.group.groupId).toBe('g-1') + expect(g.group.title).toBe('Group One') + expect(g.group.maxConcurrent).toBe(2) + expect(g.features.map(f => f.featureIndex)).toEqual([0, 1, 2]) + expect(g.features.map(f => f.title)).toEqual(['Feature A', 'Feature B', 'Feature C']) + }) + + test('prdText longer than 400 chars is exposed as a 400-char prdPreview and raw prdText is absent', () => { + const loopsRepo = createLoopsRepo(db!) + const groupsRepo = createFeatureGroupsRepo(db!) + loopsRepo.insert( + makeLoopRow({ projectId: 'p1', loopName: 'l1' }), + { lastAuditResult: null }, + ) + const longPrd = 'P'.repeat(1000) + groupsRepo.createGroup({ + projectId: 'p1', + groupId: 'g-1', + title: 'Group One', + status: 'running', + prdText: longPrd, + }) + + const payload = collectDashboardData(db!) + const g = payload.projects[0].groups[0] + expect((g.group as Record).prdText).toBeUndefined() + expect(g.group.prdPreview).toHaveLength(400) + expect(g.group.prdPreview).toBe('P'.repeat(400)) + }) + + test('a database without the feature_groups table returns groups: [] and does not throw', () => { + // Build a database with only the loops + minimum tables by dropping + // feature_groups; relies on hasTable() gating the repo construction. + const loopsRepo = createLoopsRepo(db!) + loopsRepo.insert( + makeLoopRow({ projectId: 'p1', loopName: 'l1' }), + { lastAuditResult: null }, + ) + db!.exec('DROP TABLE group_features') + db!.exec('DROP TABLE feature_groups') + + expect(() => collectDashboardData(db!)).not.toThrow() + const payload = collectDashboardData(db!) + expect(payload.projects[0].groups).toEqual([]) + }) + + test('a project containing only a feature group appears with loops: [] and populated groups', () => { + const groupsRepo = createFeatureGroupsRepo(db!) + groupsRepo.createGroup({ + projectId: 'p1', + groupId: 'g-1', + title: 'Group One', + status: 'extracting', + maxConcurrent: 2, + }) + groupsRepo.insertFeatures('p1', 'g-1', [ + { title: 'Feature A', description: 'a' }, + ]) + + const payload = collectDashboardData(db!) + expect(payload.projects).toHaveLength(1) + const proj = payload.projects[0] + expect(proj.projectId).toBe('p1') + expect(proj.loops).toEqual([]) + expect(proj.groups).toHaveLength(1) + expect(proj.groups[0].group.groupId).toBe('g-1') + expect(proj.groups[0].features).toHaveLength(1) + }) + + test('groups from another project_id do not leak into this project', () => { + const loopsRepo = createLoopsRepo(db!) + const groupsRepo = createFeatureGroupsRepo(db!) + loopsRepo.insert( + makeLoopRow({ projectId: 'p1', loopName: 'l1' }), + { lastAuditResult: null }, + ) + loopsRepo.insert( + makeLoopRow({ projectId: 'p2', loopName: 'l2' }), + { lastAuditResult: null }, + ) + groupsRepo.createGroup({ + projectId: 'p2', + groupId: 'g-other', + title: 'Other Project Group', + status: 'running', + }) + + const payload = collectDashboardData(db!) + const p1 = payload.projects.find(p => p.projectId === 'p1')! + const p2 = payload.projects.find(p => p.projectId === 'p2')! + expect(p1.groups).toEqual([]) + expect(p2.groups).toHaveLength(1) + expect(p2.groups[0].group.groupId).toBe('g-other') + }) }) diff --git a/test/dashboard/render.test.ts b/test/dashboard/render.test.ts index 7bc19938a9..967607cea9 100644 --- a/test/dashboard/render.test.ts +++ b/test/dashboard/render.test.ts @@ -34,19 +34,16 @@ describe('renderDashboardHtml', () => { const html = renderDashboardHtml() const cssClasses = [ - 'badge-filter', - 'badge-active', - '#fe7d37', + 'repo-menu', + 'repo-menu-item', + 'breadcrumb', + 'breadcrumb-path', + 'section-nav', + 'section-nav-item', 'search-input', - 'dash-layout', - 'project-sidebar', - 'project-detail', - 'project-nav-item', - 'project-nav-count', 'empty-state', 'resizable-block', 'resize: vertical', - 'back-to-loops', 'loop-table', 'lt-meter', 'lt-row', @@ -54,6 +51,8 @@ describe('renderDashboardHtml', () => { 'sections-panel', 'section-list-row', 'back-to-sections', + 'badge-filter', + 'badge-active', ] for (const cls of cssClasses) { expect(html).toContain(cls) @@ -72,4 +71,14 @@ describe('renderDashboardHtml', () => { // no static
in the shell) expect(html).not.toContain('
{ + const html = renderDashboardHtml() + const style = html.slice(html.indexOf('')) + expect(style).toContain('--bg-0:') + expect(style).toContain('--accent:') + expect(style).toContain('--ph-coding:') + const afterRoot = style.slice(style.indexOf('}', style.indexOf(':root'))) + expect(afterRoot).not.toMatch(/#[0-9a-fA-F]{6}\b/) + }) }) diff --git a/test/dashboard/server.test.ts b/test/dashboard/server.test.ts index f1205ae84c..e798a500b0 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, type LoopRow, type LoopTransitionRow, type PlanAmendmentRow } from '../../src/storage' +import { createLoopsRepo, createLoopTransitionsRepo, createPlanAmendmentsRepo, createFeatureGroupsRepo, type LoopRow, type LoopTransitionRow, type PlanAmendmentRow } from '../../src/storage' function makeLoopRow(overrides?: Partial): LoopRow { return { @@ -311,4 +311,45 @@ describe('createRequestHandler', () => { expect(Array.isArray(loopData.amendments)).toBe(true) expect(loopData.amendments).toHaveLength(0) }) + + // ─── Cycle 8: feature groups surface under the right project ────────── + + test('GET /api/data against a live database containing a group returns it under the right project', async () => { + const handler = createRequestHandler(makeDeps(db!)) + + const loopsRepo = createLoopsRepo(db!) + const groupsRepo = createFeatureGroupsRepo(db!) + loopsRepo.insert( + makeLoopRow({ projectId: 'p1', loopName: 'host-loop' }), + { lastAuditResult: null }, + ) + groupsRepo.createGroup({ + projectId: 'p1', + groupId: 'g-1', + title: 'Group One', + status: 'running', + maxConcurrent: 3, + prdText: 'PRD'.repeat(200), + }) + groupsRepo.insertFeatures('p1', 'g-1', [ + { title: 'Feature A', description: 'a' }, + { title: 'Feature B', description: 'b' }, + ]) + + const res = await handler(new Request('http://localhost/api/data')) + const body = await res.json() + expect(body.projects).toHaveLength(1) + const proj = body.projects[0] + expect(proj.projectId).toBe('p1') + expect(Array.isArray(proj.groups)).toBe(true) + expect(proj.groups).toHaveLength(1) + const g = proj.groups[0] + expect(g.id).toBe('g-1') + expect(g.group.groupId).toBe('g-1') + expect(g.group.title).toBe('Group One') + expect(g.group.prdPreview).toHaveLength(400) + expect(g.group).not.toHaveProperty('prdText') + expect(g.features).toHaveLength(2) + expect(g.features.map((f: { featureIndex: number }) => f.featureIndex)).toEqual([0, 1]) + }) }) diff --git a/test/loop-session-usage-repo.test.ts b/test/loop-session-usage-repo.test.ts index 0131b45f2b..83241b174d 100644 --- a/test/loop-session-usage-repo.test.ts +++ b/test/loop-session-usage-repo.test.ts @@ -265,6 +265,21 @@ describe('LoopSessionUsageRepo', () => { expect(agg?.totalCost).toBe(0.03) expect(agg?.totalMessageCount).toBe(15) }) + + test('aggregates by role across sessions using the same model', () => { + const row1 = createUsageRow({ sessionId: 'session-1', model: 'shared-model', role: 'code', cost: 2, messageCount: 6 }) + const row2 = createUsageRow({ sessionId: 'session-2', model: 'shared-model', role: 'auditor', cost: 1, messageCount: 2 }) + const row3 = createUsageRow({ sessionId: 'session-3', model: 'shared-model', role: 'unknown', cost: 0.5, messageCount: 1 }) + + repo.upsertSessionUsage(row1) + repo.upsertSessionUsage(row2) + 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 }) + }) }) describe('listSessionUsage', () => { From b11aed8ae14edb6b50a1464afe62fb70adf2a8af Mon Sep 17 00:00:00 2001 From: Forge Date: Sun, 26 Jul 2026 10:12:29 -0400 Subject: [PATCH 2/7] feat(dashboard): make markdown sections collapsible and non-scrolling Replace the internal scroll box with a full-height body that toggles collapse via an accessible caret, and promote the state-machine graph on the timeline tab out of a
block to always-visible. --- src/dashboard/app-bundle.ts | 4 +- src/dashboard/app/components.ts | 36 ++++++--- src/dashboard/render.ts | 16 +++- test/dashboard/app-dom.test.ts | 128 ++++++++++++++++++++------------ test/dashboard/render.test.ts | 13 ++++ 5 files changed, 132 insertions(+), 65 deletions(-) diff --git a/src/dashboard/app-bundle.ts b/src/dashboard/app-bundle.ts index 1290691f23..2c9fbb3049 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 _={context:void 0,registry:void 0,effects:void 0,done:!1,getContextId(){return Un(this.context.count)},getNextContextId(){return Un(this.context.count++)}};function Un(e){let n=String(e),t=n.length-1;return _.context.id+(t?String.fromCharCode(96+t):\"\")+n}function me(e){_.context=e}function Ei(){return{..._.context,id:_.getNextContextId(),count:0}}var ki=!0,Si=(e,n)=>e===n,q=Symbol(\"solid-proxy\"),Ai=typeof Proxy===\"function\",pe=Symbol(\"solid-track\"),Yn=Symbol(\"solid-dev-component\"),Ve={equals:Si},Gn=null,Hn=Jn,Y=1,we=2,Ni={};var C=null,O=null,xe=null,re=null,D=null,G=null,W=null,je=0,X={afterUpdate:null,afterCreateOwner:null,afterCreateSignal:null,afterRegisterGraph:null};function qn(e,n){let t=D,i=C,f=e.length===0,r=n===void 0?i:n,u=f?{owned:null,cleanups:null,context:null,owner:null}:{owned:null,cleanups:null,context:r?r.context:null,owner:r},l=f?()=>e(()=>{throw Error(\"Dispose method must be an explicit argument to createRoot function\")}):()=>e(()=>v(()=>M(u)));X.afterCreateOwner&&X.afterCreateOwner(u),C=u,D=null;try{return L(l,!0)}finally{D=t,C=i}}function U(e,n){n=n?Object.assign({},Ve,n):Ve;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(Ln(t),X.afterCreateSignal)X.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 en(t,f)};return[Rn.bind(t),i]}function R(e,n,t){let i=De(e,n,!1,Y,t);if(xe&&O&&O.running)G.push(i);else le(i)}function B(e,n,t){Hn=Ti;let i=De(e,n,!1,Y,t),f=Me&&Ii(Me);if(f)i.suspense=f;if(!t||!t.render)i.user=!0;W?W.push(i):le(i)}function g(e,n,t){t=t?Object.assign({},Ve,t):Ve;let i=De(e,n,!0,0,t);if(i.observers=null,i.observerSlots=null,i.comparator=t.equals||void 0,xe&&O&&O.running)i.tState=Y,G.push(i);else le(i);return Rn.bind(i)}function Xn(e){return L(e,!1)}function v(e){if(!re&&D===null)return e();let n=D;D=null;try{if(re)return re.untrack(e);return e()}finally{D=n}}function Zn(e){B(()=>v(e))}function ue(e){if(C===null)console.warn(\"cleanups created outside a `createRoot` or `render` will never be run\");else if(C.cleanups===null)C.cleanups=[e];else C.cleanups.push(e);return e}function _e(){return D}function Ci(e){if(O&&O.running)return e(),O.done;let n=D,t=C;return Promise.resolve().then(()=>{D=n,C=t;let i;if(xe||Me)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 L(e,!1),D=C=null,i?i.done:void 0})}var[Qf,Kn]=U(!1);function Wn(e,n){let t=De(()=>v(()=>{return Object.assign(e,{[Yn]:!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 Ln(e){if(C){if(C.sourceMap)C.sourceMap.push(e);else C.sourceMap=[e];e.graph=C}if(X.afterRegisterGraph)X.afterRegisterGraph(e)}function Ii(e){let n;return C&&C.context&&(n=C.context[e.id])!==void 0?n:e.defaultValue}var Me;function Rn(){let e=O&&O.running;if(this.sources&&(e?this.tState:this.state))if((e?this.tState:this.state)===Y)le(this);else{let n=G;G=null,L(()=>Te(this),!1),G=n}if(D){let n=this.observers?this.observers.length:0;if(!D.sources)D.sources=[this],D.sourceSlots=[n];else D.sources.push(this),D.sourceSlots.push(n);if(!this.observers)this.observers=[D],this.observerSlots=[D.sources.length-1];else this.observers.push(D),this.observerSlots.push(D.sources.length-1)}if(e&&O.sources.has(this))return this.tValue;return this.value}function en(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)L(()=>{for(let f=0;f1e6){if(G=[],ki)throw Error(\"Potential Infinite Loop Detected.\");throw Error()}},!1)}return n}function le(e){if(!e.fn)return;M(e);let n=je;if(vn(e,O&&O.running&&O.sources.has(e)?e.tValue:e.value,n),O&&!O.running&&O.sources.has(e))queueMicrotask(()=>{L(()=>{O&&(O.running=!0),D=C=e,vn(e,e.tValue,n),D=C=null},!1)})}function vn(e,n,t){let i,f=C,r=D;D=C=e;try{i=e.fn(n)}catch(u){if(e.pure)if(O&&O.running)e.tState=Y,e.tOwned&&e.tOwned.forEach(M),e.tOwned=void 0;else e.state=Y,e.owned&&e.owned.forEach(M),e.owned=null;return e.updatedAt=t+1,nn(u)}finally{D=r,C=f}if(!e.updatedAt||e.updatedAt<=t){if(e.updatedAt!=null&&\"observers\"in e)en(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 De(e,n,t,i=Y,f){let r={fn:e,state:i,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:n,owner:C,context:C?C.context:null,pure:t};if(O&&O.running)r.state=0,r.tState=i;if(C===null)console.warn(\"computations created outside a `createRoot` or `render` will never be disposed\");else if(C!==Ni)if(O&&O.running&&C.pure)if(!C.tOwned)C.tOwned=[r];else C.tOwned.push(r);else if(!C.owned)C.owned=[r];else C.owned.push(r);if(f&&f.name)r.name=f.name;if(re&&r.fn){let u=r.fn,[l,c]=U(void 0,{equals:!1}),a=re.factory(u,c);ue(()=>a.dispose());let d,w=()=>Ci(c).then(()=>{if(d)d.dispose(),d=void 0});r.fn=(E)=>{if(l(),O&&O.running){if(!d)d=re.factory(u,w);return d.track(E)}return a.track(E)}}return X.afterCreateOwner&&X.afterCreateOwner(r),r}function $e(e){let n=O&&O.running;if((n?e.tState:e.state)===0)return;if((n?e.tState:e.state)===we)return Te(e);if(e.suspense&&v(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,r=t[i+1];while((f=f.owner)&&f!==r)if(O.disposed.has(f))return}if((n?e.tState:e.state)===Y)le(e);else if((n?e.tState:e.state)===we){let f=G;G=null,L(()=>Te(e,t[0]),!1),G=f}}}function L(e,n){if(G)return e();let t=!1;if(!n)G=[];if(W)t=!0;else W=[];je++;try{let i=e();return Pi(t),i}catch(i){if(!t)W=null;G=null,nn(i)}}function Pi(e){if(G){if(xe&&O&&O.running)Vi(G);else Jn(G);G=null}if(e)return;let n;if(O){if(!O.promises.size&&!O.queue.size){let{sources:i,disposed:f}=O;W.push.apply(W,O.effects),n=O.resolve;for(let r of W)\"tState\"in r&&(r.state=r.tState),delete r.tState;O=null,L(()=>{for(let r of f)M(r);for(let r of i){if(r.value=r.tValue,r.owned)for(let u=0,l=r.owned.length;uHn(t),!1);else X.afterUpdate&&X.afterUpdate();if(n)n()}function Jn(e){for(let n=0;n{i.delete(t),L(()=>{O.running=!0,$e(t)},!1),O&&(O.running=!1)})}}function Ti(e){let n,t=0;for(n=0;n=0;n--)M(e.tOwned[n]);delete e.tOwned}if(O&&O.running&&e.pure)Bn(e,!0);else if(e.owned){for(n=e.owned.length-1;n>=0;n--)M(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 Bn(e,n){if(!n)e.tState=0,O.disposed.add(e);if(e.owned)for(let t=0;t=0;l--){let c=Be(e[l])[u];if(c!==void 0)return c}},has(u){for(let l=e.length-1;l>=0;l--)if(u in Be(e[l]))return!0;return!1},keys(){let u=[];for(let l=0;l=0;u--){let l=e[u];if(!l)continue;let c=Object.getOwnPropertyNames(l);for(let a=c.length-1;a>=0;a--){let d=c[a];if(d===\"__proto__\"||d===\"constructor\")continue;let w=Object.getOwnPropertyDescriptor(l,d);if(!i[d])i[d]=w.get?{enumerable:!0,configurable:!0,get:Fi.bind(t[d]=[w.get.bind(l)])}:w.value!==void 0?w:void 0;else{let E=t[d];if(E){if(w.get)E.push(w.get.bind(l));else if(w.value!==void 0)E.push(()=>w.value)}}}}let f={},r=Object.keys(i);for(let u=r.length-1;u>=0;u--){let l=r[u],c=i[l];if(c&&c.get)Object.defineProperty(f,l,c);else f[l]=c?c.value:void 0}return f}var mn={hooks:X,writeSignal:en,registerGraph:Ln};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 Gi=[\"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\"],un=new Set([\"className\",\"value\",\"readOnly\",\"noValidate\",\"formNoValidate\",\"isMap\",\"noModule\",\"playsInline\",\"adAuctionHeaders\",\"allowFullscreen\",\"browsingTopics\",\"defaultChecked\",\"defaultMuted\",\"defaultSelected\",\"disablePictureInPicture\",\"disableRemotePlayback\",\"preservesPitch\",\"shadowRootClonable\",\"shadowRootCustomElementRegistry\",\"shadowRootDelegatesFocus\",\"shadowRootSerializable\",\"sharedStorageWritable\",...Gi]),ln=new Set([\"innerHTML\",\"textContent\",\"innerText\",\"children\"]),cn=Object.assign(Object.create(null),{className:\"class\",htmlFor:\"for\"}),Ki=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 sn(e,n){let t=Ki[e];return typeof t===\"object\"?t[n]?t.$:void 0:t}var on=new Set([\"beforeinput\",\"click\",\"dblclick\",\"contextmenu\",\"focusin\",\"focusout\",\"input\",\"keydown\",\"keyup\",\"mousedown\",\"mousemove\",\"mouseout\",\"mouseover\",\"mouseup\",\"pointerdown\",\"pointermove\",\"pointerout\",\"pointerover\",\"pointerup\",\"touchend\",\"touchmove\",\"touchstart\"]),tt=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\"]),dn={xlink:\"http://www.w3.org/1999/xlink\",xml:\"http://www.w3.org/XML/1998/namespace\"};function Wi(e,n,t){let i=t.length,f=n.length,r=i,u=0,l=0,c=n[f-1].nextSibling,a=null;while(ud-l){let o=n[u];while(l{f=r,n===document?e():wn(n,e(),n.firstChild?null:void 0,t)},i.owner),()=>{f(),n.textContent=\"\"}}function an(e,n=window.document){let t=n[Mn]||(n[Mn]=new Set);for(let i=0,f=e.length;if.call(e,t[1],r))}else e.addEventListener(n,t,typeof t!==\"function\"&&t)}function bn(e,n,t={}){let i=Object.keys(n||{}),f=Object.keys(t),r,u;for(r=0,u=f.length;rf.children=se(e,n.children,f.children));return R(()=>typeof n.ref===\"function\"&&Yi(n.ref,e)),R(()=>Hi(e,n,t,!0,f,!0)),f}function rt(e,n){let t=e[n];return Object.defineProperty(e,n,{get(){return t()},enumerable:!0}),e}function Yi(e,n,t){return v(()=>e(n,t))}function wn(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 Hi(e,n,t,i,f={},r=!1){n||(n={});for(let u in f)if(!(u in n)){if(u===\"children\")continue;f[u]=et(e,u,null,f[u],t,r,n)}for(let u in n){if(u===\"children\"){if(!i)se(e,n.children);continue}let l=n[u];f[u]=et(e,u,l,f[u],t,r,n)}}function oe(e){return!!_.context&&!_.done&&(!e||e.isConnected)}function qi(e){return e.toLowerCase().replace(/-([a-z])/g,(n,t)=>t.toUpperCase())}function pn(e,n,t){let i=n.trim().split(/\\s+/);for(let f=0,r=i.length;f-1&&dn[n.split(\":\")[0]];if(E)hn(e,E,n,t);else Oe(e,cn[n]||n,t)}return t}function Xi(e){if(_.registry&&_.events){if(_.events.find(([c,a])=>a===e))return}let n=e.target,t=`$$${e.type}`,i=e.target,f=e.currentTarget,r=(c)=>Object.defineProperty(e,\"target\",{configurable:!0,value:c}),u=()=>{let c=n[t];if(c&&!n.disabled){let a=n[`${t}Data`];if(a!==void 0?c.call(n,a,e):c.call(n,e),e.cancelBubble)return}return n.host&&typeof n.host!==\"string\"&&!n.host._$host&&n.contains(e.target)&&r(n.host),!0},l=()=>{while(u()&&(n=n._$host||n.parentNode||n.host));};if(Object.defineProperty(e,\"currentTarget\",{configurable:!0,get(){return n||document}}),_.registry&&!_.done)_.done=_$HY.done=!0;if(e.composedPath){let c=e.composedPath();r(c[0]);for(let a=0;a{let c=n();while(typeof c===\"function\")c=c();t=se(e,c,t,i)}),()=>t;else if(Array.isArray(n)){let c=[],a=t&&Array.isArray(t);if(rn(c,n,t,f))return R(()=>t=se(e,c,t,i,!0)),()=>t;if(r){if(!c.length)return t;if(i===void 0)return t=[...e.childNodes];let d=c[0];if(d.parentNode!==e)return t;let w=[d];while((d=d.nextSibling)!==i)w.push(d);return t=w}if(c.length===0){if(t=ce(e,t,i),l)return t}else if(a)if(t.length===0)nt(e,c,i);else Wi(e,t,c);else t&&ce(e),nt(e,c);t=c}else if(n.nodeType){if(r&&n.parentNode)return t=l?[n]:n;if(Array.isArray(t)){if(l)return t=ce(e,t,i,n);ce(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 rn(e,n,t,i){let f=!1;for(let r=0,u=n.length;r=0;u--){let l=n[u];if(f!==l){let c=l.parentNode===e;if(!r&&!u)c?e.replaceChild(f,l):e.insertBefore(f,t);else c&&l.remove()}else r=!0}}else e.insertBefore(f,t);return[f]}var or=Symbol();var $n=Symbol(\"store-raw\"),ae=Symbol(\"store-node\"),m=Symbol(\"store-has\"),lt=Symbol(\"store-self\"),ut={onStoreNodeUpdate:null};function ct(e){let n=e[q];if(!n){if(Object.defineProperty(e,q,{value:n=new Proxy(e,Ri)}),!Array.isArray(e)){let t=Object.keys(e),i=Object.getOwnPropertyDescriptors(e);for(let f=0,r=t.length;fe[q][n],t}function st(e){_e()&&ke(Fe(e,ae),lt)()}function Li(e){return st(e),Reflect.ownKeys(e)}var Ri={get(e,n,t){if(n===$n)return e;if(n===q)return t;if(n===pe)return st(e),t;let i=Fe(e,ae),f=i[n],r=f?f():e[n];if(n===ae||n===m||n===\"__proto__\")return r;if(!f){let u=Object.getOwnPropertyDescriptor(e,n);if(_e()&&(typeof r!==\"function\"||e.hasOwnProperty(n))&&!(u&&u.get))r=ke(i,n,r)()}return p(r)?ct(r):r},has(e,n){if(n===$n||n===q||n===pe||n===ae||n===m||n===\"__proto__\")return!0;return _e()&&ke(Fe(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:Li,getOwnPropertyDescriptor:Zi};function H(e,n,t,i=!1){if(!i&&e[n]===t)return;let f=e[n],r=e.length;if(ut.onStoreNodeUpdate&&ut.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 u=Fe(e,ae),l;if(l=ke(u,n,f))l.$(()=>t);if(Array.isArray(e)&&e.length!==r){for(let c=e.length;c1){i=n.shift();let u=typeof i,l=Array.isArray(e);if(Array.isArray(i)){for(let c=0;c1){Ee(e[i],n,[i].concat(t));return}f=e[i],t=[i].concat(t)}let r=n[0];if(typeof r===\"function\"){if(r=r(f,t),r===f)return}if(i===void 0&&r==null)return;if(r=he(r),i===void 0||p(f)&&p(r)&&!Array.isArray(r))ot(f,r);else H(e,i,r)}function dt(...[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=ct(t);mn.registerGraph({value:t,name:n&&n.name});function r(...u){Xn(()=>{i&&u.length===1?Ji(t,u[0]):Ee(t,u)})}return[f,r]}var xn=Symbol(\"store-root\");function de(e,n,t,i,f){let r=n[t];if(e===r)return;let u=Array.isArray(e);if(t!==xn&&(!p(e)||!p(r)||u!==Array.isArray(r)||f&&e[f]!==r[f])){H(n,t,e);return}if(u){if(e.length&&r.length&&(!i||f&&e[0]&&e[0][f]!=null)){let a,d,w,E,b,o,s,x;for(w=0,E=Math.min(r.length,e.length);w=w&&b>=w&&(r[E]===e[b]||f&&r[E]&&e[b]&&r[E][f]&&r[E][f]===e[b][f]);E--,b--)k[b]=r[E];if(w>b||w>E){for(d=w;d<=b;d++)H(r,d,e[d]);for(;de.length)H(r,\"length\",e.length);return}s=Array(b+1);for(d=b;d>=w;d--)o=e[d],x=f&&o?o[f]:o,a=I.get(x),s[d]=a===void 0?-1:a,I.set(x,d);for(a=w;a<=E;a++)if(o=r[a],x=f&&o?o[f]:o,d=I.get(x),d!==void 0&&d!==-1)k[d]=r[a],d=s[d],I.set(x,d);for(d=w;de.length)H(r,\"length\",e.length);return}let l=Object.keys(e);for(let a=0,d=l.length;a{if(!p(r)||!p(f))return f;let u=de(f,{[xn]:r},xn,t,i);return u===void 0?r:u}}var Qi=/(?:|<(?:\"[^\"]*\"['\"]*|'[^']*'['\"]*|[^'\">])+>)/g,Bi=/(?:\\s(?[^/\\s><=]+?)(?=[\\s/>]))|(?:(?\\S+?)(?:\\s*=\\s*(?:(['\"])(?[\\s\\S]*?)\\3|(?[^\\s>]+))))/g,mi={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 Mi(e){let n={type:\"tag\",name:\"\",voidElement:!1,attrs:[],children:[]},t=e.match(/<\\/?([^\\s]+?)[/\\s>]/);if(t){if(n.name=t[1],mi[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(Bi);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 ht(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 yt(e,n){let t=n.replace(\"\",\"\");if(!/^\\s*$/.test(t))e.push({type:\"comment\",content:t})}function pi(e){let n=[],t=void 0,i=-1,f=[],r={};return e.replace(Qi,(u,l)=>{let c=u.charAt(1)!==\"/\",a=u.slice(0,4)===\"\"}}function nf(e){return e.reduce(function(n,t){return n+wt(\"\",t)},\"\")}var bt=new Map,tf=/^(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$/i,Ue=\" \\\\f\\\\n\\\\r\\\\t\",$t=\"[^\"+Ue+`\\\\/>\"'=]+`,En=\"[ \"+Ue+\"]+(?:use:|\"+$t+\")\",xt=\"<([A-Za-z$#]+[A-Za-z0-9:_-]*)((?:\",Ot=`(?:\\\\s*=\\\\s*(?:'[^']*?'|\"[^\"]*?\"|\\\\([^)]*?\\\\)|<[^>]*?>|`+$t+\"))?)\",ff=new RegExp(xt+En+Ot+\"+)([ \"+Ue+\"]*/?>)\",\"g\"),rf=new RegExp(\"(\"+En+`\\\\s*=\\\\s*)(|['\"(]([\\\\w\\\\s]*[\\\\w\\\\s]*)*['\")])`,\"gi\"),uf=new RegExp(xt+En+Ot+\"*)([ \"+Ue+\"]*/>)\",\"g\"),gt=\"\",lf=new Set([\"class\",\"on\",\"oncapture\",\"style\",\"use\",\"prop\",\"attr\"]);function cf(e,n,t,i){return\"<\"+n+t.replace(rf,sf)+i}function sf(e,n,t){return n.replace(//g,\"###\")+(t[0]==='\"'||t[0]===\"'\"?t.replace(//g,\"###\"):'\"###\"')}function of(e,n,t){return tf.test(n)?e:\"<\"+n+t+\">\"}function df(e){return e.toLowerCase().replace(/-([a-z])/g,(n,t)=>t.toUpperCase())}function On(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 af(e,{delegateEvents:n=!0,functionBuilder:t=(...i)=>Function(...i)}={}){let i=1;e.wrapProps=(b)=>{let o=Object.getOwnPropertyDescriptors(b);for(let s in o)if(typeof o[s].value===\"function\"&&!o[s].value.length)e.dynamicProperty(b,s);return b};function f(b,o){let s=0,x=\"\";for(;s\";x=x+b[s],x=[[uf,of],[/<()/g,\"<###\"],[/\\.\\.\\.()/g,\"###\"],[ff,cf],[/>\\n+\\s*/g,\">\"],[/\\n+\\s*\\s+/g,\"> \"]].reduce((V,T)=>{return V.replace(T[0],T[1])},x);let I=pi(x),[A,S]=w(I,o.funcBuilder),N=[];for(let V=0;V-1){let J=j.split(gt).reduce((Q,Ie,Le)=>{return Le&&Q.push(\"\"),Q.push(Ie),Q},[]);T[V].firstChild.replaceWith(...J)}}}return N[0].create=S,bt.set(b,N),N}function r(b,o,s,x,k,I,A){let S=x===\"###\"?`!doNotWrap ? exprs[${A.counter}]() : exprs[${A.counter++}]`:x.split(\"###\").map((j,J)=>J?` + (typeof exprs[${A.counter}] === \"function\" ? exprs[${A.counter}]() : exprs[${A.counter++}]) + \"${j}\"`:`\"${j}\"`).join(\"\"),N,V;if((N=s.split(\":\"))&&N[1]&&lf.has(N[0]))s=N[1],V=N[0];let T=e.ChildProperties.has(s),K=e.Properties.has(s);if(s===\"style\"){let j=`_$v${i++}`;A.decl.push(`${j}={}`),A.exprs.push(`r.style(${o},${S},${j})`)}else if(s===\"classList\"){let j=`_$v${i++}`;A.decl.push(`${j}={}`),A.exprs.push(`r.classList(${o},${S},${j})`)}else if(V!==\"attr\"&&(T||!k&&(e.getPropAlias(s,b.name.toUpperCase())||K)||I||V===\"prop\")){if(I&&!T&&!K&&V!==\"prop\")s=df(s);A.exprs.push(`${o}.${e.getPropAlias(s,b.name.toUpperCase())||s} = ${S}`)}else{let j=k&&s.indexOf(\":\")>-1&&e.SVGNamespace[s.split(\":\")[0]];if(j)A.exprs.push(`r.setAttributeNS(${o},\"${j}\",\"${s}\",${S})`);else A.exprs.push(`r.setAttribute(${o},\"${e.Aliases[s]||s}\",${S})`)}}function u(b,o,s,x,k,I,A){if(s.slice(0,2)===\"on\")if(!s.includes(\":\")){let S=s.slice(2).toLowerCase(),N=n&&e.DelegatedEvents.has(S);A.exprs.push(`r.addEventListener(${o},\"${S}\",exprs[${A.counter++}],${N})`),N&&A.delegatedEvents.add(S)}else{let S=s.startsWith(\"oncapture:\");A.exprs.push(`${o}.addEventListener(\"${s.slice(S?10:3)}\",exprs[${A.counter++}]${S?\",true\":\"\"})`)}else if(s===\"ref\")A.exprs.push(`exprs[${A.counter++}](${o})`);else{let S=Object.assign({},A,{exprs:[]}),N=A.counter;if(r(b,o,s,x,k,I,S),A.decl.push(`_fn${N} = (${x===\"###\"?\"doNotWrap\":\"\"}) => {\n${S.exprs.join(`;\n`)};\n}`),x===\"###\")A.exprs.push(`typeof exprs[${N}] === \"function\" ? r.effect(_fn${N}) : _fn${N}(true)`);else{let V=\"\";for(let T=N;T1)for(let k=0;k1?`r.mergeProps(${o.join(\",\")})`:o[0]}function a(b,o){let s=[],x=Object.keys(b.attrs),k=[s],I=o.counter++;for(let S=0;S exprs[${o.counter++}]`);else if(b.children.length){let S={type:\"fragment\",children:b.children},N=Object.assign({},o,{first:!0,decl:[],exprs:[],parent:!1});d(S,N),s.push(`children: () => { ${N.exprs.join(`;\n`)}}`),o.templateId=N.templateId,o.counter=N.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[${I}],${c(k)})${A?`, ${A}`:\"\"})`);else o.exprs.push(`${o.fragment?\"\":\"return \"}r.createComponent(exprs[${I}],${c(k)})`);o.path=A,o.first=!1}function d(b,o){if(b.type===\"fragment\"){let s=[];b.children.forEach((x)=>{if(x.type===\"tag\"){if(x.name===\"###\"){let A=Object.assign({},o,{first:!0,fragment:!0,decl:[],exprs:[]});a(x,A),s.push(A.exprs[0]),o.counter=A.counter,o.templateId=A.templateId;return}o.templateId++;let k=i,I=Object.assign({},o,{first:!0,decl:[],exprs:[]});o.templateNodes.push([x]),d(x,I),s.push(`function() { ${I.decl.join(`,\n`)+`;\n`+I.exprs.join(`;\n`)+`;\nreturn _$el${k};\n`}}()`),o.counter=I.counter,o.templateId=I.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;kS.name===\"is\");if(o.hasCustomElement=A,o.isImportNode=(b.name===\"img\"||b.name===\"iframe\")&&b.attrs.some((S)=>S.name===\"loading\"&&S.value===\"lazy\"),b.attrs.some((S)=>S.name===\"###\")){let S=[],N=\"\",V=[];for(let T=0;T({${N}})`),N=\"\";S.push(`exprs[${o.counter++}]`)}else V.push(b.attrs[T]);else if(K===\"directive\")On(j,J,s,o)}if(b.attrs=V,N.length)S.push(`()=>({${N}})`);o.exprs.push(`r.spread(${s},${S.length===1?`typeof ${S[0]} === \"function\" ? r.mergeProps(${S[0]}) : ${S[0]}`:`r.mergeProps(${S.join(\",\")})`},${I},${!!b.children.length})`)}else for(let S=0;S document.importNode(tmpls[${k}].content.firstChild, true))`:`const ${s} = tmpls[${k}].content.firstChild.cloneNode(true)`}else if(b.type===\"text\"){let s=`_$el${i++}`;o.decl.push(`${s} = ${o.path}.${o.first?\"firstChild\":\"nextSibling\"}`),o.path=s,o.first=!1}else if(b.type===\"comment\"){let s=`_$el${i++}`;if(o.decl.push(`${s} = ${o.path}.${o.first?\"firstChild\":\"nextSibling\"}`),b.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 w(b,o){let s={path:\"\",decl:[],exprs:[],delegatedEvents:new Set,counter:0,first:!0,multi:!1,templateId:0,templateNodes:[]},x=i,k=b,I;if(b.length>1)b=[{type:\"fragment\",children:b}];if(b[0].name===\"###\")I=!0,a(b[0],s);else d(b[0],s);return e.delegateEvents(Array.from(s.delegatedEvents)),[[k].concat(s.templateNodes).map((S)=>nf(S)),o(\"tmpls\",\"exprs\",\"r\",s.decl.join(`,\n`)+`;\n`+s.exprs.join(`;\n`)+(I?\"\":`;\nreturn _$el${x};\n`))]}function E(b,...o){let s=bt.get(b)||f(b,{funcBuilder:t});return s[0].create(s,o,e)}return E}var h=af({effect:R,style:gn,insert:wn,untrack:v,spread:ft,createComponent:tn,delegateEvents:an,classList:bn,mergeProps:fn,dynamicProperty:rt,setAttribute:Oe,setAttributeNS:hn,addEventListener:yn,Aliases:cn,getPropAlias:sn,Properties:un,ChildProperties:ln,DelegatedEvents:on,SVGElements:tt,SVGNamespace:dn});function Se(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 Et(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 hf=new Set([\"loops\",\"groups\",\"findings\",\"plans\"]),yf=new Set([\"overview\",\"timeline\",\"sections\",\"findings\",\"plan\",\"usage\"]);function ne(e){try{return decodeURIComponent(e)}catch{return e}}function bf(){return{projectId:null,section:\"loops\",loopName:null,tab:\"overview\",groupId:null,statuses:[],query:\"\"}}function kn(e,n){if(!n)return;for(let t of n.split(\"&\")){if(!t)continue;let i=t.indexOf(\"=\");if(i===-1)continue;let f=ne(t.slice(0,i)),r=ne(t.slice(i+1));if(f===\"status\")e.statuses=r.split(\",\").map((u)=>u.trim()).filter((u)=>u!==\"\");else if(f===\"q\")e.query=r}}function Ge(e){let n=bf(),t=(e||\"\").replace(/^#/,\"\");if(!t)return n;let i=t.indexOf(\"?\"),f=i===-1?t:t.slice(0,i),r=i===-1?\"\":t.slice(i+1),u=f.indexOf(\"/\"),l,c;if(u===-1)l=f,c=\"\";else l=f.slice(0,u),c=f.slice(u+1);if(l)n.projectId=ne(l);if(!c)return kn(n,r),n;let a=c.split(\"/\").filter((w)=>w!==\"\");if(a.length===0)return kn(n,r),n;let d=ne(a[0]);if(d===\"loop\"){if(a.length>=2){if(n.loopName=ne(a[1]),a.length>=3){let w=ne(a[2]);if(yf.has(w))n.tab=w}}}else if(hf.has(d)){if(n.section=d,d===\"groups\"&&a.length>=2)n.groupId=ne(a[1])}else n.loopName=d;return kn(n,r),n}function Ne(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 St(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=(c)=>c<10?\"0\"+c:String(c),i=t(n.getMonth()+1),f=t(n.getDate()),r=n.getFullYear(),u=n.getHours(),l=u>=12?\"PM\":\"AM\";if(u=u%12,u===0)u=12;return i+\"-\"+f+\"-\"+r+\" \"+u+\":\"+t(n.getMinutes())+\" \"+l}function ye(e){return\"status-badge status-\"+e}function Ke(e){return\"section-status section-\"+e}function At(e,n,t,i){if(!(n.size===0||n.has(e.status)))return!1;if(!t)return!0;let r=t.toLowerCase(),u=e.worktreeBranch??\"\";return((e.loopName||\"\")+\" \"+u+\" \"+(i||\"\")).toLowerCase().indexOf(r)!==-1}function kt(e){if(!e)return 0;let n=0,t=e.match(/(\\d+)\\s*h/i);if(t)n+=parseInt(t[1],10)*3600;let i=e.match(/(\\d+)\\s*m(?!s)/i);if(i)n+=parseInt(i[1],10)*60;let f=e.match(/(\\d+)\\s*s/i);if(f)n+=parseInt(f[1],10);return n}function Nt(e,n){if(n===\"recent\")return[...e];let t=[...e];if(n===\"cost\")t.sort((i,f)=>{let r=i.usage?.totalCost??null,u=f.usage?.totalCost??null;if(r===null&&u===null)return 0;if(r===null)return 1;if(u===null)return-1;return u-r});else if(n===\"duration\")t.sort((i,f)=>{let r=i.duration?kt(i.duration):0;return(f.duration?kt(f.duration):0)-r});else if(n===\"findings\")t.sort((i,f)=>f.findings.length-i.findings.length);return t}function Ct(e){return JSON.stringify(e,(n,t)=>{if(n===\"generatedAt\")return;return t})}function It(e){let n=new Map;for(let f of e){if(f===\"\"){n.set(f,[]);continue}let r=f.split(\"/\"),u=r.length;while(u>0&&r[u-1]===\"\")u--;n.set(f,u>0?r.slice(0,u):[f])}let t=new Map;for(let f of e){let r=n.get(f);t.set(f,r.length?r[r.length-1]:f)}let i=new Map;for(let f of e){let r=t.get(f),u=i.get(r);if(u)u.push(f);else i.set(r,[f])}for(let[,f]of i){let r=[...new Set(f)];if(r.length<2)continue;let u=1;while(u<1024){let l=new Set,c=!0,a=!0;for(let d of r){let w=n.get(d),E=Math.max(0,w.length-u),b=w.slice(E).join(\"/\");if(l.has(b))c=!1;else l.add(b);if(E>0)a=!1}if(c||a)break;u++}for(let l of r){let c=n.get(l),a=Math.max(0,c.length-u);t.set(l,c.slice(a).join(\"/\"))}}return t}function Sn(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 Pt(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 We(e,n){if(!n||n<=0)return 0;return Math.max(0,Math.min(100,e/n*100))}function An(e){let n=e.file+\":\"+e.line+\" — \"+e.description;if(e.scenario)n+=\" (\"+e.scenario+\")\";return n}function Vt(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 ve(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 ze(e){if(!Number.isFinite(e)||e<=0)return\"$0\";return\"$\"+(e>=1?e.toFixed(2):e.toFixed(4))}function Tt(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 jt(e){let n={role:\"execution\",cost:0,messageCount:0,pct:0},t={role:\"auditor\",cost:0,messageCount:0,pct:0};for(let[f,r]of Object.entries(e.byRole??{}))if(f===\"auditor\")t.cost+=r.cost,t.messageCount+=r.messageCount;else if(f===\"code\")n.cost+=r.cost,n.messageCount+=r.messageCount;let i=Math.max(n.cost,t.cost);return n.pct=i>0?n.cost/i*100:0,t.pct=i>0?t.cost/i*100:0,[n,t]}function _t(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 Dt(e,n){if(!e)return\"\";let t=Et(e,n??void 0);return t>0?Se(t):\"\"}function te(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 r=Math.floor(f/24);if(r<30)return r+\"d ago\";return z(e)}function Nn(e){if(!e)return\"\";let n=Ae.get(e);if(n!==void 0)return n;let t=globalThis.marked;if(!t)return\"\";let i=t.parse(e);if(i){if(Ae.size>=gf){let f=Ae.keys().next().value;if(f!==void 0)Ae.delete(f)}Ae.set(e,i)}return i}var Ae=new Map,gf=200;function Ye(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 He(e,n,t,i,f=\"coding\"){let r=[],u=e.findIndex((w)=>w.createdAt>=n),l=u===-1?[]:e.slice(u);if(t!==null)l=l.filter((w)=>w.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 a=n;for(let w of l){if(w.eventType===\"restart\"){if(w.toPhase!==null)c=w.toPhase;continue}if(c===\"\")c=w.fromPhase,a=Math.max(a,w.createdAt);let E=w.createdAt,b=Math.max(a,E);if(r.push({phase:c,startedAt:a,endedAt:b,durationMs:b-a,open:!1}),w.toPhase===null)c=\"\",a=b;else c=w.toPhase,a=b}if(c!==\"\"){let E=Math.max(a,t??i);r.push({phase:c,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&&r.length>0)r[0]={...r[0],phase:\"\"};return{spans:r,truncated:d}}function Ft(e){let n={};for(let t of e){if(!t.phase)continue;n[t.phase]=(n[t.phase]??0)+t.durationMs}return n}function Ut(e,n,t){let i=[];for(let f=0;f=n)u=Math.max(0,r.createdAt-n);else if(r.createdAt=n)u=Math.max(0,r.createdAt-n);else u=null}i.push({transition:r,elapsedMs:u})}return i.reverse(),i}var wf=[{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}],qe=110,Xe=36,Gt=655,Kt=160,$f=[{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 xf(e){if(e.toPhase===null)return e.fromPhase+\"→terminal\";return e.fromPhase+\"→\"+e.toPhase}function Of(e){return e.toPhase??e.status??\"\"}function Cn(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 Wt(e){let n=()=>e.loop(),t=g(()=>n().status===\"running\"),i=g(()=>{let l=new Map;for(let c of e.transitions()){let a=xf(c);l.set(a,(l.get(a)??0)+1)}return l}),f=$f.map((l)=>Cn(h`\n \n \n ${()=>{let c=i().get(l.key);return c?String(c):\"\"}}\n \n `)),r=wf.map((l)=>{let c=l.cx-qe/2,a=l.cy-Xe/2;return Cn(h`\n \"mg-node\"+(n().phase===l.phase&&t()?\" mg-node-active\":\"\")}>\n \n ${l.label}\n \n `)}),u=Cn(h`\n \n \n ${()=>n().status}\n \n `);return h`
\n \n \n \n \n \n \n\n ${f}\n ${r}\n ${()=>t()?\"\":u}\n \n\n
\n
Transition history
\n
\n ${()=>e.transitions().slice(-20).reverse().map((l)=>h`
\n ${l.eventType}\n ${l.fromPhase} → ${Of(l)}\n ${te(l.createdAt)}\n
`)}\n
\n
\n
`}function qt(e){let[n,t]=U(e.query()),i=null,f=(u)=>{let l=u.target.value;if(t(l),i)clearTimeout(i);i=setTimeout(()=>{i=null,e.onChangeQuery(l)},250)};ue(()=>{if(i)clearTimeout(i),i=null}),B(()=>{e.externalNav();let u=v(()=>e.query());if(i!==null)clearTimeout(i),i=null;t(u)});let r=g(()=>{let u=e.counts();return[{label:\"Running\",key:\"running\",value:u.running},{label:\"Completed\",key:\"completed\",value:u.completed},{label:\"Cancelled\",key:\"cancelled\",value:u.cancelled},{label:\"Errored\",key:\"errored\",value:u.errored},{label:\"Stalled\",key:\"stalled\",value:u.stalled}]});return h`
\n ${()=>r().map(({label:u,key:l,value:c})=>{let d=`badge badge-filter${e.statuses().has(l)?\" badge-active\":\"\"}`;return h`e.onToggleStatus(l)}>${u}: ${c}`})}\n \n \n
`}function Xt(e){return h`
Last updated: ${new Date(e.generatedAt).toLocaleString()}
`}function Zt(e){let n=g(()=>e.entries().map((t)=>{let i=t.loops.some((u)=>u.loop.status===\"running\"),f=t.proj.projectDir||t.proj.projectId||\"\",r=e.labels().get(f)??f;return h`
e.onSelect(t.proj.projectId)}>\n ${i?h``:\"\"}\n ${r}\n ${t.loops.length}\n
`}));return h`
${()=>n()}
`}function Ef(e,n){let t=0,i=0,f=0,r=[],u=[];for(let l of e){let c=l.proj.projectDir||l.proj.projectId||\"\",a=n.get(c)??c;for(let d of l.loops){i++;for(let b of d.findings)if(b.severity===\"bug\")f++;let w=d.loop.status,E=d.loop.completedAt||d.loop.startedAt||0;if(w===\"running\")t++,r.push({projectId:l.proj.projectId,label:a,loopName:d.loop.loopName,phase:d.loop.phase||\"\"});u.push({projectId:l.proj.projectId,label:a,loopName:d.loop.loopName,status:w,when:E})}}return u.sort((l,c)=>c.when-l.when),{runningCount:t,loopCount:i,bugCount:f,runningCards:r,recentRows:u.slice(0,8)}}function Lt(e){let n=g(()=>Ef(e.entries(),e.labels())),t=g(()=>n().runningCount),i=g(()=>n().loopCount),f=g(()=>n().bugCount),r=g(()=>n().runningCards),u=g(()=>n().recentRows);return h`
\n
\n

All repositories

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

Running

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

Recent activity

\n
\n ${u().map((l)=>h`
e.onOpenLoop(l.projectId,l.loopName)}>\n \"status-badge status-\"+l.status}>${l.status}\n ${l.label}\n ${l.loopName}\n ${z(l.when)}\n
`)}\n
\n
`:\"\"}\n
`}function kf(e){let[n,t]=U(!1),[i,f]=U(\"\"),[r,u]=U(0),l=g(()=>{let s=i().trim().toLowerCase(),x=e.loops();return s?x.filter((k)=>k.name.toLowerCase().indexOf(s)!==-1):x}),c=()=>{if(n())return;f(\"\");let s=e.loops().findIndex((x)=>x.name===e.loopName);u(s<0?0:s),t(!0)},a=()=>{t(!1),f(\"\"),u(0)},d=(s)=>{if(a(),o.blur(),s!==e.loopName)e.onSelect(s)},w=h`n()?\"display:block\":\"display:none\"}\n onmousedown=${(s)=>s.preventDefault()}\n >\n ${()=>l().map((s,x)=>h`\"loop-picker-option\"+(x===r()?\" 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 ${()=>l().length===0?h`
No matching loops
`:\"\"}\n
`,E=()=>{w.querySelector(\".loop-picker-option-active\")?.scrollIntoView?.({block:\"nearest\"})},b=(s)=>{if(s.key===\"ArrowDown\"||s.key===\"ArrowUp\"){if(s.preventDefault(),!n()){c();return}let x=l().length;if(x===0)return;let k=s.key===\"ArrowDown\"?1:-1;u((I)=>(I+k+x)%x),E();return}if(s.key===\"Enter\"){s.preventDefault();let x=l()[r()];if(x)d(x.name);return}if(s.key===\"Escape\")s.preventDefault(),a(),o.blur()},o=h`n()?i():e.loopName}\n onfocus=${c}\n oninput=${(s)=>{f(s.target.value),u(0),t(!0)}}\n onkeydown=${b}\n onblur=${a}\n />`;return h`\n ${o}\n {if(s.preventDefault(),n())a(),o.blur();else o.focus()}}>▾\n ${w}\n `}function Rt(e){if(e.loopName)return h`
\n ← ${e.repoLabel}\n /\n ${kf({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 Jt(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 vt(e){let n=()=>We(e.current(),e.total());return h`\n \"width:\"+n()+\"%\"}>\n ${()=>e.current()}/${()=>e.total()}\n `}var Sf={sm:4,md:14,lg:22};function Pn(e){let n=Sf[e.variant],t=g(()=>e.spans().filter((r)=>r.phase!==\"\")),i=g(()=>Math.max(0,t().reduce((r,u)=>r+u.durationMs,0))),f=g(()=>e.spans().some((r)=>r.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 r=t(),u=i();return r.map((l)=>{let c=u>0?l.durationMs/u*100:0,a=\"phase-seg\"+(l.open?\" phase-seg-open\":\"\");return h`
`})}}\n
`}function Af(e,n){return He(e.transitions??[],e.loop.startedAt,e.loop.completedAt,n,e.loop.phase).spans}function Qt(e){return h`\n \n \n \n \n \n ${()=>e.loops().map((n)=>Nf({dashLoop:n,now:e.now,onOpen:e.onOpen}))}\n \n
StatusLoopPhasePhase spanIterSectionsFindingsCostDurationUpdated
`}function Nf(e){let n=()=>e.dashLoop.loop,t=()=>e.dashLoop,i=g(()=>Sn(t().findings)),f=g(()=>Af(t(),e.now()));return h`e.onOpen(e.dashLoop.loop.loopName)}>\n ye(n().status)}>${()=>n().status}\n ${()=>n().loopName}\n ${()=>n().phase}\n ${()=>Pn({spans:f,variant:\"sm\",now:e.now})}\n ${vt({current:()=>n().iteration,total:()=>n().maxIterations})}\n ${()=>n().totalSections>0?vt({current:()=>n().currentSectionIndex,total:()=>n().totalSections}):h``}\n ${()=>{let r=i();if(r.bugs.length===0&&r.warnings.length===0)return h``;return h`\n ${r.bugs.length>0?h`${be(r.bugs.length,\"bug\")}`:\"\"}\n ${r.bugs.length>0&&r.warnings.length>0?\" · \":\"\"}\n ${r.warnings.length>0?h`${r.warnings.length} warn`:\"\"}\n `}}\n ${()=>t().usage?ze(t().usage.totalCost):h``}\n ${()=>t().duration||\"\"}\n ${()=>z(n().completedAt||n().startedAt)}\n `}function Ce(e){let n=()=>typeof e.label===\"function\"?e.label():e.label;return h`
\n
\n

${()=>n()}

\n \"Copy \"+n()+\" as markdown\"}\n onclick=${(t)=>{t.stopPropagation();let i=t.target,f=\"Copy\";navigator.clipboard.writeText(e.src()||\"\").then(()=>{i.textContent=\"Copied!\"},()=>{i.textContent=\"Failed\"}).then(()=>{setTimeout(()=>{i.textContent=f},2000)})}}\n >Copy\n
\n
\n
Nn(e.src()||\"\")}>
\n
\n
`}function In(e){return h`
\n
${e.label}
\n
Nn(e.value()||\"\")}>
\n
`}function Cf(e){let n=e.sec,t=g(()=>!!n.startedAt||!!n.completedAt),i=g(()=>!!n.summaryDone),f=g(()=>!!n.summaryDeviations),r=g(()=>!!n.summaryFollowUps),u=g(()=>!!n.content),l=g(()=>t()||i()||f()||r()||u());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()?In({label:\"Done\",value:()=>n.summaryDone}):\"\"}\n ${()=>f()?In({label:\"Deviations\",value:()=>n.summaryDeviations}):\"\"}\n ${()=>r()?In({label:\"Follow-ups\",value:()=>n.summaryFollowUps}):\"\"}\n ${()=>u()?Ce({label:\"Section Plan\",src:()=>n.content}):\"\"}\n ${()=>l()?\"\":h`
No details captured for this section yet.
`}\n
`}function If(e){let n=e.sec,t=g(()=>Dt(n.startedAt,n.completedAt));return h`
\"section-list-row section-item-\"+n.status} onclick=${e.onOpen}>\n #${n.sectionIndex}\n ${()=>n.title}\n Ke(n.status)}>${()=>n.status}\n ${()=>t()?h`${()=>t()}`:\"\"}\n ${()=>n.attempts>0?h`${()=>n.attempts} attempts`:\"\"}\n \n
`}function Pf(e){let[n,t]=U(null),i=g(()=>{let f=n();if(f===null)return null;return e.sections().find((r)=>r.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 Ke(f.status)}>${()=>f.status}\n
\n ${Cf({sec:f})}\n
`;return h`
\n ${e.sections().map((r)=>If({sec:r,onOpen:()=>t(r.sectionIndex)}))}\n
`}}\n
`}function ge(e){return h`
\n ${e.label}\n ${()=>e.value()}\n
`}function zt(e){let n=()=>We(e.current(),e.total());return h`
\n
\n ${e.label}\n ${()=>e.current()} / ${()=>e.total()}\n
\n
\n
\"width:\"+n()+\"%\"}>
\n
\n
`}function Vf(e){let n=()=>e.dashLoop.loop,t=()=>e.dashLoop,i=g(()=>!!n().completedAt),f=g(()=>!!t().duration),r=g(()=>n().totalSections>0),u=g(()=>!!n().terminationReason),l=g(()=>Pt(e.split())),c=g(()=>!!e.dashLoop.usage);return h`
\n
\"ldh-findings ldh-findings-\"+l()}>\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(be(a.bugs.length,\"bug\"));if(a.warnings.length>0)d.push(be(a.warnings.length,\"warning\"));return d.join(\" · \")}}\n
\n
\n ye(n().status)}>${()=>n().status}\n

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

\n ${()=>n().phase}\n
\n\n
\n ${ge({label:\"Started\",value:()=>z(n().startedAt)})}\n ${()=>i()?ge({label:\"Completed\",value:()=>z(n().completedAt)}):\"\"}\n ${()=>f()?ge({label:\"Duration\",value:()=>t().duration||\"\"}):\"\"}\n ${ge({label:\"Iteration\",value:()=>n().iteration+\" / \"+n().maxIterations})}\n ${()=>r()?ge({label:\"Section\",value:()=>n().currentSectionIndex+\" / \"+n().totalSections}):\"\"}\n ${()=>c()?ge({label:\"Messages\",value:()=>String(e.dashLoop.usage.totalMessageCount)}):\"\"}\n
\n\n
\n ${zt({label:\"Iterations\",current:()=>n().iteration,total:()=>n().maxIterations})}\n ${()=>r()?zt({label:\"Sections\",current:()=>n().currentSectionIndex,total:()=>n().totalSections}):\"\"}\n
\n\n ${()=>u()?h`
\"ldh-banner ldh-banner-\"+n().status}>${()=>n().terminationReason}
`:\"\"}\n
`}function Tf(e){let n=()=>e.usage(),t=g(()=>Tt(n())),i=g(()=>_t(n())),f=g(()=>i().length>0),r=g(()=>jt(n()));return h`
\n

Usage

\n\n
\n
Role split
\n
\n ${()=>r().map((u)=>h`
u.role}>\n
\n u.role}>${u.role}\n ${()=>ze(u.cost)}\n
\n
\n
\"width:\"+u.pct+\"%\"}>
\n
\n
${()=>u.messageCount+\" msg\"}
\n
`)}\n
\n
\n\n
\n
Token composition
\n
\n ${()=>t().map((u)=>u.pct>0?h`
`:\"\")}\n
\n
\n ${()=>t().map((u)=>h`
\n \n ${u.label}\n ${ve(u.value)}\n
`)}\n
\n
\n\n ${()=>f()?h`
\n
Cost by model
\n
\n ${()=>i().map((u)=>h`
\n
\n ${u.model}\n ${ze(u.cost)}\n
\n
\n
\n
\n
\n ${ve(u.inputTokens)} in / ${ve(u.outputTokens)} out · ${u.messageCount} msg\n
\n
`)}\n
\n
`:\"\"}\n
`}function Yt(e){try{return JSON.parse(e)}catch{return[]}}function Ht(e){return\"#\"+e.index+\" \"+e.title}function jf(e){let n=e.amendment,t=g(()=>Yt(n.sectionsBefore)),i=g(()=>Yt(n.sectionsAfter));return h`
\n
\n ${()=>te(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`
${()=>Ht(f)}
`)}\n
`:\"\"}\n ${()=>i().length>0?h`
\n
After
\n ${()=>i().map((f)=>h`
${()=>Ht(f)}
`)}\n
`:\"\"}\n
\n
\n
`}function _f(e){let n=()=>e.amendments(),[t,i]=U(null);return h`
\n

Plan amendments

\n
\n ${()=>n().map((f)=>jf({amendment:f,expanded:()=>t()===f.id,onToggle:()=>i(t()===f.id?null:f.id)}))}\n
\n
`}var Df=[\"overview\",\"timeline\",\"sections\",\"findings\",\"plan\",\"usage\"],Ff={overview:\"Overview\",timeline:\"Timeline\",sections:\"Sections\",findings:\"Findings\",plan:\"Plan\",usage:\"Usage\"};function Uf(e){return h`
\n ${()=>e.tabs().map((n)=>{let t=Ff[n];return h`\"tab-item\"+(e.activeTab()===n?\" tab-active\":\"\")} data-tab=\"${n}\" onclick=${()=>e.onSelect(n)}>${t}`})}\n
`}function ie(e){return h`
\n ${e.label}\n ${()=>e.value()??\"—\"}\n
`}function Gf(e){let n=()=>e.dashLoop,t=()=>n().loop,i=()=>e.split(),f=g(()=>!!n().goal),r=g(()=>i().bugs.length>0),u=g(()=>i().warnings.length>0),l=g(()=>r()||u()),c=g(()=>n().postActionReport??t().completionSummary??null),a=g(()=>n().postActionReport?\"Post-Action Report\":\"Completion Summary\"),d=g(()=>!!c()),w=g(()=>!!n().lastAuditResult),E=g(()=>He(n().transitions??[],t().startedAt,t().completedAt,e.now(),t().phase));return h`
\n ${()=>Pn({spans:()=>E().spans,variant:\"md\",now:e.now})}\n ${()=>f()?Ce({label:\"Goal\",src:()=>n().goal}):\"\"}\n ${Vf({dashLoop:e.dashLoop,split:e.split})}\n
\n ${ie({label:\"Execution model\",value:()=>t().executionModel})}\n ${ie({label:\"Auditor model\",value:()=>t().auditorModel})}\n ${ie({label:\"Branch\",value:()=>t().worktreeBranch})}\n ${ie({label:\"Sandbox\",value:()=>t().sandbox?\"on\":\"off\"})}\n ${ie({label:\"Kind\",value:()=>t().kind})}\n ${ie({label:\"Audits\",value:()=>String(t().auditCount)})}\n ${ie({label:\"Errors\",value:()=>String(t().errorCount)})}\n
\n
\n ${()=>l()?h`
\n ${()=>r()?h`${()=>be(i().bugs.length,\"bug\")}`:\"\"}\n ${()=>u()?h`${()=>be(i().warnings.length,\"warning\")}`:\"\"}\n
`:h`No findings`}\n
\n ${()=>d()?Ce({label:a,src:()=>c()}):\"\"}\n ${()=>w()?Ce({label:\"Last Audit Result\",src:()=>n().lastAuditResult}):\"\"}\n
`}function Kf(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 ${te(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?Se(i):\"0s\"}\n
`}function Wf(e){let n=()=>e.dashLoop,t=()=>n().loop,i=()=>n().transitions??[],f=g(()=>He(i(),t().startedAt,t().completedAt,e.now(),t().phase)),r=g(()=>f().spans),u=g(()=>f().truncated),l=g(()=>Ft(r())),c=g(()=>r().filter((s)=>s.phase!==\"\").reduce((s,x)=>s+x.durationMs,0)),a=g(()=>{let s=l();return Object.keys(s).filter((x)=>x!==\"\").sort((x,k)=>s[k]-s[x])}),d=g(()=>Ut(i(),t().startedAt,u())),[w,E]=U(!1),b=g(()=>{let s=d();return w()?s:s.slice(0,20)}),o=g(()=>w()?0:Math.max(0,d().length-20));return h`
\n ${()=>Pn({spans:r,variant:\"lg\",now:e.now})}\n ${()=>u()?h`
Earlier history was truncated by the 100-row fetch window.
`:\"\"}\n\n
\n ${()=>a().map((s)=>{let x=l()[s],k=c()>0?Math.round(x/c()*100):0;return h`
\n \n ${s}\n \n ${()=>x>=1000?Se(Math.floor(x/1000)):x+\"ms\"} · ${k}%\n
`})}\n
\n\n
\n ${()=>b().map((s)=>Kf({ev:s}))}\n ${()=>o()>0?h`
E(!0)}>[ ${()=>o()} earlier events ]
`:\"\"}\n
\n\n
\n State machine graph\n ${Wt({loop:()=>t(),transitions:i})}\n
\n
`}function vf(e){return Pf({sections:()=>e.dashLoop.sections})}function zf(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 Yf(e){let n=()=>e.dashLoop,t=g(()=>n().findings.length>0),i=g(()=>zf(n().findings));return h`
\n ${()=>t()?i().map((f)=>h`
f.key===null?\"cross\":String(f.key)}>\n
${f.label}
\n
\n ${f.rows.map((r)=>h`
\"finding finding-\"+r.severity}>\n ${()=>te(r.createdAt)}\n ${()=>An(r)}\n
`)}\n
\n
`):h`
No findings recorded for this loop.
`}\n
`}function Hf(e){let n=()=>e.dashLoop,t=g(()=>!!n().plan),i=g(()=>!!n().amendments&&n().amendments.length>0);return h`
\n ${()=>t()?Ce({label:\"Plan\",src:()=>n().plan}):h`
No plan recorded for this loop.
`}\n ${()=>i()?_f({amendments:()=>n().amendments}):\"\"}\n
`}function qf(e){let n=()=>e.dashLoop,t=g(()=>!!n().usage);return h`
\n ${()=>t()?Tf({usage:()=>n().usage}):h`
No usage recorded for this loop.
`}\n
`}function Xf(e){let n=g(()=>e.tabs().includes(e.tab)&&e.activeTab()===e.tab),t=(()=>{switch(e.tab){case\"overview\":return Gf({dashLoop:e.dashLoop,split:e.split,now:e.now});case\"timeline\":return Wf({dashLoop:e.dashLoop,now:e.now});case\"sections\":return vf({dashLoop:e.dashLoop});case\"findings\":return Yf({dashLoop:e.dashLoop});case\"plan\":return Hf({dashLoop:e.dashLoop});case\"usage\":return qf({dashLoop:e.dashLoop})}})();return h`
n()?\"display:block\":\"display:none\"}>\n ${t}\n
`}function Zf(e){return h`
\n ${Df.map((n)=>Xf({tab:n,dashLoop:e.dashLoop,split:e.split,now:e.now,tabs:e.tabs,activeTab:e.activeTab}))}\n
`}function Bt(e){let n=()=>e.dashLoop,t=g(()=>Sn(n().findings)),i=g(()=>Ye(n())),f=g(()=>{let r=e.routeTab();return i().includes(r)?r:\"overview\"});return h`
\n
\n ${Uf({tabs:i,activeTab:f,onSelect:e.onSelectTab})}\n ${Zf({dashLoop:e.dashLoop,split:t,now:e.now,tabs:i,activeTab:f})}\n
\n
`}function Vn(){return h`
No loops match the current filters.
`}var mt=new Set([\"completed\",\"cancelled\",\"errored\"]);function Ze(e){let n=e.length;return{completed:e.filter((i)=>i.stage===\"completed\").length,total:n}}function Lf(e){return\"group-row\"+(mt.has(e)?\" group-row-terminal\":\" group-row-active\")}function Mt(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;return h`
Lf(i.status)} data-group-status=${()=>i.status} onclick=${()=>e.onOpen(i.groupId)}>\n ye(i.status)}>${()=>i.status}\n ${()=>i.title}\n \n \"width:\"+We(Ze(t.features).completed,Ze(t.features).total)+\"%\"}>\n ${()=>{let f=Ze(t.features);return f.completed+\"/\"+f.total}}\n \n max ${()=>i.maxConcurrent}\n \n Created ${()=>z(i.createdAt)}\n ${()=>i.completedAt?h`Completed ${()=>z(i.completedAt)}`:\"\"}\n \n
`})}\n
`}}\n
`}function Rf(e){return\"group-header\"+(mt.has(e)?\" group-header-terminal\":\" group-header-active\")}function pt(e){return h`
\n
← Back to groups
\n ${()=>{let n=e.group();if(!n)return h`
Group not found.
`;let t=n.group;return h`
Rf(t.status)} data-group-status=${()=>t.status}>\n
\n ye(t.status)}>${()=>t.status}\n

${()=>t.title}

\n max ${()=>t.maxConcurrent}\n
\n
\n ${()=>{let i=Ze(n.features);return\"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((i)=>{return h`
i.stage}>\n #${()=>i.featureIndex}\n ${()=>i.title}\n Ke(i.stage)}>${()=>i.stage}\n ${()=>i.attempts} attempts\n ${()=>!!i.loopName&&e.loopNames().has(i.loopName)?h`Ne({projectId:e.projectId(),section:\"loops\",loopName:i.loopName,tab:\"overview\",groupId:null,statuses:[],query:\"\"})} onclick=${(f)=>{if(f.defaultPrevented||f.button!==0||f.metaKey||f.ctrlKey||f.shiftKey||f.altKey)return;f.preventDefault(),e.onOpenLoop(i.loopName)}}>${()=>i.loopName}`:\"\"}\n ${()=>i.error?h`${()=>i.error}`:\"\"}\n
`})}\n
`}}\n
`}function Jf(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 ei(e){let n=g(()=>Jf(e.loops())),t=g(()=>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
`}function ni(e){let n=g(()=>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 ye(i.loop.status)}>${()=>i.loop.status}\n ${()=>i.loop.loopName}\n \n ${()=>i.findings.length+\" finding\"+(i.findings.length===1?\"\":\"s\")}\n \n iter ${()=>i.loop.iteration}/${()=>i.loop.maxIterations}\n
`)}\n
`}}\n
`}function ti(){let[e,n]=dt({generatedAt:0,projects:[],totals:{projects:0,loops:0,running:0,completed:0,cancelled:0,errored:0,stalled:0}}),[t,i]=U(!1),[f,r]=U(Ge(\"\")),[u,l]=U(null),[c,a]=U(\"recent\"),[d,w]=U(0),[E,b]=U(Date.now()),o={current:\"\"},s={current:!1},x=async()=>{try{let $=await(await fetch(\"/api/data\",{cache:\"no-store\"})).json(),P=Ct($);if(P!==o.current)o.current=P,n(at($,{key:\"id\"})),i(!0),l(null);b(Date.now())}catch(y){let $=y instanceof Error?y.message:String(y);l(\"Failed to load dashboard data: \"+$)}},k=(y)=>{r(($)=>{let P={...$,...y};if(y.projectId!==void 0&&y.projectId!==$.projectId)P.statuses=[],P.query=\"\";return P})},I=(y)=>{k({tab:y})},A=(y)=>{k({loopName:y,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})},S=(y)=>{let $=new Set(f().statuses);if($.has(y))$.delete(y);else $.add(y);k({statuses:[...$]})},N=(y)=>{k({query:y})},V=(y)=>{a(y)},T=g(()=>new Set(f().statuses)),K=g(()=>It(e.projects.map((y)=>y.projectDir||y.projectId||\"\"))),j=g(()=>{if(!t()||e.projects.length===0)return[];let y=T(),$=f().query,P=K(),F=[],ee=$.trim().toLowerCase();for(let fe of e.projects){let Qe=[],_n=fe.projectDir||fe.projectId||\"\",Dn=P.get(_n)??_n;for(let Fn of fe.loops)if(At(Fn.loop,y,$,Dn))Qe.push(Fn);let Oi=(fe.groups?.length??0)>0;if(Qe.length>0)F.push({proj:fe,loops:Qe});else if(Oi&&(!ee||Dn.toLowerCase().indexOf(ee)!==-1))F.push({proj:fe,loops:[]})}return F}),J=g(()=>f().projectId===null),Q=g(()=>f().section===\"groups\"),Ie=g(()=>f().section===\"loops\"),Le=g(()=>f().section===\"findings\"),fi=g(()=>f().section===\"plans\"),Tn=g(()=>{let y=j(),$=f().projectId;if(!$)return null;return y.find((P)=>P.proj.projectId===$)??null}),Re=g(()=>{let y=f();if(!y.projectId||!y.loopName)return null;let $=Z();if(!$)return null;return $.loops.find((P)=>P.loop.loopName===y.loopName)??null}),Z=g(()=>{let y=f().projectId;if(!y)return null;return e.projects.find(($)=>$.projectId===y)??null}),ri=g(()=>{let y=Z(),$=y?.groups?.length??0;if(!y)return{loops:0,groups:$,findings:0,plans:0};let P=0,F=0;for(let ee of y.loops)if(P+=ee.findings.length,ee.plan)F++;return{loops:y.loops.length,groups:$,findings:P,plans:F}}),Je=g(()=>{let y=Z();if(!y)return[];return y.loops.map(($)=>({name:$.loop.loopName,when:$.loop.completedAt||$.loop.startedAt||0})).sort(($,P)=>P.when-$.when||$.name.localeCompare(P.name))}),ui=g(()=>{let y=f().loopName;if(!y)return-1;return Je().findIndex(($)=>$.name===y)}),li=g(()=>{let y=ui();if(y<0)return null;let $=Je();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})}}}),ci=g(()=>{let y=Z(),$={running:0,completed:0,cancelled:0,errored:0,stalled:0};if(!y)return $;for(let P of y.loops){let F=P.loop.status;if(F===\"running\")$.running++;else if(F===\"completed\")$.completed++;else if(F===\"cancelled\")$.cancelled++;else if(F===\"errored\")$.errored++;else if(F===\"stalled\")$.stalled++}return $}),si=g(()=>{let y=f();if(!y.projectId)return\"\";let $=Z();if(!$)return\"\";let P=$.projectDir||$.projectId||\"\",F=K().get(P)??P;return v(()=>Rt({repoLabel:F,projectDir:P,loopName:y.loopName,loops:Je,loopNav:li,onSelectLoop:(ee)=>k({loopName:ee}),onBackToRepos:()=>k({projectId:null,loopName:null,section:\"loops\"}),onBackToRepo:()=>k({loopName:null})}))}),oi=g(()=>{let y=f();if(!y.projectId||y.loopName)return\"\";return Jt({section:y.section,counts:ri(),onNavigate:($)=>k({section:$,loopName:null,groupId:null})})}),di=g(()=>{let y=f();return y.projectId!==null&&y.loopName===null&&y.section===\"loops\"}),ai=g(()=>{if(!di())return\"\";return v(()=>qt({counts:ci,statuses:T,query:()=>f().query,sortMode:c,onToggleStatus:S,onChangeQuery:N,onChangeSort:V,externalNav:d}))}),hi=g(()=>{if(!Ie())return\"\";let y=Re();return y?Bt({dashLoop:y,now:E,routeTab:()=>f().tab,onSelectTab:I}):\"\"}),yi=g(()=>{if(!Ie())return\"\";if(Re())return\"\";if(!f().projectId)return\"\";return h`
\n ${Qt({loops:()=>{let $=Tn();return $?Nt($.loops,c()):[]},now:E,onOpen:($)=>A($)})}\n ${()=>Tn()?\"\":Vn()}\n
`}),bi=g(()=>{let y=f();if(y.section!==\"groups\"||!y.groupId)return null;let $=Z();if(!$)return null;return $.groups.find((P)=>P.group.groupId===y.groupId)??null}),gi=g(()=>{let y=Z();if(!y)return new Set;return new Set(y.loops.map(($)=>$.loop.loopName))}),wi=g(()=>{if(!Q())return\"\";let y=f();if(!y.projectId)return\"\";if(y.groupId)return pt({group:bi,loopNames:gi,projectId:()=>f().projectId,onBack:()=>k({groupId:null}),onOpenLoop:A});return Mt({groups:()=>Z()?.groups??[],onOpen:($)=>k({groupId:$})})}),jn=g(()=>{let y=Z();return y?y.loops:[]}),$i=g(()=>{if(!Le())return\"\";if(!f().projectId)return\"\";return ei({loops:jn,projectId:()=>f().projectId,onOpenLoop:A})}),xi=g(()=>{if(!fi())return\"\";if(!f().projectId)return\"\";return ni({loops:jn,onOpenLoop:A})});return B(()=>{if(!t())return;let y=f();if(y.projectId===null)return;if(!e.projects.some((P)=>P.projectId===y.projectId))r((P)=>({...P,projectId:null,loopName:null,section:\"loops\",statuses:[],query:\"\"}))}),B(()=>{let y=f();if(!y.projectId||!y.loopName)return;let $=Z();if(!$)return;if(!$.loops.some((F)=>F.loop.loopName===y.loopName))r((F)=>({...F,loopName:null}))}),B(()=>{let y=f();if(!y.projectId||!y.loopName||y.tab===\"overview\")return;let $=Re();if(!$)return;if(!Ye($).includes(y.tab))r((F)=>({...F,tab:\"overview\"}))}),B(()=>{if(!t())return;St(Ne(f()),s)}),Zn(()=>{r(Ge(location.hash)),x();let y=setInterval(()=>{x()},5000),$=()=>{if(s.current){s.current=!1;return}r(Ge(location.hash)),w((P)=>P+1)};window.addEventListener(\"hashchange\",$),ue(()=>{clearInterval(y),window.removeEventListener(\"hashchange\",$)})}),h`
\n

Forge Dashboard

\n\n ${()=>{let y=u();return y?h`
${y}
`:\"\"}}\n\n ${()=>{if(!t())return\"\";return h`
\n ${Xt({generatedAt:e.generatedAt})}\n
`}}\n\n ${()=>{if(!t())return\"\";if(J()&&j().length===0)return Vn();return h`
\n ${()=>{if(J())return h`
\n ${Zt({entries:()=>j(),labels:()=>K(),onSelect:(y)=>k({projectId:y,loopName:null,section:\"loops\",groupId:null})})}\n ${Lt({entries:()=>j(),labels:()=>K(),onOpenLoop:(y,$)=>k({projectId:y,loopName:$,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})})}\n
`;return h`
\n ${si}\n ${ai}\n ${oi}\n ${yi}\n ${hi}\n ${wi}\n ${$i}\n ${xi}\n
`}}\n
`}}\n
`}var ii=document.getElementById(\"forge-app-root\");if(ii)it(ti,ii);\n"; -export const DASHBOARD_APP_SOURCE_HASH: string = "335749099c1ed3ca7731af7876959768d19503df26fbf6d17173949dc89fa1ef"; +export const DASHBOARD_APP_BUNDLE: string = "var _={context:void 0,registry:void 0,effects:void 0,done:!1,getContextId(){return Un(this.context.count)},getNextContextId(){return Un(this.context.count++)}};function Un(e){let n=String(e),t=n.length-1;return _.context.id+(t?String.fromCharCode(96+t):\"\")+n}function me(e){_.context=e}function Ei(){return{..._.context,id:_.getNextContextId(),count:0}}var ki=!0,Si=(e,n)=>e===n,q=Symbol(\"solid-proxy\"),Ai=typeof Proxy===\"function\",pe=Symbol(\"solid-track\"),Yn=Symbol(\"solid-dev-component\"),Ve={equals:Si},Gn=null,Hn=Jn,Y=1,ge=2,Ni={};var C=null,O=null,xe=null,re=null,D=null,G=null,W=null,je=0,X={afterUpdate:null,afterCreateOwner:null,afterCreateSignal:null,afterRegisterGraph:null};function qn(e,n){let t=D,i=C,f=e.length===0,r=n===void 0?i:n,u=f?{owned:null,cleanups:null,context:null,owner:null}:{owned:null,cleanups:null,context:r?r.context:null,owner:r},l=f?()=>e(()=>{throw Error(\"Dispose method must be an explicit argument to createRoot function\")}):()=>e(()=>v(()=>M(u)));X.afterCreateOwner&&X.afterCreateOwner(u),C=u,D=null;try{return L(l,!0)}finally{D=t,C=i}}function F(e,n){n=n?Object.assign({},Ve,n):Ve;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(Ln(t),X.afterCreateSignal)X.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 en(t,f)};return[Rn.bind(t),i]}function R(e,n,t){let i=De(e,n,!1,Y,t);if(xe&&O&&O.running)G.push(i);else le(i)}function B(e,n,t){Hn=Ti;let i=De(e,n,!1,Y,t),f=Me&&Ii(Me);if(f)i.suspense=f;if(!t||!t.render)i.user=!0;W?W.push(i):le(i)}function w(e,n,t){t=t?Object.assign({},Ve,t):Ve;let i=De(e,n,!0,0,t);if(i.observers=null,i.observerSlots=null,i.comparator=t.equals||void 0,xe&&O&&O.running)i.tState=Y,G.push(i);else le(i);return Rn.bind(i)}function Xn(e){return L(e,!1)}function v(e){if(!re&&D===null)return e();let n=D;D=null;try{if(re)return re.untrack(e);return e()}finally{D=n}}function Zn(e){B(()=>v(e))}function ue(e){if(C===null)console.warn(\"cleanups created outside a `createRoot` or `render` will never be run\");else if(C.cleanups===null)C.cleanups=[e];else C.cleanups.push(e);return e}function _e(){return D}function Ci(e){if(O&&O.running)return e(),O.done;let n=D,t=C;return Promise.resolve().then(()=>{D=n,C=t;let i;if(xe||Me)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 L(e,!1),D=C=null,i?i.done:void 0})}var[Qf,Kn]=F(!1);function Wn(e,n){let t=De(()=>v(()=>{return Object.assign(e,{[Yn]:!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 Ln(e){if(C){if(C.sourceMap)C.sourceMap.push(e);else C.sourceMap=[e];e.graph=C}if(X.afterRegisterGraph)X.afterRegisterGraph(e)}function Ii(e){let n;return C&&C.context&&(n=C.context[e.id])!==void 0?n:e.defaultValue}var Me;function Rn(){let e=O&&O.running;if(this.sources&&(e?this.tState:this.state))if((e?this.tState:this.state)===Y)le(this);else{let n=G;G=null,L(()=>Te(this),!1),G=n}if(D){let n=this.observers?this.observers.length:0;if(!D.sources)D.sources=[this],D.sourceSlots=[n];else D.sources.push(this),D.sourceSlots.push(n);if(!this.observers)this.observers=[D],this.observerSlots=[D.sources.length-1];else this.observers.push(D),this.observerSlots.push(D.sources.length-1)}if(e&&O.sources.has(this))return this.tValue;return this.value}function en(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)L(()=>{for(let f=0;f1e6){if(G=[],ki)throw Error(\"Potential Infinite Loop Detected.\");throw Error()}},!1)}return n}function le(e){if(!e.fn)return;M(e);let n=je;if(vn(e,O&&O.running&&O.sources.has(e)?e.tValue:e.value,n),O&&!O.running&&O.sources.has(e))queueMicrotask(()=>{L(()=>{O&&(O.running=!0),D=C=e,vn(e,e.tValue,n),D=C=null},!1)})}function vn(e,n,t){let i,f=C,r=D;D=C=e;try{i=e.fn(n)}catch(u){if(e.pure)if(O&&O.running)e.tState=Y,e.tOwned&&e.tOwned.forEach(M),e.tOwned=void 0;else e.state=Y,e.owned&&e.owned.forEach(M),e.owned=null;return e.updatedAt=t+1,nn(u)}finally{D=r,C=f}if(!e.updatedAt||e.updatedAt<=t){if(e.updatedAt!=null&&\"observers\"in e)en(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 De(e,n,t,i=Y,f){let r={fn:e,state:i,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:n,owner:C,context:C?C.context:null,pure:t};if(O&&O.running)r.state=0,r.tState=i;if(C===null)console.warn(\"computations created outside a `createRoot` or `render` will never be disposed\");else if(C!==Ni)if(O&&O.running&&C.pure)if(!C.tOwned)C.tOwned=[r];else C.tOwned.push(r);else if(!C.owned)C.owned=[r];else C.owned.push(r);if(f&&f.name)r.name=f.name;if(re&&r.fn){let u=r.fn,[l,c]=F(void 0,{equals:!1}),a=re.factory(u,c);ue(()=>a.dispose());let d,g=()=>Ci(c).then(()=>{if(d)d.dispose(),d=void 0});r.fn=(E)=>{if(l(),O&&O.running){if(!d)d=re.factory(u,g);return d.track(E)}return a.track(E)}}return X.afterCreateOwner&&X.afterCreateOwner(r),r}function $e(e){let n=O&&O.running;if((n?e.tState:e.state)===0)return;if((n?e.tState:e.state)===ge)return Te(e);if(e.suspense&&v(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,r=t[i+1];while((f=f.owner)&&f!==r)if(O.disposed.has(f))return}if((n?e.tState:e.state)===Y)le(e);else if((n?e.tState:e.state)===ge){let f=G;G=null,L(()=>Te(e,t[0]),!1),G=f}}}function L(e,n){if(G)return e();let t=!1;if(!n)G=[];if(W)t=!0;else W=[];je++;try{let i=e();return Pi(t),i}catch(i){if(!t)W=null;G=null,nn(i)}}function Pi(e){if(G){if(xe&&O&&O.running)Vi(G);else Jn(G);G=null}if(e)return;let n;if(O){if(!O.promises.size&&!O.queue.size){let{sources:i,disposed:f}=O;W.push.apply(W,O.effects),n=O.resolve;for(let r of W)\"tState\"in r&&(r.state=r.tState),delete r.tState;O=null,L(()=>{for(let r of f)M(r);for(let r of i){if(r.value=r.tValue,r.owned)for(let u=0,l=r.owned.length;uHn(t),!1);else X.afterUpdate&&X.afterUpdate();if(n)n()}function Jn(e){for(let n=0;n{i.delete(t),L(()=>{O.running=!0,$e(t)},!1),O&&(O.running=!1)})}}function Ti(e){let n,t=0;for(n=0;n=0;n--)M(e.tOwned[n]);delete e.tOwned}if(O&&O.running&&e.pure)Bn(e,!0);else if(e.owned){for(n=e.owned.length-1;n>=0;n--)M(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 Bn(e,n){if(!n)e.tState=0,O.disposed.add(e);if(e.owned)for(let t=0;t=0;l--){let c=Be(e[l])[u];if(c!==void 0)return c}},has(u){for(let l=e.length-1;l>=0;l--)if(u in Be(e[l]))return!0;return!1},keys(){let u=[];for(let l=0;l=0;u--){let l=e[u];if(!l)continue;let c=Object.getOwnPropertyNames(l);for(let a=c.length-1;a>=0;a--){let d=c[a];if(d===\"__proto__\"||d===\"constructor\")continue;let g=Object.getOwnPropertyDescriptor(l,d);if(!i[d])i[d]=g.get?{enumerable:!0,configurable:!0,get:Fi.bind(t[d]=[g.get.bind(l)])}:g.value!==void 0?g:void 0;else{let E=t[d];if(E){if(g.get)E.push(g.get.bind(l));else if(g.value!==void 0)E.push(()=>g.value)}}}}let f={},r=Object.keys(i);for(let u=r.length-1;u>=0;u--){let l=r[u],c=i[l];if(c&&c.get)Object.defineProperty(f,l,c);else f[l]=c?c.value:void 0}return f}var mn={hooks:X,writeSignal:en,registerGraph:Ln};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 Gi=[\"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\"],un=new Set([\"className\",\"value\",\"readOnly\",\"noValidate\",\"formNoValidate\",\"isMap\",\"noModule\",\"playsInline\",\"adAuctionHeaders\",\"allowFullscreen\",\"browsingTopics\",\"defaultChecked\",\"defaultMuted\",\"defaultSelected\",\"disablePictureInPicture\",\"disableRemotePlayback\",\"preservesPitch\",\"shadowRootClonable\",\"shadowRootCustomElementRegistry\",\"shadowRootDelegatesFocus\",\"shadowRootSerializable\",\"sharedStorageWritable\",...Gi]),ln=new Set([\"innerHTML\",\"textContent\",\"innerText\",\"children\"]),cn=Object.assign(Object.create(null),{className:\"class\",htmlFor:\"for\"}),Ki=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 sn(e,n){let t=Ki[e];return typeof t===\"object\"?t[n]?t.$:void 0:t}var on=new Set([\"beforeinput\",\"click\",\"dblclick\",\"contextmenu\",\"focusin\",\"focusout\",\"input\",\"keydown\",\"keyup\",\"mousedown\",\"mousemove\",\"mouseout\",\"mouseover\",\"mouseup\",\"pointerdown\",\"pointermove\",\"pointerout\",\"pointerover\",\"pointerup\",\"touchend\",\"touchmove\",\"touchstart\"]),tt=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\"]),dn={xlink:\"http://www.w3.org/1999/xlink\",xml:\"http://www.w3.org/XML/1998/namespace\"};function Wi(e,n,t){let i=t.length,f=n.length,r=i,u=0,l=0,c=n[f-1].nextSibling,a=null;while(ud-l){let o=n[u];while(l{f=r,n===document?e():gn(n,e(),n.firstChild?null:void 0,t)},i.owner),()=>{f(),n.textContent=\"\"}}function an(e,n=window.document){let t=n[Mn]||(n[Mn]=new Set);for(let i=0,f=e.length;if.call(e,t[1],r))}else e.addEventListener(n,t,typeof t!==\"function\"&&t)}function bn(e,n,t={}){let i=Object.keys(n||{}),f=Object.keys(t),r,u;for(r=0,u=f.length;rf.children=se(e,n.children,f.children));return R(()=>typeof n.ref===\"function\"&&Yi(n.ref,e)),R(()=>Hi(e,n,t,!0,f,!0)),f}function rt(e,n){let t=e[n];return Object.defineProperty(e,n,{get(){return t()},enumerable:!0}),e}function Yi(e,n,t){return v(()=>e(n,t))}function gn(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 Hi(e,n,t,i,f={},r=!1){n||(n={});for(let u in f)if(!(u in n)){if(u===\"children\")continue;f[u]=et(e,u,null,f[u],t,r,n)}for(let u in n){if(u===\"children\"){if(!i)se(e,n.children);continue}let l=n[u];f[u]=et(e,u,l,f[u],t,r,n)}}function oe(e){return!!_.context&&!_.done&&(!e||e.isConnected)}function qi(e){return e.toLowerCase().replace(/-([a-z])/g,(n,t)=>t.toUpperCase())}function pn(e,n,t){let i=n.trim().split(/\\s+/);for(let f=0,r=i.length;f-1&&dn[n.split(\":\")[0]];if(E)hn(e,E,n,t);else Oe(e,cn[n]||n,t)}return t}function Xi(e){if(_.registry&&_.events){if(_.events.find(([c,a])=>a===e))return}let n=e.target,t=`$$${e.type}`,i=e.target,f=e.currentTarget,r=(c)=>Object.defineProperty(e,\"target\",{configurable:!0,value:c}),u=()=>{let c=n[t];if(c&&!n.disabled){let a=n[`${t}Data`];if(a!==void 0?c.call(n,a,e):c.call(n,e),e.cancelBubble)return}return n.host&&typeof n.host!==\"string\"&&!n.host._$host&&n.contains(e.target)&&r(n.host),!0},l=()=>{while(u()&&(n=n._$host||n.parentNode||n.host));};if(Object.defineProperty(e,\"currentTarget\",{configurable:!0,get(){return n||document}}),_.registry&&!_.done)_.done=_$HY.done=!0;if(e.composedPath){let c=e.composedPath();r(c[0]);for(let a=0;a{let c=n();while(typeof c===\"function\")c=c();t=se(e,c,t,i)}),()=>t;else if(Array.isArray(n)){let c=[],a=t&&Array.isArray(t);if(rn(c,n,t,f))return R(()=>t=se(e,c,t,i,!0)),()=>t;if(r){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=ce(e,t,i),l)return t}else if(a)if(t.length===0)nt(e,c,i);else Wi(e,t,c);else t&&ce(e),nt(e,c);t=c}else if(n.nodeType){if(r&&n.parentNode)return t=l?[n]:n;if(Array.isArray(t)){if(l)return t=ce(e,t,i,n);ce(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 rn(e,n,t,i){let f=!1;for(let r=0,u=n.length;r=0;u--){let l=n[u];if(f!==l){let c=l.parentNode===e;if(!r&&!u)c?e.replaceChild(f,l):e.insertBefore(f,t);else c&&l.remove()}else r=!0}}else e.insertBefore(f,t);return[f]}var or=Symbol();var $n=Symbol(\"store-raw\"),ae=Symbol(\"store-node\"),m=Symbol(\"store-has\"),lt=Symbol(\"store-self\"),ut={onStoreNodeUpdate:null};function ct(e){let n=e[q];if(!n){if(Object.defineProperty(e,q,{value:n=new Proxy(e,Ri)}),!Array.isArray(e)){let t=Object.keys(e),i=Object.getOwnPropertyDescriptors(e);for(let f=0,r=t.length;fe[q][n],t}function st(e){_e()&&ke(Fe(e,ae),lt)()}function Li(e){return st(e),Reflect.ownKeys(e)}var Ri={get(e,n,t){if(n===$n)return e;if(n===q)return t;if(n===pe)return st(e),t;let i=Fe(e,ae),f=i[n],r=f?f():e[n];if(n===ae||n===m||n===\"__proto__\")return r;if(!f){let u=Object.getOwnPropertyDescriptor(e,n);if(_e()&&(typeof r!==\"function\"||e.hasOwnProperty(n))&&!(u&&u.get))r=ke(i,n,r)()}return p(r)?ct(r):r},has(e,n){if(n===$n||n===q||n===pe||n===ae||n===m||n===\"__proto__\")return!0;return _e()&&ke(Fe(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:Li,getOwnPropertyDescriptor:Zi};function H(e,n,t,i=!1){if(!i&&e[n]===t)return;let f=e[n],r=e.length;if(ut.onStoreNodeUpdate&&ut.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 u=Fe(e,ae),l;if(l=ke(u,n,f))l.$(()=>t);if(Array.isArray(e)&&e.length!==r){for(let c=e.length;c1){i=n.shift();let u=typeof i,l=Array.isArray(e);if(Array.isArray(i)){for(let c=0;c1){Ee(e[i],n,[i].concat(t));return}f=e[i],t=[i].concat(t)}let r=n[0];if(typeof r===\"function\"){if(r=r(f,t),r===f)return}if(i===void 0&&r==null)return;if(r=he(r),i===void 0||p(f)&&p(r)&&!Array.isArray(r))ot(f,r);else H(e,i,r)}function dt(...[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=ct(t);mn.registerGraph({value:t,name:n&&n.name});function r(...u){Xn(()=>{i&&u.length===1?Ji(t,u[0]):Ee(t,u)})}return[f,r]}var xn=Symbol(\"store-root\");function de(e,n,t,i,f){let r=n[t];if(e===r)return;let u=Array.isArray(e);if(t!==xn&&(!p(e)||!p(r)||u!==Array.isArray(r)||f&&e[f]!==r[f])){H(n,t,e);return}if(u){if(e.length&&r.length&&(!i||f&&e[0]&&e[0][f]!=null)){let a,d,g,E,b,o,s,x;for(g=0,E=Math.min(r.length,e.length);g=g&&b>=g&&(r[E]===e[b]||f&&r[E]&&e[b]&&r[E][f]&&r[E][f]===e[b][f]);E--,b--)k[b]=r[E];if(g>b||g>E){for(d=g;d<=b;d++)H(r,d,e[d]);for(;de.length)H(r,\"length\",e.length);return}s=Array(b+1);for(d=b;d>=g;d--)o=e[d],x=f&&o?o[f]:o,a=I.get(x),s[d]=a===void 0?-1:a,I.set(x,d);for(a=g;a<=E;a++)if(o=r[a],x=f&&o?o[f]:o,d=I.get(x),d!==void 0&&d!==-1)k[d]=r[a],d=s[d],I.set(x,d);for(d=g;de.length)H(r,\"length\",e.length);return}let l=Object.keys(e);for(let a=0,d=l.length;a{if(!p(r)||!p(f))return f;let u=de(f,{[xn]:r},xn,t,i);return u===void 0?r:u}}var Qi=/(?:|<(?:\"[^\"]*\"['\"]*|'[^']*'['\"]*|[^'\">])+>)/g,Bi=/(?:\\s(?[^/\\s><=]+?)(?=[\\s/>]))|(?:(?\\S+?)(?:\\s*=\\s*(?:(['\"])(?[\\s\\S]*?)\\3|(?[^\\s>]+))))/g,mi={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 Mi(e){let n={type:\"tag\",name:\"\",voidElement:!1,attrs:[],children:[]},t=e.match(/<\\/?([^\\s]+?)[/\\s>]/);if(t){if(n.name=t[1],mi[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(Bi);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 ht(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 yt(e,n){let t=n.replace(\"\",\"\");if(!/^\\s*$/.test(t))e.push({type:\"comment\",content:t})}function pi(e){let n=[],t=void 0,i=-1,f=[],r={};return e.replace(Qi,(u,l)=>{let c=u.charAt(1)!==\"/\",a=u.slice(0,4)===\"\"}}function nf(e){return e.reduce(function(n,t){return n+gt(\"\",t)},\"\")}var bt=new Map,tf=/^(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$/i,Ue=\" \\\\f\\\\n\\\\r\\\\t\",$t=\"[^\"+Ue+`\\\\/>\"'=]+`,En=\"[ \"+Ue+\"]+(?:use:|\"+$t+\")\",xt=\"<([A-Za-z$#]+[A-Za-z0-9:_-]*)((?:\",Ot=`(?:\\\\s*=\\\\s*(?:'[^']*?'|\"[^\"]*?\"|\\\\([^)]*?\\\\)|<[^>]*?>|`+$t+\"))?)\",ff=new RegExp(xt+En+Ot+\"+)([ \"+Ue+\"]*/?>)\",\"g\"),rf=new RegExp(\"(\"+En+`\\\\s*=\\\\s*)(|['\"(]([\\\\w\\\\s]*[\\\\w\\\\s]*)*['\")])`,\"gi\"),uf=new RegExp(xt+En+Ot+\"*)([ \"+Ue+\"]*/>)\",\"g\"),wt=\"\",lf=new Set([\"class\",\"on\",\"oncapture\",\"style\",\"use\",\"prop\",\"attr\"]);function cf(e,n,t,i){return\"<\"+n+t.replace(rf,sf)+i}function sf(e,n,t){return n.replace(//g,\"###\")+(t[0]==='\"'||t[0]===\"'\"?t.replace(//g,\"###\"):'\"###\"')}function of(e,n,t){return tf.test(n)?e:\"<\"+n+t+\">\"}function df(e){return e.toLowerCase().replace(/-([a-z])/g,(n,t)=>t.toUpperCase())}function On(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 af(e,{delegateEvents:n=!0,functionBuilder:t=(...i)=>Function(...i)}={}){let i=1;e.wrapProps=(b)=>{let o=Object.getOwnPropertyDescriptors(b);for(let s in o)if(typeof o[s].value===\"function\"&&!o[s].value.length)e.dynamicProperty(b,s);return b};function f(b,o){let s=0,x=\"\";for(;s\";x=x+b[s],x=[[uf,of],[/<()/g,\"<###\"],[/\\.\\.\\.()/g,\"###\"],[ff,cf],[/>\\n+\\s*/g,\">\"],[/\\n+\\s*\\s+/g,\"> \"]].reduce((V,T)=>{return V.replace(T[0],T[1])},x);let I=pi(x),[A,S]=g(I,o.funcBuilder),N=[];for(let V=0;V-1){let J=j.split(wt).reduce((Q,Ie,Le)=>{return Le&&Q.push(\"\"),Q.push(Ie),Q},[]);T[V].firstChild.replaceWith(...J)}}}return N[0].create=S,bt.set(b,N),N}function r(b,o,s,x,k,I,A){let S=x===\"###\"?`!doNotWrap ? exprs[${A.counter}]() : exprs[${A.counter++}]`:x.split(\"###\").map((j,J)=>J?` + (typeof exprs[${A.counter}] === \"function\" ? exprs[${A.counter}]() : exprs[${A.counter++}]) + \"${j}\"`:`\"${j}\"`).join(\"\"),N,V;if((N=s.split(\":\"))&&N[1]&&lf.has(N[0]))s=N[1],V=N[0];let T=e.ChildProperties.has(s),K=e.Properties.has(s);if(s===\"style\"){let j=`_$v${i++}`;A.decl.push(`${j}={}`),A.exprs.push(`r.style(${o},${S},${j})`)}else if(s===\"classList\"){let j=`_$v${i++}`;A.decl.push(`${j}={}`),A.exprs.push(`r.classList(${o},${S},${j})`)}else if(V!==\"attr\"&&(T||!k&&(e.getPropAlias(s,b.name.toUpperCase())||K)||I||V===\"prop\")){if(I&&!T&&!K&&V!==\"prop\")s=df(s);A.exprs.push(`${o}.${e.getPropAlias(s,b.name.toUpperCase())||s} = ${S}`)}else{let j=k&&s.indexOf(\":\")>-1&&e.SVGNamespace[s.split(\":\")[0]];if(j)A.exprs.push(`r.setAttributeNS(${o},\"${j}\",\"${s}\",${S})`);else A.exprs.push(`r.setAttribute(${o},\"${e.Aliases[s]||s}\",${S})`)}}function u(b,o,s,x,k,I,A){if(s.slice(0,2)===\"on\")if(!s.includes(\":\")){let S=s.slice(2).toLowerCase(),N=n&&e.DelegatedEvents.has(S);A.exprs.push(`r.addEventListener(${o},\"${S}\",exprs[${A.counter++}],${N})`),N&&A.delegatedEvents.add(S)}else{let S=s.startsWith(\"oncapture:\");A.exprs.push(`${o}.addEventListener(\"${s.slice(S?10:3)}\",exprs[${A.counter++}]${S?\",true\":\"\"})`)}else if(s===\"ref\")A.exprs.push(`exprs[${A.counter++}](${o})`);else{let S=Object.assign({},A,{exprs:[]}),N=A.counter;if(r(b,o,s,x,k,I,S),A.decl.push(`_fn${N} = (${x===\"###\"?\"doNotWrap\":\"\"}) => {\n${S.exprs.join(`;\n`)};\n}`),x===\"###\")A.exprs.push(`typeof exprs[${N}] === \"function\" ? r.effect(_fn${N}) : _fn${N}(true)`);else{let V=\"\";for(let T=N;T1)for(let k=0;k1?`r.mergeProps(${o.join(\",\")})`:o[0]}function a(b,o){let s=[],x=Object.keys(b.attrs),k=[s],I=o.counter++;for(let S=0;S exprs[${o.counter++}]`);else if(b.children.length){let S={type:\"fragment\",children:b.children},N=Object.assign({},o,{first:!0,decl:[],exprs:[],parent:!1});d(S,N),s.push(`children: () => { ${N.exprs.join(`;\n`)}}`),o.templateId=N.templateId,o.counter=N.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[${I}],${c(k)})${A?`, ${A}`:\"\"})`);else o.exprs.push(`${o.fragment?\"\":\"return \"}r.createComponent(exprs[${I}],${c(k)})`);o.path=A,o.first=!1}function d(b,o){if(b.type===\"fragment\"){let s=[];b.children.forEach((x)=>{if(x.type===\"tag\"){if(x.name===\"###\"){let A=Object.assign({},o,{first:!0,fragment:!0,decl:[],exprs:[]});a(x,A),s.push(A.exprs[0]),o.counter=A.counter,o.templateId=A.templateId;return}o.templateId++;let k=i,I=Object.assign({},o,{first:!0,decl:[],exprs:[]});o.templateNodes.push([x]),d(x,I),s.push(`function() { ${I.decl.join(`,\n`)+`;\n`+I.exprs.join(`;\n`)+`;\nreturn _$el${k};\n`}}()`),o.counter=I.counter,o.templateId=I.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;kS.name===\"is\");if(o.hasCustomElement=A,o.isImportNode=(b.name===\"img\"||b.name===\"iframe\")&&b.attrs.some((S)=>S.name===\"loading\"&&S.value===\"lazy\"),b.attrs.some((S)=>S.name===\"###\")){let S=[],N=\"\",V=[];for(let T=0;T({${N}})`),N=\"\";S.push(`exprs[${o.counter++}]`)}else V.push(b.attrs[T]);else if(K===\"directive\")On(j,J,s,o)}if(b.attrs=V,N.length)S.push(`()=>({${N}})`);o.exprs.push(`r.spread(${s},${S.length===1?`typeof ${S[0]} === \"function\" ? r.mergeProps(${S[0]}) : ${S[0]}`:`r.mergeProps(${S.join(\",\")})`},${I},${!!b.children.length})`)}else for(let S=0;S document.importNode(tmpls[${k}].content.firstChild, true))`:`const ${s} = tmpls[${k}].content.firstChild.cloneNode(true)`}else if(b.type===\"text\"){let s=`_$el${i++}`;o.decl.push(`${s} = ${o.path}.${o.first?\"firstChild\":\"nextSibling\"}`),o.path=s,o.first=!1}else if(b.type===\"comment\"){let s=`_$el${i++}`;if(o.decl.push(`${s} = ${o.path}.${o.first?\"firstChild\":\"nextSibling\"}`),b.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 g(b,o){let s={path:\"\",decl:[],exprs:[],delegatedEvents:new Set,counter:0,first:!0,multi:!1,templateId:0,templateNodes:[]},x=i,k=b,I;if(b.length>1)b=[{type:\"fragment\",children:b}];if(b[0].name===\"###\")I=!0,a(b[0],s);else d(b[0],s);return e.delegateEvents(Array.from(s.delegatedEvents)),[[k].concat(s.templateNodes).map((S)=>nf(S)),o(\"tmpls\",\"exprs\",\"r\",s.decl.join(`,\n`)+`;\n`+s.exprs.join(`;\n`)+(I?\"\":`;\nreturn _$el${x};\n`))]}function E(b,...o){let s=bt.get(b)||f(b,{funcBuilder:t});return s[0].create(s,o,e)}return E}var h=af({effect:R,style:wn,insert:gn,untrack:v,spread:ft,createComponent:tn,delegateEvents:an,classList:bn,mergeProps:fn,dynamicProperty:rt,setAttribute:Oe,setAttributeNS:hn,addEventListener:yn,Aliases:cn,getPropAlias:sn,Properties:un,ChildProperties:ln,DelegatedEvents:on,SVGElements:tt,SVGNamespace:dn});function Se(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 Et(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 hf=new Set([\"loops\",\"groups\",\"findings\",\"plans\"]),yf=new Set([\"overview\",\"timeline\",\"sections\",\"findings\",\"plan\",\"usage\"]);function ne(e){try{return decodeURIComponent(e)}catch{return e}}function bf(){return{projectId:null,section:\"loops\",loopName:null,tab:\"overview\",groupId:null,statuses:[],query:\"\"}}function kn(e,n){if(!n)return;for(let t of n.split(\"&\")){if(!t)continue;let i=t.indexOf(\"=\");if(i===-1)continue;let f=ne(t.slice(0,i)),r=ne(t.slice(i+1));if(f===\"status\")e.statuses=r.split(\",\").map((u)=>u.trim()).filter((u)=>u!==\"\");else if(f===\"q\")e.query=r}}function Ge(e){let n=bf(),t=(e||\"\").replace(/^#/,\"\");if(!t)return n;let i=t.indexOf(\"?\"),f=i===-1?t:t.slice(0,i),r=i===-1?\"\":t.slice(i+1),u=f.indexOf(\"/\"),l,c;if(u===-1)l=f,c=\"\";else l=f.slice(0,u),c=f.slice(u+1);if(l)n.projectId=ne(l);if(!c)return kn(n,r),n;let a=c.split(\"/\").filter((g)=>g!==\"\");if(a.length===0)return kn(n,r),n;let d=ne(a[0]);if(d===\"loop\"){if(a.length>=2){if(n.loopName=ne(a[1]),a.length>=3){let g=ne(a[2]);if(yf.has(g))n.tab=g}}}else if(hf.has(d)){if(n.section=d,d===\"groups\"&&a.length>=2)n.groupId=ne(a[1])}else n.loopName=d;return kn(n,r),n}function Ne(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 St(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=(c)=>c<10?\"0\"+c:String(c),i=t(n.getMonth()+1),f=t(n.getDate()),r=n.getFullYear(),u=n.getHours(),l=u>=12?\"PM\":\"AM\";if(u=u%12,u===0)u=12;return i+\"-\"+f+\"-\"+r+\" \"+u+\":\"+t(n.getMinutes())+\" \"+l}function ye(e){return\"status-badge status-\"+e}function Ke(e){return\"section-status section-\"+e}function At(e,n,t,i){if(!(n.size===0||n.has(e.status)))return!1;if(!t)return!0;let r=t.toLowerCase(),u=e.worktreeBranch??\"\";return((e.loopName||\"\")+\" \"+u+\" \"+(i||\"\")).toLowerCase().indexOf(r)!==-1}function kt(e){if(!e)return 0;let n=0,t=e.match(/(\\d+)\\s*h/i);if(t)n+=parseInt(t[1],10)*3600;let i=e.match(/(\\d+)\\s*m(?!s)/i);if(i)n+=parseInt(i[1],10)*60;let f=e.match(/(\\d+)\\s*s/i);if(f)n+=parseInt(f[1],10);return n}function Nt(e,n){if(n===\"recent\")return[...e];let t=[...e];if(n===\"cost\")t.sort((i,f)=>{let r=i.usage?.totalCost??null,u=f.usage?.totalCost??null;if(r===null&&u===null)return 0;if(r===null)return 1;if(u===null)return-1;return u-r});else if(n===\"duration\")t.sort((i,f)=>{let r=i.duration?kt(i.duration):0;return(f.duration?kt(f.duration):0)-r});else if(n===\"findings\")t.sort((i,f)=>f.findings.length-i.findings.length);return t}function Ct(e){return JSON.stringify(e,(n,t)=>{if(n===\"generatedAt\")return;return t})}function It(e){let n=new Map;for(let f of e){if(f===\"\"){n.set(f,[]);continue}let r=f.split(\"/\"),u=r.length;while(u>0&&r[u-1]===\"\")u--;n.set(f,u>0?r.slice(0,u):[f])}let t=new Map;for(let f of e){let r=n.get(f);t.set(f,r.length?r[r.length-1]:f)}let i=new Map;for(let f of e){let r=t.get(f),u=i.get(r);if(u)u.push(f);else i.set(r,[f])}for(let[,f]of i){let r=[...new Set(f)];if(r.length<2)continue;let u=1;while(u<1024){let l=new Set,c=!0,a=!0;for(let d of r){let g=n.get(d),E=Math.max(0,g.length-u),b=g.slice(E).join(\"/\");if(l.has(b))c=!1;else l.add(b);if(E>0)a=!1}if(c||a)break;u++}for(let l of r){let c=n.get(l),a=Math.max(0,c.length-u);t.set(l,c.slice(a).join(\"/\"))}}return t}function Sn(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 Pt(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 We(e,n){if(!n||n<=0)return 0;return Math.max(0,Math.min(100,e/n*100))}function An(e){let n=e.file+\":\"+e.line+\" — \"+e.description;if(e.scenario)n+=\" (\"+e.scenario+\")\";return n}function Vt(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 ve(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 ze(e){if(!Number.isFinite(e)||e<=0)return\"$0\";return\"$\"+(e>=1?e.toFixed(2):e.toFixed(4))}function Tt(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 jt(e){let n={role:\"execution\",cost:0,messageCount:0,pct:0},t={role:\"auditor\",cost:0,messageCount:0,pct:0};for(let[f,r]of Object.entries(e.byRole??{}))if(f===\"auditor\")t.cost+=r.cost,t.messageCount+=r.messageCount;else if(f===\"code\")n.cost+=r.cost,n.messageCount+=r.messageCount;let i=Math.max(n.cost,t.cost);return n.pct=i>0?n.cost/i*100:0,t.pct=i>0?t.cost/i*100:0,[n,t]}function _t(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 Dt(e,n){if(!e)return\"\";let t=Et(e,n??void 0);return t>0?Se(t):\"\"}function te(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 r=Math.floor(f/24);if(r<30)return r+\"d ago\";return z(e)}function Nn(e){if(!e)return\"\";let n=Ae.get(e);if(n!==void 0)return n;let t=globalThis.marked;if(!t)return\"\";let i=t.parse(e);if(i){if(Ae.size>=wf){let f=Ae.keys().next().value;if(f!==void 0)Ae.delete(f)}Ae.set(e,i)}return i}var Ae=new Map,wf=200;function Ye(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 He(e,n,t,i,f=\"coding\"){let r=[],u=e.findIndex((g)=>g.createdAt>=n),l=u===-1?[]:e.slice(u);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 a=n;for(let g of l){if(g.eventType===\"restart\"){if(g.toPhase!==null)c=g.toPhase;continue}if(c===\"\")c=g.fromPhase,a=Math.max(a,g.createdAt);let E=g.createdAt,b=Math.max(a,E);if(r.push({phase:c,startedAt:a,endedAt:b,durationMs:b-a,open:!1}),g.toPhase===null)c=\"\",a=b;else c=g.toPhase,a=b}if(c!==\"\"){let E=Math.max(a,t??i);r.push({phase:c,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&&r.length>0)r[0]={...r[0],phase:\"\"};return{spans:r,truncated:d}}function Ft(e){let n={};for(let t of e){if(!t.phase)continue;n[t.phase]=(n[t.phase]??0)+t.durationMs}return n}function Ut(e,n,t){let i=[];for(let f=0;f=n)u=Math.max(0,r.createdAt-n);else if(r.createdAt=n)u=Math.max(0,r.createdAt-n);else u=null}i.push({transition:r,elapsedMs:u})}return i.reverse(),i}var gf=[{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}],qe=110,Xe=36,Gt=655,Kt=160,$f=[{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 xf(e){if(e.toPhase===null)return e.fromPhase+\"→terminal\";return e.fromPhase+\"→\"+e.toPhase}function Of(e){return e.toPhase??e.status??\"\"}function Cn(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 Wt(e){let n=()=>e.loop(),t=w(()=>n().status===\"running\"),i=w(()=>{let l=new Map;for(let c of e.transitions()){let a=xf(c);l.set(a,(l.get(a)??0)+1)}return l}),f=$f.map((l)=>Cn(h`\n \n \n ${()=>{let c=i().get(l.key);return c?String(c):\"\"}}\n \n `)),r=gf.map((l)=>{let c=l.cx-qe/2,a=l.cy-Xe/2;return Cn(h`\n \"mg-node\"+(n().phase===l.phase&&t()?\" mg-node-active\":\"\")}>\n \n ${l.label}\n \n `)}),u=Cn(h`\n \n \n ${()=>n().status}\n \n `);return h`
\n \n \n \n \n \n \n\n ${f}\n ${r}\n ${()=>t()?\"\":u}\n \n\n
\n
Transition history
\n
\n ${()=>e.transitions().slice(-20).reverse().map((l)=>h`
\n ${l.eventType}\n ${l.fromPhase} → ${Of(l)}\n ${te(l.createdAt)}\n
`)}\n
\n
\n
`}function qt(e){let[n,t]=F(e.query()),i=null,f=(u)=>{let l=u.target.value;if(t(l),i)clearTimeout(i);i=setTimeout(()=>{i=null,e.onChangeQuery(l)},250)};ue(()=>{if(i)clearTimeout(i),i=null}),B(()=>{e.externalNav();let u=v(()=>e.query());if(i!==null)clearTimeout(i),i=null;t(u)});let r=w(()=>{let u=e.counts();return[{label:\"Running\",key:\"running\",value:u.running},{label:\"Completed\",key:\"completed\",value:u.completed},{label:\"Cancelled\",key:\"cancelled\",value:u.cancelled},{label:\"Errored\",key:\"errored\",value:u.errored},{label:\"Stalled\",key:\"stalled\",value:u.stalled}]});return h`
\n ${()=>r().map(({label:u,key:l,value:c})=>{let d=`badge badge-filter${e.statuses().has(l)?\" badge-active\":\"\"}`;return h`e.onToggleStatus(l)}>${u}: ${c}`})}\n \n \n
`}function Xt(e){return h`
Last updated: ${new Date(e.generatedAt).toLocaleString()}
`}function Zt(e){let n=w(()=>e.entries().map((t)=>{let i=t.loops.some((u)=>u.loop.status===\"running\"),f=t.proj.projectDir||t.proj.projectId||\"\",r=e.labels().get(f)??f;return h`
e.onSelect(t.proj.projectId)}>\n ${i?h``:\"\"}\n ${r}\n ${t.loops.length}\n
`}));return h`
${()=>n()}
`}function Ef(e,n){let t=0,i=0,f=0,r=[],u=[];for(let l of e){let c=l.proj.projectDir||l.proj.projectId||\"\",a=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,E=d.loop.completedAt||d.loop.startedAt||0;if(g===\"running\")t++,r.push({projectId:l.proj.projectId,label:a,loopName:d.loop.loopName,phase:d.loop.phase||\"\"});u.push({projectId:l.proj.projectId,label:a,loopName:d.loop.loopName,status:g,when:E})}}return u.sort((l,c)=>c.when-l.when),{runningCount:t,loopCount:i,bugCount:f,runningCards:r,recentRows:u.slice(0,8)}}function Lt(e){let n=w(()=>Ef(e.entries(),e.labels())),t=w(()=>n().runningCount),i=w(()=>n().loopCount),f=w(()=>n().bugCount),r=w(()=>n().runningCards),u=w(()=>n().recentRows);return h`
\n
\n

All repositories

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

Running

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

Recent activity

\n
\n ${u().map((l)=>h`
e.onOpenLoop(l.projectId,l.loopName)}>\n \"status-badge status-\"+l.status}>${l.status}\n ${l.label}\n ${l.loopName}\n ${z(l.when)}\n
`)}\n
\n
`:\"\"}\n
`}function kf(e){let[n,t]=F(!1),[i,f]=F(\"\"),[r,u]=F(0),l=w(()=>{let s=i().trim().toLowerCase(),x=e.loops();return s?x.filter((k)=>k.name.toLowerCase().indexOf(s)!==-1):x}),c=()=>{if(n())return;f(\"\");let s=e.loops().findIndex((x)=>x.name===e.loopName);u(s<0?0:s),t(!0)},a=()=>{t(!1),f(\"\"),u(0)},d=(s)=>{if(a(),o.blur(),s!==e.loopName)e.onSelect(s)},g=h`n()?\"display:block\":\"display:none\"}\n onmousedown=${(s)=>s.preventDefault()}\n >\n ${()=>l().map((s,x)=>h`\"loop-picker-option\"+(x===r()?\" 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 ${()=>l().length===0?h`
No matching loops
`:\"\"}\n
`,E=()=>{g.querySelector(\".loop-picker-option-active\")?.scrollIntoView?.({block:\"nearest\"})},b=(s)=>{if(s.key===\"ArrowDown\"||s.key===\"ArrowUp\"){if(s.preventDefault(),!n()){c();return}let x=l().length;if(x===0)return;let k=s.key===\"ArrowDown\"?1:-1;u((I)=>(I+k+x)%x),E();return}if(s.key===\"Enter\"){s.preventDefault();let x=l()[r()];if(x)d(x.name);return}if(s.key===\"Escape\")s.preventDefault(),a(),o.blur()},o=h`n()?i():e.loopName}\n onfocus=${c}\n oninput=${(s)=>{f(s.target.value),u(0),t(!0)}}\n onkeydown=${b}\n onblur=${a}\n />`;return h`\n ${o}\n {if(s.preventDefault(),n())a(),o.blur();else o.focus()}}>▾\n ${g}\n `}function Rt(e){if(e.loopName)return h`
\n ← ${e.repoLabel}\n /\n ${kf({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 Jt(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 vt(e){let n=()=>We(e.current(),e.total());return h`\n \"width:\"+n()+\"%\"}>\n ${()=>e.current()}/${()=>e.total()}\n `}var Sf={sm:4,md:14,lg:22};function Pn(e){let n=Sf[e.variant],t=w(()=>e.spans().filter((r)=>r.phase!==\"\")),i=w(()=>Math.max(0,t().reduce((r,u)=>r+u.durationMs,0))),f=w(()=>e.spans().some((r)=>r.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 r=t(),u=i();return r.map((l)=>{let c=u>0?l.durationMs/u*100:0,a=\"phase-seg\"+(l.open?\" phase-seg-open\":\"\");return h`
`})}}\n
`}function Af(e,n){return He(e.transitions??[],e.loop.startedAt,e.loop.completedAt,n,e.loop.phase).spans}function Qt(e){return h`\n \n \n \n \n \n ${()=>e.loops().map((n)=>Nf({dashLoop:n,now:e.now,onOpen:e.onOpen}))}\n \n
StatusLoopPhasePhase spanIterSectionsFindingsCostDurationUpdated
`}function Nf(e){let n=()=>e.dashLoop.loop,t=()=>e.dashLoop,i=w(()=>Sn(t().findings)),f=w(()=>Af(t(),e.now()));return h`e.onOpen(e.dashLoop.loop.loopName)}>\n ye(n().status)}>${()=>n().status}\n ${()=>n().loopName}\n ${()=>n().phase}\n ${()=>Pn({spans:f,variant:\"sm\",now:e.now})}\n ${vt({current:()=>n().iteration,total:()=>n().maxIterations})}\n ${()=>n().totalSections>0?vt({current:()=>n().currentSectionIndex,total:()=>n().totalSections}):h``}\n ${()=>{let r=i();if(r.bugs.length===0&&r.warnings.length===0)return h``;return h`\n ${r.bugs.length>0?h`${be(r.bugs.length,\"bug\")}`:\"\"}\n ${r.bugs.length>0&&r.warnings.length>0?\" · \":\"\"}\n ${r.warnings.length>0?h`${r.warnings.length} warn`:\"\"}\n `}}\n ${()=>t().usage?ze(t().usage.totalCost):h``}\n ${()=>t().duration||\"\"}\n ${()=>z(n().completedAt||n().startedAt)}\n `}function Ce(e){let n=()=>typeof e.label===\"function\"?e.label():e.label,[t,i]=F(!1),f=()=>i((r)=>!r);return h`
\n
\n t()?\"false\":\"true\"}\n onclick=${f}\n onkeydown=${(r)=>{if(r.key!==\"Enter\"&&r.key!==\" \")return;r.preventDefault(),f()}}\n >\n ${()=>t()?\"▸\":\"▾\"}\n

${()=>n()}

\n
\n \"Copy \"+n()+\" as markdown\"}\n onclick=${(r)=>{r.stopPropagation();let u=r.target,l=\"Copy\";navigator.clipboard.writeText(e.src()||\"\").then(()=>{u.textContent=\"Copied!\"},()=>{u.textContent=\"Failed\"}).then(()=>{setTimeout(()=>{u.textContent=l},2000)})}}\n >Copy\n
\n
t()?\"display:none\":\"display:block\"}>\n
Nn(e.src()||\"\")}>
\n
\n
`}function In(e){return h`
\n
${e.label}
\n
Nn(e.value()||\"\")}>
\n
`}function Cf(e){let n=e.sec,t=w(()=>!!n.startedAt||!!n.completedAt),i=w(()=>!!n.summaryDone),f=w(()=>!!n.summaryDeviations),r=w(()=>!!n.summaryFollowUps),u=w(()=>!!n.content),l=w(()=>t()||i()||f()||r()||u());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()?In({label:\"Done\",value:()=>n.summaryDone}):\"\"}\n ${()=>f()?In({label:\"Deviations\",value:()=>n.summaryDeviations}):\"\"}\n ${()=>r()?In({label:\"Follow-ups\",value:()=>n.summaryFollowUps}):\"\"}\n ${()=>u()?Ce({label:\"Section Plan\",src:()=>n.content}):\"\"}\n ${()=>l()?\"\":h`
No details captured for this section yet.
`}\n
`}function If(e){let n=e.sec,t=w(()=>Dt(n.startedAt,n.completedAt));return h`
\"section-list-row section-item-\"+n.status} onclick=${e.onOpen}>\n #${n.sectionIndex}\n ${()=>n.title}\n Ke(n.status)}>${()=>n.status}\n ${()=>t()?h`${()=>t()}`:\"\"}\n ${()=>n.attempts>0?h`${()=>n.attempts} attempts`:\"\"}\n \n
`}function Pf(e){let[n,t]=F(null),i=w(()=>{let f=n();if(f===null)return null;return e.sections().find((r)=>r.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 Ke(f.status)}>${()=>f.status}\n
\n ${Cf({sec:f})}\n
`;return h`
\n ${e.sections().map((r)=>If({sec:r,onOpen:()=>t(r.sectionIndex)}))}\n
`}}\n
`}function we(e){return h`
\n ${e.label}\n ${()=>e.value()}\n
`}function zt(e){let n=()=>We(e.current(),e.total());return h`
\n
\n ${e.label}\n ${()=>e.current()} / ${()=>e.total()}\n
\n
\n
\"width:\"+n()+\"%\"}>
\n
\n
`}function Vf(e){let n=()=>e.dashLoop.loop,t=()=>e.dashLoop,i=w(()=>!!n().completedAt),f=w(()=>!!t().duration),r=w(()=>n().totalSections>0),u=w(()=>!!n().terminationReason),l=w(()=>Pt(e.split())),c=w(()=>!!e.dashLoop.usage);return h`
\n
\"ldh-findings ldh-findings-\"+l()}>\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(be(a.bugs.length,\"bug\"));if(a.warnings.length>0)d.push(be(a.warnings.length,\"warning\"));return d.join(\" · \")}}\n
\n
\n ye(n().status)}>${()=>n().status}\n

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

\n ${()=>n().phase}\n
\n\n
\n ${we({label:\"Started\",value:()=>z(n().startedAt)})}\n ${()=>i()?we({label:\"Completed\",value:()=>z(n().completedAt)}):\"\"}\n ${()=>f()?we({label:\"Duration\",value:()=>t().duration||\"\"}):\"\"}\n ${we({label:\"Iteration\",value:()=>n().iteration+\" / \"+n().maxIterations})}\n ${()=>r()?we({label:\"Section\",value:()=>n().currentSectionIndex+\" / \"+n().totalSections}):\"\"}\n ${()=>c()?we({label:\"Messages\",value:()=>String(e.dashLoop.usage.totalMessageCount)}):\"\"}\n
\n\n
\n ${zt({label:\"Iterations\",current:()=>n().iteration,total:()=>n().maxIterations})}\n ${()=>r()?zt({label:\"Sections\",current:()=>n().currentSectionIndex,total:()=>n().totalSections}):\"\"}\n
\n\n ${()=>u()?h`
\"ldh-banner ldh-banner-\"+n().status}>${()=>n().terminationReason}
`:\"\"}\n
`}function Tf(e){let n=()=>e.usage(),t=w(()=>Tt(n())),i=w(()=>_t(n())),f=w(()=>i().length>0),r=w(()=>jt(n()));return h`
\n

Usage

\n\n
\n
Role split
\n
\n ${()=>r().map((u)=>h`
u.role}>\n
\n u.role}>${u.role}\n ${()=>ze(u.cost)}\n
\n
\n
\"width:\"+u.pct+\"%\"}>
\n
\n
${()=>u.messageCount+\" msg\"}
\n
`)}\n
\n
\n\n
\n
Token composition
\n
\n ${()=>t().map((u)=>u.pct>0?h`
`:\"\")}\n
\n
\n ${()=>t().map((u)=>h`
\n \n ${u.label}\n ${ve(u.value)}\n
`)}\n
\n
\n\n ${()=>f()?h`
\n
Cost by model
\n
\n ${()=>i().map((u)=>h`
\n
\n ${u.model}\n ${ze(u.cost)}\n
\n
\n
\n
\n
\n ${ve(u.inputTokens)} in / ${ve(u.outputTokens)} out · ${u.messageCount} msg\n
\n
`)}\n
\n
`:\"\"}\n
`}function Yt(e){try{return JSON.parse(e)}catch{return[]}}function Ht(e){return\"#\"+e.index+\" \"+e.title}function jf(e){let n=e.amendment,t=w(()=>Yt(n.sectionsBefore)),i=w(()=>Yt(n.sectionsAfter));return h`
\n
\n ${()=>te(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`
${()=>Ht(f)}
`)}\n
`:\"\"}\n ${()=>i().length>0?h`
\n
After
\n ${()=>i().map((f)=>h`
${()=>Ht(f)}
`)}\n
`:\"\"}\n
\n
\n
`}function _f(e){let n=()=>e.amendments(),[t,i]=F(null);return h`
\n

Plan amendments

\n
\n ${()=>n().map((f)=>jf({amendment:f,expanded:()=>t()===f.id,onToggle:()=>i(t()===f.id?null:f.id)}))}\n
\n
`}var Df=[\"overview\",\"timeline\",\"sections\",\"findings\",\"plan\",\"usage\"],Ff={overview:\"Overview\",timeline:\"Timeline\",sections:\"Sections\",findings:\"Findings\",plan:\"Plan\",usage:\"Usage\"};function Uf(e){return h`
\n ${()=>e.tabs().map((n)=>{let t=Ff[n];return h`\"tab-item\"+(e.activeTab()===n?\" tab-active\":\"\")} data-tab=\"${n}\" onclick=${()=>e.onSelect(n)}>${t}`})}\n
`}function ie(e){return h`
\n ${e.label}\n ${()=>e.value()??\"—\"}\n
`}function Gf(e){let n=()=>e.dashLoop,t=()=>n().loop,i=()=>e.split(),f=w(()=>!!n().goal),r=w(()=>i().bugs.length>0),u=w(()=>i().warnings.length>0),l=w(()=>r()||u()),c=w(()=>n().postActionReport??t().completionSummary??null),a=w(()=>n().postActionReport?\"Post-Action Report\":\"Completion Summary\"),d=w(()=>!!c()),g=w(()=>!!n().lastAuditResult),E=w(()=>He(n().transitions??[],t().startedAt,t().completedAt,e.now(),t().phase));return h`
\n ${()=>Pn({spans:()=>E().spans,variant:\"md\",now:e.now})}\n ${()=>f()?Ce({label:\"Goal\",src:()=>n().goal}):\"\"}\n ${Vf({dashLoop:e.dashLoop,split:e.split})}\n
\n ${ie({label:\"Execution model\",value:()=>t().executionModel})}\n ${ie({label:\"Auditor model\",value:()=>t().auditorModel})}\n ${ie({label:\"Branch\",value:()=>t().worktreeBranch})}\n ${ie({label:\"Sandbox\",value:()=>t().sandbox?\"on\":\"off\"})}\n ${ie({label:\"Kind\",value:()=>t().kind})}\n ${ie({label:\"Audits\",value:()=>String(t().auditCount)})}\n ${ie({label:\"Errors\",value:()=>String(t().errorCount)})}\n
\n
\n ${()=>l()?h`
\n ${()=>r()?h`${()=>be(i().bugs.length,\"bug\")}`:\"\"}\n ${()=>u()?h`${()=>be(i().warnings.length,\"warning\")}`:\"\"}\n
`:h`No findings`}\n
\n ${()=>d()?Ce({label:a,src:()=>c()}):\"\"}\n ${()=>g()?Ce({label:\"Last Audit Result\",src:()=>n().lastAuditResult}):\"\"}\n
`}function Kf(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 ${te(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?Se(i):\"0s\"}\n
`}function Wf(e){let n=()=>e.dashLoop,t=()=>n().loop,i=()=>n().transitions??[],f=w(()=>He(i(),t().startedAt,t().completedAt,e.now(),t().phase)),r=w(()=>f().spans),u=w(()=>f().truncated),l=w(()=>Ft(r())),c=w(()=>r().filter((s)=>s.phase!==\"\").reduce((s,x)=>s+x.durationMs,0)),a=w(()=>{let s=l();return Object.keys(s).filter((x)=>x!==\"\").sort((x,k)=>s[k]-s[x])}),d=w(()=>Ut(i(),t().startedAt,u())),[g,E]=F(!1),b=w(()=>{let s=d();return g()?s:s.slice(0,20)}),o=w(()=>g()?0:Math.max(0,d().length-20));return h`
\n ${Wt({loop:()=>t(),transitions:i})}\n ${()=>Pn({spans:r,variant:\"lg\",now:e.now})}\n ${()=>u()?h`
Earlier history was truncated by the 100-row fetch window.
`:\"\"}\n\n
\n ${()=>a().map((s)=>{let x=l()[s],k=c()>0?Math.round(x/c()*100):0;return h`
\n \n ${s}\n \n ${()=>x>=1000?Se(Math.floor(x/1000)):x+\"ms\"} · ${k}%\n
`})}\n
\n\n
\n ${()=>b().map((s)=>Kf({ev:s}))}\n ${()=>o()>0?h`
E(!0)}>[ ${()=>o()} earlier events ]
`:\"\"}\n
\n
`}function vf(e){return Pf({sections:()=>e.dashLoop.sections})}function zf(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 Yf(e){let n=()=>e.dashLoop,t=w(()=>n().findings.length>0),i=w(()=>zf(n().findings));return h`
\n ${()=>t()?i().map((f)=>h`
f.key===null?\"cross\":String(f.key)}>\n
${f.label}
\n
\n ${f.rows.map((r)=>h`
\"finding finding-\"+r.severity}>\n ${()=>te(r.createdAt)}\n ${()=>An(r)}\n
`)}\n
\n
`):h`
No findings recorded for this loop.
`}\n
`}function Hf(e){let n=()=>e.dashLoop,t=w(()=>!!n().plan),i=w(()=>!!n().amendments&&n().amendments.length>0);return h`
\n ${()=>t()?Ce({label:\"Plan\",src:()=>n().plan}):h`
No plan recorded for this loop.
`}\n ${()=>i()?_f({amendments:()=>n().amendments}):\"\"}\n
`}function qf(e){let n=()=>e.dashLoop,t=w(()=>!!n().usage);return h`
\n ${()=>t()?Tf({usage:()=>n().usage}):h`
No usage recorded for this loop.
`}\n
`}function Xf(e){let n=w(()=>e.tabs().includes(e.tab)&&e.activeTab()===e.tab),t=(()=>{switch(e.tab){case\"overview\":return Gf({dashLoop:e.dashLoop,split:e.split,now:e.now});case\"timeline\":return Wf({dashLoop:e.dashLoop,now:e.now});case\"sections\":return vf({dashLoop:e.dashLoop});case\"findings\":return Yf({dashLoop:e.dashLoop});case\"plan\":return Hf({dashLoop:e.dashLoop});case\"usage\":return qf({dashLoop:e.dashLoop})}})();return h`
n()?\"display:block\":\"display:none\"}>\n ${t}\n
`}function Zf(e){return h`
\n ${Df.map((n)=>Xf({tab:n,dashLoop:e.dashLoop,split:e.split,now:e.now,tabs:e.tabs,activeTab:e.activeTab}))}\n
`}function Bt(e){let n=()=>e.dashLoop,t=w(()=>Sn(n().findings)),i=w(()=>Ye(n())),f=w(()=>{let r=e.routeTab();return i().includes(r)?r:\"overview\"});return h`
\n
\n ${Uf({tabs:i,activeTab:f,onSelect:e.onSelectTab})}\n ${Zf({dashLoop:e.dashLoop,split:t,now:e.now,tabs:i,activeTab:f})}\n
\n
`}function Vn(){return h`
No loops match the current filters.
`}var mt=new Set([\"completed\",\"cancelled\",\"errored\"]);function Ze(e){let n=e.length;return{completed:e.filter((i)=>i.stage===\"completed\").length,total:n}}function Lf(e){return\"group-row\"+(mt.has(e)?\" group-row-terminal\":\" group-row-active\")}function Mt(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;return h`
Lf(i.status)} data-group-status=${()=>i.status} onclick=${()=>e.onOpen(i.groupId)}>\n ye(i.status)}>${()=>i.status}\n ${()=>i.title}\n \n \"width:\"+We(Ze(t.features).completed,Ze(t.features).total)+\"%\"}>\n ${()=>{let f=Ze(t.features);return f.completed+\"/\"+f.total}}\n \n max ${()=>i.maxConcurrent}\n \n Created ${()=>z(i.createdAt)}\n ${()=>i.completedAt?h`Completed ${()=>z(i.completedAt)}`:\"\"}\n \n
`})}\n
`}}\n
`}function Rf(e){return\"group-header\"+(mt.has(e)?\" group-header-terminal\":\" group-header-active\")}function pt(e){return h`
\n
← Back to groups
\n ${()=>{let n=e.group();if(!n)return h`
Group not found.
`;let t=n.group;return h`
Rf(t.status)} data-group-status=${()=>t.status}>\n
\n ye(t.status)}>${()=>t.status}\n

${()=>t.title}

\n max ${()=>t.maxConcurrent}\n
\n
\n ${()=>{let i=Ze(n.features);return\"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((i)=>{return h`
i.stage}>\n #${()=>i.featureIndex}\n ${()=>i.title}\n Ke(i.stage)}>${()=>i.stage}\n ${()=>i.attempts} attempts\n ${()=>!!i.loopName&&e.loopNames().has(i.loopName)?h`Ne({projectId:e.projectId(),section:\"loops\",loopName:i.loopName,tab:\"overview\",groupId:null,statuses:[],query:\"\"})} onclick=${(f)=>{if(f.defaultPrevented||f.button!==0||f.metaKey||f.ctrlKey||f.shiftKey||f.altKey)return;f.preventDefault(),e.onOpenLoop(i.loopName)}}>${()=>i.loopName}`:\"\"}\n ${()=>i.error?h`${()=>i.error}`:\"\"}\n
`})}\n
`}}\n
`}function Jf(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 ei(e){let n=w(()=>Jf(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
`}function ni(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 ye(i.loop.status)}>${()=>i.loop.status}\n ${()=>i.loop.loopName}\n \n ${()=>i.findings.length+\" finding\"+(i.findings.length===1?\"\":\"s\")}\n \n iter ${()=>i.loop.iteration}/${()=>i.loop.maxIterations}\n
`)}\n
`}}\n
`}function ti(){let[e,n]=dt({generatedAt:0,projects:[],totals:{projects:0,loops:0,running:0,completed:0,cancelled:0,errored:0,stalled:0}}),[t,i]=F(!1),[f,r]=F(Ge(\"\")),[u,l]=F(null),[c,a]=F(\"recent\"),[d,g]=F(0),[E,b]=F(Date.now()),o={current:\"\"},s={current:!1},x=async()=>{try{let $=await(await fetch(\"/api/data\",{cache:\"no-store\"})).json(),P=Ct($);if(P!==o.current)o.current=P,n(at($,{key:\"id\"})),i(!0),l(null);b(Date.now())}catch(y){let $=y instanceof Error?y.message:String(y);l(\"Failed to load dashboard data: \"+$)}},k=(y)=>{r(($)=>{let P={...$,...y};if(y.projectId!==void 0&&y.projectId!==$.projectId)P.statuses=[],P.query=\"\";return P})},I=(y)=>{k({tab:y})},A=(y)=>{k({loopName:y,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})},S=(y)=>{let $=new Set(f().statuses);if($.has(y))$.delete(y);else $.add(y);k({statuses:[...$]})},N=(y)=>{k({query:y})},V=(y)=>{a(y)},T=w(()=>new Set(f().statuses)),K=w(()=>It(e.projects.map((y)=>y.projectDir||y.projectId||\"\"))),j=w(()=>{if(!t()||e.projects.length===0)return[];let y=T(),$=f().query,P=K(),U=[],ee=$.trim().toLowerCase();for(let fe of e.projects){let Qe=[],_n=fe.projectDir||fe.projectId||\"\",Dn=P.get(_n)??_n;for(let Fn of fe.loops)if(At(Fn.loop,y,$,Dn))Qe.push(Fn);let Oi=(fe.groups?.length??0)>0;if(Qe.length>0)U.push({proj:fe,loops:Qe});else if(Oi&&(!ee||Dn.toLowerCase().indexOf(ee)!==-1))U.push({proj:fe,loops:[]})}return U}),J=w(()=>f().projectId===null),Q=w(()=>f().section===\"groups\"),Ie=w(()=>f().section===\"loops\"),Le=w(()=>f().section===\"findings\"),fi=w(()=>f().section===\"plans\"),Tn=w(()=>{let y=j(),$=f().projectId;if(!$)return null;return y.find((P)=>P.proj.projectId===$)??null}),Re=w(()=>{let y=f();if(!y.projectId||!y.loopName)return null;let $=Z();if(!$)return null;return $.loops.find((P)=>P.loop.loopName===y.loopName)??null}),Z=w(()=>{let y=f().projectId;if(!y)return null;return e.projects.find(($)=>$.projectId===y)??null}),ri=w(()=>{let y=Z(),$=y?.groups?.length??0;if(!y)return{loops:0,groups:$,findings:0,plans:0};let P=0,U=0;for(let ee of y.loops)if(P+=ee.findings.length,ee.plan)U++;return{loops:y.loops.length,groups:$,findings:P,plans:U}}),Je=w(()=>{let y=Z();if(!y)return[];return y.loops.map(($)=>({name:$.loop.loopName,when:$.loop.completedAt||$.loop.startedAt||0})).sort(($,P)=>P.when-$.when||$.name.localeCompare(P.name))}),ui=w(()=>{let y=f().loopName;if(!y)return-1;return Je().findIndex(($)=>$.name===y)}),li=w(()=>{let y=ui();if(y<0)return null;let $=Je();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})}}}),ci=w(()=>{let y=Z(),$={running:0,completed:0,cancelled:0,errored:0,stalled:0};if(!y)return $;for(let P of y.loops){let U=P.loop.status;if(U===\"running\")$.running++;else if(U===\"completed\")$.completed++;else if(U===\"cancelled\")$.cancelled++;else if(U===\"errored\")$.errored++;else if(U===\"stalled\")$.stalled++}return $}),si=w(()=>{let y=f();if(!y.projectId)return\"\";let $=Z();if(!$)return\"\";let P=$.projectDir||$.projectId||\"\",U=K().get(P)??P;return v(()=>Rt({repoLabel:U,projectDir:P,loopName:y.loopName,loops:Je,loopNav:li,onSelectLoop:(ee)=>k({loopName:ee}),onBackToRepos:()=>k({projectId:null,loopName:null,section:\"loops\"}),onBackToRepo:()=>k({loopName:null})}))}),oi=w(()=>{let y=f();if(!y.projectId||y.loopName)return\"\";return Jt({section:y.section,counts:ri(),onNavigate:($)=>k({section:$,loopName:null,groupId:null})})}),di=w(()=>{let y=f();return y.projectId!==null&&y.loopName===null&&y.section===\"loops\"}),ai=w(()=>{if(!di())return\"\";return v(()=>qt({counts:ci,statuses:T,query:()=>f().query,sortMode:c,onToggleStatus:S,onChangeQuery:N,onChangeSort:V,externalNav:d}))}),hi=w(()=>{if(!Ie())return\"\";let y=Re();return y?Bt({dashLoop:y,now:E,routeTab:()=>f().tab,onSelectTab:I}):\"\"}),yi=w(()=>{if(!Ie())return\"\";if(Re())return\"\";if(!f().projectId)return\"\";return h`
\n ${Qt({loops:()=>{let $=Tn();return $?Nt($.loops,c()):[]},now:E,onOpen:($)=>A($)})}\n ${()=>Tn()?\"\":Vn()}\n
`}),bi=w(()=>{let y=f();if(y.section!==\"groups\"||!y.groupId)return null;let $=Z();if(!$)return null;return $.groups.find((P)=>P.group.groupId===y.groupId)??null}),wi=w(()=>{let y=Z();if(!y)return new Set;return new Set(y.loops.map(($)=>$.loop.loopName))}),gi=w(()=>{if(!Q())return\"\";let y=f();if(!y.projectId)return\"\";if(y.groupId)return pt({group:bi,loopNames:wi,projectId:()=>f().projectId,onBack:()=>k({groupId:null}),onOpenLoop:A});return Mt({groups:()=>Z()?.groups??[],onOpen:($)=>k({groupId:$})})}),jn=w(()=>{let y=Z();return y?y.loops:[]}),$i=w(()=>{if(!Le())return\"\";if(!f().projectId)return\"\";return ei({loops:jn,projectId:()=>f().projectId,onOpenLoop:A})}),xi=w(()=>{if(!fi())return\"\";if(!f().projectId)return\"\";return ni({loops:jn,onOpenLoop:A})});return B(()=>{if(!t())return;let y=f();if(y.projectId===null)return;if(!e.projects.some((P)=>P.projectId===y.projectId))r((P)=>({...P,projectId:null,loopName:null,section:\"loops\",statuses:[],query:\"\"}))}),B(()=>{let y=f();if(!y.projectId||!y.loopName)return;let $=Z();if(!$)return;if(!$.loops.some((U)=>U.loop.loopName===y.loopName))r((U)=>({...U,loopName:null}))}),B(()=>{let y=f();if(!y.projectId||!y.loopName||y.tab===\"overview\")return;let $=Re();if(!$)return;if(!Ye($).includes(y.tab))r((U)=>({...U,tab:\"overview\"}))}),B(()=>{if(!t())return;St(Ne(f()),s)}),Zn(()=>{r(Ge(location.hash)),x();let y=setInterval(()=>{x()},5000),$=()=>{if(s.current){s.current=!1;return}r(Ge(location.hash)),g((P)=>P+1)};window.addEventListener(\"hashchange\",$),ue(()=>{clearInterval(y),window.removeEventListener(\"hashchange\",$)})}),h`
\n

Forge Dashboard

\n\n ${()=>{let y=u();return y?h`
${y}
`:\"\"}}\n\n ${()=>{if(!t())return\"\";return h`
\n ${Xt({generatedAt:e.generatedAt})}\n
`}}\n\n ${()=>{if(!t())return\"\";if(J()&&j().length===0)return Vn();return h`
\n ${()=>{if(J())return h`
\n ${Zt({entries:()=>j(),labels:()=>K(),onSelect:(y)=>k({projectId:y,loopName:null,section:\"loops\",groupId:null})})}\n ${Lt({entries:()=>j(),labels:()=>K(),onOpenLoop:(y,$)=>k({projectId:y,loopName:$,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})})}\n
`;return h`
\n ${si}\n ${ai}\n ${oi}\n ${yi}\n ${hi}\n ${gi}\n ${$i}\n ${xi}\n
`}}\n
`}}\n
`}var ii=document.getElementById(\"forge-app-root\");if(ii)it(ti,ii);\n"; +export const DASHBOARD_APP_SOURCE_HASH: string = "ac7c18df928ea542b2130f3505829570052bf592a997a21ce98cbc7de618d872"; diff --git a/src/dashboard/app/components.ts b/src/dashboard/app/components.ts index 4c4a455e36..9462f14792 100644 --- a/src/dashboard/app/components.ts +++ b/src/dashboard/app/components.ts @@ -492,14 +492,32 @@ function LoopTableRow(props: { dashLoop: DashboardLoop; now: () => number; onOpe // ── MarkdownSection ─────────────────────────────────────────────────────── // Always renders its block; callers gate presence with a boolean memo so the -// scroll box persists across polls and only innerHTML updates in place. The -// single root element is required — a template that is only `${...}` generates -// invalid code in solid-js/html. +// body node persists across polls and only innerHTML updates in place, which is +// what keeps the collapsed/expanded state stable. The body is not scrollable — +// it lays out at full height and the page scrolls instead. The single root +// element is required — a template that is only `${...}` generates invalid code +// in solid-js/html. function MarkdownSection(props: { label: string | (() => string); src: () => string | null | undefined }) { const label = () => (typeof props.label === 'function' ? props.label() : props.label) + const [collapsed, setCollapsed] = createSignal(false) + const toggle = () => setCollapsed(c => !c) return html`
- +
(collapsed() ? 'false' : 'true')} + onclick=${toggle} + onkeydown=${(e: KeyboardEvent) => { + if (e.key !== 'Enter' && e.key !== ' ') return + e.preventDefault() + toggle() + }} + > + ${() => (collapsed() ? '▸' : '▾')} + +
-
+
(collapsed() ? 'display:none' : 'display:block')}>
renderMarkdown(props.src() || '')}>
` @@ -578,7 +596,7 @@ function SectionListRow(props: { sec: DashboardSection; onOpen: () => void }) { // Owns a selectedIndex signal; selecting shows a back link + the section title + // reused SectionBody; deselected shows the list. Persistence: current() returns // the same store proxy across polls, so the selected branch's thunk does not -// re-run and SectionBody (its .markdown-scrollable) is not rebuilt. +// re-run and SectionBody (its .markdown-body) is not rebuilt. function SectionsPanel(props: { sections: () => DashboardSection[] }) { const [selected, setSelected] = createSignal(null) const current = createMemo(() => { @@ -972,6 +990,7 @@ function TimelineTabBody(props: { dashLoop: DashboardLoop; now: () => number }) const hiddenCount = createMemo(() => (showAll() ? 0 : Math.max(0, eventsNewestFirst().length - 20))) return html`
+ ${LoopMachineGraph({ loop: () => lp(), transitions })} ${() => PhaseBar({ spans, variant: 'lg', now: props.now })} ${() => (truncated() ? html`
Earlier history was truncated by the 100-row fetch window.
` @@ -997,11 +1016,6 @@ function TimelineTabBody(props: { dashLoop: DashboardLoop; now: () => number }) ? html`
setShowAll(true)}>[ ${() => hiddenCount()} earlier events ]
` : '')}
- -
- State machine graph - ${LoopMachineGraph({ loop: () => lp(), transitions })} -
` } diff --git a/src/dashboard/render.ts b/src/dashboard/render.ts index be9df63814..47257159fe 100644 --- a/src/dashboard/render.ts +++ b/src/dashboard/render.ts @@ -413,11 +413,19 @@ export function renderDashboardHtml(): string { .ldh-findings-bug { background: rgba(248,81,73,0.12); border-color: rgba(248,81,73,0.4); color: var(--errored); } .ldh-findings-warn { background: rgba(210,153,34,0.12); border-color: rgba(210,153,34,0.4); color: var(--running); } .ldh-findings-clean { background: rgba(63,185,80,0.10); border-color: rgba(63,185,80,0.3); color: var(--ok); } - .markdown-scrollable { - max-height: 320px; overflow-y: auto; - border: 1px solid var(--divider); border-radius: 6px; - padding: 8px 12px; margin-top: 4px; + .markdown-body { padding: 2px 0 4px; } + .markdown-toggle { + display: flex; align-items: center; gap: 8px; min-width: 0; + cursor: pointer; user-select: none; } + .markdown-toggle:focus-visible { + outline: 1px solid var(--ph-coding); outline-offset: 2px; border-radius: var(--r-1); + } + .markdown-caret { + flex-shrink: 0; width: 16px; text-align: center; + color: var(--fg-1); font-size: 0.95rem; line-height: 1; + } + .markdown-toggle:hover .markdown-caret { color: var(--fg-bright); } .loop-detail .section-label { color: var(--running); } .markdown-heading-row { display: flex; align-items: center; gap: 8px; margin: 8px 0 4px; diff --git a/test/dashboard/app-dom.test.ts b/test/dashboard/app-dom.test.ts index 5dcd872f12..053901ed66 100644 --- a/test/dashboard/app-dom.test.ts +++ b/test/dashboard/app-dom.test.ts @@ -174,8 +174,8 @@ describe('dashboard App fine-grained reactivity', () => { dispose = render(() => App() as unknown as Element, container) await flush() - const scrollable = container.querySelector('.markdown-scrollable') - expect(scrollable).toBeTruthy() + const body = container.querySelector('.markdown-body') + expect(body).toBeTruthy() expect(container.querySelector('.markdown-content')?.innerHTML).toContain('PLAN ONE') }) @@ -183,18 +183,16 @@ describe('dashboard App fine-grained reactivity', () => { dispose = render(() => App() as unknown as Element, container) await flush() - const scroll1 = container.querySelector('.markdown-scrollable') as HTMLElement - expect(scroll1).toBeTruthy() - scroll1.scrollTop = 123 - ;(scroll1 as any).__id = 'orig' + const body1 = container.querySelector('.markdown-body') as HTMLElement + expect(body1).toBeTruthy() + ;(body1 as any).__id = 'orig' await poll(makePayload({ dashLoop: { plan: 'PLAN TWO' } })) - const scroll2 = container.querySelector('.markdown-scrollable') as HTMLElement - // Same DOM node => scroll position / resize state preserved - expect(scroll2).toBe(scroll1) - expect((scroll2 as any).__id).toBe('orig') - expect(scroll2.scrollTop).toBe(123) + const body2 = container.querySelector('.markdown-body') as HTMLElement + // Same DOM node => collapse state and page scroll anchor preserved + expect(body2).toBe(body1) + expect((body2 as any).__id).toBe('orig') // ...and the content reflects the new plan expect(container.querySelector('.markdown-content')?.innerHTML).toContain('PLAN TWO') }) @@ -203,9 +201,8 @@ describe('dashboard App fine-grained reactivity', () => { dispose = render(() => App() as unknown as Element, container) await flush() - const scroll1 = container.querySelector('.markdown-scrollable') as HTMLElement - scroll1.scrollTop = 77 - ;(scroll1 as any).__id = 'orig' + const body1 = container.querySelector('.markdown-body') as HTMLElement + ;(body1 as any).__id = 'orig' expect(container.querySelector('.loop-detail-header .status-badge')?.textContent).toBe('running') await poll( @@ -215,13 +212,43 @@ describe('dashboard App fine-grained reactivity', () => { }), ) - const scroll2 = container.querySelector('.markdown-scrollable') as HTMLElement - expect(scroll2).toBe(scroll1) - expect((scroll2 as any).__id).toBe('orig') - expect(scroll2.scrollTop).toBe(77) + const body2 = container.querySelector('.markdown-body') as HTMLElement + expect(body2).toBe(body1) + expect((body2 as any).__id).toBe('orig') expect(container.querySelector('.loop-detail-header .status-badge')?.textContent).toBe('completed') }) + test('a markdown section collapses and expands, keeps Copy available, and survives a poll', async () => { + dispose = render(() => App() as unknown as Element, container) + await flush() + + const section = container.querySelector('.markdown-section') as HTMLElement + const toggle = section.querySelector('.markdown-toggle') as HTMLElement + const body = section.querySelector('.markdown-body') as HTMLElement + expect(body.style.display).toBe('block') + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(section.querySelector('.markdown-caret')!.textContent).toBe('▾') + + toggle.click() + await flush() + expect(body.style.display).toBe('none') + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(section.querySelector('.markdown-caret')!.textContent).toBe('▸') + // Copy stays reachable while collapsed. + expect(section.querySelector('.copy-btn')).toBeTruthy() + + // A poll must not silently re-expand the section. + await poll(makePayload({ dashLoop: { plan: 'PLAN TWO' } })) + expect(container.querySelector('.markdown-body')).toBe(body) + expect(body.style.display).toBe('none') + + toggle.click() + await flush() + expect(body.style.display).toBe('block') + expect(container.querySelector('.markdown-content')?.innerHTML).toContain('PLAN TWO') + }) + + test('section drill-in: click a row to open details, back returns to list', async () => { payload = makePayload({ dashLoop: { @@ -266,13 +293,12 @@ describe('dashboard App fine-grained reactivity', () => { // Summary labels: only Done and Follow-ups (Deviations is null) const labels = Array.from(container.querySelectorAll('.section-summary-label')).map(l => l.textContent) expect(labels).toEqual(['Done', 'Follow-ups']) - expect(container.querySelector('.markdown-scrollable .markdown-content')!.innerHTML).toContain('SECTION PLAN BODY') + expect(container.querySelector('.markdown-body .markdown-content')!.innerHTML).toContain('SECTION PLAN BODY') - // Scroll position preserved across a data poll that mutates section title - const scroll1 = container.querySelector('.markdown-scrollable') as HTMLElement - expect(scroll1).toBeTruthy() - scroll1.scrollTop = 42 - ;(scroll1 as any).__id = 'drill' + // Node identity preserved across a data poll that mutates section title + const body1 = container.querySelector('.markdown-body') as HTMLElement + expect(body1).toBeTruthy() + ;(body1 as any).__id = 'drill' await poll(makePayload({ dashLoop: { @@ -296,10 +322,9 @@ describe('dashboard App fine-grained reactivity', () => { }, })) - const scroll2 = container.querySelector('.markdown-scrollable') as HTMLElement - expect(scroll2).toBe(scroll1) - expect((scroll2 as any).__id).toBe('drill') - expect(scroll2.scrollTop).toBe(42) + const body2 = container.querySelector('.markdown-body') as HTMLElement + expect(body2).toBe(body1) + expect((body2 as any).__id).toBe('drill') // Title reflects the edit expect(container.querySelector('.section-drill-title .section-title')!.textContent).toContain('(edited)') @@ -1660,26 +1685,29 @@ describe('dashboard App loop detail tabs', () => { expect((container.querySelector('.tab-body[data-tab="overview"]') as HTMLElement).style.display).toBe('none') }) - test('tab identity is preserved across tab switch: scrollTop survives Plan tab hide/show', async () => { + test('tab identity is preserved across tab switch: Plan collapse state survives hide/show', async () => { window.location.hash = '#p1/loop/loop-a' payload = planLoopFixture() dispose = render(() => App() as unknown as Element, container) await flush() - const planScroll = container.querySelector('.tab-body[data-tab="plan"] .markdown-scrollable') as HTMLElement - expect(planScroll).toBeTruthy() - planScroll.scrollTop = 200 - ;(planScroll as any).__id = 'plan-orig' + const planTab = () => container.querySelector('.tab-body[data-tab="plan"]') as HTMLElement + const planMd = planTab().querySelector('.markdown-body') as HTMLElement + expect(planMd).toBeTruthy() + ;(planMd as any).__id = 'plan-orig' + ;(planTab().querySelector('.markdown-toggle') as HTMLElement).click() + await flush() + expect(planMd.style.display).toBe('none') await clickTab('usage') - const planBodyAfterHide = container.querySelector('.tab-body[data-tab="plan"]') as HTMLElement - expect(planBodyAfterHide.style.display).toBe('none') + expect(planTab().style.display).toBe('none') await clickTab('plan') - const planScroll2 = container.querySelector('.tab-body[data-tab="plan"] .markdown-scrollable') as HTMLElement - expect(planScroll2).toBe(planScroll) - expect((planScroll2 as any).__id).toBe('plan-orig') - expect(planScroll2.scrollTop).toBe(200) + const planMd2 = planTab().querySelector('.markdown-body') as HTMLElement + expect(planMd2).toBe(planMd) + expect((planMd2 as any).__id).toBe('plan-orig') + // Still collapsed: the node was hidden, not rebuilt. + expect(planMd2.style.display).toBe('none') }) test('a poll changing loop status while on the Usage tab leaves the active tab unchanged', async () => { @@ -2388,21 +2416,25 @@ describe('dashboard App timeline tab', () => { expect(body.querySelector('.tl-event-expand')).toBeNull() }) - test('the machine graph lives inside a collapsed
on the Timeline tab and keeps five phase nodes', async () => { + test('the machine graph renders unhidden at the top of the Timeline tab with five phase nodes', async () => { window.location.hash = '#p1/loop/loop-a/timeline' payload = makePayload() dispose = render(() => App() as unknown as Element, container) await flush() const body = container.querySelector('.tab-body[data-tab="timeline"]') as HTMLElement - const details = body.querySelector('details') as HTMLDetailsElement - expect(details).toBeTruthy() - // Collapsed by default. - expect(details.open).toBe(false) - const summary = details.querySelector('summary') as HTMLElement - expect(summary?.textContent).toContain('State machine graph') - // Machine graph still renders five phase nodes inside the details block. - expect(details.querySelectorAll('g.mg-node').length).toBe(5) + // No disclosure wrapper: the graph is always visible. + expect(body.querySelector('details')).toBeNull() + + const graph = body.querySelector('.mg-graph') as HTMLElement + expect(graph).toBeTruthy() + expect(graph.querySelectorAll('g.mg-node').length).toBe(5) + + // It is the first child of the timeline tab, above the phase bar. + const timeline = body.querySelector('.timeline-tab') as HTMLElement + expect(timeline.firstElementChild).toBe(graph) + const phaseBar = timeline.querySelector('.phase-bar, .phase-totals') as HTMLElement + expect(graph.compareDocumentPosition(phaseBar) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() }) test('the SVG root survives a poll that changes the active phase', async () => { diff --git a/test/dashboard/render.test.ts b/test/dashboard/render.test.ts index 967607cea9..d675b726db 100644 --- a/test/dashboard/render.test.ts +++ b/test/dashboard/render.test.ts @@ -53,12 +53,25 @@ describe('renderDashboardHtml', () => { 'back-to-sections', 'badge-filter', 'badge-active', + 'markdown-body', + 'markdown-toggle', ] for (const cls of cssClasses) { expect(html).toContain(cls) } }) + test('the markdown body renders at full height instead of scrolling internally', () => { + const html = renderDashboardHtml() + const style = html.slice(html.indexOf('')) + const start = style.indexOf('.markdown-body') + const rule = style.slice(start, style.indexOf('}', start)) + + expect(start).toBeGreaterThan(0) + expect(rule).not.toContain('max-height') + expect(rule).not.toContain('overflow') + }) + test('no longer contains inline script or old static dashboard nodes', () => { const html = renderDashboardHtml() From 41786bc8263802b693d946eb997515e4b2958b64 Mon Sep 17 00:00:00 2001 From: Forge Date: Sun, 26 Jul 2026 11:25:13 -0400 Subject: [PATCH 3/7] feat(dashboard): add markdown TOC, feature-stage badges, and faster usage queries --- README.md | 14 +- docs/api/README.md | 14 +- docs/api/_media/loop-system.md | 2 +- docs/api/_media/tools.md | 7 +- 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 | 4 +- docs/api/variables/default.md | 2 +- src/dashboard/app-bundle.ts | 4 +- src/dashboard/app/app.ts | 1 - src/dashboard/app/components.ts | 235 ++++++++++-------- src/dashboard/app/helpers.ts | 127 ++++++---- src/dashboard/data.ts | 51 +--- src/dashboard/render.ts | 46 +++- src/storage/repos/feature-groups-repo.ts | 24 ++ src/storage/repos/loop-session-usage-repo.ts | 125 ++++------ test/dashboard/app-dom.test.ts | 135 +++++++--- test/dashboard/app-helpers.test.ts | 14 +- test/dashboard/data.test.ts | 30 +-- test/dashboard/launch.test.ts | 2 +- test/dashboard/render.test.ts | 96 ++++--- test/dashboard/server.test.ts | 11 +- 27 files changed, 617 insertions(+), 391 deletions(-) diff --git a/README.md b/README.md index c63f5ede06..fafc95959a 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,19 @@ Forge includes a read-only observability Dashboard — a standalone Bun HTTP ser ### Views -The dashboard shows a **Loops view** by default — groups loops by project with filterable project/loop lists, loop detail (plan, sections, findings, usage, audit results, completion summary), and live polled state (5 s interval). Supports `#/` deep linking. +The dashboard is a **repo shell**: pick a repository, then move between its **Loops**, **Groups**, **Findings**, and **Plans** sections. Loop detail opens as tabs (overview, timeline, sections, findings, plan, usage), with live polled state on a 5 s interval. + +Deep links use the hash: + +| Hash | Opens | +| --- | --- | +| `#` | The repo's Loops section | +| `#/loops` \| `/groups` \| `/findings` \| `/plans` | That repo section | +| `#/groups/` | A feature group's detail view | +| `#/loop/[/]` | A loop, optionally on a given tab | +| `?status=running,errored&q=` | Appended to any of the above to preserve filters | + +`loops`, `groups`, `findings`, and `plans` are reserved as the second segment, so a loop with one of those names must be addressed as `#/loop/`. ### API Endpoints diff --git a/docs/api/README.md b/docs/api/README.md index 4fd168224e..df0ed21d20 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -73,7 +73,19 @@ Forge includes a read-only observability Dashboard — a standalone Bun HTTP ser ### Views -The dashboard shows a **Loops view** by default — groups loops by project with filterable project/loop lists, loop detail (plan, sections, findings, usage, audit results, completion summary), and live polled state (5 s interval). Supports `#/` deep linking. +The dashboard is a **repo shell**: pick a repository, then move between its **Loops**, **Groups**, **Findings**, and **Plans** sections. Loop detail opens as tabs (overview, timeline, sections, findings, plan, usage), with live polled state on a 5 s interval. + +Deep links use the hash: + +| Hash | Opens | +| --- | --- | +| `#` | The repo's Loops section | +| `#/loops` \| `/groups` \| `/findings` \| `/plans` | That repo section | +| `#/groups/` | A feature group's detail view | +| `#/loop/[/]` | A loop, optionally on a given tab | +| `?status=running,errored&q=` | Appended to any of the above to preserve filters | + +`loops`, `groups`, `findings`, and `plans` are reserved as the second segment, so a loop with one of those names must be addressed as `#/loop/`. ### API Endpoints diff --git a/docs/api/_media/loop-system.md b/docs/api/_media/loop-system.md index 3b94a7a8a4..25db2c3f82 100644 --- a/docs/api/_media/loop-system.md +++ b/docs/api/_media/loop-system.md @@ -221,7 +221,7 @@ Decomposition is a one-shot preprocessing step at loop start (`services/determin ### Plan Amendments -After decomposition, the *remaining* (not yet started) milestones can still be amended mid-loop: during a section audit, the auditor may call the `plan-adjust` tool to replace the pending section suffix when completed work makes the remaining sections unable to achieve the plan objective as written. The objective and verification criteria are immutable, completed/current sections cannot be changed, goal loops are excluded, and the resulting total is capped at 24 sections. Every amendment is recorded in the `plan_amendments` table with before/after snapshots and a rationale. If an amendment appends sections while the loop is already in `final_auditing`, the loop reverts to `auditing` to execute them. +After decomposition, the plan can still be amended mid-loop: during a section audit, the auditor may call the `plan-adjust` tool to revise the section currently under audit (`currentSection`, edited in place with its progress preserved) and/or replace the pending section suffix (`sections`) when completed work makes the plan unable to achieve its objective as written. The objective and verification criteria are immutable, already-completed sections cannot be changed, goal loops are excluded, and the resulting total is capped at 24 sections. Every amendment is recorded in the `plan_amendments` table with before/after snapshots and a rationale. If an amendment appends sections while the loop is already in `final_auditing`, the loop reverts to `auditing` to execute them. ### Transition Log diff --git a/docs/api/_media/tools.md b/docs/api/_media/tools.md index 4558c144da..5f46e618f3 100644 --- a/docs/api/_media/tools.md +++ b/docs/api/_media/tools.md @@ -10,7 +10,7 @@ See also: [Agents and Slash Commands](agents-and-commands.md), [Configuration](c |---|---|---| | `plan-read` | Read the current session or loop plan, or list/search recent project plans. | [`src/tools/plan-kv.ts`](../src/tools/plan-kv.ts) | | `section-read` | Read a section plan and status for the active loop session. | [`src/tools/section-read.ts`](../src/tools/section-read.ts) | -| `plan-adjust` | Replace the remaining (not yet started) sections of the active loop plan; auditor-only, logged as a plan amendment. | [`src/tools/plan-adjust.ts`](../src/tools/plan-adjust.ts) | +| `plan-adjust` | Revise the section under audit and/or replace the remaining (not yet started) sections of the active loop plan; auditor-only, logged as a plan amendment. | [`src/tools/plan-adjust.ts`](../src/tools/plan-adjust.ts) | | `review-write` | Store a review finding. | [`src/tools/review.ts`](../src/tools/review.ts) | | `review-read` | Read review findings. | [`src/tools/review.ts`](../src/tools/review.ts) | | `review-delete` | Delete a review finding. | [`src/tools/review.ts`](../src/tools/review.ts) | @@ -49,13 +49,14 @@ Arguments: ### `plan-adjust` -Only callable by the current auditor session of a sectioned plan loop during the `auditing` phase (rejected in goal loops and during the final audit). Replaces the pending section suffix (from the current section + 1 onward); the plan objective and verification are immutable. The resulting total may not exceed 24 sections. Every adjustment is recorded in the `plan_amendments` table with before/after snapshots. +Only callable by the current auditor session of a sectioned plan loop during the `auditing` phase (rejected in goal loops and during the final audit). Can revise the section currently under audit (`currentSection`, edited in place with its progress preserved) and/or replace the pending section suffix from the current section + 1 onward (`sections`). Already-completed sections, the plan objective, and verification are immutable. The resulting total may not exceed 24 sections. Every adjustment is recorded in the `plan_amendments` table with before/after snapshots. Arguments: | Argument | Description | |---|---| -| `sections` | Replacement list of `{ title, content }` for the remaining sections. An empty list removes the entire pending suffix. | +| `sections` | Optional replacement list of `{ title, content }` for the not-yet-started sections after the current one. Omit to leave future sections unchanged; an empty list removes the entire pending suffix. | +| `currentSection` | Optional `{ title, content }` revision of the section currently under audit, edited in place. If the revision means the existing work no longer satisfies the section, also write bug findings so it is re-coded. | | `rationale` | Why the plan needs adjustment. | ## Review Tools diff --git a/docs/api/functions/createForgePlugin.md b/docs/api/functions/createForgePlugin.md index e8a21dadee..70bf55317b 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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/index.ts#L199) +Defined in: [index.ts:199](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L199) 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 91207d8361..576205641f 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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/index.ts#L53) +Defined in: [index.ts:53](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L53) ## Parameters diff --git a/docs/api/functions/createSessionDirectoryLookup.md b/docs/api/functions/createSessionDirectoryLookup.md index f663530ac3..acd5e6b7fb 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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/index.ts#L134) +Defined in: [index.ts:134](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L134) ## Parameters diff --git a/docs/api/interfaces/CompactionConfig.md b/docs/api/interfaces/CompactionConfig.md index daad1fdb98..0d867c4a74 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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/types.ts#L152) +Defined in: [types.ts:152](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/types.ts#L154) +Defined in: [types.ts:154](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/types.ts#L156) +Defined in: [types.ts:156](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/types.ts#L156) Maximum context tokens for compaction. diff --git a/docs/api/interfaces/CreateParentSessionLookupOptions.md b/docs/api/interfaces/CreateParentSessionLookupOptions.md index f8626cc97e..243a5832c3 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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/index.ts#L43) +Defined in: [index.ts:43](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L43) ## 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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/index.ts#L44) +Defined in: [index.ts:44](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L44) *** @@ -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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/index.ts#L45) +Defined in: [index.ts:45](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L45) *** @@ -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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/index.ts#L47) +Defined in: [index.ts:47](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L47) #### 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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/index.ts#L46) +Defined in: [index.ts:46](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L46) *** @@ -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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/index.ts#L48) +Defined in: [index.ts:48](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L48) diff --git a/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md b/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md index d12d4164d3..1c61fbcd0a 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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/index.ts#L128) +Defined in: [index.ts:128](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L128) ## 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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/index.ts#L129) +Defined in: [index.ts:129](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L129) *** @@ -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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/index.ts#L130) +Defined in: [index.ts:130](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L130) *** @@ -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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/index.ts#L131) +Defined in: [index.ts:131](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L131) diff --git a/docs/api/interfaces/PluginConfig.md b/docs/api/interfaces/PluginConfig.md index 7dc98590fd..319632aa31 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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/types.ts#L216) +Defined in: [types.ts:216](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/types.ts#L244) +Defined in: [types.ts:244](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/types.ts#L228) +Defined in: [types.ts:228](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/types.ts#L232) +Defined in: [types.ts:232](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/types.ts#L222) +Defined in: [types.ts:222](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/types.ts#L240) +Defined in: [types.ts:240](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/types.ts#L218) +Defined in: [types.ts:218](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/types.ts#L226) +Defined in: [types.ts:226](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/types.ts#L230) +Defined in: [types.ts:230](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/types.ts#L236) +Defined in: [types.ts:236](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/types.ts#L220) +Defined in: [types.ts:220](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/types.ts#L234) +Defined in: [types.ts:234](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/types.ts#L224) +Defined in: [types.ts:224](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/types.ts#L238) +Defined in: [types.ts:238](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/types.ts#L246) +Defined in: [types.ts:246](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/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/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/types.ts#L242) +Defined in: [types.ts:242](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/types.ts#L242) TUI display configuration. diff --git a/docs/api/variables/VERSION.md b/docs/api/variables/VERSION.md index 715fcdc546..dc8f1e674b 100644 --- a/docs/api/variables/VERSION.md +++ b/docs/api/variables/VERSION.md @@ -6,6 +6,6 @@ # Variable: VERSION -> `const` **VERSION**: `"0.6.2"` = `'0.6.2'` +> `const` **VERSION**: `"0.7.1"` = `'0.7.1'` -Defined in: [version.ts:1](https://github.com/chriswritescode-dev/opencode-forge/blob/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/version.ts#L1) +Defined in: [version.ts:1](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/version.ts#L1) diff --git a/docs/api/variables/default.md b/docs/api/variables/default.md index ab6954bd69..dadfae2a79 100644 --- a/docs/api/variables/default.md +++ b/docs/api/variables/default.md @@ -8,7 +8,7 @@ > `const` **default**: `object` -Defined in: [index.ts:798](https://github.com/chriswritescode-dev/opencode-forge/blob/a221960b2db97da192fa8ae878f660ac7cfa0d90/src/index.ts#L798) +Defined in: [index.ts:798](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L798) ## Type Declaration diff --git a/src/dashboard/app-bundle.ts b/src/dashboard/app-bundle.ts index 2c9fbb3049..2120f3327c 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 _={context:void 0,registry:void 0,effects:void 0,done:!1,getContextId(){return Un(this.context.count)},getNextContextId(){return Un(this.context.count++)}};function Un(e){let n=String(e),t=n.length-1;return _.context.id+(t?String.fromCharCode(96+t):\"\")+n}function me(e){_.context=e}function Ei(){return{..._.context,id:_.getNextContextId(),count:0}}var ki=!0,Si=(e,n)=>e===n,q=Symbol(\"solid-proxy\"),Ai=typeof Proxy===\"function\",pe=Symbol(\"solid-track\"),Yn=Symbol(\"solid-dev-component\"),Ve={equals:Si},Gn=null,Hn=Jn,Y=1,ge=2,Ni={};var C=null,O=null,xe=null,re=null,D=null,G=null,W=null,je=0,X={afterUpdate:null,afterCreateOwner:null,afterCreateSignal:null,afterRegisterGraph:null};function qn(e,n){let t=D,i=C,f=e.length===0,r=n===void 0?i:n,u=f?{owned:null,cleanups:null,context:null,owner:null}:{owned:null,cleanups:null,context:r?r.context:null,owner:r},l=f?()=>e(()=>{throw Error(\"Dispose method must be an explicit argument to createRoot function\")}):()=>e(()=>v(()=>M(u)));X.afterCreateOwner&&X.afterCreateOwner(u),C=u,D=null;try{return L(l,!0)}finally{D=t,C=i}}function F(e,n){n=n?Object.assign({},Ve,n):Ve;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(Ln(t),X.afterCreateSignal)X.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 en(t,f)};return[Rn.bind(t),i]}function R(e,n,t){let i=De(e,n,!1,Y,t);if(xe&&O&&O.running)G.push(i);else le(i)}function B(e,n,t){Hn=Ti;let i=De(e,n,!1,Y,t),f=Me&&Ii(Me);if(f)i.suspense=f;if(!t||!t.render)i.user=!0;W?W.push(i):le(i)}function w(e,n,t){t=t?Object.assign({},Ve,t):Ve;let i=De(e,n,!0,0,t);if(i.observers=null,i.observerSlots=null,i.comparator=t.equals||void 0,xe&&O&&O.running)i.tState=Y,G.push(i);else le(i);return Rn.bind(i)}function Xn(e){return L(e,!1)}function v(e){if(!re&&D===null)return e();let n=D;D=null;try{if(re)return re.untrack(e);return e()}finally{D=n}}function Zn(e){B(()=>v(e))}function ue(e){if(C===null)console.warn(\"cleanups created outside a `createRoot` or `render` will never be run\");else if(C.cleanups===null)C.cleanups=[e];else C.cleanups.push(e);return e}function _e(){return D}function Ci(e){if(O&&O.running)return e(),O.done;let n=D,t=C;return Promise.resolve().then(()=>{D=n,C=t;let i;if(xe||Me)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 L(e,!1),D=C=null,i?i.done:void 0})}var[Qf,Kn]=F(!1);function Wn(e,n){let t=De(()=>v(()=>{return Object.assign(e,{[Yn]:!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 Ln(e){if(C){if(C.sourceMap)C.sourceMap.push(e);else C.sourceMap=[e];e.graph=C}if(X.afterRegisterGraph)X.afterRegisterGraph(e)}function Ii(e){let n;return C&&C.context&&(n=C.context[e.id])!==void 0?n:e.defaultValue}var Me;function Rn(){let e=O&&O.running;if(this.sources&&(e?this.tState:this.state))if((e?this.tState:this.state)===Y)le(this);else{let n=G;G=null,L(()=>Te(this),!1),G=n}if(D){let n=this.observers?this.observers.length:0;if(!D.sources)D.sources=[this],D.sourceSlots=[n];else D.sources.push(this),D.sourceSlots.push(n);if(!this.observers)this.observers=[D],this.observerSlots=[D.sources.length-1];else this.observers.push(D),this.observerSlots.push(D.sources.length-1)}if(e&&O.sources.has(this))return this.tValue;return this.value}function en(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)L(()=>{for(let f=0;f1e6){if(G=[],ki)throw Error(\"Potential Infinite Loop Detected.\");throw Error()}},!1)}return n}function le(e){if(!e.fn)return;M(e);let n=je;if(vn(e,O&&O.running&&O.sources.has(e)?e.tValue:e.value,n),O&&!O.running&&O.sources.has(e))queueMicrotask(()=>{L(()=>{O&&(O.running=!0),D=C=e,vn(e,e.tValue,n),D=C=null},!1)})}function vn(e,n,t){let i,f=C,r=D;D=C=e;try{i=e.fn(n)}catch(u){if(e.pure)if(O&&O.running)e.tState=Y,e.tOwned&&e.tOwned.forEach(M),e.tOwned=void 0;else e.state=Y,e.owned&&e.owned.forEach(M),e.owned=null;return e.updatedAt=t+1,nn(u)}finally{D=r,C=f}if(!e.updatedAt||e.updatedAt<=t){if(e.updatedAt!=null&&\"observers\"in e)en(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 De(e,n,t,i=Y,f){let r={fn:e,state:i,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:n,owner:C,context:C?C.context:null,pure:t};if(O&&O.running)r.state=0,r.tState=i;if(C===null)console.warn(\"computations created outside a `createRoot` or `render` will never be disposed\");else if(C!==Ni)if(O&&O.running&&C.pure)if(!C.tOwned)C.tOwned=[r];else C.tOwned.push(r);else if(!C.owned)C.owned=[r];else C.owned.push(r);if(f&&f.name)r.name=f.name;if(re&&r.fn){let u=r.fn,[l,c]=F(void 0,{equals:!1}),a=re.factory(u,c);ue(()=>a.dispose());let d,g=()=>Ci(c).then(()=>{if(d)d.dispose(),d=void 0});r.fn=(E)=>{if(l(),O&&O.running){if(!d)d=re.factory(u,g);return d.track(E)}return a.track(E)}}return X.afterCreateOwner&&X.afterCreateOwner(r),r}function $e(e){let n=O&&O.running;if((n?e.tState:e.state)===0)return;if((n?e.tState:e.state)===ge)return Te(e);if(e.suspense&&v(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,r=t[i+1];while((f=f.owner)&&f!==r)if(O.disposed.has(f))return}if((n?e.tState:e.state)===Y)le(e);else if((n?e.tState:e.state)===ge){let f=G;G=null,L(()=>Te(e,t[0]),!1),G=f}}}function L(e,n){if(G)return e();let t=!1;if(!n)G=[];if(W)t=!0;else W=[];je++;try{let i=e();return Pi(t),i}catch(i){if(!t)W=null;G=null,nn(i)}}function Pi(e){if(G){if(xe&&O&&O.running)Vi(G);else Jn(G);G=null}if(e)return;let n;if(O){if(!O.promises.size&&!O.queue.size){let{sources:i,disposed:f}=O;W.push.apply(W,O.effects),n=O.resolve;for(let r of W)\"tState\"in r&&(r.state=r.tState),delete r.tState;O=null,L(()=>{for(let r of f)M(r);for(let r of i){if(r.value=r.tValue,r.owned)for(let u=0,l=r.owned.length;uHn(t),!1);else X.afterUpdate&&X.afterUpdate();if(n)n()}function Jn(e){for(let n=0;n{i.delete(t),L(()=>{O.running=!0,$e(t)},!1),O&&(O.running=!1)})}}function Ti(e){let n,t=0;for(n=0;n=0;n--)M(e.tOwned[n]);delete e.tOwned}if(O&&O.running&&e.pure)Bn(e,!0);else if(e.owned){for(n=e.owned.length-1;n>=0;n--)M(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 Bn(e,n){if(!n)e.tState=0,O.disposed.add(e);if(e.owned)for(let t=0;t=0;l--){let c=Be(e[l])[u];if(c!==void 0)return c}},has(u){for(let l=e.length-1;l>=0;l--)if(u in Be(e[l]))return!0;return!1},keys(){let u=[];for(let l=0;l=0;u--){let l=e[u];if(!l)continue;let c=Object.getOwnPropertyNames(l);for(let a=c.length-1;a>=0;a--){let d=c[a];if(d===\"__proto__\"||d===\"constructor\")continue;let g=Object.getOwnPropertyDescriptor(l,d);if(!i[d])i[d]=g.get?{enumerable:!0,configurable:!0,get:Fi.bind(t[d]=[g.get.bind(l)])}:g.value!==void 0?g:void 0;else{let E=t[d];if(E){if(g.get)E.push(g.get.bind(l));else if(g.value!==void 0)E.push(()=>g.value)}}}}let f={},r=Object.keys(i);for(let u=r.length-1;u>=0;u--){let l=r[u],c=i[l];if(c&&c.get)Object.defineProperty(f,l,c);else f[l]=c?c.value:void 0}return f}var mn={hooks:X,writeSignal:en,registerGraph:Ln};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 Gi=[\"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\"],un=new Set([\"className\",\"value\",\"readOnly\",\"noValidate\",\"formNoValidate\",\"isMap\",\"noModule\",\"playsInline\",\"adAuctionHeaders\",\"allowFullscreen\",\"browsingTopics\",\"defaultChecked\",\"defaultMuted\",\"defaultSelected\",\"disablePictureInPicture\",\"disableRemotePlayback\",\"preservesPitch\",\"shadowRootClonable\",\"shadowRootCustomElementRegistry\",\"shadowRootDelegatesFocus\",\"shadowRootSerializable\",\"sharedStorageWritable\",...Gi]),ln=new Set([\"innerHTML\",\"textContent\",\"innerText\",\"children\"]),cn=Object.assign(Object.create(null),{className:\"class\",htmlFor:\"for\"}),Ki=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 sn(e,n){let t=Ki[e];return typeof t===\"object\"?t[n]?t.$:void 0:t}var on=new Set([\"beforeinput\",\"click\",\"dblclick\",\"contextmenu\",\"focusin\",\"focusout\",\"input\",\"keydown\",\"keyup\",\"mousedown\",\"mousemove\",\"mouseout\",\"mouseover\",\"mouseup\",\"pointerdown\",\"pointermove\",\"pointerout\",\"pointerover\",\"pointerup\",\"touchend\",\"touchmove\",\"touchstart\"]),tt=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\"]),dn={xlink:\"http://www.w3.org/1999/xlink\",xml:\"http://www.w3.org/XML/1998/namespace\"};function Wi(e,n,t){let i=t.length,f=n.length,r=i,u=0,l=0,c=n[f-1].nextSibling,a=null;while(ud-l){let o=n[u];while(l{f=r,n===document?e():gn(n,e(),n.firstChild?null:void 0,t)},i.owner),()=>{f(),n.textContent=\"\"}}function an(e,n=window.document){let t=n[Mn]||(n[Mn]=new Set);for(let i=0,f=e.length;if.call(e,t[1],r))}else e.addEventListener(n,t,typeof t!==\"function\"&&t)}function bn(e,n,t={}){let i=Object.keys(n||{}),f=Object.keys(t),r,u;for(r=0,u=f.length;rf.children=se(e,n.children,f.children));return R(()=>typeof n.ref===\"function\"&&Yi(n.ref,e)),R(()=>Hi(e,n,t,!0,f,!0)),f}function rt(e,n){let t=e[n];return Object.defineProperty(e,n,{get(){return t()},enumerable:!0}),e}function Yi(e,n,t){return v(()=>e(n,t))}function gn(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 Hi(e,n,t,i,f={},r=!1){n||(n={});for(let u in f)if(!(u in n)){if(u===\"children\")continue;f[u]=et(e,u,null,f[u],t,r,n)}for(let u in n){if(u===\"children\"){if(!i)se(e,n.children);continue}let l=n[u];f[u]=et(e,u,l,f[u],t,r,n)}}function oe(e){return!!_.context&&!_.done&&(!e||e.isConnected)}function qi(e){return e.toLowerCase().replace(/-([a-z])/g,(n,t)=>t.toUpperCase())}function pn(e,n,t){let i=n.trim().split(/\\s+/);for(let f=0,r=i.length;f-1&&dn[n.split(\":\")[0]];if(E)hn(e,E,n,t);else Oe(e,cn[n]||n,t)}return t}function Xi(e){if(_.registry&&_.events){if(_.events.find(([c,a])=>a===e))return}let n=e.target,t=`$$${e.type}`,i=e.target,f=e.currentTarget,r=(c)=>Object.defineProperty(e,\"target\",{configurable:!0,value:c}),u=()=>{let c=n[t];if(c&&!n.disabled){let a=n[`${t}Data`];if(a!==void 0?c.call(n,a,e):c.call(n,e),e.cancelBubble)return}return n.host&&typeof n.host!==\"string\"&&!n.host._$host&&n.contains(e.target)&&r(n.host),!0},l=()=>{while(u()&&(n=n._$host||n.parentNode||n.host));};if(Object.defineProperty(e,\"currentTarget\",{configurable:!0,get(){return n||document}}),_.registry&&!_.done)_.done=_$HY.done=!0;if(e.composedPath){let c=e.composedPath();r(c[0]);for(let a=0;a{let c=n();while(typeof c===\"function\")c=c();t=se(e,c,t,i)}),()=>t;else if(Array.isArray(n)){let c=[],a=t&&Array.isArray(t);if(rn(c,n,t,f))return R(()=>t=se(e,c,t,i,!0)),()=>t;if(r){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=ce(e,t,i),l)return t}else if(a)if(t.length===0)nt(e,c,i);else Wi(e,t,c);else t&&ce(e),nt(e,c);t=c}else if(n.nodeType){if(r&&n.parentNode)return t=l?[n]:n;if(Array.isArray(t)){if(l)return t=ce(e,t,i,n);ce(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 rn(e,n,t,i){let f=!1;for(let r=0,u=n.length;r=0;u--){let l=n[u];if(f!==l){let c=l.parentNode===e;if(!r&&!u)c?e.replaceChild(f,l):e.insertBefore(f,t);else c&&l.remove()}else r=!0}}else e.insertBefore(f,t);return[f]}var or=Symbol();var $n=Symbol(\"store-raw\"),ae=Symbol(\"store-node\"),m=Symbol(\"store-has\"),lt=Symbol(\"store-self\"),ut={onStoreNodeUpdate:null};function ct(e){let n=e[q];if(!n){if(Object.defineProperty(e,q,{value:n=new Proxy(e,Ri)}),!Array.isArray(e)){let t=Object.keys(e),i=Object.getOwnPropertyDescriptors(e);for(let f=0,r=t.length;fe[q][n],t}function st(e){_e()&&ke(Fe(e,ae),lt)()}function Li(e){return st(e),Reflect.ownKeys(e)}var Ri={get(e,n,t){if(n===$n)return e;if(n===q)return t;if(n===pe)return st(e),t;let i=Fe(e,ae),f=i[n],r=f?f():e[n];if(n===ae||n===m||n===\"__proto__\")return r;if(!f){let u=Object.getOwnPropertyDescriptor(e,n);if(_e()&&(typeof r!==\"function\"||e.hasOwnProperty(n))&&!(u&&u.get))r=ke(i,n,r)()}return p(r)?ct(r):r},has(e,n){if(n===$n||n===q||n===pe||n===ae||n===m||n===\"__proto__\")return!0;return _e()&&ke(Fe(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:Li,getOwnPropertyDescriptor:Zi};function H(e,n,t,i=!1){if(!i&&e[n]===t)return;let f=e[n],r=e.length;if(ut.onStoreNodeUpdate&&ut.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 u=Fe(e,ae),l;if(l=ke(u,n,f))l.$(()=>t);if(Array.isArray(e)&&e.length!==r){for(let c=e.length;c1){i=n.shift();let u=typeof i,l=Array.isArray(e);if(Array.isArray(i)){for(let c=0;c1){Ee(e[i],n,[i].concat(t));return}f=e[i],t=[i].concat(t)}let r=n[0];if(typeof r===\"function\"){if(r=r(f,t),r===f)return}if(i===void 0&&r==null)return;if(r=he(r),i===void 0||p(f)&&p(r)&&!Array.isArray(r))ot(f,r);else H(e,i,r)}function dt(...[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=ct(t);mn.registerGraph({value:t,name:n&&n.name});function r(...u){Xn(()=>{i&&u.length===1?Ji(t,u[0]):Ee(t,u)})}return[f,r]}var xn=Symbol(\"store-root\");function de(e,n,t,i,f){let r=n[t];if(e===r)return;let u=Array.isArray(e);if(t!==xn&&(!p(e)||!p(r)||u!==Array.isArray(r)||f&&e[f]!==r[f])){H(n,t,e);return}if(u){if(e.length&&r.length&&(!i||f&&e[0]&&e[0][f]!=null)){let a,d,g,E,b,o,s,x;for(g=0,E=Math.min(r.length,e.length);g=g&&b>=g&&(r[E]===e[b]||f&&r[E]&&e[b]&&r[E][f]&&r[E][f]===e[b][f]);E--,b--)k[b]=r[E];if(g>b||g>E){for(d=g;d<=b;d++)H(r,d,e[d]);for(;de.length)H(r,\"length\",e.length);return}s=Array(b+1);for(d=b;d>=g;d--)o=e[d],x=f&&o?o[f]:o,a=I.get(x),s[d]=a===void 0?-1:a,I.set(x,d);for(a=g;a<=E;a++)if(o=r[a],x=f&&o?o[f]:o,d=I.get(x),d!==void 0&&d!==-1)k[d]=r[a],d=s[d],I.set(x,d);for(d=g;de.length)H(r,\"length\",e.length);return}let l=Object.keys(e);for(let a=0,d=l.length;a{if(!p(r)||!p(f))return f;let u=de(f,{[xn]:r},xn,t,i);return u===void 0?r:u}}var Qi=/(?:|<(?:\"[^\"]*\"['\"]*|'[^']*'['\"]*|[^'\">])+>)/g,Bi=/(?:\\s(?[^/\\s><=]+?)(?=[\\s/>]))|(?:(?\\S+?)(?:\\s*=\\s*(?:(['\"])(?[\\s\\S]*?)\\3|(?[^\\s>]+))))/g,mi={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 Mi(e){let n={type:\"tag\",name:\"\",voidElement:!1,attrs:[],children:[]},t=e.match(/<\\/?([^\\s]+?)[/\\s>]/);if(t){if(n.name=t[1],mi[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(Bi);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 ht(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 yt(e,n){let t=n.replace(\"\",\"\");if(!/^\\s*$/.test(t))e.push({type:\"comment\",content:t})}function pi(e){let n=[],t=void 0,i=-1,f=[],r={};return e.replace(Qi,(u,l)=>{let c=u.charAt(1)!==\"/\",a=u.slice(0,4)===\"\"}}function nf(e){return e.reduce(function(n,t){return n+gt(\"\",t)},\"\")}var bt=new Map,tf=/^(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$/i,Ue=\" \\\\f\\\\n\\\\r\\\\t\",$t=\"[^\"+Ue+`\\\\/>\"'=]+`,En=\"[ \"+Ue+\"]+(?:use:|\"+$t+\")\",xt=\"<([A-Za-z$#]+[A-Za-z0-9:_-]*)((?:\",Ot=`(?:\\\\s*=\\\\s*(?:'[^']*?'|\"[^\"]*?\"|\\\\([^)]*?\\\\)|<[^>]*?>|`+$t+\"))?)\",ff=new RegExp(xt+En+Ot+\"+)([ \"+Ue+\"]*/?>)\",\"g\"),rf=new RegExp(\"(\"+En+`\\\\s*=\\\\s*)(|['\"(]([\\\\w\\\\s]*[\\\\w\\\\s]*)*['\")])`,\"gi\"),uf=new RegExp(xt+En+Ot+\"*)([ \"+Ue+\"]*/>)\",\"g\"),wt=\"\",lf=new Set([\"class\",\"on\",\"oncapture\",\"style\",\"use\",\"prop\",\"attr\"]);function cf(e,n,t,i){return\"<\"+n+t.replace(rf,sf)+i}function sf(e,n,t){return n.replace(//g,\"###\")+(t[0]==='\"'||t[0]===\"'\"?t.replace(//g,\"###\"):'\"###\"')}function of(e,n,t){return tf.test(n)?e:\"<\"+n+t+\">\"}function df(e){return e.toLowerCase().replace(/-([a-z])/g,(n,t)=>t.toUpperCase())}function On(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 af(e,{delegateEvents:n=!0,functionBuilder:t=(...i)=>Function(...i)}={}){let i=1;e.wrapProps=(b)=>{let o=Object.getOwnPropertyDescriptors(b);for(let s in o)if(typeof o[s].value===\"function\"&&!o[s].value.length)e.dynamicProperty(b,s);return b};function f(b,o){let s=0,x=\"\";for(;s\";x=x+b[s],x=[[uf,of],[/<()/g,\"<###\"],[/\\.\\.\\.()/g,\"###\"],[ff,cf],[/>\\n+\\s*/g,\">\"],[/\\n+\\s*\\s+/g,\"> \"]].reduce((V,T)=>{return V.replace(T[0],T[1])},x);let I=pi(x),[A,S]=g(I,o.funcBuilder),N=[];for(let V=0;V-1){let J=j.split(wt).reduce((Q,Ie,Le)=>{return Le&&Q.push(\"\"),Q.push(Ie),Q},[]);T[V].firstChild.replaceWith(...J)}}}return N[0].create=S,bt.set(b,N),N}function r(b,o,s,x,k,I,A){let S=x===\"###\"?`!doNotWrap ? exprs[${A.counter}]() : exprs[${A.counter++}]`:x.split(\"###\").map((j,J)=>J?` + (typeof exprs[${A.counter}] === \"function\" ? exprs[${A.counter}]() : exprs[${A.counter++}]) + \"${j}\"`:`\"${j}\"`).join(\"\"),N,V;if((N=s.split(\":\"))&&N[1]&&lf.has(N[0]))s=N[1],V=N[0];let T=e.ChildProperties.has(s),K=e.Properties.has(s);if(s===\"style\"){let j=`_$v${i++}`;A.decl.push(`${j}={}`),A.exprs.push(`r.style(${o},${S},${j})`)}else if(s===\"classList\"){let j=`_$v${i++}`;A.decl.push(`${j}={}`),A.exprs.push(`r.classList(${o},${S},${j})`)}else if(V!==\"attr\"&&(T||!k&&(e.getPropAlias(s,b.name.toUpperCase())||K)||I||V===\"prop\")){if(I&&!T&&!K&&V!==\"prop\")s=df(s);A.exprs.push(`${o}.${e.getPropAlias(s,b.name.toUpperCase())||s} = ${S}`)}else{let j=k&&s.indexOf(\":\")>-1&&e.SVGNamespace[s.split(\":\")[0]];if(j)A.exprs.push(`r.setAttributeNS(${o},\"${j}\",\"${s}\",${S})`);else A.exprs.push(`r.setAttribute(${o},\"${e.Aliases[s]||s}\",${S})`)}}function u(b,o,s,x,k,I,A){if(s.slice(0,2)===\"on\")if(!s.includes(\":\")){let S=s.slice(2).toLowerCase(),N=n&&e.DelegatedEvents.has(S);A.exprs.push(`r.addEventListener(${o},\"${S}\",exprs[${A.counter++}],${N})`),N&&A.delegatedEvents.add(S)}else{let S=s.startsWith(\"oncapture:\");A.exprs.push(`${o}.addEventListener(\"${s.slice(S?10:3)}\",exprs[${A.counter++}]${S?\",true\":\"\"})`)}else if(s===\"ref\")A.exprs.push(`exprs[${A.counter++}](${o})`);else{let S=Object.assign({},A,{exprs:[]}),N=A.counter;if(r(b,o,s,x,k,I,S),A.decl.push(`_fn${N} = (${x===\"###\"?\"doNotWrap\":\"\"}) => {\n${S.exprs.join(`;\n`)};\n}`),x===\"###\")A.exprs.push(`typeof exprs[${N}] === \"function\" ? r.effect(_fn${N}) : _fn${N}(true)`);else{let V=\"\";for(let T=N;T1)for(let k=0;k1?`r.mergeProps(${o.join(\",\")})`:o[0]}function a(b,o){let s=[],x=Object.keys(b.attrs),k=[s],I=o.counter++;for(let S=0;S exprs[${o.counter++}]`);else if(b.children.length){let S={type:\"fragment\",children:b.children},N=Object.assign({},o,{first:!0,decl:[],exprs:[],parent:!1});d(S,N),s.push(`children: () => { ${N.exprs.join(`;\n`)}}`),o.templateId=N.templateId,o.counter=N.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[${I}],${c(k)})${A?`, ${A}`:\"\"})`);else o.exprs.push(`${o.fragment?\"\":\"return \"}r.createComponent(exprs[${I}],${c(k)})`);o.path=A,o.first=!1}function d(b,o){if(b.type===\"fragment\"){let s=[];b.children.forEach((x)=>{if(x.type===\"tag\"){if(x.name===\"###\"){let A=Object.assign({},o,{first:!0,fragment:!0,decl:[],exprs:[]});a(x,A),s.push(A.exprs[0]),o.counter=A.counter,o.templateId=A.templateId;return}o.templateId++;let k=i,I=Object.assign({},o,{first:!0,decl:[],exprs:[]});o.templateNodes.push([x]),d(x,I),s.push(`function() { ${I.decl.join(`,\n`)+`;\n`+I.exprs.join(`;\n`)+`;\nreturn _$el${k};\n`}}()`),o.counter=I.counter,o.templateId=I.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;kS.name===\"is\");if(o.hasCustomElement=A,o.isImportNode=(b.name===\"img\"||b.name===\"iframe\")&&b.attrs.some((S)=>S.name===\"loading\"&&S.value===\"lazy\"),b.attrs.some((S)=>S.name===\"###\")){let S=[],N=\"\",V=[];for(let T=0;T({${N}})`),N=\"\";S.push(`exprs[${o.counter++}]`)}else V.push(b.attrs[T]);else if(K===\"directive\")On(j,J,s,o)}if(b.attrs=V,N.length)S.push(`()=>({${N}})`);o.exprs.push(`r.spread(${s},${S.length===1?`typeof ${S[0]} === \"function\" ? r.mergeProps(${S[0]}) : ${S[0]}`:`r.mergeProps(${S.join(\",\")})`},${I},${!!b.children.length})`)}else for(let S=0;S document.importNode(tmpls[${k}].content.firstChild, true))`:`const ${s} = tmpls[${k}].content.firstChild.cloneNode(true)`}else if(b.type===\"text\"){let s=`_$el${i++}`;o.decl.push(`${s} = ${o.path}.${o.first?\"firstChild\":\"nextSibling\"}`),o.path=s,o.first=!1}else if(b.type===\"comment\"){let s=`_$el${i++}`;if(o.decl.push(`${s} = ${o.path}.${o.first?\"firstChild\":\"nextSibling\"}`),b.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 g(b,o){let s={path:\"\",decl:[],exprs:[],delegatedEvents:new Set,counter:0,first:!0,multi:!1,templateId:0,templateNodes:[]},x=i,k=b,I;if(b.length>1)b=[{type:\"fragment\",children:b}];if(b[0].name===\"###\")I=!0,a(b[0],s);else d(b[0],s);return e.delegateEvents(Array.from(s.delegatedEvents)),[[k].concat(s.templateNodes).map((S)=>nf(S)),o(\"tmpls\",\"exprs\",\"r\",s.decl.join(`,\n`)+`;\n`+s.exprs.join(`;\n`)+(I?\"\":`;\nreturn _$el${x};\n`))]}function E(b,...o){let s=bt.get(b)||f(b,{funcBuilder:t});return s[0].create(s,o,e)}return E}var h=af({effect:R,style:wn,insert:gn,untrack:v,spread:ft,createComponent:tn,delegateEvents:an,classList:bn,mergeProps:fn,dynamicProperty:rt,setAttribute:Oe,setAttributeNS:hn,addEventListener:yn,Aliases:cn,getPropAlias:sn,Properties:un,ChildProperties:ln,DelegatedEvents:on,SVGElements:tt,SVGNamespace:dn});function Se(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 Et(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 hf=new Set([\"loops\",\"groups\",\"findings\",\"plans\"]),yf=new Set([\"overview\",\"timeline\",\"sections\",\"findings\",\"plan\",\"usage\"]);function ne(e){try{return decodeURIComponent(e)}catch{return e}}function bf(){return{projectId:null,section:\"loops\",loopName:null,tab:\"overview\",groupId:null,statuses:[],query:\"\"}}function kn(e,n){if(!n)return;for(let t of n.split(\"&\")){if(!t)continue;let i=t.indexOf(\"=\");if(i===-1)continue;let f=ne(t.slice(0,i)),r=ne(t.slice(i+1));if(f===\"status\")e.statuses=r.split(\",\").map((u)=>u.trim()).filter((u)=>u!==\"\");else if(f===\"q\")e.query=r}}function Ge(e){let n=bf(),t=(e||\"\").replace(/^#/,\"\");if(!t)return n;let i=t.indexOf(\"?\"),f=i===-1?t:t.slice(0,i),r=i===-1?\"\":t.slice(i+1),u=f.indexOf(\"/\"),l,c;if(u===-1)l=f,c=\"\";else l=f.slice(0,u),c=f.slice(u+1);if(l)n.projectId=ne(l);if(!c)return kn(n,r),n;let a=c.split(\"/\").filter((g)=>g!==\"\");if(a.length===0)return kn(n,r),n;let d=ne(a[0]);if(d===\"loop\"){if(a.length>=2){if(n.loopName=ne(a[1]),a.length>=3){let g=ne(a[2]);if(yf.has(g))n.tab=g}}}else if(hf.has(d)){if(n.section=d,d===\"groups\"&&a.length>=2)n.groupId=ne(a[1])}else n.loopName=d;return kn(n,r),n}function Ne(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 St(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=(c)=>c<10?\"0\"+c:String(c),i=t(n.getMonth()+1),f=t(n.getDate()),r=n.getFullYear(),u=n.getHours(),l=u>=12?\"PM\":\"AM\";if(u=u%12,u===0)u=12;return i+\"-\"+f+\"-\"+r+\" \"+u+\":\"+t(n.getMinutes())+\" \"+l}function ye(e){return\"status-badge status-\"+e}function Ke(e){return\"section-status section-\"+e}function At(e,n,t,i){if(!(n.size===0||n.has(e.status)))return!1;if(!t)return!0;let r=t.toLowerCase(),u=e.worktreeBranch??\"\";return((e.loopName||\"\")+\" \"+u+\" \"+(i||\"\")).toLowerCase().indexOf(r)!==-1}function kt(e){if(!e)return 0;let n=0,t=e.match(/(\\d+)\\s*h/i);if(t)n+=parseInt(t[1],10)*3600;let i=e.match(/(\\d+)\\s*m(?!s)/i);if(i)n+=parseInt(i[1],10)*60;let f=e.match(/(\\d+)\\s*s/i);if(f)n+=parseInt(f[1],10);return n}function Nt(e,n){if(n===\"recent\")return[...e];let t=[...e];if(n===\"cost\")t.sort((i,f)=>{let r=i.usage?.totalCost??null,u=f.usage?.totalCost??null;if(r===null&&u===null)return 0;if(r===null)return 1;if(u===null)return-1;return u-r});else if(n===\"duration\")t.sort((i,f)=>{let r=i.duration?kt(i.duration):0;return(f.duration?kt(f.duration):0)-r});else if(n===\"findings\")t.sort((i,f)=>f.findings.length-i.findings.length);return t}function Ct(e){return JSON.stringify(e,(n,t)=>{if(n===\"generatedAt\")return;return t})}function It(e){let n=new Map;for(let f of e){if(f===\"\"){n.set(f,[]);continue}let r=f.split(\"/\"),u=r.length;while(u>0&&r[u-1]===\"\")u--;n.set(f,u>0?r.slice(0,u):[f])}let t=new Map;for(let f of e){let r=n.get(f);t.set(f,r.length?r[r.length-1]:f)}let i=new Map;for(let f of e){let r=t.get(f),u=i.get(r);if(u)u.push(f);else i.set(r,[f])}for(let[,f]of i){let r=[...new Set(f)];if(r.length<2)continue;let u=1;while(u<1024){let l=new Set,c=!0,a=!0;for(let d of r){let g=n.get(d),E=Math.max(0,g.length-u),b=g.slice(E).join(\"/\");if(l.has(b))c=!1;else l.add(b);if(E>0)a=!1}if(c||a)break;u++}for(let l of r){let c=n.get(l),a=Math.max(0,c.length-u);t.set(l,c.slice(a).join(\"/\"))}}return t}function Sn(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 Pt(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 We(e,n){if(!n||n<=0)return 0;return Math.max(0,Math.min(100,e/n*100))}function An(e){let n=e.file+\":\"+e.line+\" — \"+e.description;if(e.scenario)n+=\" (\"+e.scenario+\")\";return n}function Vt(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 ve(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 ze(e){if(!Number.isFinite(e)||e<=0)return\"$0\";return\"$\"+(e>=1?e.toFixed(2):e.toFixed(4))}function Tt(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 jt(e){let n={role:\"execution\",cost:0,messageCount:0,pct:0},t={role:\"auditor\",cost:0,messageCount:0,pct:0};for(let[f,r]of Object.entries(e.byRole??{}))if(f===\"auditor\")t.cost+=r.cost,t.messageCount+=r.messageCount;else if(f===\"code\")n.cost+=r.cost,n.messageCount+=r.messageCount;let i=Math.max(n.cost,t.cost);return n.pct=i>0?n.cost/i*100:0,t.pct=i>0?t.cost/i*100:0,[n,t]}function _t(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 Dt(e,n){if(!e)return\"\";let t=Et(e,n??void 0);return t>0?Se(t):\"\"}function te(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 r=Math.floor(f/24);if(r<30)return r+\"d ago\";return z(e)}function Nn(e){if(!e)return\"\";let n=Ae.get(e);if(n!==void 0)return n;let t=globalThis.marked;if(!t)return\"\";let i=t.parse(e);if(i){if(Ae.size>=wf){let f=Ae.keys().next().value;if(f!==void 0)Ae.delete(f)}Ae.set(e,i)}return i}var Ae=new Map,wf=200;function Ye(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 He(e,n,t,i,f=\"coding\"){let r=[],u=e.findIndex((g)=>g.createdAt>=n),l=u===-1?[]:e.slice(u);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 a=n;for(let g of l){if(g.eventType===\"restart\"){if(g.toPhase!==null)c=g.toPhase;continue}if(c===\"\")c=g.fromPhase,a=Math.max(a,g.createdAt);let E=g.createdAt,b=Math.max(a,E);if(r.push({phase:c,startedAt:a,endedAt:b,durationMs:b-a,open:!1}),g.toPhase===null)c=\"\",a=b;else c=g.toPhase,a=b}if(c!==\"\"){let E=Math.max(a,t??i);r.push({phase:c,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&&r.length>0)r[0]={...r[0],phase:\"\"};return{spans:r,truncated:d}}function Ft(e){let n={};for(let t of e){if(!t.phase)continue;n[t.phase]=(n[t.phase]??0)+t.durationMs}return n}function Ut(e,n,t){let i=[];for(let f=0;f=n)u=Math.max(0,r.createdAt-n);else if(r.createdAt=n)u=Math.max(0,r.createdAt-n);else u=null}i.push({transition:r,elapsedMs:u})}return i.reverse(),i}var gf=[{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}],qe=110,Xe=36,Gt=655,Kt=160,$f=[{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 xf(e){if(e.toPhase===null)return e.fromPhase+\"→terminal\";return e.fromPhase+\"→\"+e.toPhase}function Of(e){return e.toPhase??e.status??\"\"}function Cn(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 Wt(e){let n=()=>e.loop(),t=w(()=>n().status===\"running\"),i=w(()=>{let l=new Map;for(let c of e.transitions()){let a=xf(c);l.set(a,(l.get(a)??0)+1)}return l}),f=$f.map((l)=>Cn(h`\n \n \n ${()=>{let c=i().get(l.key);return c?String(c):\"\"}}\n \n `)),r=gf.map((l)=>{let c=l.cx-qe/2,a=l.cy-Xe/2;return Cn(h`\n \"mg-node\"+(n().phase===l.phase&&t()?\" mg-node-active\":\"\")}>\n \n ${l.label}\n \n `)}),u=Cn(h`\n \n \n ${()=>n().status}\n \n `);return h`
\n \n \n \n \n \n \n\n ${f}\n ${r}\n ${()=>t()?\"\":u}\n \n\n
\n
Transition history
\n
\n ${()=>e.transitions().slice(-20).reverse().map((l)=>h`
\n ${l.eventType}\n ${l.fromPhase} → ${Of(l)}\n ${te(l.createdAt)}\n
`)}\n
\n
\n
`}function qt(e){let[n,t]=F(e.query()),i=null,f=(u)=>{let l=u.target.value;if(t(l),i)clearTimeout(i);i=setTimeout(()=>{i=null,e.onChangeQuery(l)},250)};ue(()=>{if(i)clearTimeout(i),i=null}),B(()=>{e.externalNav();let u=v(()=>e.query());if(i!==null)clearTimeout(i),i=null;t(u)});let r=w(()=>{let u=e.counts();return[{label:\"Running\",key:\"running\",value:u.running},{label:\"Completed\",key:\"completed\",value:u.completed},{label:\"Cancelled\",key:\"cancelled\",value:u.cancelled},{label:\"Errored\",key:\"errored\",value:u.errored},{label:\"Stalled\",key:\"stalled\",value:u.stalled}]});return h`
\n ${()=>r().map(({label:u,key:l,value:c})=>{let d=`badge badge-filter${e.statuses().has(l)?\" badge-active\":\"\"}`;return h`e.onToggleStatus(l)}>${u}: ${c}`})}\n \n \n
`}function Xt(e){return h`
Last updated: ${new Date(e.generatedAt).toLocaleString()}
`}function Zt(e){let n=w(()=>e.entries().map((t)=>{let i=t.loops.some((u)=>u.loop.status===\"running\"),f=t.proj.projectDir||t.proj.projectId||\"\",r=e.labels().get(f)??f;return h`
e.onSelect(t.proj.projectId)}>\n ${i?h``:\"\"}\n ${r}\n ${t.loops.length}\n
`}));return h`
${()=>n()}
`}function Ef(e,n){let t=0,i=0,f=0,r=[],u=[];for(let l of e){let c=l.proj.projectDir||l.proj.projectId||\"\",a=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,E=d.loop.completedAt||d.loop.startedAt||0;if(g===\"running\")t++,r.push({projectId:l.proj.projectId,label:a,loopName:d.loop.loopName,phase:d.loop.phase||\"\"});u.push({projectId:l.proj.projectId,label:a,loopName:d.loop.loopName,status:g,when:E})}}return u.sort((l,c)=>c.when-l.when),{runningCount:t,loopCount:i,bugCount:f,runningCards:r,recentRows:u.slice(0,8)}}function Lt(e){let n=w(()=>Ef(e.entries(),e.labels())),t=w(()=>n().runningCount),i=w(()=>n().loopCount),f=w(()=>n().bugCount),r=w(()=>n().runningCards),u=w(()=>n().recentRows);return h`
\n
\n

All repositories

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

Running

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

Recent activity

\n
\n ${u().map((l)=>h`
e.onOpenLoop(l.projectId,l.loopName)}>\n \"status-badge status-\"+l.status}>${l.status}\n ${l.label}\n ${l.loopName}\n ${z(l.when)}\n
`)}\n
\n
`:\"\"}\n
`}function kf(e){let[n,t]=F(!1),[i,f]=F(\"\"),[r,u]=F(0),l=w(()=>{let s=i().trim().toLowerCase(),x=e.loops();return s?x.filter((k)=>k.name.toLowerCase().indexOf(s)!==-1):x}),c=()=>{if(n())return;f(\"\");let s=e.loops().findIndex((x)=>x.name===e.loopName);u(s<0?0:s),t(!0)},a=()=>{t(!1),f(\"\"),u(0)},d=(s)=>{if(a(),o.blur(),s!==e.loopName)e.onSelect(s)},g=h`n()?\"display:block\":\"display:none\"}\n onmousedown=${(s)=>s.preventDefault()}\n >\n ${()=>l().map((s,x)=>h`\"loop-picker-option\"+(x===r()?\" 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 ${()=>l().length===0?h`
No matching loops
`:\"\"}\n
`,E=()=>{g.querySelector(\".loop-picker-option-active\")?.scrollIntoView?.({block:\"nearest\"})},b=(s)=>{if(s.key===\"ArrowDown\"||s.key===\"ArrowUp\"){if(s.preventDefault(),!n()){c();return}let x=l().length;if(x===0)return;let k=s.key===\"ArrowDown\"?1:-1;u((I)=>(I+k+x)%x),E();return}if(s.key===\"Enter\"){s.preventDefault();let x=l()[r()];if(x)d(x.name);return}if(s.key===\"Escape\")s.preventDefault(),a(),o.blur()},o=h`n()?i():e.loopName}\n onfocus=${c}\n oninput=${(s)=>{f(s.target.value),u(0),t(!0)}}\n onkeydown=${b}\n onblur=${a}\n />`;return h`\n ${o}\n {if(s.preventDefault(),n())a(),o.blur();else o.focus()}}>▾\n ${g}\n `}function Rt(e){if(e.loopName)return h`
\n ← ${e.repoLabel}\n /\n ${kf({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 Jt(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 vt(e){let n=()=>We(e.current(),e.total());return h`\n \"width:\"+n()+\"%\"}>\n ${()=>e.current()}/${()=>e.total()}\n `}var Sf={sm:4,md:14,lg:22};function Pn(e){let n=Sf[e.variant],t=w(()=>e.spans().filter((r)=>r.phase!==\"\")),i=w(()=>Math.max(0,t().reduce((r,u)=>r+u.durationMs,0))),f=w(()=>e.spans().some((r)=>r.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 r=t(),u=i();return r.map((l)=>{let c=u>0?l.durationMs/u*100:0,a=\"phase-seg\"+(l.open?\" phase-seg-open\":\"\");return h`
`})}}\n
`}function Af(e,n){return He(e.transitions??[],e.loop.startedAt,e.loop.completedAt,n,e.loop.phase).spans}function Qt(e){return h`\n \n \n \n \n \n ${()=>e.loops().map((n)=>Nf({dashLoop:n,now:e.now,onOpen:e.onOpen}))}\n \n
StatusLoopPhasePhase spanIterSectionsFindingsCostDurationUpdated
`}function Nf(e){let n=()=>e.dashLoop.loop,t=()=>e.dashLoop,i=w(()=>Sn(t().findings)),f=w(()=>Af(t(),e.now()));return h`e.onOpen(e.dashLoop.loop.loopName)}>\n ye(n().status)}>${()=>n().status}\n ${()=>n().loopName}\n ${()=>n().phase}\n ${()=>Pn({spans:f,variant:\"sm\",now:e.now})}\n ${vt({current:()=>n().iteration,total:()=>n().maxIterations})}\n ${()=>n().totalSections>0?vt({current:()=>n().currentSectionIndex,total:()=>n().totalSections}):h``}\n ${()=>{let r=i();if(r.bugs.length===0&&r.warnings.length===0)return h``;return h`\n ${r.bugs.length>0?h`${be(r.bugs.length,\"bug\")}`:\"\"}\n ${r.bugs.length>0&&r.warnings.length>0?\" · \":\"\"}\n ${r.warnings.length>0?h`${r.warnings.length} warn`:\"\"}\n `}}\n ${()=>t().usage?ze(t().usage.totalCost):h``}\n ${()=>t().duration||\"\"}\n ${()=>z(n().completedAt||n().startedAt)}\n `}function Ce(e){let n=()=>typeof e.label===\"function\"?e.label():e.label,[t,i]=F(!1),f=()=>i((r)=>!r);return h`
\n
\n t()?\"false\":\"true\"}\n onclick=${f}\n onkeydown=${(r)=>{if(r.key!==\"Enter\"&&r.key!==\" \")return;r.preventDefault(),f()}}\n >\n ${()=>t()?\"▸\":\"▾\"}\n

${()=>n()}

\n
\n \"Copy \"+n()+\" as markdown\"}\n onclick=${(r)=>{r.stopPropagation();let u=r.target,l=\"Copy\";navigator.clipboard.writeText(e.src()||\"\").then(()=>{u.textContent=\"Copied!\"},()=>{u.textContent=\"Failed\"}).then(()=>{setTimeout(()=>{u.textContent=l},2000)})}}\n >Copy\n
\n
t()?\"display:none\":\"display:block\"}>\n
Nn(e.src()||\"\")}>
\n
\n
`}function In(e){return h`
\n
${e.label}
\n
Nn(e.value()||\"\")}>
\n
`}function Cf(e){let n=e.sec,t=w(()=>!!n.startedAt||!!n.completedAt),i=w(()=>!!n.summaryDone),f=w(()=>!!n.summaryDeviations),r=w(()=>!!n.summaryFollowUps),u=w(()=>!!n.content),l=w(()=>t()||i()||f()||r()||u());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()?In({label:\"Done\",value:()=>n.summaryDone}):\"\"}\n ${()=>f()?In({label:\"Deviations\",value:()=>n.summaryDeviations}):\"\"}\n ${()=>r()?In({label:\"Follow-ups\",value:()=>n.summaryFollowUps}):\"\"}\n ${()=>u()?Ce({label:\"Section Plan\",src:()=>n.content}):\"\"}\n ${()=>l()?\"\":h`
No details captured for this section yet.
`}\n
`}function If(e){let n=e.sec,t=w(()=>Dt(n.startedAt,n.completedAt));return h`
\"section-list-row section-item-\"+n.status} onclick=${e.onOpen}>\n #${n.sectionIndex}\n ${()=>n.title}\n Ke(n.status)}>${()=>n.status}\n ${()=>t()?h`${()=>t()}`:\"\"}\n ${()=>n.attempts>0?h`${()=>n.attempts} attempts`:\"\"}\n \n
`}function Pf(e){let[n,t]=F(null),i=w(()=>{let f=n();if(f===null)return null;return e.sections().find((r)=>r.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 Ke(f.status)}>${()=>f.status}\n
\n ${Cf({sec:f})}\n
`;return h`
\n ${e.sections().map((r)=>If({sec:r,onOpen:()=>t(r.sectionIndex)}))}\n
`}}\n
`}function we(e){return h`
\n ${e.label}\n ${()=>e.value()}\n
`}function zt(e){let n=()=>We(e.current(),e.total());return h`
\n
\n ${e.label}\n ${()=>e.current()} / ${()=>e.total()}\n
\n
\n
\"width:\"+n()+\"%\"}>
\n
\n
`}function Vf(e){let n=()=>e.dashLoop.loop,t=()=>e.dashLoop,i=w(()=>!!n().completedAt),f=w(()=>!!t().duration),r=w(()=>n().totalSections>0),u=w(()=>!!n().terminationReason),l=w(()=>Pt(e.split())),c=w(()=>!!e.dashLoop.usage);return h`
\n
\"ldh-findings ldh-findings-\"+l()}>\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(be(a.bugs.length,\"bug\"));if(a.warnings.length>0)d.push(be(a.warnings.length,\"warning\"));return d.join(\" · \")}}\n
\n
\n ye(n().status)}>${()=>n().status}\n

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

\n ${()=>n().phase}\n
\n\n
\n ${we({label:\"Started\",value:()=>z(n().startedAt)})}\n ${()=>i()?we({label:\"Completed\",value:()=>z(n().completedAt)}):\"\"}\n ${()=>f()?we({label:\"Duration\",value:()=>t().duration||\"\"}):\"\"}\n ${we({label:\"Iteration\",value:()=>n().iteration+\" / \"+n().maxIterations})}\n ${()=>r()?we({label:\"Section\",value:()=>n().currentSectionIndex+\" / \"+n().totalSections}):\"\"}\n ${()=>c()?we({label:\"Messages\",value:()=>String(e.dashLoop.usage.totalMessageCount)}):\"\"}\n
\n\n
\n ${zt({label:\"Iterations\",current:()=>n().iteration,total:()=>n().maxIterations})}\n ${()=>r()?zt({label:\"Sections\",current:()=>n().currentSectionIndex,total:()=>n().totalSections}):\"\"}\n
\n\n ${()=>u()?h`
\"ldh-banner ldh-banner-\"+n().status}>${()=>n().terminationReason}
`:\"\"}\n
`}function Tf(e){let n=()=>e.usage(),t=w(()=>Tt(n())),i=w(()=>_t(n())),f=w(()=>i().length>0),r=w(()=>jt(n()));return h`
\n

Usage

\n\n
\n
Role split
\n
\n ${()=>r().map((u)=>h`
u.role}>\n
\n u.role}>${u.role}\n ${()=>ze(u.cost)}\n
\n
\n
\"width:\"+u.pct+\"%\"}>
\n
\n
${()=>u.messageCount+\" msg\"}
\n
`)}\n
\n
\n\n
\n
Token composition
\n
\n ${()=>t().map((u)=>u.pct>0?h`
`:\"\")}\n
\n
\n ${()=>t().map((u)=>h`
\n \n ${u.label}\n ${ve(u.value)}\n
`)}\n
\n
\n\n ${()=>f()?h`
\n
Cost by model
\n
\n ${()=>i().map((u)=>h`
\n
\n ${u.model}\n ${ze(u.cost)}\n
\n
\n
\n
\n
\n ${ve(u.inputTokens)} in / ${ve(u.outputTokens)} out · ${u.messageCount} msg\n
\n
`)}\n
\n
`:\"\"}\n
`}function Yt(e){try{return JSON.parse(e)}catch{return[]}}function Ht(e){return\"#\"+e.index+\" \"+e.title}function jf(e){let n=e.amendment,t=w(()=>Yt(n.sectionsBefore)),i=w(()=>Yt(n.sectionsAfter));return h`
\n
\n ${()=>te(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`
${()=>Ht(f)}
`)}\n
`:\"\"}\n ${()=>i().length>0?h`
\n
After
\n ${()=>i().map((f)=>h`
${()=>Ht(f)}
`)}\n
`:\"\"}\n
\n
\n
`}function _f(e){let n=()=>e.amendments(),[t,i]=F(null);return h`
\n

Plan amendments

\n
\n ${()=>n().map((f)=>jf({amendment:f,expanded:()=>t()===f.id,onToggle:()=>i(t()===f.id?null:f.id)}))}\n
\n
`}var Df=[\"overview\",\"timeline\",\"sections\",\"findings\",\"plan\",\"usage\"],Ff={overview:\"Overview\",timeline:\"Timeline\",sections:\"Sections\",findings:\"Findings\",plan:\"Plan\",usage:\"Usage\"};function Uf(e){return h`
\n ${()=>e.tabs().map((n)=>{let t=Ff[n];return h`\"tab-item\"+(e.activeTab()===n?\" tab-active\":\"\")} data-tab=\"${n}\" onclick=${()=>e.onSelect(n)}>${t}`})}\n
`}function ie(e){return h`
\n ${e.label}\n ${()=>e.value()??\"—\"}\n
`}function Gf(e){let n=()=>e.dashLoop,t=()=>n().loop,i=()=>e.split(),f=w(()=>!!n().goal),r=w(()=>i().bugs.length>0),u=w(()=>i().warnings.length>0),l=w(()=>r()||u()),c=w(()=>n().postActionReport??t().completionSummary??null),a=w(()=>n().postActionReport?\"Post-Action Report\":\"Completion Summary\"),d=w(()=>!!c()),g=w(()=>!!n().lastAuditResult),E=w(()=>He(n().transitions??[],t().startedAt,t().completedAt,e.now(),t().phase));return h`
\n ${()=>Pn({spans:()=>E().spans,variant:\"md\",now:e.now})}\n ${()=>f()?Ce({label:\"Goal\",src:()=>n().goal}):\"\"}\n ${Vf({dashLoop:e.dashLoop,split:e.split})}\n
\n ${ie({label:\"Execution model\",value:()=>t().executionModel})}\n ${ie({label:\"Auditor model\",value:()=>t().auditorModel})}\n ${ie({label:\"Branch\",value:()=>t().worktreeBranch})}\n ${ie({label:\"Sandbox\",value:()=>t().sandbox?\"on\":\"off\"})}\n ${ie({label:\"Kind\",value:()=>t().kind})}\n ${ie({label:\"Audits\",value:()=>String(t().auditCount)})}\n ${ie({label:\"Errors\",value:()=>String(t().errorCount)})}\n
\n
\n ${()=>l()?h`
\n ${()=>r()?h`${()=>be(i().bugs.length,\"bug\")}`:\"\"}\n ${()=>u()?h`${()=>be(i().warnings.length,\"warning\")}`:\"\"}\n
`:h`No findings`}\n
\n ${()=>d()?Ce({label:a,src:()=>c()}):\"\"}\n ${()=>g()?Ce({label:\"Last Audit Result\",src:()=>n().lastAuditResult}):\"\"}\n
`}function Kf(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 ${te(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?Se(i):\"0s\"}\n
`}function Wf(e){let n=()=>e.dashLoop,t=()=>n().loop,i=()=>n().transitions??[],f=w(()=>He(i(),t().startedAt,t().completedAt,e.now(),t().phase)),r=w(()=>f().spans),u=w(()=>f().truncated),l=w(()=>Ft(r())),c=w(()=>r().filter((s)=>s.phase!==\"\").reduce((s,x)=>s+x.durationMs,0)),a=w(()=>{let s=l();return Object.keys(s).filter((x)=>x!==\"\").sort((x,k)=>s[k]-s[x])}),d=w(()=>Ut(i(),t().startedAt,u())),[g,E]=F(!1),b=w(()=>{let s=d();return g()?s:s.slice(0,20)}),o=w(()=>g()?0:Math.max(0,d().length-20));return h`
\n ${Wt({loop:()=>t(),transitions:i})}\n ${()=>Pn({spans:r,variant:\"lg\",now:e.now})}\n ${()=>u()?h`
Earlier history was truncated by the 100-row fetch window.
`:\"\"}\n\n
\n ${()=>a().map((s)=>{let x=l()[s],k=c()>0?Math.round(x/c()*100):0;return h`
\n \n ${s}\n \n ${()=>x>=1000?Se(Math.floor(x/1000)):x+\"ms\"} · ${k}%\n
`})}\n
\n\n
\n ${()=>b().map((s)=>Kf({ev:s}))}\n ${()=>o()>0?h`
E(!0)}>[ ${()=>o()} earlier events ]
`:\"\"}\n
\n
`}function vf(e){return Pf({sections:()=>e.dashLoop.sections})}function zf(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 Yf(e){let n=()=>e.dashLoop,t=w(()=>n().findings.length>0),i=w(()=>zf(n().findings));return h`
\n ${()=>t()?i().map((f)=>h`
f.key===null?\"cross\":String(f.key)}>\n
${f.label}
\n
\n ${f.rows.map((r)=>h`
\"finding finding-\"+r.severity}>\n ${()=>te(r.createdAt)}\n ${()=>An(r)}\n
`)}\n
\n
`):h`
No findings recorded for this loop.
`}\n
`}function Hf(e){let n=()=>e.dashLoop,t=w(()=>!!n().plan),i=w(()=>!!n().amendments&&n().amendments.length>0);return h`
\n ${()=>t()?Ce({label:\"Plan\",src:()=>n().plan}):h`
No plan recorded for this loop.
`}\n ${()=>i()?_f({amendments:()=>n().amendments}):\"\"}\n
`}function qf(e){let n=()=>e.dashLoop,t=w(()=>!!n().usage);return h`
\n ${()=>t()?Tf({usage:()=>n().usage}):h`
No usage recorded for this loop.
`}\n
`}function Xf(e){let n=w(()=>e.tabs().includes(e.tab)&&e.activeTab()===e.tab),t=(()=>{switch(e.tab){case\"overview\":return Gf({dashLoop:e.dashLoop,split:e.split,now:e.now});case\"timeline\":return Wf({dashLoop:e.dashLoop,now:e.now});case\"sections\":return vf({dashLoop:e.dashLoop});case\"findings\":return Yf({dashLoop:e.dashLoop});case\"plan\":return Hf({dashLoop:e.dashLoop});case\"usage\":return qf({dashLoop:e.dashLoop})}})();return h`
n()?\"display:block\":\"display:none\"}>\n ${t}\n
`}function Zf(e){return h`
\n ${Df.map((n)=>Xf({tab:n,dashLoop:e.dashLoop,split:e.split,now:e.now,tabs:e.tabs,activeTab:e.activeTab}))}\n
`}function Bt(e){let n=()=>e.dashLoop,t=w(()=>Sn(n().findings)),i=w(()=>Ye(n())),f=w(()=>{let r=e.routeTab();return i().includes(r)?r:\"overview\"});return h`
\n
\n ${Uf({tabs:i,activeTab:f,onSelect:e.onSelectTab})}\n ${Zf({dashLoop:e.dashLoop,split:t,now:e.now,tabs:i,activeTab:f})}\n
\n
`}function Vn(){return h`
No loops match the current filters.
`}var mt=new Set([\"completed\",\"cancelled\",\"errored\"]);function Ze(e){let n=e.length;return{completed:e.filter((i)=>i.stage===\"completed\").length,total:n}}function Lf(e){return\"group-row\"+(mt.has(e)?\" group-row-terminal\":\" group-row-active\")}function Mt(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;return h`
Lf(i.status)} data-group-status=${()=>i.status} onclick=${()=>e.onOpen(i.groupId)}>\n ye(i.status)}>${()=>i.status}\n ${()=>i.title}\n \n \"width:\"+We(Ze(t.features).completed,Ze(t.features).total)+\"%\"}>\n ${()=>{let f=Ze(t.features);return f.completed+\"/\"+f.total}}\n \n max ${()=>i.maxConcurrent}\n \n Created ${()=>z(i.createdAt)}\n ${()=>i.completedAt?h`Completed ${()=>z(i.completedAt)}`:\"\"}\n \n
`})}\n
`}}\n
`}function Rf(e){return\"group-header\"+(mt.has(e)?\" group-header-terminal\":\" group-header-active\")}function pt(e){return h`
\n
← Back to groups
\n ${()=>{let n=e.group();if(!n)return h`
Group not found.
`;let t=n.group;return h`
Rf(t.status)} data-group-status=${()=>t.status}>\n
\n ye(t.status)}>${()=>t.status}\n

${()=>t.title}

\n max ${()=>t.maxConcurrent}\n
\n
\n ${()=>{let i=Ze(n.features);return\"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((i)=>{return h`
i.stage}>\n #${()=>i.featureIndex}\n ${()=>i.title}\n Ke(i.stage)}>${()=>i.stage}\n ${()=>i.attempts} attempts\n ${()=>!!i.loopName&&e.loopNames().has(i.loopName)?h`Ne({projectId:e.projectId(),section:\"loops\",loopName:i.loopName,tab:\"overview\",groupId:null,statuses:[],query:\"\"})} onclick=${(f)=>{if(f.defaultPrevented||f.button!==0||f.metaKey||f.ctrlKey||f.shiftKey||f.altKey)return;f.preventDefault(),e.onOpenLoop(i.loopName)}}>${()=>i.loopName}`:\"\"}\n ${()=>i.error?h`${()=>i.error}`:\"\"}\n
`})}\n
`}}\n
`}function Jf(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 ei(e){let n=w(()=>Jf(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
`}function ni(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 ye(i.loop.status)}>${()=>i.loop.status}\n ${()=>i.loop.loopName}\n \n ${()=>i.findings.length+\" finding\"+(i.findings.length===1?\"\":\"s\")}\n \n iter ${()=>i.loop.iteration}/${()=>i.loop.maxIterations}\n
`)}\n
`}}\n
`}function ti(){let[e,n]=dt({generatedAt:0,projects:[],totals:{projects:0,loops:0,running:0,completed:0,cancelled:0,errored:0,stalled:0}}),[t,i]=F(!1),[f,r]=F(Ge(\"\")),[u,l]=F(null),[c,a]=F(\"recent\"),[d,g]=F(0),[E,b]=F(Date.now()),o={current:\"\"},s={current:!1},x=async()=>{try{let $=await(await fetch(\"/api/data\",{cache:\"no-store\"})).json(),P=Ct($);if(P!==o.current)o.current=P,n(at($,{key:\"id\"})),i(!0),l(null);b(Date.now())}catch(y){let $=y instanceof Error?y.message:String(y);l(\"Failed to load dashboard data: \"+$)}},k=(y)=>{r(($)=>{let P={...$,...y};if(y.projectId!==void 0&&y.projectId!==$.projectId)P.statuses=[],P.query=\"\";return P})},I=(y)=>{k({tab:y})},A=(y)=>{k({loopName:y,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})},S=(y)=>{let $=new Set(f().statuses);if($.has(y))$.delete(y);else $.add(y);k({statuses:[...$]})},N=(y)=>{k({query:y})},V=(y)=>{a(y)},T=w(()=>new Set(f().statuses)),K=w(()=>It(e.projects.map((y)=>y.projectDir||y.projectId||\"\"))),j=w(()=>{if(!t()||e.projects.length===0)return[];let y=T(),$=f().query,P=K(),U=[],ee=$.trim().toLowerCase();for(let fe of e.projects){let Qe=[],_n=fe.projectDir||fe.projectId||\"\",Dn=P.get(_n)??_n;for(let Fn of fe.loops)if(At(Fn.loop,y,$,Dn))Qe.push(Fn);let Oi=(fe.groups?.length??0)>0;if(Qe.length>0)U.push({proj:fe,loops:Qe});else if(Oi&&(!ee||Dn.toLowerCase().indexOf(ee)!==-1))U.push({proj:fe,loops:[]})}return U}),J=w(()=>f().projectId===null),Q=w(()=>f().section===\"groups\"),Ie=w(()=>f().section===\"loops\"),Le=w(()=>f().section===\"findings\"),fi=w(()=>f().section===\"plans\"),Tn=w(()=>{let y=j(),$=f().projectId;if(!$)return null;return y.find((P)=>P.proj.projectId===$)??null}),Re=w(()=>{let y=f();if(!y.projectId||!y.loopName)return null;let $=Z();if(!$)return null;return $.loops.find((P)=>P.loop.loopName===y.loopName)??null}),Z=w(()=>{let y=f().projectId;if(!y)return null;return e.projects.find(($)=>$.projectId===y)??null}),ri=w(()=>{let y=Z(),$=y?.groups?.length??0;if(!y)return{loops:0,groups:$,findings:0,plans:0};let P=0,U=0;for(let ee of y.loops)if(P+=ee.findings.length,ee.plan)U++;return{loops:y.loops.length,groups:$,findings:P,plans:U}}),Je=w(()=>{let y=Z();if(!y)return[];return y.loops.map(($)=>({name:$.loop.loopName,when:$.loop.completedAt||$.loop.startedAt||0})).sort(($,P)=>P.when-$.when||$.name.localeCompare(P.name))}),ui=w(()=>{let y=f().loopName;if(!y)return-1;return Je().findIndex(($)=>$.name===y)}),li=w(()=>{let y=ui();if(y<0)return null;let $=Je();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})}}}),ci=w(()=>{let y=Z(),$={running:0,completed:0,cancelled:0,errored:0,stalled:0};if(!y)return $;for(let P of y.loops){let U=P.loop.status;if(U===\"running\")$.running++;else if(U===\"completed\")$.completed++;else if(U===\"cancelled\")$.cancelled++;else if(U===\"errored\")$.errored++;else if(U===\"stalled\")$.stalled++}return $}),si=w(()=>{let y=f();if(!y.projectId)return\"\";let $=Z();if(!$)return\"\";let P=$.projectDir||$.projectId||\"\",U=K().get(P)??P;return v(()=>Rt({repoLabel:U,projectDir:P,loopName:y.loopName,loops:Je,loopNav:li,onSelectLoop:(ee)=>k({loopName:ee}),onBackToRepos:()=>k({projectId:null,loopName:null,section:\"loops\"}),onBackToRepo:()=>k({loopName:null})}))}),oi=w(()=>{let y=f();if(!y.projectId||y.loopName)return\"\";return Jt({section:y.section,counts:ri(),onNavigate:($)=>k({section:$,loopName:null,groupId:null})})}),di=w(()=>{let y=f();return y.projectId!==null&&y.loopName===null&&y.section===\"loops\"}),ai=w(()=>{if(!di())return\"\";return v(()=>qt({counts:ci,statuses:T,query:()=>f().query,sortMode:c,onToggleStatus:S,onChangeQuery:N,onChangeSort:V,externalNav:d}))}),hi=w(()=>{if(!Ie())return\"\";let y=Re();return y?Bt({dashLoop:y,now:E,routeTab:()=>f().tab,onSelectTab:I}):\"\"}),yi=w(()=>{if(!Ie())return\"\";if(Re())return\"\";if(!f().projectId)return\"\";return h`
\n ${Qt({loops:()=>{let $=Tn();return $?Nt($.loops,c()):[]},now:E,onOpen:($)=>A($)})}\n ${()=>Tn()?\"\":Vn()}\n
`}),bi=w(()=>{let y=f();if(y.section!==\"groups\"||!y.groupId)return null;let $=Z();if(!$)return null;return $.groups.find((P)=>P.group.groupId===y.groupId)??null}),wi=w(()=>{let y=Z();if(!y)return new Set;return new Set(y.loops.map(($)=>$.loop.loopName))}),gi=w(()=>{if(!Q())return\"\";let y=f();if(!y.projectId)return\"\";if(y.groupId)return pt({group:bi,loopNames:wi,projectId:()=>f().projectId,onBack:()=>k({groupId:null}),onOpenLoop:A});return Mt({groups:()=>Z()?.groups??[],onOpen:($)=>k({groupId:$})})}),jn=w(()=>{let y=Z();return y?y.loops:[]}),$i=w(()=>{if(!Le())return\"\";if(!f().projectId)return\"\";return ei({loops:jn,projectId:()=>f().projectId,onOpenLoop:A})}),xi=w(()=>{if(!fi())return\"\";if(!f().projectId)return\"\";return ni({loops:jn,onOpenLoop:A})});return B(()=>{if(!t())return;let y=f();if(y.projectId===null)return;if(!e.projects.some((P)=>P.projectId===y.projectId))r((P)=>({...P,projectId:null,loopName:null,section:\"loops\",statuses:[],query:\"\"}))}),B(()=>{let y=f();if(!y.projectId||!y.loopName)return;let $=Z();if(!$)return;if(!$.loops.some((U)=>U.loop.loopName===y.loopName))r((U)=>({...U,loopName:null}))}),B(()=>{let y=f();if(!y.projectId||!y.loopName||y.tab===\"overview\")return;let $=Re();if(!$)return;if(!Ye($).includes(y.tab))r((U)=>({...U,tab:\"overview\"}))}),B(()=>{if(!t())return;St(Ne(f()),s)}),Zn(()=>{r(Ge(location.hash)),x();let y=setInterval(()=>{x()},5000),$=()=>{if(s.current){s.current=!1;return}r(Ge(location.hash)),g((P)=>P+1)};window.addEventListener(\"hashchange\",$),ue(()=>{clearInterval(y),window.removeEventListener(\"hashchange\",$)})}),h`
\n

Forge Dashboard

\n\n ${()=>{let y=u();return y?h`
${y}
`:\"\"}}\n\n ${()=>{if(!t())return\"\";return h`
\n ${Xt({generatedAt:e.generatedAt})}\n
`}}\n\n ${()=>{if(!t())return\"\";if(J()&&j().length===0)return Vn();return h`
\n ${()=>{if(J())return h`
\n ${Zt({entries:()=>j(),labels:()=>K(),onSelect:(y)=>k({projectId:y,loopName:null,section:\"loops\",groupId:null})})}\n ${Lt({entries:()=>j(),labels:()=>K(),onOpenLoop:(y,$)=>k({projectId:y,loopName:$,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})})}\n
`;return h`
\n ${si}\n ${ai}\n ${oi}\n ${yi}\n ${hi}\n ${gi}\n ${$i}\n ${xi}\n
`}}\n
`}}\n
`}var ii=document.getElementById(\"forge-app-root\");if(ii)it(ti,ii);\n"; -export const DASHBOARD_APP_SOURCE_HASH: string = "ac7c18df928ea542b2130f3505829570052bf592a997a21ce98cbc7de618d872"; +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 \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 = "d6961e824012c482c186851197466fd7da0ebde0f09034072becb662606e85e2"; diff --git a/src/dashboard/app/app.ts b/src/dashboard/app/app.ts index 99beb84221..049884fc79 100644 --- a/src/dashboard/app/app.ts +++ b/src/dashboard/app/app.ts @@ -30,7 +30,6 @@ export function App() { const [state, setState] = createStore({ generatedAt: 0, projects: [], - totals: { projects: 0, loops: 0, running: 0, completed: 0, cancelled: 0, errored: 0, stalled: 0 }, }) const [loaded, setLoaded] = createSignal(false) const [route, setRoute] = createSignal(parseDashboardHash('')) diff --git a/src/dashboard/app/components.ts b/src/dashboard/app/components.ts index 9462f14792..bfd2eb737d 100644 --- a/src/dashboard/app/components.ts +++ b/src/dashboard/app/components.ts @@ -3,9 +3,11 @@ import { createMemo, createSignal, createEffect, onCleanup, untrack } from 'soli import type { DashboardLoop, DashboardProject, DashboardGroup } from './types' import type { GroupFeatureRow } from '../../storage' import type { RepoSection, SortMode } from './helpers' +import type { MarkdownHeading } from './helpers' import { statusClass, sectionStatusClass, + featureStageClass, fmtTime, formatSectionDuration, splitFindings, @@ -20,6 +22,7 @@ import { modelUsageBars, roleUsageBars, renderMarkdown, + renderMarkdownWithOutline, formatRelativeTime, tabsForLoop, computePhaseSpans, @@ -213,7 +216,7 @@ export function RepoIndexPane(props: {

Recent activity

${recentRows().map(r => html`
props.onOpenLoop(r.projectId, r.loopName)}> - 'status-badge status-' + r.status}>${r.status} + statusClass(r.status)}>${r.status} ${r.label} ${r.loopName} ${fmtTime(r.when)} @@ -419,7 +422,6 @@ const PHASE_BAR_HEIGHTS: Record = { sm: 4, md: 14, lg: function PhaseBar(props: { spans: () => PhaseSpan[] variant: PhaseBarVariant - now: () => number }) { const height = PHASE_BAR_HEIGHTS[props.variant] const visible = createMemo(() => props.spans().filter(s => s.phase !== '')) @@ -439,14 +441,22 @@ function PhaseBar(props: {
` } -function loopPhaseSpans(loop: DashboardLoop, now: number): PhaseSpan[] { +// Single entry point for a loop's phase spans. `now` is only read while the +// loop is in flight: a completed loop's spans are fully determined by its +// transitions, so tracking now() would recompute the spans of every table row +// on each 5s poll tick for the lifetime of the page. +function loopPhaseState( + loop: DashboardLoop, + now: () => number, +): { spans: PhaseSpan[]; truncated: boolean } { + const lp = loop.loop return computePhaseSpans( loop.transitions ?? [], - loop.loop.startedAt, - loop.loop.completedAt, - now, - loop.loop.phase, - ).spans + lp.startedAt, + lp.completedAt, + lp.completedAt === null ? now() : 0, + lp.phase, + ) } export function LoopTable(props: { loops: () => DashboardLoop[]; now: () => number; onOpen: (name: string) => void }) { @@ -465,12 +475,12 @@ function LoopTableRow(props: { dashLoop: DashboardLoop; now: () => number; onOpe const lp = () => props.dashLoop.loop const dl = () => props.dashLoop const counts = createMemo(() => splitFindings(dl().findings)) - const spans = createMemo(() => loopPhaseSpans(dl(), props.now())) + const spans = createMemo(() => loopPhaseState(dl(), props.now).spans) return html` props.onOpen(props.dashLoop.loop.loopName)}> statusClass(lp().status)}>${() => lp().status} ${() => lp().loopName} ${() => lp().phase} - ${() => PhaseBar({ spans, variant: 'sm', now: props.now })} + ${() => PhaseBar({ spans, variant: 'sm' })} ${MiniMeter({ current: () => lp().iteration, total: () => lp().maxIterations })} ${() => (lp().totalSections > 0 ? MiniMeter({ current: () => lp().currentSectionIndex, total: () => lp().totalSections }) @@ -481,7 +491,7 @@ function LoopTableRow(props: { dashLoop: DashboardLoop; now: () => number; onOpe return html` ${c.bugs.length > 0 ? html`${formatFindingCount(c.bugs.length, 'bug')}` : ''} ${c.bugs.length > 0 && c.warnings.length > 0 ? ' · ' : ''} - ${c.warnings.length > 0 ? html`${c.warnings.length} warn` : ''} + ${c.warnings.length > 0 ? html`${formatFindingCount(c.warnings.length, 'warn')}` : ''} ` }} ${() => (dl().usage ? formatUsageCost(dl().usage!.totalCost) : html``)} @@ -501,6 +511,8 @@ function MarkdownSection(props: { label: string | (() => string); src: () => str const label = () => (typeof props.label === 'function' ? props.label() : props.label) const [collapsed, setCollapsed] = createSignal(false) const toggle = () => setCollapsed(c => !c) + const rendered = createMemo(() => renderMarkdownWithOutline(props.src() || '')) + const toc = createMemo(() => rendered().outline.filter(h => h.depth <= 3)) return html`
string); src: () => str >Copy
(collapsed() ? 'display:none' : 'display:block')}> -
renderMarkdown(props.src() || '')}>
+ ${() => { + const items = toc() + return items.length >= 2 ? MarkdownToc({ items: () => items }) : null + }} +
rendered().html}>
` } +// Floating table of contents for a markdown body. Renders only when the parent +// gates it (>= 2 h1-h3 headings). Jump links scroll the matching heading into +// view via the id injected by renderMarkdownWithOutline. +// +// Heading ids are slugs of the heading text, so the same slug can occur in +// several markdown sections and in several (kept-mounted, display:none) tabs. +// The lookup is therefore scoped to the owning .markdown-body instead of using +// document.getElementById, which would resolve the first match anywhere in the +// document — often inside a hidden tab, where scrollIntoView is a no-op. +function MarkdownToc(props: { items: () => MarkdownHeading[] }) { + const jump = (e: MouseEvent, id: string) => { + e.preventDefault() + const scope = (e.currentTarget as HTMLElement).closest('.markdown-body') + const el = scope + ? Array.from(scope.querySelectorAll('h1,h2,h3,h4,h5,h6')).find(h => h.id === id) + : document.getElementById(id) + if (el && typeof el.scrollIntoView === 'function') { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + } + return html`` +} + // ── Sections ────────────────────────────────────────────────────────────── // A labeled markdown summary block (Done / Deviations / Follow-ups). @@ -694,6 +740,13 @@ function LoopDetailHeader(props: { ${LoopDetailStat({ label: 'Iteration', value: () => lp().iteration + ' / ' + lp().maxIterations })} ${() => (hasSectionsTotal() ? LoopDetailStat({ label: 'Section', value: () => lp().currentSectionIndex + ' / ' + lp().totalSections }) : '')} ${() => (hasUsage() ? LoopDetailStat({ label: 'Messages', value: () => String(props.dashLoop.usage!.totalMessageCount) }) : '')} + ${LoopDetailStat({ label: 'Execution model', value: () => lp().executionModel ?? '—' })} + ${LoopDetailStat({ label: 'Auditor model', value: () => lp().auditorModel ?? '—' })} + ${LoopDetailStat({ label: 'Branch', value: () => lp().worktreeBranch ?? '—' })} + ${LoopDetailStat({ label: 'Sandbox', value: () => (lp().sandbox ? 'on' : 'off') })} + ${LoopDetailStat({ label: 'Kind', value: () => lp().kind ?? '—' })} + ${LoopDetailStat({ label: 'Audits', value: () => String(lp().auditCount) })} + ${LoopDetailStat({ label: 'Errors', value: () => String(lp().errorCount) })}
@@ -894,49 +947,19 @@ function TabBar(props: {
` } -function OverviewMetaCell(props: { label: string; value: () => string | null | undefined }) { - return html`
- ${props.label} - ${() => props.value() ?? '—'} -
` -} - function OverviewTabBody(props: { dashLoop: DashboardLoop; split: () => Split; now: () => number }) { const dl = () => props.dashLoop const lp = () => dl().loop - const split = () => props.split() const hasGoal = createMemo(() => !!dl().goal) - const hasBugs = createMemo(() => split().bugs.length > 0) - const hasWarnings = createMemo(() => split().warnings.length > 0) - const hasFindings = createMemo(() => hasBugs() || hasWarnings()) const reportSrc = createMemo(() => dl().postActionReport ?? lp().completionSummary ?? null) const reportLabel = createMemo(() => dl().postActionReport ? 'Post-Action Report' : 'Completion Summary') const hasReport = createMemo(() => !!reportSrc()) const hasAudit = createMemo(() => !!dl().lastAuditResult) - const phaseState = createMemo(() => - computePhaseSpans(dl().transitions ?? [], lp().startedAt, lp().completedAt, props.now(), lp().phase), - ) + const phaseState = createMemo(() => loopPhaseState(dl(), props.now)) return html`
- ${() => PhaseBar({ spans: () => phaseState().spans, variant: 'md', now: props.now })} + ${() => PhaseBar({ spans: () => phaseState().spans, variant: 'md' })} ${() => (hasGoal() ? MarkdownSection({ label: 'Goal', src: () => dl().goal }) : '')} ${LoopDetailHeader({ dashLoop: props.dashLoop, split: props.split })} -
- ${OverviewMetaCell({ label: 'Execution model', value: () => lp().executionModel })} - ${OverviewMetaCell({ label: 'Auditor model', value: () => lp().auditorModel })} - ${OverviewMetaCell({ label: 'Branch', value: () => lp().worktreeBranch })} - ${OverviewMetaCell({ label: 'Sandbox', value: () => (lp().sandbox ? 'on' : 'off') })} - ${OverviewMetaCell({ label: 'Kind', value: () => lp().kind })} - ${OverviewMetaCell({ label: 'Audits', value: () => String(lp().auditCount) })} - ${OverviewMetaCell({ label: 'Errors', value: () => String(lp().errorCount) })} -
-
- ${() => (hasFindings() - ? html`
- ${() => (hasBugs() ? html`${() => formatFindingCount(split().bugs.length, 'bug')}` : '')} - ${() => (hasWarnings() ? html`${() => formatFindingCount(split().warnings.length, 'warning')}` : '')} -
` - : html`No findings`)} -
${() => (hasReport() ? MarkdownSection({ label: reportLabel, src: () => reportSrc() }) : '')} ${() => (hasAudit() ? MarkdownSection({ label: 'Last Audit Result', src: () => dl().lastAuditResult }) : '')}
` @@ -963,9 +986,7 @@ function TimelineTabBody(props: { dashLoop: DashboardLoop; now: () => number }) const dl = () => props.dashLoop const lp = () => dl().loop const transitions = () => dl().transitions ?? [] - const phaseState = createMemo(() => - computePhaseSpans(transitions(), lp().startedAt, lp().completedAt, props.now(), lp().phase), - ) + const phaseState = createMemo(() => loopPhaseState(dl(), props.now)) const spans = createMemo(() => phaseState().spans) const truncated = createMemo(() => phaseState().truncated) const totals = createMemo(() => summarizePhaseTotals(spans())) @@ -991,7 +1012,7 @@ function TimelineTabBody(props: { dashLoop: DashboardLoop; now: () => number }) return html`
${LoopMachineGraph({ loop: () => lp(), transitions })} - ${() => PhaseBar({ spans, variant: 'lg', now: props.now })} + ${() => PhaseBar({ spans, variant: 'lg' })} ${() => (truncated() ? html`
Earlier history was truncated by the 100-row fetch window.
` : '')} @@ -1049,6 +1070,43 @@ function groupFindingsBySection(findings: DashboardLoop['findings']): Array<{ ke })) } +// The single renderer for a block of finding rows, shared by the per-loop +// Findings tab and the repo-level Findings section. +function FindingRows(props: { rows: DashboardLoop['findings'] }) { + return html`
+ ${props.rows.map(f => html`
'finding finding-' + f.severity}> + ${() => formatRelativeTime(f.createdAt)} + ${() => formatFinding(f)} +
`)} +
` +} + +// The single renderer for an anchor that opens a loop in-app. Owns both the +// deep-link hash and the modifier-key guard that lets browser-native +// open-in-new-tab keep working. +function LoopLink(props: { + className: string + projectId: () => string | null + loopName: string + onOpen: (loopName: string) => void +}) { + const href = () => + buildDashboardHash({ + projectId: props.projectId(), + section: 'loops', + loopName: props.loopName, + tab: 'overview', + groupId: null, + statuses: [], + query: '', + }) + return html` { + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return + e.preventDefault() + props.onOpen(props.loopName) + }}>${props.loopName}` +} + function FindingsTabBody(props: { dashLoop: DashboardLoop }) { const dl = () => props.dashLoop const hasFindings = createMemo(() => dl().findings.length > 0) @@ -1057,12 +1115,7 @@ function FindingsTabBody(props: { dashLoop: DashboardLoop }) { ${() => (hasFindings() ? groups().map(g => html`
g.key === null ? 'cross' : String(g.key)}>
${g.label}
-
- ${g.rows.map(f => html`
'finding finding-' + f.severity}> - ${() => formatRelativeTime(f.createdAt)} - ${() => formatFinding(f)} -
`)} -
+ ${FindingRows({ rows: g.rows })}
`) : html`
No findings recorded for this loop.
`)}
` @@ -1188,13 +1241,11 @@ export function GroupsPanel(props: { return html`
${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)}> statusClass(gr.status)}>${() => gr.status} ${() => gr.title} - - 'width:' + clampPercent(groupFeaturesMeter(g.features).completed, groupFeaturesMeter(g.features).total) + '%'}> - ${() => { const m = groupFeaturesMeter(g.features); return m.completed + '/' + m.total }} - + ${MiniMeter({ current: () => meter().completed, total: () => meter().total })} max ${() => gr.maxConcurrent} Created ${() => fmtTime(gr.createdAt)} @@ -1225,40 +1276,39 @@ export function GroupDetail(props: { const dg = props.group() if (!dg) return html`
Group not found.
` const gr = dg.group - return html`
groupHeaderClass(gr.status)} data-group-status=${() => gr.status}> -
- statusClass(gr.status)}>${() => gr.status} -

${() => gr.title}

- max ${() => gr.maxConcurrent} -
-
- ${() => { const m = groupFeaturesMeter(dg.features); return 'Features ' + m.completed + '/' + m.total }} - Created ${() => fmtTime(gr.createdAt)} - ${() => (gr.completedAt ? html`Completed ${() => fmtTime(gr.completedAt)}` : '')} - ${() => (gr.error ? html`${() => gr.error}` : '')} + const meter = createMemo(() => groupFeaturesMeter(dg.features)) + // 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}> +
+ statusClass(gr.status)}>${() => gr.status} +

${() => gr.title}

+ max ${() => gr.maxConcurrent} +
+
+ ${() => 'Features ' + meter().completed + '/' + meter().total} + Created ${() => fmtTime(gr.createdAt)} + ${() => (gr.completedAt ? html`Completed ${() => fmtTime(gr.completedAt)}` : '')} + ${() => (gr.error ? html`${() => gr.error}` : '')} +
+ ${() => (gr.prdPreview ? html`
+
+
${() => gr.prdPreview || ''}
+
` : '')}
- ${() => (gr.prdPreview ? html`
-
-
${() => gr.prdPreview || ''}
-
` : '')} -
-
- ${dg.features.map(f => { - return html`
f.stage}> +
+ ${dg.features.map(f => html`
f.stage}> #${() => f.featureIndex} ${() => f.title} - sectionStatusClass(f.stage)}>${() => f.stage} + featureStageClass(f.stage)}>${() => f.stage} ${() => f.attempts} attempts ${() => (!!f.loopName && props.loopNames().has(f.loopName) - ? html` buildDashboardHash({ projectId: props.projectId(), section: 'loops', loopName: f.loopName, tab: 'overview', groupId: null, statuses: [], query: '' })} onclick=${(e: MouseEvent) => { - if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return - e.preventDefault() - props.onOpenLoop(f.loopName!) - }}>${() => f.loopName}` + ? LoopLink({ className: 'feature-loop-link', projectId: props.projectId, loopName: f.loopName, onOpen: props.onOpenLoop }) : '')} ${() => (f.error ? html`${() => f.error}` : '')} -
` - })} +
`)} +
` }}
` @@ -1293,19 +1343,10 @@ export function FindingsPanel(props: { return html`
${gs.map(g => html`
g.loopName}> -
- ${g.rows.map(f => html`
'finding finding-' + f.severity}> - ${() => formatRelativeTime(f.createdAt)} - ${() => formatFinding(f)} -
`)} -
+ ${FindingRows({ rows: g.rows })}
`)}
` }} @@ -1327,7 +1368,7 @@ export function PlansPanel(props: { statusClass(dl.loop.status)}>${() => dl.loop.status} ${() => dl.loop.loopName} - ${() => dl.findings.length + ' finding' + (dl.findings.length === 1 ? '' : 's')} + ${() => formatFindingCount(dl.findings.length, 'finding')} iter ${() => dl.loop.iteration}/${() => dl.loop.maxIterations}
`)} diff --git a/src/dashboard/app/helpers.ts b/src/dashboard/app/helpers.ts index f9691a7eeb..65a109a551 100644 --- a/src/dashboard/app/helpers.ts +++ b/src/dashboard/app/helpers.ts @@ -171,6 +171,14 @@ export function sectionStatusClass(s: string): string { return 'section-status section-' + s } +// Group features have their own stage vocabulary (pending/planning/planned/ +// launching/running/completed/failed/cancelled) that only partly overlaps the +// section-status vocabulary, so it gets its own class family rather than +// borrowing `section-*` and rendering unstyled for the non-overlapping stages. +export function featureStageClass(stage: string): string { + return 'feature-stage feature-stage-' + stage +} + export type SortMode = 'recent' | 'cost' | 'duration' | 'findings' export function loopMatchesFilters( @@ -190,18 +198,6 @@ export function loopMatchesFilters( return hay.indexOf(q) !== -1 } -function parseDurationSeconds(s: string): number { - if (!s) return 0 - let total = 0 - const h = s.match(/(\d+)\s*h/i) - if (h) total += parseInt(h[1], 10) * 3600 - const m = s.match(/(\d+)\s*m(?!s)/i) - if (m) total += parseInt(m[1], 10) * 60 - const sec = s.match(/(\d+)\s*s/i) - if (sec) total += parseInt(sec[1], 10) - return total -} - export function sortLoops(loops: DashboardLoop[], mode: SortMode): DashboardLoop[] { if (mode === 'recent') return [...loops] const sorted = [...loops] @@ -216,8 +212,8 @@ export function sortLoops(loops: DashboardLoop[], mode: SortMode): DashboardLoop }) } else if (mode === 'duration') { sorted.sort((a, b) => { - const ad = a.duration ? parseDurationSeconds(a.duration) : 0 - const bd = b.duration ? parseDurationSeconds(b.duration) : 0 + const ad = computeElapsedSeconds(a.loop.startedAt, a.loop.completedAt ?? undefined) + const bd = computeElapsedSeconds(b.loop.startedAt, b.loop.completedAt ?? undefined) return bd - ad }) } else if (mode === 'findings') { @@ -226,11 +222,14 @@ export function sortLoops(loops: DashboardLoop[], mode: SortMode): DashboardLoop return sorted } +/** + * Change key for a poll response, used to skip reconciliation when nothing + * moved. `generatedAt` is excluded because it changes on every poll; serializing + * only `projects` also keeps this on V8's fast stringify path (a replacer + * function would be invoked once per key/value pair of a multi-MB payload). + */ export function dataHash(data: DashboardPayload): string { - return JSON.stringify(data, (_k: string, v: unknown) => { - if (_k === 'generatedAt') return undefined - return v - }) + return JSON.stringify(data.projects) } export function buildRepoLabels(paths: string[]): Map { @@ -406,28 +405,31 @@ interface ModelUsageBar { } interface RoleUsageBar { - role: 'execution' | 'auditor' + role: 'execution' | 'auditor' | 'other' cost: number messageCount: number pct: number } +/** + * 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. + */ 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 } for (const [role, agg] of Object.entries(u.byRole ?? {})) { - if (role === 'auditor') { - audit.cost += agg.cost - audit.messageCount += agg.messageCount - } else if (role === 'code') { - exec.cost += agg.cost - exec.messageCount += agg.messageCount - } + const bar = role === 'auditor' ? audit : role === 'code' ? exec : other + bar.cost += agg.cost + bar.messageCount += agg.messageCount } - const max = Math.max(exec.cost, audit.cost) - exec.pct = max > 0 ? (exec.cost / max) * 100 : 0 - audit.pct = max > 0 ? (audit.cost / max) * 100 : 0 - return [exec, audit] + 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) + for (const bar of bars) bar.pct = max > 0 ? (bar.cost / max) * 100 : 0 + return bars } export function modelUsageBars(u: NonNullable): ModelUsageBar[] { @@ -473,25 +475,64 @@ export function formatRelativeTime(ts: number | null | undefined): string { return fmtTime(ts) } -export function renderMarkdown(src: string): string { - if (!src) return '' - const cached = markdownCache.get(src) +export interface MarkdownHeading { depth: number; id: string; text: string } +export interface MarkdownResult { html: string; outline: MarkdownHeading[] } + +// Default marked output is `...` with no id; code-block content is +// HTML-escaped, so a literal `

` inside a code block becomes `<h1>` +// and is not matched here. +const HEADING_RE = /([\s\S]*?)<\/h\1>/g + +function stripHtml(html: string): string { + return html.replace(/<[^>]+>/g, '').replace(/&[a-z#0-9]+;/gi, '').trim() +} + +function slugifyHeading(htmlText: string): string { + const text = stripHtml(htmlText) + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/[\s_-]+/g, '-') + .replace(/^-+|-+$/g, '') + return text || 'heading' +} + +function injectHeadingIds(html: string): { html: string; outline: MarkdownHeading[] } { + if (!//.test(html)) return { html, outline: [] } + const outline: MarkdownHeading[] = [] + const out = html.replace(HEADING_RE, (_full, levelStr: string, inner: string) => { + const depth = Number(levelStr) + const id = slugifyHeading(inner) + outline.push({ depth, id, text: stripHtml(inner) }) + return `${inner}` + }) + return { html: out, outline } +} + +function computeMarkdown(src: string): MarkdownResult { + if (!src) return { html: '', outline: [] } + const cached = markdownResultCache.get(src) if (cached !== undefined) return cached const m = (globalThis as { marked?: { parse(s: string): string } }).marked - if (!m) return '' - const result = m.parse(src) - if (result) { - // Evict oldest entry if at capacity (insertion-order eviction) - if (markdownCache.size >= MD_CACHE_MAX) { - const firstKey = markdownCache.keys().next().value - if (firstKey !== undefined) markdownCache.delete(firstKey) - } - markdownCache.set(src, result) + if (!m) return { html: '', outline: [] } + const { html, outline } = injectHeadingIds(m.parse(src)) + const result: MarkdownResult = { html, outline } + if (markdownResultCache.size >= MD_CACHE_MAX) { + const firstKey = markdownResultCache.keys().next().value + if (firstKey !== undefined) markdownResultCache.delete(firstKey) } + markdownResultCache.set(src, result) return result } -const markdownCache = new Map() +export function renderMarkdown(src: string): string { + return computeMarkdown(src).html +} + +export function renderMarkdownWithOutline(src: string): MarkdownResult { + return computeMarkdown(src) +} + +const markdownResultCache = new Map() const MD_CACHE_MAX = 200 export function tabsForLoop(loop: DashboardLoop): LoopTab[] { diff --git a/src/dashboard/data.ts b/src/dashboard/data.ts index 09846fc27f..32eecaf6d9 100644 --- a/src/dashboard/data.ts +++ b/src/dashboard/data.ts @@ -53,20 +53,9 @@ export interface DashboardProject { groups: DashboardGroup[] } -export interface DashboardTotals { - projects: number - loops: number - running: number - completed: number - cancelled: number - errored: number - stalled: number -} - export interface DashboardPayload { generatedAt: number projects: DashboardProject[] - totals: DashboardTotals } /** Check whether *tbl* exists on this database. */ @@ -149,15 +138,6 @@ export function collectDashboardData(db: Database): DashboardPayload { new Set([...loopProjectIds.map(r => r.project_id), ...groupProjectIds.map(r => r.project_id)]) ).sort() const projects: DashboardProject[] = [] - const totals: DashboardTotals = { - projects: 0, - loops: 0, - running: 0, - completed: 0, - cancelled: 0, - errored: 0, - stalled: 0, - } for (const projectId of projectIds) { const loopRows = loopsRepo.listAll(projectId) @@ -198,30 +178,25 @@ export function collectDashboardData(db: Database): DashboardPayload { return { id: loopName, loop, lastAuditResult, postActionReport, goal, plan, sections, findings, usage, duration, transitions, amendments } }) - const groups: DashboardGroup[] = featureGroupsRepo - ? featureGroupsRepo.listGroups(projectId).map(g => { - const { prdText, ...rest } = g - return { - id: g.groupId, - group: { ...rest, prdPreview: prdText ? prdText.slice(0, PRD_PREVIEW_MAX) : null }, - features: featureGroupsRepo.listFeatures(projectId, g.groupId), - } - }) - : [] + let groups: DashboardGroup[] = [] + if (featureGroupsRepo) { + // One features query per project, not one per group. + const featuresByGroup = featureGroupsRepo.listFeaturesByGroup(projectId) + groups = featureGroupsRepo.listGroups(projectId).map(g => { + const { prdText, ...rest } = g + return { + id: g.groupId, + group: { ...rest, prdPreview: prdText ? prdText.slice(0, PRD_PREVIEW_MAX) : null }, + features: featuresByGroup.get(g.groupId) ?? [], + } + }) + } projects.push({ id: projectId, projectId, projectDir, loops: dashboardLoops, groups }) - - // Accumulate totals - totals.projects = projectIds.length - totals.loops += sortedLoops.length - for (const loop of sortedLoops) { - totals[loop.status]++ - } } return { generatedAt: Date.now(), projects, - totals, } } diff --git a/src/dashboard/render.ts b/src/dashboard/render.ts index 47257159fe..74d7e922cf 100644 --- a/src/dashboard/render.ts +++ b/src/dashboard/render.ts @@ -213,6 +213,9 @@ export function renderDashboardHtml(): string { .status-cancelled { background: var(--neutral); color: #fff; } .status-errored { background: var(--errored-bg); color: #fff; } .status-stalled { background: var(--running); color: #fff; } + .status-extracting { background: var(--link); color: #fff; } + .status-planning { background: var(--link); color: #fff; } + .status-interrupted { background: var(--running); color: #fff; } .loop-detail { padding: 8px 12px 12px; border-top: 1px solid var(--divider); font-size: 0.85rem; } .loop-detail h4 { color: var(--fg-bright); margin: 8px 0 4px; font-size: 0.95rem; } .loop-detail h4:first-child { margin-top: 0; } @@ -321,10 +324,10 @@ export function renderDashboardHtml(): string { background: var(--bg-0); padding: 8px; margin-top: 4px; } .markdown-content { font-size: 0.85rem; line-height: 1.6; color: var(--fg-0); } - .markdown-content h1 { font-size: 1.3rem; margin: 16px 0 8px; color: var(--fg-bright); border-bottom: 1px solid var(--divider); padding-bottom: 4px; } - .markdown-content h2 { font-size: 1.15rem; margin: 14px 0 6px; color: var(--fg-bright); border-bottom: 1px solid var(--surface); padding-bottom: 3px; } - .markdown-content h3 { font-size: 1.05rem; margin: 12px 0 5px; color: var(--fg-bright); } - .markdown-content h4 { font-size: 0.95rem; margin: 10px 0 4px; color: var(--fg-bright); } + .markdown-content h1 { font-size: 1.3rem; margin: 16px 0 8px; color: var(--accent); border-bottom: 1px solid var(--divider); padding-bottom: 4px; } + .markdown-content h2 { font-size: 1.15rem; margin: 14px 0 6px; color: var(--accent); border-bottom: 1px solid var(--surface); padding-bottom: 3px; } + .markdown-content h3 { font-size: 1.05rem; margin: 12px 0 5px; color: var(--accent); } + .markdown-content h4 { font-size: 0.95rem; margin: 10px 0 4px; color: var(--accent); } .markdown-content p { margin: 6px 0; } .markdown-content ul, .markdown-content ol { margin: 4px 0; padding-left: 20px; } .markdown-content li { margin: 2px 0; } @@ -414,6 +417,25 @@ export function renderDashboardHtml(): string { .ldh-findings-warn { background: rgba(210,153,34,0.12); border-color: rgba(210,153,34,0.4); color: var(--running); } .ldh-findings-clean { background: rgba(63,185,80,0.10); border-color: rgba(63,185,80,0.3); color: var(--ok); } .markdown-body { padding: 2px 0 4px; } + .markdown-toc { + float: right; clear: right; + margin: 4px 0 8px 12px; padding: 8px 10px; + max-width: 220px; + background: var(--panel); border: 1px solid var(--divider); border-radius: 6px; + font-size: 0.8rem; + } + .markdown-toc-title { + color: var(--fg-1); font-size: 0.72rem; + text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; + } + .markdown-toc ul { list-style: none; padding: 0; margin: 0; } + .markdown-toc-item a { + display: block; color: var(--fg-0); text-decoration: none; padding: 2px 0; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .markdown-toc-item a:hover { color: var(--accent); } + .markdown-toc-depth-2 { padding-left: 8px; } + .markdown-toc-depth-3 { padding-left: 16px; } .markdown-toggle { display: flex; align-items: center; gap: 8px; min-width: 0; cursor: pointer; user-select: none; @@ -610,10 +632,10 @@ export function renderDashboardHtml(): string { } .group-row-title { color: var(--fg-bright); font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .group-row-meter { display: inline-flex; align-items: center; gap: 6px; } .group-row-meta { color: var(--fg-dim); font-family: var(--mono); font-size: 0.74rem; } .group-row-time { color: var(--fg-dim); font-family: var(--mono); font-size: 0.72rem; } - .group-detail { display: flex; flex-direction: column; gap: 12px; } + .group-detail, + .group-detail-body { display: flex; flex-direction: column; gap: 12px; } .back-to-groups { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; color: var(--link); font-size: 0.85rem; user-select: none; @@ -651,6 +673,18 @@ export function renderDashboardHtml(): string { background: var(--bg-0); font-size: 0.82rem; } .feature-index { color: var(--cancelled); font-weight: 600; } + .feature-stage { + font-size: 0.66rem; font-weight: 600; text-transform: uppercase; + letter-spacing: 0.04em; flex-shrink: 0; + } + .feature-stage-pending { color: var(--cancelled); } + .feature-stage-planning, + .feature-stage-planned { color: var(--link); } + .feature-stage-launching, + .feature-stage-running { color: var(--ph-coding); } + .feature-stage-completed { color: var(--ok); } + .feature-stage-failed { color: var(--errored); } + .feature-stage-cancelled { color: var(--neutral); } .feature-title { color: var(--fg-0); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .feature-attempts { color: var(--fg-dim); font-family: var(--mono); font-size: 0.74rem; } .feature-loop-link { color: var(--link); text-decoration: none; font-family: var(--mono); font-size: 0.74rem; } diff --git a/src/storage/repos/feature-groups-repo.ts b/src/storage/repos/feature-groups-repo.ts index 4024dd35d5..a73594cf6b 100644 --- a/src/storage/repos/feature-groups-repo.ts +++ b/src/storage/repos/feature-groups-repo.ts @@ -68,6 +68,11 @@ export interface FeatureGroupsRepo { getGroupBySplitterSession(projectId: string, sessionId: string): FeatureGroupRow | null insertFeatures(projectId: string, groupId: string, features: ParsedFeature[]): void listFeatures(projectId: string, groupId: string): GroupFeatureRow[] + /** + * Every feature of a project, bucketed by group id. Lets a caller that needs + * all of a project's groups avoid one `listFeatures` query per group. + */ + listFeaturesByGroup(projectId: string): Map getFeatureByArchitectSession(projectId: string, sessionId: string): GroupFeatureRow | null getFeatureByLoopName(projectId: string, loopName: string): GroupFeatureRow | null claimFeatureStage(projectId: string, groupId: string, featureIndex: number, fromStage: string, toStage: string): boolean @@ -226,6 +231,14 @@ export function createFeatureGroupsRepo(db: Database): FeatureGroupsRepo { ORDER BY feature_index ASC `) + const listAllFeaturesStmt = db.prepare(` + SELECT project_id, group_id, feature_index, title, description, stage, + architect_session_id, loop_name, error, attempts, created_at, updated_at + FROM group_features + WHERE project_id = ? + ORDER BY group_id ASC, feature_index ASC + `) + const getFeatureByArchitectSessionStmt = db.prepare(` SELECT project_id, group_id, feature_index, title, description, stage, architect_session_id, loop_name, error, attempts, created_at, updated_at @@ -350,6 +363,17 @@ export function createFeatureGroupsRepo(db: Database): FeatureGroupsRepo { return (listFeaturesStmt.all(projectId, groupId) as GroupFeatureRowRaw[]).map(mapFeatureRow) }, + listFeaturesByGroup(projectId: string): Map { + const byGroup = new Map() + for (const raw of listAllFeaturesStmt.all(projectId) as GroupFeatureRowRaw[]) { + const row = mapFeatureRow(raw) + const bucket = byGroup.get(row.groupId) + if (bucket) bucket.push(row) + else byGroup.set(row.groupId, [row]) + } + return byGroup + }, + getFeatureByArchitectSession(projectId: string, sessionId: string): GroupFeatureRow | null { const row = getFeatureByArchitectSessionStmt.get(projectId, sessionId) as GroupFeatureRowRaw | null return row ? mapFeatureRow(row) : null diff --git a/src/storage/repos/loop-session-usage-repo.ts b/src/storage/repos/loop-session-usage-repo.ts index 90949f9cbd..123c969e26 100644 --- a/src/storage/repos/loop-session-usage-repo.ts +++ b/src/storage/repos/loop-session-usage-repo.ts @@ -96,23 +96,12 @@ export function createLoopSessionUsageRepo(db: Database): LoopSessionUsageRepo { WHERE project_id = ? AND loop_name = ? AND session_id = ? `) + // One scan of idx_loop_session_usage_project_loop produces the totals, the + // per-model split and the per-role split; the dashboard poll calls this once + // per loop, so a statement per shape tripled the work for no extra data. const getAggregateStmt = db.prepare(` SELECT - loop_name, - SUM(cost) as total_cost, - SUM(input_tokens) as total_input_tokens, - SUM(output_tokens) as total_output_tokens, - SUM(reasoning_tokens) as total_reasoning_tokens, - SUM(cache_read_tokens) as total_cache_read_tokens, - SUM(cache_write_tokens) as total_cache_write_tokens, - SUM(message_count) as total_message_count - FROM loop_session_usage - WHERE project_id = ? AND loop_name = ? - GROUP BY loop_name - `) - - const getByModelStmt = db.prepare(` - SELECT + role, model, SUM(cost) as cost, SUM(input_tokens) as input_tokens, @@ -123,17 +112,7 @@ export function createLoopSessionUsageRepo(db: Database): LoopSessionUsageRepo { SUM(message_count) as message_count FROM loop_session_usage WHERE project_id = ? AND loop_name = ? - GROUP BY model - `) - - const getByRoleStmt = db.prepare(` - SELECT - role, - SUM(cost) as cost, - SUM(message_count) as message_count - FROM loop_session_usage - WHERE project_id = ? AND loop_name = ? - GROUP BY role + GROUP BY role, model `) const listSessionUsageStmt = db.prepare(` @@ -186,20 +165,8 @@ export function createLoopSessionUsageRepo(db: Database): LoopSessionUsageRepo { }, getAggregate(projectId: string, loopName: string): LoopUsageAggregate | null { - const aggregate = getAggregateStmt.get(projectId, loopName) as { - loop_name: string - total_cost: number - total_input_tokens: number - total_output_tokens: number - total_reasoning_tokens: number - total_cache_read_tokens: number - total_cache_write_tokens: number - total_message_count: number - } | null - - if (!aggregate) return null - - const byModelRows = getByModelStmt.all(projectId, loopName) as Array<{ + const rows = getAggregateStmt.all(projectId, loopName) as Array<{ + role: string model: string cost: number input_tokens: number @@ -210,44 +177,54 @@ export function createLoopSessionUsageRepo(db: Database): LoopSessionUsageRepo { message_count: number }> - const byModel: Record = {} - for (const row of byModelRows) { - byModel[row.model] = { - cost: row.cost, - inputTokens: row.input_tokens, - outputTokens: row.output_tokens, - reasoningTokens: row.reasoning_tokens, - cacheReadTokens: row.cache_read_tokens, - cacheWriteTokens: row.cache_write_tokens, - messageCount: row.message_count, - } + if (rows.length === 0) return null + + const aggregate: LoopUsageAggregate = { + loopName, + totalCost: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalReasoningTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + totalMessageCount: 0, + byModel: {}, + byRole: {}, } - const byRoleRows = getByRoleStmt.all(projectId, loopName) as Array<{ - role: string - cost: number - message_count: number - }> - const byRole: LoopUsageAggregate['byRole'] = {} - for (const row of byRoleRows) { - byRole[row.role as LoopSessionUsageRow['role']] = { - cost: row.cost, - messageCount: row.message_count, - } + for (const row of rows) { + aggregate.totalCost += row.cost + aggregate.totalInputTokens += row.input_tokens + aggregate.totalOutputTokens += row.output_tokens + aggregate.totalReasoningTokens += row.reasoning_tokens + aggregate.totalCacheReadTokens += row.cache_read_tokens + 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, messageCount: 0 }) + role.cost += row.cost + role.messageCount += row.message_count } - return { - loopName: aggregate.loop_name, - totalCost: aggregate.total_cost, - totalInputTokens: aggregate.total_input_tokens, - totalOutputTokens: aggregate.total_output_tokens, - totalReasoningTokens: aggregate.total_reasoning_tokens, - totalCacheReadTokens: aggregate.total_cache_read_tokens, - totalCacheWriteTokens: aggregate.total_cache_write_tokens, - totalMessageCount: aggregate.total_message_count, - byModel, - byRole, - } + return aggregate }, listSessionUsage(projectId: string, loopName: string): LoopSessionUsageRow[] { diff --git a/test/dashboard/app-dom.test.ts b/test/dashboard/app-dom.test.ts index 053901ed66..441e14ddfc 100644 --- a/test/dashboard/app-dom.test.ts +++ b/test/dashboard/app-dom.test.ts @@ -110,7 +110,13 @@ beforeEach(() => { payload = makePayload() intervalFn = null dispose = null - ;(globalThis as any).marked = { parse: (s: string) => `

${s}

` } + ;(globalThis as any).marked = { + parse: (s: string) => s.split('\n').map(line => { + const m = /^(#{1,6})\s+(.*)$/.exec(line) + if (m) { const lvl = m[1].length; return `${m[2]}` } + return `

${line}

` + }).join('\n'), + } vi.stubGlobal('setInterval', ((fn: () => void) => { intervalFn = fn return 1 as unknown as ReturnType @@ -248,6 +254,76 @@ describe('dashboard App fine-grained reactivity', () => { expect(container.querySelector('.markdown-content')?.innerHTML).toContain('PLAN TWO') }) + test('renders a floating table of contents when the plan has multiple headings', async () => { + payload = makePayload({ + dashLoop: { plan: '# Goal\n\nIntro paragraph.\n\n## Step One\n\nDo thing.\n\n## Step Two\n\nDo more.' }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + const toc = container.querySelector('.markdown-toc') as HTMLElement + expect(toc).toBeTruthy() + const items = toc.querySelectorAll('.markdown-toc-item') + expect(items.length).toBe(3) + expect(Array.from(items).map(i => i.className)).toEqual([ + 'markdown-toc-item markdown-toc-depth-1', + 'markdown-toc-item markdown-toc-depth-2', + 'markdown-toc-item markdown-toc-depth-2', + ]) + const links = Array.from(toc.querySelectorAll('a')).map(a => (a as HTMLAnchorElement).getAttribute('href')) + expect(links).toEqual(['#goal', '#step-one', '#step-two']) + + const content = container.querySelector('.markdown-content') as HTMLElement + expect(content.querySelector('#goal')).toBeTruthy() + expect(content.querySelector('#step-one')).toBeTruthy() + expect(content.querySelector('#step-two')).toBeTruthy() + // h4 inside markdown-content uses the accent color. + const tocItem = toc.querySelector('.markdown-toc-item a') as HTMLElement + expect(tocItem.textContent).toBe('Goal') + }) + + // 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. + 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' + payload = makePayload({ + dashLoop: { plan: shared, lastAuditResult: shared, postActionReport: shared }, + }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + 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. + expect(container.querySelectorAll('#verification').length).toBe(bodies.length) + + const planBody = bodies[bodies.length - 1] + const target = planBody.querySelector('#verification') as HTMLElement + let scrolled: HTMLElement | null = null + for (const el of Array.from(container.querySelectorAll('#verification')) as HTMLElement[]) { + el.scrollIntoView = () => { scrolled = el } + } + + const link = Array.from(planBody.querySelectorAll('.markdown-toc-item a')).find( + a => a.getAttribute('href') === '#verification', + ) as HTMLElement + link.click() + await flush() + + expect(scrolled).toBe(target) + }) + + test('does not render a table of contents when the plan has fewer than two h1-h3 headings', async () => { + payload = makePayload({ dashLoop: { plan: 'PLAN ONE' } }) + dispose = render(() => App() as unknown as Element, container) + await flush() + + expect(container.querySelector('.markdown-toc')).toBeFalsy() + expect(container.querySelector('.markdown-body')).toBeTruthy() + }) + test('section drill-in: click a row to open details, back returns to list', async () => { payload = makePayload({ @@ -1994,7 +2070,7 @@ describe('dashboard App overview tab content', () => { const overviewBody = container.querySelector('.tab-body[data-tab="overview"]') as HTMLElement expect(overviewBody.style.display).not.toBe('none') - const meta = overviewBody.querySelector('.overview-meta') as HTMLElement + const meta = overviewBody.querySelector('.ldh-stats') as HTMLElement expect(meta).toBeTruthy() const text = meta.textContent ?? '' expect(text).toContain('exec-m') @@ -2008,7 +2084,9 @@ describe('dashboard App overview tab content', () => { expect(text).toContain('2') }) - test('overview renders findings summary chips with bug and warning counts', async () => { + // The overview summarises findings exactly once, through the styled + // .ldh-findings badge in LoopDetailHeader. + test('overview summarises findings once with bug and warning counts', async () => { window.location.hash = '#p1/loop/loop-a' payload = makePayload({ dashLoop: { @@ -2023,28 +2101,27 @@ describe('dashboard App overview tab content', () => { await flush() const overviewBody = container.querySelector('.tab-body[data-tab="overview"]') as HTMLElement - const chips = Array.from(overviewBody.querySelectorAll('.overview-chip')) as HTMLElement[] - const bugChip = chips.find(c => c.classList.contains('overview-chip-bug'))! - const warnChip = chips.find(c => c.classList.contains('overview-chip-warning'))! - expect(bugChip).toBeTruthy() - expect(bugChip.textContent).toContain('2 bugs') - expect(warnChip).toBeTruthy() - expect(warnChip.textContent).toContain('1 warning') + const badges = Array.from(overviewBody.querySelectorAll('.ldh-findings')) as HTMLElement[] + expect(badges).toHaveLength(1) + expect(badges[0].classList.contains('ldh-findings-bug')).toBe(true) + expect(badges[0].textContent).toContain('2 bugs') + expect(badges[0].textContent).toContain('1 warning') }) - test('overview with no findings renders a clean chip', async () => { + test('overview with no findings renders a clean findings badge', async () => { window.location.hash = '#p1/loop/loop-a' payload = makePayload({ findings: [] }) dispose = render(() => App() as unknown as Element, container) await flush() const overviewBody = container.querySelector('.tab-body[data-tab="overview"]') as HTMLElement - const chip = overviewBody.querySelector('.overview-chip-clean') as HTMLElement - expect(chip).toBeTruthy() - expect(chip.textContent).toContain('No findings') + const badges = Array.from(overviewBody.querySelectorAll('.ldh-findings')) as HTMLElement[] + expect(badges).toHaveLength(1) + expect(badges[0].classList.contains('ldh-findings-clean')).toBe(true) + expect(badges[0].textContent).toContain('No findings') }) - test('overview with warnings only renders the warning chip and no bug chip', async () => { + test('overview with warnings only renders the warning count and no bug count', async () => { window.location.hash = '#p1/loop/loop-a' payload = makePayload({ dashLoop: { @@ -2057,12 +2134,9 @@ describe('dashboard App overview tab content', () => { await flush() const overviewBody = container.querySelector('.tab-body[data-tab="overview"]') as HTMLElement - const chips = Array.from(overviewBody.querySelectorAll('.overview-chip')) as HTMLElement[] - const bugChip = chips.find(c => c.classList.contains('overview-chip-bug')) - const warnChip = chips.find(c => c.classList.contains('overview-chip-warning'))! - expect(bugChip).toBeUndefined() - expect(warnChip).toBeTruthy() - expect(warnChip.textContent).toContain('1 warning') + const badge = overviewBody.querySelector('.ldh-findings') as HTMLElement + expect(badge.classList.contains('ldh-findings-warn')).toBe(true) + expect(badge.textContent).toContain('1 warning') expect(overviewBody.textContent).not.toContain('0 bug') }) @@ -2812,7 +2886,7 @@ describe('dashboard App groups section', () => { const rows = Array.from(container.querySelectorAll('.group-row')) as HTMLElement[] expect(rows.length).toBe(2) - expect(rows.every(r => r.querySelector('.group-row-meter'))).toBe(true) + expect(rows.every(r => r.querySelector('.lt-meter-cell'))).toBe(true) // Loop table is hidden on the groups section. expect(container.querySelector('table.loop-table')).toBeFalsy() }) @@ -2864,9 +2938,9 @@ describe('dashboard App groups section', () => { expect(linked.querySelector('.feature-loop-link')!.textContent).toBe('loop-linked') // The anchor targets the canonical project-aware loop hash, not the index. expect(linked.querySelector('.feature-loop-link')!.getAttribute('href')).toBe('#p1/loop/loop-linked') - // Pending feature has no loop link, but renders its stage as a status badge. + // Pending feature has no loop link, but renders its stage as a stage badge. expect(pending.querySelector('.feature-loop-link')).toBeFalsy() - expect(pending.querySelector('.section-status')!.textContent).toBe('pending') + expect(pending.querySelector('.feature-stage')!.textContent).toBe('pending') }) test('a feature loop link encodes its loop name and primary clicks still navigate', async () => { @@ -3443,7 +3517,7 @@ describe('dashboard App findings and plans sections', () => { expect(location.hash).not.toContain('q=') }) - test('role split excludes unknown role from the execution and auditor bars', async () => { + test('role split surfaces unknown role cost in an other bar', async () => { window.location.hash = '#p1/loop/loop-a/usage' payload = makePayload({ dashLoop: { @@ -3476,12 +3550,11 @@ describe('dashboard App findings and plans sections', () => { const roleBlock = Array.from(usageBody.querySelectorAll('.usage-block')).find(b => b.querySelector('.usage-block-title')?.textContent === 'Role split')! as HTMLElement const rows = Array.from(roleBlock.querySelectorAll('.usage-model-row')) as HTMLElement[] - expect(rows.length).toBe(2) + expect(rows.length).toBe(3) const names = rows.map(r => r.querySelector('.usage-model-name')?.textContent) - expect(names).not.toContain('unknown') - const execRow = rows.find(r => r.querySelector('.usage-model-name')?.textContent === 'execution')! - const auditRow = rows.find(r => r.querySelector('.usage-model-name')?.textContent === 'auditor')! - expect(execRow.querySelector('.usage-model-cost')?.textContent).toBe('$2.00') - expect(auditRow.querySelector('.usage-model-cost')?.textContent).toBe('$1.00') + expect(names).toEqual(['execution', 'auditor', 'other']) + 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']) }) }) \ No newline at end of file diff --git a/test/dashboard/app-helpers.test.ts b/test/dashboard/app-helpers.test.ts index 2fe4ed3541..c4aa7d6d8c 100644 --- a/test/dashboard/app-helpers.test.ts +++ b/test/dashboard/app-helpers.test.ts @@ -541,7 +541,6 @@ describe('dataHash', () => { const emptyPayload: DashboardPayload = { generatedAt: 100, projects: [], - totals: { projects: 0, loops: 0, running: 0, completed: 0, cancelled: 0, errored: 0, stalled: 0 }, } test('identical payloads differing only in generatedAt produce equal hashes', () => { @@ -975,7 +974,7 @@ describe('roleUsageBars', () => { expect(audit.pct).toBeCloseTo(50) }) - test('unknown roles are excluded from the execution and auditor comparison', () => { + test('unknown roles land in an other bar so the split reconciles with total cost', () => { const extra: NonNullable = { ...usage, byRole: { @@ -986,12 +985,19 @@ describe('roleUsageBars', () => { const bars = roleUsageBars(extra) const exec = bars.find(b => b.role === 'execution')! const audit = bars.find(b => b.role === 'auditor')! + const other = bars.find(b => b.role === 'other')! expect(exec.cost).toBeCloseTo(2) expect(audit.cost).toBeCloseTo(1) expect(exec.messageCount).toBe(6) expect(audit.messageCount).toBe(2) - expect(bars).toHaveLength(2) - expect(bars.some(b => (b as { role: string }).role === 'unknown')).toBe(false) + expect(other.cost).toBeCloseTo(0.5) + expect(other.messageCount).toBe(1) + expect(bars).toHaveLength(3) + expect(bars.reduce((sum, b) => sum + b.cost, 0)).toBeCloseTo(3.5) + }) + + test('an other bar is omitted when every role is known', () => { + expect(roleUsageBars(usage).map(b => b.role)).toEqual(['execution', 'auditor']) }) test('zero-cost usage yields zero-width bars', () => { diff --git a/test/dashboard/data.test.ts b/test/dashboard/data.test.ts index 9dbf463578..a16b960914 100644 --- a/test/dashboard/data.test.ts +++ b/test/dashboard/data.test.ts @@ -72,17 +72,10 @@ describe('collectDashboardData', () => { // ─── Cycle 1: empty DB ────────────────────────────────────────────── - test('empty DB returns empty projects and zero totals', () => { + test('empty DB returns empty projects', () => { const payload = collectDashboardData(db!) expect(payload.projects).toEqual([]) - expect(payload.totals.projects).toBe(0) - expect(payload.totals.loops).toBe(0) - expect(payload.totals.running).toBe(0) - expect(payload.totals.completed).toBe(0) - expect(payload.totals.cancelled).toBe(0) - expect(payload.totals.errored).toBe(0) - expect(payload.totals.stalled).toBe(0) expect(payload.generatedAt).toBeGreaterThan(0) }) @@ -167,9 +160,9 @@ describe('collectDashboardData', () => { expect(dashLoop.usage!.byRole.code).toEqual({ cost: 0.005, messageCount: 10 }) expect(dashLoop.usage!.byRole.auditor).toBeUndefined() - expect(payload.totals.projects).toBe(1) - expect(payload.totals.loops).toBe(1) - expect(payload.totals.running).toBe(1) + expect(payload.projects).toHaveLength(1) + expect(payload.projects[0].loops).toHaveLength(1) + expect(payload.projects[0].loops[0].loop.status).toBe('running') }) test('computes a human-readable duration from started/completed timestamps', () => { @@ -315,7 +308,7 @@ describe('collectDashboardData', () => { // ─── Cycle 4: multiple projects with mixed statuses ───────────────── - test('aggregates totals across multiple projects with mixed statuses', () => { + test('groups loops under their project across multiple projects with mixed statuses', () => { const loopsRepo = createLoopsRepo(db!) // Project A: 1 running, 1 completed @@ -379,13 +372,12 @@ describe('collectDashboardData', () => { const payload = collectDashboardData(db!) expect(payload.projects).toHaveLength(2) - expect(payload.totals.projects).toBe(2) - expect(payload.totals.loops).toBe(5) - expect(payload.totals.running).toBe(1) - expect(payload.totals.completed).toBe(1) - expect(payload.totals.cancelled).toBe(1) - expect(payload.totals.errored).toBe(1) - expect(payload.totals.stalled).toBe(1) + expect(payload.projects.map(p => p.projectId)).toEqual(['project-a', 'project-b']) + const statusesByProject = payload.projects.map(p => p.loops.map(dl => dl.loop.status).sort()) + expect(statusesByProject).toEqual([ + ['completed', 'running'], + ['cancelled', 'errored', 'stalled'], + ]) }) // ─── Cycle 5: feature groups ────────────────────────────────────────── diff --git a/test/dashboard/launch.test.ts b/test/dashboard/launch.test.ts index 64a9824bcf..163dcee9eb 100644 --- a/test/dashboard/launch.test.ts +++ b/test/dashboard/launch.test.ts @@ -75,6 +75,6 @@ describe('startDashboardServer', () => { expect(res.headers.get('content-type')).toMatch(/application\/json/) const body = await res.json() expect(body).toHaveProperty('projects') - expect(body).toHaveProperty('totals') + expect(body).toHaveProperty('generatedAt') }) }) diff --git a/test/dashboard/render.test.ts b/test/dashboard/render.test.ts index d675b726db..8aa7245519 100644 --- a/test/dashboard/render.test.ts +++ b/test/dashboard/render.test.ts @@ -1,6 +1,49 @@ import { describe, test, expect } from 'vitest' +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' import { renderDashboardHtml } from '../../src/dashboard/render' +const APP_DIR = join(__dirname, '../../src/dashboard/app') + +/** + * Static `class="…"` tokens emitted by the browser app. Templates that build a + * class name by concatenation (`'status-' + status`) are not visible here, so + * this is a lower bound, not a complete inventory. + */ +function staticAppClassNames(): string[] { + const tokens = new Set() + for (const file of readdirSync(APP_DIR)) { + if (!file.endsWith('.ts')) continue + const source = readFileSync(join(APP_DIR, file), 'utf8') + for (const match of source.matchAll(/class="([^"$]+)"/g)) { + for (const token of match[1].trim().split(/\s+/)) { + if (token) tokens.add(token) + } + } + } + return [...tokens].sort() +} + +/** + * Layout wrappers, semantic hooks, and test selectors that deliberately carry + * no rule of their own. Everything else must be styled: render.ts holds the + * only stylesheet, so a class it does not define ships as unstyled markup. + */ +const UNSTYLED_BY_DESIGN = new Set([ + 'forge-app', + 'group-row-completed', + 'group-row-created', + 'loop-picker-input', + 'lt-findings', + 'markdown-section', + 'overview-tab', + 'plan-tab', + 'repo-loop-pane', + 'tab-bodies', + 'timeline-tab', + 'usage-tab', +]) + describe('renderDashboardHtml', () => { test('contains DOCTYPE html, title, and a single forge-app-root mount node', () => { const html = renderDashboardHtml() @@ -30,35 +73,20 @@ describe('renderDashboardHtml', () => { expect(html).toContain('forge-app-root') }) - test('retains CSS class definitions for dashboard components', () => { - const html = renderDashboardHtml() + test('every static class the app emits has a rule in the stylesheet', () => { + const style = renderDashboardHtml().slice(0, renderDashboardHtml().indexOf('')) + const unstyled = staticAppClassNames().filter( + cls => !UNSTYLED_BY_DESIGN.has(cls) && !style.includes('.' + cls), + ) - const cssClasses = [ - 'repo-menu', - 'repo-menu-item', - 'breadcrumb', - 'breadcrumb-path', - 'section-nav', - 'section-nav-item', - 'search-input', - 'empty-state', - 'resizable-block', - 'resize: vertical', - 'loop-table', - 'lt-meter', - 'lt-row', - 'ldh-findings', - 'sections-panel', - 'section-list-row', - 'back-to-sections', - 'badge-filter', - 'badge-active', - 'markdown-body', - 'markdown-toggle', - ] - for (const cls of cssClasses) { - expect(html).toContain(cls) - } + expect(unstyled).toEqual([]) + }) + + test('the unstyled-by-design allowlist has no stale entries', () => { + const emitted = new Set(staticAppClassNames()) + const stale = [...UNSTYLED_BY_DESIGN].filter(cls => !emitted.has(cls)) + + expect(stale).toEqual([]) }) test('the markdown body renders at full height instead of scrolling internally', () => { @@ -72,6 +100,18 @@ describe('renderDashboardHtml', () => { expect(rule).not.toContain('overflow') }) + test('markdown headings use the accent color to stand out from body text', () => { + const html = renderDashboardHtml() + const style = html.slice(html.indexOf('')) + for (const level of ['h1', 'h2', 'h3', 'h4']) { + const start = style.indexOf(`.markdown-content ${level} `) + expect(start).toBeGreaterThan(0) + const rule = style.slice(start, style.indexOf('}', start)) + expect(rule).toContain('var(--accent)') + expect(rule).not.toContain('var(--fg-bright)') + } + }) + test('no longer contains inline script or old static dashboard nodes', () => { const html = renderDashboardHtml() diff --git a/test/dashboard/server.test.ts b/test/dashboard/server.test.ts index e798a500b0..98bbed797c 100644 --- a/test/dashboard/server.test.ts +++ b/test/dashboard/server.test.ts @@ -83,7 +83,7 @@ describe('createRequestHandler', () => { }) }) - // ─── Cycle 2: /api/data returns JSON with projects and totals ──────── + // ─── Cycle 2: /api/data returns JSON with projects ─────────────────── test('GET /api/data returns 200 with application/json and no-store cache', async () => { const handler = createRequestHandler(makeDeps(db!)) @@ -94,10 +94,9 @@ describe('createRequestHandler', () => { const body = await res.json() expect(body).toHaveProperty('projects') - expect(body).toHaveProperty('totals') + expect(body).toHaveProperty('generatedAt') expect(Array.isArray(body.projects)).toBe(true) - expect(body.totals.projects).toBe(0) - expect(body.totals.loops).toBe(0) + expect(body.projects).toHaveLength(0) }) // ─── Cycle 3: live re-query — inserting a loop changes /api/data ───── @@ -108,7 +107,7 @@ describe('createRequestHandler', () => { // Verify empty before insertion const resBefore = await handler(new Request('http://localhost/api/data')) const bodyBefore = await resBefore.json() - expect(bodyBefore.totals.loops).toBe(0) + expect(bodyBefore.projects).toHaveLength(0) // Insert a loop via the same db reference const loopsRepo = createLoopsRepo(db!) @@ -120,8 +119,8 @@ describe('createRequestHandler', () => { // Verify data now includes the new loop const resAfter = await handler(new Request('http://localhost/api/data')) const bodyAfter = await resAfter.json() - expect(bodyAfter.totals.loops).toBe(1) expect(bodyAfter.projects).toHaveLength(1) + expect(bodyAfter.projects[0].loops).toHaveLength(1) expect(bodyAfter.projects[0].projectId).toBe('p1') expect(bodyAfter.projects[0].loops[0].loop.loopName).toBe('newly-inserted') }) From 968d4fded8baa6f58ced98feb6973e181e705773 Mon Sep 17 00:00:00 2001 From: Forge Date: Sun, 26 Jul 2026 12:46:26 -0400 Subject: [PATCH 4/7] feat(dashboard): stack running cards and make title a home link --- src/dashboard/app-bundle.ts | 4 ++-- src/dashboard/app/app.ts | 2 +- src/dashboard/app/components.ts | 3 +-- src/dashboard/render.ts | 12 +++++++++--- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/dashboard/app-bundle.ts b/src/dashboard/app-bundle.ts index 2120f3327c..6dbdd20502 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 \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 = "d6961e824012c482c186851197466fd7da0ebde0f09034072becb662606e85e2"; +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"; diff --git a/src/dashboard/app/app.ts b/src/dashboard/app/app.ts index 049884fc79..d19b792361 100644 --- a/src/dashboard/app/app.ts +++ b/src/dashboard/app/app.ts @@ -442,7 +442,7 @@ export function App() { // solid-js/html template are not wired as reactive inserts, so wrap the whole // UI in one container to keep the ${() => ...} regions reactive. return html`
-

Forge Dashboard

+

Forge Dashboard

${() => { const err = loadError() diff --git a/src/dashboard/app/components.ts b/src/dashboard/app/components.ts index bfd2eb737d..c9400d73c1 100644 --- a/src/dashboard/app/components.ts +++ b/src/dashboard/app/components.ts @@ -203,8 +203,7 @@ export function RepoIndexPane(props: {

Running

${runningCards().map(c => html`
props.onOpenLoop(c.projectId, c.loopName)}> - - ${c.label} + ${c.label} ${c.loopName} ${c.phase}
`)} diff --git a/src/dashboard/render.ts b/src/dashboard/render.ts index 74d7e922cf..6cb2882177 100644 --- a/src/dashboard/render.ts +++ b/src/dashboard/render.ts @@ -38,6 +38,8 @@ export function renderDashboardHtml(): string { padding: 16px; } h1 { font-size: 1.3rem; margin-bottom: 8px; color: var(--fg-bright); } + h1 .forge-home { color: inherit; text-decoration: none; } + h1 .forge-home:hover { color: var(--link-hover); } h2 { font-size: 1.2rem; margin-bottom: 6px; color: var(--fg-bright); } .filter-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; @@ -79,7 +81,7 @@ export function renderDashboardHtml(): string { } .repo-running-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; } .repo-running-card { - display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; + display: flex; flex-direction: column; gap: 3px; min-width: 0; cursor: pointer; user-select: none; padding: 8px 12px; border: 1px solid var(--divider); border-radius: 6px; background: var(--panel); font-size: 0.82rem; } @@ -88,10 +90,14 @@ export function renderDashboardHtml(): string { width: 8px; height: 8px; border-radius: 50%; background: var(--warning); flex: 0 0 8px; box-shadow: 0 0 6px var(--warning); } - .repo-running-label { color: var(--link); font-weight: 600; } + .repo-running-label { + display: flex; align-items: center; gap: 6px; min-width: 0; + color: var(--link); font-weight: 600; + } + .repo-running-name, .repo-running-phase { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .repo-running-name { color: var(--fg-bright); } .repo-running-phase { - margin-left: auto; color: var(--fg-2); font-family: var(--mono); + color: var(--fg-2); font-family: var(--mono); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; } .repo-recent-list { display: flex; flex-direction: column; gap: 2px; } From aa3bc797efcdb99244bc7b7b292e52fac4f3e204 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:56:19 -0400 Subject: [PATCH 5/7] feat: add plan-authoring tools, capture, and structure utilities (#76) * feat: add plan-authoring tools, capture, and structure utilities Introduces a plan-authoring tool surface with capture and parsing helpers, section-bootstrap and decomposer updates, plus tests across plan-authoring, capture, structure, fences, and TUI stored-plan state. * refactor: centralize plan-authoring tool names and plan-of-record resolution --------- Co-authored-by: Forge --- AGENTS.md | 2 + README.md | 14 +- docs/agents-and-commands.md | 2 +- docs/api/README.md | 14 +- docs/api/_media/agents-and-commands.md | 2 +- docs/api/_media/architecture.md | 6 +- docs/api/_media/tools.md | 29 + 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/architecture.md | 6 +- docs/modules.md | 6 +- docs/tools.md | 29 + src/agents/auditor.ts | 2 + src/agents/code.ts | 3 +- src/agents/feature-splitter.ts | 3 +- src/constants/loop.ts | 19 + src/hooks/plan-capture.ts | 8 +- src/index.ts | 29 +- src/loop/service.ts | 3 +- src/prompts/agents/architect-auto.md | 4 +- src/prompts/agents/architect.md | 27 +- src/services/deterministic-decomposer.ts | 72 ++- src/services/group-orchestrator.ts | 7 +- src/services/plan-capture.ts | 164 +++++- src/services/section-bootstrap.ts | 3 +- src/tools/index.ts | 2 + src/tools/loop.ts | 16 +- src/tools/plan-adjust.ts | 2 +- src/tools/plan-authoring.ts | 158 ++++++ src/tui.tsx | 9 +- src/utils/markdown-fences.ts | 19 + src/utils/marked-plan-parser.ts | 193 ++++--- src/utils/plan-execution.ts | 4 +- src/utils/plan-from-messages.ts | 11 +- src/utils/plan-structure.ts | 113 ++++ src/utils/tui-client.ts | 28 +- src/utils/tui-loop-store.ts | 49 +- test/agent-tools-map.test.ts | 26 +- test/agents.test.ts | 11 + test/config.test.ts | 6 +- test/constants/loop.test.ts | 16 +- test/deterministic-decomposer.test.ts | 41 +- test/loop-permission-ruleset.test.ts | 7 +- test/loop-tool-new-session.test.ts | 76 +++ test/markdown-fences.test.ts | 27 + test/plan-capture.test.ts | 503 +++++++++++++++++- test/plan-structure.test.ts | 185 +++++++ test/plugin.test.ts | 4 +- test/prompts/loader.test.ts | 8 + test/services/group-orchestrator.test.ts | 40 ++ test/services/section-bootstrap.test.ts | 61 +++ test/tools/plan-authoring.test.ts | 468 ++++++++++++++++ test/utils/tui-client-stored-plan.test.ts | 113 ++++ test/utils/tui-stored-plan.test.ts | 51 ++ 61 files changed, 2500 insertions(+), 269 deletions(-) create mode 100644 src/tools/plan-authoring.ts create mode 100644 src/utils/markdown-fences.ts create mode 100644 src/utils/plan-structure.ts create mode 100644 test/markdown-fences.test.ts create mode 100644 test/plan-structure.test.ts create mode 100644 test/tools/plan-authoring.test.ts create mode 100644 test/utils/tui-client-stored-plan.test.ts create mode 100644 test/utils/tui-stored-plan.test.ts diff --git a/AGENTS.md b/AGENTS.md index 5e78acc4b7..eeafd60631 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,8 @@ - The build does not clean `dist/`; remove stale output when deleting or renaming source modules before validating package contents. - Bundled prompts live in `src/prompts/`; bundled skills live in `skills/`. They sync on every plugin load, preserving user edits and never deleting files. The standalone installer handles conflicts and orphan pruning. - 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. ## Dashboard and storage gotchas diff --git a/README.md b/README.md index fafc95959a..2be2a115f7 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Execution flow dialog with mode and model selection: ## Features -- **Plans** — architect produces marked plans that are auto-captured to SQL storage +- **Plans** — architect authors plans directly into SQL storage with `plan-write`/`plan-edit`; marked plans emitted in chat are still auto-captured - **Execution** — approved-plan launch paths plus direct `/execute-goal` loops in dedicated worktree sessions; plan loops can also target a configured remote opencode server (see [Configuration](docs/configuration.md#remotes)) - **Loops** — iterative coding/auditing with isolated git worktree and optional Docker sandbox - **Review Findings** — persistent, loop-scoped review findings across loop sessions @@ -123,7 +123,7 @@ The auditor agent is a read-only subagent that cannot edit source files or execu **Tool restrictions:** The auditor cannot use file-editing tools, planning tools, or loop-management tools. See [Auditor restrictions](docs/agents-and-commands.md#auditor-restrictions). -The architect agent operates as a read-only planner with message-level reinforcement via the `experimental.chat.messages.transform` hook. Final plans are rendered once in the assistant response between `` and `` markers, then auto-captured into SQL before execution approval. After user approval via the question tool, execution is dispatched programmatically — no additional LLM calls are needed. The user can view and edit the cached plan from the sidebar or command palette before or during execution. +The architect agent operates as a read-only planner with message-level reinforcement via the `experimental.chat.messages.transform` hook. Final plans are authored straight into SQL storage with the `plan-write` and `plan-edit` tools, which return a structure report the architect uses to fix warnings before asking for approval. A plan emitted in the assistant response between `` and `` markers is still auto-captured into the same row, so marker-only architect prompts keep working. After user approval via the question tool, execution is dispatched programmatically — no additional LLM calls are needed. The user can view and edit the stored plan from the sidebar or command palette before or during execution. ## Tools @@ -132,7 +132,7 @@ See [Tools reference](docs/tools.md) for full arguments, section-scoping behavio Forge provides these tool groups: -- **Plan tools** — `plan-read`, `section-read` +- **Plan tools** — `plan-write`, `plan-edit`, `plan-read`, `section-read`, `plan-adjust` - **Review tools** — `review-write`, `review-read`, `review-delete` - **Loop tools** — `execute-plan`, `execute-goal`, `loop-cancel`, `loop-status` - **Sandbox shell** — `sh` when a sandbox manager is available @@ -221,7 +221,7 @@ The sidebar shows Forge's connection status and version. Captured plans live on ### Execution Dialog -Open the dialog from the command palette as `Execute plan` (default keybind `f`). The plan is sourced from the most recent architect message in the current session — the marked ` ... ` block is parsed out of the assistant's reply. If no marked plan exists in the session, the dialog will not open and you'll see a toast asking the architect to produce one first. +Open the dialog from the command palette as `Execute plan` (default keybind `f`). The plan is sourced from the stored plan for the current session, so the dialog shows exactly what `execute-plan` would run. When no stored row exists, the most recent architect message is scanned for a marked ` ... ` block as a fallback. If neither is present, the dialog will not open and you'll see a toast asking the architect to produce a plan first. The dialog provides full control over execution parameters: @@ -323,9 +323,9 @@ Plan with a smart model, execute with a fast model. The architect agent research ### How Plans Work -The architect is read-only and must output exactly one final plan between `` and `` markers. Forge auto-captures that marked plan into SQL storage for the current session. +The architect is read-only and authors the plan into SQL storage for the current session with `plan-write`, appending further phases with `plan-write { append: true }` and revising with `plan-edit`. Every write returns a structure report — line/character counts, the detected `Loop Name:`, the sections the decomposer would emit, and warnings — so structural problems surface before approval. If those tools are unavailable, a plan emitted once between `` and `` markers is auto-captured into the same row. -The captured plan is the source of truth for execution. The architect's own message in the chat history is the human-readable view; programmatic access is via the `plan-read` tool. +The stored plan is the source of truth for execution: `execute-plan`, the approval hook, and the TUI dialog all read it, and a marker-free assistant message can never replay an older chat plan over a newer tool-authored one. Programmatic access is via the `plan-read` tool. ### Execution @@ -375,7 +375,7 @@ Model and variant selection follows this priority order: ### Troubleshooting -- **No plan found** — Ensure the architect output included the `` / `` markers; the capture hook only stores plans wrapped in those markers. +- **No plan found** — Ensure the architect called `plan-write`, or that its response wrapped the plan in `` / `` markers; a plan emitted in chat without those markers is not captured. - **TUI shows no plan** — Plans are session-scoped on the server; switch to the session where the architect produced the plan. - **Need logs** — Set `logging.enabled` to `true`, and optionally `logging.debug` for verbose output. diff --git a/docs/agents-and-commands.md b/docs/agents-and-commands.md index cd3cc7d120..9c86b85069 100644 --- a/docs/agents-and-commands.md +++ b/docs/agents-and-commands.md @@ -9,7 +9,7 @@ See also: [Tools](tools.md), [Configuration](configuration.md), [Loop System](lo | Agent | Mode | Description | |---|---|---| | `code` | `all` | Primary implementation agent. | -| `architect` | `primary` | Read-only planning agent. Produces marked plans for approval and execution. | +| `architect` | `primary` | Read-only planning agent. Authors the stored plan with `plan-write`/`plan-edit` for approval and execution; marked plans in chat are still captured. | | `auditor` | `subagent` | Read-only code review agent for convention-aware reviews. | | `auditor-loop` | `primary`, hidden | Internal auditor used by loop audit sessions. | diff --git a/docs/api/README.md b/docs/api/README.md index df0ed21d20..9008e9181f 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -104,7 +104,7 @@ Execution flow dialog with mode and model selection: ## Features -- **Plans** — architect produces marked plans that are auto-captured to SQL storage +- **Plans** — architect authors plans directly into SQL storage with `plan-write`/`plan-edit`; marked plans emitted in chat are still auto-captured - **Execution** — approved-plan launch paths plus direct `/execute-goal` loops in dedicated worktree sessions; plan loops can also target a configured remote opencode server (see [Configuration](_media/configuration.md#remotes)) - **Loops** — iterative coding/auditing with isolated git worktree and optional Docker sandbox - **Review Findings** — persistent, loop-scoped review findings across loop sessions @@ -126,7 +126,7 @@ The auditor agent is a read-only subagent that cannot edit source files or execu **Tool restrictions:** The auditor cannot use file-editing tools, planning tools, or loop-management tools. See [Auditor restrictions](_media/agents-and-commands.md#auditor-restrictions). -The architect agent operates as a read-only planner with message-level reinforcement via the `experimental.chat.messages.transform` hook. Final plans are rendered once in the assistant response between `` and `` markers, then auto-captured into SQL before execution approval. After user approval via the question tool, execution is dispatched programmatically — no additional LLM calls are needed. The user can view and edit the cached plan from the sidebar or command palette before or during execution. +The architect agent operates as a read-only planner with message-level reinforcement via the `experimental.chat.messages.transform` hook. Final plans are authored straight into SQL storage with the `plan-write` and `plan-edit` tools, which return a structure report the architect uses to fix warnings before asking for approval. A plan emitted in the assistant response between `` and `` markers is still auto-captured into the same row, so marker-only architect prompts keep working. After user approval via the question tool, execution is dispatched programmatically — no additional LLM calls are needed. The user can view and edit the stored plan from the sidebar or command palette before or during execution. ## Tools @@ -134,7 +134,7 @@ See [Tools reference](_media/tools.md) for full arguments, section-scoping behav Forge provides these tool groups: -- **Plan tools** — `plan-read`, `section-read` +- **Plan tools** — `plan-write`, `plan-edit`, `plan-read`, `section-read`, `plan-adjust` - **Review tools** — `review-write`, `review-read`, `review-delete` - **Loop tools** — `execute-plan`, `execute-goal`, `loop-cancel`, `loop-status` - **Sandbox shell** — `sh` when a sandbox manager is available @@ -223,7 +223,7 @@ The sidebar shows Forge's connection status and version. Captured plans live on ### Execution Dialog -Open the dialog from the command palette as `Execute plan` (default keybind `f`). The plan is sourced from the most recent architect message in the current session — the marked ` ... ` block is parsed out of the assistant's reply. If no marked plan exists in the session, the dialog will not open and you'll see a toast asking the architect to produce one first. +Open the dialog from the command palette as `Execute plan` (default keybind `f`). The plan is sourced from the stored plan for the current session, so the dialog shows exactly what `execute-plan` would run. When no stored row exists, the most recent architect message is scanned for a marked ` ... ` block as a fallback. If neither is present, the dialog will not open and you'll see a toast asking the architect to produce a plan first. The dialog provides full control over execution parameters: @@ -325,9 +325,9 @@ Plan with a smart model, execute with a fast model. The architect agent research ### How Plans Work -The architect is read-only and must output exactly one final plan between `` and `` markers. Forge auto-captures that marked plan into SQL storage for the current session. +The architect is read-only and authors the plan into SQL storage for the current session with `plan-write`, appending further phases with `plan-write { append: true }` and revising with `plan-edit`. Every write returns a structure report — line/character counts, the detected `Loop Name:`, the sections the decomposer would emit, and warnings — so structural problems surface before approval. If those tools are unavailable, a plan emitted once between `` and `` markers is auto-captured into the same row. -The captured plan is the source of truth for execution. The architect's own message in the chat history is the human-readable view; programmatic access is via the `plan-read` tool. +The stored plan is the source of truth for execution: `execute-plan`, the approval hook, and the TUI dialog all read it, and a marker-free assistant message can never replay an older chat plan over a newer tool-authored one. Programmatic access is via the `plan-read` tool. ### Execution @@ -377,7 +377,7 @@ Model and variant selection follows this priority order: ### Troubleshooting -- **No plan found** — Ensure the architect output included the `` / `` markers; the capture hook only stores plans wrapped in those markers. +- **No plan found** — Ensure the architect called `plan-write`, or that its response wrapped the plan in `` / `` markers; a plan emitted in chat without those markers is not captured. - **TUI shows no plan** — Plans are session-scoped on the server; switch to the session where the architect produced the plan. - **Need logs** — Set `logging.enabled` to `true`, and optionally `logging.debug` for verbose output. diff --git a/docs/api/_media/agents-and-commands.md b/docs/api/_media/agents-and-commands.md index cd3cc7d120..9c86b85069 100644 --- a/docs/api/_media/agents-and-commands.md +++ b/docs/api/_media/agents-and-commands.md @@ -9,7 +9,7 @@ See also: [Tools](tools.md), [Configuration](configuration.md), [Loop System](lo | Agent | Mode | Description | |---|---|---| | `code` | `all` | Primary implementation agent. | -| `architect` | `primary` | Read-only planning agent. Produces marked plans for approval and execution. | +| `architect` | `primary` | Read-only planning agent. Authors the stored plan with `plan-write`/`plan-edit` for approval and execution; marked plans in chat are still captured. | | `auditor` | `subagent` | Read-only code review agent for convention-aware reviews. | | `auditor-loop` | `primary`, hidden | Internal auditor used by loop audit sessions. | diff --git a/docs/api/_media/architecture.md b/docs/api/_media/architecture.md index d9e9119647..84320e4247 100644 --- a/docs/api/_media/architecture.md +++ b/docs/api/_media/architecture.md @@ -130,7 +130,7 @@ OpenCode Forge integrates with OpenCode through several hook points. The plugin ### Message Transform Hooks (`src/index.ts`) -- `experimental.chat.messages.transform` - Appends a `` block onto the last user message in architect sessions, instructing the model to search/analyze only and to wrap the final plan with `` markers. This is **message-level reinforcement** via OpenCode's transform hook, not a permission lockdown — the architect agent's `tools.exclude` config separately excludes `plan`, `plan_enter`, `plan_exit`. +- `experimental.chat.messages.transform` - Appends a `` block onto the last user message in architect sessions, instructing the model to search/analyze only and to author the final plan into storage with `plan-write`/`plan-edit`, falling back to `` markers when those tools are unavailable. This is **message-level reinforcement** via OpenCode's transform hook, not a permission lockdown — the architect agent's `tools.exclude` config separately excludes `plan`, `plan_enter`, `plan_exit`. ### Tool Execution Hooks @@ -154,7 +154,7 @@ A `PATCHED_SESSIONS` set deduplicates retries. Audit-only subagents use the stri ### Additional Hooks -- **Plan Capture** (`src/hooks/plan-capture.ts`) - Extracts `...end-->` markers from streaming assistant messages +- **Plan Capture** (`src/hooks/plan-capture.ts`) - Captures the session plan of record. The primary authoring path is the `plan-write` / `plan-edit` tools, which write directly to the session-scoped `plans` row. Marker capture of `...end-->` from assistant messages is the fallback path and runs both on streaming `message.part.updated` events and on assistant message completion (`message.updated`). - **Forge Session Attach** (`src/hooks/forge-session-attach.ts`) - Automatically attaches loops when new sessions are created - **Watchdog** (`src/hooks/watchdog.ts`) - Stall detection and recovery for loops @@ -176,7 +176,7 @@ All data access goes through typed repository interfaces created via factory fun | Repository | Purpose | Key Types | |---|---|---| | `LoopsRepo` | CRUD for loop rows | `LoopRow`, `LoopLargeFields` | -| `PlansRepo` | CRUD for plans | `PlanRow`, `PlansRepo` | +| `PlansRepo` | CRUD for plans (session-scoped plan of record read by `plan-read`, the approval hook, `execute-plan`, and the TUI plan dialog) | `PlanRow`, `PlansRepo` | | `ReviewFindingsRepo` | CRUD for review findings | `ReviewFindingRow`, `ReviewFindingsRepo` | | `SectionPlansRepo` | CRUD for milestone (section) plans used in decomposed loops | `SectionPlanRow`, `SectionPlansRepo` | | `LoopTransitionsRepo` | Append-only loop phase-transition log | `LoopTransitionRow` | diff --git a/docs/api/_media/tools.md b/docs/api/_media/tools.md index 5f46e618f3..2d70aa2f2f 100644 --- a/docs/api/_media/tools.md +++ b/docs/api/_media/tools.md @@ -9,6 +9,8 @@ See also: [Agents and Slash Commands](agents-and-commands.md), [Configuration](c | Tool | Purpose | Source | |---|---|---| | `plan-read` | Read the current session or loop plan, or list/search recent project plans. | [`src/tools/plan-kv.ts`](../src/tools/plan-kv.ts) | +| `plan-write` | Create, overwrite, or append the stored session plan. | [`src/tools/plan-authoring.ts`](../src/tools/plan-authoring.ts) | +| `plan-edit` | Edit the stored session plan by exact string replacement. | [`src/tools/plan-authoring.ts`](../src/tools/plan-authoring.ts) | | `section-read` | Read a section plan and status for the active loop session. | [`src/tools/section-read.ts`](../src/tools/section-read.ts) | | `plan-adjust` | Revise the section under audit and/or replace the remaining (not yet started) sections of the active loop plan; auditor-only, logged as a plan amendment. | [`src/tools/plan-adjust.ts`](../src/tools/plan-adjust.ts) | | `review-write` | Store a review finding. | [`src/tools/review.ts`](../src/tools/review.ts) | @@ -37,6 +39,33 @@ Arguments: | `session_id` | Explicit session ID to read from. | | `recent` | List or search recent project-scoped plans. | +### `plan-write` + +Creates, overwrites, or appends the plan stored for the current session — the plan of record read by `plan-read`, the approval hook, `execute-plan`, and the TUI plan dialog. Author long plans incrementally with `append` instead of emitting the whole plan in chat. Outer `` / `` markers are optional and stripped. Available to architect and architect-auto sessions; denied in code, auditor, auditor-loop, and feature-splitter sessions, and inside loop/audit sessions. + +Denied when the session owns a running loop: a running loop's plan is amended only with `plan-adjust` during a section audit. On success the tool persists the plan through the shared session-scoped write path and returns a structure report: a `Plan stored: N lines, M chars.` line, the detected `Loop Name:` when present, the numbered `Sections (N):` the decomposer would emit, and a `Warnings:` block when any apply. + +Arguments: + +| Argument | Description | +|---|---| +| `content` | Plan markdown. Use `` markers before each `## Phase` heading. Outer plan markers are optional and stripped. | +| `append` | Append to the existing stored plan instead of replacing it. Two newlines are inserted between the existing content and the new fragment. Creates the plan when none exists. | + +### `plan-edit` + +Edits the stored session plan by exact string replacement, the same way the Edit tool edits a file. Use `plan-read` to inspect the current text first; do not include `plan-read`'s `N: ` line-number prefixes in `oldString`. Subject to the same availability and running-loop guard as `plan-write`. On success the tool rewrites the plan through the shared session-scoped write path and returns a `Replaced N occurrence(s).` line followed by a structure report. + +Arguments: + +| Argument | Description | +|---|---| +| `oldString` | Exact text to replace, including indentation. Must match exactly once unless `replaceAll` is true. | +| `newString` | Replacement text. Must differ from `oldString`. | +| `replaceAll` | Replace every occurrence instead of requiring a unique match. | + +`plan-write` and `plan-edit` author the plan before execution; `plan-adjust` amends an already-running sectioned loop's plan during a section audit and is auditor-only. + ## Section Tools ### `section-read` diff --git a/docs/api/functions/createForgePlugin.md b/docs/api/functions/createForgePlugin.md index 70bf55317b..ea48b35a8f 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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L199) +Defined in: [index.ts:199](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L199) 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 576205641f..4f45577320 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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L53) +Defined in: [index.ts:53](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L53) ## Parameters diff --git a/docs/api/functions/createSessionDirectoryLookup.md b/docs/api/functions/createSessionDirectoryLookup.md index acd5e6b7fb..c436ab33dc 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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L134) +Defined in: [index.ts:134](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L134) ## Parameters diff --git a/docs/api/interfaces/CompactionConfig.md b/docs/api/interfaces/CompactionConfig.md index 0d867c4a74..3a14d2a71e 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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/types.ts#L152) +Defined in: [types.ts:152](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/types.ts#L154) +Defined in: [types.ts:154](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/types.ts#L156) +Defined in: [types.ts:156](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/types.ts#L156) Maximum context tokens for compaction. diff --git a/docs/api/interfaces/CreateParentSessionLookupOptions.md b/docs/api/interfaces/CreateParentSessionLookupOptions.md index 243a5832c3..2b2ec6e6e0 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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L43) +Defined in: [index.ts:43](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L43) ## 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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L44) +Defined in: [index.ts:44](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L44) *** @@ -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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L45) +Defined in: [index.ts:45](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L45) *** @@ -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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L47) +Defined in: [index.ts:47](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L47) #### 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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L46) +Defined in: [index.ts:46](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L46) *** @@ -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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L48) +Defined in: [index.ts:48](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L48) diff --git a/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md b/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md index 1c61fbcd0a..cd5333a67d 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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L128) +Defined in: [index.ts:128](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L128) ## 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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L129) +Defined in: [index.ts:129](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L129) *** @@ -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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L130) +Defined in: [index.ts:130](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L130) *** @@ -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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L131) +Defined in: [index.ts:131](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L131) diff --git a/docs/api/interfaces/PluginConfig.md b/docs/api/interfaces/PluginConfig.md index 319632aa31..5fd4cb8344 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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/types.ts#L216) +Defined in: [types.ts:216](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/types.ts#L244) +Defined in: [types.ts:244](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/types.ts#L228) +Defined in: [types.ts:228](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/types.ts#L232) +Defined in: [types.ts:232](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/types.ts#L222) +Defined in: [types.ts:222](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/types.ts#L240) +Defined in: [types.ts:240](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/types.ts#L218) +Defined in: [types.ts:218](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/types.ts#L226) +Defined in: [types.ts:226](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/types.ts#L230) +Defined in: [types.ts:230](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/types.ts#L236) +Defined in: [types.ts:236](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/types.ts#L220) +Defined in: [types.ts:220](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/types.ts#L234) +Defined in: [types.ts:234](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/types.ts#L224) +Defined in: [types.ts:224](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/types.ts#L238) +Defined in: [types.ts:238](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/types.ts#L246) +Defined in: [types.ts:246](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/types.ts#L242) +Defined in: [types.ts:242](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/types.ts#L242) TUI display configuration. diff --git a/docs/api/variables/VERSION.md b/docs/api/variables/VERSION.md index dc8f1e674b..e8b3c00403 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/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/version.ts#L1) +Defined in: [version.ts:1](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/version.ts#L1) diff --git a/docs/api/variables/default.md b/docs/api/variables/default.md index dadfae2a79..0f6f31a142 100644 --- a/docs/api/variables/default.md +++ b/docs/api/variables/default.md @@ -8,7 +8,7 @@ > `const` **default**: `object` -Defined in: [index.ts:798](https://github.com/chriswritescode-dev/opencode-forge/blob/b11aed8ae14edb6b50a1464afe62fb70adf2a8af/src/index.ts#L798) +Defined in: [index.ts:799](https://github.com/chriswritescode-dev/opencode-forge/blob/7692f27202f2fb152720f5d5c2214b0908746dbb/src/index.ts#L799) ## Type Declaration diff --git a/docs/architecture.md b/docs/architecture.md index d9e9119647..84320e4247 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -130,7 +130,7 @@ OpenCode Forge integrates with OpenCode through several hook points. The plugin ### Message Transform Hooks (`src/index.ts`) -- `experimental.chat.messages.transform` - Appends a `` block onto the last user message in architect sessions, instructing the model to search/analyze only and to wrap the final plan with `` markers. This is **message-level reinforcement** via OpenCode's transform hook, not a permission lockdown — the architect agent's `tools.exclude` config separately excludes `plan`, `plan_enter`, `plan_exit`. +- `experimental.chat.messages.transform` - Appends a `` block onto the last user message in architect sessions, instructing the model to search/analyze only and to author the final plan into storage with `plan-write`/`plan-edit`, falling back to `` markers when those tools are unavailable. This is **message-level reinforcement** via OpenCode's transform hook, not a permission lockdown — the architect agent's `tools.exclude` config separately excludes `plan`, `plan_enter`, `plan_exit`. ### Tool Execution Hooks @@ -154,7 +154,7 @@ A `PATCHED_SESSIONS` set deduplicates retries. Audit-only subagents use the stri ### Additional Hooks -- **Plan Capture** (`src/hooks/plan-capture.ts`) - Extracts `...end-->` markers from streaming assistant messages +- **Plan Capture** (`src/hooks/plan-capture.ts`) - Captures the session plan of record. The primary authoring path is the `plan-write` / `plan-edit` tools, which write directly to the session-scoped `plans` row. Marker capture of `...end-->` from assistant messages is the fallback path and runs both on streaming `message.part.updated` events and on assistant message completion (`message.updated`). - **Forge Session Attach** (`src/hooks/forge-session-attach.ts`) - Automatically attaches loops when new sessions are created - **Watchdog** (`src/hooks/watchdog.ts`) - Stall detection and recovery for loops @@ -176,7 +176,7 @@ All data access goes through typed repository interfaces created via factory fun | Repository | Purpose | Key Types | |---|---|---| | `LoopsRepo` | CRUD for loop rows | `LoopRow`, `LoopLargeFields` | -| `PlansRepo` | CRUD for plans | `PlanRow`, `PlansRepo` | +| `PlansRepo` | CRUD for plans (session-scoped plan of record read by `plan-read`, the approval hook, `execute-plan`, and the TUI plan dialog) | `PlanRow`, `PlansRepo` | | `ReviewFindingsRepo` | CRUD for review findings | `ReviewFindingRow`, `ReviewFindingsRepo` | | `SectionPlansRepo` | CRUD for milestone (section) plans used in decomposed loops | `SectionPlanRow`, `SectionPlansRepo` | | `LoopTransitionsRepo` | Append-only loop phase-transition log | `LoopTransitionRow` | diff --git a/docs/modules.md b/docs/modules.md index a4d158e791..0f3bfe715c 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -100,7 +100,7 @@ Translates OpenCode host events into loop actions and manages lifecycle side-eff | `host-side-effects.ts` | Termination side-effects (teardown, toast, log) | | `watchdog.ts` | Stall detection and recovery | | `plan-approval.ts` | Plan approval dedup/event gating + tool execute before/after hooks | -| `plan-capture.ts` | Plan capture from streaming assistant messages | +| `plan-capture.ts` | Marked-plan capture from streaming assistant message parts and on message completion | | `forge-session-attach.ts` | Auto-attach loops on `session.created` and `chat.message` events | | `loop-permission.ts` | Patches subagent permission rulesets on `session.created` for active-loop sessions | | `sandbox-tools.ts` | Sandbox tool before/after redirection hooks | @@ -242,7 +242,7 @@ Higher-level orchestration services coordinating between hooks, loop runtime, an | `execution.ts` | Unified command bus for plan execution (`createForgeExecutionService()`) | | `session-loop-resolver.ts` | Resolve which loop owns a given session | | `deterministic-decomposer.ts` | Slice a plan into milestones (`section_plans` rows) deterministically — called once at loop start by `execution.ts`, not a runtime loop phase | -| `plan-capture.ts` | Extract plan text from messages | +| `plan-capture.ts` | The single write path into a session-scoped `plans` row (`writeSessionPlanContent`), marked-plan capture from messages, and `resolveSessionPlanOfRecord` — the one implementation of "stored plan wins, chat capture is the fallback" | | `worktree-log.ts` | Log worktree completions | ### Key Interfaces @@ -368,6 +368,8 @@ Implements tools callable by AI agents during conversations. | `review-write` | `review.ts` | Store a code review finding (file, line, severity, description) | | `review-read` | `review.ts` | Retrieve review findings, filter by file or regex pattern | | `review-delete` | `review.ts` | Delete a review finding by file and line | +| `plan-write` | `plan-authoring.ts` | Architect-only: create, overwrite, or append the stored session plan; returns a structure report. Denied while the session owns a running loop. | +| `plan-edit` | `plan-authoring.ts` | Architect-only: edit the stored session plan by exact string replacement (`oldString`/`newString`/`replaceAll`); returns a structure report. | | `plan-read` | `plan-kv.ts` | Retrieve plans with pagination and pattern search | | `section-read` | `section-read.ts` | Retrieve a specific section of a plan | | `plan-adjust` | `plan-adjust.ts` | Auditor-only: revise the section under audit and/or replace the remaining sections of the active loop plan (logged as a plan amendment) | diff --git a/docs/tools.md b/docs/tools.md index 5f46e618f3..2d70aa2f2f 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -9,6 +9,8 @@ See also: [Agents and Slash Commands](agents-and-commands.md), [Configuration](c | Tool | Purpose | Source | |---|---|---| | `plan-read` | Read the current session or loop plan, or list/search recent project plans. | [`src/tools/plan-kv.ts`](../src/tools/plan-kv.ts) | +| `plan-write` | Create, overwrite, or append the stored session plan. | [`src/tools/plan-authoring.ts`](../src/tools/plan-authoring.ts) | +| `plan-edit` | Edit the stored session plan by exact string replacement. | [`src/tools/plan-authoring.ts`](../src/tools/plan-authoring.ts) | | `section-read` | Read a section plan and status for the active loop session. | [`src/tools/section-read.ts`](../src/tools/section-read.ts) | | `plan-adjust` | Revise the section under audit and/or replace the remaining (not yet started) sections of the active loop plan; auditor-only, logged as a plan amendment. | [`src/tools/plan-adjust.ts`](../src/tools/plan-adjust.ts) | | `review-write` | Store a review finding. | [`src/tools/review.ts`](../src/tools/review.ts) | @@ -37,6 +39,33 @@ Arguments: | `session_id` | Explicit session ID to read from. | | `recent` | List or search recent project-scoped plans. | +### `plan-write` + +Creates, overwrites, or appends the plan stored for the current session — the plan of record read by `plan-read`, the approval hook, `execute-plan`, and the TUI plan dialog. Author long plans incrementally with `append` instead of emitting the whole plan in chat. Outer `` / `` markers are optional and stripped. Available to architect and architect-auto sessions; denied in code, auditor, auditor-loop, and feature-splitter sessions, and inside loop/audit sessions. + +Denied when the session owns a running loop: a running loop's plan is amended only with `plan-adjust` during a section audit. On success the tool persists the plan through the shared session-scoped write path and returns a structure report: a `Plan stored: N lines, M chars.` line, the detected `Loop Name:` when present, the numbered `Sections (N):` the decomposer would emit, and a `Warnings:` block when any apply. + +Arguments: + +| Argument | Description | +|---|---| +| `content` | Plan markdown. Use `` markers before each `## Phase` heading. Outer plan markers are optional and stripped. | +| `append` | Append to the existing stored plan instead of replacing it. Two newlines are inserted between the existing content and the new fragment. Creates the plan when none exists. | + +### `plan-edit` + +Edits the stored session plan by exact string replacement, the same way the Edit tool edits a file. Use `plan-read` to inspect the current text first; do not include `plan-read`'s `N: ` line-number prefixes in `oldString`. Subject to the same availability and running-loop guard as `plan-write`. On success the tool rewrites the plan through the shared session-scoped write path and returns a `Replaced N occurrence(s).` line followed by a structure report. + +Arguments: + +| Argument | Description | +|---|---| +| `oldString` | Exact text to replace, including indentation. Must match exactly once unless `replaceAll` is true. | +| `newString` | Replacement text. Must differ from `oldString`. | +| `replaceAll` | Replace every occurrence instead of requiring a unique match. | + +`plan-write` and `plan-edit` author the plan before execution; `plan-adjust` amends an already-running sectioned loop's plan during a section audit and is auditor-only. + ## Section Tools ### `section-read` diff --git a/src/agents/auditor.ts b/src/agents/auditor.ts index 42ef0a7a1d..328c7ee0f1 100644 --- a/src/agents/auditor.ts +++ b/src/agents/auditor.ts @@ -1,6 +1,7 @@ import type { AgentDefinition } from './types' import { loadPrompt } from '../prompts/loader' import { hasSectionSummaryMarkers } from '../utils/section-summary' +import { PLAN_AUTHORING_TOOL_NAMES } from '../constants/loop' const AUDITOR_TOOL_EXCLUDES = [ 'apply_patch', @@ -16,6 +17,7 @@ const AUDITOR_TOOL_EXCLUDES = [ 'launch-group', 'group-status', 'group-cancel', + ...PLAN_AUTHORING_TOOL_NAMES, ] function buildBasePrompt(promptsDir?: string): string { diff --git a/src/agents/code.ts b/src/agents/code.ts index 977c9d666e..add6afb7b9 100644 --- a/src/agents/code.ts +++ b/src/agents/code.ts @@ -1,5 +1,6 @@ import type { AgentDefinition } from './types' import { loadPrompt } from '../prompts/loader' +import { PLAN_AUTHORING_TOOL_NAMES } from '../constants/loop' export function buildCodeAgent(promptsDir?: string): AgentDefinition { return { @@ -12,7 +13,7 @@ export function buildCodeAgent(promptsDir?: string): AgentDefinition { question: 'allow', }, tools: { - exclude: ['review-write','review-delete', 'plan', 'plan_enter', 'plan_exit'] + exclude: ['review-write','review-delete', 'plan', 'plan_enter', 'plan_exit', ...PLAN_AUTHORING_TOOL_NAMES] }, systemPrompt: loadPrompt(['agents', 'code.md'], promptsDir), } diff --git a/src/agents/feature-splitter.ts b/src/agents/feature-splitter.ts index abfd6d3d15..cd27c171cb 100644 --- a/src/agents/feature-splitter.ts +++ b/src/agents/feature-splitter.ts @@ -1,5 +1,6 @@ import type { AgentDefinition } from './types' import { loadPrompt } from '../prompts/loader' +import { PLAN_AUTHORING_TOOL_NAMES } from '../constants/loop' export function buildFeatureSplitterAgent(promptsDir?: string): AgentDefinition { return { @@ -9,7 +10,7 @@ export function buildFeatureSplitterAgent(promptsDir?: string): AgentDefinition mode: 'primary', hidden: true, tools: { - exclude: ['plan', 'plan_enter', 'plan_exit', 'question', 'write', 'edit', 'patch'], + exclude: ['plan', 'plan_enter', 'plan_exit', 'question', 'write', 'edit', 'patch', ...PLAN_AUTHORING_TOOL_NAMES], }, systemPrompt: loadPrompt(['agents', 'feature-splitter.md'], promptsDir), } diff --git a/src/constants/loop.ts b/src/constants/loop.ts index 305fcd6437..af0fe82b4d 100644 --- a/src/constants/loop.ts +++ b/src/constants/loop.ts @@ -3,6 +3,18 @@ import type { PluginConfig } from '../types' export type PermissionRule = { permission: string; pattern: string; action: 'allow' | 'deny' } +/** Maximum number of plan sections a loop may execute. Shared by the + * deterministic decomposer, section bootstrap, the TUI inline-plan preview, + * and the plan-adjust cap so all four agree. */ +export const MAX_TOTAL_SECTIONS = 24 + +/** + * Tools that author the session-scoped plan of record. Only the architect may + * call them, so every agent tool-exclude list and permission ruleset denies + * this one list rather than repeating the names. + */ +export const PLAN_AUTHORING_TOOL_NAMES = ['plan-write', 'plan-edit'] as const + /** * Resolves the full set of external directories loop/audit sessions may access: the shared temp * directory (always, default `/tmp/oc-forge`) plus any user-configured `loop.allowExternalDirectories`. @@ -35,6 +47,11 @@ export interface LoopPermissionRulesetOptions { * layered on top. Both are added AFTER the blanket `external_directory` deny so last-match-wins * resolution grants access to these paths while all others stay denied. */ +/** Deny rules for every plan-authoring tool, derived from the shared name list. */ +function planAuthoringDenyRules(): PermissionRule[] { + return PLAN_AUTHORING_TOOL_NAMES.map((permission) => ({ permission, pattern: '*', action: 'deny' as const })) +} + function buildExternalDirectoryAllowRules(allowDirectories: string[] = []): PermissionRule[] { const rules: PermissionRule[] = [] const dirs = [resolveOpencodeToolOutputDir(), ...allowDirectories] @@ -83,6 +100,7 @@ export function buildLoopPermissionRuleset(options: LoopPermissionRulesetOptions { permission: 'plan', pattern: '*', action: 'deny' }, { permission: 'plan_enter', pattern: '*', action: 'deny' }, { permission: 'plan_exit', pattern: '*', action: 'deny' }, + ...planAuthoringDenyRules(), { permission: 'execute-plan', pattern: '*', action: 'deny' }, { permission: 'execute-goal', pattern: '*', action: 'deny' }, { permission: 'question', pattern: '*', action: 'deny' }, @@ -128,6 +146,7 @@ export function buildAuditSessionPermissionRuleset(options: LoopPermissionRulese { permission: 'plan', pattern: '*', action: 'deny' }, { permission: 'plan_enter', pattern: '*', action: 'deny' }, { permission: 'plan_exit', pattern: '*', action: 'deny' }, + ...planAuthoringDenyRules(), { permission: 'execute-plan', pattern: '*', action: 'deny' }, { permission: 'execute-goal', pattern: '*', action: 'deny' }, { permission: 'question', pattern: '*', action: 'deny' }, diff --git a/src/hooks/plan-capture.ts b/src/hooks/plan-capture.ts index a90544fb03..14b5805676 100644 --- a/src/hooks/plan-capture.ts +++ b/src/hooks/plan-capture.ts @@ -1,5 +1,5 @@ import type { ToolContext } from '../tools/types' -import { captureLatestPlanForSession, captureMarkedPlanTextForSession } from '../services/plan-capture' +import { captureMarkedPlanTextForSession, capturePlanForCompletedMessage } from '../services/plan-capture' import { PLAN_END_MARKER, PLAN_START_MARKER } from '../utils/marked-plan-parser' const MESSAGE_PART_UPDATED_EVENT = 'message.part.updated' @@ -62,9 +62,10 @@ export function createPlanCaptureEventHook(ctx: ToolContext) { const info = event.properties?.info if (!sessionID || info?.role !== 'assistant' || typeof info?.time?.completed !== 'number') return + if (!info.id) return try { - const result = await captureLatestPlanForSession( + const result = await capturePlanForCompletedMessage( { client, plansRepo, @@ -72,7 +73,8 @@ export function createPlanCaptureEventHook(ctx: ToolContext) { directory, logger, }, - sessionID + sessionID, + info.id ) if (result.status === 'captured') { diff --git a/src/index.ts b/src/index.ts index 436fdfdb88..408808a1e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,7 @@ import { defaultGitService } from './utils/git-service' import { resolveSandboxContextForLoop, isSandboxConfigEnabled } from './sandbox/context' import { resolveForgeTempDir } from './utils/opencode-paths' import { isForgeWorktreeDir } from './workspace/forge-naming' -import { resolveLoopAllowedDirectories } from './constants/loop' +import { MAX_TOTAL_SECTIONS, resolveLoopAllowedDirectories } from './constants/loop' import { mkdirSync } from 'fs' import { createSandboxManager } from './sandbox/manager' import type { PluginConfig, CompactionConfig } from './types' @@ -37,7 +37,7 @@ import { createGroupOrchestrator, mapLoopStateToOutcome, type GroupOrchestrator, import { parseModelString } from './utils/model-fallback' import { parseFeatureList } from './utils/feature-list-parser' import { classifyArchitectOutput } from './utils/architect-auto-output' -import { captureLatestPlanForSession } from './services/plan-capture' +import { resolveSessionPlanOfRecord } from './services/plan-capture' import { createForgeExecutionService, type ForgeExecutionRequestContext } from './services/execution' export interface CreateParentSessionLookupOptions { @@ -548,14 +548,14 @@ export function createForgePlugin(config: PluginConfig): Plugin { }, async capturePlan(sessionId) { - const result = await captureLatestPlanForSession({ + const captured = await resolveSessionPlanOfRecord({ client: forgeClient, plansRepo, projectId, directory, logger, }, sessionId) - return { captured: result.status === 'captured' || result.status === 'already-current' } + return { captured } }, async classifyArchitectFailure(sessionId) { @@ -768,16 +768,17 @@ export function createForgePlugin(config: PluginConfig): Plugin { userMessage.parts.push({ type: 'text', text: ` -READ-ONLY mode: no file edits, no destructive commands. Search and analyze only. Ask clarifying questions during research on scope, intent, or tradeoffs. - -When emitting the final plan: -- Wrap the plan in \`\` and \`\` (each on its own line) -- Include one plain machine-readable \`Loop Name: short-slug\` line near the top of the marked plan, immediately after the objective. Do not emit loop name as a markdown heading or bullet. -- Use exactly one \`\` marker per executable phase; place it immediately before that phase's \`## Phase\` heading -- Do not insert \`\` before \`### Files\`, \`### Edits\`, \`### Acceptance Criteria\`, or \`### Verification\` -- Shared \`## Decisions\` / \`## Conventions\` / \`## Key Context\` blocks go after all sections (no preceding marker) -- After the plan, call the \`question\` tool with options: "New session", "Execute here", "Loop" -- If the user selects "Loop", launch it by calling the \`loop\` tool (the stored plan is used automatically); do not re-run the question tool. +READ-ONLY mode: no file edits, no destructive commands. Search and analyze only. Ask clarifying questions during research on scope, intent, or tradeoffs. Writing the plan with plan-write/plan-edit is expected and is not a file edit. + +When producing the final plan: +- Author it into storage with \`plan-write\`; append further phases with \`plan-write { append: true }\`; revise with \`plan-edit\`. Prefer this over emitting the full plan in chat. If \`plan-write\` is unavailable, emit the plan once in chat wrapped in \`\` / \`\` markers; that is captured into the same stored plan. +- Include one plain machine-readable \`Loop Name: short-slug\` line near the top, immediately after the objective. Not a heading or bullet. +- Use exactly one \`\` marker per executable phase, immediately before that phase's \`## Phase\` heading, and at most ${MAX_TOTAL_SECTIONS} phases. +- Do not insert \`\` before \`### Files\`, \`### Edits\`, \`### Acceptance Criteria\`, or \`### Verification\`. +- Shared \`## Decisions\` / \`## Conventions\` / \`## Key Context\` blocks go after all sections (no preceding marker). +- Read the structure report returned by every plan write and fix warnings before asking for approval. +- Then call the \`question\` tool with options: "New session", "Execute here", "Loop". +- If the user selects "Loop", launch it with the \`execute-plan\` tool (the stored plan is used automatically); do not re-run the question tool. `, synthetic: true, }) diff --git a/src/loop/service.ts b/src/loop/service.ts index 64f794a039..14dec618d5 100644 --- a/src/loop/service.ts +++ b/src/loop/service.ts @@ -23,12 +23,11 @@ import { parseSectionSummary as _parseSectionSummary } from './section-summary' import { terminationStatusFor, terminationReasonToString, type TerminationReason } from './termination' import { generateUniqueName } from './name-uniqueness' import { bumpRecurrence, findingRecurrenceKey } from './finding-recurrence' +import { MAX_TOTAL_SECTIONS } from '../constants/loop' export const MAX_RETRIES = 3 const STALL_TIMEOUT_MS = 60_000 const MAX_CONSECUTIVE_STALLS = 5 -/** Hard cap on the total number of sections a loop may have after amendment. */ -export const MAX_TOTAL_SECTIONS = 24 export type LoopChangeReason = | 'insert' | 'delete' | 'terminate' diff --git a/src/prompts/agents/architect-auto.md b/src/prompts/agents/architect-auto.md index 55edfbe0b3..7874e11a42 100644 --- a/src/prompts/agents/architect-auto.md +++ b/src/prompts/agents/architect-auto.md @@ -25,7 +25,7 @@ If the request is too vague or lacks sufficient detail to produce a concrete imp Do not output anything else in that case. # Plan Format -When you have enough information, produce a detailed implementation plan wrapped with `` and `` markers (each on its own line). The plan body must follow this format: +When you have enough information, author a detailed implementation plan into storage with `plan-write`: the objective, `Loop Name:` line, and Phase 1 in the first write, then each subsequent phase with `plan-write { append: true }`, then a final `plan-write { append: true }` for `## Decisions` / `## Conventions` / `## Key Context`. Revise with `plan-edit`, not by rewriting the whole plan. The plan body must follow this format: - **Objective**: What we're building and why - **Loop Name**: A short, machine-friendly name (1-3 words) on its own line: `Loop Name: short-slug` @@ -40,7 +40,7 @@ When you have enough information, produce a detailed implementation plan wrapped If the feature brief contains multiple source issues, tickets, or PRD requirements, treat them as intentionally grouped because of non-trivial implementation coupling. Plan the shared architectural changes once, keep every source reference traceable in the objective or key context, and keep phases reviewable instead of expanding scope beyond the grouped brief. -After the marked plan, do NOT call the `question` tool. Do NOT ask "Shall I proceed?" or any variant. The plan is auto-captured and dispatched by the orchestrator. +After authoring the plan, do NOT call the `question` tool. Do NOT ask "Shall I proceed?" or any variant. Markers are no longer required; a stored plan is detected automatically. If you cannot use the plan tools, fall back to wrapping the plan in `` / ``. The plan is auto-captured and dispatched by the orchestrator. ## File paths in plans All file references in your plan output MUST be repo-relative paths (e.g. `src/services/auth.ts`, `test/auth.test.ts`). Never include absolute host paths or home-relative paths. diff --git a/src/prompts/agents/architect.md b/src/prompts/agents/architect.md index b1ecd11279..2a4a3724ba 100644 --- a/src/prompts/agents/architect.md +++ b/src/prompts/agents/architect.md @@ -39,32 +39,37 @@ All file references in your plan output MUST be repo-relative paths (e.g. `src/s ## Constraints -You are in READ-ONLY mode **for file system operations**. You must NOT directly edit source files, run destructive commands, or make code changes. You may only read, search, and analyze the codebase. +You are in READ-ONLY mode **for file system operations**. You must NOT directly edit source files, run destructive commands, or make code changes. You may only read, search, and analyze the codebase. Writing the plan through the plan-write and plan-edit tools is expected and is not a file edit. You MUST follow a gated planning flow: 1. **Intent discovery before planning**: Do not start drafting the implementation plan eagerly. First establish the user's intention: what problem are we solving, why it matters, what success looks like, and what scope boundaries apply. If any of those are unclear from the request and codebase, use the `question` tool before moving into plan output. 2. **Clarifying questions (during research and design)**: As you inspect the codebase, use the `question` tool to ask clarifying questions that sharpen the goal, the "why", and the scope. Do this in-line with discovery — whenever the inspection results surface an ambiguity, a branching decision, or a missing piece of intent, ask. See "Clarifying questions during research" below. -3. **Plan output and execution checkpoint**: Only after intent, problem, success criteria, and scope are sufficiently clear, output a brief intention/goal/approach summary followed by the marked implementation plan. After the plugin auto-captures the marked plan, use the `question` tool to collect execution approval with the three canonical options. Never ask for approval via plain text output. +3. **Plan output and execution checkpoint**: Only after intent, problem, success criteria, and scope are sufficiently clear, output a brief intention/goal/approach summary, then author the plan into storage with `plan-write` (and `plan-edit` for revisions). After the plan is stored, use the `question` tool to collect execution approval with the three canonical options. Never ask for approval via plain text output. ## Project Plan Storage You have access to specialized tools for managing implementation plans: -- `plan-read`: Retrieve the plan. Supports pagination with offset/limit, pattern search, and optional `loop_name` targeting. +- `plan-read`: Read the stored plan. Supports offset/limit pagination, regex `pattern` search, and `loop_name` targeting. +- `plan-write`: Create, overwrite, or append (`append: true`) the stored plan for this session. This is the plan of record. +- `plan-edit`: Exact-string replacement on the stored plan (`oldString`, `newString`, `replaceAll`), identical in semantics to the Edit tool. -The plugin auto-captures marked plans from your assistant responses into SQL storage. Wrap your final plan with `` and `` markers (each on its own line) to trigger auto-capture. +Author long plans incrementally: one `plan-write` for the objective, `Loop Name:` line and Phase 1, then one `plan-write { append: true }` per subsequent phase, then a final `plan-write { append: true }` for `## Decisions` / `## Conventions` / `## Key Context`. Never emit the full plan in chat — it wastes tokens and long plans get truncated mid-message. Revise with `plan-edit`, not by rewriting the whole plan. Every write returns a structure report (section count, section titles, detected `Loop Name:`, warnings); read it and fix any warning before asking for approval. + +Legacy fallback: a plan wrapped in `` / `` markers (each on its own line) is still auto-captured from your message text. Use this only if the plan tools are unavailable. ## Workflow 1. **Research (with inline clarifying questions)** — Start by identifying the requested outcome and the underlying problem. If the request describes only a mechanism (for example, "change X" or "add Y") and the why/success criteria are not obvious, ask before committing to an approach. Then continue with structural discovery and dependency tracing (what depends on X, where does Y live). Prefer launching explore agents early for broader research because they can run in parallel. Use direct inspection (Read/Grep/Glob) yourself when you need to narrow a specific file or symbol, then read relevant files and delegate follow-up research on conventions, decisions, and prior plans. **As the inspection surfaces ambiguity, branching decisions, or gaps in intent, pause and use the `question` tool to ask the user.** Do not batch all questions for the end — ask them as they arise so later research is informed by the answers. See "Clarifying questions during research" below for what to ask and when. 2. **Design** — Consider approaches, weigh tradeoffs, and ask any remaining clarifying questions via the `question` tool before outputting the plan. Do not output a plan while the core problem, why, or acceptance criteria are still inferred rather than known. -3. **Plan** — After research and design, output a concise summary followed immediately by the detailed implementation plan in your assistant response: +3. **Plan** — After research and design, output a concise summary followed immediately by the detailed implementation plan authored into storage: - Start with a short unmarked summary containing **Intention**, **Goal**, and **Approach**. Keep it brief: 1-3 sentences for intention/goal and 2-4 bullets for approach. - - After the summary, wrap exactly one final plan with `` and `` markers (each on its own line) - - Do NOT wrap only summaries, design options, or partial drafts - - The marked plan body must follow the existing detailed plan format: Objective, a machine-readable `Loop Name: short-slug` line, Phases with file targets/edits/acceptance criteria/verification, Decisions, Conventions, Key Context - - The marked plan must be extremely detailed and execution-ready: name exact files, exact symbols/functions/types to change, concrete data shapes, command wiring, expected control flow, error handling, and validation steps + - Author the plan into storage with `plan-write`: the objective, the `Loop Name:` line, and Phase 1 in the first write; each subsequent phase with `plan-write { append: true }`; `## Decisions` / `## Conventions` / `## Key Context` in a final appended write. Revise with `plan-edit`, not by rewriting the whole plan. + - Do NOT wrap only summaries, design options, or partial drafts in storage; the stored plan must be the complete, execution-ready plan. + - The stored plan body must follow the existing detailed plan format: Objective, a machine-readable `Loop Name: short-slug` line, Phases with file targets/edits/acceptance criteria/verification, Decisions, Conventions, Key Context + - The stored plan must be extremely detailed and execution-ready: name exact files, exact symbols/functions/types to change, concrete data shapes, command wiring, expected control flow, error handling, and validation steps - Every phase must include explicit implementation instructions, precise edits per file, acceptance criteria, and targeted verification commands or assertions the code agent can run -4. **Approve** — After the marked plan is output and auto-captured, call the question tool to get explicit approval with these options: + - Cap the plan at 24 `` phases; sections beyond 24 are dropped at loop start. +4. **Approve** — After the plan is authored into storage and the structure report is warning-free, call the question tool to get explicit approval with these options: - "New session" — Create a new session and send the plan to the code agent - "Execute here" — Execute the plan in the current session using the code agent (same session, no context switch) - "Loop" — Execute using an iterative development loop in an isolated git worktree (Docker sandbox is used automatically when available). When the user selects "Loop", launch it by calling the `execute-plan` tool with a short `title`; the stored plan is used automatically. Do not re-ask the question. @@ -185,4 +190,4 @@ After research, clarifying questions, and design, directly output a brief unmark - **How (brief sketch)**: 2-4 bullets on the recommended approach and proposed scope (files to touch, features to build/modify) - **Key findings**: Short list of code patterns, conventions, and constraints discovered that shape the approach -Immediately after that summary, output the final detailed plan wrapped with outer plan markers (see system reminder for marker syntax). Then use the `question` tool to ask for execution approval with the three canonical options: "New session", "Execute here", and "Loop". If the user selects "Loop", call the `execute-plan` tool to start it (the plan is already stored); for "New session" and "Execute here", the approval is handled automatically. +Immediately after that summary, author the plan with `plan-write` until the structure report is warning-free, then call the `question` tool to ask for execution approval with the three canonical options: "New session", "Execute here", and "Loop". If the user selects "Loop", call the `execute-plan` tool to start it (the plan is already stored); for "New session" and "Execute here", the approval is handled automatically. diff --git a/src/services/deterministic-decomposer.ts b/src/services/deterministic-decomposer.ts index 9f3693fea0..72b7bceade 100644 --- a/src/services/deterministic-decomposer.ts +++ b/src/services/deterministic-decomposer.ts @@ -1,12 +1,25 @@ import type { ParsedSection } from '../utils/section-capture' +import { computeFenceMask } from '../utils/markdown-fences' +import { MAX_TOTAL_SECTIONS } from '../constants/loop' -const SECTION_MARKER_REGEX = /^$/ +export 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']) -export function decomposeDeterministically(planText: string, opts?: { maxSections?: number }): ParsedSection[] { - const maxSections = opts?.maxSections ?? 12 +interface SectionMarkerScan { + lines: string[] + inFence: boolean[] + markerIndices: number[] +} + +/** + * 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 + * marker count can never disagree with what the decomposer splits on. + */ +function scanSectionMarkers(planText: string): SectionMarkerScan { const text = planText .replace(/\s*\n?/, '') .replace(/\n?\s*/, '') @@ -14,34 +27,44 @@ export function decomposeDeterministically(planText: string, opts?: { maxSection const rawLines = text.split('\n') // Track fence state to skip markers inside ``` blocks - const inFence: boolean[] = [] - let fence = false - for (let i = 0; i < rawLines.length; i++) { - if (/^```/.test(rawLines[i].trim())) fence = !fence - inFence.push(fence) - } + const rawInFence = computeFenceMask(rawLines) // Strip legacy paired markers from output (but they do NOT trigger sectioning) const lines = rawLines.map((l, i) => - !inFence[i] && LEGACY_SECTION_PAIR_REGEX.test(l.trim()) ? null : l + !rawInFence[i] && LEGACY_SECTION_PAIR_REGEX.test(l.trim()) ? null : l ).filter((l): l is string => l !== null) - // Note: also recompute inFence after stripping — re-derive aligned with the new lines array. - // Simpler: re-scan inFence against the filtered lines. - const inFence2: boolean[] = [] - let fence2 = false - for (let i = 0; i < lines.length; i++) { - if (/^```/.test(lines[i].trim())) fence2 = !fence2 - inFence2.push(fence2) - } + // Re-scan fence state aligned with the filtered lines array. + const inFence = computeFenceMask(lines) const markerIndices: number[] = [] for (let i = 0; i < lines.length; i++) { - if (!inFence2[i] && SECTION_MARKER_REGEX.test(lines[i].trim())) { + if (!inFence[i] && SECTION_MARKER_REGEX.test(lines[i].trim())) { markerIndices.push(i) } } - if (markerIndices.length === 0) return [] + return { lines, inFence, markerIndices } +} + +export interface PlanDecomposition { + sections: ParsedSection[] + /** + * Unfenced `` markers found, before the cap and before + * empty-body skipping. Reported from the same scan that produced `sections`, + * so a marker count can never disagree with what was split on. + */ + markerCount: number +} + +/** + * Decomposes a plan and reports how many markers were seen. Callers that need + * both counts use this so they do not have to scan the plan twice. + */ +export function decomposePlanSections(planText: string, opts?: { maxSections?: number }): PlanDecomposition { + const maxSections = opts?.maxSections ?? MAX_TOTAL_SECTIONS + const { lines, inFence, markerIndices } = scanSectionMarkers(planText) + + if (markerIndices.length === 0) return { sections: [], markerCount: 0 } const sections: ParsedSection[] = [] @@ -52,7 +75,7 @@ export function decomposeDeterministically(planText: string, opts?: { maxSection let endLine = nextMarker for (let j = startLine; j < nextMarker; j++) { const trimmed = lines[j].trim() - if (!inFence2[j] && STOP_HEADINGS.some(h => trimmed.startsWith(h))) { + if (!inFence[j] && STOP_HEADINGS.some(h => trimmed.startsWith(h))) { endLine = j break } @@ -76,5 +99,10 @@ export function decomposeDeterministically(planText: string, opts?: { maxSection sections.push({ index: sections.length, title, content }) } - return sections + return { sections, markerCount: markerIndices.length } +} + +/** Sections only. The common case; see `decomposePlanSections` for the count. */ +export function decomposeDeterministically(planText: string, opts?: { maxSections?: number }): ParsedSection[] { + return decomposePlanSections(planText, opts).sections } \ No newline at end of file diff --git a/src/services/group-orchestrator.ts b/src/services/group-orchestrator.ts index 46fc9566f6..8f925f50c0 100644 --- a/src/services/group-orchestrator.ts +++ b/src/services/group-orchestrator.ts @@ -551,10 +551,11 @@ export function createGroupOrchestrator(deps: { return [{ group, features }] } - const groups = repo.listGroups(projectId) - return groups.map(group => ({ + // One query for every group's features instead of one per group. + const byGroup = repo.listFeaturesByGroup(projectId) + return repo.listGroups(projectId).map(group => ({ group, - features: repo.listFeatures(projectId, group.groupId), + features: byGroup.get(group.groupId) ?? [], })) } diff --git a/src/services/plan-capture.ts b/src/services/plan-capture.ts index ab76146bea..cc538d7464 100644 --- a/src/services/plan-capture.ts +++ b/src/services/plan-capture.ts @@ -1,8 +1,13 @@ import type { ForgeClient } from '../client/port' import type { PlansRepo } from '../storage/repos/plans-repo' import type { Logger } from '../types' -import type { PlanCaptureMessage } from '../utils/marked-plan-parser' -import { extractMarkedPlan, inspectLatestMarkedPlan, sanitizePlanPaths } from '../utils/marked-plan-parser' +import type { LatestMarkedPlanInspection, PlanCaptureMessage } from '../utils/marked-plan-parser' +import { + PLAN_CAPTURE_MESSAGE_LIMIT, + extractMarkedPlan, + inspectLatestMarkedPlan, + sanitizePlanPaths, +} from '../utils/marked-plan-parser' export interface CaptureLatestPlanDeps { client: ForgeClient @@ -19,6 +24,9 @@ type CaptureLatestPlanResult = | { status: 'invalid'; reason: string } | { status: 'read-failed'; error: unknown } +/** The only two outcomes a direct write can produce. */ +type WriteSessionPlanResult = Extract + type ReadRecentMessagesResult = | { status: 'found'; messages: PlanCaptureMessage[] } | { status: 'missing' } @@ -31,12 +39,19 @@ interface CaptureMarkedPlanTextDeps { logger: Logger } -function writeCapturedPlanForSession( +/** + * The single write path into a session-scoped `plans` row. Sanitizes + * project-dir prefixes from the plan text and no-ops when the sanitized + * content already matches the stored row. All session-scoped plan writes — + * marked-plan capture, latest-plan capture, and the `plan-write` tool — go + * through here so sanitization and dedupe cannot diverge. + */ +export function writeSessionPlanContent( deps: CaptureMarkedPlanTextDeps, sessionID: string, planText: string, messageId?: string -): CaptureLatestPlanResult { +): WriteSessionPlanResult { const sanitized = sanitizePlanPaths(planText, deps.directory) if (sanitized !== planText) { deps.logger.log(`plan-capture: stripped project-dir prefix from plan for session ${sessionID}`) @@ -68,7 +83,30 @@ export function captureMarkedPlanTextForSession( return { status: 'invalid', reason: extraction.reason } } - return writeCapturedPlanForSession(deps, sessionID, extraction.planText, messageId) + return writeSessionPlanContent(deps, sessionID, extraction.planText, messageId) +} + +/** + * Maps an `inspectLatestMarkedPlan` outcome onto a capture result, persisting + * the plan when one was found. Shared by both message-scanning capture paths so + * their logging and status mapping cannot diverge. + */ +function resultForInspection( + deps: CaptureMarkedPlanTextDeps, + sessionID: string, + inspection: LatestMarkedPlanInspection +): CaptureLatestPlanResult { + if (inspection.status === 'found') { + return writeSessionPlanContent(deps, sessionID, inspection.planText, inspection.messageId) + } + + if (inspection.status === 'invalid') { + deps.logger.log(`plan-capture: invalid marked plan in session ${sessionID}: ${inspection.reason}`) + return { status: 'invalid', reason: inspection.reason } + } + + deps.logger.log(`plan-capture: no valid marked plan found in session ${sessionID}`) + return { status: 'not-found' } } async function readRecentMessages( @@ -79,7 +117,7 @@ async function readRecentMessages( const messages = await deps.client.session.messages({ sessionID, directory: deps.directory, - limit: 20, + limit: PLAN_CAPTURE_MESSAGE_LIMIT, }) if (messages && messages.length > 0) { @@ -93,10 +131,27 @@ async function readRecentMessages( } } -export async function captureLatestPlanForSession( +/** + * Completion-scoped capture. Persists a marked plan only when the selected + * message (or split-message end marker) is the assistant message currently + * completing. A marker-free completion must never replay an older marked + * response over a newer tool-authored row, so this path skips the write when + * the inspected plan belongs to a different message. + * + * Reads recent messages through `client.session.messages` (best effort) and + * delegates selection to `inspectLatestMarkedPlan`, then filters by id. + */ +export async function capturePlanForCompletedMessage( deps: CaptureLatestPlanDeps, - sessionID: string + sessionID: string, + completingMessageId: string ): Promise { + // Snapshot the session row before awaiting message retrieval. A concurrent + // `plan-write`, `plan-edit`, or completion hook can store a newer revision + // while `session.messages` is pending; storage is the plan of record, so the + // stale marked message must never overwrite it. + const snapshot = deps.plansRepo.getForSession(deps.projectId, sessionID) + const read = await readRecentMessages(deps, sessionID) if (read.status === 'read-failed') return read if (read.status === 'missing') { @@ -104,17 +159,96 @@ export async function captureLatestPlanForSession( return { status: 'not-found' } } + // Revalidate storage after the await: if a row appeared or changed during + // message retrieval, preserve it and report the stored plan as current. + // Compare both content and `updatedAt`: a concurrent `plan-write`/`plan-edit` + // revision can produce the same content as the snapshot at a newer timestamp + // (e.g. an A→B→A edit), and a same-millisecond write can produce different + // content at the same timestamp. Either field differing means storage moved + // while messages were being read, so the stale marked message must not + // overwrite it. + const current = deps.plansRepo.getForSession(deps.projectId, sessionID) + if (current && (!snapshot || current.content !== snapshot.content || current.updatedAt !== snapshot.updatedAt)) { + deps.logger.log( + `plan-capture: session row changed during message read for ${sessionID}; preserving stored plan`, + ) + return { status: 'already-current', planText: current.content } + } + const inspection = inspectLatestMarkedPlan(read.messages) + const selectedMessageId = inspection.status === 'missing' ? undefined : inspection.messageId - if (inspection.status === 'found') { - return writeCapturedPlanForSession(deps, sessionID, inspection.planText, inspection.messageId) + if (selectedMessageId !== completingMessageId) { + deps.logger.log( + `plan-capture: completing message ${completingMessageId} has no marked plan for session ${sessionID}`, + ) + return { status: 'not-found' } } - if (inspection.status === 'invalid') { - deps.logger.log(`plan-capture: invalid marked plan in session ${sessionID}: ${inspection.reason}`) - return { status: 'invalid', reason: inspection.reason } + return resultForInspection(deps, sessionID, inspection) +} + +/** + * Legacy latest-message capture. Scans recent assistant messages for the + * newest marked plan and persists it regardless of which message currently + * completing. Used only when no session-scoped `plans` row exists yet (e.g. + * `execute-plan` with no inline plan and no prior `plan-write`, or the group + * orchestrator capturing an architect's freshly emitted plan). Storage is the + * plan of record; new writes go through `capturePlanForCompletedMessage` or + * `plan-write` so this path must not be invoked after a row exists. + */ +export async function captureLatestPlanForSession( + deps: CaptureLatestPlanDeps, + sessionID: string +): Promise { + const read = await readRecentMessages(deps, sessionID) + if (read.status === 'read-failed') return read + if (read.status === 'missing') { + deps.logger.log(`plan-capture: no messages found for session ${sessionID}`) + return { status: 'not-found' } } - deps.logger.log(`plan-capture: no valid marked plan found in session ${sessionID}`) - return { status: 'not-found' } + // Storage revalidation: the caller invokes this legacy path only when no + // row existed at check time, but `session.messages` is awaited above and a + // concurrent `plan-write` (or completion hook) may have authored a newer + // session row during that await. Storage is the plan of record, so never + // replay an older marked assistant message over it. Return `already-current` + // so the caller still proceeds against the stored row. + const existing = deps.plansRepo.getForSession(deps.projectId, sessionID) + if (existing) { + deps.logger.log( + `plan-capture: session row appeared during message read for ${sessionID}; preserving stored plan`, + ) + return { status: 'already-current', planText: existing.content } + } + + return resultForInspection(deps, sessionID, inspectLatestMarkedPlan(read.messages)) +} + +/** + * Resolves the plan of record for a session: the stored `plans` row when one + * exists, else a legacy marked-message capture. The single implementation of + * "storage wins, chat is the fallback" for every server-side consumer, so the + * precedence rule and its post-await race handling live in one place. + * + * Checking the row before reading messages also skips the `session.messages` + * round trip entirely in the common `plan-write` case. The second check covers + * a read failure, where `captureLatestPlanForSession` returns before its own + * revalidation. + */ +export async function resolveSessionPlanOfRecord( + deps: CaptureLatestPlanDeps, + sessionID: string +): Promise { + if (deps.plansRepo.getForSession(deps.projectId, sessionID)) return true + + const capture = await captureLatestPlanForSession(deps, sessionID) + if (capture.status === 'captured' || capture.status === 'already-current') return true + + if (deps.plansRepo.getForSession(deps.projectId, sessionID)) { + deps.logger.log(`plan-capture: using stored plan for session ${sessionID} (no marked plan in chat)`) + return true + } + + return false } diff --git a/src/services/section-bootstrap.ts b/src/services/section-bootstrap.ts index 3fda315055..8fb96cdecd 100644 --- a/src/services/section-bootstrap.ts +++ b/src/services/section-bootstrap.ts @@ -1,4 +1,5 @@ import { decomposeDeterministically } from './deterministic-decomposer' +import { MAX_TOTAL_SECTIONS } from '../constants/loop' import type { LoopsRepo } from '../storage/repos/loops-repo' import type { SectionPlansRepo } from '../storage/repos/section-plans-repo' @@ -15,7 +16,7 @@ export interface ApplyPlanDecompositionArgs { * loop start (attachLoopToSession) and loop restart (handleLoopRestart). */ export function applyPlanDecomposition(args: ApplyPlanDecompositionArgs): { totalSections: number } { const { projectId, loopName, planText, loopsRepo, sectionPlansRepo } = args - const maxSections = args.maxSections ?? 12 + const maxSections = args.maxSections ?? MAX_TOTAL_SECTIONS const sections = decomposeDeterministically(planText, { maxSections }) if (sections.length > 0 && sectionPlansRepo) { sectionPlansRepo.bulkInsert({ projectId, loopName, sections }) diff --git a/src/tools/index.ts b/src/tools/index.ts index 7c349a7d23..66b02e626b 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,6 +1,7 @@ import { tool } from '@opencode-ai/plugin' import { createReviewTools } from './review' import { createPlanTools } from './plan-kv' +import { createPlanAuthoringTools } from './plan-authoring' import { createLoopTools } from './loop' import { createGroupTools } from './group' import { createSectionReadTool } from './section-read' @@ -19,6 +20,7 @@ export function createTools(ctx: ToolContext): Record and markers, or pass it directly as the plan argument.' - } - source = { kind: 'stored', sessionId: context.sessionID } + if (!resolved) { + return 'No plan found. Author it with the plan-write tool, wrap the final plan in and markers, or pass it directly as the plan argument.' } + source = { kind: 'stored', sessionId: context.sessionID } } else { source = { kind: 'inline', planText: args.plan } } diff --git a/src/tools/plan-adjust.ts b/src/tools/plan-adjust.ts index 7e29d15e6a..d551e7108b 100644 --- a/src/tools/plan-adjust.ts +++ b/src/tools/plan-adjust.ts @@ -1,6 +1,6 @@ import { tool } from '@opencode-ai/plugin' import type { ToolContext } from './types' -import { MAX_TOTAL_SECTIONS } from '../loop/service' +import { MAX_TOTAL_SECTIONS } from '../constants/loop' const z = tool.schema diff --git a/src/tools/plan-authoring.ts b/src/tools/plan-authoring.ts new file mode 100644 index 0000000000..e14b2762ce --- /dev/null +++ b/src/tools/plan-authoring.ts @@ -0,0 +1,158 @@ +import { tool } from '@opencode-ai/plugin' +import type { ToolContext } from './types' +import { normalizePastedPlanText } from '../utils/marked-plan-parser' +import { writeSessionPlanContent } from '../services/plan-capture' +import { formatPlanStructureSummary, summarizePlanStructure } from '../utils/plan-structure' + +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. + */ +function assertWritableSession(ctx: ToolContext, sessionID: string): string | null { + const loopName = ctx.loop.service.resolveLoopName(sessionID) + if (loopName) { + return ( + `Cannot modify the plan from an active loop session (loop: ${loopName}). ` + + `The stored plan for a running loop is amended with plan-adjust during a section audit.` + ) + } + return null +} + +/** + * Wraps `normalizePastedPlanText` and maps its reasons to user-facing messages + * for the `plan-write` tool. Returns the normalized body on success. + */ +function normalizeFragment(content: string): { ok: true; text: string } | { ok: false; message: string } { + const result = normalizePastedPlanText(content) + if (result.ok) { + return { ok: true, text: result.planText } + } + switch (result.reason) { + case 'empty': + return { ok: false, message: 'content is empty.' } + case 'multiple': + return { ok: false, message: 'content contains more than one / pair.' } + case 'unterminated': + return { ok: false, message: 'content contains an unbalanced / marker.' } + } +} + +/** + * Writes the plan text through the shared session-scoped write path and + * returns a structural report (counts + warnings) for the architect. + */ +function writeAndReport(ctx: ToolContext, sessionID: string, planText: string, prefix?: string): string { + const result = writeSessionPlanContent( + { plansRepo: ctx.plansRepo, projectId: ctx.projectId, directory: ctx.directory, logger: ctx.logger }, + sessionID, + planText, + ) + return `${prefix ?? ''}${formatPlanStructureSummary(summarizePlanStructure(result.planText))}` +} + +export function createPlanAuthoringTools(ctx: ToolContext): Record> { + return { + 'plan-write': tool({ + description: + 'Create, overwrite, or append to the implementation plan stored for the current session. This is the plan of record used by execute-plan and by the plan approval flow. Author long plans incrementally with append instead of emitting the whole plan in chat. Outer / markers are optional and are stripped.', + args: { + content: z + .string() + .min(1) + .describe( + 'Plan markdown. Use markers before each ## Phase heading. Outer plan markers are optional and stripped.', + ), + append: z + .boolean() + .optional() + .describe( + 'Append to the existing stored plan instead of replacing it. Two newlines are inserted between the existing content and the new fragment. Creates the plan when none exists.', + ), + }, + execute: async (args, context) => { + const guard = assertWritableSession(ctx, context.sessionID) + if (guard) return guard + + const normalized = normalizeFragment(args.content) + if (!normalized.ok) return `plan-write failed: ${normalized.message}` + + let next: string + if (args.append) { + const existing = ctx.plansRepo.getForSession(ctx.projectId, context.sessionID) + next = existing ? `${existing.content.trimEnd()}\n\n${normalized.text}` : normalized.text + } else { + next = normalized.text + } + + ctx.logger.log( + `plan-write: ${args.append ? 'appended to' : 'wrote'} plan for session ${context.sessionID} (${next.length} chars)`, + ) + return writeAndReport(ctx, context.sessionID, next) + }, + }), + + 'plan-edit': tool({ + description: + 'Edit the implementation plan stored for the current session by exact string replacement, the same way the Edit tool edits a file. Use plan-read to get the current text before editing. Do not include plan-read\'s "N: " line-number prefixes in oldString.', + args: { + oldString: z + .string() + .min(1) + .describe('Exact text to replace, including indentation. Must match exactly once unless replaceAll is true.'), + newString: z.string().describe('Replacement text. Must differ from oldString.'), + replaceAll: z + .boolean() + .optional() + .describe('Replace every occurrence instead of requiring a unique match.'), + }, + execute: async (args, context) => { + const guard = assertWritableSession(ctx, context.sessionID) + if (guard) return guard + + const existing = ctx.plansRepo.getForSession(ctx.projectId, context.sessionID) + if (!existing) { + return 'plan-edit failed: no plan stored for this session. Use plan-write to create it first.' + } + + if (args.oldString === args.newString) { + return 'plan-edit failed: oldString and newString are identical.' + } + + const parts = existing.content.split(args.oldString) + const occurrences = parts.length - 1 + if (occurrences === 0) { + return ( + 'plan-edit failed: oldString not found in the stored plan. ' + + 'Use plan-read to inspect the current text; whitespace and indentation must match exactly.' + ) + } + if (occurrences > 1 && !args.replaceAll) { + return ( + `plan-edit failed: found ${occurrences} matches for oldString. ` + + 'Add surrounding context to make it unique, or set replaceAll: true.' + ) + } + + const next = args.replaceAll + ? parts.join(args.newString) + : parts[0] + args.newString + parts.slice(1).join(args.oldString) + + if (next.trim() === '') { + return ( + 'plan-edit failed: replacement would leave the plan empty. ' + + 'Keep non-whitespace content in newString, or use plan-write to replace the plan.' + ) + } + + ctx.logger.log( + `plan-edit: replaced ${occurrences} occurrence(s) for session ${context.sessionID}`, + ) + return writeAndReport(ctx, context.sessionID, next, `Replaced ${occurrences} occurrence(s).\n`) + }, + }), + } +} diff --git a/src/tui.tsx b/src/tui.tsx index 5bb6781f0f..5b8bcd5023 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -13,6 +13,7 @@ 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 @@ -253,6 +254,10 @@ const tui: TuiPlugin = async (api) => { const pluginConfig = loadPluginConfig() const tuiConfig = pluginConfig.tui 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 const opts: TuiOptions = { sidebar: tuiConfig?.sidebar ?? true, showVersion: tuiConfig?.showVersion ?? true, @@ -271,7 +276,7 @@ const tui: TuiPlugin = async (api) => { const runOpenDashboard = () => { if (!dashboardServer) { try { - dashboardServer = startDashboardServer() + dashboardServer = startDashboardServer({ dbPath: forgeDbPath }) } catch (err) { api.ui.toast({ message: err instanceof Error ? err.message : 'Failed to start dashboard', @@ -374,7 +379,7 @@ const tui: TuiPlugin = async (api) => { if (connectPromise) return connectPromise setConnectionStatus('connecting') - connectPromise = connectForgeProject(api, directory, resolveLoopAllowedDirectories(pluginConfig)).then((connected) => untrack(() => { + connectPromise = connectForgeProject(api, directory, resolveLoopAllowedDirectories(pluginConfig), forgeDbPath).then((connected) => untrack(() => { connectPromise = null if (disposed) return connected diff --git a/src/utils/markdown-fences.ts b/src/utils/markdown-fences.ts new file mode 100644 index 0000000000..65e5af5386 --- /dev/null +++ b/src/utils/markdown-fences.ts @@ -0,0 +1,19 @@ +/** + * Returns a per-line mask where `true` means the line sits inside a fenced code + * block. A line whose trimmed form starts with ``` toggles the state, and the + * fence line itself is reported as inside the fence. Matches the historical + * behaviour of decomposeDeterministically so marker scanning is consistent + * across the plan pipeline. + */ +export function computeFenceMask(lines: string[]): boolean[] { + const mask = new Array(lines.length) + let fence = false + for (let i = 0; i < lines.length; i++) { + // Anchored leading-whitespace match rather than `line.trim()`: equivalent + // for a `^```" test, and avoids one throwaway string per line on what is + // the hot loop of the whole plan pipeline. + if (/^\s*```/.test(lines[i])) fence = !fence + mask[i] = fence + } + return mask +} diff --git a/src/utils/marked-plan-parser.ts b/src/utils/marked-plan-parser.ts index bc3655d114..7a51bb7898 100644 --- a/src/utils/marked-plan-parser.ts +++ b/src/utils/marked-plan-parser.ts @@ -1,6 +1,14 @@ +import { computeFenceMask } from './markdown-fences' + export const PLAN_START_MARKER = '' export const PLAN_END_MARKER = '' +/** + * Number of recent messages any marked-plan scan inspects. Shared by the + * server's capture paths and the TUI's plan fetch so both see the same window. + */ +export const PLAN_CAPTURE_MESSAGE_LIMIT = 20 + export interface PlanCaptureMessage { info: { role?: string; id?: string; agent?: string } parts: Array<{ type: string; text?: string }> @@ -19,71 +27,97 @@ export type PastedPlanNormalization = | { ok: true; planText: string; source: 'marked' | 'unmarked' } | { ok: false; reason: 'empty' | 'multiple' | 'unterminated' } -function countPlanMarkers(text: string): { startCount: number; endCount: number } { - let startCount = 0 - let endCount = 0 - - for (const rawLine of text.split('\n')) { - const line = rawLine.trim() - if (line === PLAN_START_MARKER) startCount++ - if (line === PLAN_END_MARKER) endCount++ - } +interface PlanMarkerScan { + lines: string[] + /** Line indices of unfenced plan start markers. */ + starts: number[] + /** Line indices of unfenced plan end markers. */ + ends: number[] +} - return { startCount, endCount } +/** + * The single unfenced plan-marker scan. `hasPlanMarker` short-circuits the + * common marker-free message: without either marker there is nothing to find, + * so the line split and fence mask are pure waste. Every marked-plan capture + * runs on each assistant message completion, so that guard is load-bearing. + */ +function hasPlanMarker(text: string): boolean { + return text.includes(PLAN_START_MARKER) || text.includes(PLAN_END_MARKER) } -export function extractMarkedPlan(text: string): MarkedPlanExtraction { +function scanPlanMarkers(text: string): PlanMarkerScan { + if (!hasPlanMarker(text)) return { lines: [], starts: [], ends: [] } + const lines = text.split('\n') - - let startIndex = -1 - let endIndex = -1 - let startCount = 0 - let endCount = 0 - + const mask = computeFenceMask(lines) + const starts: number[] = [] + const ends: number[] = [] for (let i = 0; i < lines.length; i++) { + if (mask[i]) continue const line = lines[i].trim() - if (line === PLAN_START_MARKER) { - startIndex = i - startCount++ - } - if (line === PLAN_END_MARKER) { - endIndex = i - endCount++ - } + if (line === PLAN_START_MARKER) starts.push(i) + else if (line === PLAN_END_MARKER) ends.push(i) } - - if (startCount === 0 && endCount === 0) { + return { lines, starts, ends } +} + +function countPlanMarkers(text: string): { startCount: number; endCount: number } { + const { starts, ends } = scanPlanMarkers(text) + return { startCount: starts.length, endCount: ends.length } +} + +/** Trims the plan body between two marker lines, rejecting an empty result. */ +function planBodyBetween(lines: string[], startIndex: number, endIndex: number): MarkedPlanExtraction { + const planText = lines.slice(startIndex + 1, endIndex).join('\n').trim() + if (planText.length === 0) { + return { ok: false, reason: 'empty' } + } + return { ok: true, planText } +} + +export function extractMarkedPlan(text: string): MarkedPlanExtraction { + const { lines, starts: unmaskedStarts, ends: unmaskedEnds } = scanPlanMarkers(text) + + if (unmaskedStarts.length === 0 && unmaskedEnds.length === 0) { return { ok: false, reason: 'missing' } } - - if (startCount > 1 || endCount > 1) { + + if (unmaskedStarts.length > 1 || unmaskedEnds.length > 1) { return { ok: false, reason: 'multiple' } } - - if (startCount === 1 && endCount === 0) { - return { ok: false, reason: 'unterminated' } - } - - if (startCount === 0 && endCount === 1) { - return { ok: false, reason: 'unterminated' } + + if (unmaskedStarts.length === 1 && unmaskedEnds.length === 0) { + // Fallback (deliberate asymmetry): an unbalanced fence inside the plan + // body must not be able to swallow plan termination, and the split-message + // repair path in inspectLatestPlanCompletedByLaterEndMarker depends on it. + // Search for the first line after startIndex whose trimmed form equals the + // end marker, ignoring the fence mask. + const startIndex = unmaskedStarts[0] + let endIndex = -1 + for (let j = startIndex + 1; j < lines.length; j++) { + if (lines[j].trim() === PLAN_END_MARKER) { + endIndex = j + break + } + } + if (endIndex === -1) { + return { ok: false, reason: 'unterminated' } + } + return planBodyBetween(lines, startIndex, endIndex) } - - if (startIndex === -1 || endIndex === -1) { + + if (unmaskedStarts.length === 0 && unmaskedEnds.length === 1) { return { ok: false, reason: 'unterminated' } } - + + const startIndex = unmaskedStarts[0] + const endIndex = unmaskedEnds[0] + if (endIndex <= startIndex) { return { ok: false, reason: 'unterminated' } } - - const planLines = lines.slice(startIndex + 1, endIndex) - const planText = planLines.join('\n').trim() - - if (planText.length === 0) { - return { ok: false, reason: 'empty' } - } - - return { ok: true, planText } + + return planBodyBetween(lines, startIndex, endIndex) } export function normalizePastedPlanText(text: string): PastedPlanNormalization { @@ -130,53 +164,52 @@ export function messageText(message: PlanCaptureMessage): string { return textParts.join('\n') } +interface AssistantMessageText { + text: string + messageId?: string +} + export function inspectLatestMarkedPlan(messages: PlanCaptureMessage[]): LatestMarkedPlanInspection { - const repaired = inspectLatestPlanCompletedByLaterEndMarker(messages) + // Flatten each assistant message's text once. Both passes below walk the same + // messages, and the split-message repair pass falls through all of them + // whenever no plan is present, which is the common case. + const assistant: AssistantMessageText[] = [] + for (const message of messages) { + if (message.info.role !== 'assistant') continue + assistant.push({ text: messageText(message), messageId: message.info.id }) + } + + const repaired = inspectLatestPlanCompletedByLaterEndMarker(assistant) if (repaired) return repaired - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i] - - if (message.info.role !== 'assistant') { - continue - } - - const text = messageText(message) + for (let i = assistant.length - 1; i >= 0; i--) { + const { text, messageId } = assistant[i] const extraction = extractMarkedPlan(text) - + if (extraction.ok) { - return { - status: 'found', - planText: extraction.planText, - messageId: message.info.id, - } + return { status: 'found', planText: extraction.planText, messageId } } - - if (!extraction.ok && extraction.reason !== 'missing') { - return { - status: 'invalid', - reason: extraction.reason, - messageId: message.info.id, - } + + if (extraction.reason !== 'missing') { + return { status: 'invalid', reason: extraction.reason, messageId } } } - + return { status: 'missing' } } -function inspectLatestPlanCompletedByLaterEndMarker(messages: PlanCaptureMessage[]): LatestMarkedPlanInspection | null { - let latestEndOnly: { text: string; messageId?: string } | undefined +function inspectLatestPlanCompletedByLaterEndMarker( + messages: AssistantMessageText[] +): LatestMarkedPlanInspection | null { + let latestEndOnly: AssistantMessageText | undefined for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i] - if (message.info.role !== 'assistant') continue - - const text = messageText(message) + const { text, messageId } = messages[i] const counts = countPlanMarkers(text) if (!latestEndOnly) { if (counts.startCount === 0 && counts.endCount === 1) { - latestEndOnly = { text, messageId: message.info.id } + latestEndOnly = { text, messageId } continue } if (counts.startCount === 0 && counts.endCount === 0) continue @@ -191,14 +224,14 @@ function inspectLatestPlanCompletedByLaterEndMarker(messages: PlanCaptureMessage return { status: 'found', planText: extraction.planText, - messageId: latestEndOnly.messageId ?? message.info.id, + messageId: latestEndOnly.messageId ?? messageId, } } return { status: 'invalid', reason: extraction.reason, - messageId: latestEndOnly.messageId ?? message.info.id, + messageId: latestEndOnly.messageId ?? messageId, } } diff --git a/src/utils/plan-execution.ts b/src/utils/plan-execution.ts index 87cafecd2a..4588111fe2 100644 --- a/src/utils/plan-execution.ts +++ b/src/utils/plan-execution.ts @@ -131,7 +131,7 @@ function extractLoopNameFromHeading(planContent: string): string | null { return null } -function extractExplicitLoopName(planContent: string): string | null { +export function findExplicitLoopName(planContent: string): string | null { const loopNameMatch = planContent.match(/^(?:\s*(?:-\s*)?)?(?:\*\*)?Loop Name(?:\*\*)?:\s*(.+)$/m) if (loopNameMatch?.[1]) { return truncateName(loopNameMatch[1].trim()) @@ -165,7 +165,7 @@ function truncateName(name: string, ellipsis = false): string { * The result is truncated to 60 characters. */ export function extractLoopName(planContent: string): string { - return extractExplicitLoopName(planContent) ?? extractFallbackPlanTitle(planContent) + return findExplicitLoopName(planContent) ?? extractFallbackPlanTitle(planContent) } /** diff --git a/src/utils/plan-from-messages.ts b/src/utils/plan-from-messages.ts index 6e62cc2a29..4e1869d916 100644 --- a/src/utils/plan-from-messages.ts +++ b/src/utils/plan-from-messages.ts @@ -13,6 +13,7 @@ import type { ForgeClient } from '../client/port' import { + PLAN_CAPTURE_MESSAGE_LIMIT, inspectLatestMarkedPlan, type PlanCaptureMessage, } from './marked-plan-parser' @@ -21,22 +22,20 @@ export interface FetchLatestPlanForSessionDeps { /** Optional logger; receives a single descriptive string per call. */ debug?: (message: string) => void /** - * Maximum number of recent messages to inspect. The server's - * `captureLatestPlanForSession` uses 20; keep this in sync so the TUI's - * view matches the server's capture window. + * Maximum number of recent messages to inspect. Defaults to the shared + * `PLAN_CAPTURE_MESSAGE_LIMIT` so the TUI's view matches the server's + * capture window. */ limit?: number } -const DEFAULT_LIMIT = 20 - export async function fetchLatestPlanForSession( client: ForgeClient, sessionID: string, directory: string | undefined, deps: FetchLatestPlanForSessionDeps = {}, ): Promise { - const limit = deps.limit ?? DEFAULT_LIMIT + const limit = deps.limit ?? PLAN_CAPTURE_MESSAGE_LIMIT const debug = deps.debug ?? (() => {}) let messages: PlanCaptureMessage[] diff --git a/src/utils/plan-structure.ts b/src/utils/plan-structure.ts new file mode 100644 index 0000000000..4c1e771fe4 --- /dev/null +++ b/src/utils/plan-structure.ts @@ -0,0 +1,113 @@ +import { decomposePlanSections } from '../services/deterministic-decomposer' +import { findExplicitLoopName } from './plan-execution' +import { MAX_TOTAL_SECTIONS } from '../constants/loop' + +export interface PlanSectionOutline { + index: number + title: string +} + +export interface PlanStructureSummary { + lines: number + characters: number + /** Count of unfenced marker lines, before any cap. */ + sectionMarkers: number + /** Sections the decomposer would actually produce, after cap and empty-body skipping. */ + sections: PlanSectionOutline[] + loopName: string | null + warnings: string[] +} + +const NON_REPO_RELATIVE_PATH_REGEX = /(?:^|\s|`|\()((?:~\/|\/Users\/|\/home\/|\/private\/)[^\s`]+)/ + +/** + * Produces a structural summary of a plan: line/char counts, section marker + * count, the sections the decomposer would actually emit (after cap and + * empty-body skipping), the explicit loop name (if any), and a list of + * authoring warnings in a stable order. + */ +export function summarizePlanStructure(planText: string): PlanStructureSummary { + // One uncapped decomposition answers everything. The capped run is exactly + // this list's prefix: the decomposer skips empty bodies without consuming cap + // budget and numbers sections by output position, so slicing yields identical + // indices and titles. + const { sections: allSections, markerCount: sectionMarkers } = decomposePlanSections(planText, { + maxSections: Number.MAX_SAFE_INTEGER, + }) + const sections: PlanSectionOutline[] = allSections + .slice(0, MAX_TOTAL_SECTIONS) + .map((s) => ({ index: s.index, title: s.title })) + const loopName = findExplicitLoopName(planText) + + const warnings: string[] = [] + + if (sectionMarkers === 0) { + warnings.push('No markers found: the whole plan will run as a single section.') + } + + if (sectionMarkers > MAX_TOTAL_SECTIONS) { + warnings.push( + `${sectionMarkers} section markers exceed the cap of ${MAX_TOTAL_SECTIONS}: sections beyond ${MAX_TOTAL_SECTIONS} are dropped at loop start. Consolidate phases.`, + ) + } + + // Compute empty-body loss independently from cap truncation. `allSections` + // holds every marker with a non-empty body, so the difference from + // `sectionMarkers` is exactly the markers the decomposer skips for being + // empty. Measuring it against the uncapped list (rather than the capped + // `sections.length`) keeps the two warnings from suppressing each other when + // a plan both overflows the cap and has empty phases. + const emptyBodyLoss = Math.max(0, sectionMarkers - allSections.length) + if (emptyBodyLoss > 0) { + warnings.push(`${emptyBodyLoss} section marker(s) have empty bodies and will be skipped.`) + } + + if (loopName === null) { + warnings.push('No "Loop Name:" line found: the loop name will be derived from the plan title.') + } + + const pathMatch = planText.match(NON_REPO_RELATIVE_PATH_REGEX) + if (pathMatch?.[1]) { + warnings.push(`Plan contains a non-repo-relative path (${pathMatch[1]}). Use repo-relative paths.`) + } + + return { + lines: planText.split('\n').length, + characters: planText.length, + sectionMarkers, + sections, + loopName, + warnings, + } +} + +/** + * Formats a `PlanStructureSummary` as a compact multi-line string suitable for + * showing the architect immediate structural feedback. Omits the `Loop Name:` + * line when null and the `Warnings:` block entirely when empty. + */ +export function formatPlanStructureSummary(summary: PlanStructureSummary): string { + const out: string[] = [`Plan stored: ${summary.lines} lines, ${summary.characters} chars.`] + + if (summary.loopName !== null) { + out.push(`Loop Name: ${summary.loopName}`) + } + + if (summary.sections.length === 0) { + out.push('Sections (0): none detected') + } else { + out.push(`Sections (${summary.sections.length}):`) + for (const s of summary.sections) { + out.push(` ${s.index + 1}. ${s.title}`) + } + } + + if (summary.warnings.length > 0) { + out.push('Warnings:') + for (const w of summary.warnings) { + out.push(` - ${w}`) + } + } + + return out.join('\n') +} diff --git a/src/utils/tui-client.ts b/src/utils/tui-client.ts index ffbc97adf7..a4ff6eb46a 100644 --- a/src/utils/tui-client.ts +++ b/src/utils/tui-client.ts @@ -13,10 +13,10 @@ import { deriveExecutionPreferencesFromWorkspaces } from './tui-execution-prefer import { parseModelString } from './model-fallback' import { listConnectedWorkspaces } from './workspace-listing' import { type ForgeLoopExtra } from '../services/execution' -import { buildLoopPermissionRuleset } from '../constants/loop' +import { buildLoopPermissionRuleset, MAX_TOTAL_SECTIONS } from '../constants/loop' import { getForgeWorkspaceLoopName, removeExistingForgeLoopWorkspaces, getWorktreeProjectPreconditionError } from '../workspace/forge-worktree' import { classifyWorkspaceCreateThrow } from '../workspace/workspace-create-error' -import { fetchLoopsList } from './tui-loop-store' +import { fetchLoopsList, fetchStoredSessionPlan } from './tui-loop-store' import { decomposeDeterministically } from '../services/deterministic-decomposer' import { buildSectionInitialPromptText } from '../loop/prompts' import { extractPlanExecutionMetadata, sanitizeLoopName } from './plan-execution' @@ -97,10 +97,15 @@ function nextAvailableLoopName(baseName: string, names: string[]): string { return candidate } -export async function reserveTuiLoopName(client: ForgeClient, projectId: string | null, baseName: string): Promise { +export async function reserveTuiLoopName( + client: ForgeClient, + projectId: string | null, + baseName: string, + dbPath?: string, +): Promise { const names = new Set() if (projectId) { - for (const loop of fetchLoopsList(projectId)) { + for (const loop of fetchLoopsList(projectId, dbPath)) { names.add(loop.name) } } @@ -230,7 +235,7 @@ async function waitForWorkspacePluginSettle(workspaceId: string): Promise } function buildTuiLoopInitialPrompt(planText: string): string { - const sections = decomposeDeterministically(planText, { maxSections: 12 }) + const sections = decomposeDeterministically(planText, { maxSections: MAX_TOTAL_SECTIONS }) const firstSection = sections[0] if (!firstSection) return planText @@ -271,6 +276,8 @@ export interface LaunchTuiLoopOptions { onLaunched?: (sessionId: string, workspaceId: string) => Promise /** Poll interval for the workspace-connected wait. Remote launches widen this to avoid hammering the server over the network. Default 100ms. */ connectPollIntervalMs?: number + /** Override path to the forge SQLite database used for local loop/plan reads. When omitted, the default data dir is used. */ + dbPath?: string debug?: (message: string) => void } @@ -287,7 +294,7 @@ export async function launchTuiLoop( const loopName = opts.loopNameReserved ? opts.requestedLoopName - : await reserveTuiLoopName(opts.client, opts.projectId, opts.requestedLoopName) + : await reserveTuiLoopName(opts.client, opts.projectId, opts.requestedLoopName, opts.dbPath) debug(`launchTuiLoop: inline plan (planText.length=${opts.plan.length}) hostSession=${opts.hostSessionId ?? 'none'} loop=${loopName}`) const createdAt = Date.now() const forgeLoop: ForgeLoopExtra = { @@ -404,6 +411,7 @@ export async function connectForgeProject( api: TuiPluginApi, directory?: string, allowExternalDirectories?: string[], + dbPath?: string, ): Promise { tuiDebug(`connect start directory=${directory ?? 'none'}`) @@ -499,6 +507,7 @@ export async function connectForgeProject( auditorVariant: req.auditorVariant, hostSessionId: sessionId || undefined, allowDirectories: allowExternalDirectories, + dbPath, onLaunched: (sid, wid) => selectTuiSession(api, client, sid, wid), debug: tuiDebug, }) @@ -534,6 +543,13 @@ export async function connectForgeProject( return selectTuiSession(api, client, sessionId, workspaceId) }, loadLatestPlan(sessionId) { + // Storage is the plan of record for execute-plan and the approval hook, so + // the dialog must show exactly what would execute. The message scan + // remains as a fallback for sessions whose marked plan was never captured. + // `dbPath` honors a configured PluginConfig.dataDir so tool-authored plans + // stored outside the default data dir still resolve here. + const stored = projectId ? fetchStoredSessionPlan(projectId, sessionId, dbPath) : null + if (stored) return Promise.resolve(stored) return fetchLatestPlanForSession(client, sessionId, directory) }, async loadExecutionContext() { diff --git a/src/utils/tui-loop-store.ts b/src/utils/tui-loop-store.ts index 74323a45cd..520af9b117 100644 --- a/src/utils/tui-loop-store.ts +++ b/src/utils/tui-loop-store.ts @@ -10,6 +10,7 @@ import { existsSync } from 'fs' import { join } from 'path' import { resolveDataDir } 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' @@ -17,6 +18,27 @@ 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() + if (!existsSync(dbPath)) return fallback + + let db: Database | null = null + try { + db = new Database(dbPath, { readonly: true }) + db.run('PRAGMA busy_timeout=5000') + return read(db) + } catch { + return fallback + } finally { + try { db?.close() } catch {} + } +} + const cap200 = (s: string | null | undefined): string | null => s ? (s.length > 200 ? s.slice(0, 200) : s) : null @@ -67,24 +89,23 @@ function rowToLoopInfo(row: import('../storage/repos/loops-repo').LoopRow, secti * Returns the same shape as the former `rpc('loops.list')`. */ export function fetchLoopsList(projectId: string, dbPathOverride?: string): LoopInfo[] { - const dbPath = dbPathOverride || getDbPath() - if (!existsSync(dbPath)) return [] - - let db: Database | null = null - try { - db = new Database(dbPath, { readonly: true }) - db.run('PRAGMA busy_timeout=5000') + return withReadOnlyForgeDb(dbPathOverride, [], (db) => { const loopsRepo = createLoopsRepo(db) const sectionPlansRepo = createSectionPlansRepo(db) - const rows = loopsRepo.listAll(projectId) - return rows.map((row) => { + return loopsRepo.listAll(projectId).map((row) => { const plans = sectionPlansRepo.list(projectId, row.loopName) return rowToLoopInfo(row, plans.length > 0 ? plans : undefined) }) - } catch { - return [] - } finally { - try { db?.close() } catch {} - } + }) +} + +/** + * Reads the session-scoped stored plan for the TUI execute-plan dialog. + * Returns null when the database, row, or content is absent. + */ +export function fetchStoredSessionPlan(projectId: string, sessionId: string, dbPathOverride?: string): string | null { + return withReadOnlyForgeDb(dbPathOverride, null, (db) => + createPlansRepo(db).getForSession(projectId, sessionId)?.content ?? null + ) } diff --git a/test/agent-tools-map.test.ts b/test/agent-tools-map.test.ts index 9fc4112266..7199e96a06 100644 --- a/test/agent-tools-map.test.ts +++ b/test/agent-tools-map.test.ts @@ -6,7 +6,7 @@ const agents = buildAgents() describe('per-agent tools.exclude (regression guard)', () => { test('code agent excludes review and plan tools but can launch loops', () => { const excluded = agents.code.tools?.exclude ?? [] - for (const tool of ['review-write', 'review-delete', 'plan', 'plan_enter', 'plan_exit']) { + for (const tool of ['review-write', 'review-delete', 'plan', 'plan_enter', 'plan_exit', 'plan-write', 'plan-edit']) { expect(excluded).toContain(tool) } expect(excluded).not.toContain('execute-plan') @@ -17,7 +17,7 @@ describe('per-agent tools.exclude (regression guard)', () => { test('auditor agent excludes plan/loop tools but NOT review tools', () => { const excluded = agents.auditor.tools?.exclude ?? [] - for (const tool of ['plan', 'plan_exit', 'execute-plan', 'loop-cancel', 'loop-status']) { + for (const tool of ['plan', 'plan_exit', 'execute-plan', 'loop-cancel', 'loop-status', 'plan-write', 'plan-edit']) { expect(excluded).toContain(tool) } // Auditor MUST be allowed to use review-write and review-delete. @@ -30,4 +30,26 @@ describe('per-agent tools.exclude (regression guard)', () => { expect(agents[role].tools?.exclude ?? []).not.toContain('plan-execute') } }) + + test('feature-splitter excludes plan-authoring tools', () => { + const excluded = agents['feature-splitter'].tools?.exclude ?? [] + for (const tool of ['plan-write', 'plan-edit']) { + expect(excluded).toContain(tool) + } + }) + + test('architect and architect-auto retain plan-authoring tools', () => { + for (const role of ['architect', 'architect-auto'] as const) { + const excluded = agents[role].tools?.exclude ?? [] + expect(excluded).not.toContain('plan-write') + expect(excluded).not.toContain('plan-edit') + } + }) + + test('auditor-loop shares plan-authoring exclusions with auditor', () => { + const loopExcluded = agents['auditor-loop'].tools?.exclude ?? [] + for (const tool of ['plan-write', 'plan-edit']) { + expect(loopExcluded).toContain(tool) + } + }) }) diff --git a/test/agents.test.ts b/test/agents.test.ts index 08bb56c199..bd760d1254 100644 --- a/test/agents.test.ts +++ b/test/agents.test.ts @@ -69,6 +69,8 @@ describe('Agent definitions', () => { expect(codeAgent.tools?.exclude).toContain('plan') expect(codeAgent.tools?.exclude).toContain('plan_enter') expect(codeAgent.tools?.exclude).toContain('plan_exit') + expect(codeAgent.tools?.exclude).toContain('plan-write') + expect(codeAgent.tools?.exclude).toContain('plan-edit') expect(codeAgent.tools?.exclude).not.toContain('execute-plan') expect(codeAgent.tools?.exclude).not.toContain('loop-cancel') expect(codeAgent.tools?.exclude).not.toContain('loop-status') @@ -160,6 +162,15 @@ describe('Agent definitions', () => { expect(featureSplitterAgent.tools?.exclude).toContain('write') expect(featureSplitterAgent.tools?.exclude).toContain('edit') expect(featureSplitterAgent.tools?.exclude).toContain('patch') + expect(featureSplitterAgent.tools?.exclude).toContain('plan-write') + expect(featureSplitterAgent.tools?.exclude).toContain('plan-edit') + }) + + test('architect agents retain plan-authoring tools', () => { + expect(architectAgent.tools?.exclude).not.toContain('plan-write') + expect(architectAgent.tools?.exclude).not.toContain('plan-edit') + expect(architectAutoAgent.tools?.exclude).not.toContain('plan-write') + expect(architectAutoAgent.tools?.exclude).not.toContain('plan-edit') }) test('hidden group agents preserve overlap-aware planning guidance', () => { diff --git a/test/config.test.ts b/test/config.test.ts index a992998b31..45c99a2e06 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -86,6 +86,8 @@ describe('createConfigHandler', () => { expect(Object.keys(codePermission).sort()).toEqual([ 'plan', + 'plan-edit', + 'plan-write', 'plan_enter', 'plan_exit', 'question', @@ -136,7 +138,7 @@ describe('createConfigHandler', () => { const permission = code.permission as Record expect(permission).toBeDefined() - for (const tool of ['review-write', 'review-delete', 'plan', 'plan_enter', 'plan_exit']) { + for (const tool of ['review-write', 'review-delete', 'plan', 'plan_enter', 'plan_exit', 'plan-write', 'plan-edit']) { expect(permission[tool]).toBe('deny') } expect(permission.loop).toBeUndefined() @@ -235,7 +237,7 @@ describe('createConfigHandler', () => { expect(permission.bash).toBe('ask') expect(wildcardIndex).toBeGreaterThanOrEqual(0) - for (const tool of ['review-write', 'review-delete', 'plan', 'plan_enter', 'plan_exit']) { + for (const tool of ['review-write', 'review-delete', 'plan', 'plan_enter', 'plan_exit', 'plan-write', 'plan-edit']) { expect(permission[tool]).toBe('deny') expect(keys.indexOf(tool)).toBeGreaterThan(wildcardIndex) } diff --git a/test/constants/loop.test.ts b/test/constants/loop.test.ts index 35a869b650..0f14aed3ac 100644 --- a/test/constants/loop.test.ts +++ b/test/constants/loop.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { buildLoopPermissionRuleset, buildAuditSessionPermissionRuleset, resolveLoopAllowedDirectories } from '../../src/constants/loop' +import { buildLoopPermissionRuleset, buildAuditSessionPermissionRuleset, resolveLoopAllowedDirectories, MAX_TOTAL_SECTIONS, PLAN_AUTHORING_TOOL_NAMES } from '../../src/constants/loop' import { resolveOpencodeToolOutputDir, DEFAULT_FORGE_TMP_DIR } from '../../src/utils/opencode-paths' const TOOL_OUTPUT_DIR = resolveOpencodeToolOutputDir() @@ -8,6 +8,18 @@ const TOOL_OUTPUT_ALLOW_RULES = [ { permission: 'external_directory', pattern: `${TOOL_OUTPUT_DIR}/**`, action: 'allow' as const }, ] +describe('MAX_TOTAL_SECTIONS', () => { + it('is the single canonical section cap', () => { + expect(MAX_TOTAL_SECTIONS).toBe(24) + }) +}) + +describe('PLAN_AUTHORING_TOOL_NAMES', () => { + it('is the single list of plan-authoring tools every deny path derives from', () => { + expect(PLAN_AUTHORING_TOOL_NAMES).toEqual(['plan-write', 'plan-edit']) + }) +}) + describe('buildLoopPermissionRuleset', () => { it('emits the full loop ruleset in order (no shell-specific rules)', () => { const rules = buildLoopPermissionRuleset() @@ -20,6 +32,8 @@ describe('buildLoopPermissionRuleset', () => { { permission: 'plan', pattern: '*', action: 'deny' }, { permission: 'plan_enter', pattern: '*', action: 'deny' }, { permission: 'plan_exit', pattern: '*', action: 'deny' }, + { permission: 'plan-write', pattern: '*', action: 'deny' }, + { permission: 'plan-edit', pattern: '*', action: 'deny' }, { permission: 'execute-plan', pattern: '*', action: 'deny' }, { permission: 'execute-goal', pattern: '*', action: 'deny' }, { permission: 'question', pattern: '*', action: 'deny' }, diff --git a/test/deterministic-decomposer.test.ts b/test/deterministic-decomposer.test.ts index 13a7b6949f..f93e984466 100644 --- a/test/deterministic-decomposer.test.ts +++ b/test/deterministic-decomposer.test.ts @@ -1,5 +1,34 @@ import { describe, test, expect } from 'vitest' -import { decomposeDeterministically } from '../src/services/deterministic-decomposer' +import { decomposeDeterministically, decomposePlanSections } from '../src/services/deterministic-decomposer' +import { MAX_TOTAL_SECTIONS } from '../src/constants/loop' + +describe('decomposePlanSections', () => { + test('markerCount reports every unfenced marker, independent of cap and empty bodies', () => { + const plan30 = Array.from({ length: 30 }, () => '\nbody').join('\n') + const capped = decomposePlanSections(plan30) + expect(capped.markerCount).toBe(30) + expect(capped.sections).toHaveLength(MAX_TOTAL_SECTIONS) + + // Empty bodies are skipped as sections but still counted as markers. + const withEmpty = ['', '', '## Phase', 'body'].join('\n') + const r = decomposePlanSections(withEmpty) + expect(r.markerCount).toBe(2) + expect(r.sections).toHaveLength(1) + + // Fenced markers are neither counted nor split on. + const fenced = ['', '## Phase', '```ts', '', '```', 'body'].join('\n') + expect(decomposePlanSections(fenced).markerCount).toBe(1) + + expect(decomposePlanSections('no markers').markerCount).toBe(0) + }) + + test('uncapped sections are the superset the capped run truncates', () => { + const plan30 = Array.from({ length: 30 }, (_, i) => `\n## Phase ${i + 1}\nbody`).join('\n') + const uncapped = decomposePlanSections(plan30, { maxSections: Number.MAX_SAFE_INTEGER }).sections + expect(uncapped).toHaveLength(30) + expect(uncapped.slice(0, MAX_TOTAL_SECTIONS)).toEqual(decomposeDeterministically(plan30)) + }) +}) describe('decomposeDeterministically', () => { test('returns empty array when no section markers found', () => { @@ -37,10 +66,12 @@ describe('decomposeDeterministically', () => { expect(r).toEqual([]) }) - test('respects maxSections cap (default 12)', () => { - const plan = Array.from({ length: 15 }, () => '\nbody').join('\n') - expect(decomposeDeterministically(plan)).toHaveLength(12) - expect(decomposeDeterministically(plan, { maxSections: 3 })).toHaveLength(3) + test(`respects maxSections cap (default ${MAX_TOTAL_SECTIONS})`, () => { + const plan20 = Array.from({ length: 20 }, () => '\nbody').join('\n') + expect(decomposeDeterministically(plan20)).toHaveLength(20) + const plan30 = Array.from({ length: 30 }, () => '\nbody').join('\n') + expect(decomposeDeterministically(plan30)).toHaveLength(MAX_TOTAL_SECTIONS) + expect(decomposeDeterministically(plan30, { maxSections: 3 })).toHaveLength(3) }) test('strips outer markers', () => { diff --git a/test/loop-permission-ruleset.test.ts b/test/loop-permission-ruleset.test.ts index bdf5354edb..5d627ab6e3 100644 --- a/test/loop-permission-ruleset.test.ts +++ b/test/loop-permission-ruleset.test.ts @@ -28,6 +28,8 @@ describe('buildLoopPermissionRuleset', () => { { permission: 'plan', pattern: '*', action: 'deny' }, { permission: 'plan_enter', pattern: '*', action: 'deny' }, { permission: 'plan_exit', pattern: '*', action: 'deny' }, + { permission: 'plan-write', pattern: '*', action: 'deny' }, + { permission: 'plan-edit', pattern: '*', action: 'deny' }, { permission: 'execute-plan', pattern: '*', action: 'deny' }, { permission: 'execute-goal', pattern: '*', action: 'deny' }, { permission: 'question', pattern: '*', action: 'deny' }, @@ -45,7 +47,7 @@ describe('buildLoopPermissionRuleset', () => { }) test('EMITS session-level denies for code-agent tool exclusions (auditor now runs in separate session)', () => { - const required = ['review-write', 'review-delete', 'plan', 'plan_enter', 'plan_exit', 'execute-plan', 'question'] + const required = ['review-write', 'review-delete', 'plan', 'plan_enter', 'plan_exit', 'plan-write', 'plan-edit', 'execute-plan', 'question'] const rules = buildLoopPermissionRuleset() for (const tool of required) { expect(rules.find((r) => r.permission === tool && r.action === 'deny')).toBeDefined() @@ -104,6 +106,9 @@ describe('buildAuditSessionPermissionRuleset', () => { expect(rules.some(r => r.permission === 'question' && r.pattern === '*' && r.action === 'deny')).toBe(true) expect(rules.some(r => r.permission === 'loop-cancel' && r.pattern === '*' && r.action === 'deny')).toBe(true) expect(rules.some(r => r.permission === 'loop-status' && r.pattern === '*' && r.action === 'deny')).toBe(true) + // Plan-authoring tools: auditor's sanctioned path is plan-adjust, never plan-write/plan-edit. + expect(rules.some(r => r.permission === 'plan-write' && r.pattern === '*' && r.action === 'deny')).toBe(true) + expect(rules.some(r => r.permission === 'plan-edit' && r.pattern === '*' && r.action === 'deny')).toBe(true) }) test('contains external_directory:*:deny rule', () => { diff --git a/test/loop-tool-new-session.test.ts b/test/loop-tool-new-session.test.ts index d26df87e84..e45c3b0a05 100644 --- a/test/loop-tool-new-session.test.ts +++ b/test/loop-tool-new-session.test.ts @@ -117,6 +117,82 @@ describe('loop tool mode=new-session', () => { const hasLoopMessage = typeof result === 'string' && result.includes('Memory loop activated') expect(workspaceCreated || hasLoopMessage).toBe(true) }) + + test('execute-plan with no inline plan uses the stored row and skips legacy message capture', async () => { + const { tools, forgeClient } = setupTools() + const plansRepo = createPlansRepo(db) + plansRepo.writeForSession(projectId, 'src-session', '# Stored Plan\n\n## Verification\n- pnpm test') + + const result = await tools['execute-plan'].execute( + { title: 'Stored plan run' }, + { sessionID: 'src-session' } as any, + ) + + // Storage is the plan of record: legacy latest-message capture must not run. + expect((forgeClient.session.messages as any).mock.calls.length).toBe(0) + + const workspaceCreated = (forgeClient.workspace.create as any).mock.calls.length > 0 + const hasLoopMessage = typeof result === 'string' && result.includes('Memory loop activated') + expect(workspaceCreated || hasLoopMessage).toBe(true) + }) + + test('execute-plan with no inline plan falls back to legacy capture only when no stored row exists', async () => { + const { tools, forgeClient } = setupTools() + const plansRepo = createPlansRepo(db) + expect(plansRepo.getForSession(projectId, 'src-session')).toBeNull() + + const marked = { + info: { role: 'assistant', id: 'msg-1' }, + parts: [{ type: 'text', text: `\n# Captured Plan\n` }], + } + ;(forgeClient.session.messages as any).mockImplementation(async () => [marked]) + + const result = await tools['execute-plan'].execute( + { title: 'Legacy capture run' }, + { sessionID: 'src-session' } as any, + ) + + expect((forgeClient.session.messages as any).mock.calls.length).toBe(1) + expect(plansRepo.getForSession(projectId, 'src-session')?.content).toBe('# Captured Plan') + + const workspaceCreated = (forgeClient.workspace.create as any).mock.calls.length > 0 + const hasLoopMessage = typeof result === 'string' && result.includes('Memory loop activated') + expect(workspaceCreated || hasLoopMessage).toBe(true) + }) + + test('execute-plan with no inline plan and nothing to capture returns the no-plan message', async () => { + const { tools } = setupTools() + const plansRepo = createPlansRepo(db) + expect(plansRepo.getForSession(projectId, 'src-session')).toBeNull() + + const result = await tools['execute-plan'].execute( + { title: 'Nothing to run' }, + { sessionID: 'src-session' } as any, + ) + + expect(result).toContain('No plan found') + }) + + test('execute-plan with no inline plan does not recapture an older marked message over a newer stored row', async () => { + const { tools, forgeClient } = setupTools() + const plansRepo = createPlansRepo(db) + plansRepo.writeForSession(projectId, 'src-session', '# plan-write revision') + + const stale = { + info: { role: 'assistant', id: 'msg-stale' }, + parts: [{ type: 'text', text: `\n# Stale Plan\n` }], + } + ;(forgeClient.session.messages as any).mockImplementation(async () => [stale]) + + await tools['execute-plan'].execute( + { title: 'Stored wins' }, + { sessionID: 'src-session' } as any, + ) + + // The stored revision must remain untouched — no messages call at all. + expect((forgeClient.session.messages as any).mock.calls.length).toBe(0) + expect(plansRepo.getForSession(projectId, 'src-session')?.content).toBe('# plan-write revision') + }) }) describe('execute-goal tool', () => { diff --git a/test/markdown-fences.test.ts b/test/markdown-fences.test.ts new file mode 100644 index 0000000000..682f5e07d5 --- /dev/null +++ b/test/markdown-fences.test.ts @@ -0,0 +1,27 @@ +import { describe, test, expect } from 'vitest' +import { computeFenceMask } from '../src/utils/markdown-fences' + +describe('computeFenceMask', () => { + test('returns empty array for empty input', () => { + expect(computeFenceMask([])).toEqual([]) + }) + + test('returns all false when no fences present', () => { + expect(computeFenceMask(['intro', 'plain', 'text'])).toEqual([false, false, false]) + }) + + test('balanced fence: opening line and inner lines true, closing line and following lines false', () => { + const lines = ['intro', '```', 'inside', 'code', '```', 'after'] + expect(computeFenceMask(lines)).toEqual([false, true, true, true, false, false]) + }) + + test('language-tagged opener toggles fence state', () => { + const lines = ['```ts', 'const x = 1', '```', 'after'] + expect(computeFenceMask(lines)).toEqual([true, true, false, false]) + }) + + test('unbalanced opener leaves the tail true', () => { + const lines = ['intro', '```', 'more', 'stuff'] + expect(computeFenceMask(lines)).toEqual([false, true, true, true]) + }) +}) diff --git a/test/plan-capture.test.ts b/test/plan-capture.test.ts index 6d56a2d6a5..1ac8c40bf5 100644 --- a/test/plan-capture.test.ts +++ b/test/plan-capture.test.ts @@ -8,7 +8,7 @@ import { PLAN_END_MARKER, type PlanCaptureMessage, } from '../src/utils/marked-plan-parser' -import { captureMarkedPlanTextForSession, captureLatestPlanForSession } from '../src/services/plan-capture' +import { captureMarkedPlanTextForSession, captureLatestPlanForSession, capturePlanForCompletedMessage } from '../src/services/plan-capture' import { createPlanCaptureEventHook } from '../src/hooks/plan-capture' describe('extractMarkedPlan', () => { @@ -147,6 +147,52 @@ ${PLAN_END_MARKER}` expect(result.reason).toBe('missing') } }) + + test('markers inside a fenced code block are treated as missing', () => { + const text = [ + 'Preamble', + '```', + PLAN_START_MARKER, + '# Fake Plan', + PLAN_END_MARKER, + '```', + 'Outro', + ].join('\n') + + const result = extractMarkedPlan(text) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toBe('missing') + } + }) + + test('plan body containing balanced fenced code blocks is captured verbatim', () => { + const text = [ + PLAN_START_MARKER, + '# Real Plan', + '', + '```ts', + 'const a = 1', + '```', + '', + '```', + 'const b = 2', + '```', + '', + '## Verification', + '- pnpm typecheck', + PLAN_END_MARKER, + ].join('\n') + + const result = extractMarkedPlan(text) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.planText).toContain('```ts\nconst a = 1\n```') + expect(result.planText).toContain('```\nconst b = 2\n```') + expect(result.planText).not.toContain(PLAN_START_MARKER) + expect(result.planText).not.toContain(PLAN_END_MARKER) + } + }) }) describe('normalizePastedPlanText', () => { @@ -425,6 +471,41 @@ Outro` expect(plansRepo.getForSession('test-project', 'session-1')?.content).toBe('# Captured Plan\n\n## Verification\n- bun test test/plan-capture.test.ts') }) + test('fenced-only marker example does not overwrite a previously stored plan', () => { + const plansRepo = createFakePlansRepo() + const realText = `${PLAN_START_MARKER} +# Real Plan +${PLAN_END_MARKER}` + captureMarkedPlanTextForSession( + { plansRepo: plansRepo as any, projectId: 'test-project', logger }, + 'session-1', + realText, + 'message-1' + ) + + const before = plansRepo.getForSession('test-project', 'session-1')?.content + + const fencedExample = [ + 'Here is an example of plan markers:', + '```', + PLAN_START_MARKER, + '# Example Plan', + PLAN_END_MARKER, + '```', + ].join('\n') + + const result = captureMarkedPlanTextForSession( + { plansRepo: plansRepo as any, projectId: 'test-project', logger }, + 'session-1', + fencedExample, + 'message-2' + ) + + expect(result.status).toBe('not-found') + expect(plansRepo.getForSession('test-project', 'session-1')?.content).toBe(before) + expect(plansRepo.getForSession('test-project', 'session-1')?.content).toBe('# Real Plan') + }) + test('message part event auto-captures before idle or approval', async () => { const plansRepo = createFakePlansRepo() const hook = createPlanCaptureEventHook({ @@ -542,6 +623,59 @@ describe('captureLatestPlanForSession with ForgeClient', () => { expect(result.status).toBe('read-failed') expect(plansRepo.getForSession('test-project', 'session-error')).toBeNull() }) + + test('preserves a plan-write row that lands while message retrieval is pending', async () => { + // Race regression: `execute-plan` checks storage (empty), awaits + // `session.messages`, and during that await a concurrent `plan-write` + // stores a newer revision. Legacy capture must not overwrite it with the + // older marked plan from history. + const plansRepo = createFakePlansRepo() + + let resolveMessages!: (messages: PlanCaptureMessage[]) => void + const messagesPromise = new Promise((resolve) => { + resolveMessages = resolve + }) + + const deps = { + client: { session: { messages: async () => messagesPromise } }, + plansRepo, + projectId: 'test-project', + directory: '/tmp/project', + logger, + } + + const capturePromise = captureLatestPlanForSession(deps as any, 'session-race') + + // While message retrieval is pending, plan-write stores a newer row. + plansRepo.writeForSession('test-project', 'session-race', '# plan-write revision') + const before = plansRepo.getForSession('test-project', 'session-race') + + // Now message retrieval resolves with an older marked plan in history. + resolveMessages([planMessage]) + + const result = await capturePromise + + expect(result.status).toBe('already-current') + const after = plansRepo.getForSession('test-project', 'session-race') + expect(after?.content).toBe('# plan-write revision') + expect(after?.updatedAt).toBe(before?.updatedAt) + }) + + test('still captures from history when no row appears during message retrieval', async () => { + const plansRepo = createFakePlansRepo() + const deps = { + client: { session: { messages: async () => [planMessage] } }, + plansRepo, + projectId: 'test-project', + directory: '/tmp/project', + logger, + } + + const result = await captureLatestPlanForSession(deps as any, 'session-no-row') + + expect(result.status).toBe('captured') + expect(plansRepo.getForSession('test-project', 'session-no-row')?.content).toBe('Found Plan') + }) }) describe('plan capture trigger on assistant message completion', () => { @@ -655,3 +789,370 @@ describe('plan capture trigger on assistant message completion', () => { expect(afterCompletion?.updatedAt).toBe(captured?.updatedAt) }) }) + +describe('capturePlanForCompletedMessage (stored-plan precedence)', () => { + function createFakePlansRepo() { + const plans = new Map() + let nextUpdatedAt = 1 + return { + writeForSession: (_projectId: string, sessionId: string, content: string) => { + plans.set(sessionId, { content, updatedAt: nextUpdatedAt++ }) + }, + getForSession: (_projectId: string, sessionId: string) => { + const row = plans.get(sessionId) + if (!row) return null + return { projectId: 'test-project', loopName: null, sessionId, content: row.content, updatedAt: row.updatedAt } + }, + } + } + + const logger = { + log: () => {}, + error: () => {}, + debug: () => {}, + } + + const olderMarkedPlan = { + info: { role: 'assistant', id: 'msg-old' }, + parts: [{ type: 'text', text: `${PLAN_START_MARKER}\n# Old Plan\n${PLAN_END_MARKER}` }], + } + + const markerFreeCompletion = { + info: { role: 'assistant', id: 'msg-current', time: { created: 3, completed: 4 } }, + parts: [{ type: 'text', text: 'Here is a status update, no plan this time.' }], + } + + test('writes when the completing message contains a marked plan', async () => { + const plansRepo = createFakePlansRepo() + const messages = [ + olderMarkedPlan, + { + info: { role: 'assistant', id: 'msg-current', time: { created: 3, completed: 4 } }, + parts: [{ type: 'text', text: `${PLAN_START_MARKER}\n# New Plan\n${PLAN_END_MARKER}` }], + }, + ] + const deps = { + client: { session: { messages: async () => messages } }, + plansRepo, + projectId: 'test-project', + directory: '/tmp/project', + logger, + } + + const result = await capturePlanForCompletedMessage(deps as any, 'session-prec', 'msg-current') + + expect(result.status).toBe('captured') + expect(plansRepo.getForSession('test-project', 'session-prec')?.content).toBe('# New Plan') + }) + + test('does not overwrite a newer stored row when a marker-free completion has an older marked message in history', async () => { + const plansRepo = createFakePlansRepo() + plansRepo.writeForSession('test-project', 'session-prec', '# plan-write revision') + const before = plansRepo.getForSession('test-project', 'session-prec') + + const deps = { + client: { session: { messages: async () => [olderMarkedPlan, markerFreeCompletion] } }, + plansRepo, + projectId: 'test-project', + directory: '/tmp/project', + logger, + } + + const result = await capturePlanForCompletedMessage(deps as any, 'session-prec', 'msg-current') + + expect(result.status).toBe('not-found') + expect(plansRepo.getForSession('test-project', 'session-prec')?.content).toBe('# plan-write revision') + expect(plansRepo.getForSession('test-project', 'session-prec')?.updatedAt).toBe(before?.updatedAt) + }) + + test('writes when the completing message is the split-message end marker', async () => { + const plansRepo = createFakePlansRepo() + const messages = [ + { + info: { role: 'assistant', id: 'msg-start' }, + parts: [{ type: 'text', text: `${PLAN_START_MARKER}\n## Phase 1: Build` }], + }, + { + info: { role: 'assistant', id: 'msg-end', time: { created: 3, completed: 4 } }, + parts: [{ type: 'text', text: PLAN_END_MARKER }], + }, + ] + const deps = { + client: { session: { messages: async () => messages } }, + plansRepo, + projectId: 'test-project', + directory: '/tmp/project', + logger, + } + + const result = await capturePlanForCompletedMessage(deps as any, 'session-split', 'msg-end') + + expect(result.status).toBe('captured') + expect(plansRepo.getForSession('test-project', 'session-split')?.content).toContain('## Phase 1: Build') + }) + + test('does not replay an earlier split-message start from a different completion', async () => { + const plansRepo = createFakePlansRepo() + plansRepo.writeForSession('test-project', 'session-prec', '# plan-write revision') + const before = plansRepo.getForSession('test-project', 'session-prec') + + // An orphaned start marker in history (no later end) plus a marker-free + // completion must not touch the row. + const messages: PlanCaptureMessage[] = [ + { + info: { role: 'assistant', id: 'msg-start' }, + parts: [{ type: 'text', text: `${PLAN_START_MARKER}\n## Phase 1: Build` }], + }, + markerFreeCompletion, + ] + const deps = { + client: { session: { messages: async () => messages } }, + plansRepo, + projectId: 'test-project', + directory: '/tmp/project', + logger, + } + + const result = await capturePlanForCompletedMessage(deps as any, 'session-prec', 'msg-current') + + expect(result.status).toBe('not-found') + expect(plansRepo.getForSession('test-project', 'session-prec')?.content).toBe('# plan-write revision') + expect(plansRepo.getForSession('test-project', 'session-prec')?.updatedAt).toBe(before?.updatedAt) + }) + + test('preserves a same-timestamp different-content row that lands while message retrieval is pending', async () => { + // Regression: writes use millisecond-resolution `Date.now()`, so a + // concurrent `plan-edit` can store different content with the same + // `updatedAt` as the snapshot. A timestamp-only check would miss that and + // let the stale marked plan overwrite the revision. The revalidation must + // compare content. + const plans: Map = new Map() + const fixedTimestamp = 1_700_000_000_000 + const plansRepo = { + writeForSession: (_projectId: string, sessionId: string, content: string) => { + plans.set(sessionId, { content, updatedAt: fixedTimestamp }) + }, + getForSession: (_projectId: string, sessionId: string) => { + const row = plans.get(sessionId) + if (!row) return null + return { projectId: 'test-project', loopName: null, sessionId, content: row.content, updatedAt: row.updatedAt } + }, + } + + // Seed the row that the completion path will snapshot. + plansRepo.writeForSession('test-project', 'session-same-ts', '# original') + const snapshot = plansRepo.getForSession('test-project', 'session-same-ts') + expect(snapshot?.updatedAt).toBe(fixedTimestamp) + + let resolveMessages!: (messages: PlanCaptureMessage[]) => void + const messagesPromise = new Promise((resolve) => { + resolveMessages = resolve + }) + + const completingMessage: PlanCaptureMessage = { + info: { role: 'assistant', id: 'msg-current' }, + parts: [{ type: 'text', text: `${PLAN_START_MARKER}\n# Stale Marked Plan\n${PLAN_END_MARKER}` }], + } + + const deps = { + client: { session: { messages: async () => messagesPromise } }, + plansRepo, + projectId: 'test-project', + directory: '/tmp/project', + logger, + } + + const capturePromise = capturePlanForCompletedMessage(deps as any, 'session-same-ts', 'msg-current') + + // While message retrieval is pending, plan-edit stores different content + // with the SAME `updatedAt` as the snapshot. + plansRepo.writeForSession('test-project', 'session-same-ts', '# plan-edit revision') + const concurrent = plansRepo.getForSession('test-project', 'session-same-ts') + expect(concurrent?.content).toBe('# plan-edit revision') + expect(concurrent?.updatedAt).toBe(snapshot?.updatedAt) + + resolveMessages([completingMessage]) + + const result = await capturePromise + + expect(result.status).toBe('already-current') + const after = plansRepo.getForSession('test-project', 'session-same-ts') + expect(after?.content).toBe('# plan-edit revision') + expect(after?.updatedAt).toBe(fixedTimestamp) + }) + + test('preserves a same-content newer-timestamp row that lands while message retrieval is pending (A→B→A)', async () => { + // Regression: a concurrent revision can restore the snapshot's content at + // a newer `updatedAt` (e.g. plan-edit A→B→A). A content-only revalidation + // would see matching content and let the stale marked message overwrite + // the newer revision. The revalidation must also compare `updatedAt`. + const plans: Map = new Map() + const plansRepo = { + writeForSession: (_projectId: string, sessionId: string, content: string, updatedAt?: number) => { + plans.set(sessionId, { content, updatedAt: updatedAt ?? Date.now() }) + }, + getForSession: (_projectId: string, sessionId: string) => { + const row = plans.get(sessionId) + if (!row) return null + return { projectId: 'test-project', loopName: null, sessionId, content: row.content, updatedAt: row.updatedAt } + }, + } + + // Seed plan A; this is what the completion path will snapshot. + plansRepo.writeForSession('test-project', 'session-aba', '# plan A', 1_700_000_000_000) + const snapshot = plansRepo.getForSession('test-project', 'session-aba') + expect(snapshot?.content).toBe('# plan A') + + let resolveMessages!: (messages: PlanCaptureMessage[]) => void + const messagesPromise = new Promise((resolve) => { + resolveMessages = resolve + }) + + const completingMessage: PlanCaptureMessage = { + info: { role: 'assistant', id: 'msg-current' }, + parts: [{ type: 'text', text: `${PLAN_START_MARKER}\n# Stale Marked Plan\n${PLAN_END_MARKER}` }], + } + + const deps = { + client: { session: { messages: async () => messagesPromise } }, + plansRepo, + projectId: 'test-project', + directory: '/tmp/project', + logger, + } + + const capturePromise = capturePlanForCompletedMessage(deps as any, 'session-aba', 'msg-current') + + // While message retrieval is pending, a concurrent edit does A→B→A with a + // newer `updatedAt` but the same content as the snapshot. + plansRepo.writeForSession('test-project', 'session-aba', '# plan B', 1_700_000_000_001) + plansRepo.writeForSession('test-project', 'session-aba', '# plan A', 1_700_000_000_002) + const concurrent = plansRepo.getForSession('test-project', 'session-aba') + expect(concurrent?.content).toBe('# plan A') + expect(concurrent?.updatedAt).toBeGreaterThan(snapshot!.updatedAt) + + resolveMessages([completingMessage]) + + const result = await capturePromise + + expect(result.status).toBe('already-current') + const after = plansRepo.getForSession('test-project', 'session-aba') + expect(after?.content).toBe('# plan A') + expect(after?.updatedAt).toBe(1_700_000_000_002) + }) + + test('preserves a newer stored row that lands while message retrieval is pending even when the completing message has a marked plan', async () => { + // Race regression: completion capture snapshots an empty row, awaits + // `session.messages`, and during that await a concurrent `plan-write` + // stores a newer revision. The completing message itself carries a marked + // plan, but it is older than the tool-authored row and must not overwrite. + const plansRepo = createFakePlansRepo() + + let resolveMessages!: (messages: PlanCaptureMessage[]) => void + const messagesPromise = new Promise((resolve) => { + resolveMessages = resolve + }) + + const completingMessage: PlanCaptureMessage = { + info: { role: 'assistant', id: 'msg-current' }, + parts: [{ type: 'text', text: `${PLAN_START_MARKER}\n# Marked Plan\n${PLAN_END_MARKER}` }], + } + + const deps = { + client: { session: { messages: async () => messagesPromise } }, + plansRepo, + projectId: 'test-project', + directory: '/tmp/project', + logger, + } + + const capturePromise = capturePlanForCompletedMessage(deps as any, 'session-race', 'msg-current') + + // While message retrieval is pending, plan-write stores a newer row. + plansRepo.writeForSession('test-project', 'session-race', '# plan-write revision') + const before = plansRepo.getForSession('test-project', 'session-race') + + resolveMessages([completingMessage]) + + const result = await capturePromise + + expect(result.status).toBe('already-current') + const after = plansRepo.getForSession('test-project', 'session-race') + expect(after?.content).toBe('# plan-write revision') + expect(after?.updatedAt).toBe(before?.updatedAt) + }) +}) + +describe('plan-capture hook stored-plan precedence', () => { + function createFakePlansRepo() { + const plans = new Map() + let nextUpdatedAt = 1 + return { + writeForSession: (_projectId: string, sessionId: string, content: string) => { + plans.set(sessionId, { content, updatedAt: nextUpdatedAt++ }) + }, + getForSession: (_projectId: string, sessionId: string) => { + const row = plans.get(sessionId) + if (!row) return null + return { projectId: 'test-project', loopName: null, sessionId, content: row.content, updatedAt: row.updatedAt } + }, + } + } + + const logger = { + log: () => {}, + error: () => {}, + debug: () => {}, + } + + test('an older marked plan in history does not overwrite a newer plan-write row on a marker-free completion', async () => { + const plansRepo = createFakePlansRepo() + const messages = [ + { + info: { role: 'assistant', id: 'msg-architect', time: { created: 1, completed: 2 } }, + parts: [{ type: 'text', text: `${PLAN_START_MARKER}\n# Stale Architect Plan\n${PLAN_END_MARKER}` }], + }, + { + info: { role: 'assistant', id: 'msg-free', time: { created: 3, completed: 4 } }, + parts: [{ type: 'text', text: 'Acknowledged — proceeding with the revised plan.' }], + }, + ] + const hook = createPlanCaptureEventHook({ + client: { session: { messages: async () => messages } }, + plansRepo, + projectId: 'test-project', + directory: '/tmp/project', + logger, + } as any) + + // plan-write authored a newer revision + plansRepo.writeForSession('test-project', 'session-prec', '# plan-write revision') + const beforeHook = plansRepo.getForSession('test-project', 'session-prec') + + // Marker-free completion fires the hook + await hook({ event: { type: 'message.updated', properties: { sessionID: 'session-prec', info: messages[1].info } } }) + + const after = plansRepo.getForSession('test-project', 'session-prec') + expect(after?.content).toBe('# plan-write revision') + expect(after?.updatedAt).toBe(beforeHook?.updatedAt) + }) + + test('a newly completed marked plan still captures through the completion hook', async () => { + const plansRepo = createFakePlansRepo() + const messages = [{ + info: { role: 'assistant', id: 'msg-marked', time: { created: 1, completed: 2 } }, + parts: [{ type: 'text', text: `${PLAN_START_MARKER}\n# Fresh Plan\n${PLAN_END_MARKER}` }], + }] + const hook = createPlanCaptureEventHook({ + client: { session: { messages: async () => messages } }, + plansRepo, + projectId: 'test-project', + directory: '/tmp/project', + logger, + } as any) + + await hook({ event: { type: 'message.updated', properties: { sessionID: 'session-fresh', info: messages[0].info } } }) + + expect(plansRepo.getForSession('test-project', 'session-fresh')?.content).toBe('# Fresh Plan') + }) +}) diff --git a/test/plan-structure.test.ts b/test/plan-structure.test.ts new file mode 100644 index 0000000000..83287730db --- /dev/null +++ b/test/plan-structure.test.ts @@ -0,0 +1,185 @@ +import { describe, test, expect } from 'vitest' +import { + summarizePlanStructure, + formatPlanStructureSummary, +} from '../src/utils/plan-structure' +import { MAX_TOTAL_SECTIONS } from '../src/constants/loop' + +function planWithMarkers(count: number, body = 'body'): string { + const parts: string[] = [] + for (let i = 0; i < count; i++) { + parts.push(``, `## Phase ${i + 1}`, body) + } + return parts.join('\n') +} + +describe('summarizePlanStructure', () => { + test('counts unfenced marker lines', () => { + expect(summarizePlanStructure('\n## Phase 1\nbody').sectionMarkers).toBe(1) + expect(summarizePlanStructure(planWithMarkers(3)).sectionMarkers).toBe(3) + }) + + test('reports 0 markers when none are present', () => { + expect(summarizePlanStructure('## Phase 1\nbody').sectionMarkers).toBe(0) + expect(summarizePlanStructure('').sectionMarkers).toBe(0) + }) + + test('ignores markers inside ``` fences', () => { + const plan = ['', '## Phase 1', '```ts', '', 'const x = 1', '```', 'body'].join('\n') + expect(summarizePlanStructure(plan).sectionMarkers).toBe(1) + }) + + test('basic summary with 3 markers and 3 non-empty bodies', () => { + const summary = summarizePlanStructure(planWithMarkers(3)) + expect(summary.sectionMarkers).toBe(3) + expect(summary.sections).toHaveLength(3) + expect(summary.sections.map((s) => s.title)).toEqual(['Phase 1', 'Phase 2', 'Phase 3']) + expect(summary.sections.map((s) => s.index)).toEqual([0, 1, 2]) + expect(summary.warnings).not.toContainEqual(expect.stringMatching(/section marker/i)) + expect(summary.warnings).not.toContainEqual(expect.stringMatching(/cap of/)) + }) + + test('26 markers hit the cap: sectionMarkers=26, sections=24, cap warning names 26 and 24', () => { + const summary = summarizePlanStructure(planWithMarkers(26)) + expect(summary.sectionMarkers).toBe(26) + expect(summary.sections).toHaveLength(MAX_TOTAL_SECTIONS) + expect(summary.sections).toHaveLength(24) + const cap = summary.warnings.find((w) => w.includes('exceed the cap')) + expect(cap).toBeDefined() + expect(cap).toContain('26') + expect(cap).toContain('24') + // No empty-body warning: diff is fully explained by the cap. + expect(summary.warnings.find((w) => w.includes('empty bodies'))).toBeUndefined() + }) + + test('marker followed by another marker produces the empty-body warning', () => { + const plan = ['', '', '## Phase 2', 'body'].join('\n') + const summary = summarizePlanStructure(plan) + expect(summary.sectionMarkers).toBe(2) + expect(summary.sections).toHaveLength(1) + const empty = summary.warnings.find((w) => w.includes('empty bodies')) + expect(empty).toBeDefined() + expect(empty).toContain('1 section marker(s) have empty bodies and will be skipped.') + }) + + test('mixed cap + empty body: 25 markers with one empty body and 24 valid sections reports the empty-body warning', () => { + // 25 markers; the second marker is immediately followed by another marker + // so its body is empty. The remaining 24 markers each have a non-empty + // body, so the cap drops nothing and the empty-body warning must surface. + const parts: string[] = [] + for (let i = 0; i < 25; i++) { + parts.push('') + if (i === 1) { + // Skip the body so this marker is immediately followed by the next one. + continue + } + parts.push(`## Phase ${i + 1}`, 'body') + } + const summary = summarizePlanStructure(parts.join('\n')) + expect(summary.sectionMarkers).toBe(25) + expect(summary.sections).toHaveLength(24) + const empty = summary.warnings.find((w) => w.includes('empty bodies')) + expect(empty).toBeDefined() + expect(empty).toContain('1 section marker(s) have empty bodies and will be skipped.') + }) + + test('empty plan returns lines:1, characters:0, no markers, no sections, null loop name, and both warnings', () => { + const summary = summarizePlanStructure('') + expect(summary.lines).toBe(1) + expect(summary.characters).toBe(0) + expect(summary.sectionMarkers).toBe(0) + expect(summary.sections).toEqual([]) + expect(summary.loopName).toBeNull() + expect(summary.warnings).toContain('No markers found: the whole plan will run as a single section.') + expect(summary.warnings).toContain('No "Loop Name:" line found: the loop name will be derived from the plan title.') + }) + + test('non-repo-relative path ~/foo/bar.ts triggers the path warning', () => { + const plan = `\n## Phase 1\nEdit \`~/foo/bar.ts\` and \`src/foo.ts\`.` + const summary = summarizePlanStructure(plan) + const path = summary.warnings.find((w) => w.includes('non-repo-relative')) + expect(path).toBeDefined() + expect(path).toBe('Plan contains a non-repo-relative path (~/foo/bar.ts). Use repo-relative paths.') + expect(path).not.toContain('src/foo.ts') + }) + + test('a plan with only repo-relative paths produces no path warning', () => { + const plan = `\n## Phase 1\nEdit \`src/foo.ts\` and \`test/foo.test.ts\`.` + const summary = summarizePlanStructure(plan) + expect(summary.warnings.find((w) => w.includes('non-repo-relative'))).toBeUndefined() + }) + + test('Loop Name: foo is detected (plain form)', () => { + const plan = `Loop Name: foo\n\n${planWithMarkers(1)}` + const summary = summarizePlanStructure(plan) + expect(summary.loopName).toBe('foo') + expect(summary.warnings.find((w) => w.includes('"Loop Name:"'))).toBeUndefined() + }) + + test('**Loop Name**: foo is detected (bold form)', () => { + const plan = `**Loop Name**: foo\n\n${planWithMarkers(1)}` + const summary = summarizePlanStructure(plan) + expect(summary.loopName).toBe('foo') + expect(summary.warnings.find((w) => w.includes('"Loop Name:"'))).toBeUndefined() + }) + + test('lines and characters are computed from raw plan text', () => { + const plan = 'a\nb\nc' + const summary = summarizePlanStructure(plan) + expect(summary.lines).toBe(3) + expect(summary.characters).toBe(5) + }) +}) + +describe('formatPlanStructureSummary', () => { + test('renders the canonical structure with warnings', () => { + const summary = summarizePlanStructure(`Loop Name: plan-authoring-tools\n\n${planWithMarkers(2)}`) + const formatted = formatPlanStructureSummary(summary) + expect(formatted).toContain(`Plan stored: ${summary.lines} lines, ${summary.characters} chars.`) + expect(formatted).toContain('Loop Name: plan-authoring-tools') + expect(formatted).toContain('Sections (2):') + expect(formatted).toContain(' 1. Phase 1') + expect(formatted).toContain(' 2. Phase 2') + // No warnings in this case (markers and loop name present, no path). + expect(formatted).not.toContain('Warnings:') + }) + + test('omits Loop Name: line when null', () => { + const summary = summarizePlanStructure(planWithMarkers(1)) + const formatted = formatPlanStructureSummary(summary) + expect(formatted).not.toContain('\nLoop Name:') + // The warning block still mentions it. + expect(formatted).toContain('Warnings:') + expect(formatted).toContain('No "Loop Name:" line found') + }) + + test('omits the Warnings: block entirely when there are none', () => { + const summary: import('../src/utils/plan-structure').PlanStructureSummary = { + lines: 10, + characters: 100, + sectionMarkers: 1, + sections: [{ index: 0, title: 'Phase 1' }], + loopName: 'foo', + warnings: [], + } + const formatted = formatPlanStructureSummary(summary) + expect(formatted).not.toContain('Warnings:') + expect(formatted).toContain('Loop Name: foo') + expect(formatted).toContain('Sections (1):') + expect(formatted).toContain(' 1. Phase 1') + }) + + test('prints "Sections (0): none detected" when sections is empty', () => { + const summary = summarizePlanStructure('just prose, no markers') + const formatted = formatPlanStructureSummary(summary) + expect(formatted).toContain('Sections (0): none detected') + expect(formatted).not.toMatch(/Sections \(0\):\s*\n/) + }) + + test('renders cap warning when present', () => { + const summary = summarizePlanStructure(planWithMarkers(26)) + const formatted = formatPlanStructureSummary(summary) + expect(formatted).toContain('Warnings:') + expect(formatted).toContain('exceed the cap of 24') + }) +}) diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 6c8981de95..f24e87de68 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -94,8 +94,8 @@ describe('createForgePlugin', () => { expect(hooks.tool?.['memory-health']).toBeUndefined() // Plan/review tools should be registered expect(hooks.tool?.['plan-read']).toBeDefined() - expect(hooks.tool?.['plan-edit']).toBeUndefined() - expect(hooks.tool?.['plan-write']).toBeUndefined() + expect(hooks.tool?.['plan-edit']).toBeDefined() + expect(hooks.tool?.['plan-write']).toBeDefined() expect(hooks.tool?.['review-read']).toBeDefined() expect(hooks.tool?.['review-write']).toBeDefined() // Ast-grep tools should NOT be registered diff --git a/test/prompts/loader.test.ts b/test/prompts/loader.test.ts index 6e5ac12391..cd792794d7 100644 --- a/test/prompts/loader.test.ts +++ b/test/prompts/loader.test.ts @@ -9,6 +9,14 @@ describe('loadPrompt', () => { test('loads architect prompt from bundled markdown', () => { const prompt = loadPrompt(['agents', 'architect.md']) expect(prompt).toContain('You are a planning agent') + expect(prompt).toContain('plan-write') + expect(prompt).toContain('plan-edit') + }) + + test('architect-auto prompt retains forge-plan:none token', () => { + const prompt = loadPrompt(['agents', 'architect-auto.md']) + expect(prompt).toContain('') + expect(prompt).toContain('plan-write') }) test('loads code prompt from bundled markdown', () => { diff --git a/test/services/group-orchestrator.test.ts b/test/services/group-orchestrator.test.ts index cd83006c13..a1e7340b97 100644 --- a/test/services/group-orchestrator.test.ts +++ b/test/services/group-orchestrator.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, rmSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' import { createFeatureGroupsRepo } from '../../src/storage/repos/feature-groups-repo' +import { createPlansRepo } from '../../src/storage/repos/plans-repo' import { createGroupOrchestrator, mapLoopStateToOutcome, @@ -405,6 +406,45 @@ describe('GroupOrchestrator', () => { expect(ctx.effects.launchLoop).toHaveBeenCalledTimes(0) }) + test('onArchitectIdle advances when capturePlan falls back to a stored plans row', async () => { + // Simulate the new capturePlan fallback in src/index.ts: the marked-plan + // scan returns not-found, but a stored plans row exists for the architect + // session, so capturePlan returns captured:true and the feature advances + // rather than being classified as a failure. + ctx.db.run(` + CREATE TABLE IF NOT EXISTS plans ( + project_id TEXT NOT NULL, + loop_name TEXT, + session_id TEXT, + content TEXT NOT NULL, + updated_at INTEGER NOT NULL, + CHECK (loop_name IS NOT NULL OR session_id IS NOT NULL), + CHECK (NOT (loop_name IS NOT NULL AND session_id IS NOT NULL)), + UNIQUE (project_id, loop_name), + UNIQUE (project_id, session_id) + ) + `) + const plansRepo = createPlansRepo(ctx.db) + plansRepo.writeForSession(PROJECT_ID, 'arch-session-0', '# Tool-authored plan') + + ctx.effects.capturePlan.mockImplementation(async (sessionId: string) => ({ + captured: plansRepo.getForSession(PROJECT_ID, sessionId) != null, + })) + + const result = await ctx.orchestrator.startGroup({ + title: 'Stored Plan Fallback', + features: makeFeatures(1), + }) + + await ctx.orchestrator.onArchitectIdle('arch-session-0') + + const features = ctx.repo.listFeatures(PROJECT_ID, result.groupId) + expect(features[0].stage).toBe('running') + expect(ctx.effects.capturePlan).toHaveBeenCalledWith('arch-session-0') + expect(ctx.effects.classifyArchitectFailure).not.toHaveBeenCalled() + expect(ctx.effects.launchLoop).toHaveBeenCalledTimes(1) + }) + // Phase 8: premature-idle guard test goes here (currently removed for Phase 7) // ── onLoopTerminated + queue draining ──────────────────────────────────── diff --git a/test/services/section-bootstrap.test.ts b/test/services/section-bootstrap.test.ts index 28d2dda815..c72383d0bb 100644 --- a/test/services/section-bootstrap.test.ts +++ b/test/services/section-bootstrap.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect, vi } from 'vitest' import { applyPlanDecomposition } from '../../src/services/section-bootstrap' +import { MAX_TOTAL_SECTIONS } from '../../src/constants/loop' import type { LoopsRepo } from '../../src/storage/repos/loops-repo' import type { SectionPlansRepo } from '../../src/storage/repos/section-plans-repo' @@ -122,6 +123,66 @@ describe('applyPlanDecomposition', () => { expect(sectionPlansRepo.setStartedAt).toHaveBeenCalledWith(PROJECT_ID, LOOP_NAME, 0, expect.any(Number)) }) + test('20-section plan sets total_sections=20 and inserts 20 section_plans rows', () => { + const loopsRepo = buildSpyLoopsRepo() + const sectionPlansRepo = buildSpySectionPlansRepo() + + const planText = Array.from({ length: 20 }, (_, i) => + [ + '', + `## Phase ${i + 1}`, + '', + `Body ${i + 1}.`, + ].join('\n'), + ).join('\n') + + const result = applyPlanDecomposition({ + projectId: PROJECT_ID, + loopName: LOOP_NAME, + planText, + loopsRepo, + sectionPlansRepo, + }) + + expect(result).toEqual({ totalSections: 20 }) + + expect(sectionPlansRepo.bulkInsert).toHaveBeenCalledTimes(1) + const bulkInsertArgs = vi.mocked(sectionPlansRepo.bulkInsert).mock.calls[0][0] + expect(bulkInsertArgs.sections).toHaveLength(20) + expect(bulkInsertArgs.sections[0].title).toBe('Phase 1') + expect(bulkInsertArgs.sections[19].title).toBe('Phase 20') + + expect(loopsRepo.setTotalSections).toHaveBeenCalledTimes(1) + expect(loopsRepo.setTotalSections).toHaveBeenCalledWith(PROJECT_ID, LOOP_NAME, 20) + }) + + test('30-section plan is capped at MAX_TOTAL_SECTIONS', () => { + const loopsRepo = buildSpyLoopsRepo() + const sectionPlansRepo = buildSpySectionPlansRepo() + + const planText = Array.from({ length: 30 }, (_, i) => + [ + '', + `## Phase ${i + 1}`, + '', + `Body ${i + 1}.`, + ].join('\n'), + ).join('\n') + + const result = applyPlanDecomposition({ + projectId: PROJECT_ID, + loopName: LOOP_NAME, + planText, + loopsRepo, + sectionPlansRepo, + }) + + expect(result).toEqual({ totalSections: MAX_TOTAL_SECTIONS }) + const bulkInsertArgs = vi.mocked(sectionPlansRepo.bulkInsert).mock.calls[0][0] + expect(bulkInsertArgs.sections).toHaveLength(MAX_TOTAL_SECTIONS) + expect(loopsRepo.setTotalSections).toHaveBeenCalledWith(PROJECT_ID, LOOP_NAME, MAX_TOTAL_SECTIONS) + }) + test('no-marker plan: returns 0, does not persist sections', () => { const loopsRepo = buildSpyLoopsRepo() const sectionPlansRepo = buildSpySectionPlansRepo() diff --git a/test/tools/plan-authoring.test.ts b/test/tools/plan-authoring.test.ts new file mode 100644 index 0000000000..2f507c6ffd --- /dev/null +++ b/test/tools/plan-authoring.test.ts @@ -0,0 +1,468 @@ +import { describe, test, expect, beforeEach, afterEach } from 'vitest' +import type { Database } from 'bun:sqlite' +import { createPlanAuthoringTools } from '../../src/tools/plan-authoring' +import { createPlanTools } from '../../src/tools/plan-kv' +import { createLoopService } from '../../src/loop/service' +import { createLoopsRepo } from '../../src/storage/repos/loops-repo' +import { createPlansRepo } from '../../src/storage/repos/plans-repo' +import { createReviewFindingsRepo } from '../../src/storage/repos/review-findings-repo' +import { openForgeDatabase } from '../../src/storage/database' +import { tmpdir } from 'os' +import { join } from 'path' +import { randomUUID } from 'crypto' +import type { Logger } from '../../src/types' +import type { ToolContext } from '../../src/tools/types' + +const TEST_DIR = '/tmp/opencode-plan-write-test-' + Date.now() + +function createTestDb(): Database { + return openForgeDatabase(join(tmpdir(), `forge-test-${randomUUID()}.db`)) +} + +const mockLogger: Logger = { + log: () => {}, + error: () => {}, + debug: () => {}, +} + +function insertRunningLoop( + loopsRepo: ReturnType, + loopName: string, + sessionId: string, +) { + loopsRepo.insert( + { + projectId: 'test-project', + loopName, + status: 'running', + currentSessionId: sessionId, + worktree: false, + worktreeDir: TEST_DIR, + worktreeBranch: 'feature-branch', + projectDir: TEST_DIR, + maxIterations: 10, + iteration: 1, + auditCount: 0, + errorCount: 0, + phase: 'coding', + executionModel: 'test-model', + auditorModel: 'test-auditor', + modelFailed: false, + sandbox: false, + sandboxContainer: null, + completedAt: null, + terminationReason: null, + completionSummary: null, + workspaceId: null, + hostSessionId: null, + startedAt: Date.now(), + currentSectionIndex: 0, + totalSections: 0, + finalAuditDone: 0, + executionVariant: null, + auditorVariant: null, + kind: 'plan', + }, + { lastAuditResult: null }, + ) +} + +function createToolContext(db: Database) { + const loopsRepo = createLoopsRepo(db) + const plansRepo = createPlansRepo(db) + const reviewFindingsRepo = createReviewFindingsRepo(db) + const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, 'test-project', mockLogger) + return { + ctx: { + plansRepo, + loopsRepo, + reviewFindingsRepo, + projectId: 'test-project', + logger: mockLogger, + loop: { service: loopService }, + directory: TEST_DIR, + } as unknown as ToolContext, + plansRepo, + loopsRepo, + } +} + +describe('plan-write', () => { + let db: Database + let tools: ReturnType + let plansRepo: ReturnType + let loopsRepo: ReturnType + + beforeEach(() => { + db = createTestDb() + const env = createToolContext(db) + tools = createPlanAuthoringTools(env.ctx) + plansRepo = env.plansRepo + loopsRepo = env.loopsRepo + }) + + afterEach(() => { + db.close() + }) + + test('writes normalized plan content to the session row', async () => { + const content = `# Plan + +Loop Name: my-loop + + +## Phase 1 +- do one + + +## Phase 2 +- do two +` + const result = await tools['plan-write'].execute( + { content }, + { sessionID: 'sess-1', directory: TEST_DIR } as any, + ) + + expect(result).toContain('Plan stored:') + expect(result).toContain('Sections (2):') + + const row = plansRepo.getForSession('test-project', 'sess-1') + expect(row).not.toBeNull() + expect(row!.content).toBe(content.trim()) + expect(row!.content).not.toContain('') + expect(row!.content).not.toContain('') + }) + + test('strips outer forge-plan markers before storing', async () => { + const body = `# Plan + + +## Phase 1 +- do one +` + const content = ` +${body} +` + + const result = await tools['plan-write'].execute( + { content }, + { sessionID: 'sess-1', directory: TEST_DIR } as any, + ) + + expect(result).toContain('Plan stored:') + const row = plansRepo.getForSession('test-project', 'sess-1') + expect(row!.content).toBe(body.trim()) + expect(row!.content).not.toContain('forge-plan:') + }) + + test('append: true concatenates existing content with two newlines', async () => { + const existing = `# Plan + + +## Phase 1 +- do one` + plansRepo.writeForSession('test-project', 'sess-1', existing) + + const fragment = ` +## Phase 2 +- do two` + + const result = await tools['plan-write'].execute( + { content: fragment, append: true }, + { sessionID: 'sess-1', directory: TEST_DIR } as any, + ) + + expect(result).toContain('Plan stored:') + const row = plansRepo.getForSession('test-project', 'sess-1') + expect(row!.content).toBe(`${existing}\n\n${fragment.trim()}`) + }) + + test('append: true creates the row when none exists', async () => { + const fragment = `# Fresh Plan + + +## Phase 1 +- do one` + + const result = await tools['plan-write'].execute( + { content: fragment, append: true }, + { sessionID: 'sess-1', directory: TEST_DIR } as any, + ) + + expect(result).toContain('Plan stored:') + const row = plansRepo.getForSession('test-project', 'sess-1') + expect(row).not.toBeNull() + expect(row!.content).toBe(fragment.trim()) + }) + + test('from a running-loop session performs no write and names the loop', async () => { + plansRepo.writeForSession('test-project', 'sess-1', 'pre-existing content') + insertRunningLoop(loopsRepo, 'test-loop', 'sess-1') + + const result = await tools['plan-write'].execute( + { content: '# attempt\n' }, + { sessionID: 'sess-1', directory: TEST_DIR } as any, + ) + + expect(result).toContain('Cannot modify the plan from an active loop session') + expect(result).toContain('test-loop') + expect(result).toContain('plan-adjust') + + const row = plansRepo.getForSession('test-project', 'sess-1') + expect(row!.content).toBe('pre-existing content') + }) + + test('unbalanced plan marker performs no write and returns failure message', async () => { + const content = ` +# Plan +- no end marker` + + const result = await tools['plan-write'].execute( + { content }, + { sessionID: 'sess-1', directory: TEST_DIR } as any, + ) + + expect(result).toBe( + 'plan-write failed: content contains an unbalanced / marker.', + ) + expect(plansRepo.getForSession('test-project', 'sess-1')).toBeNull() + }) + + test('empty content returns a failure message and does not write', async () => { + const result = await tools['plan-write'].execute( + { content: ' ' }, + { sessionID: 'sess-1', directory: TEST_DIR } as any, + ) + + expect(result).toBe('plan-write failed: content is empty.') + expect(plansRepo.getForSession('test-project', 'sess-1')).toBeNull() + }) +}) + +describe('plan-edit', () => { + let db: Database + let tools: ReturnType + let plansRepo: ReturnType + let loopsRepo: ReturnType + + beforeEach(() => { + db = createTestDb() + const env = createToolContext(db) + tools = createPlanAuthoringTools(env.ctx) + plansRepo = env.plansRepo + loopsRepo = env.loopsRepo + }) + + afterEach(() => { + db.close() + }) + + const BASE_PLAN = `# Plan + + +## Phase 1 +- do one + + +## Phase 2 +- do two +` + + test('a unique-match edit rewrites only the matched span and leaves the rest byte-identical', async () => { + plansRepo.writeForSession('test-project', 'sess-1', BASE_PLAN) + + const result = await tools['plan-edit'].execute( + { oldString: '- do one', newString: '- do one (revised)' }, + { sessionID: 'sess-1', directory: TEST_DIR } as any, + ) + + expect(result).toContain('Replaced 1 occurrence(s).') + expect(result).toContain('Plan stored:') + + const row = plansRepo.getForSession('test-project', 'sess-1') + expect(row!.content).toBe(BASE_PLAN.replace('- do one', '- do one (revised)')) + // Untouched portions remain byte-identical. + expect(row!.content).toContain('## Phase 2') + expect(row!.content).toContain('- do two') + }) + + test('a 3-occurrence oldString without replaceAll performs no write and names 3', async () => { + const content = `# Plan\n\nline\nline\nline\n` + plansRepo.writeForSession('test-project', 'sess-1', content) + const before = plansRepo.getForSession('test-project', 'sess-1')! + + const result = await tools['plan-edit'].execute( + { oldString: 'line', newString: 'replaced' }, + { sessionID: 'sess-1', directory: TEST_DIR } as any, + ) + + expect(result).toBe( + 'plan-edit failed: found 3 matches for oldString. Add surrounding context to make it unique, or set replaceAll: true.', + ) + const after = plansRepo.getForSession('test-project', 'sess-1')! + expect(after.content).toBe(before.content) + expect(after.updatedAt).toBe(before.updatedAt) + }) + + test('the same oldString with replaceAll: true replaces all 3 and the report leads with Replaced 3 occurrence(s).', async () => { + const content = `# Plan\n\nline\nline\nline\n` + plansRepo.writeForSession('test-project', 'sess-1', content) + + const result = await tools['plan-edit'].execute( + { oldString: 'line', newString: 'replaced', replaceAll: true }, + { sessionID: 'sess-1', directory: TEST_DIR } as any, + ) + + expect(result).toMatch(/^Replaced 3 occurrence\(s\)\./) + expect(result).toContain('Plan stored:') + const row = plansRepo.getForSession('test-project', 'sess-1') + expect(row!.content).toBe('# Plan\n\nreplaced\nreplaced\nreplaced\n') + }) + + test('oldString containing regex metacharacters is matched literally', async () => { + const content = `# Plan + +[Phase 1] (a|b) $x is special +` + plansRepo.writeForSession('test-project', 'sess-1', content) + + const result = await tools['plan-edit'].execute( + { oldString: '[Phase 1] (a|b) $x', newString: '[Phase 2] (c) $y' }, + { sessionID: 'sess-1', directory: TEST_DIR } as any, + ) + + expect(result).toContain('Replaced 1 occurrence(s).') + const row = plansRepo.getForSession('test-project', 'sess-1') + expect(row!.content).toBe(content.replace('[Phase 1] (a|b) $x', '[Phase 2] (c) $y')) + }) + + test('editing with no stored plan performs no write and returns the corresponding message', async () => { + const result = await tools['plan-edit'].execute( + { oldString: 'foo', newString: 'bar' }, + { sessionID: 'sess-empty', directory: TEST_DIR } as any, + ) + + expect(result).toBe('plan-edit failed: no plan stored for this session. Use plan-write to create it first.') + expect(plansRepo.getForSession('test-project', 'sess-empty')).toBeNull() + }) + + test('editing with oldString === newString performs no write and returns the corresponding message', async () => { + plansRepo.writeForSession('test-project', 'sess-1', BASE_PLAN) + const before = plansRepo.getForSession('test-project', 'sess-1')! + + const result = await tools['plan-edit'].execute( + { oldString: '## Phase 1', newString: '## Phase 1' }, + { sessionID: 'sess-1', directory: TEST_DIR } as any, + ) + + expect(result).toBe('plan-edit failed: oldString and newString are identical.') + const after = plansRepo.getForSession('test-project', 'sess-1')! + expect(after.content).toBe(before.content) + expect(after.updatedAt).toBe(before.updatedAt) + }) + + test('a zero-match edit performs no write and leaves content and updatedAt untouched', async () => { + plansRepo.writeForSession('test-project', 'sess-1', BASE_PLAN) + const before = plansRepo.getForSession('test-project', 'sess-1')! + + const result = await tools['plan-edit'].execute( + { oldString: 'no such text here', newString: 'whatever' }, + { sessionID: 'sess-1', directory: TEST_DIR } as any, + ) + + expect(result).toBe( + 'plan-edit failed: oldString not found in the stored plan. Use plan-read to inspect the current text; whitespace and indentation must match exactly.', + ) + const after = plansRepo.getForSession('test-project', 'sess-1')! + expect(after.content).toBe(before.content) + expect(after.updatedAt).toBe(before.updatedAt) + }) + + test('editing from a running-loop session performs no write and returns the plan-adjust guidance', async () => { + plansRepo.writeForSession('test-project', 'sess-1', BASE_PLAN) + insertRunningLoop(loopsRepo, 'test-loop', 'sess-1') + + const result = await tools['plan-edit'].execute( + { oldString: '## Phase 1', newString: '## Phase 1 (edited)' }, + { sessionID: 'sess-1', directory: TEST_DIR } as any, + ) + + expect(result).toContain('Cannot modify the plan from an active loop session') + expect(result).toContain('test-loop') + expect(result).toContain('plan-adjust') + expect(plansRepo.getForSession('test-project', 'sess-1')!.content).toBe(BASE_PLAN) + }) + + test('an edit that adds a forge-section marker is reflected in the returned section count', async () => { + const single = `# Plan + + +## Phase 1 +- do one +` + plansRepo.writeForSession('test-project', 'sess-1', single) + + const result = await tools['plan-edit'].execute( + { + oldString: '- do one', + newString: '- do one\n\n\n## Phase 2\n- do two', + }, + { sessionID: 'sess-1', directory: TEST_DIR } as any, + ) + + expect(result).toContain('Sections (2):') + expect(result).toContain('Phase 1') + expect(result).toContain('Phase 2') + const row = plansRepo.getForSession('test-project', 'sess-1') + expect(row!.content).toContain('## Phase 2') + }) + + test('an edit whose result would be empty or whitespace-only performs no write', async () => { + const content = '# The entire plan' + plansRepo.writeForSession('test-project', 'sess-1', content) + const before = plansRepo.getForSession('test-project', 'sess-1')! + + const result = await tools['plan-edit'].execute( + { oldString: content, newString: ' ' }, + { sessionID: 'sess-1', directory: TEST_DIR } as any, + ) + + expect(result).toContain('plan-edit failed: replacement would leave the plan empty.') + const after = plansRepo.getForSession('test-project', 'sess-1')! + expect(after.content).toBe(before.content) + expect(after.updatedAt).toBe(before.updatedAt) + }) + + test('deleting an ordinary substring to leave non-whitespace content still writes', async () => { + const content = `# Plan + +Line A +Line B` + plansRepo.writeForSession('test-project', 'sess-1', content) + + const result = await tools['plan-edit'].execute( + { oldString: 'Line B', newString: '' }, + { sessionID: 'sess-1', directory: TEST_DIR } as any, + ) + + expect(result).toContain('Replaced 1 occurrence(s).') + const row = plansRepo.getForSession('test-project', 'sess-1') + expect(row!.content).toBe('# Plan\n\nLine A\n') + }) +}) + +describe('createTools registration', () => { + test('plan-write is registered alongside plan-read', async () => { + const db = createTestDb() + try { + const env = createToolContext(db) + // Simulate the full createTools spread by combining plan-kv and plan-authoring + const tools = { ...createPlanTools(env.ctx), ...createPlanAuthoringTools(env.ctx) } + expect(tools['plan-read']).toBeDefined() + expect(tools['plan-write']).toBeDefined() + expect(tools['plan-edit']).toBeDefined() + } finally { + db.close() + } + }) +}) diff --git a/test/utils/tui-client-stored-plan.test.ts b/test/utils/tui-client-stored-plan.test.ts new file mode 100644 index 0000000000..7807547d1d --- /dev/null +++ b/test/utils/tui-client-stored-plan.test.ts @@ -0,0 +1,113 @@ +import { describe, test, expect, beforeEach, vi } from 'vitest' + +vi.mock('../../src/utils/tui-execution-preferences', () => ({ + deriveExecutionPreferencesFromWorkspaces: vi.fn().mockReturnValue(null), +})) + +vi.mock('../../src/utils/tui-models', () => ({ + fetchAvailableModels: vi.fn().mockResolvedValue({ providers: [] }), + readOpenCodeFavoriteModels: vi.fn().mockReturnValue([]), +})) + +vi.mock('../../src/utils/workspace-listing', () => ({ + listConnectedWorkspaces: vi.fn().mockResolvedValue([]), +})) + +vi.mock('../../src/storage', () => ({ + resolveLogPath: vi.fn().mockReturnValue('/tmp/forge-test.log'), + resolveDataDir: vi.fn().mockReturnValue('/tmp/forge-test-data-dir'), +})) + +vi.mock('../../src/services/execution', () => ({ + ForgeLoopExtra: {}, +})) + +import { join } from 'path' +import { tmpdir } from 'os' +import { randomUUID } from 'crypto' +import { connectForgeProject } from '../../src/utils/tui-client' +import { openForgeDatabase } from '../../src/storage/database' +import { createPlansRepo } from '../../src/storage/repos/plans-repo' + +const PROJECT_ID = 'proj_stored_plan' +const DIRECTORY = '/tmp/test' +const SESSION_ID = 'sess_stored_plan' + +function makeDbPath(): string { + return join(tmpdir(), `forge-tui-client-stored-plan-${randomUUID()}.db`) +} + +describe('connectForgeProject.loadLatestPlan honors configured dataDir', () => { + let mockApi: any + + beforeEach(() => { + mockApi = { + client: { + project: { + list: vi.fn().mockResolvedValue({ + data: [{ id: PROJECT_ID, worktree: DIRECTORY }], + }), + }, + session: { + // Spy used to assert the chat-scan fallback is NOT hit when the + // stored plan resolves from the configured data directory. + messages: vi.fn().mockResolvedValue({ data: [] }), + }, + }, + } + }) + + test('returns the stored plan from the configured dataDir without calling session.messages', async () => { + const dbPath = makeDbPath() + const db = openForgeDatabase(dbPath) + try { + createPlansRepo(db).writeForSession(PROJECT_ID, SESSION_ID, '# Stored Plan\n## Phase 1\nbody') + db.close() + } catch (err) { + db.close() + throw err + } + + const client = await connectForgeProject(mockApi, DIRECTORY, [], dbPath) + expect(client).not.toBeNull() + + const plan = await client!.loadLatestPlan(SESSION_ID) + expect(plan).toBe('# Stored Plan\n## Phase 1\nbody') + + // Storage-first means the chat scan must not run when a stored row exists. + expect(mockApi.client.session.messages).not.toHaveBeenCalled() + }) + + test('falls back to the chat scan when no stored row exists for the session', async () => { + const dbPath = makeDbPath() + const db = openForgeDatabase(dbPath) + db.close() + + const client = await connectForgeProject(mockApi, DIRECTORY, [], dbPath) + expect(client).not.toBeNull() + + const plan = await client!.loadLatestPlan(SESSION_ID) + expect(plan).toBeNull() + + // No stored plan → the message-scan fallback runs. + expect(mockApi.client.session.messages).toHaveBeenCalledTimes(1) + expect(mockApi.client.session.messages.mock.calls[0][0]).toMatchObject({ + sessionID: SESSION_ID, + directory: DIRECTORY, + }) + }) + + test('with no dbPath override, reads from the default data dir path (still null for unknown session)', async () => { + // No configured dataDir → connectForgeProject receives undefined and + // fetchStoredSessionPlan falls back to the default path. We can't write + // into the real default DB from a test, so we only assert that the + // chat-scan fallback is reached (proving the default path was tried and + // missed). + const client = await connectForgeProject(mockApi, DIRECTORY, [], undefined) + expect(client).not.toBeNull() + + const plan = await client!.loadLatestPlan(SESSION_ID) + expect(plan).toBeNull() + expect(mockApi.client.session.messages).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/utils/tui-stored-plan.test.ts b/test/utils/tui-stored-plan.test.ts new file mode 100644 index 0000000000..79afc1231b --- /dev/null +++ b/test/utils/tui-stored-plan.test.ts @@ -0,0 +1,51 @@ +import { describe, test, expect } from 'vitest' +import { openForgeDatabase } from '../../src/storage/database' +import { createPlansRepo } from '../../src/storage/repos/plans-repo' +import { fetchStoredSessionPlan } from '../../src/utils/tui-loop-store' +import { tmpdir } from 'os' +import { join } from 'path' +import { randomUUID } from 'crypto' + +function makeDbPath(): string { + return join(tmpdir(), `forge-tui-stored-plan-${randomUUID()}.db`) +} + +const PROJECT_ID = 'proj-tui-stored-plan' +const SESSION_ID = 'sess-tui-stored-plan' + +describe('fetchStoredSessionPlan', () => { + test('returns stored content written via plans repo', () => { + const dbPath = makeDbPath() + const db = openForgeDatabase(dbPath) + try { + const plansRepo = createPlansRepo(db) + plansRepo.writeForSession(PROJECT_ID, SESSION_ID, '# Plan\n## Section 1') + db.close() + } catch (err) { + db.close() + throw err + } + + expect(fetchStoredSessionPlan(PROJECT_ID, SESSION_ID, dbPath)).toBe('# Plan\n## Section 1') + }) + + test('returns null for an unknown session', () => { + const dbPath = makeDbPath() + const db = openForgeDatabase(dbPath) + try { + const plansRepo = createPlansRepo(db) + plansRepo.writeForSession(PROJECT_ID, SESSION_ID, '# Plan') + db.close() + } catch (err) { + db.close() + throw err + } + + expect(fetchStoredSessionPlan(PROJECT_ID, 'sess-other', dbPath)).toBeNull() + }) + + test('returns null when the database file does not exist', () => { + const missingPath = join(tmpdir(), `forge-tui-stored-plan-missing-${randomUUID()}.db`) + expect(fetchStoredSessionPlan(PROJECT_ID, SESSION_ID, missingPath)).toBeNull() + }) +}) From 31ad9526ccf96cab03ff08d8d97652f6b526c9e1 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:52:35 -0400 Subject: [PATCH 6/7] feat(dashboard): reduce poll/render cost with leaner usage queries and UI refinements (#77) * feat(dashboard): reduce poll/render cost with leaner usage queries and UI refinements * refactor: unify forge-db path and active-loop guards, slim dashboard payload * Update test/utils/tui-client-stored-plan.test.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: Forge Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- AGENTS.md | 2 + README.md | 6 +- 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 | 278 +++++-- src/dashboard/app/components.ts | 185 +++-- src/dashboard/app/helpers.ts | 100 ++- src/dashboard/data.ts | 66 +- src/dashboard/launch.ts | 11 +- src/dashboard/render.ts | 6 + src/dashboard/server.ts | 5 +- src/hooks/plan-approval.ts | 5 +- src/index.ts | 3 +- src/loop/service.ts | 13 + src/loop/token-usage.ts | 108 ++- 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 | 81 +- src/storage/repos/loop-transitions-repo.ts | 32 + src/storage/repos/plans-repo.ts | 13 + src/storage/repos/review-findings-repo.ts | 18 + src/storage/repos/section-plans-repo.ts | 14 + src/tools/loop.ts | 22 +- 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 | 87 +- 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 | 772 ++++++++++++++++-- test/dashboard/app-helpers.test.ts | 89 +- test/dashboard/data.test.ts | 312 ++++++- test/dashboard/server.test.ts | 81 +- test/hooks/plan-approval-dedupe.test.ts | 2 +- .../plan-approval-worktree-timing.test.ts | 1 + test/loop-format.test.ts | 130 ++- test/loop-session-usage-repo.test.ts | 109 ++- test/loop-status-tool.test.ts | 84 ++ test/loop-transitions-repo.test.ts | 35 + test/loop/token-usage.test.ts | 105 +++ 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 | 4 + test/worktree-log.test.ts | 18 + 61 files changed, 2555 insertions(+), 454 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 2be2a115f7..a4e7da2479 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 `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 @@ -403,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 6dbdd20502..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 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 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 d19b792361..0acecada62 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, repoRawPath, repoLabel, loopActivityAt, 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,23 +182,20 @@ 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 || '')), - ) + const repoLabels = createMemo(() => buildRepoLabels(state.projects.map(repoRawPath))) 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() 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) @@ -142,34 +211,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 +248,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 +257,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 = loopActivityAt(dl.loop) + 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 +275,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,16 +320,16 @@ 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 || '' - const label = repoLabels().get(rawPath) ?? rawPath + const rawPath = repoRawPath(proj) + const label = repoLabel(repoLabels(), proj) return untrack(() => Breadcrumb({ repoLabel: label, projectDir: rawPath, - loopName: r.loopName, + loopName: name, loops: repoLoopOptions, loopNav, onSelectLoop: (name: string) => navigate({ loopName: name }), @@ -251,18 +339,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 +358,7 @@ export function App() { return untrack(() => FilterBar({ counts: selectedRepoCounts, statuses: activeStatusSet, - query: () => route().query, + query, sortMode, onToggleStatus: toggleStatus, onChangeQuery: handleChangeQuery, @@ -286,23 +374,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 +400,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 +414,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 +438,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 +461,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 +502,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 +533,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..ac4656a32e 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,14 @@ import { summarizePhaseTotals, computeTimelineEvents, buildDashboardHash, + capList, + repoRawPath, + repoLabel, + loopActivityAt, + ALL_TABS, + 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' @@ -136,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} @@ -164,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 || '' }) @@ -226,7 +233,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,10 +252,12 @@ 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) + const idx = view().rows.findIndex(o => o.name === props.loopName) setActive(idx < 0 ? 0 : idx) setOpen(true) } @@ -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 }) { @@ -495,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 ─────────────────────────────────────────────────────── @@ -792,7 +820,7 @@ function LoopUsage(props: {
'width:' + r.pct + '%'}>
-
${() => r.messageCount + ' msg'}
+
${() => r.messageCount + ' msg · ' + formatTokenCount(r.tokens) + ' tok'}
`)} @@ -921,8 +949,6 @@ function AmendmentsPanel(props: { ` } -const ALL_TABS: LoopTab[] = ['overview', 'timeline', 'sections', 'findings', 'plan', 'usage'] - const TAB_LABELS: Record = { overview: 'Overview', timeline: 'Timeline', @@ -1122,7 +1148,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 +1170,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 +1201,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: { @@ -1223,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: { @@ -1241,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 })} @@ -1257,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 @@ -1279,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}

@@ -1313,18 +1353,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 +1377,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 +1404,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..1e674e775f 100644 --- a/src/dashboard/app/helpers.ts +++ b/src/dashboard/app/helpers.ts @@ -1,9 +1,41 @@ -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 + +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 @@ -15,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 { @@ -232,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) { @@ -408,25 +456,37 @@ interface RoleUsageBar { role: 'execution' | 'auditor' | 'other' cost: number messageCount: number + tokens: number pct: number } /** - * 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, pct: 0 } - const audit: RoleUsageBar = { role: 'auditor', cost: 0, messageCount: 0, pct: 0 } - const other: RoleUsageBar = { role: 'other', cost: 0, messageCount: 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 - } - 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 @@ -488,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' } @@ -537,9 +595,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..77e00369e8 100644 --- a/src/dashboard/data.ts +++ b/src/dashboard/data.ts @@ -36,8 +36,16 @@ 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 + /** 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[] @@ -58,6 +66,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 +139,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 +175,55 @@ 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 bugCounts = reviewFindingsRepo.bugCountsByLoop(projectId) + const transitionsByLoop = inScopedProject && loopTransitionsRepo + ? loopTransitionsRepo.listForProject(projectId, 100) + : null + 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 findings = reviewFindingsRepo.listByLoopName(projectId, loopName) - const usage = loopSessionUsageRepo.getAggregate(projectId, loopName) - const transitions = loopTransitionsRepo ? loopTransitionsRepo.listForLoop(projectId, loopName, 100) : [] - const amendments = amendmentsRepo + + 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 bugCount = bugCounts.get(loopName) ?? 0 + const findings = inScopedProject ? reviewFindingsRepo.listByLoopName(projectId, loopName) : [] + const usage = inScopedProject ? loopSessionUsageRepo.getAggregate(projectId, loopName) : null + + const transitions = transitionsByLoop?.get(loopName) ?? [] + + 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, bugCount, usage, duration, transitions, amendments, + } }) let groups: DashboardGroup[] = [] 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/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/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 d55b31580e..e8120adf8c 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,47 @@ 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() + for (const group of groups) { + for (const entry of group ?? []) { + accumulateUsage(map, entry.role, entry) + } + } + 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 */ const DEFAULT_MODEL_LABEL = 'default/session model' @@ -176,7 +236,7 @@ export function summarizeAssistantUsage( }[], attribution?: UsageAttribution, ): LoopUsageSummary { - const modelMap = new Map() + const modelMap = new Map() let totalCost = 0 let totalTokens = emptyTokenBreakdown() @@ -190,25 +250,21 @@ 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, + cost: totalCost, + tokens: { ...totalTokens }, + messageCount: assistantMessages.length, + }] + : [], attribution, } } @@ -223,10 +279,11 @@ export function mergeUsageSummaries(...summaries: LoopUsageSummary[]): LoopUsage totalCost: 0, totalTokens: emptyTokenBreakdown(), perModel: [], + perRole: [], } } - const modelMap = new Map() + const modelMap = new Map() let totalCost = 0 let totalTokens = emptyTokenBreakdown() let attribution: UsageAttribution | undefined @@ -240,26 +297,15 @@ 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 123c969e26..251df9bd24 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> } @@ -64,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, @@ -165,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 @@ -201,27 +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, messageCount: 0 }) - role.cost += row.cost - 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/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/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/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/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 11929c50bd..ea875d039a 100644 --- a/src/utils/loop-format.ts +++ b/src/utils/loop-format.ts @@ -1,8 +1,8 @@ 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' @@ -17,7 +17,7 @@ export type { LoopUsageSummary } 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, @@ -49,6 +49,33 @@ 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}`)] +} + +/** 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 = { @@ -62,39 +89,58 @@ 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)) + 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: tokensFromAggregate(data), + messageCount: data.messageCount, + } + }) + return { totalCost: aggregate.totalCost, totalTokens, perModel, + perRole, } } +/** 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)}`) + } + } + + const perRole = summary.perRole ?? [] + if (perRole.length > 0) { + lines.push('Per-role usage:') + for (const roleUsage of perRole) { + lines.push(` ${roleUsage.role}: ${formatCost(roleUsage.cost)} | ${formatTokenBreakdown(roleUsage.tokens)}`) } } @@ -123,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 441e14ddfc..10ab2a90ea 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,12 +60,17 @@ function makeLoop(over: Record = {}): any { duration: null, ...over, } + return { + ...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. @@ -84,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, - }, } } @@ -177,6 +172,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 +182,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 +201,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' @@ -214,7 +217,6 @@ describe('dashboard App fine-grained reactivity', () => { await poll( makePayload({ loop: { status: 'completed', completedAt: 1700000500000 }, - totals: { running: 0, completed: 1 }, }), ) @@ -225,6 +227,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 +258,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 +287,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 +299,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 +331,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 +342,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 +429,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: { @@ -427,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 }, + }, }, }, }) @@ -477,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 }, + }, }, }, }) @@ -498,7 +522,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() @@ -528,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') @@ -536,6 +564,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 +919,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({ @@ -904,10 +943,11 @@ describe('dashboard App machine graph', () => { }, }), ], - totals: { loops: 2, running: 1, completed: 1 }, }) 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 @@ -942,7 +982,6 @@ describe('dashboard App machine graph', () => { }, }), ], - totals: { loops: 2, running: 0, completed: 2 }, }), ) @@ -969,6 +1008,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, @@ -1170,7 +1216,6 @@ describe('dashboard App three-level shell', () => { await poll( makePayload({ loop: { status: 'completed', completedAt: 1700000500000 }, - totals: { running: 0, completed: 1 }, }), ) @@ -1216,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) @@ -1265,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) @@ -1306,7 +1341,6 @@ describe('dashboard App three-level shell', () => { }, }), ], - totals: { loops: 2, running: 0, completed: 2 }, }), ) @@ -1343,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 }, } } @@ -1524,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 }, }), ) @@ -1671,7 +1703,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 +1712,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 () => { @@ -1717,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 }, + }, }, }, }) @@ -1749,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 }, + }, }, }, }) @@ -1762,7 +1856,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() @@ -1802,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 }, + }, }, }, }) @@ -1813,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') @@ -1874,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() @@ -1914,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() @@ -1934,6 +2028,293 @@ 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 } }), + ], + }) + 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' }), + ], + }) + // 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 } }), + ], + }) + + 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 }, + }, + byRole: { + code: { 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 } }), + ], + }) + 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 } }), + ], + }) + 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 } }), + ], + }) + base.projects.push(p2.projects[0]) + 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). @@ -1944,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 }, }) } @@ -2250,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 }, }, }, }, @@ -2300,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 }, }, }, }, @@ -3536,9 +3916,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 +3936,293 @@ 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 } }), + ], + }) + } + + 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 } }), + ], + }) + 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 } }), + ], + }) + 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 } }), + ], + })) + + 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, + }) + } + + 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, + }) + 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 } }), + ], + }) + 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..e0ffe5f314 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') @@ -950,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 }, }, } @@ -979,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) @@ -1004,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) @@ -1026,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) @@ -1040,6 +1039,22 @@ 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) + }) }) // --------------------------------------------------------------------------- @@ -1708,3 +1723,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..3b746ae856 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,296 @@ 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('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!) + 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!) + 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' }) + // 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', () => { + 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.findings).toEqual([]) + expect(dl.usage).toBeNull() + 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/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-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-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/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([]) + }) }) 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..62652bcb2d 100644 --- a/test/utils/tui-client-stored-plan.test.ts +++ b/test/utils/tui-client-stored-plan.test.ts @@ -16,6 +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) => { + const trimmed = dataDir?.trim() + return `${trimmed && trimmed.length > 0 ? trimmed : '/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 5015afe99f6b9c5a105d04b6e128c7caf4968914 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:13:46 +0000 Subject: [PATCH 7/7] fix: apply CodeRabbit auto-fixes Fixed 7 file(s) based on 6 unresolved review comments. Co-authored-by: CodeRabbit --- README.md | 4 ++-- docs/agents-and-commands.md | 2 ++ docs/api/_media/agents-and-commands.md | 2 ++ docs/api/_media/tools.md | 2 +- docs/tools.md | 2 +- test/dashboard/app-helpers.test.ts | 8 ++++++++ test/dashboard/render.test.ts | 7 +++++-- 7 files changed, 21 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a4e7da2479..f987955ae7 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ The auditor agent is a read-only subagent that cannot edit source files or execu **Tool restrictions:** The auditor cannot use file-editing tools, planning tools, or loop-management tools. See [Auditor restrictions](docs/agents-and-commands.md#auditor-restrictions). -The architect agent operates as a read-only planner with message-level reinforcement via the `experimental.chat.messages.transform` hook. Final plans are authored straight into SQL storage with the `plan-write` and `plan-edit` tools, which return a structure report the architect uses to fix warnings before asking for approval. A plan emitted in the assistant response between `` and `` markers is still auto-captured into the same row, so marker-only architect prompts keep working. After user approval via the question tool, execution is dispatched programmatically — no additional LLM calls are needed. The user can view and edit the stored plan from the sidebar or command palette before or during execution. +The architect agent operates as a read-only planner with message-level reinforcement via the `experimental.chat.messages.transform` hook. Final plans are authored straight into SQL storage with the `plan-write` and `plan-edit` tools, which return a structure report the architect uses to fix warnings before asking for approval. A plan emitted in the assistant response between `` and `` markers is still auto-captured into the same row, so marker-only architect prompts keep working. After user approval via the question tool, execution is dispatched programmatically for New session and Execute here modes — no additional LLM calls are needed. Loop mode remains an exception: the architect must invoke `execute-plan`. The user can view and edit the stored plan from the sidebar or command palette before or during execution. ## Tools @@ -325,7 +325,7 @@ Plan with a smart model, execute with a fast model. The architect agent research ### How Plans Work -The architect is read-only and authors the plan into SQL storage for the current session with `plan-write`, appending further phases with `plan-write { append: true }` and revising with `plan-edit`. Every write returns a structure report — line/character counts, the detected `Loop Name:`, the sections the decomposer would emit, and warnings — so structural problems surface before approval. If those tools are unavailable, a plan emitted once between `` and `` markers is auto-captured into the same row. +The architect is read-only and authors the plan into SQL storage for the current session with `plan-write`, appending further phases with `plan-write { append: true }` and revising with `plan-edit`. Every write returns a structure report — line/character counts, the detected `Loop Name:`, the sections the decomposer would emit, and warnings — so structural problems surface before approval. When no stored plan was written, a plan emitted once between `` and `` markers is auto-captured into the same row. The stored plan is the source of truth for execution: `execute-plan`, the approval hook, and the TUI dialog all read it, and a marker-free assistant message can never replay an older chat plan over a newer tool-authored one. Programmatic access is via the `plan-read` tool. diff --git a/docs/agents-and-commands.md b/docs/agents-and-commands.md index 9c86b85069..5430e12759 100644 --- a/docs/agents-and-commands.md +++ b/docs/agents-and-commands.md @@ -27,6 +27,8 @@ Excluded tools: - `multiedit` - `plan` - `plan_exit` +- `plan-write` +- `plan-edit` - `execute-plan` - `execute-goal` - `loop-cancel` diff --git a/docs/api/_media/agents-and-commands.md b/docs/api/_media/agents-and-commands.md index 9c86b85069..5430e12759 100644 --- a/docs/api/_media/agents-and-commands.md +++ b/docs/api/_media/agents-and-commands.md @@ -27,6 +27,8 @@ Excluded tools: - `multiedit` - `plan` - `plan_exit` +- `plan-write` +- `plan-edit` - `execute-plan` - `execute-goal` - `loop-cancel` diff --git a/docs/api/_media/tools.md b/docs/api/_media/tools.md index 2d70aa2f2f..f6306243d1 100644 --- a/docs/api/_media/tools.md +++ b/docs/api/_media/tools.md @@ -54,7 +54,7 @@ Arguments: ### `plan-edit` -Edits the stored session plan by exact string replacement, the same way the Edit tool edits a file. Use `plan-read` to inspect the current text first; do not include `plan-read`'s `N: ` line-number prefixes in `oldString`. Subject to the same availability and running-loop guard as `plan-write`. On success the tool rewrites the plan through the shared session-scoped write path and returns a `Replaced N occurrence(s).` line followed by a structure report. +Edits the stored session plan by exact string replacement, the same way the Edit tool edits a file. Use `plan-read` to inspect the current text first; do not include `plan-read`'s `N:` line-number prefixes (with trailing space) in `oldString`. Subject to the same availability and running-loop guard as `plan-write`. On success the tool rewrites the plan through the shared session-scoped write path and returns a `Replaced N occurrence(s).` line followed by a structure report. Arguments: diff --git a/docs/tools.md b/docs/tools.md index 2d70aa2f2f..f6306243d1 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -54,7 +54,7 @@ Arguments: ### `plan-edit` -Edits the stored session plan by exact string replacement, the same way the Edit tool edits a file. Use `plan-read` to inspect the current text first; do not include `plan-read`'s `N: ` line-number prefixes in `oldString`. Subject to the same availability and running-loop guard as `plan-write`. On success the tool rewrites the plan through the shared session-scoped write path and returns a `Replaced N occurrence(s).` line followed by a structure report. +Edits the stored session plan by exact string replacement, the same way the Edit tool edits a file. Use `plan-read` to inspect the current text first; do not include `plan-read`'s `N:` line-number prefixes (with trailing space) in `oldString`. Subject to the same availability and running-loop guard as `plan-write`. On success the tool rewrites the plan through the shared session-scoped write path and returns a `Replaced N occurrence(s).` line followed by a structure report. Arguments: diff --git a/test/dashboard/app-helpers.test.ts b/test/dashboard/app-helpers.test.ts index e0ffe5f314..43be9f3ccc 100644 --- a/test/dashboard/app-helpers.test.ts +++ b/test/dashboard/app-helpers.test.ts @@ -464,13 +464,21 @@ describe('loopMatchesFilters', () => { describe('sortLoops', () => { function makeDash(over: Partial = {}): DashboardLoop { return { + id: 'test-id', loop: mockLoopRow(), lastAuditResult: null, + postActionReport: null, + goal: null, plan: null, + hasPlan: false, sections: [], + sectionCount: 0, findings: [], + bugCount: 0, usage: null, duration: null, + transitions: [], + amendments: [], ...over, } } diff --git a/test/dashboard/render.test.ts b/test/dashboard/render.test.ts index 8aa7245519..8f7e212171 100644 --- a/test/dashboard/render.test.ts +++ b/test/dashboard/render.test.ts @@ -36,9 +36,11 @@ const UNSTYLED_BY_DESIGN = new Set([ 'loop-picker-input', 'lt-findings', 'markdown-section', + 'mg-edge', // SVG group wrapper for edge path + label 'overview-tab', 'plan-tab', 'repo-loop-pane', + 'section-drill', // container for section drill-down view 'tab-bodies', 'timeline-tab', 'usage-tab', @@ -74,9 +76,10 @@ describe('renderDashboardHtml', () => { }) test('every static class the app emits has a rule in the stylesheet', () => { - const style = renderDashboardHtml().slice(0, renderDashboardHtml().indexOf('')) + const html = renderDashboardHtml() + const style = html.slice(0, html.indexOf('')) const unstyled = staticAppClassNames().filter( - cls => !UNSTYLED_BY_DESIGN.has(cls) && !style.includes('.' + cls), + cls => !UNSTYLED_BY_DESIGN.has(cls) && !style.match(new RegExp(`\\.${cls}(?![\\w-])`)), ) expect(unstyled).toEqual([])