From de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:51:14 -0400 Subject: [PATCH 1/2] feat(loop): migrate running loops to remote opencode servers --- README.md | 4 +- docs/agents-and-commands.md | 2 + docs/configuration.md | 11 +- docs/loop-system.md | 3 +- docs/modules.md | 4 + docs/tools.md | 21 + src/agents/architect-auto.ts | 1 + src/config.ts | 2 + src/constants/loop.ts | 1 + src/dashboard/app-bundle.ts | 2 +- src/hooks/forge-session-attach.ts | 43 +- src/hooks/host-side-effects.ts | 5 + src/loop/restartability.ts | 9 + src/loop/resume-prompt.ts | 116 ++++++ src/loop/resume-snapshot.ts | 109 +++++ src/loop/termination.ts | 5 + src/prompts/commands/loop-migrate.md | 15 + src/services/execution-response.ts | 41 ++ src/services/execution.ts | 234 ++++++----- src/services/loop-migration.ts | 210 ++++++++++ src/tools/loop.ts | 38 ++ src/utils/tui-client.ts | 34 +- src/utils/tui-remote-launch.ts | 140 +++++-- test/config-commands.test.ts | 6 + test/config.test.ts | 2 +- test/constants/loop.test.ts | 8 + test/helpers/fake-remote-client.ts | 49 +++ test/hooks/forge-session-attach.test.ts | 56 +++ test/loop-permission-ruleset.test.ts | 2 + test/loop/restartability.test.ts | 12 + test/loop/resume-prompt.test.ts | 191 +++++++++ test/loop/resume-snapshot.test.ts | 387 +++++++++++++++++ test/loop/termination.test.ts | 15 + test/plugin.test.ts | 1 + test/services/attach-loop.test.ts | 148 +++++++ test/services/loop-migration.test.ts | 390 ++++++++++++++++++ test/tools/loop-migrate-tool.test.ts | 304 ++++++++++++++ .../tui-client-launch-initial-prompt.test.ts | 166 ++++++++ test/utils/tui-remote-launch.test.ts | 245 +++++++++-- 39 files changed, 2812 insertions(+), 220 deletions(-) create mode 100644 src/loop/resume-prompt.ts create mode 100644 src/loop/resume-snapshot.ts create mode 100644 src/prompts/commands/loop-migrate.md create mode 100644 src/services/execution-response.ts create mode 100644 src/services/loop-migration.ts create mode 100644 test/helpers/fake-remote-client.ts create mode 100644 test/loop/resume-prompt.test.ts create mode 100644 test/loop/resume-snapshot.test.ts create mode 100644 test/services/loop-migration.test.ts create mode 100644 test/tools/loop-migrate-tool.test.ts create mode 100644 test/utils/tui-client-launch-initial-prompt.test.ts diff --git a/README.md b/README.md index ea381db59..8aa48e872 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ Forge provides these tool groups: - **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` +- **Loop tools** — `execute-plan`, `execute-goal`, `loop-cancel`, `loop-status`, `loop-migrate` - **Sandbox routing** — native `bash`, `glob`, and `grep` tools route into msb for sandboxed sessions Loops always run in an isolated git worktree; msb is used when enabled, configured, and available. @@ -170,6 +170,7 @@ Loops always run in an isolated git worktree; msb is used when enabled, configur | `execute-goal` | Execute a free-text goal in rotating dedicated code and auditor sessions inside an isolated git worktree. Args: `goal` required; `title`, `loopName`, `maxIterations` optional. | | `loop-cancel` | Cancel an active loop by worktree name | | `loop-status` | List active/recent loops or get detailed status by worktree name, including cumulative token usage when available. Supports `restart=true` to restart any non-completed loop (`running`, `cancelled`, `errored`, `stalled`). Completed loops are history-only and cannot be restarted. | +| `loop-migrate` | Move a loop to a configured remote opencode server, preserving phase, section progress, section summaries, and review findings. | `execute-plan` reads the current session's captured plan when `plan` is omitted. `maxIterations`, execution model, auditor model, and sandbox behavior come from configuration or the TUI execution dialog, not direct `execute-plan` tool arguments. @@ -183,6 +184,7 @@ Loops always run in an isolated git worktree; msb is used when enabled, configur | `/execute-goal` | Execute a free-text goal in dedicated worktree sessions until an audit leaves no findings | code | | `/loop-status` | Check status of all active loops | code | | `/loop-cancel` | Cancel the active loop | code | +| `/loop-migrate` | Migrate a loop to a remote opencode server | code | | `/launch-group` | Decompose a PRD or feature list into features and launch them as parallel planning + development loops | code | ## Configuration diff --git a/docs/agents-and-commands.md b/docs/agents-and-commands.md index f83ace4b1..bf5f85759 100644 --- a/docs/agents-and-commands.md +++ b/docs/agents-and-commands.md @@ -39,6 +39,7 @@ Excluded tools: - `execute-goal` - `loop-cancel` - `loop-status` +- `loop-migrate` Source: [`AUDITOR_TOOL_EXCLUDES`](../src/agents/auditor.ts). @@ -52,5 +53,6 @@ Source: [`AUDITOR_TOOL_EXCLUDES`](../src/agents/auditor.ts). | `/execute-goal` | Execute a goal in rotating dedicated code and auditor sessions inside an isolated worktree. | `code` | no | | `/loop-status` | Check status of all active loops. | `code` | no | | `/loop-cancel` | Cancel the active loop. | `code` | no | +| `/loop-migrate` | Migrate a loop to a configured remote opencode server. | `code` | no | Source: [`buildPluginCommands()`](../src/config.ts). diff --git a/docs/configuration.md b/docs/configuration.md index 58a12edf6..57584aed6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -81,7 +81,7 @@ Configured rules are layered into the ruleset in this order: Because configured rules sit between the external-directory allows and Forge's structural denies, they can deny user tools but can never override a structural deny. -**Forge-managed permissions are rejected** (ignored with a warning at plugin load, surfaced in the log and a one-time TUI toast). These include `*`, `external_directory`, and the structural denies: `plan`, `plan_enter`, `plan_exit`, `plan-write`, `plan-edit`, `execute-plan`, `execute-goal`, `question`, `loop-cancel`, `loop-status`, `launch-group`, `group-status`, `group-cancel`, `review-write`, `review-delete`, `edit`, `write`, `multiedit`, and `apply_patch`. An unattended loop that can call `question` would hang forever; allowing `plan-write`/`review-write` would corrupt the plan-of-record and audit channels; and `execute-plan`/`loop-*`/`group-*` would let a loop recurse into itself or manage other loops. +**Forge-managed permissions are rejected** (ignored with a warning at plugin load, surfaced in the log and a one-time TUI toast). These include `*`, `external_directory`, and the structural denies: `plan`, `plan_enter`, `plan_exit`, `plan-write`, `plan-edit`, `execute-plan`, `execute-goal`, `question`, `loop-cancel`, `loop-status`, `loop-migrate`, `launch-group`, `group-status`, `group-cancel`, `review-write`, `review-delete`, `edit`, `write`, `multiedit`, and `apply_patch`. An unattended loop that can call `question` would hang forever; allowing `plan-write`/`review-write` would corrupt the plan-of-record and audit channels; and `execute-plan`/`loop-*`/`group-*` would let a loop recurse into itself or manage other loops. **Blanket denies of Forge-required permissions are rejected too**: `review-read`, `plan-read`, `section-read`, `plan-adjust`, `bash`, and `read` may not be denied outright. A loop that cannot read its findings, section plan, or plan-of-record — or cannot run `bash` or `read` at all — cannot do its job and would silently burn iterations to `maxIterations` with nothing pointing at the config. Only the blanket form (a bare tool name, or pattern `*`) is rejected; a scoped deny such as `{ "permission": "bash", "pattern": "git push *" }` is honoured. @@ -232,6 +232,15 @@ Example: 3. The remote server creates the loop worktree pinned to that exact SHA, fetching the sync ref when the commit is not yet in its clone. 4. On final loop teardown, the remote deletes the sync ref from the shared git remote (restart-preserving teardowns keep it). If a loop is deleted outside normal teardown, remove leftovers manually with `git push --delete refs/forge/`. +### Migrating a running loop + +The `loop-migrate` tool (and `/loop-migrate`) moves an existing local loop to a remote server. It reuses the same remote discovery and sync-ref mechanics as the TUI launch above, with two differences: + +- The pushed sync ref `refs/forge/` points at the **loop branch tip** (`refs/heads/`), not local `HEAD` — the work done inside the loop travels with the migration even though it was never merged. +- The remote worktree is pinned to that loop-branch SHA via `startRef`, and the loop resumes from a snapshot of its phase, section progress, and review findings. + +The local worktree and branch stay in place; the local loop is terminated as `migrated: `, which blocks a local restart, and all further management (including `loop-status` and `loop-cancel`) happens on the remote server. The observability caveat below applies — the migrated loop disappears from the local sidebar, `loop-status`, and dashboard. + ### Caveats - **Version skew**: the remote server must run a forge version with SHA-pin support (`startRef`/`syncRef` handling — the same release that introduced `remotes`, or newer). An older remote silently ignores the pin and runs the loop from its clone's current `HEAD` with no error on either side. diff --git a/docs/loop-system.md b/docs/loop-system.md index cde0e4f03..fffde3a51 100644 --- a/docs/loop-system.md +++ b/docs/loop-system.md @@ -15,13 +15,14 @@ The loop system provides autonomous iterative development with automatic code au - **Any non-completed loop is restartable** via explicit restart when the worktree is available. - Restartable statuses: `running`, `cancelled`, `errored`, `stalled`. - **Completed loops are history-only** and cannot be restarted. +- **`migrated` loops are not restartable locally** — a loop migrated to a remote server with `loop-migrate` is terminated with the terminal reason `migrated: ` and must be managed on that server. - **Missing worktree blocks restart** — the worktree directory must exist for restart to proceed. ### Restart Semantics - Restart preserves loop identity, plan, worktree path, section progress, and review findings. - Restart resets iteration count and error budget. -- Restart creates a fresh session and resumes from the persisted phase and section index. +- Restart creates a fresh session and resumes from the persisted phase and section index. The phase/prompt selection for the resumed session is shared with the remote migration path: a loop migrated with `loop-migrate` starts on the remote from the same phase-appropriate prompt plan a local restart would build. ### Stale Workspace Sweep diff --git a/docs/modules.md b/docs/modules.md index 4bfc76e17..26c450b42 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -145,6 +145,8 @@ The heart of Forge. Implements autonomous iterative development with phases: `co | `idle-gate.ts` | Session busy detection and timeout tracking | | `in-flight-guard.ts` | Single-flight guard for concurrent loop start attempts | | `restartability.ts` | `getRestartability()` — decides whether a non-completed loop can restart, blocked, or requires force | +| `resume-prompt.ts` | `buildResumePromptPlan()` — single owner of restart/migration prompt selection (post_action → goal → final_audit_fix → sectioned/legacy chain) | +| `resume-snapshot.ts` | `captureLoopResumeSnapshot()` / `restoreLoopResumeRows()` — portable snapshot of loop progress carried across restarts and remote migration | | `token-usage.ts` | Extract and normalize per-message usage from session output | | `name-uniqueness.ts` | Reserve a unique loop identity before any side effects | | `session-output.ts` | Fetch session output for loop display | @@ -240,6 +242,8 @@ Higher-level orchestration services coordinating between hooks, loop runtime, an | File | Purpose | |------|---------| | `execution.ts` | Unified command bus for plan execution (`createForgeExecutionService()`) | +| `execution-response.ts` | Shared `ok`/`fail` response builders and error/warning shapes for the command bus | +| `loop-migration.ts` | `migrateLoopToRemote()` — `loop.migrate` command handler: freeze as migrated, snapshot, push loop branch tip to the sync ref, launch on the remote | | `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` | 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" | diff --git a/docs/tools.md b/docs/tools.md index 885e9683f..9109b2694 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -20,6 +20,7 @@ See also: [Agents and Slash Commands](agents-and-commands.md), [Configuration](c | `execute-goal` | Start a managed goal loop in a dedicated code session inside an isolated Forge worktree. | [`src/tools/loop.ts`](../src/tools/loop.ts) | | `loop-cancel` | Cancel an active loop. | [`src/tools/loop.ts`](../src/tools/loop.ts) | | `loop-status` | List loops, inspect one loop, or restart a restartable loop. | [`src/tools/loop.ts`](../src/tools/loop.ts) | +| `loop-migrate` | Move a loop to a configured remote opencode server, preserving its progress. | [`src/tools/loop.ts`](../src/tools/loop.ts) | | `launch-group` | Launch a group of features (from a PRD or a pre-split list), each planned and run as its own loop, scheduled with a concurrency cap. | [`src/tools/group.ts`](../src/tools/group.ts) | | `group-status` | List groups, inspect one group's per-feature stages, or restart a non-completed group. | [`src/tools/group.ts`](../src/tools/group.ts) | | `group-cancel` | Cancel a group, optionally cancelling its running loops. | [`src/tools/group.ts`](../src/tools/group.ts) | @@ -187,6 +188,26 @@ Arguments: Completed loops are history-only and cannot be restarted. See [Loop System](loop-system.md#restartability). +### `loop-migrate` + +Moves a loop to a configured remote opencode server (see [Configuration → Remotes](configuration.md#remotes)). The local loop is terminated with the terminal reason `migrated: `, which makes it permanently non-restartable locally; the loop then continues on the remote from the pushed loop branch tip. + +What is carried over: + +- The loop's phase, section pointers (`currentSectionIndex`/`totalSections`/`finalAuditDone`), section plan rows, section summaries, and review findings travel as a resume snapshot in the remote workspace's `forgeLoop` extra. +- The loop's original plan text is forwarded, so restartability display and legacy non-sectioned resume keep working on the remote. +- The execution/auditor models and variants are forwarded unchanged. +- The remote loop name is reserved (the local name is kept when available), and the remote session's permission rules come from the configured `loop.permissions` without host-specific external directories. + +Failure semantics: every pre-freeze failure (unknown remote, no matching project, unreachable server) leaves the local loop untouched. A failure after the freeze — snapshot, branch-tip resolve, push, or remote launch — relabels the local loop as plain `cancelled` and rolls back the sync-ref push (best effort), so the loop stays restartable locally with `loop-status restart=true`. Only the success path leaves the loop non-restartable as `migrated`. + +Arguments: + +| Argument | Description | +|---|---| +| `name` | Required loop name (or branch) to migrate. | +| `remote` | Required configured `remotes[].name` to migrate to. | + > Group, loop, and plan tools are denied inside loop and audit sessions so an in-flight loop cannot recursively spawn more work. ## Group Tools diff --git a/src/agents/architect-auto.ts b/src/agents/architect-auto.ts index b3e5c5533..f230067ab 100644 --- a/src/agents/architect-auto.ts +++ b/src/agents/architect-auto.ts @@ -20,6 +20,7 @@ export function buildArchitectAutoAgent(promptsDir?: string): AgentDefinition { 'group-cancel', 'loop-status', 'loop-cancel', + 'loop-migrate', ], }, systemPrompt: loadPrompt(['agents', 'architect-auto.md'], promptsDir), diff --git a/src/config.ts b/src/config.ts index f749f466b..988f0ee20 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,6 +21,8 @@ function buildPluginCommands(promptsDir?: string): Record template: loadPrompt(['commands','loop-status.md'], promptsDir) }, 'loop-cancel': { description: 'Cancel the active loop', agent: 'code', subtask: false, template: loadPrompt(['commands','loop-cancel.md'], promptsDir) }, + 'loop-migrate': { description: 'Migrate a loop to a remote opencode server', agent: 'code', subtask: false, + template: loadPrompt(['commands','loop-migrate.md'], promptsDir) }, } } diff --git a/src/constants/loop.ts b/src/constants/loop.ts index 448990abc..127225bd1 100644 --- a/src/constants/loop.ts +++ b/src/constants/loop.ts @@ -27,6 +27,7 @@ export const SHARED_STRUCTURAL_DENY_PERMISSIONS = [ 'question', 'loop-cancel', 'loop-status', + 'loop-migrate', 'launch-group', 'group-status', 'group-cancel', diff --git a/src/dashboard/app-bundle.ts b/src/dashboard/app-bundle.ts index 289be2df1..fcc05da37 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 X={context:void 0,registry:void 0,effects:void 0,done:!1,getContextId(){return yf(this.context.count)},getNextContextId(){return yf(this.context.count++)}};function yf(n){let e=String(n),f=e.length-1;return X.context.id+(f?String.fromCharCode(96+f):\"\")+e}function Pe(n){X.context=n}function Pi(){return{...X.context,id:X.getNextContextId(),count:0}}var Ei=!0,Vi=(n,e)=>n===e,tn=Symbol(\"solid-proxy\"),Ci=typeof Proxy===\"function\",Ve=Symbol(\"solid-track\"),kf=Symbol(\"solid-dev-component\"),ne={equals:Vi},bf=null,Pf=Yf,nn=1,Jn=2,Ai={};var F=null,E=null,qn=null,Cn=null,_=null,T=null,R=null,fe=0,un={afterUpdate:null,afterCreateOwner:null,afterCreateSignal:null,afterRegisterGraph:null};function Ef(n,e){let f=_,t=F,i=n.length===0,u=e===void 0?t:e,c=i?{owned:null,cleanups:null,context:null,owner:null}:{owned:null,cleanups:null,context:u?u.context:null,owner:u},l=i?()=>n(()=>{throw Error(\"Dispose method must be an explicit argument to createRoot function\")}):()=>n(()=>v(()=>bn(c)));un.afterCreateOwner&&un.afterCreateOwner(c),F=c,_=null;try{return cn(l,!0)}finally{_=f,F=t}}function A(n,e){e=e?Object.assign({},ne,e):ne;let f={value:n,observers:null,observerSlots:null,comparator:e.equals||void 0};{if(e.name)f.name=e.name;if(e.internal)f.internal=!0;else if(Af(f),un.afterCreateSignal)un.afterCreateSignal(f)}let t=(i)=>{if(typeof i===\"function\")if(E&&E.running&&E.sources.has(f))i=i(f.tValue);else i=i(f.value);return Ae(f,i)};return[Kf.bind(f),t]}function $n(n,e,f){let t=ue(n,e,!1,nn,f);if(qn&&E&&E.running)T.push(t);else An(t)}function D(n,e,f){Pf=zi;let t=ue(n,e,!1,nn,f),i=Ee&&Yi(Ee);if(i)t.suspense=i;if(!f||!f.render)t.user=!0;R?R.push(t):An(t)}function g(n,e,f){f=f?Object.assign({},ne,f):ne;let t=ue(n,e,!0,0,f);if(t.observers=null,t.observerSlots=null,t.comparator=f.equals||void 0,qn&&E&&E.running)t.tState=nn,T.push(t);else An(t);return Kf.bind(t)}function Xn(n){return cn(n,!1)}function v(n){if(!Cn&&_===null)return n();let e=_;_=null;try{if(Cn)return Cn.untrack(n);return n()}finally{_=e}}function Vf(n,e,f){let t=Array.isArray(n),i,u=f&&f.defer;return(c)=>{let l;if(t){l=Array(n.length);for(let d=0;de(l,i,c));return i=l,r}}function Cf(n){D(()=>v(n))}function wn(n){if(F===null)console.warn(\"cleanups created outside a `createRoot` or `render` will never be run\");else if(F.cleanups===null)F.cleanups=[n];else F.cleanups.push(n);return n}function te(){return _}function ie(){return F}function Ce(n,e){let f=F,t=_;F=n,_=null;try{return cn(e,!0)}catch(i){ce(i)}finally{F=f,_=t}}function Ki(n){if(E&&E.running)return n(),E.done;let e=_,f=F;return Promise.resolve().then(()=>{_=e,F=f;let t;if(qn||Ee)t=E||(E={sources:new Set,effects:[],promises:new Set,disposed:new Set,queue:new Set,running:!0}),t.done||(t.done=new Promise((i)=>t.resolve=i)),t.running=!0;return cn(n,!1),_=F=null,t?t.done:void 0})}var[rc,xf]=A(!1);function Of(n,e){let f=ue(()=>v(()=>{return Object.assign(n,{[kf]:!0}),n(e)}),void 0,!0,0);return f.props=e,f.observers=null,f.observerSlots=null,f.name=n.name,f.component=n,An(f),f.tValue!==void 0?f.tValue:f.value}function Af(n){if(F){if(F.sourceMap)F.sourceMap.push(n);else F.sourceMap=[n];n.graph=F}if(un.afterRegisterGraph)un.afterRegisterGraph(n)}function Yi(n){let e;return F&&F.context&&(e=F.context[n.id])!==void 0?e:n.defaultValue}var Ee;function Kf(){let n=E&&E.running;if(this.sources&&(n?this.tState:this.state))if((n?this.tState:this.state)===nn)An(this);else{let e=T;T=null,cn(()=>ee(this),!1),T=e}if(_){let e=this.observers?this.observers.length:0;if(!_.sources)_.sources=[this],_.sourceSlots=[e];else _.sources.push(this),_.sourceSlots.push(e);if(!this.observers)this.observers=[_],this.observerSlots=[_.sources.length-1];else this.observers.push(_),this.observerSlots.push(_.sources.length-1)}if(n&&E.sources.has(this))return this.tValue;return this.value}function Ae(n,e,f){let t=E&&E.running&&E.sources.has(n)?n.tValue:n.value;if(!n.comparator||!n.comparator(t,e)){if(E){let i=E.running;if(i||!f&&E.sources.has(n))E.sources.add(n),n.tValue=e;if(!i)n.value=e}else n.value=e;if(n.observers&&n.observers.length)cn(()=>{for(let i=0;i1e6){if(T=[],Ei)throw Error(\"Potential Infinite Loop Detected.\");throw Error()}},!1)}return e}function An(n){if(!n.fn)return;bn(n);let e=fe;if(gf(n,E&&E.running&&E.sources.has(n)?n.tValue:n.value,e),E&&!E.running&&E.sources.has(n))queueMicrotask(()=>{cn(()=>{E&&(E.running=!0),_=F=n,gf(n,n.tValue,e),_=F=null},!1)})}function gf(n,e,f){let t,i=F,u=_;_=F=n;try{t=n.fn(e)}catch(c){if(n.pure)if(E&&E.running)n.tState=nn,n.tOwned&&n.tOwned.forEach(bn),n.tOwned=void 0;else n.state=nn,n.owned&&n.owned.forEach(bn),n.owned=null;return n.updatedAt=f+1,ce(c)}finally{_=u,F=i}if(!n.updatedAt||n.updatedAt<=f){if(n.updatedAt!=null&&\"observers\"in n)Ae(n,t,!0);else if(E&&E.running&&n.pure){if(!E.sources.has(n))n.value=t;E.sources.add(n),n.tValue=t}else n.value=t;n.updatedAt=f}}function ue(n,e,f,t=nn,i){let u={fn:n,state:t,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:e,owner:F,context:F?F.context:null,pure:f};if(E&&E.running)u.state=0,u.tState=t;if(F===null)console.warn(\"computations created outside a `createRoot` or `render` will never be disposed\");else if(F!==Ai)if(E&&E.running&&F.pure)if(!F.tOwned)F.tOwned=[u];else F.tOwned.push(u);else if(!F.owned)F.owned=[u];else F.owned.push(u);if(i&&i.name)u.name=i.name;if(Cn&&u.fn){let c=u.fn,[l,r]=A(void 0,{equals:!1}),d=Cn.factory(c,r);wn(()=>d.dispose());let w,O=()=>Ki(r).then(()=>{if(w)w.dispose(),w=void 0});u.fn=(k)=>{if(l(),E&&E.running){if(!w)w=Cn.factory(c,O);return w.track(k)}return d.track(k)}}return un.afterCreateOwner&&un.afterCreateOwner(u),u}function Un(n){let e=E&&E.running;if((e?n.tState:n.state)===0)return;if((e?n.tState:n.state)===Jn)return ee(n);if(n.suspense&&v(n.suspense.inFallback))return n.suspense.effects.push(n);let f=[n];while((n=n.owner)&&(!n.updatedAt||n.updatedAt=0;t--){if(n=f[t],e){let i=n,u=f[t+1];while((i=i.owner)&&i!==u)if(E.disposed.has(i))return}if((e?n.tState:n.state)===nn)An(n);else if((e?n.tState:n.state)===Jn){let i=T;T=null,cn(()=>ee(n,f[0]),!1),T=i}}}function cn(n,e){if(T)return n();let f=!1;if(!e)T=[];if(R)f=!0;else R=[];fe++;try{let t=n();return Wi(f),t}catch(t){if(!f)R=null;T=null,ce(t)}}function Wi(n){if(T){if(qn&&E&&E.running)si(T);else Yf(T);T=null}if(n)return;let e;if(E){if(!E.promises.size&&!E.queue.size){let{sources:t,disposed:i}=E;R.push.apply(R,E.effects),e=E.resolve;for(let u of R)\"tState\"in u&&(u.state=u.tState),delete u.tState;E=null,cn(()=>{for(let u of i)bn(u);for(let u of t){if(u.value=u.tValue,u.owned)for(let c=0,l=u.owned.length;cPf(f),!1);else un.afterUpdate&&un.afterUpdate();if(e)e()}function Yf(n){for(let e=0;e{t.delete(f),cn(()=>{E.running=!0,Un(f)},!1),E&&(E.running=!1)})}}function zi(n){let e,f=0;for(e=0;e=0;e--)bn(n.tOwned[e]);delete n.tOwned}if(E&&E.running&&n.pure)sf(n,!0);else if(n.owned){for(e=n.owned.length-1;e>=0;e--)bn(n.owned[e]);n.owned=null}if(n.cleanups){for(e=n.cleanups.length-1;e>=0;e--)n.cleanups[e]();n.cleanups=null}if(E&&E.running)n.tState=0;else n.state=0;delete n.sourceMap}function sf(n,e){if(!e)n.tState=0,E.disposed.add(n);if(n.owned)for(let f=0;f=0;l--){let r=ke(n[l])[c];if(r!==void 0)return r}},has(c){for(let l=n.length-1;l>=0;l--)if(c in ke(n[l]))return!0;return!1},keys(){let c=[];for(let l=0;l=0;c--){let l=n[c];if(!l)continue;let r=Object.getOwnPropertyNames(l);for(let d=r.length-1;d>=0;d--){let w=r[d];if(w===\"__proto__\"||w===\"constructor\")continue;let O=Object.getOwnPropertyDescriptor(l,w);if(!t[w])t[w]=O.get?{enumerable:!0,configurable:!0,get:Zi.bind(f[w]=[O.get.bind(l)])}:O.value!==void 0?O:void 0;else{let k=f[w];if(k){if(O.get)k.push(O.get.bind(l));else if(O.value!==void 0)k.push(()=>O.value)}}}}let i={},u=Object.keys(t);for(let c=u.length-1;c>=0;c--){let l=u[c],r=t[l];if(r&&r.get)Object.defineProperty(i,l,r);else i[l]=r?r.value:void 0}return i}var zf={hooks:un,writeSignal:Ae,registerGraph:Af};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 _i=[\"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\"],se=new Set([\"className\",\"value\",\"readOnly\",\"noValidate\",\"formNoValidate\",\"isMap\",\"noModule\",\"playsInline\",\"adAuctionHeaders\",\"allowFullscreen\",\"browsingTopics\",\"defaultChecked\",\"defaultMuted\",\"defaultSelected\",\"disablePictureInPicture\",\"disableRemotePlayback\",\"preservesPitch\",\"shadowRootClonable\",\"shadowRootCustomElementRegistry\",\"shadowRootDelegatesFocus\",\"shadowRootSerializable\",\"sharedStorageWritable\",..._i]),ze=new Set([\"innerHTML\",\"textContent\",\"innerText\",\"children\"]),He=Object.assign(Object.create(null),{className:\"class\",htmlFor:\"for\"}),ji=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 Fe(n,e){let f=ji[n];return typeof f===\"object\"?f[e]?f.$:void 0:f}var Ge=new Set([\"beforeinput\",\"click\",\"dblclick\",\"contextmenu\",\"focusin\",\"focusout\",\"input\",\"keydown\",\"keyup\",\"mousedown\",\"mousemove\",\"mouseout\",\"mouseover\",\"mouseup\",\"pointerdown\",\"pointermove\",\"pointerout\",\"pointerover\",\"pointerup\",\"touchend\",\"touchmove\",\"touchstart\"]),_f=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\"]),Ze={xlink:\"http://www.w3.org/1999/xlink\",xml:\"http://www.w3.org/XML/1998/namespace\"};function Ji(n,e,f){let t=f.length,i=e.length,u=t,c=0,l=0,r=e[i-1].nextSibling,d=null;while(cw-l){let $=e[c];while(l{i=u,e===document?n():Xe(e,n(),e.firstChild?null:void 0,f)},t.owner),()=>{i(),e.textContent=\"\"}}function _e(n,e=window.document){let f=e[Hf]||(e[Hf]=new Set);for(let t=0,i=n.length;ti.call(n,f[1],u))}else n.addEventListener(e,f,typeof f!==\"function\"&&f)}function Ue(n,e,f={}){let t=Object.keys(e||{}),i=Object.keys(f),u,c;for(u=0,c=i.length;ui.children=Yn(n,e.children,i.children));return $n(()=>typeof e.ref===\"function\"&&Xi(e.ref,n)),$n(()=>Si(n,e,f,!0,i,!0)),i}function Uf(n,e){let f=n[e];return Object.defineProperty(n,e,{get(){return f()},enumerable:!0}),n}function Xi(n,e,f){return v(()=>n(e,f))}function Xe(n,e,f,t){if(f!==void 0&&!t)t=[];if(typeof e!==\"function\")return Yn(n,e,t,f);$n((i)=>Yn(n,e(),i,f),t)}function Si(n,e,f,t,i={},u=!1){e||(e={});for(let c in i)if(!(c in e)){if(c===\"children\")continue;i[c]=Gf(n,c,null,i[c],f,u,e)}for(let c in e){if(c===\"children\"){if(!t)Yn(n,e.children);continue}let l=e[c];i[c]=Gf(n,c,l,i[c],f,u,e)}}function Wn(n){return!!X.context&&!X.done&&(!n||n.isConnected)}function Qi(n){return n.toLowerCase().replace(/-([a-z])/g,(e,f)=>f.toUpperCase())}function Ff(n,e,f){let t=e.trim().split(/\\s+/);for(let i=0,u=t.length;i-1&&Ze[e.split(\":\")[0]];if(k)je(n,k,e,f);else Sn(n,He[e]||e,f)}return f}function Ti(n){if(X.registry&&X.events){if(X.events.find(([r,d])=>d===n))return}let e=n.target,f=`$$${n.type}`,t=n.target,i=n.currentTarget,u=(r)=>Object.defineProperty(n,\"target\",{configurable:!0,value:r}),c=()=>{let r=e[f];if(r&&!e.disabled){let d=e[`${f}Data`];if(d!==void 0?r.call(e,d,n):r.call(e,n),n.cancelBubble)return}return e.host&&typeof e.host!==\"string\"&&!e.host._$host&&e.contains(n.target)&&u(e.host),!0},l=()=>{while(c()&&(e=e._$host||e.parentNode||e.host));};if(Object.defineProperty(n,\"currentTarget\",{configurable:!0,get(){return e||document}}),X.registry&&!X.done)X.done=_$HY.done=!0;if(n.composedPath){let r=n.composedPath();u(r[0]);for(let d=0;d{let r=e();while(typeof r===\"function\")r=r();f=Yn(n,r,f,t)}),()=>f;else if(Array.isArray(e)){let r=[],d=f&&Array.isArray(f);if(We(r,e,f,i))return $n(()=>f=Yn(n,r,f,t,!0)),()=>f;if(u){if(!r.length)return f;if(t===void 0)return f=[...n.childNodes];let w=r[0];if(w.parentNode!==n)return f;let O=[w];while((w=w.nextSibling)!==t)O.push(w);return f=O}if(r.length===0){if(f=Kn(n,f,t),l)return f}else if(d)if(f.length===0)Zf(n,r,t);else Ji(n,f,r);else f&&Kn(n),Zf(n,r);f=r}else if(e.nodeType){if(u&&e.parentNode)return f=l?[e]:e;if(Array.isArray(f)){if(l)return f=Kn(n,f,t,e);Kn(n,f,null,e)}else if(f==null||f===\"\"||!n.firstChild)n.appendChild(e);else n.replaceChild(e,n.firstChild);f=e}else console.warn(\"Unrecognized value. Skipped inserting\",e);return f}function We(n,e,f,t){let i=!1;for(let u=0,c=e.length;u=0;c--){let l=e[c];if(i!==l){let r=l.parentNode===n;if(!u&&!c)r?n.replaceChild(i,l):n.insertBefore(i,f);else r&&l.remove()}else u=!0}}else n.insertBefore(i,f);return[i]}var Vc=Symbol();var Se=Symbol(\"store-raw\"),zn=Symbol(\"store-node\"),yn=Symbol(\"store-has\"),Xf=Symbol(\"store-self\"),qf={onStoreNodeUpdate:null};function Sf(n){let e=n[tn];if(!e){if(Object.defineProperty(n,tn,{value:e=new Proxy(n,Bi)}),!Array.isArray(n)){let f=Object.keys(n),t=Object.getOwnPropertyDescriptors(n);for(let i=0,u=f.length;in[tn][e],f}function Qf(n){te()&&Tn(le(n,zn),Xf)()}function Di(n){return Qf(n),Reflect.ownKeys(n)}var Bi={get(n,e,f){if(e===Se)return n;if(e===tn)return f;if(e===Ve)return Qf(n),f;let t=le(n,zn),i=t[e],u=i?i():n[e];if(e===zn||e===yn||e===\"__proto__\")return u;if(!i){let c=Object.getOwnPropertyDescriptor(n,e);if(te()&&(typeof u!==\"function\"||n.hasOwnProperty(e))&&!(c&&c.get))u=Tn(t,e,u)()}return xn(u)?Sf(u):u},has(n,e){if(e===Se||e===tn||e===Ve||e===zn||e===yn||e===\"__proto__\")return!0;return te()&&Tn(le(n,yn),e)(),e in n},set(){return console.warn(\"Cannot mutate a Store directly\"),!0},deleteProperty(){return console.warn(\"Cannot mutate a Store directly\"),!0},ownKeys:Di,getOwnPropertyDescriptor:Ii};function en(n,e,f,t=!1){if(!t&&n[e]===f)return;let i=n[e],u=n.length;if(qf.onStoreNodeUpdate&&qf.onStoreNodeUpdate(n,e,f,i),f===void 0){if(delete n[e],n[yn]&&n[yn][e]&&i!==void 0)n[yn][e].$()}else if(n[e]=f,n[yn]&&n[yn][e]&&i===void 0)n[yn][e].$();let c=le(n,zn),l;if(l=Tn(c,e,i))l.$(()=>f);if(Array.isArray(n)&&n.length!==u){for(let r=n.length;r1){t=e.shift();let c=typeof t,l=Array.isArray(n);if(Array.isArray(t)){for(let r=0;r1){Qn(n[t],e,[t].concat(f));return}i=n[t],f=[t].concat(f)}let u=e[0];if(typeof u===\"function\"){if(u=u(i,f),u===i)return}if(t===void 0&&u==null)return;if(u=Hn(u),t===void 0||xn(i)&&xn(u)&&!Array.isArray(u))Tf(i,u);else en(n,t,u)}function If(...[n,e]){let f=Hn(n||{}),t=Array.isArray(f);if(typeof f!==\"object\"&&typeof f!==\"function\")throw Error(`Unexpected type ${typeof f} received when initializing 'createStore'. Expected an object.`);let i=Sf(f);zf.registerGraph({value:f,name:e&&e.name});function u(...c){Xn(()=>{t&&c.length===1?Ri(f,c[0]):Qn(f,c)})}return[i,u]}var Qe=Symbol(\"store-root\");function sn(n,e,f,t,i){let u=e[f];if(n===u)return;let c=Array.isArray(n);if(f!==Qe&&(!xn(n)||!xn(u)||c!==Array.isArray(u)||i&&n[i]!==u[i])){en(e,f,n);return}if(c){if(n.length&&u.length&&(!t||i&&n[0]&&n[0][i]!=null)){let d,w,O,k,y,$,b,N;for(O=0,k=Math.min(u.length,n.length);O=O&&y>=O&&(u[k]===n[y]||i&&u[k]&&n[y]&&u[k][i]&&u[k][i]===n[y][i]);k--,y--)V[y]=u[k];if(O>y||O>k){for(w=O;w<=y;w++)en(u,w,n[w]);for(;wn.length)en(u,\"length\",n.length);return}b=Array(y+1);for(w=y;w>=O;w--)$=n[w],N=i&&$?$[i]:$,d=W.get(N),b[w]=d===void 0?-1:d,W.set(N,w);for(d=O;d<=k;d++)if($=u[d],N=i&&$?$[i]:$,w=W.get(N),w!==void 0&&w!==-1)V[w]=u[d],w=b[w],W.set(N,w);for(w=O;wn.length)en(u,\"length\",n.length);return}let l=Object.keys(n);for(let d=0,w=l.length;d{if(!xn(u)||!xn(i))return i;let c=sn(i,{[Qe]:u},Qe,f,t);return c===void 0?u:c}}var vi=/(?:|<(?:\"[^\"]*\"['\"]*|'[^']*'['\"]*|[^'\">])+>)/g,oi=/(?:\\s(?[^/\\s><=]+?)(?=[\\s/>]))|(?:(?\\S+?)(?:\\s*=\\s*(?:(['\"])(?[\\s\\S]*?)\\3|(?[^\\s>]+))))/g,Li={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(n){let e={type:\"tag\",name:\"\",voidElement:!1,attrs:[],children:[]},f=n.match(/<\\/?([^\\s]+?)[/\\s>]/);if(f){if(e.name=f[1],Li[f[1].toLowerCase()]||n.charAt(n.length-2)===\"/\")e.voidElement=!0;if(e.name.startsWith(\"!--\")){let i=n.indexOf(\"-->\");return{type:\"comment\",comment:i!==-1?n.slice(4,i):\"\"}}}let t=new RegExp(oi);for(let i of n.matchAll(t))if((i[1]||i[2]).startsWith(\"use:\"))e.attrs.push({type:\"directive\",name:i[1]||i[2],value:i[4]||i[5]||\"\"});else e.attrs.push({type:\"attr\",name:i[1]||i[2],value:i[4]||i[5]||\"\"});return e}function Bf(n,e,f){let t=e.indexOf(\"<\",f),i=e.slice(f,t===-1?void 0:t);if(!/^\\s*$/.test(i))n.push({type:\"text\",content:i})}function Rf(n,e){let f=e.replace(\"\",\"\");if(!/^\\s*$/.test(f))n.push({type:\"comment\",content:f})}function ai(n){let e=[],f=void 0,t=-1,i=[],u={};return n.replace(vi,(c,l)=>{let r=c.charAt(1)!==\"/\",d=c.slice(0,4)===\"\"}}function pi(n){return n.reduce(function(e,f){return e+Lf(\"\",f)},\"\")}var vf=new Map,nu=/^(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$/i,re=\" \\\\f\\\\n\\\\r\\\\t\",Mf=\"[^\"+re+`\\\\/>\"'=]+`,Ie=\"[ \"+re+\"]+(?:use:|\"+Mf+\")\",af=\"<([A-Za-z$#]+[A-Za-z0-9:_-]*)((?:\",mf=`(?:\\\\s*=\\\\s*(?:'[^']*?'|\"[^\"]*?\"|\\\\([^)]*?\\\\)|<[^>]*?>|`+Mf+\"))?)\",eu=new RegExp(af+Ie+mf+\"+)([ \"+re+\"]*/?>)\",\"g\"),fu=new RegExp(\"(\"+Ie+`\\\\s*=\\\\s*)(|['\"(]([\\\\w\\\\s]*[\\\\w\\\\s]*)*['\")])`,\"gi\"),tu=new RegExp(af+Ie+mf+\"*)([ \"+re+\"]*/>)\",\"g\"),of=\"\",iu=new Set([\"class\",\"on\",\"oncapture\",\"style\",\"use\",\"prop\",\"attr\"]);function uu(n,e,f,t){return\"<\"+e+f.replace(fu,cu)+t}function cu(n,e,f){return e.replace(//g,\"###\")+(f[0]==='\"'||f[0]===\"'\"?f.replace(//g,\"###\"):'\"###\"')}function lu(n,e,f){return nu.test(e)?n:\"<\"+e+f+\">\"}function ru(n){return n.toLowerCase().replace(/-([a-z])/g,(e,f)=>f.toUpperCase())}function Te(n,e,f,t){if(n===\"use:###\"&&e===\"###\"){let i=t.counter++;t.exprs.push(`typeof exprs[${i}] === \"function\" ? r.use(exprs[${i}], ${f}, exprs[${t.counter++}]) : (()=>{throw new Error(\"use:### must be a function\")})()`)}else throw Error(`Not support syntax ${n} must be use:{function}`)}function $u(n,{delegateEvents:e=!0,functionBuilder:f=(...t)=>Function(...t)}={}){let t=1;n.wrapProps=(y)=>{let $=Object.getOwnPropertyDescriptors(y);for(let b in $)if(typeof $[b].value===\"function\"&&!$[b].value.length)n.dynamicProperty(y,b);return y};function i(y,$){let b=0,N=\"\";for(;b\";N=N+y[b],N=[[tu,lu],[/<()/g,\"<###\"],[/\\.\\.\\.()/g,\"###\"],[eu,uu],[/>\\n+\\s*/g,\">\"],[/\\n+\\s*\\s+/g,\"> \"]].reduce((G,Z)=>{return G.replace(Z[0],Z[1])},N);let W=ai(N),[K,C]=O(W,$.funcBuilder),Y=[];for(let G=0;G-1){let a=j.split(of).reduce((o,z,J)=>{return J&&o.push(\"\"),o.push(z),o},[]);Z[G].firstChild.replaceWith(...a)}}}return Y[0].create=C,vf.set(y,Y),Y}function u(y,$,b,N,V,W,K){let C=N===\"###\"?`!doNotWrap ? exprs[${K.counter}]() : exprs[${K.counter++}]`:N.split(\"###\").map((j,a)=>a?` + (typeof exprs[${K.counter}] === \"function\" ? exprs[${K.counter}]() : exprs[${K.counter++}]) + \"${j}\"`:`\"${j}\"`).join(\"\"),Y,G;if((Y=b.split(\":\"))&&Y[1]&&iu.has(Y[0]))b=Y[1],G=Y[0];let Z=n.ChildProperties.has(b),Q=n.Properties.has(b);if(b===\"style\"){let j=`_$v${t++}`;K.decl.push(`${j}={}`),K.exprs.push(`r.style(${$},${C},${j})`)}else if(b===\"classList\"){let j=`_$v${t++}`;K.decl.push(`${j}={}`),K.exprs.push(`r.classList(${$},${C},${j})`)}else if(G!==\"attr\"&&(Z||!V&&(n.getPropAlias(b,y.name.toUpperCase())||Q)||W||G===\"prop\")){if(W&&!Z&&!Q&&G!==\"prop\")b=ru(b);K.exprs.push(`${$}.${n.getPropAlias(b,y.name.toUpperCase())||b} = ${C}`)}else{let j=V&&b.indexOf(\":\")>-1&&n.SVGNamespace[b.split(\":\")[0]];if(j)K.exprs.push(`r.setAttributeNS(${$},\"${j}\",\"${b}\",${C})`);else K.exprs.push(`r.setAttribute(${$},\"${n.Aliases[b]||b}\",${C})`)}}function c(y,$,b,N,V,W,K){if(b.slice(0,2)===\"on\")if(!b.includes(\":\")){let C=b.slice(2).toLowerCase(),Y=e&&n.DelegatedEvents.has(C);K.exprs.push(`r.addEventListener(${$},\"${C}\",exprs[${K.counter++}],${Y})`),Y&&K.delegatedEvents.add(C)}else{let C=b.startsWith(\"oncapture:\");K.exprs.push(`${$}.addEventListener(\"${b.slice(C?10:3)}\",exprs[${K.counter++}]${C?\",true\":\"\"})`)}else if(b===\"ref\")K.exprs.push(`exprs[${K.counter++}](${$})`);else{let C=Object.assign({},K,{exprs:[]}),Y=K.counter;if(u(y,$,b,N,V,W,C),K.decl.push(`_fn${Y} = (${N===\"###\"?\"doNotWrap\":\"\"}) => {\n${C.exprs.join(`;\n`)};\n}`),N===\"###\")K.exprs.push(`typeof exprs[${Y}] === \"function\" ? r.effect(_fn${Y}) : _fn${Y}(true)`);else{let G=\"\";for(let Z=Y;Z1)for(let V=0;V1?`r.mergeProps(${$.join(\",\")})`:$[0]}function d(y,$){let b=[],N=Object.keys(y.attrs),V=[b],W=$.counter++;for(let C=0;C exprs[${$.counter++}]`);else if(y.children.length){let C={type:\"fragment\",children:y.children},Y=Object.assign({},$,{first:!0,decl:[],exprs:[],parent:!1});w(C,Y),b.push(`children: () => { ${Y.exprs.join(`;\n`)}}`),$.templateId=Y.templateId,$.counter=Y.counter}let K;if($.multi)K=`_$el${t++}`,$.decl.push(`${K} = ${$.path}.${$.first?\"firstChild\":\"nextSibling\"}`);if($.parent)$.exprs.push(`r.insert(${$.parent}, r.createComponent(exprs[${W}],${r(V)})${K?`, ${K}`:\"\"})`);else $.exprs.push(`${$.fragment?\"\":\"return \"}r.createComponent(exprs[${W}],${r(V)})`);$.path=K,$.first=!1}function w(y,$){if(y.type===\"fragment\"){let b=[];y.children.forEach((N)=>{if(N.type===\"tag\"){if(N.name===\"###\"){let K=Object.assign({},$,{first:!0,fragment:!0,decl:[],exprs:[]});d(N,K),b.push(K.exprs[0]),$.counter=K.counter,$.templateId=K.templateId;return}$.templateId++;let V=t,W=Object.assign({},$,{first:!0,decl:[],exprs:[]});$.templateNodes.push([N]),w(N,W),b.push(`function() { ${W.decl.join(`,\n`)+`;\n`+W.exprs.join(`;\n`)+`;\nreturn _$el${V};\n`}}()`),$.counter=W.counter,$.templateId=W.templateId}else if(N.type===\"text\")b.push(`\"${N.content}\"`);else if(N.type===\"comment\"){if(N.content===\"#\")b.push(`exprs[${$.counter++}]`);else if(N.content)for(let V=0;VC.name===\"is\");if($.hasCustomElement=K,$.isImportNode=(y.name===\"img\"||y.name===\"iframe\")&&y.attrs.some((C)=>C.name===\"loading\"&&C.value===\"lazy\"),y.attrs.some((C)=>C.name===\"###\")){let C=[],Y=\"\",G=[];for(let Z=0;Z({${Y}})`),Y=\"\";C.push(`exprs[${$.counter++}]`)}else G.push(y.attrs[Z]);else if(Q===\"directive\")Te(j,a,b,$)}if(y.attrs=G,Y.length)C.push(`()=>({${Y}})`);$.exprs.push(`r.spread(${b},${C.length===1?`typeof ${C[0]} === \"function\" ? r.mergeProps(${C[0]}) : ${C[0]}`:`r.mergeProps(${C.join(\",\")})`},${W},${!!y.children.length})`)}else for(let C=0;C document.importNode(tmpls[${V}].content.firstChild, true))`:`const ${b} = tmpls[${V}].content.firstChild.cloneNode(true)`}else if(y.type===\"text\"){let b=`_$el${t++}`;$.decl.push(`${b} = ${$.path}.${$.first?\"firstChild\":\"nextSibling\"}`),$.path=b,$.first=!1}else if(y.type===\"comment\"){let b=`_$el${t++}`;if($.decl.push(`${b} = ${$.path}.${$.first?\"firstChild\":\"nextSibling\"}`),y.content===\"#\")if($.multi)$.exprs.push(`r.insert(${$.parent}, exprs[${$.counter++}], ${b})`);else $.exprs.push(`r.insert(${$.parent}, exprs[${$.counter++}])`);$.path=b,$.first=!1}}function O(y,$){let b={path:\"\",decl:[],exprs:[],delegatedEvents:new Set,counter:0,first:!0,multi:!1,templateId:0,templateNodes:[]},N=t,V=y,W;if(y.length>1)y=[{type:\"fragment\",children:y}];if(y[0].name===\"###\")W=!0,d(y[0],b);else w(y[0],b);return n.delegateEvents(Array.from(b.delegatedEvents)),[[V].concat(b.templateNodes).map((C)=>pi(C)),$(\"tmpls\",\"exprs\",\"r\",b.decl.join(`,\n`)+`;\n`+b.exprs.join(`;\n`)+(W?\"\":`;\nreturn _$el${N};\n`))]}function k(y,...$){let b=vf.get(y)||i(y,{funcBuilder:f});return b[0].create(b,$,n)}return k}var h=$u({effect:$n,style:qe,insert:Xe,untrack:v,spread:Jf,createComponent:Ke,delegateEvents:_e,classList:Ue,mergeProps:Ye,dynamicProperty:Uf,setAttribute:Sn,setAttributeNS:je,addEventListener:Je,Aliases:He,getPropAlias:Fe,Properties:se,ChildProperties:ze,DelegatedEvents:Ge,SVGElements:_f,SVGNamespace:Ze});function In(n){let e=Math.floor(n/3600),f=Math.floor(n%3600/60),t=n%60;if(e>0)return`${e}h ${f}m`;return f>0?`${f}m ${t}s`:`${t}s`}function $e(n,e){if(!n)return 0;let f=new Date(n).getTime(),t=e?new Date(e).getTime():Date.now();return Math.round((t-f)/1000)}function pf(n){return n.toLowerCase().replace(/[^\\w\\s-]/g,\"\").trim().replace(/\\s+/g,\"-\").replace(/-+/g,\"-\")}var nt=[\"code\",\"auditor\",\"unknown\"];var Be=[\"overview\",\"live\",\"timeline\",\"sections\",\"findings\",\"plan\",\"usage\"],Re=200,ft=300,tt=100;function de(n,e,f){if(f||n.length<=e)return{rows:n,total:n.length,capped:!1};return{rows:n.slice(0,e),total:n.length,capped:!0}}function ve(n,e){return n.length===e.length&&n.every((f,t)=>f===e[t])}var du=new Set([\"loops\",\"groups\",\"findings\",\"plans\"]),hu=new Set(Be);function kn(n){try{return decodeURIComponent(n)}catch{return n}}function wu(){return{projectId:null,section:\"loops\",loopName:null,tab:\"overview\",groupId:null,statuses:[],query:\"\"}}function De(n,e){if(!e)return;for(let f of e.split(\"&\")){if(!f)continue;let t=f.indexOf(\"=\");if(t===-1)continue;let i=kn(f.slice(0,t)),u=kn(f.slice(t+1));if(i===\"status\")n.statuses=u.split(\",\").map((c)=>c.trim()).filter((c)=>c!==\"\");else if(i===\"q\")n.query=u}}function he(n){let e=wu(),f=(n||\"\").replace(/^#/,\"\");if(!f)return e;let t=f.indexOf(\"?\"),i=t===-1?f:f.slice(0,t),u=t===-1?\"\":f.slice(t+1),c=i.indexOf(\"/\"),l,r;if(c===-1)l=i,r=\"\";else l=i.slice(0,c),r=i.slice(c+1);if(l)e.projectId=kn(l);if(!r)return De(e,u),e;let d=r.split(\"/\").filter((O)=>O!==\"\");if(d.length===0)return De(e,u),e;let w=kn(d[0]);if(w===\"loop\"){if(d.length>=2){if(e.loopName=kn(d[1]),d.length>=3){let O=kn(d[2]);if(hu.has(O))e.tab=O}}}else if(du.has(w)){if(e.section=w,w===\"groups\"&&d.length>=2)e.groupId=kn(d[1])}else e.loopName=w;return De(e,u),e}function we(n){if(!n.projectId)return\"\";let e=\"#\"+encodeURIComponent(n.projectId);if(n.section===\"loops\"){if(n.loopName){if(e+=\"/loop/\"+encodeURIComponent(n.loopName),n.tab!==\"overview\")e+=\"/\"+n.tab}}else if(n.section===\"groups\"){if(e+=\"/groups\",n.groupId)e+=\"/\"+encodeURIComponent(n.groupId)}else e+=\"/\"+n.section;let f=[];if(n.statuses.length>0)f.push(\"status=\"+n.statuses.map((t)=>encodeURIComponent(t)).join(\",\"));if(n.query)f.push(\"q=\"+encodeURIComponent(n.query));if(f.length>0)e+=\"?\"+f.join(\"&\");return e}function it(n,e){let t=\"#\"+(location.hash||\"\").replace(/^#/,\"\"),i=\"#\"+n.replace(/^#/,\"\");if(t!==i)e.current=!0,location.hash=n}function m(n){if(!n||n===0)return\"\";let e=new Date(n),f=(r)=>r<10?\"0\"+r:String(r),t=f(e.getMonth()+1),i=f(e.getDate()),u=e.getFullYear(),c=e.getHours(),l=c>=12?\"PM\":\"AM\";if(c=c%12,c===0)c=12;return t+\"-\"+i+\"-\"+u+\" \"+c+\":\"+f(e.getMinutes())+\" \"+l}function Pn(n){return\"status-badge status-\"+n}function oe(n){return\"section-status section-\"+n}function ut(n){return\"feature-stage feature-stage-\"+n}function ct(n,e,f,t){if(!(e.size===0||e.has(n.status)))return!1;if(!f)return!0;let u=f.toLowerCase(),c=n.worktreeBranch??\"\";return((n.loopName||\"\")+\" \"+c+\" \"+(t||\"\")).toLowerCase().indexOf(u)!==-1}function lt(n,e){if(e===\"recent\")return[...n];let f=[...n];if(e===\"cost\")f.sort((t,i)=>{let u=t.usage?.totalCost??null,c=i.usage?.totalCost??null;if(u===null&&c===null)return 0;if(u===null)return 1;if(c===null)return-1;return c-u});else if(e===\"duration\")f.sort((t,i)=>{let u=$e(t.loop.startedAt,t.loop.completedAt??void 0);return $e(i.loop.startedAt,i.loop.completedAt??void 0)-u});else if(e===\"findings\")f.sort((t,i)=>i.findings.length-t.findings.length);return f}function rt(n){return JSON.stringify(n.projects)}function Fn(n){return n.projectDir||n.projectId||\"\"}function Gn(n,e){let f=Fn(e);return n.get(f)??f}function Bn(n){return n.completedAt||n.startedAt||0}function $t(n){let e=new Map;for(let i of n){if(i===\"\"){e.set(i,[]);continue}let u=i.split(\"/\"),c=u.length;while(c>0&&u[c-1]===\"\")c--;e.set(i,c>0?u.slice(0,c):[i])}let f=new Map;for(let i of n){let u=e.get(i);f.set(i,u.length?u[u.length-1]:i)}let t=new Map;for(let i of n){let u=f.get(i),c=t.get(u);if(c)c.push(i);else t.set(u,[i])}for(let[,i]of t){let u=[...new Set(i)];if(u.length<2)continue;let c=1;while(c<1024){let l=new Set,r=!0,d=!0;for(let w of u){let O=e.get(w),k=Math.max(0,O.length-c),y=O.slice(k).join(\"/\");if(l.has(y))r=!1;else l.add(y);if(k>0)d=!1}if(r||d)break;c++}for(let l of u){let r=e.get(l),d=Math.max(0,r.length-c);f.set(l,r.slice(d).join(\"/\"))}}return f}function Le(n){let e=[],f=[];for(let t of n)if(t.severity===\"bug\")e.push(t);else f.push(t);return{bugs:e,warnings:f}}function dt(n){if(n.bugs.length>0)return\"bug\";if(n.warnings.length>0)return\"warn\";return\"clean\"}function Zn(n,e){return n+\" \"+e+(n===1?\"\":\"s\")}function fn(n){return String(n+1)}function Me(n,e){if(!e||e<=0)return 0;return Math.max(0,Math.min(100,n/e*100))}function ht(n){let e=n.file+\":\"+n.line+\" — \"+n.description;if(n.scenario)e+=\" (\"+n.scenario+\")\";return e}function wt(n,e){return\" \"+n+\": $\"+e.cost.toFixed(6)+\", \"+e.inputTokens+\" in / \"+e.outputTokens+\" out (reasoning: \"+e.reasoningTokens+\", cache R: \"+e.cacheReadTokens+\" W: \"+e.cacheWriteTokens+\"), messages: \"+e.messageCount}function Rn(n){if(!Number.isFinite(n)||n<=0)return\"0\";if(n>=1e6)return(n/1e6).toFixed(1).replace(/\\.0$/,\"\")+\"M\";if(n>=1000)return(n/1000).toFixed(1).replace(/\\.0$/,\"\")+\"k\";return String(n)}function vn(n){if(!Number.isFinite(n)||n<=0)return\"$0\";return\"$\"+(n>=1?n.toFixed(2):n.toFixed(4))}function yt(n){let e=[{label:\"Input\",value:n.totalInputTokens,color:\"var(--seg-input)\"},{label:\"Output\",value:n.totalOutputTokens,color:\"var(--seg-output)\"},{label:\"Reasoning\",value:n.totalReasoningTokens,color:\"var(--seg-reasoning)\"},{label:\"Cache R\",value:n.totalCacheReadTokens,color:\"var(--seg-cache-read)\"},{label:\"Cache W\",value:n.totalCacheWriteTokens,color:\"var(--seg-cache-write)\"}],f=e.reduce((t,i)=>t+Math.max(0,i.value),0);return e.map((t)=>({...t,pct:f>0?Math.max(0,t.value)/f*100:0}))}var yu={code:\"execution\",auditor:\"auditor\",unknown:\"other\"};function bt(n){let f=nt.map((i)=>{let u={role:yu[i],cost:0,messageCount:0,tokens:0,pct:0},c=n.byRole[i];if(!c)return u;return u.cost=c.cost,u.messageCount=c.messageCount,u.tokens=c.inputTokens+c.outputTokens+c.reasoningTokens,u}).filter((i)=>i.role!==\"other\"||i.cost>0||i.messageCount>0),t=f.reduce((i,u)=>Math.max(i,u.cost),0);for(let i of f)i.pct=t>0?i.cost/t*100:0;return f}function xt(n){let e=Object.keys(n.byModel).map((t)=>({model:t,...n.byModel[t]})),f=e.reduce((t,i)=>Math.max(t,i.cost),0);return e.sort((t,i)=>i.cost-t.cost).map((t)=>({model:t.model,cost:t.cost,inputTokens:t.inputTokens,outputTokens:t.outputTokens,messageCount:t.messageCount,pct:f>0?t.cost/f*100:0}))}function Ot(n,e){if(!n)return\"\";let f=$e(n,e??void 0);return f>0?In(f):\"\"}function ae(n){return n>=1000?In(Math.floor(n/1000)):n+\"ms\"}function On(n){if(!n||n===0)return\"\";let e=Date.now()-n,f=Math.floor(e/1000);if(f<60)return f+\"s ago\";let t=Math.floor(f/60);if(t<60)return t+\"m ago\";let i=Math.floor(t/60);if(i<24)return i+\"h ago\";let u=Math.floor(i/24);if(u<30)return u+\"d ago\";return m(n)}var bu=/([\\s\\S]*?)<\\/h\\1>/g;function gt(n){return n.replace(/<[^>]+>/g,\"\").replace(/&[a-z#0-9]+;/gi,\"\").trim()}function xu(n){return pf(gt(n)).replace(/[_-]+/g,\"-\").replace(/^-+|-+$/g,\"\")||\"heading\"}function Ou(n){if(!//.test(n))return{html:n,outline:[]};let e=[];return{html:n.replace(bu,(t,i,u)=>{let c=Number(i),l=xu(u);return e.push({depth:c,id:l,text:gt(u)}),`${u}`}),outline:e}}function Nt(n){if(!n)return{html:\"\",outline:[]};let e=Dn.get(n);if(e!==void 0)return e;let f=globalThis.marked;if(!f)return{html:\"\",outline:[]};let{html:t,outline:i}=Ou(f.parse(n)),u={html:t,outline:i};if(Dn.size>=gu){let c=Dn.keys().next().value;if(c!==void 0)Dn.delete(c)}return Dn.set(n,u),u}function kt(n){return Nt(n).html}function Pt(n){return Nt(n)}var Dn=new Map,gu=200;function ye(n){let e=[\"overview\"];if(n.loop.status===\"running\")e.push(\"live\");if(e.push(\"timeline\"),n.sectionCount>0)e.push(\"sections\");if(e.push(\"findings\"),n.hasPlan)e.push(\"plan\");return e.push(\"usage\"),e}var et=4000;function Nu(n){let e=S(n?.title);if(e)return e;let f=ln(n?.input);if(!f)return;let t=S(f.command)??S(f.filePath)??S(f.pattern)??S(f.path);if(t)return t;let i=Object.keys(f);return i.length>0?`${i.length} argument${i.length===1?\"\":\"s\"}`:void 0}function ku(n){let e=S(n?.output)??S(n?.error);if(!e)return;return e.length>et?`…${e.slice(-et)}`:e}function ln(n){return n&&typeof n===\"object\"&&!Array.isArray(n)?n:null}function S(n){return typeof n===\"string\"?n:void 0}function Et(n){let e=ln(n);if(!e)return null;let f=S(e.id),t=S(e.messageID),i=S(e.type);if(!f||!t||!i)return null;if(i===\"text\"){let u=S(e.text)??\"\";if(e.synthetic===!0)return null;return{id:f,messageID:t,type:i,text:u}}if(i===\"tool\"){let u=ln(e.state);return{id:f,messageID:t,type:i,tool:S(e.tool)??\"tool\",status:S(u?.status)??\"pending\",title:Nu(u),output:ku(u)}}return null}function Vt(n){if(!Array.isArray(n))return[];let e=[];for(let f of n){let t=ln(f),i=ln(t?.info),u=S(i?.id),c=S(i?.role);if(!u||!c)continue;let l=[],r=t?.parts;if(Array.isArray(r))for(let d of r){let w=Et(d);if(w){let{messageID:O,...k}=w;l.push(k)}}e.push({id:u,role:c,parts:l})}return e}function Ct(n,e){let f=ln(e),t=S(f?.type),i=ln(f?.properties);if(!t||!i)return n;if(t===\"message.updated\"){let u=ln(i.info),c=S(u?.id),l=S(u?.role);if(!c||!l)return n;let r=n.findIndex((w)=>w.id===c);if(r===-1)return[...n,{id:c,role:l,parts:[]}];if(n[r].role===l)return n;let d=[...n];return d[r]={...d[r],role:l},d}if(t===\"message.removed\"){let u=S(i.messageID);if(!u)return n;let c=n.filter((l)=>l.id!==u);return c.length===n.length?n:c}if(t===\"message.part.updated\"){let u=Et(i.part);if(!u)return n;let{messageID:c,...l}=u,r=n.findIndex((y)=>y.id===c);if(r===-1)return[...n,{id:c,role:\"assistant\",parts:[l]}];let d=n[r],w=d.parts.findIndex((y)=>y.id===l.id),O=w===-1?[...d.parts,l]:d.parts.map((y,$)=>$===w?l:y),k=[...n];return k[r]={...d,parts:O},k}if(t===\"message.part.removed\"){let u=S(i.messageID),c=S(i.partID);if(!u||!c)return n;let l=n.findIndex((O)=>O.id===u);if(l===-1)return n;let r=n[l],d=r.parts.filter((O)=>O.id!==c);if(d.length===r.parts.length)return n;let w=[...n];return w[l]={...r,parts:d},w}return n}function At(n){let e=ln(n),f=S(e?.type),t=ln(e?.properties);if(f===\"session.idle\")return\"idle\";if(f===\"session.status\"){let i=ln(t?.status);return S(i?.type)??null}return null}function Kt(n,e,f,t,i=\"coding\"){let u=[],c=n.findIndex((O)=>O.createdAt>=e),l=c===-1?[]:n.slice(c);if(f!==null)l=l.filter((O)=>O.createdAt<=f);let r;if(l.length>0&&l[0].eventType===\"restart\"&&l[0].toPhase!==null)r=l[0].toPhase;else if(l.length>0&&l[0].eventType!==\"restart\")r=l[0].fromPhase;else r=i;let d=e;for(let O of l){if(O.eventType===\"restart\"){if(O.toPhase!==null)r=O.toPhase;continue}if(r===\"\")r=O.fromPhase,d=Math.max(d,O.createdAt);let k=O.createdAt,y=Math.max(d,k);if(u.push({phase:r,startedAt:d,endedAt:y,durationMs:y-d,open:!1}),O.toPhase===null)r=\"\",d=y;else r=O.toPhase,d=y}if(r!==\"\"){let k=Math.max(d,f??t);u.push({phase:r,startedAt:d,endedAt:f,durationMs:k-d,open:f===null})}let w=n.length>0&&n[0].createdAt>=e&&n[0].createdAt>e+1000;if(w&&u.length>0)u[0]={...u[0],phase:\"\"};return{spans:u,truncated:w}}function Pu(n){let e={};for(let f of n){if(!f.phase)continue;e[f.phase]=(e[f.phase]??0)+f.durationMs}return e}var Eu={coding:\"Coding\",auditing:\"Auditing\",final_auditing:\"Final audit\",final_audit_fix:\"Final audit fix\",post_action:\"Post-action\"};function gn(n){if(n===\"\")return\"Unknown\";return Eu[n]??n}function Yt(n){let e=Pu(n),f=Object.keys(e).map((i)=>({phase:i,label:gn(i),durationMs:e[i],pct:0})),t=f.reduce((i,u)=>i+u.durationMs,0);for(let i of f)i.pct=t>0?Math.round(i.durationMs/t*100):0;return f.sort((i,u)=>u.durationMs-i.durationMs)}function Vu(n,e,f){let t=[];for(let i=0;i=e)c=Math.max(0,u.createdAt-e);else if(u.createdAt=e)c=Math.max(0,u.createdAt-e);else c=null}t.push({transition:u,elapsedMs:c})}return t.reverse(),t}function Wt(n){if(n.length===0)return{count:0,lastAt:null,lastSection:null};let e=n[0];for(let f of n)if(f.createdAt>e.createdAt||f.createdAt===e.createdAt&&f.id>e.id)e=f;return{count:n.length,lastAt:e.createdAt,lastSection:e.appliedAtSection}}function st(n){let e=new Set;for(let f of n)e.add(f.appliedAtSection);return e}function zt(n,e,f,t){let i=[...Vu(n,f,t).map((u)=>({kind:\"transition\",transition:u.transition,elapsedMs:u.elapsedMs})),...e.map((u)=>({kind:\"amendment\",amendment:u,elapsedMs:null}))];return i.sort((u,c)=>{let l=u.kind===\"transition\"?u.transition.createdAt:u.amendment.createdAt,r=c.kind===\"transition\"?c.transition.createdAt:c.amendment.createdAt;if(r!==l)return r-l;if(u.kind!==c.kind)return u.kind===\"transition\"?-1:1;let d=u.kind===\"transition\"?u.transition.id:u.amendment.id;return(c.kind===\"transition\"?c.transition.id:c.amendment.id)-d}),i}var Cu=[{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}],be=110,xe=36,Ht=655,Ft=160,Au=[{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→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 Ku(n){if(n.toPhase===null)return n.fromPhase+\"→terminal\";return n.fromPhase+\"→\"+n.toPhase}function Yu(n){return n.toPhase??n.status??\"\"}function me(n){let e=Array.isArray(n)?n[0]:n,f=e instanceof SVGSVGElement?e.firstElementChild:null;if(!(f instanceof SVGElement))throw Error(\"Expected an SVG child\");return f}function Gt(n){let e=()=>n.loop(),f=g(()=>e().status===\"running\"),t=g(()=>{let l=new Map;for(let r of n.transitions()){let d=Ku(r);l.set(d,(l.get(d)??0)+1)}return l}),i=Au.map((l)=>me(h`\n \n \n ${()=>{let r=t().get(l.key);return r?String(r):\"\"}}\n \n `)),u=Cu.map((l)=>{let r=l.cx-be/2,d=l.cy-xe/2;return me(h`\n \"mg-node\"+(e().phase===l.phase&&f()?\" mg-node-active\":\"\")}>\n \n ${l.label}\n \n `)}),c=me(h`\n \n \n ${()=>e().status}\n \n `);return h`
\n \n \n \n \n \n \n\n ${i}\n ${u}\n ${()=>f()?\"\":c}\n \n\n
\n
Transition history
\n
\n ${()=>n.transitions().slice(-20).reverse().map((l)=>h`
\n ${l.eventType}\n ${l.fromPhase} → ${Yu(l)}\n ${On(l.createdAt)}\n
`)}\n
\n
\n
`}function _t(n){let[e,f]=A(n.query()),t=null,i=(c)=>{let l=c.target.value;if(f(l),t)clearTimeout(t);t=setTimeout(()=>{t=null,n.onChangeQuery(l)},250)};wn(()=>{if(t)clearTimeout(t),t=null}),D(()=>{n.externalNav();let c=v(()=>n.query());if(t!==null)clearTimeout(t),t=null;f(c)});let u=g(()=>{let c=n.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 h`
\n ${()=>u().map(({label:c,key:l,value:r})=>{let w=`badge badge-filter${n.statuses().has(l)?\" badge-active\":\"\"}`;return h`n.onToggleStatus(l)}>${c}: ${r}`})}\n \n \n
`}function Wu(n){return h`
${()=>{let e=n.generatedAt();return e>0?`Last updated: ${new Date(e).toLocaleString()}`:\"\"}}
`}function jt(n){return h`
\n

Forge Dashboard

\n
${()=>n.breadcrumb()}
\n ${Wu({generatedAt:n.generatedAt})}\n
`}function Jt(n){let e=g(()=>n.entries().map((f)=>{let t=f.loops.some((c)=>c.loop.status===\"running\"),i=Fn(f.proj),u=Gn(n.labels(),f.proj);return h`
n.onSelect(f.proj.projectId)}>\n ${t?h``:\"\"}\n ${u}\n ${f.loops.length}\n
`}));return h`
${()=>e()}
`}function su(n,e){let f=0,t=0,i=0,u=[],c=[];for(let l of n){let r=Gn(e,l.proj);for(let d of l.loops){t++,i+=d.bugCount;let w=d.loop.status,O=Bn(d.loop);if(w===\"running\")f++,u.push({projectId:l.proj.projectId,label:r,loopName:d.loop.loopName,phase:d.loop.phase||\"\"});c.push({projectId:l.proj.projectId,label:r,loopName:d.loop.loopName,status:w,when:O})}}return c.sort((l,r)=>r.when-l.when),{runningCount:f,loopCount:t,bugCount:i,runningCards:u,recentRows:c.slice(0,8)}}function Ut(n){let e=g(()=>su(n.entries(),n.labels())),f=g(()=>e().runningCount),t=g(()=>e().loopCount),i=g(()=>e().bugCount),u=g(()=>e().runningCards),c=g(()=>e().recentRows);return h`
\n
\n

All repositories

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

Running

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

Recent activity

\n
\n ${c().map((l)=>h`
n.onOpenLoop(l.projectId,l.loopName)}>\n Pn(l.status)}>${l.status}\n ${l.label}\n ${l.loopName}\n ${m(l.when)}\n
`)}\n
\n
`:\"\"}\n
`}function zu(n){let[e,f]=A(!1),[t,i]=A(\"\"),[u,c]=A(0),l=g(()=>{let N=t().trim().toLowerCase(),V=n.loops();return N?V.filter((W)=>W.name.toLowerCase().indexOf(N)!==-1):V}),r=g(()=>de(l(),tt,!1)),d=()=>{if(e())return;i(\"\");let N=r().rows.findIndex((V)=>V.name===n.loopName);c(N<0?0:N),f(!0)},w=()=>{f(!1),i(\"\"),c(0)},O=(N)=>{if(w(),b.blur(),N!==n.loopName)n.onSelect(N)},k=h`e()?\"display:block\":\"display:none\"}\n onmousedown=${(N)=>N.preventDefault()}\n >\n ${()=>r().rows.map((N,V)=>h`\"loop-picker-option\"+(V===u()?\" loop-picker-option-active\":\"\")+(N.name===n.loopName?\" loop-picker-option-current\":\"\")}\n onclick=${()=>O(N.name)}\n >\n ${N.name}\n ${N.whenLabel}\n `)}\n ${()=>r().total===0?h`
No matching loops
`:\"\"}\n ${()=>r().capped?h`
${()=>\"Showing \"+r().rows.length+\" of \"+r().total+\" — type to filter\"}
`:\"\"}\n `,y=()=>{k.querySelector(\".loop-picker-option-active\")?.scrollIntoView?.({block:\"nearest\"})},$=(N)=>{if(N.key===\"ArrowDown\"||N.key===\"ArrowUp\"){if(N.preventDefault(),!e()){d();return}let V=r().rows.length;if(V===0)return;let W=N.key===\"ArrowDown\"?1:-1;c((K)=>(K+W+V)%V),y();return}if(N.key===\"Enter\"){N.preventDefault();let V=r().rows[u()];if(V)O(V.name);return}if(N.key===\"Escape\")N.preventDefault(),w(),b.blur()},b=h`e()?t():n.loopName}\n onfocus=${d}\n oninput=${(N)=>{i(N.target.value),c(0),f(!0)}}\n onkeydown=${$}\n onblur=${w}\n />`;return h`\n ${b}\n {if(N.preventDefault(),e())w(),b.blur();else b.focus()}}>▾\n ${k}\n `}function qt(n){if(n.loopName)return h`
\n ← ${n.repoLabel}\n /\n ${zu({loopName:n.loopName,loops:n.loops,onSelect:n.onSelectLoop})}\n ${()=>{let e=n.loopNav();return e?h`\n \n ${e.index+1} of ${e.total}\n \n `:\"\"}}\n
`;return h`
\n ← Repositories\n /\n ${n.repoLabel}\n ${n.projectDir}\n
`}function Xt(n){let e=[{key:\"loops\",label:\"Loops\",count:n.counts.loops},{key:\"groups\",label:\"Groups\",count:n.counts.groups},{key:\"findings\",label:\"Findings\",count:n.counts.findings},{key:\"plans\",label:\"Plans\",count:n.counts.plans}];return h`
\n ${e.map((f)=>{let i=`section-nav-item${f.key===n.section?\" section-nav-active\":\"\"}`;return h`
n.onNavigate(f.key)}>\n ${f.label}\n ${f.count}\n
`})}\n
`}function ff(n){return h`
\n ${()=>\"Showing \"+n.shown()+\" of \"+n.total()+\" \"+n.noun}\n ${n.onShowAll?h``:\"\"}\n
`}function ef(n){let e=()=>Me(n.current(),n.total());return h`\n \"width:\"+e()+\"%\"}>\n ${()=>n.formatCurrent?n.formatCurrent(n.current()):String(n.current())}/${()=>n.total()}\n `}var Hu={sm:4,md:14,lg:22};function tf(n){let e=Hu[n.variant],f=g(()=>n.spans().filter((u)=>u.phase!==\"\")),t=g(()=>Math.max(0,f().reduce((u,c)=>u+c.durationMs,0))),i=g(()=>n.spans().some((u)=>u.phase===\"\"));return h`
\"phase-bar phase-bar-\"+n.variant+(i()?\" phase-bar-trunc\":\"\")} data-truncated=${()=>i()?\"true\":\"false\"} style=${()=>\"height:\"+e+\"px\"}>\n ${()=>i()?h`\\u22ef`:\"\"}\n ${()=>{let u=f(),c=t();return u.map((l)=>{let r=c>0?l.durationMs/c*100:0,d=\"phase-seg\"+(l.open?\" phase-seg-open\":\"\"),w=ae(l.durationMs);return h`
`})}}\n
`}function St(n){return h`
\"legend\"+(n.phase?\" legend-phase\":\"\")}>\n ${()=>n.items().map((e)=>h`
\n \n ${e.label}\n ${e.value}\n
`)}\n
`}function Qt(n){let e=g(()=>Yt(n.spans()));return St({phase:!0,items:()=>e().map((f)=>({phase:f.phase,label:f.label,value:ae(f.durationMs)+\" · \"+f.pct+\"%\"}))})}function uf(n,e){let f=n.loop;return Kt(n.transitions??[],f.startedAt,f.completedAt,f.completedAt===null?e():0,f.phase)}function Tt(n){let[e,f]=A(!1),t=g(()=>de(n.loops(),Re,e()));return h`
\n \n \n \n \n \n \n \n \n ${()=>t().rows.map((i)=>Fu({dashLoop:i,now:n.now,onOpen:n.onOpen}))}\n \n
StatusLoopPhasePhase spanIterSectionsFindingsCostDurationUpdated
\n ${()=>t().capped?ff({shown:()=>t().rows.length,total:()=>t().total,noun:\"loops\",onShowAll:()=>f(!0)}):\"\"}\n
`}function Fu(n){let e=()=>n.dashLoop.loop,f=()=>n.dashLoop,t=g(()=>Le(f().findings)),i=g(()=>uf(f(),n.now).spans);return h`n.onOpen(n.dashLoop.loop.loopName)}>\n Pn(e().status)}>${()=>e().status}\n ${()=>e().loopName}\n ${()=>e().completedAt===null?h`e().phase}>${()=>gn(e().phase)}`:h``}\n ${()=>tf({spans:i,variant:\"sm\"})}\n ${ef({current:()=>e().iteration,total:()=>e().maxIterations})}\n ${()=>e().totalSections>0?ef({current:()=>e().currentSectionIndex,total:()=>e().totalSections,formatCurrent:fn}):h``}\n ${()=>{let u=t();if(u.bugs.length===0&&u.warnings.length===0)return h``;return h`\n ${u.bugs.length>0?h`${Zn(u.bugs.length,\"bug\")}`:\"\"}\n ${u.bugs.length>0&&u.warnings.length>0?\" · \":\"\"}\n ${u.warnings.length>0?h`${Zn(u.warnings.length,\"warn\")}`:\"\"}\n `}}\n ${()=>f().usage?vn(f().usage.totalCost):h``}\n ${()=>f().duration||\"\"}\n ${()=>m(Bn(e()))}\n `}function on(n){let e=()=>typeof n.label===\"function\"?n.label():n.label,[f,t]=A(!1),i=()=>t((l)=>!l),u=g(()=>Pt(n.src()||\"\")),c=g(()=>u().outline.filter((l)=>l.depth<=3));return h`
\n
\n f()?\"false\":\"true\"}\n onclick=${i}\n onkeydown=${(l)=>{if(l.key!==\"Enter\"&&l.key!==\" \")return;l.preventDefault(),i()}}\n >\n ${()=>f()?\"▸\":\"▾\"}\n

${()=>e()}

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

Sections

\n ${()=>{let u=i();if(u)return h`
\n
f(null)}>← Back to sections
\n
\n #${fn(u.sectionIndex)}\n ${()=>u.title}\n oe(u.status)}>${()=>u.status}\n
\n ${Zu({sec:u})}\n
`;return h`
\n ${n.sections().map((c)=>_u({sec:c,onOpen:()=>f(c.sectionIndex),adjusted:()=>t().has(c.sectionIndex)}))}\n
`}}\n
`}function M(n){let e=()=>\"ldh-cell\"+(n.size===\"stat\"?\" ldh-cell-stat\":\"\")+(n.tone?\" ldh-cell-\"+n.tone():\"\"),f=()=>\"ldh-cell-value\"+(n.size===\"stat\"?\" ldh-cell-value-stat\":\"\");return h`
\n ${n.label}\n n.title?n.title():\"\"}>${()=>n.value()}\n
`}function nf(n){return h`
\n
${n.label}
\n
${()=>n.children.map((e)=>e)}
\n
`}function Zt(n){let e=()=>Me(n.current(),n.total());return h`
\n
\n ${n.label}\n ${()=>n.formatCurrent?n.formatCurrent(n.current()):String(n.current())} / ${()=>n.total()}\n
\n
\n
\"width:\"+e()+\"%\"}>
\n
\n
`}function Ju(n){let e=()=>n.dashLoop.loop,f=()=>n.dashLoop,t=g(()=>!!e().completedAt),i=g(()=>e().completedAt===null),u=g(()=>e().totalSections>0),c=g(()=>Wt(f().amendments??[])),l=g(()=>{let k=e().terminationReason;if(!k)return null;return k.trim().toLowerCase()===e().status.toLowerCase()?null:k}),r=g(()=>dt(n.split())),d=g(()=>!!n.dashLoop.usage),w=g(()=>{let k=n.split();if(k.bugs.length===0&&k.warnings.length===0)return\"No findings\";let y=[];if(k.bugs.length>0)y.push(Zn(k.bugs.length,\"bug\"));if(k.warnings.length>0)y.push(Zn(k.warnings.length,\"warning\"));return y.join(\" · \")}),O=g(()=>{let k=[M({label:\"Started\",value:()=>m(e().startedAt),size:\"stat\"})];if(t())k.push(M({label:\"Completed\",value:()=>m(e().completedAt),size:\"stat\"}));let y=[M({label:\"Execution model\",value:()=>e().executionModel??\"—\",title:()=>e().executionModel??\"\",size:\"stat\"}),M({label:\"Auditor model\",value:()=>{let b=e().auditorModel??\"—\",N=e().auditorFallbackIndex??0;return N>0?`${b} (fallback ${N})`:b},title:()=>e().auditorModel??\"\",size:\"stat\"})],$=[M({label:\"Branch\",value:()=>e().worktreeBranch??\"—\",title:()=>e().worktreeBranch??\"\",size:\"stat\"}),M({label:\"Sandbox\",value:()=>e().sandbox?\"on\":\"off\",size:\"stat\"}),M({label:\"Kind\",value:()=>e().kind??\"—\",size:\"stat\"}),M({label:\"Audits\",value:()=>String(e().auditCount),size:\"stat\"}),M({label:\"Errors\",value:()=>String(e().errorCount),size:\"stat\"})];if(d())$.push(M({label:\"Messages\",value:()=>String(n.dashLoop.usage.totalMessageCount),size:\"stat\"}));return[nf({label:\"Timing\",children:k}),nf({label:\"Models\",children:y}),nf({label:\"Environment\",children:$})]});return h`
\n
\n Pn(e().status)}>${()=>e().status}\n

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

\n ${()=>c().count>0?h`n.onSelectTab(\"plan\")}\n >${()=>\"Plan adjusted \"+c().count+\"× · last \"+On(c().lastAt)+\" @ section \"+fn(c().lastSection)}`:\"\"}\n ${()=>i()?h`${gn(e().phase)}`:\"\"}\n
\n\n
\n ${M({label:\"Duration\",value:()=>f().duration||\"—\"})}\n ${M({label:\"Cost\",value:()=>d()?vn(n.dashLoop.usage.totalCost):\"—\"})}\n ${M({label:\"Iterations\",value:()=>e().iteration+\" / \"+e().maxIterations})}\n ${()=>u()?M({label:\"Sections\",value:()=>fn(e().currentSectionIndex)+\" / \"+e().totalSections}):\"\"}\n ${M({label:\"Findings\",value:()=>w(),tone:()=>r()})}\n
\n\n
\n ${Zt({label:\"Iterations\",current:()=>e().iteration,total:()=>e().maxIterations})}\n ${()=>u()?Zt({label:\"Sections\",current:()=>e().currentSectionIndex,total:()=>e().totalSections,formatCurrent:fn}):\"\"}\n
\n\n
${()=>O()}
\n\n ${()=>l()?h`
\"ldh-banner ldh-banner-\"+e().status}>${()=>l()}
`:\"\"}\n
`}function Uu(n){let e=()=>n.usage(),f=g(()=>yt(e())),t=g(()=>xt(e())),i=g(()=>t().length>0),u=g(()=>bt(e()));return h`
\n

Usage

\n\n
\n
Role split
\n
\n ${()=>u().map((c)=>h`
c.role}>\n
\n c.role}>${c.role}\n ${()=>vn(c.cost)}\n
\n
\n
\"width:\"+c.pct+\"%\"}>
\n
\n
${()=>c.messageCount+\" msg · \"+Rn(c.tokens)+\" tok\"}
\n
`)}\n
\n
\n\n
\n
Token composition
\n
\n ${()=>f().map((c)=>c.pct>0?h`
`:\"\")}\n
\n ${St({items:()=>f().map((c)=>({color:c.color,label:c.label,value:Rn(c.value)}))})}\n
\n\n ${()=>i()?h`
\n
Cost by model
\n
\n ${()=>t().map((c)=>h`
\n
\n ${c.model}\n ${vn(c.cost)}\n
\n
\n
\n
\n
\n ${Rn(c.inputTokens)} in / ${Rn(c.outputTokens)} out · ${c.messageCount} msg\n
\n
`)}\n
\n
`:\"\"}\n `}function qu(n){let e=n.amendment,[f,t]=A(null),[i,u]=A(!1),[c,l]=A(\"\"),[r,d]=A(!1);D(()=>{if(n.expanded()){if(r()||f()!==null)return;d(!0),l(\"\"),u(!0);let $=new URLSearchParams({project:e.projectId,loop:e.loopName,id:String(e.id)});fetch(\"/api/amendment?\"+$.toString()).then(async(b)=>{if(!b.ok){l(await b.text().catch(()=>\"\")||`Failed (status ${b.status})`);return}let N=await b.json();t(N)}).catch((b)=>l(b instanceof Error?b.message:String(b))).finally(()=>u(!1));return}if(r()&&!i()&&f()===null)d(!1)});let w=()=>{let $=[];if(e.summary.added>0)$.push({cls:\"amendment-count-add\",label:\"+\"+e.summary.added});if(e.summary.removed>0)$.push({cls:\"amendment-count-remove\",label:\"−\"+e.summary.removed});if(e.summary.modified>0)$.push({cls:\"amendment-count-modified\",label:\"~\"+e.summary.modified});return $},O=()=>(f()?.sections??[]).filter(($)=>$.change!==\"unchanged\"),k=()=>f()!==null&&O().length===0,y=($)=>{if($.kind===\"add\")return\"+ \"+$.text;if($.kind===\"remove\")return\"- \"+$.text;if($.kind===\"context\")return\" \"+$.text;return $.text};return h`
\n
\n ${()=>On(e.createdAt)}\n ${()=>\"applied @ section \"+fn(e.appliedAtSection)}\n ${()=>e.source}\n ${()=>w().length>0?h`${w().map(($)=>h`${$.label}`)}`:\"\"}\n ${()=>e.rationale}\n ${()=>n.expanded()?\"▾\":\"▸\"}\n
\n
n.expanded()?\"display:block\":\"display:none\"}>\n
\n ${()=>i()?h`
Loading diff…
`:\"\"}\n ${()=>c()?h`
${c()}
`:\"\"}\n ${()=>k()?h`
No section changes recorded.
`:\"\"}\n ${()=>O().map(($)=>h`
\n
\n ${()=>\"#\"+fn($.index)}\n ${()=>$.change}\n ${()=>$.title}\n ${()=>$.previousTitle!==null?h`${()=>\"was: \"+$.previousTitle}`:\"\"}\n
\n ${()=>$.lines.length===0?h`
Title changed only.
`:$.lines.map((b)=>h`
${y(b)}
`)}\n
`)}\n
\n
\n
`}function Xu(n){let e=()=>n.amendments(),[f,t]=A(null);return h`
\n

Plan amendments

\n
\n ${()=>e().map((i)=>qu({amendment:i,expanded:()=>f()===i.id,onToggle:()=>t(f()===i.id?null:i.id)}))}\n
\n
`}var Su={overview:\"Overview\",live:\"Live\",timeline:\"Timeline\",sections:\"Sections\",findings:\"Findings\",plan:\"Plan\",usage:\"Usage\"};function Qu(n){return h`
\n ${()=>n.tabs().map((e)=>{let f=Su[e];return h`\"tab-item\"+(n.activeTab()===e?\" tab-active\":\"\")} data-tab=\"${e}\" onclick=${()=>n.onSelect(e)}>${f}`})}\n
`}function Tu(n){let e=()=>n.dashLoop,f=()=>e().loop,t=g(()=>!!e().goal),i=g(()=>e().postActionReport??f().completionSummary??null),u=g(()=>e().postActionReport?\"Post-Action Report\":\"Completion Summary\"),c=g(()=>!!i()),l=g(()=>!!e().lastAuditResult),r=g(()=>uf(e(),n.now));return h`
\n ${()=>tf({spans:()=>r().spans,variant:\"md\"})}\n ${()=>Qt({spans:()=>r().spans})}\n ${()=>t()?on({label:\"Goal\",src:()=>e().goal}):\"\"}\n ${Ju({dashLoop:n.dashLoop,split:n.split,onSelectTab:n.onSelectTab})}\n ${()=>c()?on({label:u,src:()=>i()}):\"\"}\n ${()=>l()?on({label:\"Last Audit Result\",src:()=>e().lastAuditResult}):\"\"}\n
`}function Iu(n){let e=n.ev.transition,f=n.ev.elapsedMs,t=f===null?null:Math.floor(f/1000),i=e.toPhase===null?e.status??\"\":e.toPhase;return h`
\n ${On(e.createdAt)}\n ${gn(e.fromPhase)} → ${gn(i)}\n ${e.transitionKind}\n iter ${e.iteration}\n ${()=>e.sectionIndex===null?\"—\":\"sect \"+fn(e.sectionIndex)}\n ${()=>t===null?\"—\":t>0?In(t):\"0s\"}\n
`}function Du(n){let e=n.amendment;return h`
\n ${On(e.createdAt)}\n plan adjusted\n ${()=>\"section \"+fn(e.appliedAtSection)}\n ${()=>e.rationale}\n
`}function Bu(n){let e=()=>n.dashLoop,f=()=>e().loop,t=()=>e().transitions??[],i=g(()=>uf(e(),n.now)),u=g(()=>i().spans),c=g(()=>i().truncated),l=g(()=>zt(t(),e().amendments??[],f().startedAt,c())),[r,d]=A(!1),w=g(()=>{let k=l();return r()?k:k.slice(0,20)}),O=g(()=>r()?0:Math.max(0,l().length-20));return h`
\n ${Gt({loop:()=>f(),transitions:t})}\n ${()=>tf({spans:u,variant:\"lg\"})}\n ${()=>c()?h`
Earlier history was truncated by the 100-row fetch window.
`:\"\"}\n ${()=>Qt({spans:u})}\n\n
\n ${()=>w().map((k)=>k.kind===\"transition\"?Iu({ev:{transition:k.transition,elapsedMs:k.elapsedMs}}):Du({amendment:k.amendment}))}\n ${()=>O()>0?h`
d(!0)}>[ ${()=>O()} earlier events ]
`:\"\"}\n
\n
`}function Ru(n){return ju({sections:()=>n.dashLoop.sections,amendments:()=>n.dashLoop.amendments??[]})}function vu(n){let e=[],f=new Map;for(let t of n){let i=t.sectionIndex??null;if(!f.has(i))f.set(i,[]),e.push(i);f.get(i).push(t)}return e.sort((t,i)=>{if(t===null&&i===null)return 0;if(t===null)return 1;if(i===null)return-1;return t-i}),e.map((t)=>({key:t,label:t===null?\"cross-section\":\"sect \"+fn(t),rows:f.get(t)}))}function It(n){return h`
\n ${n.rows.map((e)=>h`
\"finding finding-\"+e.severity}>\n ${()=>On(e.createdAt)}\n ${()=>ht(e)}\n
`)}\n
`}function Dt(n){let e=()=>we({projectId:n.projectId(),section:\"loops\",loopName:n.loopName,tab:\"overview\",groupId:null,statuses:[],query:\"\"});return h`{if(f.defaultPrevented||f.button!==0||f.metaKey||f.ctrlKey||f.shiftKey||f.altKey)return;f.preventDefault(),n.onOpen(n.loopName)}}>${n.loopName}`}function ou(n){let e=()=>n.dashLoop,f=g(()=>e().findings.length>0),t=g(()=>vu(e().findings));return h`
\n ${()=>f()?t().map((i)=>h`
i.key===null?\"cross\":String(i.key)}>\n
${i.label}
\n ${It({rows:i.rows})}\n
`):h`
No findings recorded for this loop.
`}\n
`}function Lu(n){let e=()=>n.dashLoop,f=g(()=>e().hasPlan),t=g(()=>!!e().amendments&&e().amendments.length>0);return h`
\n ${()=>f()?on({label:\"Plan\",src:()=>e().plan}):h`
No plan recorded for this loop.
`}\n ${()=>t()?Xu({amendments:()=>e().amendments}):\"\"}\n
`}function Mu(n){let e=()=>n.dashLoop,f=g(()=>!!e().usage);return h`
\n ${()=>f()?Uu({usage:()=>e().usage}):h`
No usage recorded for this loop.
`}\n
`}function au(n){let e=()=>n.dashLoop.loop,[f,t]=A([]),[i,u]=A(\"\"),[c,l]=A(\"\"),[r,d]=A(\"\"),[w,O]=A(\"\"),[k,y]=A(\"\"),[$,b]=A(!1),[N,V]=A(\"\"),[W,K]=A(!1),[C,Y]=A(!1),G=!1,Z=[\"\",\"\",\"\",\"\"];D(()=>{let z=[e().executionModel??\"\",e().executionVariant??\"\",e().auditorModel??\"\",e().auditorVariant??\"\"];if(!G&&(z[0]!==Z[0]||z[1]!==Z[1]||z[2]!==Z[2]||z[3]!==Z[3]))l(z[0]),d(z[1]),O(z[2]),y(z[3]);Z=z}),D(()=>{if(!C()||f().length>0)return;let z=new URLSearchParams({project:e().projectId,loop:e().loopName});fetch(\"/api/models?\"+z.toString()).then(async(J)=>{if(!J.ok){u(await J.text().catch(()=>\"\")||`Failed (status ${J.status})`);return}let L=await J.json();t(L.models??[]),u(\"\")}).catch((J)=>u(J instanceof Error?J.message:String(J)))});let Q=(z)=>f().find((J)=>J.id===z)?.variants??[],j=async()=>{if($())return;b(!0),V(\"\"),K(!1);try{let z=await fetch(\"/api/loop/models\",{method:\"POST\",headers:{\"content-type\":\"application/json\"},body:JSON.stringify({projectId:e().projectId,loopName:e().loopName,executionModel:c()||null,executionVariant:r()||null,auditorModel:w()||null,auditorVariant:k()||null})});if(!z.ok){V(await z.text().catch(()=>\"\")||`Failed (status ${z.status})`);return}let J=await z.json(),L=[J.executionModel??\"\",J.executionVariant??\"\",J.auditorModel??\"\",J.auditorVariant??\"\"];l(L[0]),d(L[1]),O(L[2]),y(L[3]),Z=L,G=!1,K(!0)}catch(z){V(z instanceof Error?z.message:String(z))}finally{b(!1)}},a=(z,J,L)=>h`{G=!0,J(q.currentTarget.value),L()}}\n >\n \n ${()=>f().map((q)=>h``)}\n ${()=>z()&&!f().some((q)=>q.id===z())?h``:\"\"}\n `,o=(z,J,L)=>h`Q(z()).length===0}\n onchange=${(q)=>{G=!0,L(q.currentTarget.value)}}\n >\n \n ${()=>Q(z()).map((q)=>h``)}\n `;return h`
\n \n ${()=>{if(!C())return\"\";return h`
\n ${()=>i()?h`
${()=>i()}
`:\"\"}\n \n \n
\n Applies to the next prompt, not the turn in flight.\n \n
\n ${()=>N()?h`
${()=>N()}
`:\"\"}\n ${()=>W()&&!N()?h`
Models updated.
`:\"\"}\n
`}}\n
`}function mu(n){let e=()=>n.part,[f,t]=A(!1),i=()=>!!e().output;return h`
\"live-tool live-tool-\"+(e().status??\"pending\")}>\n \"live-tool-head\"+(i()?\" live-tool-head-clickable\":\"\")}\n disabled=${()=>!i()}\n aria-expanded=${()=>i()?f()?\"true\":\"false\":void 0}\n onclick=${()=>{if(i())t((u)=>!u)}}\n >\n ${()=>i()?f()?\"▾\":\"▸\":\"\"}\n ${()=>e().tool??\"tool\"}\n ${()=>e().title??\"\"}\n ${()=>e().status??\"pending\"}\n \n ${()=>f()&&i()?h`
${()=>e().output}
`:\"\"}\n
`}var pu=8000,nc=48;function ec(){if(typeof document>\"u\")return()=>!0;let[n,e]=A(document.visibilityState!==\"hidden\"),f=()=>e(document.visibilityState!==\"hidden\");return document.addEventListener(\"visibilitychange\",f),wn(()=>document.removeEventListener(\"visibilitychange\",f)),n}function fc(n){let e=()=>n.dashLoop,f=()=>e().loop,[t,i]=A([]),[u,c]=A(\"connecting\"),[l,r]=A(\"\"),[d,w]=A(\"\"),[O,k]=A(\"\"),[y,$]=A(!1),[b,N]=A(\"\"),[V,W]=A(\"stream\"),[K,C]=A(0),[Y,G]=A(0),Z=ec(),Q=g(()=>n.visible()&&Z());D(()=>{if(!Q())return;let H=setInterval(()=>G((I)=>I+1),1000);wn(()=>clearInterval(H))});let j=g(()=>{if(Y(),d()===\"busy\")return!0;let H=K();return H>0&&Date.now()-H{return\"/api/loop/stream?\"+new URLSearchParams({project:f().projectId,loop:f().loopName}).toString()}),o=\"\";D(()=>{let H=f().currentSessionId,I=a();if(!Q()||!H||typeof EventSource>\"u\")return;if(H!==o)o=H,i([]);c(\"connecting\"),r(\"\"),W(\"stream\"),C(0);let B=new EventSource(I);B.addEventListener(\"snapshot\",(Nn)=>{try{let hn=JSON.parse(Nn.data),Vn=Vt(hn.messages);if(i(Vn),c(\"live\"),hn.reason===\"poll\")W(\"poll\"),C(Date.now())}catch{c(\"failed\"),r(\"Could not read the transcript snapshot.\")}}),B.addEventListener(\"event\",(Nn)=>{try{let hn=JSON.parse(Nn.data);i((Mn)=>Ct(Mn,hn)),W(\"stream\"),C(Date.now());let Vn=At(hn);if(Vn)w(Vn)}catch{}}),B.addEventListener(\"failed\",(Nn)=>{try{let hn=JSON.parse(Nn.data);r(hn.message??\"Live stream ended.\")}catch{r(\"Live stream ended.\")}c(\"failed\"),B.close()}),B.onerror=()=>{if(B.readyState===EventSource.CLOSED)c(\"failed\")},wn(()=>B.close())});let z=async()=>{let H=O().trim();if(!H||y())return;$(!0),N(\"\");try{let I=await fetch(\"/api/loop/message\",{method:\"POST\",headers:{\"content-type\":\"application/json\"},body:JSON.stringify({projectId:f().projectId,loopName:f().loopName,text:H})});if(!I.ok){N(await I.text().catch(()=>\"\")||`Failed (status ${I.status})`);return}k(\"\")}catch(I){N(I instanceof Error?I.message:String(I))}finally{$(!1)}},J=g(()=>{if(u()===\"connecting\")return\"connecting\";if(u()===\"failed\")return\"disconnected\";if(j())return V()===\"poll\"?\"working (refreshing)\":\"working\";return V()===\"poll\"?\"refreshing\":d()||\"idle\"}),L=g(()=>{if(u()!==\"live\")return\"live-dot live-dot-\"+u();if(j())return\"live-dot live-dot-working\";return\"live-dot live-dot-idle\"}),q=h`
\n ${()=>{let H=t();if(H.length===0)return h`
${()=>u()===\"live\"?\"No messages in this session yet.\":\"Loading session…\"}
`;return H.map((I)=>h`
\n
${I.role}
\n
\n ${()=>I.parts.map((B)=>B.type===\"text\"?h`
${B.text??\"\"}
`:mu({part:B}))}\n
\n
`)}}\n
`,[dn,En]=A(!0),Ln=(H)=>H.scrollHeight-H.scrollTop-H.clientHeightEn(Ln(q))),D(()=>{if(t(),!dn())return;queueMicrotask(()=>{q.scrollTop=q.scrollHeight})}),h`
\n
\n L()}>\n ${()=>J()}\n ${()=>V()===\"poll\"?h`events unavailable — refreshing every 4s`:\"\"}\n ${()=>f().currentSessionId}\n
\n\n ${()=>l()?h`
${()=>l()}
`:\"\"}\n\n ${au({dashLoop:n.dashLoop})}\n\n ${q}\n\n ${()=>dn()?\"\":h``}\n\n
\n O()}\n oninput=${(H)=>k(H.currentTarget.value)}\n onkeydown=${(H)=>{if(H.key!==\"Enter\"||H.shiftKey)return;H.preventDefault(),z()}}\n >\n void z()}\n disabled=${()=>y()||O().trim().length===0}\n >${()=>y()?\"Sending…\":\"Send\"}\n
\n ${()=>b()?h`
${()=>b()}
`:\"\"}\n
`}function tc(n,e){switch(n.tab){case\"overview\":return Tu({dashLoop:n.dashLoop,split:n.split,now:n.now,onSelectTab:n.onSelectTab});case\"live\":return fc({dashLoop:n.dashLoop,visible:e});case\"timeline\":return Bu({dashLoop:n.dashLoop,now:n.now});case\"sections\":return Ru({dashLoop:n.dashLoop});case\"findings\":return ou({dashLoop:n.dashLoop});case\"plan\":return Lu({dashLoop:n.dashLoop});case\"usage\":return Mu({dashLoop:n.dashLoop})}}function ic(n){let e=g(()=>n.tabs().includes(n.tab)&&n.activeTab()===n.tab),f=ie(),t=h`
e()?\"display:block\":\"display:none\"}>
`,i=!1;return D(()=>{if(!e()||i)return;i=!0;let u=Ce(f,()=>tc(n,e));if(u)t.appendChild(u)}),t}function uc(n){return h`
\n ${Be.map((e)=>ic({tab:e,dashLoop:n.dashLoop,split:n.split,now:n.now,tabs:n.tabs,activeTab:n.activeTab,onSelectTab:n.onSelectTab}))}\n
`}function Bt(n){let e=()=>n.dashLoop,f=g(()=>Le(e().findings)),t=g(()=>ye(e())),i=g(()=>{let u=n.routeTab();return t().includes(u)?u:\"overview\"});return h`
\n
\n ${Qu({tabs:t,activeTab:i,onSelect:n.onSelectTab})}\n ${uc({dashLoop:n.dashLoop,split:f,now:n.now,tabs:t,activeTab:i,onSelectTab:n.onSelectTab})}\n
\n
`}function cf(){return h`
No loops match the current filters.
`}var cc=new Set([\"completed\",\"cancelled\",\"errored\"]);function Rt(n){let e=n.length;return{completed:n.filter((t)=>t.stage===\"completed\").length,total:e}}function vt(n,e){let f=cc.has(e);return n+(f?` ${n}-terminal`:` ${n}-active`)}function ot(n){return h`
\n

Groups

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

${()=>f.title}

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

PRD preview

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

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

\n ${()=>t().capped?ff({shown:()=>t().groups.reduce((i,u)=>i+u.rows.length,0),total:()=>t().total,noun:\"findings\",onShowAll:()=>f(!0)}):\"\"}\n ${()=>{let i=t().groups;if(i.length===0)return h`
No findings recorded for this repo.
`;return h`
\n ${i.map((u)=>h`
u.loopName}>\n
\n ${Dt({className:\"findings-loop-link\",projectId:n.projectId,loopName:u.loopName,onOpen:n.onOpenLoop})}\n ${()=>u.rows.length}\n
\n ${It({rows:u.rows})}\n
`)}\n
`}}\n
`}function at(n){let[e,f]=A(!1),t=g(()=>n.loops().filter((u)=>u.hasPlan)),i=g(()=>de(t(),Re,e()));return h`
\n

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

\n ${()=>i().capped?ff({shown:()=>i().rows.length,total:()=>i().total,noun:\"plans\",onShowAll:()=>f(!0)}):\"\"}\n ${()=>{let u=i().rows;if(u.length===0)return h`
No plans recorded for this repo.
`;return h`
\n ${u.map((c)=>h`
n.onOpenLoop(c.loop.loopName)}>\n Pn(c.loop.status)}>${()=>c.loop.status}\n ${()=>c.loop.loopName}\n \n ${()=>Zn(c.findings.length,\"finding\")}\n \n iter ${()=>c.loop.iteration}/${()=>c.loop.maxIterations}\n
`)}\n
`}}\n
`}function mt(){let[n,e]=If({generatedAt:0,projects:[]}),[f,t]=A(!1),i=he(typeof location<\"u\"?location.hash:\"\"),[u,c]=A(i.projectId),[l,r]=A(i.section),[d,w]=A(i.loopName),[O,k]=A(i.tab),[y,$]=A(i.groupId),[b,N]=A(i.statuses,{equals:ve}),[V,W]=A(i.query),K=g(()=>({projectId:u(),section:l(),loopName:d(),tab:O(),groupId:y(),statuses:b(),query:V()})),[C,Y]=A(null),[G,Z]=A(\"recent\"),[Q,j]=A(0),[a,o]=A(Date.now()),z={current:\"\"},J={current:!1},L=()=>{let x=v(K),P=new URLSearchParams;if(x.projectId)P.set(\"project\",x.projectId);if(x.loopName)P.set(\"loop\",x.loopName);let s=P.toString();return s?\"/api/data?\"+s:\"/api/data\"},q=0,dn={url:null,gen:0},En=async()=>{let x=L();if(dn.url===x)return;let P=++q;dn.url=x,dn.gen=P;try{let s=await fetch(x,{cache:\"no-store\"});if(P!==q)return;let U=await s.json();if(P!==q)return;let p=rt(U);if(p!==z.current)z.current=p,e(Df(U,{key:\"id\"})),t(!0),Y(null);o(Date.now())}catch(s){if(P!==q)return;let U=s instanceof Error?s.message:String(s);Y(\"Failed to load dashboard data: \"+U)}finally{if(dn.gen===P)dn.url=null,dn.gen=0}},Ln=(x)=>{Xn(()=>{c(x.projectId),r(x.section),w(x.loopName),k(x.tab),$(x.groupId),N(x.statuses),W(x.query)})},H=(x)=>{Xn(()=>{let P=x.projectId!==void 0&&x.projectId!==u();if(x.projectId!==void 0)c(x.projectId);if(x.section!==void 0)r(x.section);if(x.loopName!==void 0)w(x.loopName);if(x.tab!==void 0)k(x.tab);if(x.groupId!==void 0)$(x.groupId);if(x.statuses!==void 0)N(x.statuses);if(x.query!==void 0)W(x.query);if(P)N([]),W(\"\")})},I=(x)=>{H({tab:x})},B=(x)=>{H({loopName:x,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})},Nn=(x)=>{let P=new Set(b());if(P.has(x))P.delete(x);else P.add(x);H({statuses:[...P]})},hn=(x)=>{H({query:x})},Vn=(x)=>{Z(x)},Mn=g(()=>new Set(b())),an=g(()=>$t(n.projects.map(Fn))),mn=g(()=>{if(!f()||n.projects.length===0)return[];let x=Mn(),P=V(),s=an(),U=[],p=P.trim().toLowerCase();for(let jn of n.projects){let Ne=[],hf=Gn(s,jn);for(let wf of jn.loops)if(ct(wf.loop,x,P,hf))Ne.push(wf);let ki=(jn.groups?.length??0)>0;if(Ne.length>0)U.push({proj:jn,loops:Ne});else if(ki&&(!p||hf.toLowerCase().indexOf(p)!==-1))U.push({proj:jn,loops:[]})}return U}),lf=g(()=>u()===null),ni=g(()=>l()===\"groups\"),rf=g(()=>l()===\"loops\"),ei=g(()=>l()===\"findings\"),fi=g(()=>l()===\"plans\"),$f=g(()=>{let x=mn(),P=u();if(!P)return null;return x.find((s)=>s.proj.projectId===P)??null}),rn=g(()=>{let x=u();if(!x)return null;return n.projects.find((P)=>P.projectId===x)??null}),Oe=g(()=>{let x=u(),P=d();if(!x||!P)return null;let s=rn();if(!s)return null;return s.loops.find((U)=>U.loop.loopName===P)??null}),ti=g(()=>{let x=rn(),P=x?.groups?.length??0;if(!x)return{loops:0,groups:P,findings:0,plans:0};let s=0,U=0;for(let p of x.loops)if(s+=p.findings.length,p.hasPlan)U++;return{loops:x.loops.length,groups:P,findings:s,plans:U}}),_n=g(()=>{let x=rn();if(!x)return[];return x.loops.map((P)=>{let s=Bn(P.loop);return{name:P.loop.loopName,when:s,whenLabel:m(s)}}).sort((P,s)=>s.when-P.when||P.name.localeCompare(s.name))}),ge=g(()=>{let x=d();if(!x)return-1;return _n().findIndex((P)=>P.name===x)}),ii=g(()=>{let x=ge();if(x<0)return null;let P=_n();if(P.length<=1)return null;return{index:x,total:P.length,onPrev:()=>{let s=ge(),U=_n();if(s>0)H({loopName:U[s-1].name})},onNext:()=>{let s=ge(),U=_n();if(sx===P||!!x&&!!P&&x.index===P.index&&x.total===P.total}),ui=g(()=>{let x=rn(),P={running:0,completed:0,cancelled:0,errored:0,stalled:0};if(!x)return P;for(let s of x.loops){let U=s.loop.status;if(U===\"running\")P.running++;else if(U===\"completed\")P.completed++;else if(U===\"cancelled\")P.cancelled++;else if(U===\"errored\")P.errored++;else if(U===\"stalled\")P.stalled++}return P}),ci=g(()=>{let x=d();if(!u())return\"\";let P=rn();if(!P)return\"\";let s=Fn(P),U=Gn(an(),P);return v(()=>qt({repoLabel:U,projectDir:s,loopName:x,loops:_n,loopNav:ii,onSelectLoop:(p)=>H({loopName:p}),onBackToRepos:()=>H({projectId:null,loopName:null,section:\"loops\"}),onBackToRepo:()=>H({loopName:null})}))}),li=g(()=>{let x=u(),P=d();if(!x||P)return\"\";return Xt({section:l(),counts:ti(),onNavigate:(s)=>H({section:s,loopName:null,groupId:null})})}),ri=g(()=>{return u()!==null&&d()===null&&l()===\"loops\"}),$i=g(()=>{if(!ri())return\"\";return v(()=>_t({counts:ui,statuses:Mn,query:V,sortMode:G,onToggleStatus:Nn,onChangeQuery:hn,onChangeSort:Vn,externalNav:Q}))}),di=g(()=>{if(!rf())return\"\";let x=Oe();return x?Bt({dashLoop:x,now:a,routeTab:O,onSelectTab:I}):\"\"}),hi=g(()=>{let x=$f();return x?lt(x.loops,G()):[]},[],{equals:ve}),wi=g(()=>{if(!rf())return\"\";if(Oe())return\"\";if(!u())return\"\";return h`
\n ${Tt({loops:hi,now:a,onOpen:(x)=>B(x)})}\n ${()=>$f()?\"\":cf()}\n
`}),yi=g(()=>{if(l()!==\"groups\"||!y())return null;let x=rn();if(!x)return null;return x.groups.find((P)=>P.group.groupId===y())??null}),bi=g(()=>{let x=rn();if(!x)return new Set;return new Set(x.loops.map((P)=>P.loop.loopName))}),xi=g(()=>{if(!ni())return\"\";if(!u())return\"\";if(y())return Lt({group:yi,loopNames:bi,projectId:()=>u(),onBack:()=>H({groupId:null}),onOpenLoop:B});return ot({groups:()=>rn()?.groups??[],onOpen:(P)=>H({groupId:P})})}),df=g(()=>{let x=rn();return x?x.loops:[]}),Oi=g(()=>{if(!ei())return\"\";if(!u())return\"\";return Mt({loops:df,projectId:()=>u(),onOpenLoop:B})}),gi=g(()=>{if(!fi())return\"\";if(!u())return\"\";return at({loops:df,onOpenLoop:B})});D(()=>{if(!f())return;let x=u();if(x===null)return;if(!n.projects.some((s)=>s.projectId===x))c(null),w(null),r(\"loops\"),N([]),W(\"\")}),D(()=>{let x=u(),P=d();if(!x||!P)return;let s=rn();if(!s)return;if(!s.loops.some((p)=>p.loop.loopName===P))w(null)}),D(()=>{let x=d(),P=O();if(!u()||!x||P===\"overview\")return;let s=Oe();if(!s)return;if(!ye(s).includes(P))k(\"overview\")}),D(()=>{if(!f())return;it(we(K()),J)});let Ni=g(()=>(u()??\"\")+\"\\x00\"+(d()??\"\"));return D(Vf(Ni,()=>{En()},{defer:!0})),Cf(()=>{Ln(he(location.hash)),En();let x=setInterval(()=>{En()},5000),P=()=>{if(J.current){J.current=!1;return}Ln(he(location.hash)),j((s)=>s+1)};window.addEventListener(\"hashchange\",P),wn(()=>{clearInterval(x),window.removeEventListener(\"hashchange\",P)})}),h`
\n ${jt({generatedAt:()=>n.generatedAt,breadcrumb:ci})}\n\n ${()=>{let x=C();return x?h`
${x}
`:\"\"}}\n\n ${()=>{if(!f())return\"\";if(lf()&&mn().length===0)return cf();return h`
\n ${()=>{if(lf())return h`
\n ${Jt({entries:()=>mn(),labels:()=>an(),onSelect:(x)=>H({projectId:x,loopName:null,section:\"loops\",groupId:null})})}\n ${Ut({entries:()=>mn(),labels:()=>an(),onOpenLoop:(x,P)=>H({projectId:x,loopName:P,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})})}\n
`;return h`
\n ${$i}\n ${li}\n ${wi}\n ${di}\n ${xi}\n ${Oi}\n ${gi}\n
`}}\n
`}}\n
`}var pt=document.getElementById(\"forge-app-root\");if(pt)jf(mt,pt);\n"; +export const DASHBOARD_APP_BUNDLE: string = "var F={context:void 0,registry:void 0,effects:void 0,done:!1,getContextId(){return pn(this.context.count)},getNextContextId(){return pn(this.context.count++)}};function pn(e){let t=String(e),n=t.length-1;return F.context.id+(n?String.fromCharCode(96+n):\"\")+t}function wt(e){F.context=e}function $o(){return{...F.context,id:F.getNextContextId(),count:0}}var xo=!0,So=(e,t)=>e===t,oe=Symbol(\"solid-proxy\"),ko=typeof Proxy===\"function\",xt=Symbol(\"solid-track\"),yn=Symbol(\"solid-dev-component\"),et={equals:So},gn=null,wn=Tn,te=1,Oe=2,Lo={};var D=null,x=null,_e=null,Se=null,R=null,H=null,K=null,nt=0,re={afterUpdate:null,afterCreateOwner:null,afterCreateSignal:null,afterRegisterGraph:null};function $n(e,t){let n=R,s=D,o=e.length===0,r=t===void 0?s:t,a=o?{owned:null,cleanups:null,context:null,owner:null}:{owned:null,cleanups:null,context:r?r.context:null,owner:r},i=o?()=>e(()=>{throw Error(\"Dispose method must be an explicit argument to createRoot function\")}):()=>e(()=>z(()=>ge(a)));re.afterCreateOwner&&re.afterCreateOwner(a),D=a,R=null;try{return ae(i,!0)}finally{R=n,D=s}}function L(e,t){t=t?Object.assign({},et,t):et;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(kn(n),re.afterCreateSignal)re.afterCreateSignal(n)}let s=(o)=>{if(typeof o===\"function\")if(x&&x.running&&x.sources.has(n))o=o(n.tValue);else o=o(n.value);return kt(n,o)};return[Ln.bind(n),s]}function ce(e,t,n){let s=rt(e,t,!1,te,n);if(_e&&x&&x.running)H.push(s);else ke(s)}function G(e,t,n){wn=Co;let s=rt(e,t,!1,te,n),o=$t&&Ao($t);if(o)s.suspense=o;if(!n||!n.render)s.user=!0;K?K.push(s):ke(s)}function b(e,t,n){n=n?Object.assign({},et,n):et;let s=rt(e,t,!0,0,n);if(s.observers=null,s.observerSlots=null,s.comparator=n.equals||void 0,_e&&x&&x.running)s.tState=te,H.push(s);else ke(s);return Ln.bind(s)}function Ue(e){return ae(e,!1)}function z(e){if(!Se&&R===null)return e();let t=R;R=null;try{if(Se)return Se.untrack(e);return e()}finally{R=t}}function xn(e,t,n){let s=Array.isArray(e),o,r=n&&n.defer;return(a)=>{let i;if(s){i=Array(e.length);for(let u=0;ut(i,o,a));return o=i,l}}function Sn(e){G(()=>z(e))}function fe(e){if(D===null)console.warn(\"cleanups created outside a `createRoot` or `render` will never be run\");else if(D.cleanups===null)D.cleanups=[e];else D.cleanups.push(e);return e}function st(){return R}function ot(){return D}function St(e,t){let n=D,s=R;D=e,R=null;try{return ae(t,!0)}catch(o){at(o)}finally{D=n,R=s}}function To(e){if(x&&x.running)return e(),x.done;let t=R,n=D;return Promise.resolve().then(()=>{R=t,D=n;let s;if(_e||$t)s=x||(x={sources:new Set,effects:[],promises:new Set,disposed:new Set,queue:new Set,running:!0}),s.done||(s.done=new Promise((o)=>s.resolve=o)),s.running=!0;return ae(e,!1),R=D=null,s?s.done:void 0})}var[ca,mn]=L(!1);function hn(e,t){let n=rt(()=>z(()=>(Object.assign(e,{[yn]:!0}),e(t))),void 0,!0,0);return n.props=t,n.observers=null,n.observerSlots=null,n.name=e.name,n.component=e,ke(n),n.tValue!==void 0?n.tValue:n.value}function kn(e){if(D){if(D.sourceMap)D.sourceMap.push(e);else D.sourceMap=[e];e.graph=D}if(re.afterRegisterGraph)re.afterRegisterGraph(e)}function Ao(e){let t;return D&&D.context&&(t=D.context[e.id])!==void 0?t:e.defaultValue}var $t;function Ln(){let e=x&&x.running;if(this.sources&&(e?this.tState:this.state))if((e?this.tState:this.state)===te)ke(this);else{let t=H;H=null,ae(()=>tt(this),!1),H=t}if(R){let t=this.observers?this.observers.length:0;if(!R.sources)R.sources=[this],R.sourceSlots=[t];else R.sources.push(this),R.sourceSlots.push(t);if(!this.observers)this.observers=[R],this.observerSlots=[R.sources.length-1];else this.observers.push(R),this.observerSlots.push(R.sources.length-1)}if(e&&x.sources.has(this))return this.tValue;return this.value}function kt(e,t,n){let s=x&&x.running&&x.sources.has(e)?e.tValue:e.value;if(!e.comparator||!e.comparator(s,t)){if(x){let o=x.running;if(o||!n&&x.sources.has(e))x.sources.add(e),e.tValue=t;if(!o)e.value=t}else e.value=t;if(e.observers&&e.observers.length)ae(()=>{for(let o=0;o1e6){if(H=[],xo)throw Error(\"Potential Infinite Loop Detected.\");throw Error()}},!1)}return t}function ke(e){if(!e.fn)return;ge(e);let t=nt;if(bn(e,x&&x.running&&x.sources.has(e)?e.tValue:e.value,t),x&&!x.running&&x.sources.has(e))queueMicrotask(()=>{ae(()=>{x&&(x.running=!0),R=D=e,bn(e,e.tValue,t),R=D=null},!1)})}function bn(e,t,n){let s,o=D,r=R;R=D=e;try{s=e.fn(t)}catch(a){if(e.pure)if(x&&x.running)e.tState=te,e.tOwned&&e.tOwned.forEach(ge),e.tOwned=void 0;else e.state=te,e.owned&&e.owned.forEach(ge),e.owned=null;return e.updatedAt=n+1,at(a)}finally{R=r,D=o}if(!e.updatedAt||e.updatedAt<=n){if(e.updatedAt!=null&&\"observers\"in e)kt(e,s,!0);else if(x&&x.running&&e.pure){if(!x.sources.has(e))e.value=s;x.sources.add(e),e.tValue=s}else e.value=s;e.updatedAt=n}}function rt(e,t,n,s=te,o){let r={fn:e,state:s,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:D,context:D?D.context:null,pure:n};if(x&&x.running)r.state=0,r.tState=s;if(D===null)console.warn(\"computations created outside a `createRoot` or `render` will never be disposed\");else if(D!==Lo)if(x&&x.running&&D.pure)if(!D.tOwned)D.tOwned=[r];else D.tOwned.push(r);else if(!D.owned)D.owned=[r];else D.owned.push(r);if(o&&o.name)r.name=o.name;if(Se&&r.fn){let a=r.fn,[i,l]=L(void 0,{equals:!1}),u=Se.factory(a,l);fe(()=>u.dispose());let f,h=()=>To(l).then(()=>{if(f)f.dispose(),f=void 0});r.fn=(y)=>{if(i(),x&&x.running){if(!f)f=Se.factory(a,h);return f.track(y)}return u.track(y)}}return re.afterCreateOwner&&re.afterCreateOwner(r),r}function je(e){let t=x&&x.running;if((t?e.tState:e.state)===0)return;if((t?e.tState:e.state)===Oe)return tt(e);if(e.suspense&&z(e.suspense.inFallback))return e.suspense.effects.push(e);let n=[e];while((e=e.owner)&&(!e.updatedAt||e.updatedAt=0;s--){if(e=n[s],t){let o=e,r=n[s+1];while((o=o.owner)&&o!==r)if(x.disposed.has(o))return}if((t?e.tState:e.state)===te)ke(e);else if((t?e.tState:e.state)===Oe){let o=H;H=null,ae(()=>tt(e,n[0]),!1),H=o}}}function ae(e,t){if(H)return e();let n=!1;if(!t)H=[];if(K)n=!0;else K=[];nt++;try{let s=e();return Mo(n),s}catch(s){if(!n)K=null;H=null,at(s)}}function Mo(e){if(H){if(_e&&x&&x.running)Eo(H);else Tn(H);H=null}if(e)return;let t;if(x){if(!x.promises.size&&!x.queue.size){let{sources:s,disposed:o}=x;K.push.apply(K,x.effects),t=x.resolve;for(let r of K)\"tState\"in r&&(r.state=r.tState),delete r.tState;x=null,ae(()=>{for(let r of o)ge(r);for(let r of s){if(r.value=r.tValue,r.owned)for(let a=0,i=r.owned.length;awn(n),!1);else re.afterUpdate&&re.afterUpdate();if(t)t()}function Tn(e){for(let t=0;t{s.delete(n),ae(()=>{x.running=!0,je(n)},!1),x&&(x.running=!1)})}}function Co(e){let t,n=0;for(t=0;t=0;t--)ge(e.tOwned[t]);delete e.tOwned}if(x&&x.running&&e.pure)Mn(e,!0);else if(e.owned){for(t=e.owned.length-1;t>=0;t--)ge(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(x&&x.running)e.tState=0;else e.state=0;delete e.sourceMap}function Mn(e,t){if(!t)e.tState=0,x.disposed.add(e);if(e.owned)for(let n=0;n=0;i--){let l=yt(e[i])[a];if(l!==void 0)return l}},has(a){for(let i=e.length-1;i>=0;i--)if(a in yt(e[i]))return!0;return!1},keys(){let a=[];for(let i=0;i=0;a--){let i=e[a];if(!i)continue;let l=Object.getOwnPropertyNames(i);for(let u=l.length-1;u>=0;u--){let f=l[u];if(f===\"__proto__\"||f===\"constructor\")continue;let h=Object.getOwnPropertyDescriptor(i,f);if(!s[f])s[f]=h.get?{enumerable:!0,configurable:!0,get:Io.bind(n[f]=[h.get.bind(i)])}:h.value!==void 0?h:void 0;else{let y=n[f];if(y){if(h.get)y.push(h.get.bind(i));else if(h.value!==void 0)y.push(()=>h.value)}}}}let o={},r=Object.keys(s);for(let a=r.length-1;a>=0;a--){let i=r[a],l=s[i];if(l&&l.get)Object.defineProperty(o,i,l);else o[i]=l?l.value:void 0}return o}var En={hooks:re,writeSignal:kt,registerGraph:kn};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 Ro=[\"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\"],Mt=new Set([\"className\",\"value\",\"readOnly\",\"noValidate\",\"formNoValidate\",\"isMap\",\"noModule\",\"playsInline\",\"adAuctionHeaders\",\"allowFullscreen\",\"browsingTopics\",\"defaultChecked\",\"defaultMuted\",\"defaultSelected\",\"disablePictureInPicture\",\"disableRemotePlayback\",\"preservesPitch\",\"shadowRootClonable\",\"shadowRootCustomElementRegistry\",\"shadowRootDelegatesFocus\",\"shadowRootSerializable\",\"sharedStorageWritable\",...Ro]),Et=new Set([\"innerHTML\",\"textContent\",\"innerText\",\"children\"]),Ct=Object.assign(Object.create(null),{className:\"class\",htmlFor:\"for\"}),Oo=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 Nt(e,t){let n=Oo[e];return typeof n===\"object\"?n[t]?n.$:void 0:n}var Dt=new Set([\"beforeinput\",\"click\",\"dblclick\",\"contextmenu\",\"focusin\",\"focusout\",\"input\",\"keydown\",\"keyup\",\"mousedown\",\"mousemove\",\"mouseout\",\"mouseover\",\"mouseup\",\"pointerdown\",\"pointermove\",\"pointerout\",\"pointerover\",\"pointerup\",\"touchend\",\"touchmove\",\"touchstart\"]),In=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\"]),Pt={xlink:\"http://www.w3.org/1999/xlink\",xml:\"http://www.w3.org/XML/1998/namespace\"};function jo(e,t,n){let s=n.length,o=t.length,r=s,a=0,i=0,l=t[o-1].nextSibling,u=null;while(af-i){let c=t[a];while(i{o=r,t===document?e():Ut(t,e(),t.firstChild?null:void 0,n)},s.owner),()=>{o(),t.textContent=\"\"}}function It(e,t=window.document){let n=t[Cn]||(t[Cn]=new Set);for(let s=0,o=e.length;so.call(e,n[1],r))}else e.addEventListener(t,n,typeof n!==\"function\"&&n)}function jt(e,t,n={}){let s=Object.keys(t||{}),o=Object.keys(n),r,a;for(r=0,a=o.length;ro.children=Te(e,t.children,o.children));return ce(()=>typeof t.ref===\"function\"&&Fo(t.ref,e)),ce(()=>Bo(e,t,n,!0,o,!0)),o}function jn(e,t){let n=e[t];return Object.defineProperty(e,t,{get(){return n()},enumerable:!0}),e}function Fo(e,t,n){return z(()=>e(t,n))}function Ut(e,t,n,s){if(n!==void 0&&!s)s=[];if(typeof t!==\"function\")return Te(e,t,s,n);ce((o)=>Te(e,t(),o,n),s)}function Bo(e,t,n,s,o={},r=!1){t||(t={});for(let a in o)if(!(a in t)){if(a===\"children\")continue;o[a]=Dn(e,a,null,o[a],n,r,t)}for(let a in t){if(a===\"children\"){if(!s)Te(e,t.children);continue}let i=t[a];o[a]=Dn(e,a,i,o[a],n,r,t)}}function Ae(e){return!!F.context&&!F.done&&(!e||e.isConnected)}function Vo(e){return e.toLowerCase().replace(/-([a-z])/g,(t,n)=>n.toUpperCase())}function Nn(e,t,n){let s=t.trim().split(/\\s+/);for(let o=0,r=s.length;o-1&&Pt[t.split(\":\")[0]];if(y)Rt(e,y,t,n);else Fe(e,Ct[t]||t,n)}return n}function Ho(e){if(F.registry&&F.events){if(F.events.find(([l,u])=>u===e))return}let t=e.target,n=`$$${e.type}`,s=e.target,o=e.currentTarget,r=(l)=>Object.defineProperty(e,\"target\",{configurable:!0,value:l}),a=()=>{let l=t[n];if(l&&!t.disabled){let u=t[`${n}Data`];if(u!==void 0?l.call(t,u,e):l.call(t,e),e.cancelBubble)return}return t.host&&typeof t.host!==\"string\"&&!t.host._$host&&t.contains(e.target)&&r(t.host),!0},i=()=>{while(a()&&(t=t._$host||t.parentNode||t.host));};if(Object.defineProperty(e,\"currentTarget\",{configurable:!0,get(){return t||document}}),F.registry&&!F.done)F.done=_$HY.done=!0;if(e.composedPath){let l=e.composedPath();r(l[0]);for(let u=0;u{let l=t();while(typeof l===\"function\")l=l();n=Te(e,l,n,s)}),()=>n;else if(Array.isArray(t)){let l=[],u=n&&Array.isArray(n);if(At(l,t,n,o))return ce(()=>n=Te(e,l,n,s,!0)),()=>n;if(r){if(!l.length)return n;if(s===void 0)return n=[...e.childNodes];let f=l[0];if(f.parentNode!==e)return n;let h=[f];while((f=f.nextSibling)!==s)h.push(f);return n=h}if(l.length===0){if(n=Le(e,n,s),i)return n}else if(u)if(n.length===0)Pn(e,l,s);else jo(e,n,l);else n&&Le(e),Pn(e,l);n=l}else if(t.nodeType){if(r&&t.parentNode)return n=i?[t]:t;if(Array.isArray(n)){if(i)return n=Le(e,n,s,t);Le(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 At(e,t,n,s){let o=!1;for(let r=0,a=t.length;r=0;a--){let i=t[a];if(o!==i){let l=i.parentNode===e;if(!r&&!a)l?e.replaceChild(o,i):e.insertBefore(o,n);else l&&i.remove()}else r=!0}}else e.insertBefore(o,n);return[o]}var Sa=Symbol();var Ft=Symbol(\"store-raw\"),Ee=Symbol(\"store-node\"),pe=Symbol(\"store-has\"),Un=Symbol(\"store-self\"),_n={onStoreNodeUpdate:null};function Fn(e){let t=e[oe];if(!t){if(Object.defineProperty(e,oe,{value:t=new Proxy(e,Wo)}),!Array.isArray(e)){let n=Object.keys(e),s=Object.getOwnPropertyDescriptors(e);for(let o=0,r=n.length;oe[oe][t],n}function Bn(e){st()&&Ve(it(e,Ee),Un)()}function Go(e){return Bn(e),Reflect.ownKeys(e)}var Wo={get(e,t,n){if(t===Ft)return e;if(t===oe)return n;if(t===xt)return Bn(e),n;let s=it(e,Ee),o=s[t],r=o?o():e[t];if(t===Ee||t===pe||t===\"__proto__\")return r;if(!o){let a=Object.getOwnPropertyDescriptor(e,t);if(st()&&(typeof r!==\"function\"||e.hasOwnProperty(t))&&!(a&&a.get))r=Ve(s,t,r)()}return me(r)?Fn(r):r},has(e,t){if(t===Ft||t===oe||t===xt||t===Ee||t===pe||t===\"__proto__\")return!0;return st()&&Ve(it(e,pe),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:Go,getOwnPropertyDescriptor:qo};function ne(e,t,n,s=!1){if(!s&&e[t]===n)return;let o=e[t],r=e.length;if(_n.onStoreNodeUpdate&&_n.onStoreNodeUpdate(e,t,n,o),n===void 0){if(delete e[t],e[pe]&&e[pe][t]&&o!==void 0)e[pe][t].$()}else if(e[t]=n,e[pe]&&e[pe][t]&&o===void 0)e[pe][t].$();let a=it(e,Ee),i;if(i=Ve(a,t,o))i.$(()=>n);if(Array.isArray(e)&&e.length!==r){for(let l=e.length;l1){s=t.shift();let a=typeof s,i=Array.isArray(e);if(Array.isArray(s)){for(let l=0;l1){Be(e[s],t,[s].concat(n));return}o=e[s],n=[s].concat(n)}let r=t[0];if(typeof r===\"function\"){if(r=r(o,n),r===o)return}if(s===void 0&&r==null)return;if(r=Ce(r),s===void 0||me(o)&&me(r)&&!Array.isArray(r))Vn(o,r);else ne(e,s,r)}function Hn(...[e,t]){let n=Ce(e||{}),s=Array.isArray(n);if(typeof n!==\"object\"&&typeof n!==\"function\")throw Error(`Unexpected type ${typeof n} received when initializing 'createStore'. Expected an object.`);let o=Fn(n);En.registerGraph({value:n,name:t&&t.name});function r(...a){Ue(()=>{s&&a.length===1?Ko(n,a[0]):Be(n,a)})}return[o,r]}var Bt=Symbol(\"store-root\");function Me(e,t,n,s,o){let r=t[n];if(e===r)return;let a=Array.isArray(e);if(n!==Bt&&(!me(e)||!me(r)||a!==Array.isArray(r)||o&&e[o]!==r[o])){ne(t,n,e);return}if(a){if(e.length&&r.length&&(!s||o&&e[0]&&e[0][o]!=null)){let u,f,h,y,p,c,g,v;for(h=0,y=Math.min(r.length,e.length);h=h&&p>=h&&(r[y]===e[p]||o&&r[y]&&e[p]&&r[y][o]&&r[y][o]===e[p][o]);y--,p--)S[p]=r[y];if(h>p||h>y){for(f=h;f<=p;f++)ne(r,f,e[f]);for(;fe.length)ne(r,\"length\",e.length);return}g=Array(p+1);for(f=p;f>=h;f--)c=e[f],v=o&&c?c[o]:c,u=M.get(v),g[f]=u===void 0?-1:u,M.set(v,f);for(u=h;u<=y;u++)if(c=r[u],v=o&&c?c[o]:c,f=M.get(v),f!==void 0&&f!==-1)S[f]=r[u],f=g[f],M.set(v,f);for(f=h;fe.length)ne(r,\"length\",e.length);return}let i=Object.keys(e);for(let u=0,f=i.length;u{if(!me(r)||!me(o))return o;let a=Me(o,{[Bt]:r},Bt,n,s);return a===void 0?r:a}}var zo=/(?:|<(?:\"[^\"]*\"['\"]*|'[^']*'['\"]*|[^'\">])+>)/g,Yo=/(?:\\s(?[^/\\s><=]+?)(?=[\\s/>]))|(?:(?\\S+?)(?:\\s*=\\s*(?:(['\"])(?[\\s\\S]*?)\\3|(?[^\\s>]+))))/g,Xo={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 Qo(e){let t={type:\"tag\",name:\"\",voidElement:!1,attrs:[],children:[]},n=e.match(/<\\/?([^\\s]+?)[/\\s>]/);if(n){if(t.name=n[1],Xo[n[1].toLowerCase()]||e.charAt(e.length-2)===\"/\")t.voidElement=!0;if(t.name.startsWith(\"!--\")){let o=e.indexOf(\"-->\");return{type:\"comment\",comment:o!==-1?e.slice(4,o):\"\"}}}let s=new RegExp(Yo);for(let o of e.matchAll(s))if((o[1]||o[2]).startsWith(\"use:\"))t.attrs.push({type:\"directive\",name:o[1]||o[2],value:o[4]||o[5]||\"\"});else t.attrs.push({type:\"attr\",name:o[1]||o[2],value:o[4]||o[5]||\"\"});return t}function Gn(e,t,n){let s=t.indexOf(\"<\",n),o=t.slice(n,s===-1?void 0:s);if(!/^\\s*$/.test(o))e.push({type:\"text\",content:o})}function Wn(e,t){let n=t.replace(\"\",\"\");if(!/^\\s*$/.test(n))e.push({type:\"comment\",content:n})}function Jo(e){let t=[],n=void 0,s=-1,o=[],r={};return e.replace(zo,(a,i)=>{let l=a.charAt(1)!==\"/\",u=a.slice(0,4)===\"\"}}function er(e){return e.reduce(function(t,n){return t+Yn(\"\",n)},\"\")}var Kn=new Map,tr=/^(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$/i,lt=\" \\\\f\\\\n\\\\r\\\\t\",Xn=\"[^\"+lt+`\\\\/>\"'=]+`,Ht=\"[ \"+lt+\"]+(?:use:|\"+Xn+\")\",Qn=\"<([A-Za-z$#]+[A-Za-z0-9:_-]*)((?:\",Jn=`(?:\\\\s*=\\\\s*(?:'[^']*?'|\"[^\"]*?\"|\\\\([^)]*?\\\\)|<[^>]*?>|`+Xn+\"))?)\",nr=new RegExp(Qn+Ht+Jn+\"+)([ \"+lt+\"]*/?>)\",\"g\"),sr=new RegExp(\"(\"+Ht+`\\\\s*=\\\\s*)(|['\"(]([\\\\w\\\\s]*[\\\\w\\\\s]*)*['\")])`,\"gi\"),or=new RegExp(Qn+Ht+Jn+\"*)([ \"+lt+\"]*/>)\",\"g\"),zn=\"\",rr=new Set([\"class\",\"on\",\"oncapture\",\"style\",\"use\",\"prop\",\"attr\"]);function ar(e,t,n,s){return\"<\"+t+n.replace(sr,ir)+s}function ir(e,t,n){return t.replace(//g,\"###\")+(n[0]==='\"'||n[0]===\"'\"?n.replace(//g,\"###\"):'\"###\"')}function lr(e,t,n){return tr.test(t)?e:\"<\"+t+n+\">\"}function cr(e){return e.toLowerCase().replace(/-([a-z])/g,(t,n)=>n.toUpperCase())}function Vt(e,t,n,s){if(e===\"use:###\"&&t===\"###\"){let o=s.counter++;s.exprs.push(`typeof exprs[${o}] === \"function\" ? r.use(exprs[${o}], ${n}, exprs[${s.counter++}]) : (()=>{throw new Error(\"use:### must be a function\")})()`)}else throw Error(`Not support syntax ${e} must be use:{function}`)}function ur(e,{delegateEvents:t=!0,functionBuilder:n=(...s)=>Function(...s)}={}){let s=1;e.wrapProps=(p)=>{let c=Object.getOwnPropertyDescriptors(p);for(let g in c)if(typeof c[g].value===\"function\"&&!c[g].value.length)e.dynamicProperty(p,g);return p};function o(p,c){let g=0,v=\"\";for(;g\";v=v+p[g],v=[[or,lr],[/<()/g,\"<###\"],[/\\.\\.\\.()/g,\"###\"],[nr,ar],[/>\\n+\\s*/g,\">\"],[/\\n+\\s*\\s+/g,\"> \"]].reduce((P,I)=>P.replace(I[0],I[1]),v);let M=Jo(v),[T,k]=h(M,c.funcBuilder),A=[];for(let P=0;P-1){let J=O.split(zn).reduce((Y,C,j)=>(j&&Y.push(\"\"),Y.push(C),Y),[]);I[P].firstChild.replaceWith(...J)}}}return A[0].create=k,Kn.set(p,A),A}function r(p,c,g,v,S,M,T){let k=v===\"###\"?`!doNotWrap ? exprs[${T.counter}]() : exprs[${T.counter++}]`:v.split(\"###\").map((O,J)=>J?` + (typeof exprs[${T.counter}] === \"function\" ? exprs[${T.counter}]() : exprs[${T.counter++}]) + \"${O}\"`:`\"${O}\"`).join(\"\"),A,P;if((A=g.split(\":\"))&&A[1]&&rr.has(A[0]))g=A[1],P=A[0];let I=e.ChildProperties.has(g),V=e.Properties.has(g);if(g===\"style\"){let O=`_$v${s++}`;T.decl.push(`${O}={}`),T.exprs.push(`r.style(${c},${k},${O})`)}else if(g===\"classList\"){let O=`_$v${s++}`;T.decl.push(`${O}={}`),T.exprs.push(`r.classList(${c},${k},${O})`)}else if(P!==\"attr\"&&(I||!S&&(e.getPropAlias(g,p.name.toUpperCase())||V)||M||P===\"prop\")){if(M&&!I&&!V&&P!==\"prop\")g=cr(g);T.exprs.push(`${c}.${e.getPropAlias(g,p.name.toUpperCase())||g} = ${k}`)}else{let O=S&&g.indexOf(\":\")>-1&&e.SVGNamespace[g.split(\":\")[0]];if(O)T.exprs.push(`r.setAttributeNS(${c},\"${O}\",\"${g}\",${k})`);else T.exprs.push(`r.setAttribute(${c},\"${e.Aliases[g]||g}\",${k})`)}}function a(p,c,g,v,S,M,T){if(g.slice(0,2)===\"on\")if(!g.includes(\":\")){let k=g.slice(2).toLowerCase(),A=t&&e.DelegatedEvents.has(k);T.exprs.push(`r.addEventListener(${c},\"${k}\",exprs[${T.counter++}],${A})`),A&&T.delegatedEvents.add(k)}else{let k=g.startsWith(\"oncapture:\");T.exprs.push(`${c}.addEventListener(\"${g.slice(k?10:3)}\",exprs[${T.counter++}]${k?\",true\":\"\"})`)}else if(g===\"ref\")T.exprs.push(`exprs[${T.counter++}](${c})`);else{let k=Object.assign({},T,{exprs:[]}),A=T.counter;if(r(p,c,g,v,S,M,k),T.decl.push(`_fn${A} = (${v===\"###\"?\"doNotWrap\":\"\"}) => {\n${k.exprs.join(`;\n`)};\n}`),v===\"###\")T.exprs.push(`typeof exprs[${A}] === \"function\" ? r.effect(_fn${A}) : _fn${A}(true)`);else{let P=\"\";for(let I=A;I1)for(let S=0;S1?`r.mergeProps(${c.join(\",\")})`:c[0]}function u(p,c){let g=[],v=Object.keys(p.attrs),S=[g],M=c.counter++;for(let k=0;k exprs[${c.counter++}]`);else if(p.children.length){let k={type:\"fragment\",children:p.children},A=Object.assign({},c,{first:!0,decl:[],exprs:[],parent:!1});f(k,A),g.push(`children: () => { ${A.exprs.join(`;\n`)}}`),c.templateId=A.templateId,c.counter=A.counter}let T;if(c.multi)T=`_$el${s++}`,c.decl.push(`${T} = ${c.path}.${c.first?\"firstChild\":\"nextSibling\"}`);if(c.parent)c.exprs.push(`r.insert(${c.parent}, r.createComponent(exprs[${M}],${l(S)})${T?`, ${T}`:\"\"})`);else c.exprs.push(`${c.fragment?\"\":\"return \"}r.createComponent(exprs[${M}],${l(S)})`);c.path=T,c.first=!1}function f(p,c){if(p.type===\"fragment\"){let g=[];p.children.forEach((v)=>{if(v.type===\"tag\"){if(v.name===\"###\"){let T=Object.assign({},c,{first:!0,fragment:!0,decl:[],exprs:[]});u(v,T),g.push(T.exprs[0]),c.counter=T.counter,c.templateId=T.templateId;return}c.templateId++;let S=s,M=Object.assign({},c,{first:!0,decl:[],exprs:[]});c.templateNodes.push([v]),f(v,M),g.push(`function() { ${M.decl.join(`,\n`)+`;\n`+M.exprs.join(`;\n`)+`;\nreturn _$el${S};\n`}}()`),c.counter=M.counter,c.templateId=M.templateId}else if(v.type===\"text\")g.push(`\"${v.content}\"`);else if(v.type===\"comment\"){if(v.content===\"#\")g.push(`exprs[${c.counter++}]`);else if(v.content)for(let S=0;Sk.name===\"is\");if(c.hasCustomElement=T,c.isImportNode=(p.name===\"img\"||p.name===\"iframe\")&&p.attrs.some((k)=>k.name===\"loading\"&&k.value===\"lazy\"),p.attrs.some((k)=>k.name===\"###\")){let k=[],A=\"\",P=[];for(let I=0;I({${A}})`),A=\"\";k.push(`exprs[${c.counter++}]`)}else P.push(p.attrs[I]);else if(V===\"directive\")Vt(O,J,g,c)}if(p.attrs=P,A.length)k.push(`()=>({${A}})`);c.exprs.push(`r.spread(${g},${k.length===1?`typeof ${k[0]} === \"function\" ? r.mergeProps(${k[0]}) : ${k[0]}`:`r.mergeProps(${k.join(\",\")})`},${M},${!!p.children.length})`)}else for(let k=0;k document.importNode(tmpls[${S}].content.firstChild, true))`:`const ${g} = tmpls[${S}].content.firstChild.cloneNode(true)`}else if(p.type===\"text\"){let g=`_$el${s++}`;c.decl.push(`${g} = ${c.path}.${c.first?\"firstChild\":\"nextSibling\"}`),c.path=g,c.first=!1}else if(p.type===\"comment\"){let g=`_$el${s++}`;if(c.decl.push(`${g} = ${c.path}.${c.first?\"firstChild\":\"nextSibling\"}`),p.content===\"#\")if(c.multi)c.exprs.push(`r.insert(${c.parent}, exprs[${c.counter++}], ${g})`);else c.exprs.push(`r.insert(${c.parent}, exprs[${c.counter++}])`);c.path=g,c.first=!1}}function h(p,c){let g={path:\"\",decl:[],exprs:[],delegatedEvents:new Set,counter:0,first:!0,multi:!1,templateId:0,templateNodes:[]},v=s,S=p,M;if(p.length>1)p=[{type:\"fragment\",children:p}];if(p[0].name===\"###\")M=!0,u(p[0],g);else f(p[0],g);return e.delegateEvents(Array.from(g.delegatedEvents)),[[S].concat(g.templateNodes).map((k)=>er(k)),c(\"tmpls\",\"exprs\",\"r\",g.decl.join(`,\n`)+`;\n`+g.exprs.join(`;\n`)+(M?\"\":`;\nreturn _$el${v};\n`))]}function y(p,...c){let g=Kn.get(p)||o(p,{funcBuilder:n});return g[0].create(g,c,e)}return y}var d=ur({effect:ce,style:_t,insert:Ut,untrack:z,spread:On,createComponent:Lt,delegateEvents:It,classList:jt,mergeProps:Tt,dynamicProperty:jn,setAttribute:Fe,setAttributeNS:Rt,addEventListener:Ot,Aliases:Ct,getPropAlias:Nt,Properties:Mt,ChildProperties:Et,DelegatedEvents:Dt,SVGElements:In,SVGNamespace:Pt});function He(e){let t=Math.floor(e/3600),n=Math.floor(e%3600/60),s=e%60;if(t>0)return`${t}h ${n}m`;return n>0?`${n}m ${s}s`:`${s}s`}function ct(e,t){if(!e)return 0;let n=new Date(e).getTime(),s=t?new Date(t).getTime():Date.now();return Math.round((s-n)/1000)}function Zn(e){return e.toLowerCase().replace(/[^\\w\\s-]/g,\"\").trim().replace(/\\s+/g,\"-\").replace(/-+/g,\"-\")}var es=[\"code\",\"auditor\",\"unknown\"];var Gt=[\"overview\",\"live\",\"timeline\",\"sections\",\"findings\",\"plan\",\"usage\"],Wt=200,ns=300,ss=100;function ut(e,t,n){if(n||e.length<=t)return{rows:e,total:e.length,capped:!1};return{rows:e.slice(0,t),total:e.length,capped:!0}}function Kt(e,t){return e.length===t.length&&e.every((n,s)=>n===t[s])}var dr=new Set([\"loops\",\"groups\",\"findings\",\"plans\"]),fr=new Set(Gt);function ye(e){try{return decodeURIComponent(e)}catch{return e}}function pr(){return{projectId:null,section:\"loops\",loopName:null,tab:\"overview\",groupId:null,statuses:[],query:\"\"}}function qt(e,t){if(!t)return;for(let n of t.split(\"&\")){if(!n)continue;let s=n.indexOf(\"=\");if(s===-1)continue;let o=ye(n.slice(0,s)),r=ye(n.slice(s+1));if(o===\"status\")e.statuses=r.split(\",\").map((a)=>a.trim()).filter((a)=>a!==\"\");else if(o===\"q\")e.query=r}}function dt(e){let t=pr(),n=(e||\"\").replace(/^#/,\"\");if(!n)return t;let s=n.indexOf(\"?\"),o=s===-1?n:n.slice(0,s),r=s===-1?\"\":n.slice(s+1),a=o.indexOf(\"/\"),i,l;if(a===-1)i=o,l=\"\";else i=o.slice(0,a),l=o.slice(a+1);if(i)t.projectId=ye(i);if(!l)return qt(t,r),t;let u=l.split(\"/\").filter((h)=>h!==\"\");if(u.length===0)return qt(t,r),t;let f=ye(u[0]);if(f===\"loop\"){if(u.length>=2){if(t.loopName=ye(u[1]),u.length>=3){let h=ye(u[2]);if(fr.has(h))t.tab=h}}}else if(dr.has(f)){if(t.section=f,f===\"groups\"&&u.length>=2)t.groupId=ye(u[1])}else t.loopName=f;return qt(t,r),t}function ft(e){if(!e.projectId)return\"\";let t=\"#\"+encodeURIComponent(e.projectId);if(e.section===\"loops\"){if(e.loopName){if(t+=\"/loop/\"+encodeURIComponent(e.loopName),e.tab!==\"overview\")t+=\"/\"+e.tab}}else if(e.section===\"groups\"){if(t+=\"/groups\",e.groupId)t+=\"/\"+encodeURIComponent(e.groupId)}else t+=\"/\"+e.section;let n=[];if(e.statuses.length>0)n.push(\"status=\"+e.statuses.map((s)=>encodeURIComponent(s)).join(\",\"));if(e.query)n.push(\"q=\"+encodeURIComponent(e.query));if(n.length>0)t+=\"?\"+n.join(\"&\");return t}function os(e,t){let s=\"#\"+(location.hash||\"\").replace(/^#/,\"\"),o=\"#\"+e.replace(/^#/,\"\");if(s!==o)t.current=!0,location.hash=e}function Z(e){if(!e||e===0)return\"\";let t=new Date(e),n=(l)=>l<10?\"0\"+l:String(l),s=n(t.getMonth()+1),o=n(t.getDate()),r=t.getFullYear(),a=t.getHours(),i=a>=12?\"PM\":\"AM\";if(a=a%12,a===0)a=12;return s+\"-\"+o+\"-\"+r+\" \"+a+\":\"+n(t.getMinutes())+\" \"+i}function we(e){return\"status-badge status-\"+e}function zt(e){return\"section-status section-\"+e}function rs(e){return\"feature-stage feature-stage-\"+e}function as(e,t,n,s){if(!(t.size===0||t.has(e.status)))return!1;if(!n)return!0;let r=n.toLowerCase(),a=e.worktreeBranch??\"\";return((e.loopName||\"\")+\" \"+a+\" \"+(s||\"\")).toLowerCase().indexOf(r)!==-1}function is(e,t){if(t===\"recent\")return[...e];let n=[...e];if(t===\"cost\")n.sort((s,o)=>{let r=s.usage?.totalCost??null,a=o.usage?.totalCost??null;if(r===null&&a===null)return 0;if(r===null)return 1;if(a===null)return-1;return a-r});else if(t===\"duration\")n.sort((s,o)=>{let r=ct(s.loop.startedAt,s.loop.completedAt??void 0);return ct(o.loop.startedAt,o.loop.completedAt??void 0)-r});else if(t===\"findings\")n.sort((s,o)=>o.findings.length-s.findings.length);return n}function ls(e){return JSON.stringify(e.projects)}function Ne(e){return e.projectDir||e.projectId||\"\"}function De(e,t){let n=Ne(t);return e.get(n)??n}function Ge(e){return e.completedAt||e.startedAt||0}function cs(e){let t=new Map;for(let o of e){if(o===\"\"){t.set(o,[]);continue}let r=o.split(\"/\"),a=r.length;while(a>0&&r[a-1]===\"\")a--;t.set(o,a>0?r.slice(0,a):[o])}let n=new Map;for(let o of e){let r=t.get(o);n.set(o,r.length?r[r.length-1]:o)}let s=new Map;for(let o of e){let r=n.get(o),a=s.get(r);if(a)a.push(o);else s.set(r,[o])}for(let[,o]of s){let r=[...new Set(o)];if(r.length<2)continue;let a=1;while(a<1024){let i=new Set,l=!0,u=!0;for(let f of r){let h=t.get(f),y=Math.max(0,h.length-a),p=h.slice(y).join(\"/\");if(i.has(p))l=!1;else i.add(p);if(y>0)u=!1}if(l||u)break;a++}for(let i of r){let l=t.get(i),u=Math.max(0,l.length-a);n.set(i,l.slice(u).join(\"/\"))}}return n}function Yt(e){let t=[],n=[];for(let s of e)if(s.severity===\"bug\")t.push(s);else n.push(s);return{bugs:t,warnings:n}}function us(e){if(e.bugs.length>0)return\"bug\";if(e.warnings.length>0)return\"warn\";return\"clean\"}function Pe(e,t){return e+\" \"+t+(e===1?\"\":\"s\")}function se(e){return String(e+1)}function Xt(e,t){if(!t||t<=0)return 0;return Math.max(0,Math.min(100,e/t*100))}function ds(e){let t=e.file+\":\"+e.line+\" — \"+e.description;if(e.scenario)t+=\" (\"+e.scenario+\")\";return t}function fs(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 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 ps(e){let t=[{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)\"}],n=t.reduce((s,o)=>s+Math.max(0,o.value),0);return t.map((s)=>({...s,pct:n>0?Math.max(0,s.value)/n*100:0}))}var gr={code:\"execution\",auditor:\"auditor\",unknown:\"other\"};function gs(e){let n=es.map((o)=>{let r={role:gr[o],cost:0,messageCount:0,tokens:0,pct:0},a=e.byRole[o];if(!a)return r;return r.cost=a.cost,r.messageCount=a.messageCount,r.tokens=a.inputTokens+a.outputTokens+a.reasoningTokens,r}).filter((o)=>o.role!==\"other\"||o.cost>0||o.messageCount>0),s=n.reduce((o,r)=>Math.max(o,r.cost),0);for(let o of n)o.pct=s>0?o.cost/s*100:0;return n}function ms(e){let t=Object.keys(e.byModel).map((s)=>({model:s,...e.byModel[s]})),n=t.reduce((s,o)=>Math.max(s,o.cost),0);return t.sort((s,o)=>o.cost-s.cost).map((s)=>({model:s.model,cost:s.cost,inputTokens:s.inputTokens,outputTokens:s.outputTokens,messageCount:s.messageCount,pct:n>0?s.cost/n*100:0}))}function hs(e,t){if(!e)return\"\";let n=ct(e,t??void 0);return n>0?He(n):\"\"}function Qt(e){return e>=1000?He(Math.floor(e/1000)):e+\"ms\"}function he(e){if(!e||e===0)return\"\";let t=Date.now()-e,n=Math.floor(t/1000);if(n<60)return n+\"s ago\";let s=Math.floor(n/60);if(s<60)return s+\"m ago\";let o=Math.floor(s/60);if(o<24)return o+\"h ago\";let r=Math.floor(o/24);if(r<30)return r+\"d ago\";return Z(e)}var mr=/([\\s\\S]*?)<\\/h\\1>/g;function bs(e){return e.replace(/<[^>]+>/g,\"\").replace(/&[a-z#0-9]+;/gi,\"\").trim()}function hr(e){return Zn(bs(e)).replace(/[_-]+/g,\"-\").replace(/^-+|-+$/g,\"\")||\"heading\"}function br(e){if(!//.test(e))return{html:e,outline:[]};let t=[];return{html:e.replace(mr,(s,o,r)=>{let a=Number(o),i=hr(r);return t.push({depth:a,id:i,text:bs(r)}),`${r}`}),outline:t}}function vs(e){if(!e)return{html:\"\",outline:[]};let t=qe.get(e);if(t!==void 0)return t;let n=globalThis.marked;if(!n)return{html:\"\",outline:[]};let{html:s,outline:o}=br(n.parse(e)),r={html:s,outline:o};if(qe.size>=vr){let a=qe.keys().next().value;if(a!==void 0)qe.delete(a)}return qe.set(e,r),r}function ys(e){return vs(e).html}function ws(e){return vs(e)}var qe=new Map,vr=200;function pt(e){let t=[\"overview\"];if(e.loop.status===\"running\")t.push(\"live\");if(t.push(\"timeline\"),e.sectionCount>0)t.push(\"sections\");if(t.push(\"findings\"),e.hasPlan)t.push(\"plan\");return t.push(\"usage\"),t}var ts=4000;function yr(e){let t=B(e?.title);if(t)return t;let n=ie(e?.input);if(!n)return;let s=B(n.command)??B(n.filePath)??B(n.pattern)??B(n.path);if(s)return s;let o=Object.keys(n);return o.length>0?`${o.length} argument${o.length===1?\"\":\"s\"}`:void 0}function wr(e){let t=B(e?.output)??B(e?.error);if(!t)return;return t.length>ts?`…${t.slice(-ts)}`:t}function ie(e){return e&&typeof e===\"object\"&&!Array.isArray(e)?e:null}function B(e){return typeof e===\"string\"?e:void 0}function $s(e){let t=ie(e);if(!t)return null;let n=B(t.id),s=B(t.messageID),o=B(t.type);if(!n||!s||!o)return null;if(o===\"text\"){let r=B(t.text)??\"\";if(t.synthetic===!0)return null;return{id:n,messageID:s,type:o,text:r}}if(o===\"tool\"){let r=ie(t.state);return{id:n,messageID:s,type:o,tool:B(t.tool)??\"tool\",status:B(r?.status)??\"pending\",title:yr(r),output:wr(r)}}return null}function xs(e){if(!Array.isArray(e))return[];let t=[];for(let n of e){let s=ie(n),o=ie(s?.info),r=B(o?.id),a=B(o?.role);if(!r||!a)continue;let i=[],l=s?.parts;if(Array.isArray(l))for(let u of l){let f=$s(u);if(f){let{messageID:h,...y}=f;i.push(y)}}t.push({id:r,role:a,parts:i})}return t}function Ss(e,t){let n=ie(t),s=B(n?.type),o=ie(n?.properties);if(!s||!o)return e;if(s===\"message.updated\"){let r=ie(o.info),a=B(r?.id),i=B(r?.role);if(!a||!i)return e;let l=e.findIndex((f)=>f.id===a);if(l===-1)return[...e,{id:a,role:i,parts:[]}];if(e[l].role===i)return e;let u=[...e];return u[l]={...u[l],role:i},u}if(s===\"message.removed\"){let r=B(o.messageID);if(!r)return e;let a=e.filter((i)=>i.id!==r);return a.length===e.length?e:a}if(s===\"message.part.updated\"){let r=$s(o.part);if(!r)return e;let{messageID:a,...i}=r,l=e.findIndex((p)=>p.id===a);if(l===-1)return[...e,{id:a,role:\"assistant\",parts:[i]}];let u=e[l],f=u.parts.findIndex((p)=>p.id===i.id),h=f===-1?[...u.parts,i]:u.parts.map((p,c)=>c===f?i:p),y=[...e];return y[l]={...u,parts:h},y}if(s===\"message.part.removed\"){let r=B(o.messageID),a=B(o.partID);if(!r||!a)return e;let i=e.findIndex((h)=>h.id===r);if(i===-1)return e;let l=e[i],u=l.parts.filter((h)=>h.id!==a);if(u.length===l.parts.length)return e;let f=[...e];return f[i]={...l,parts:u},f}return e}function ks(e){let t=ie(e),n=B(t?.type),s=ie(t?.properties);if(n===\"session.idle\")return\"idle\";if(n===\"session.status\"){let o=ie(s?.status);return B(o?.type)??null}return null}function Ls(e,t,n,s,o=\"coding\"){let r=[],a=e.findIndex((h)=>h.createdAt>=t),i=a===-1?[]:e.slice(a);if(n!==null)i=i.filter((h)=>h.createdAt<=n);let l;if(i.length>0&&i[0].eventType===\"restart\"&&i[0].toPhase!==null)l=i[0].toPhase;else if(i.length>0&&i[0].eventType!==\"restart\")l=i[0].fromPhase;else l=o;let u=t;for(let h of i){if(h.eventType===\"restart\"){if(h.toPhase!==null)l=h.toPhase;continue}if(l===\"\")l=h.fromPhase,u=Math.max(u,h.createdAt);let y=h.createdAt,p=Math.max(u,y);if(r.push({phase:l,startedAt:u,endedAt:p,durationMs:p-u,open:!1}),h.toPhase===null)l=\"\",u=p;else l=h.toPhase,u=p}if(l!==\"\"){let y=Math.max(u,n??s);r.push({phase:l,startedAt:u,endedAt:n,durationMs:y-u,open:n===null})}let f=e.length>0&&e[0].createdAt>=t&&e[0].createdAt>t+1000;if(f&&r.length>0)r[0]={...r[0],phase:\"\"};return{spans:r,truncated:f}}function $r(e){let t={};for(let n of e){if(!n.phase)continue;t[n.phase]=(t[n.phase]??0)+n.durationMs}return t}var xr={coding:\"Coding\",auditing:\"Auditing\",final_auditing:\"Final audit\",final_audit_fix:\"Final audit fix\",post_action:\"Post-action\"};function be(e){if(e===\"\")return\"Unknown\";return xr[e]??e}function Ts(e){let t=$r(e),n=Object.keys(t).map((o)=>({phase:o,label:be(o),durationMs:t[o],pct:0})),s=n.reduce((o,r)=>o+r.durationMs,0);for(let o of n)o.pct=s>0?Math.round(o.durationMs/s*100):0;return n.sort((o,r)=>r.durationMs-o.durationMs)}function Sr(e,t,n){let s=[];for(let o=0;o=t)a=Math.max(0,r.createdAt-t);else if(r.createdAt=t)a=Math.max(0,r.createdAt-t);else a=null}s.push({transition:r,elapsedMs:a})}return s.reverse(),s}function As(e){if(e.length===0)return{count:0,lastAt:null,lastSection:null};let t=e[0];for(let n of e)if(n.createdAt>t.createdAt||n.createdAt===t.createdAt&&n.id>t.id)t=n;return{count:e.length,lastAt:t.createdAt,lastSection:t.appliedAtSection}}function Ms(e){let t=new Set;for(let n of e)t.add(n.appliedAtSection);return t}function Es(e,t,n,s){let o=[...Sr(e,n,s).map((r)=>({kind:\"transition\",transition:r.transition,elapsedMs:r.elapsedMs})),...t.map((r)=>({kind:\"amendment\",amendment:r,elapsedMs:null}))];return o.sort((r,a)=>{let i=r.kind===\"transition\"?r.transition.createdAt:r.amendment.createdAt,l=a.kind===\"transition\"?a.transition.createdAt:a.amendment.createdAt;if(l!==i)return l-i;if(r.kind!==a.kind)return r.kind===\"transition\"?-1:1;let u=r.kind===\"transition\"?r.transition.id:r.amendment.id;return(a.kind===\"transition\"?a.transition.id:a.amendment.id)-u}),o}var kr=[{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}],gt=110,mt=36,Cs=655,Ns=160,Lr=[{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→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 Tr(e){if(e.toPhase===null)return e.fromPhase+\"→terminal\";return e.fromPhase+\"→\"+e.toPhase}function Ar(e){return e.toPhase??e.status??\"\"}function Jt(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 Ds(e){let t=()=>e.loop(),n=b(()=>t().status===\"running\"),s=b(()=>{let i=new Map;for(let l of e.transitions()){let u=Tr(l);i.set(u,(i.get(u)??0)+1)}return i}),o=Lr.map((i)=>Jt(d`\n \n \n ${()=>{let l=s().get(i.key);return l?String(l):\"\"}}\n \n `)),r=kr.map((i)=>{let l=i.cx-gt/2,u=i.cy-mt/2;return Jt(d`\n \"mg-node\"+(t().phase===i.phase&&n()?\" mg-node-active\":\"\")}>\n \n ${i.label}\n \n `)}),a=Jt(d`\n \n \n ${()=>t().status}\n \n `);return d`
\n \n \n \n \n \n \n\n ${o}\n ${r}\n ${()=>n()?\"\":a}\n \n\n
\n
Transition history
\n
\n ${()=>e.transitions().slice(-20).reverse().map((i)=>d`
\n ${i.eventType}\n ${i.fromPhase} → ${Ar(i)}\n ${he(i.createdAt)}\n
`)}\n
\n
\n
`}function Is(e){let[t,n]=L(e.query()),s=null,o=(a)=>{let i=a.target.value;if(n(i),s)clearTimeout(s);s=setTimeout(()=>{s=null,e.onChangeQuery(i)},250)};fe(()=>{if(s)clearTimeout(s),s=null}),G(()=>{e.externalNav();let a=z(()=>e.query());if(s!==null)clearTimeout(s),s=null;n(a)});let r=b(()=>{let a=e.counts();return[{label:\"Running\",key:\"running\",value:a.running},{label:\"Completed\",key:\"completed\",value:a.completed},{label:\"Cancelled\",key:\"cancelled\",value:a.cancelled},{label:\"Errored\",key:\"errored\",value:a.errored},{label:\"Stalled\",key:\"stalled\",value:a.stalled}]});return d`
\n ${()=>r().map(({label:a,key:i,value:l})=>{let f=`badge badge-filter${e.statuses().has(i)?\" badge-active\":\"\"}`;return d`e.onToggleStatus(i)}>${a}: ${l}`})}\n \n \n
`}function Mr(e){return d`
${()=>{let t=e.generatedAt();return t>0?`Last updated: ${new Date(t).toLocaleString()}`:\"\"}}
`}function Rs(e){return d`
\n

Forge Dashboard

\n
${()=>e.breadcrumb()}
\n ${Mr({generatedAt:e.generatedAt})}\n
`}function Os(e){let t=b(()=>e.entries().map((n)=>{let s=n.loops.some((a)=>a.loop.status===\"running\"),o=Ne(n.proj),r=De(e.labels(),n.proj);return d`
e.onSelect(n.proj.projectId)}>\n ${s?d``:\"\"}\n ${r}\n ${n.loops.length}\n
`}));return d`
${()=>t()}
`}function Er(e,t){let n=0,s=0,o=0,r=[],a=[];for(let i of e){let l=De(t,i.proj);for(let u of i.loops){s++,o+=u.bugCount;let f=u.loop.status,h=Ge(u.loop);if(f===\"running\")n++,r.push({projectId:i.proj.projectId,label:l,loopName:u.loop.loopName,phase:u.loop.phase||\"\"});a.push({projectId:i.proj.projectId,label:l,loopName:u.loop.loopName,status:f,when:h})}}return a.sort((i,l)=>l.when-i.when),{runningCount:n,loopCount:s,bugCount:o,runningCards:r,recentRows:a.slice(0,8)}}function js(e){let t=b(()=>Er(e.entries(),e.labels())),n=b(()=>t().runningCount),s=b(()=>t().loopCount),o=b(()=>t().bugCount),r=b(()=>t().runningCards),a=b(()=>t().recentRows);return d`
\n
\n

All repositories

\n
${()=>`${n()} running · ${s()} loops · ${o()} open bugs`}
\n
\n ${()=>r().length>0?d`
\n

Running

\n
\n ${r().map((i)=>d`
e.onOpenLoop(i.projectId,i.loopName)}>\n ${i.label}\n ${i.loopName}\n ${be(i.phase)}\n
`)}\n
\n
`:\"\"}\n ${()=>a().length>0?d`
\n

Recent activity

\n
\n ${a().map((i)=>d`
e.onOpenLoop(i.projectId,i.loopName)}>\n we(i.status)}>${i.status}\n ${i.label}\n ${i.loopName}\n ${Z(i.when)}\n
`)}\n
\n
`:\"\"}\n
`}function Cr(e){let[t,n]=L(!1),[s,o]=L(\"\"),[r,a]=L(0),i=b(()=>{let v=s().trim().toLowerCase(),S=e.loops();return v?S.filter((M)=>M.name.toLowerCase().indexOf(v)!==-1):S}),l=b(()=>ut(i(),ss,!1)),u=()=>{if(t())return;o(\"\");let v=l().rows.findIndex((S)=>S.name===e.loopName);a(v<0?0:v),n(!0)},f=()=>{n(!1),o(\"\"),a(0)},h=(v)=>{if(f(),g.blur(),v!==e.loopName)e.onSelect(v)},y=d`t()?\"display:block\":\"display:none\"}\n onmousedown=${(v)=>v.preventDefault()}\n >\n ${()=>l().rows.map((v,S)=>d`\"loop-picker-option\"+(S===r()?\" loop-picker-option-active\":\"\")+(v.name===e.loopName?\" loop-picker-option-current\":\"\")}\n onclick=${()=>h(v.name)}\n >\n ${v.name}\n ${v.whenLabel}\n `)}\n ${()=>l().total===0?d`
No matching loops
`:\"\"}\n ${()=>l().capped?d`
${()=>\"Showing \"+l().rows.length+\" of \"+l().total+\" — type to filter\"}
`:\"\"}\n `,p=()=>{y.querySelector(\".loop-picker-option-active\")?.scrollIntoView?.({block:\"nearest\"})},c=(v)=>{if(v.key===\"ArrowDown\"||v.key===\"ArrowUp\"){if(v.preventDefault(),!t()){u();return}let S=l().rows.length;if(S===0)return;let M=v.key===\"ArrowDown\"?1:-1;a((T)=>(T+M+S)%S),p();return}if(v.key===\"Enter\"){v.preventDefault();let S=l().rows[r()];if(S)h(S.name);return}if(v.key===\"Escape\")v.preventDefault(),f(),g.blur()},g=d`t()?s():e.loopName}\n onfocus=${u}\n oninput=${(v)=>{o(v.target.value),a(0),n(!0)}}\n onkeydown=${c}\n onblur=${f}\n />`;return d`\n ${g}\n {if(v.preventDefault(),t())f(),g.blur();else g.focus()}}>▾\n ${y}\n `}function _s(e){if(e.loopName)return d`
\n ← ${e.repoLabel}\n /\n ${Cr({loopName:e.loopName,loops:e.loops,onSelect:e.onSelectLoop})}\n ${()=>{let t=e.loopNav();return t?d`\n \n ${t.index+1} of ${t.total}\n \n `:\"\"}}\n
`;return d`
\n ← Repositories\n /\n ${e.repoLabel}\n ${e.projectDir}\n
`}function Us(e){let t=[{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 d`
\n ${t.map((n)=>{let o=`section-nav-item${n.key===e.section?\" section-nav-active\":\"\"}`;return d`
e.onNavigate(n.key)}>\n ${n.label}\n ${n.count}\n
`})}\n
`}function nn(e){return d`
\n ${()=>\"Showing \"+e.shown()+\" of \"+e.total()+\" \"+e.noun}\n ${e.onShowAll?d``:\"\"}\n
`}function tn(e){let t=()=>Xt(e.current(),e.total());return d`\n \"width:\"+t()+\"%\"}>\n ${()=>e.formatCurrent?e.formatCurrent(e.current()):String(e.current())}/${()=>e.total()}\n `}var Nr={sm:4,md:14,lg:22};function sn(e){let t=Nr[e.variant],n=b(()=>e.spans().filter((r)=>r.phase!==\"\")),s=b(()=>Math.max(0,n().reduce((r,a)=>r+a.durationMs,0))),o=b(()=>e.spans().some((r)=>r.phase===\"\"));return d`
\"phase-bar phase-bar-\"+e.variant+(o()?\" phase-bar-trunc\":\"\")} data-truncated=${()=>o()?\"true\":\"false\"} style=${()=>\"height:\"+t+\"px\"}>\n ${()=>o()?d`\\u22ef`:\"\"}\n ${()=>{let r=n(),a=s();return r.map((i)=>{let l=a>0?i.durationMs/a*100:0,u=\"phase-seg\"+(i.open?\" phase-seg-open\":\"\"),f=Qt(i.durationMs);return d`
`})}}\n
`}function Fs(e){return d`
\"legend\"+(e.phase?\" legend-phase\":\"\")}>\n ${()=>e.items().map((t)=>d`
\n \n ${t.label}\n ${t.value}\n
`)}\n
`}function Bs(e){let t=b(()=>Ts(e.spans()));return Fs({phase:!0,items:()=>t().map((n)=>({phase:n.phase,label:n.label,value:Qt(n.durationMs)+\" · \"+n.pct+\"%\"}))})}function on(e,t){let n=e.loop;return Ls(e.transitions??[],n.startedAt,n.completedAt,n.completedAt===null?t():0,n.phase)}function Vs(e){let[t,n]=L(!1),s=b(()=>ut(e.loops(),Wt,t()));return d`
\n \n \n \n \n \n \n \n \n ${()=>s().rows.map((o)=>Dr({dashLoop:o,now:e.now,onOpen:e.onOpen}))}\n \n
StatusLoopPhasePhase spanIterSectionsFindingsCostDurationUpdated
\n ${()=>s().capped?nn({shown:()=>s().rows.length,total:()=>s().total,noun:\"loops\",onShowAll:()=>n(!0)}):\"\"}\n
`}function Dr(e){let t=()=>e.dashLoop.loop,n=()=>e.dashLoop,s=b(()=>Yt(n().findings)),o=b(()=>on(n(),e.now).spans);return d`e.onOpen(e.dashLoop.loop.loopName)}>\n we(t().status)}>${()=>t().status}\n ${()=>t().loopName}\n ${()=>t().completedAt===null?d`t().phase}>${()=>be(t().phase)}`:d``}\n ${()=>sn({spans:o,variant:\"sm\"})}\n ${tn({current:()=>t().iteration,total:()=>t().maxIterations})}\n ${()=>t().totalSections>0?tn({current:()=>t().currentSectionIndex,total:()=>t().totalSections,formatCurrent:se}):d``}\n ${()=>{let r=s();if(r.bugs.length===0&&r.warnings.length===0)return d``;return d`\n ${r.bugs.length>0?d`${Pe(r.bugs.length,\"bug\")}`:\"\"}\n ${r.bugs.length>0&&r.warnings.length>0?\" · \":\"\"}\n ${r.warnings.length>0?d`${Pe(r.warnings.length,\"warn\")}`:\"\"}\n `}}\n ${()=>n().usage?Ke(n().usage.totalCost):d``}\n ${()=>n().duration||\"\"}\n ${()=>Z(Ge(t()))}\n `}function ze(e){let t=()=>typeof e.label===\"function\"?e.label():e.label,[n,s]=L(!1),o=()=>s((i)=>!i),r=b(()=>ws(e.src()||\"\")),a=b(()=>r().outline.filter((i)=>i.depth<=3));return d`
\n
\n n()?\"false\":\"true\"}\n onclick=${o}\n onkeydown=${(i)=>{if(i.key!==\"Enter\"&&i.key!==\" \")return;i.preventDefault(),o()}}\n >\n ${()=>n()?\"▸\":\"▾\"}\n

${()=>t()}

\n
\n \"Copy \"+t()+\" as markdown\"}\n onclick=${(i)=>{i.stopPropagation();let l=i.target,u=\"Copy\";navigator.clipboard.writeText(e.src()||\"\").then(()=>{l.textContent=\"Copied!\"},()=>{l.textContent=\"Failed\"}).then(()=>{setTimeout(()=>{l.textContent=u},2000)})}}\n >Copy\n
\n
n()?\"display:none\":\"display:block\"}>\n ${()=>{let i=a();return i.length>=2?Pr({items:()=>i}):null}}\n
r().html}>
\n
\n `}function Pr(e){let t=(n,s)=>{n.preventDefault();let o=n.currentTarget.closest(\".markdown-body\"),r=o?Array.from(o.querySelectorAll(\"h1,h2,h3,h4,h5,h6\")).find((a)=>a.id===s):document.getElementById(s);if(r&&typeof r.scrollIntoView===\"function\")r.scrollIntoView({behavior:\"smooth\",block:\"start\"})};return d``}function Zt(e){return d`
\n
${e.label}
\n
ys(e.value()||\"\")}>
\n
`}function Ir(e){let t=e.sec,n=b(()=>!!t.startedAt||!!t.completedAt),s=b(()=>!!t.summaryDone),o=b(()=>!!t.summaryDeviations),r=b(()=>!!t.summaryFollowUps),a=b(()=>!!t.content),i=b(()=>n()||s()||o()||r()||a());return d`
\n ${()=>n()?d`
\n ${()=>t.startedAt?\"Started \"+Z(t.startedAt):\"Not started\"}\n ${()=>t.completedAt?d` → Completed ${()=>Z(t.completedAt)}`:\"\"}\n
`:\"\"}\n ${()=>s()?Zt({label:\"Done\",value:()=>t.summaryDone}):\"\"}\n ${()=>o()?Zt({label:\"Deviations\",value:()=>t.summaryDeviations}):\"\"}\n ${()=>r()?Zt({label:\"Follow-ups\",value:()=>t.summaryFollowUps}):\"\"}\n ${()=>a()?ze({label:\"Section Plan\",src:()=>t.content}):\"\"}\n ${()=>i()?\"\":d`
No details captured for this section yet.
`}\n
`}function Rr(e){let t=e.sec,n=b(()=>hs(t.startedAt,t.completedAt));return d`
\"section-list-row section-item-\"+t.status} onclick=${e.onOpen}>\n #${se(t.sectionIndex)}\n ${()=>t.title}\n zt(t.status)}>${()=>t.status}\n ${()=>e.adjusted()?d`adjusted`:\"\"}\n ${()=>n()?d`${()=>n()}`:\"\"}\n ${()=>t.attempts>0?d`${()=>t.attempts} attempts`:\"\"}\n \n
`}function Or(e){let[t,n]=L(null),s=b(()=>Ms(e.amendments())),o=b(()=>{let r=t();if(r===null)return null;return e.sections().find((a)=>a.sectionIndex===r)??null});return d`
\n

Sections

\n ${()=>{let r=o();if(r)return d`
\n
n(null)}>← Back to sections
\n
\n #${se(r.sectionIndex)}\n ${()=>r.title}\n zt(r.status)}>${()=>r.status}\n
\n ${Ir({sec:r})}\n
`;return d`
\n ${e.sections().map((a)=>Rr({sec:a,onOpen:()=>n(a.sectionIndex),adjusted:()=>s().has(a.sectionIndex)}))}\n
`}}\n
`}function Q(e){let t=()=>\"ldh-cell\"+(e.size===\"stat\"?\" ldh-cell-stat\":\"\")+(e.tone?\" ldh-cell-\"+e.tone():\"\"),n=()=>\"ldh-cell-value\"+(e.size===\"stat\"?\" ldh-cell-value-stat\":\"\");return d`
\n ${e.label}\n e.title?e.title():\"\"}>${()=>e.value()}\n
`}function en(e){return d`
\n
${e.label}
\n
${()=>e.children.map((t)=>t)}
\n
`}function Ps(e){let t=()=>Xt(e.current(),e.total());return d`
\n
\n ${e.label}\n ${()=>e.formatCurrent?e.formatCurrent(e.current()):String(e.current())} / ${()=>e.total()}\n
\n
\n
\"width:\"+t()+\"%\"}>
\n
\n
`}function jr(e){let t=()=>e.dashLoop.loop,n=()=>e.dashLoop,s=b(()=>!!t().completedAt),o=b(()=>t().completedAt===null),r=b(()=>t().totalSections>0),a=b(()=>As(n().amendments??[])),i=b(()=>{let y=t().terminationReason;if(!y)return null;return y.trim().toLowerCase()===t().status.toLowerCase()?null:y}),l=b(()=>us(e.split())),u=b(()=>!!e.dashLoop.usage),f=b(()=>{let y=e.split();if(y.bugs.length===0&&y.warnings.length===0)return\"No findings\";let p=[];if(y.bugs.length>0)p.push(Pe(y.bugs.length,\"bug\"));if(y.warnings.length>0)p.push(Pe(y.warnings.length,\"warning\"));return p.join(\" · \")}),h=b(()=>{let y=[Q({label:\"Started\",value:()=>Z(t().startedAt),size:\"stat\"})];if(s())y.push(Q({label:\"Completed\",value:()=>Z(t().completedAt),size:\"stat\"}));let p=[Q({label:\"Execution model\",value:()=>t().executionModel??\"—\",title:()=>t().executionModel??\"\",size:\"stat\"}),Q({label:\"Auditor model\",value:()=>{let g=t().auditorModel??\"—\",v=t().auditorFallbackIndex??0;return v>0?`${g} (fallback ${v})`:g},title:()=>t().auditorModel??\"\",size:\"stat\"})],c=[Q({label:\"Branch\",value:()=>t().worktreeBranch??\"—\",title:()=>t().worktreeBranch??\"\",size:\"stat\"}),Q({label:\"Sandbox\",value:()=>t().sandbox?\"on\":\"off\",size:\"stat\"}),Q({label:\"Kind\",value:()=>t().kind??\"—\",size:\"stat\"}),Q({label:\"Audits\",value:()=>String(t().auditCount),size:\"stat\"}),Q({label:\"Errors\",value:()=>String(t().errorCount),size:\"stat\"})];if(u())c.push(Q({label:\"Messages\",value:()=>String(e.dashLoop.usage.totalMessageCount),size:\"stat\"}));return[en({label:\"Timing\",children:y}),en({label:\"Models\",children:p}),en({label:\"Environment\",children:c})]});return d`
\n
\n we(t().status)}>${()=>t().status}\n

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

\n ${()=>a().count>0?d`e.onSelectTab(\"plan\")}\n >${()=>\"Plan adjusted \"+a().count+\"× · last \"+he(a().lastAt)+\" @ section \"+se(a().lastSection)}`:\"\"}\n ${()=>o()?d`${be(t().phase)}`:\"\"}\n
\n\n
\n ${Q({label:\"Duration\",value:()=>n().duration||\"—\"})}\n ${Q({label:\"Cost\",value:()=>u()?Ke(e.dashLoop.usage.totalCost):\"—\"})}\n ${Q({label:\"Iterations\",value:()=>t().iteration+\" / \"+t().maxIterations})}\n ${()=>r()?Q({label:\"Sections\",value:()=>se(t().currentSectionIndex)+\" / \"+t().totalSections}):\"\"}\n ${Q({label:\"Findings\",value:()=>f(),tone:()=>l()})}\n
\n\n
\n ${Ps({label:\"Iterations\",current:()=>t().iteration,total:()=>t().maxIterations})}\n ${()=>r()?Ps({label:\"Sections\",current:()=>t().currentSectionIndex,total:()=>t().totalSections,formatCurrent:se}):\"\"}\n
\n\n
${()=>h()}
\n\n ${()=>i()?d`
\"ldh-banner ldh-banner-\"+t().status}>${()=>i()}
`:\"\"}\n
`}function _r(e){let t=()=>e.usage(),n=b(()=>ps(t())),s=b(()=>ms(t())),o=b(()=>s().length>0),r=b(()=>gs(t()));return d`
\n

Usage

\n\n
\n
Role split
\n
\n ${()=>r().map((a)=>d`
a.role}>\n
\n a.role}>${a.role}\n ${()=>Ke(a.cost)}\n
\n
\n
\"width:\"+a.pct+\"%\"}>
\n
\n
${()=>a.messageCount+\" msg · \"+We(a.tokens)+\" tok\"}
\n
`)}\n
\n
\n\n
\n
Token composition
\n
\n ${()=>n().map((a)=>a.pct>0?d`
`:\"\")}\n
\n ${Fs({items:()=>n().map((a)=>({color:a.color,label:a.label,value:We(a.value)}))})}\n
\n\n ${()=>o()?d`
\n
Cost by model
\n
\n ${()=>s().map((a)=>d`
\n
\n ${a.model}\n ${Ke(a.cost)}\n
\n
\n
\n
\n
\n ${We(a.inputTokens)} in / ${We(a.outputTokens)} out · ${a.messageCount} msg\n
\n
`)}\n
\n
`:\"\"}\n `}function Ur(e){let t=e.amendment,[n,s]=L(null),[o,r]=L(!1),[a,i]=L(\"\"),[l,u]=L(!1);G(()=>{if(e.expanded()){if(l()||n()!==null)return;u(!0),i(\"\"),r(!0);let c=new URLSearchParams({project:t.projectId,loop:t.loopName,id:String(t.id)});fetch(\"/api/amendment?\"+c.toString()).then(async(g)=>{if(!g.ok){i(await g.text().catch(()=>\"\")||`Failed (status ${g.status})`);return}let v=await g.json();s(v)}).catch((g)=>i(g instanceof Error?g.message:String(g))).finally(()=>r(!1));return}if(l()&&!o()&&n()===null)u(!1)});let f=()=>{let c=[];if(t.summary.added>0)c.push({cls:\"amendment-count-add\",label:\"+\"+t.summary.added});if(t.summary.removed>0)c.push({cls:\"amendment-count-remove\",label:\"−\"+t.summary.removed});if(t.summary.modified>0)c.push({cls:\"amendment-count-modified\",label:\"~\"+t.summary.modified});return c},h=()=>(n()?.sections??[]).filter((c)=>c.change!==\"unchanged\"),y=()=>n()!==null&&h().length===0,p=(c)=>{if(c.kind===\"add\")return\"+ \"+c.text;if(c.kind===\"remove\")return\"- \"+c.text;if(c.kind===\"context\")return\" \"+c.text;return c.text};return d`
\n
\n ${()=>he(t.createdAt)}\n ${()=>\"applied @ section \"+se(t.appliedAtSection)}\n ${()=>t.source}\n ${()=>f().length>0?d`${f().map((c)=>d`${c.label}`)}`:\"\"}\n ${()=>t.rationale}\n ${()=>e.expanded()?\"▾\":\"▸\"}\n
\n
e.expanded()?\"display:block\":\"display:none\"}>\n
\n ${()=>o()?d`
Loading diff…
`:\"\"}\n ${()=>a()?d`
${a()}
`:\"\"}\n ${()=>y()?d`
No section changes recorded.
`:\"\"}\n ${()=>h().map((c)=>d`
\n
\n ${()=>\"#\"+se(c.index)}\n ${()=>c.change}\n ${()=>c.title}\n ${()=>c.previousTitle!==null?d`${()=>\"was: \"+c.previousTitle}`:\"\"}\n
\n ${()=>c.lines.length===0?d`
Title changed only.
`:c.lines.map((g)=>d`
${p(g)}
`)}\n
`)}\n
\n
\n
`}function Fr(e){let t=()=>e.amendments(),[n,s]=L(null);return d`
\n

Plan amendments

\n
\n ${()=>t().map((o)=>Ur({amendment:o,expanded:()=>n()===o.id,onToggle:()=>s(n()===o.id?null:o.id)}))}\n
\n
`}var Br={overview:\"Overview\",live:\"Live\",timeline:\"Timeline\",sections:\"Sections\",findings:\"Findings\",plan:\"Plan\",usage:\"Usage\"};function Vr(e){return d`
\n ${()=>e.tabs().map((t)=>{let n=Br[t];return d`\"tab-item\"+(e.activeTab()===t?\" tab-active\":\"\")} data-tab=\"${t}\" onclick=${()=>e.onSelect(t)}>${n}`})}\n
`}function Hr(e){let t=()=>e.dashLoop,n=()=>t().loop,s=b(()=>!!t().goal),o=b(()=>t().postActionReport??n().completionSummary??null),r=b(()=>t().postActionReport?\"Post-Action Report\":\"Completion Summary\"),a=b(()=>!!o()),i=b(()=>!!t().lastAuditResult),l=b(()=>on(t(),e.now));return d`
\n ${()=>sn({spans:()=>l().spans,variant:\"md\"})}\n ${()=>Bs({spans:()=>l().spans})}\n ${()=>s()?ze({label:\"Goal\",src:()=>t().goal}):\"\"}\n ${jr({dashLoop:e.dashLoop,split:e.split,onSelectTab:e.onSelectTab})}\n ${()=>a()?ze({label:r,src:()=>o()}):\"\"}\n ${()=>i()?ze({label:\"Last Audit Result\",src:()=>t().lastAuditResult}):\"\"}\n
`}function qr(e){let t=e.ev.transition,n=e.ev.elapsedMs,s=n===null?null:Math.floor(n/1000),o=t.toPhase===null?t.status??\"\":t.toPhase;return d`
\n ${he(t.createdAt)}\n ${be(t.fromPhase)} → ${be(o)}\n ${t.transitionKind}\n iter ${t.iteration}\n ${()=>t.sectionIndex===null?\"—\":\"sect \"+se(t.sectionIndex)}\n ${()=>s===null?\"—\":s>0?He(s):\"0s\"}\n
`}function Gr(e){let t=e.amendment;return d`
\n ${he(t.createdAt)}\n plan adjusted\n ${()=>\"section \"+se(t.appliedAtSection)}\n ${()=>t.rationale}\n
`}function Wr(e){let t=()=>e.dashLoop,n=()=>t().loop,s=()=>t().transitions??[],o=b(()=>on(t(),e.now)),r=b(()=>o().spans),a=b(()=>o().truncated),i=b(()=>Es(s(),t().amendments??[],n().startedAt,a())),[l,u]=L(!1),f=b(()=>{let y=i();return l()?y:y.slice(0,20)}),h=b(()=>l()?0:Math.max(0,i().length-20));return d`
\n ${Ds({loop:()=>n(),transitions:s})}\n ${()=>sn({spans:r,variant:\"lg\"})}\n ${()=>a()?d`
Earlier history was truncated by the 100-row fetch window.
`:\"\"}\n ${()=>Bs({spans:r})}\n\n
\n ${()=>f().map((y)=>y.kind===\"transition\"?qr({ev:{transition:y.transition,elapsedMs:y.elapsedMs}}):Gr({amendment:y.amendment}))}\n ${()=>h()>0?d`
u(!0)}>[ ${()=>h()} earlier events ]
`:\"\"}\n
\n
`}function Kr(e){return Or({sections:()=>e.dashLoop.sections,amendments:()=>e.dashLoop.amendments??[]})}function zr(e){let t=[],n=new Map;for(let s of e){let o=s.sectionIndex??null;if(!n.has(o))n.set(o,[]),t.push(o);n.get(o).push(s)}return t.sort((s,o)=>{if(s===null&&o===null)return 0;if(s===null)return 1;if(o===null)return-1;return s-o}),t.map((s)=>({key:s,label:s===null?\"cross-section\":\"sect \"+se(s),rows:n.get(s)}))}function Hs(e){return d`
\n ${e.rows.map((t)=>d`
\"finding finding-\"+t.severity}>\n ${()=>he(t.createdAt)}\n ${()=>ds(t)}\n
`)}\n
`}function qs(e){let t=()=>ft({projectId:e.projectId(),section:\"loops\",loopName:e.loopName,tab:\"overview\",groupId:null,statuses:[],query:\"\"});return d`{if(n.defaultPrevented||n.button!==0||n.metaKey||n.ctrlKey||n.shiftKey||n.altKey)return;n.preventDefault(),e.onOpen(e.loopName)}}>${e.loopName}`}function Yr(e){let t=()=>e.dashLoop,n=b(()=>t().findings.length>0),s=b(()=>zr(t().findings));return d`
\n ${()=>n()?s().map((o)=>d`
o.key===null?\"cross\":String(o.key)}>\n
${o.label}
\n ${Hs({rows:o.rows})}\n
`):d`
No findings recorded for this loop.
`}\n
`}function Xr(e){let t=()=>e.dashLoop,n=b(()=>t().hasPlan),s=b(()=>!!t().amendments&&t().amendments.length>0);return d`
\n ${()=>n()?ze({label:\"Plan\",src:()=>t().plan}):d`
No plan recorded for this loop.
`}\n ${()=>s()?Fr({amendments:()=>t().amendments}):\"\"}\n
`}function Qr(e){let t=()=>e.dashLoop,n=b(()=>!!t().usage);return d`
\n ${()=>n()?_r({usage:()=>t().usage}):d`
No usage recorded for this loop.
`}\n
`}function Jr(e){let t=()=>e.dashLoop.loop,[n,s]=L([]),[o,r]=L(\"\"),[a,i]=L(\"\"),[l,u]=L(\"\"),[f,h]=L(\"\"),[y,p]=L(\"\"),[c,g]=L(!1),[v,S]=L(\"\"),[M,T]=L(!1),[k,A]=L(!1),P=!1,I=[\"\",\"\",\"\",\"\"];G(()=>{let C=[t().executionModel??\"\",t().executionVariant??\"\",t().auditorModel??\"\",t().auditorVariant??\"\"];if(!P&&(C[0]!==I[0]||C[1]!==I[1]||C[2]!==I[2]||C[3]!==I[3]))i(C[0]),u(C[1]),h(C[2]),p(C[3]);I=C}),G(()=>{if(!k()||n().length>0)return;let C=new URLSearchParams({project:t().projectId,loop:t().loopName});fetch(\"/api/models?\"+C.toString()).then(async(j)=>{if(!j.ok){r(await j.text().catch(()=>\"\")||`Failed (status ${j.status})`);return}let X=await j.json();s(X.models??[]),r(\"\")}).catch((j)=>r(j instanceof Error?j.message:String(j)))});let V=(C)=>n().find((j)=>j.id===C)?.variants??[],O=async()=>{if(c())return;g(!0),S(\"\"),T(!1);try{let C=await fetch(\"/api/loop/models\",{method:\"POST\",headers:{\"content-type\":\"application/json\"},body:JSON.stringify({projectId:t().projectId,loopName:t().loopName,executionModel:a()||null,executionVariant:l()||null,auditorModel:f()||null,auditorVariant:y()||null})});if(!C.ok){S(await C.text().catch(()=>\"\")||`Failed (status ${C.status})`);return}let j=await C.json(),X=[j.executionModel??\"\",j.executionVariant??\"\",j.auditorModel??\"\",j.auditorVariant??\"\"];i(X[0]),u(X[1]),h(X[2]),p(X[3]),I=X,P=!1,T(!0)}catch(C){S(C instanceof Error?C.message:String(C))}finally{g(!1)}},J=(C,j,X)=>d`{P=!0,j(U.currentTarget.value),X()}}\n >\n \n ${()=>n().map((U)=>d``)}\n ${()=>C()&&!n().some((U)=>U.id===C())?d``:\"\"}\n `,Y=(C,j,X)=>d`V(C()).length===0}\n onchange=${(U)=>{P=!0,X(U.currentTarget.value)}}\n >\n \n ${()=>V(C()).map((U)=>d``)}\n `;return d`
\n \n ${()=>{if(!k())return\"\";return d`
\n ${()=>o()?d`
${()=>o()}
`:\"\"}\n \n \n
\n Applies to the next prompt, not the turn in flight.\n \n
\n ${()=>v()?d`
${()=>v()}
`:\"\"}\n ${()=>M()&&!v()?d`
Models updated.
`:\"\"}\n
`}}\n
`}function Zr(e){let t=()=>e.part,[n,s]=L(!1),o=()=>!!t().output;return d`
\"live-tool live-tool-\"+(t().status??\"pending\")}>\n \"live-tool-head\"+(o()?\" live-tool-head-clickable\":\"\")}\n disabled=${()=>!o()}\n aria-expanded=${()=>o()?n()?\"true\":\"false\":void 0}\n onclick=${()=>{if(o())s((r)=>!r)}}\n >\n ${()=>o()?n()?\"▾\":\"▸\":\"\"}\n ${()=>t().tool??\"tool\"}\n ${()=>t().title??\"\"}\n ${()=>t().status??\"pending\"}\n \n ${()=>n()&&o()?d`
${()=>t().output}
`:\"\"}\n
`}var ea=8000,ta=48;function na(){if(typeof document>\"u\")return()=>!0;let[e,t]=L(document.visibilityState!==\"hidden\"),n=()=>t(document.visibilityState!==\"hidden\");return document.addEventListener(\"visibilitychange\",n),fe(()=>document.removeEventListener(\"visibilitychange\",n)),e}function sa(e){let t=()=>e.dashLoop,n=()=>t().loop,[s,o]=L([]),[r,a]=L(\"connecting\"),[i,l]=L(\"\"),[u,f]=L(\"\"),[h,y]=L(\"\"),[p,c]=L(!1),[g,v]=L(\"\"),[S,M]=L(\"stream\"),[T,k]=L(0),[A,P]=L(0),I=na(),V=b(()=>e.visible()&&I());G(()=>{if(!V())return;let N=setInterval(()=>P((q)=>q+1),1000);fe(()=>clearInterval(N))});let O=b(()=>{if(A(),u()===\"busy\")return!0;let N=T();return N>0&&Date.now()-N\"/api/loop/stream?\"+new URLSearchParams({project:n().projectId,loop:n().loopName}).toString()),Y=\"\";G(()=>{let N=n().currentSessionId,q=J();if(!V()||!N||typeof EventSource>\"u\")return;if(N!==Y)Y=N,o([]);a(\"connecting\"),l(\"\"),M(\"stream\"),k(0);let W=new EventSource(q);W.addEventListener(\"snapshot\",(ve)=>{try{let de=JSON.parse(ve.data),xe=xs(de.messages);if(o(xe),a(\"live\"),de.reason===\"poll\")M(\"poll\"),k(Date.now())}catch{a(\"failed\"),l(\"Could not read the transcript snapshot.\")}}),W.addEventListener(\"event\",(ve)=>{try{let de=JSON.parse(ve.data);o((Xe)=>Ss(Xe,de)),M(\"stream\"),k(Date.now());let xe=ks(de);if(xe)f(xe)}catch{}}),W.addEventListener(\"failed\",(ve)=>{try{let de=JSON.parse(ve.data);l(de.message??\"Live stream ended.\")}catch{l(\"Live stream ended.\")}a(\"failed\"),W.close()}),W.onerror=()=>{if(W.readyState===EventSource.CLOSED)a(\"failed\")},fe(()=>W.close())});let C=async()=>{let N=h().trim();if(!N||p())return;c(!0),v(\"\");try{let q=await fetch(\"/api/loop/message\",{method:\"POST\",headers:{\"content-type\":\"application/json\"},body:JSON.stringify({projectId:n().projectId,loopName:n().loopName,text:N})});if(!q.ok){v(await q.text().catch(()=>\"\")||`Failed (status ${q.status})`);return}y(\"\")}catch(q){v(q instanceof Error?q.message:String(q))}finally{c(!1)}},j=b(()=>{if(r()===\"connecting\")return\"connecting\";if(r()===\"failed\")return\"disconnected\";if(O())return S()===\"poll\"?\"working (refreshing)\":\"working\";return S()===\"poll\"?\"refreshing\":u()||\"idle\"}),X=b(()=>{if(r()!==\"live\")return\"live-dot live-dot-\"+r();if(O())return\"live-dot live-dot-working\";return\"live-dot live-dot-idle\"}),U=d`
\n ${()=>{let N=s();if(N.length===0)return d`
${()=>r()===\"live\"?\"No messages in this session yet.\":\"Loading session…\"}
`;return N.map((q)=>d`
\n
${q.role}
\n
\n ${()=>q.parts.map((W)=>W.type===\"text\"?d`
${W.text??\"\"}
`:Zr({part:W}))}\n
\n
`)}}\n
`,[ue,$e]=L(!0),Ye=(N)=>N.scrollHeight-N.scrollTop-N.clientHeight$e(Ye(U))),G(()=>{if(s(),!ue())return;queueMicrotask(()=>{U.scrollTop=U.scrollHeight})}),d`
\n
\n X()}>\n ${()=>j()}\n ${()=>S()===\"poll\"?d`events unavailable — refreshing every 4s`:\"\"}\n ${()=>n().currentSessionId}\n
\n\n ${()=>i()?d`
${()=>i()}
`:\"\"}\n\n ${Jr({dashLoop:e.dashLoop})}\n\n ${U}\n\n ${()=>ue()?\"\":d``}\n\n
\n h()}\n oninput=${(N)=>y(N.currentTarget.value)}\n onkeydown=${(N)=>{if(N.key!==\"Enter\"||N.shiftKey)return;N.preventDefault(),C()}}\n >\n void C()}\n disabled=${()=>p()||h().trim().length===0}\n >${()=>p()?\"Sending…\":\"Send\"}\n
\n ${()=>g()?d`
${()=>g()}
`:\"\"}\n
`}function oa(e,t){switch(e.tab){case\"overview\":return Hr({dashLoop:e.dashLoop,split:e.split,now:e.now,onSelectTab:e.onSelectTab});case\"live\":return sa({dashLoop:e.dashLoop,visible:t});case\"timeline\":return Wr({dashLoop:e.dashLoop,now:e.now});case\"sections\":return Kr({dashLoop:e.dashLoop});case\"findings\":return Yr({dashLoop:e.dashLoop});case\"plan\":return Xr({dashLoop:e.dashLoop});case\"usage\":return Qr({dashLoop:e.dashLoop})}}function ra(e){let t=b(()=>e.tabs().includes(e.tab)&&e.activeTab()===e.tab),n=ot(),s=d`
t()?\"display:block\":\"display:none\"}>
`,o=!1;return G(()=>{if(!t()||o)return;o=!0;let r=St(n,()=>oa(e,t));if(r)s.appendChild(r)}),s}function aa(e){return d`
\n ${Gt.map((t)=>ra({tab:t,dashLoop:e.dashLoop,split:e.split,now:e.now,tabs:e.tabs,activeTab:e.activeTab,onSelectTab:e.onSelectTab}))}\n
`}function Gs(e){let t=()=>e.dashLoop,n=b(()=>Yt(t().findings)),s=b(()=>pt(t())),o=b(()=>{let r=e.routeTab();return s().includes(r)?r:\"overview\"});return d`
\n
\n ${Vr({tabs:s,activeTab:o,onSelect:e.onSelectTab})}\n ${aa({dashLoop:e.dashLoop,split:n,now:e.now,tabs:s,activeTab:o,onSelectTab:e.onSelectTab})}\n
\n
`}function rn(){return d`
No loops match the current filters.
`}var ia=new Set([\"completed\",\"cancelled\",\"errored\"]);function Ws(e){let t=e.length;return{completed:e.filter((s)=>s.stage===\"completed\").length,total:t}}function Ks(e,t){let n=ia.has(t);return e+(n?` ${e}-terminal`:` ${e}-active`)}function zs(e){return d`
\n

Groups

\n ${()=>{let t=e.groups();if(t.length===0)return d`
No feature groups for this repo.
`;return d`
\n ${t.map((n)=>{let s=n.group,o=b(()=>Ws(n.features));return d`
Ks(\"group-row\",s.status)} data-group-status=${()=>s.status} onclick=${()=>e.onOpen(s.groupId)}>\n we(s.status)}>${()=>s.status}\n ${()=>s.title}\n ${tn({current:()=>o().completed,total:()=>o().total})}\n max ${()=>s.maxConcurrent}\n \n Created ${()=>Z(s.createdAt)}\n ${()=>s.completedAt?d`Completed ${()=>Z(s.completedAt)}`:\"\"}\n \n
`})}\n
`}}\n
`}function Ys(e){return d`
\n
← Back to groups
\n ${()=>{let t=e.group();if(!t)return d`
Group not found.
`;let n=t.group,s=b(()=>Ws(t.features));return d`
\n
Ks(\"group-header\",n.status)} data-group-status=${()=>n.status}>\n
\n we(n.status)}>${()=>n.status}\n

${()=>n.title}

\n max ${()=>n.maxConcurrent}\n
\n
\n ${()=>\"Features \"+s().completed+\"/\"+s().total}\n Created ${()=>Z(n.createdAt)}\n ${()=>n.completedAt?d`Completed ${()=>Z(n.completedAt)}`:\"\"}\n ${()=>n.error?d`${()=>n.error}`:\"\"}\n
\n ${()=>n.prdPreview?d`
\n

PRD preview

\n
${()=>n.prdPreview||\"\"}
\n
`:\"\"}\n
\n
\n ${t.features.map((o)=>d`
o.stage}>\n #${()=>o.featureIndex}\n ${()=>o.title}\n rs(o.stage)}>${()=>o.stage}\n ${()=>o.attempts} attempts\n ${()=>!!o.loopName&&e.loopNames().has(o.loopName)?qs({className:\"feature-loop-link\",projectId:e.projectId,loopName:o.loopName,onOpen:e.onOpenLoop}):\"\"}\n ${()=>o.error?d`${()=>o.error}`:\"\"}\n
`)}\n
\n
`}}\n
`}function la(e,t,n){let s=[],o=0,r=n?1/0:t;for(let a of e){if(a.findings.length===0)continue;if(o+=a.findings.length,r<=0)continue;let i=Math.min(r,a.findings.length);s.push({loopName:a.loop.loopName,rows:a.findings.slice(0,i)}),r-=i}return{groups:s,total:o,capped:!n&&o>t}}function Xs(e){let[t,n]=L(!1),s=b(()=>la(e.loops(),ns,t()));return d`
\n

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

\n ${()=>s().capped?nn({shown:()=>s().groups.reduce((o,r)=>o+r.rows.length,0),total:()=>s().total,noun:\"findings\",onShowAll:()=>n(!0)}):\"\"}\n ${()=>{let o=s().groups;if(o.length===0)return d`
No findings recorded for this repo.
`;return d`
\n ${o.map((r)=>d`
r.loopName}>\n
\n ${qs({className:\"findings-loop-link\",projectId:e.projectId,loopName:r.loopName,onOpen:e.onOpenLoop})}\n ${()=>r.rows.length}\n
\n ${Hs({rows:r.rows})}\n
`)}\n
`}}\n
`}function Qs(e){let[t,n]=L(!1),s=b(()=>e.loops().filter((r)=>r.hasPlan)),o=b(()=>ut(s(),Wt,t()));return d`
\n

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

\n ${()=>o().capped?nn({shown:()=>o().rows.length,total:()=>o().total,noun:\"plans\",onShowAll:()=>n(!0)}):\"\"}\n ${()=>{let r=o().rows;if(r.length===0)return d`
No plans recorded for this repo.
`;return d`
\n ${r.map((a)=>d`
e.onOpenLoop(a.loop.loopName)}>\n we(a.loop.status)}>${()=>a.loop.status}\n ${()=>a.loop.loopName}\n \n ${()=>Pe(a.findings.length,\"finding\")}\n \n iter ${()=>a.loop.iteration}/${()=>a.loop.maxIterations}\n
`)}\n
`}}\n
`}function Js(){let[e,t]=Hn({generatedAt:0,projects:[]}),[n,s]=L(!1),o=dt(typeof location<\"u\"?location.hash:\"\"),[r,a]=L(o.projectId),[i,l]=L(o.section),[u,f]=L(o.loopName),[h,y]=L(o.tab),[p,c]=L(o.groupId),[g,v]=L(o.statuses,{equals:Kt}),[S,M]=L(o.query),T=b(()=>({projectId:r(),section:i(),loopName:u(),tab:h(),groupId:p(),statuses:g(),query:S()})),[k,A]=L(null),[P,I]=L(\"recent\"),[V,O]=L(0),[J,Y]=L(Date.now()),C={current:\"\"},j={current:!1},X=()=>{let m=z(T),w=new URLSearchParams;if(m.projectId)w.set(\"project\",m.projectId);if(m.loopName)w.set(\"loop\",m.loopName);let E=w.toString();return E?\"/api/data?\"+E:\"/api/data\"},U=0,ue={url:null,gen:0},$e=async()=>{let m=X();if(ue.url===m)return;let w=++U;ue.url=m,ue.gen=w;try{let E=await fetch(m,{cache:\"no-store\"});if(w!==U)return;let _=await E.json();if(w!==U)return;let ee=ls(_);if(ee!==C.current)C.current=ee,t(qn(_,{key:\"id\"})),s(!0),A(null);Y(Date.now())}catch(E){if(w!==U)return;let _=E instanceof Error?E.message:String(E);A(\"Failed to load dashboard data: \"+_)}finally{if(ue.gen===w)ue.url=null,ue.gen=0}},Ye=(m)=>{Ue(()=>{a(m.projectId),l(m.section),f(m.loopName),y(m.tab),c(m.groupId),v(m.statuses),M(m.query)})},N=(m)=>{Ue(()=>{let w=m.projectId!==void 0&&m.projectId!==r();if(m.projectId!==void 0)a(m.projectId);if(m.section!==void 0)l(m.section);if(m.loopName!==void 0)f(m.loopName);if(m.tab!==void 0)y(m.tab);if(m.groupId!==void 0)c(m.groupId);if(m.statuses!==void 0)v(m.statuses);if(m.query!==void 0)M(m.query);if(w)v([]),M(\"\")})},q=(m)=>{N({tab:m})},W=(m)=>{N({loopName:m,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})},ve=(m)=>{let w=new Set(g());if(w.has(m))w.delete(m);else w.add(m);N({statuses:[...w]})},de=(m)=>{N({query:m})},xe=(m)=>{I(m)},Xe=b(()=>new Set(g())),Qe=b(()=>cs(e.projects.map(Ne))),Je=b(()=>{if(!n()||e.projects.length===0)return[];let m=Xe(),w=S(),E=Qe(),_=[],ee=w.trim().toLowerCase();for(let Re of e.projects){let vt=[],dn=De(E,Re);for(let fn of Re.loops)if(as(fn.loop,m,w,dn))vt.push(fn);let wo=(Re.groups?.length??0)>0;if(vt.length>0)_.push({proj:Re,loops:vt});else if(wo&&(!ee||dn.toLowerCase().indexOf(ee)!==-1))_.push({proj:Re,loops:[]})}return _}),an=b(()=>r()===null),eo=b(()=>i()===\"groups\"),ln=b(()=>i()===\"loops\"),to=b(()=>i()===\"findings\"),no=b(()=>i()===\"plans\"),cn=b(()=>{let m=Je(),w=r();if(!w)return null;return m.find((E)=>E.proj.projectId===w)??null}),le=b(()=>{let m=r();if(!m)return null;return e.projects.find((w)=>w.projectId===m)??null}),ht=b(()=>{let m=r(),w=u();if(!m||!w)return null;let E=le();if(!E)return null;return E.loops.find((_)=>_.loop.loopName===w)??null}),so=b(()=>{let m=le(),w=m?.groups?.length??0;if(!m)return{loops:0,groups:w,findings:0,plans:0};let E=0,_=0;for(let ee of m.loops)if(E+=ee.findings.length,ee.hasPlan)_++;return{loops:m.loops.length,groups:w,findings:E,plans:_}}),Ie=b(()=>{let m=le();if(!m)return[];return m.loops.map((w)=>{let E=Ge(w.loop);return{name:w.loop.loopName,when:E,whenLabel:Z(E)}}).sort((w,E)=>E.when-w.when||w.name.localeCompare(E.name))}),bt=b(()=>{let m=u();if(!m)return-1;return Ie().findIndex((w)=>w.name===m)}),oo=b(()=>{let m=bt();if(m<0)return null;let w=Ie();if(w.length<=1)return null;return{index:m,total:w.length,onPrev:()=>{let E=bt(),_=Ie();if(E>0)N({loopName:_[E-1].name})},onNext:()=>{let E=bt(),_=Ie();if(E<_.length-1)N({loopName:_[E+1].name})}}},null,{equals:(m,w)=>m===w||!!m&&!!w&&m.index===w.index&&m.total===w.total}),ro=b(()=>{let m=le(),w={running:0,completed:0,cancelled:0,errored:0,stalled:0};if(!m)return w;for(let E of m.loops){let _=E.loop.status;if(_===\"running\")w.running++;else if(_===\"completed\")w.completed++;else if(_===\"cancelled\")w.cancelled++;else if(_===\"errored\")w.errored++;else if(_===\"stalled\")w.stalled++}return w}),ao=b(()=>{let m=u();if(!r())return\"\";let w=le();if(!w)return\"\";let E=Ne(w),_=De(Qe(),w);return z(()=>_s({repoLabel:_,projectDir:E,loopName:m,loops:Ie,loopNav:oo,onSelectLoop:(ee)=>N({loopName:ee}),onBackToRepos:()=>N({projectId:null,loopName:null,section:\"loops\"}),onBackToRepo:()=>N({loopName:null})}))}),io=b(()=>{let m=r(),w=u();if(!m||w)return\"\";return Us({section:i(),counts:so(),onNavigate:(E)=>N({section:E,loopName:null,groupId:null})})}),lo=b(()=>r()!==null&&u()===null&&i()===\"loops\"),co=b(()=>{if(!lo())return\"\";return z(()=>Is({counts:ro,statuses:Xe,query:S,sortMode:P,onToggleStatus:ve,onChangeQuery:de,onChangeSort:xe,externalNav:V}))}),uo=b(()=>{if(!ln())return\"\";let m=ht();return m?Gs({dashLoop:m,now:J,routeTab:h,onSelectTab:q}):\"\"}),fo=b(()=>{let m=cn();return m?is(m.loops,P()):[]},[],{equals:Kt}),po=b(()=>{if(!ln())return\"\";if(ht())return\"\";if(!r())return\"\";return d`
\n ${Vs({loops:fo,now:J,onOpen:(m)=>W(m)})}\n ${()=>cn()?\"\":rn()}\n
`}),go=b(()=>{if(i()!==\"groups\"||!p())return null;let m=le();if(!m)return null;return m.groups.find((w)=>w.group.groupId===p())??null}),mo=b(()=>{let m=le();if(!m)return new Set;return new Set(m.loops.map((w)=>w.loop.loopName))}),ho=b(()=>{if(!eo())return\"\";if(!r())return\"\";if(p())return Ys({group:go,loopNames:mo,projectId:()=>r(),onBack:()=>N({groupId:null}),onOpenLoop:W});return zs({groups:()=>le()?.groups??[],onOpen:(w)=>N({groupId:w})})}),un=b(()=>{let m=le();return m?m.loops:[]}),bo=b(()=>{if(!to())return\"\";if(!r())return\"\";return Xs({loops:un,projectId:()=>r(),onOpenLoop:W})}),vo=b(()=>{if(!no())return\"\";if(!r())return\"\";return Qs({loops:un,onOpenLoop:W})});G(()=>{if(!n())return;let m=r();if(m===null)return;if(!e.projects.some((E)=>E.projectId===m))a(null),f(null),l(\"loops\"),v([]),M(\"\")}),G(()=>{let m=r(),w=u();if(!m||!w)return;let E=le();if(!E)return;if(!E.loops.some((ee)=>ee.loop.loopName===w))f(null)}),G(()=>{let m=u(),w=h();if(!r()||!m||w===\"overview\")return;let E=ht();if(!E)return;if(!pt(E).includes(w))y(\"overview\")}),G(()=>{if(!n())return;os(ft(T()),j)});let yo=b(()=>(r()??\"\")+\"\\x00\"+(u()??\"\"));return G(xn(yo,()=>{$e()},{defer:!0})),Sn(()=>{Ye(dt(location.hash)),$e();let m=setInterval(()=>{$e()},5000),w=()=>{if(j.current){j.current=!1;return}Ye(dt(location.hash)),O((E)=>E+1)};window.addEventListener(\"hashchange\",w),fe(()=>{clearInterval(m),window.removeEventListener(\"hashchange\",w)})}),d`
\n ${Rs({generatedAt:()=>e.generatedAt,breadcrumb:ao})}\n\n ${()=>{let m=k();return m?d`
${m}
`:\"\"}}\n\n ${()=>{if(!n())return\"\";if(an()&&Je().length===0)return rn();return d`
\n ${()=>{if(an())return d`
\n ${Os({entries:()=>Je(),labels:()=>Qe(),onSelect:(m)=>N({projectId:m,loopName:null,section:\"loops\",groupId:null})})}\n ${js({entries:()=>Je(),labels:()=>Qe(),onOpenLoop:(m,w)=>N({projectId:m,loopName:w,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})})}\n
`;return d`
\n ${co}\n ${io}\n ${po}\n ${uo}\n ${ho}\n ${bo}\n ${vo}\n
`}}\n
`}}\n
`}var Zs=document.getElementById(\"forge-app-root\");if(Zs)Rn(Js,Zs);\n"; export const DASHBOARD_APP_SOURCE_HASH: string = "8fd787d9c87fbecd1044b5404fd215677df04d8a07d4ee1952eeaca163c76acf"; diff --git a/src/hooks/forge-session-attach.ts b/src/hooks/forge-session-attach.ts index dc58196fb..4097dd1da 100644 --- a/src/hooks/forge-session-attach.ts +++ b/src/hooks/forge-session-attach.ts @@ -2,6 +2,7 @@ import type { Logger } from '../types' import type { ForgeClient } from '../client/port' import type { ForgeExecutionServiceDeps, ForgeLoopExtra, PlanSource } from '../services/execution' import { attachLoopToSession } from '../services/execution' +import { isLoopResumeSnapshot } from '../loop/resume-snapshot' import { resolveSandboxContextForLoop, isSandboxEnabled } from '../sandbox/context' import { classifyForgeWorkspace, isPendingAttachWorkspace } from '../workspace/classify-stale' import { removeForgeWorkspaceWithContext } from '../workspace/remove-with-context' @@ -114,9 +115,7 @@ async function attachForgeSession( return } - const cfg = (ws.extra ?? {}).forgeLoop as - | (Partial & { maxIterations?: number }) - | undefined + const cfg = (ws.extra ?? {}).forgeLoop as Partial | undefined if (cfg?.initialPromptOwner === 'tui' && sendInitialPrompt) { deps.logger.log(`[forge-session-attach] skip session=${sessionId} loop=${loopName} reason=tui-owned-initial-prompt`) @@ -241,26 +240,31 @@ async function attachForgeSession( ? cfg.hostSessionId : sessionId - const planSource: PlanSource = - cfg.planSource === 'inline' && cfg.planText - ? { kind: 'inline', planText: cfg.planText } - : { kind: 'stored', sessionId: resolvedHostSessionId } + const resume = isLoopResumeSnapshot(cfg.resume) ? cfg.resume : undefined let planText: string - if (planSource.kind === 'inline') { - planText = planSource.planText + if (resume !== undefined) { + planText = cfg.planText ?? '' } else { - const row = deps.execDeps.plansRepo.getForSession(sessionProjectId, planSource.sessionId) - if (!row) { - deps.logger.error(`[forge-session-attach] plan not found for session=${planSource.sessionId} loop=${loopName} workspace=${workspaceId}`) - publishAttachFailureToast(deps, ws.directory ?? deps.directory, `Forge loop "${loopName}"`, 'No stored plan found for this loop. Re-run "Execute → Loop" from a session that has a captured plan.') - await removeForgeWorkspaceWithContext( - { client: deps.client, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, - { workspaceId, loopName, action: 'remove-fully', reasonLabel: 'attach-no-plan' }, - ) - return + const planSource: PlanSource = + cfg.planSource === 'inline' && cfg.planText + ? { kind: 'inline', planText: cfg.planText } + : { kind: 'stored', sessionId: resolvedHostSessionId } + if (planSource.kind === 'inline') { + planText = planSource.planText + } else { + const row = deps.execDeps.plansRepo.getForSession(sessionProjectId, planSource.sessionId) + if (!row) { + deps.logger.error(`[forge-session-attach] plan not found for session=${planSource.sessionId} loop=${loopName} workspace=${workspaceId}`) + publishAttachFailureToast(deps, ws.directory ?? deps.directory, `Forge loop "${loopName}"`, 'No stored plan found for this loop. Re-run "Execute → Loop" from a session that has a captured plan.') + await removeForgeWorkspaceWithContext( + { client: deps.client, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, + { workspaceId, loopName, action: 'remove-fully', reasonLabel: 'attach-no-plan' }, + ) + return + } + planText = row.content } - planText = row.content } try { @@ -285,6 +289,7 @@ async function attachForgeSession( sandboxEnabled: sandbox.enabled, sandboxContainer: sandbox.containerName, planText, + resume, selectSession, selectSessionTiming: 'after-prompt', startWatchdog: true, diff --git a/src/hooks/host-side-effects.ts b/src/hooks/host-side-effects.ts index d5365b527..b353eec9d 100644 --- a/src/hooks/host-side-effects.ts +++ b/src/hooks/host-side-effects.ts @@ -133,6 +133,7 @@ function getToastVariant(reason: TerminationReason): { variant: 'info' | 'succes return { variant: 'success', duration: 5000 } case 'cancelled': case 'user_aborted': + case 'migrated': return { variant: 'info', duration: 3000 } case 'max_iterations': return { variant: 'warning', duration: 3000 } @@ -156,6 +157,8 @@ function getToastMessage(state: LoopState, reason: TerminationReason): string { return `Stalled after ${iterLabel}` case 'user_aborted': return 'Loop aborted by user' + case 'migrated': + return `Migrated to ${reason.message}` default: return `Loop ended: ${terminationReasonToString(reason)}` } @@ -298,5 +301,7 @@ function resolveReasonLabel(reason: TerminationReason): string { return 'aborted' case 'max_iterations': return 'max_iterations' + case 'migrated': + return 'migrated' } } diff --git a/src/loop/restartability.ts b/src/loop/restartability.ts index 477592f7e..75d0873ee 100644 --- a/src/loop/restartability.ts +++ b/src/loop/restartability.ts @@ -11,6 +11,7 @@ export type RestartBlockedReason = | 'completed' | 'missing_worktree' | 'active_requires_force' + | 'migrated' export interface RestartabilityResult { restartable: boolean @@ -61,6 +62,14 @@ export function getRestartability( restartBlockedMessage: `Loop "${state.loopName}" completed successfully and cannot be restarted.`, } } + if (parsed.kind === 'migrated') { + return { + restartable: false, + restartRequiresForce: false, + restartBlockedReason: 'migrated', + restartBlockedMessage: `Loop "${state.loopName}" was migrated to remote "${parsed.message}"; manage it on that server.`, + } + } } // Missing worktree directory: the forge scratch branch is never deleted, so if diff --git a/src/loop/resume-prompt.ts b/src/loop/resume-prompt.ts new file mode 100644 index 000000000..fdb8ae4da --- /dev/null +++ b/src/loop/resume-prompt.ts @@ -0,0 +1,116 @@ +import type { PluginConfig } from '../types' +import type { LoopService } from './service' +import type { LoopState } from './state' +import type { ResolvedPostActionConfig } from './post-action-config' +import { resolvePostActionConfig } from './post-action-config' +import { auditorModelChoiceAt, buildAuditorModelChain } from '../utils/loop-helpers' +import { parseModelString } from '../utils/model-fallback' + +export type ResumePhase = 'coding' | 'final_auditing' | 'post_action' + +export interface ResumePromptPlan { + phase: ResumePhase + promptText: string + agent: 'code' | 'auditor-loop' + permission: 'loop' | 'audit' + model?: { providerID: string; modelID: string } + fallbackModel?: { providerID: string; modelID: string } + variant?: string + auditorModel?: string + postAction?: ResolvedPostActionConfig +} + +export function normalizeModelString(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} + +/** + * A stopped final_audit_fix loop is a coding pass (the fix session), not an + * auditor phase — restart it as coding with the code prompt agent. The other + * auditor phases (final_auditing, post_action) preserve their persisted phase. + */ +export function resolveResumePhase(phase: LoopState['phase']): ResumePhase { + return phase === 'final_auditing' + ? 'final_auditing' + : phase === 'post_action' + ? 'post_action' + : 'coding' +} + +/** + * Single owner of the restart prompt selection: maps a stopped loop's persisted + * state to the resume phase, prompt text, agent, permission, and model used to + * re-dispatch it. handleLoopRestart consumes the plan instead of inlining this + * chain so restart behavior has exactly one definition. + */ +export function buildResumePromptPlan(input: { + service: LoopService + config: PluginConfig + state: LoopState +}): ResumePromptPlan { + const { service, config, state } = input + + const phase = resolveResumePhase(state.phase) + + const postActionCfg = state.phase === 'post_action' ? resolvePostActionConfig(config) : undefined + + let promptText: string + if (state.phase === 'post_action') { + promptText = service.buildPostActionPrompt(state, { skill: postActionCfg?.skill, prompt: postActionCfg?.prompt }) + } else if (state.kind === 'goal') { + // Goal loops have no plan, sections, or approval flow — restate the goal + // directly as a fresh coding pass. No initial audit findings on restart. + promptText = service.buildContinuationPrompt(state, undefined) + } else if (state.phase === 'final_audit_fix') { + // Resume fixing the final-audit findings rather than re-coding the last + // section: the persisted findings carry the remediation for this + // recovery path. + const outstandingBugs = service.getOutstandingFindings(state.loopName, 'bug') + promptText = service.buildFinalAuditFixPrompt(state, outstandingBugs) + } else if (state.totalSections > 0) { + // Use persisted section state to build the correct section prompt + if (state.phase === 'final_auditing') { + promptText = service.buildFinalAuditPrompt(state) + } else { + promptText = service.buildSectionInitialPrompt(state) + } + } else { + // Legacy non-sectioned prompt + promptText = state.prompt ?? '' + } + + const restartAuditorState = { + ...state, + auditorModel: normalizeModelString(state.auditorModel ?? config.auditorModel), + modelFailed: false, + auditorFallbackIndex: 0, + } + const restartAuditorChoice = auditorModelChoiceAt(buildAuditorModelChain(config, restartAuditorState), 0) + const restartAuditorModel = restartAuditorChoice.model + const model = state.phase === 'post_action' && postActionCfg?.model + ? parseModelString(postActionCfg.model) + : state.phase === 'final_auditing' || state.phase === 'post_action' + ? restartAuditorModel + : parseModelString(state.executionModel) ?? parseModelString(config.executionModel) + // When a configured post-action model is used, fall back to the loop's auditor model if it fails. + const fallbackModel = state.phase === 'post_action' && postActionCfg?.model + ? restartAuditorModel + : undefined + + // final_audit_fix is a coding-style phase: restart sends the final-audit fix + // prompt as the code agent (never the auditor-loop agent). + const agent = phase === 'final_auditing' ? 'auditor-loop' as const : 'code' as const + + return { + phase, + promptText, + agent, + permission: phase === 'final_auditing' ? 'audit' : 'loop', + model, + fallbackModel, + variant: agent === 'auditor-loop' ? restartAuditorChoice.variant : state.executionVariant, + auditorModel: restartAuditorState.auditorModel, + postAction: postActionCfg, + } +} diff --git a/src/loop/resume-snapshot.ts b/src/loop/resume-snapshot.ts new file mode 100644 index 000000000..64a25f3bf --- /dev/null +++ b/src/loop/resume-snapshot.ts @@ -0,0 +1,109 @@ +import type { SectionPlanRow } from '../storage/repos/section-plans-repo' +import type { ReviewFindingRow } from '../storage/repos/review-findings-repo' +import type { SectionPlansRepo } from '../storage/repos/section-plans-repo' +import type { ReviewFindingsRepo } from '../storage/repos/review-findings-repo' +import type { LoopState } from './state' +import { resolveResumePhase, type ResumePhase } from './resume-prompt' +import { isRecord } from '../utils/is-record' + +/** + * Host-independent snapshot of a loop's resumable progress. Section rows and + * findings are stripped of project/loop scoping and timestamps so the payload + * can be serialized across a migration boundary and re-attached under a + * different project id / loop name. + */ +export interface LoopResumeSnapshot { + version: 1 + kind: 'plan' | 'goal' + phase: ResumePhase + currentSectionIndex: number + totalSections: number + finalAuditDone: boolean + /** Goal text, present only for goal loops. */ + goal?: string + sections: ResumeSectionRow[] + findings: ResumeFindingRow[] +} + +export type ResumeSectionRow = Omit + +export type ResumeFindingRow = Pick + +const RESUME_PHASES: readonly ResumePhase[] = ['coding', 'final_auditing', 'post_action'] + +export function captureLoopResumeSnapshot(input: { + projectId: string + state: LoopState + sectionPlansRepo: SectionPlansRepo + reviewFindingsRepo: ReviewFindingsRepo +}): LoopResumeSnapshot { + const { projectId, state, sectionPlansRepo, reviewFindingsRepo } = input + const sections = sectionPlansRepo.list(projectId, state.loopName).map(({ projectId: _p, loopName: _l, createdAt: _c, ...row }) => row) + const findings = reviewFindingsRepo + .listByLoopName(projectId, state.loopName) + .map((f) => ({ + file: f.file, + line: f.line, + severity: f.severity, + description: f.description, + scenario: f.scenario, + sectionIndex: f.sectionIndex, + })) + return { + version: 1, + kind: state.kind ?? 'plan', + phase: resolveResumePhase(state.phase), + currentSectionIndex: state.currentSectionIndex, + totalSections: state.totalSections, + finalAuditDone: state.finalAuditDone, + ...(state.kind === 'goal' && state.goal ? { goal: state.goal } : {}), + sections, + findings, + } +} + +export function restoreLoopResumeRows(input: { + projectId: string + loopName: string + snapshot: LoopResumeSnapshot + sectionPlansRepo: SectionPlansRepo + reviewFindingsRepo: ReviewFindingsRepo +}): void { + const { projectId, loopName, snapshot, sectionPlansRepo, reviewFindingsRepo } = input + sectionPlansRepo.immediateTransaction(() => { + sectionPlansRepo.restoreAll( + snapshot.sections.map((section) => ({ + ...section, + projectId, + loopName, + createdAt: Date.now(), + })), + ) + for (const finding of snapshot.findings) { + reviewFindingsRepo.write({ + projectId, + loopName, + file: finding.file, + line: finding.line, + severity: finding.severity, + description: finding.description, + scenario: finding.scenario, + sectionIndex: finding.sectionIndex, + }) + } + }) +} + +export function isLoopResumeSnapshot(value: unknown): value is LoopResumeSnapshot { + if (!isRecord(value)) return false + if (value.version !== 1) return false + if (value.kind !== 'plan' && value.kind !== 'goal') return false + if (!RESUME_PHASES.includes(value.phase as ResumePhase)) return false + if (typeof value.currentSectionIndex !== 'number') return false + if (typeof value.totalSections !== 'number') return false + if (typeof value.finalAuditDone !== 'boolean') return false + if (value.goal !== undefined && typeof value.goal !== 'string') return false + if (!Array.isArray(value.sections)) return false + if (!Array.isArray(value.findings)) return false + return true +} diff --git a/src/loop/termination.ts b/src/loop/termination.ts index abe1f31c7..40171b820 100644 --- a/src/loop/termination.ts +++ b/src/loop/termination.ts @@ -16,6 +16,7 @@ export type TerminationReason = | { kind: 'worktree_failed'; message: string } | { kind: 'error_max_retries'; message: string } | { kind: 'provider_limit'; message: string } + | { kind: 'migrated'; message: string } export function terminationStatusFor(reason: TerminationReason): TerminationStatus { switch (reason.kind) { @@ -24,6 +25,7 @@ export function terminationStatusFor(reason: TerminationReason): TerminationStat case 'cancelled': case 'user_aborted': case 'shutdown': + case 'migrated': return 'cancelled' case 'stall_timeout': return 'stalled' @@ -40,6 +42,8 @@ export function terminationReasonToString(reason: TerminationReason): string { return `error_max_retries: ${reason.message}` case 'provider_limit': return `provider_limit: ${reason.message}` + case 'migrated': + return `migrated: ${reason.message}` default: return reason.kind } @@ -53,6 +57,7 @@ export function parseTerminationReasonString(str: string): TerminationReason { if (prefix === 'worktree_failed') return { kind: 'worktree_failed', message } if (prefix === 'error_max_retries') return { kind: 'error_max_retries', message } if (prefix === 'provider_limit') return { kind: 'provider_limit', message } + if (prefix === 'migrated') return { kind: 'migrated', message } } // For simple kinds without messages diff --git a/src/prompts/commands/loop-migrate.md b/src/prompts/commands/loop-migrate.md new file mode 100644 index 000000000..a688d4fe4 --- /dev/null +++ b/src/prompts/commands/loop-migrate.md @@ -0,0 +1,15 @@ +## Step 1: Identify the Loop + +Run `loop-status` to see all active loops if you don't know the name. + +## Step 2: Migrate the Loop + +Run `loop-migrate` with: +- name: The worktree name of the loop to migrate +- remote: The configured remote to migrate to (the `remotes[].name` values in the plugin config) + +## Step 3: Report the Migration + +Confirm the loop was migrated: report the remote loop name and remote session id, and note that the local loop is stopped as migrated and cannot be restarted here. + +$ARGUMENTS diff --git a/src/services/execution-response.ts b/src/services/execution-response.ts new file mode 100644 index 000000000..5bcb5e33d --- /dev/null +++ b/src/services/execution-response.ts @@ -0,0 +1,41 @@ +/** + * Shared response construction for the Forge execution command bus. + * + * `ok`/`fail` builders plus the response/error/warning shapes, in one module + * so command handlers (e.g. the restart handler in execution.ts and the + * migration handler in loop-migration.ts) cannot drift in error construction. + */ + +export interface ForgeExecutionError { + code: 'bad_request' | 'not_found' | 'conflict' | 'disabled' | 'prompt_failed' | 'lifecycle_failed' | 'internal_error' | 'provider_limit' + status: number + message: string + candidates?: string[] + details?: Record +} + +export interface ForgeExecutionWarning { + code: string + message: string +} + +export type ForgeExecutionResponse = + | { ok: true; data: T; warnings?: ForgeExecutionWarning[] } + | { ok: false; error: ForgeExecutionError } + +export function ok(data: T, warnings?: ForgeExecutionWarning[]): ForgeExecutionResponse { + return { ok: true, data, warnings } +} + +export function fail( + code: ForgeExecutionError['code'], + status: number, + message: string, + details?: Record, + candidates?: string[], +): ForgeExecutionResponse { + return { + ok: false, + error: { code, status, message, details, candidates } + } +} diff --git a/src/services/execution.ts b/src/services/execution.ts index 7a173fe85..a1ac04ecf 100644 --- a/src/services/execution.ts +++ b/src/services/execution.ts @@ -15,7 +15,6 @@ import type { createLoopEventHandler } from '../hooks' import type { SandboxManager } from '../sandbox/manager' import { extractPlanExecutionMetadata, createPlanExecutionSession } from '../utils/plan-execution' import { parseModelString } from '../utils/model-fallback' -import { auditorModelChoiceAt, buildAuditorModelChain } from '../utils/loop-helpers' import { classifyProviderLimit, extractErrorSignal } from '../loop/provider-limit' import { formatLoopSessionTitle, formatPlanSessionTitle } from '../utils/session-titles' @@ -37,7 +36,16 @@ import { getRestartability, type RestartBlockedReason } from '../loop/restartabi import { loopBranchExists } from '../workspace/forge-naming' import { getWorktreeProjectPreconditionError } from '../workspace/forge-worktree' import { resolveHostSessionDirectory } from '../utils/resolve-project-root' -import { resolvePostActionConfig, type ResolvedPostActionConfig } from '../loop/post-action-config' +import { resolvePostActionConfig } from '../loop/post-action-config' +import { buildResumePromptPlan, resolveResumePhase } from '../loop/resume-prompt' +import { restoreLoopResumeRows, type LoopResumeSnapshot } from '../loop/resume-snapshot' +import { migrateLoopToRemote } from './loop-migration' +import { + ok, + fail, + type ForgeExecutionError, + type ForgeExecutionResponse, +} from './execution-response' /** * A freshly created + warped loop session can transiently report "Session not @@ -100,6 +108,10 @@ export interface ForgeLoopExtra { sandboxEnabled?: boolean /** msb container name when the loop runs sandboxed. */ sandboxContainer?: string + /** Persisted progress snapshot for a loop migrated from another host; applied on attach instead of plan decomposition. */ + resume?: LoopResumeSnapshot + /** Iteration budget carried on the workspace extra; read by the attach hook and remote/migrated launches. */ + maxIterations?: number } export interface AttachLoopInput { @@ -125,6 +137,8 @@ export interface AttachLoopInput { goal?: string /** Executor session binding for goal loops (the dedicated code session). */ executorSessionId?: string + /** Migrated-loop progress snapshot; restores phase/section pointers and rows instead of decomposing planText. */ + resume?: LoopResumeSnapshot selectSession?: boolean selectSessionTiming?: 'after-create' | 'after-prompt' startWatchdog?: boolean @@ -260,6 +274,13 @@ export interface GetLoopStatusCommand { limit?: number } +export interface MigrateLoopCommand { + type: 'loop.migrate' + /** Migration always targets a named loop; only-active has no single owner. */ + selector: Exclude + remoteName: string +} + export type ForgeExecutionCommand = | ExecutePlanNewSessionCommand | ExecutePlanHereCommand @@ -268,27 +289,14 @@ export type ForgeExecutionCommand = | RestartLoopCommand | CancelLoopCommand | GetLoopStatusCommand + | MigrateLoopCommand // ============================================================================ -// Response/Error Types +// Response/Error Types (single definition in ./execution-response) // ============================================================================ -export interface ForgeExecutionError { - code: 'bad_request' | 'not_found' | 'conflict' | 'disabled' | 'prompt_failed' | 'lifecycle_failed' | 'internal_error' | 'provider_limit' - status: number - message: string - candidates?: string[] - details?: Record -} - -export interface ForgeExecutionWarning { - code: string - message: string -} - -export type ForgeExecutionResponse = - | { ok: true; data: T; warnings?: ForgeExecutionWarning[] } - | { ok: false; error: ForgeExecutionError } +export type { ForgeExecutionError, ForgeExecutionWarning, ForgeExecutionResponse } from './execution-response' +export { ok, fail } from './execution-response' // ============================================================================ // Result Types per Command @@ -403,6 +411,19 @@ export interface LoopStatusResult { recent: LoopStatusView[] } +export interface LoopMigratedResult { + operation: 'loop.migrate' + loopName: string + remoteName: string + remoteLoopName: string + remoteSessionId: string + startRef: string + syncRef: string + phase: import('../loop/resume-prompt').ResumePhase + currentSectionIndex: number + totalSections: number +} + // Type mapping from command to result export type ForgeExecutionResult = C extends ExecutePlanNewSessionCommand ? PlanExecutionStartedResult : @@ -412,6 +433,7 @@ export type ForgeExecutionResult = C extends RestartLoopCommand ? LoopRestartedResult : C extends CancelLoopCommand ? LoopCancelledResult : C extends GetLoopStatusCommand ? LoopStatusResult : + C extends MigrateLoopCommand ? LoopMigratedResult : never // ============================================================================ @@ -446,33 +468,17 @@ export interface ForgeExecutionServiceDeps { loopSessionUsageRepo?: import('../storage/repos/loop-session-usage-repo').LoopSessionUsageRepo workspaceStatusRegistry: import('../utils/workspace-status-registry').WorkspaceStatusRegistry pendingTeardowns: import('../workspace/pending-teardown').PendingTeardownRegistry + /** Git service for loop.migrate branch-tip/sync-ref pushes; defaults to defaultGitService. */ + git?: import('../utils/git-service').GitService + /** Remote client factory for loop.migrate discovery; defaults to createRemoteForgeClient. */ + createRemoteClient?: (opts: import('../client/sdk-adapter').RemoteClientOptions) => ForgeClient } // ============================================================================ // Helper Functions // ============================================================================ -function normalizeModelString(value: string | undefined): string | undefined { - const trimmed = value?.trim() - return trimmed ? trimmed : undefined -} - -function ok(data: T, warnings?: ForgeExecutionWarning[]): ForgeExecutionResponse { - return { ok: true, data, warnings } -} - -function fail( - code: ForgeExecutionError['code'], - status: number, - message: string, - details?: Record, - candidates?: string[] -): ForgeExecutionResponse { - return { - ok: false, - error: { code, status, message, details, candidates } - } -} +// `ok`/`fail` are imported from ./execution-response. // ============================================================================ // Plan Source Resolution @@ -621,8 +627,9 @@ export async function attachLoopToSession( kind, goal, executorSessionId, + resume, } = input - const isGoal = kind === 'goal' + const isGoal = (resume?.kind ?? kind) === 'goal' const loopModel = parseModelString(executionModel) @@ -658,6 +665,10 @@ export async function attachLoopToSession( (await resolveHostSessionDirectory(deps.client, input.hostSessionId, ctx.directory, deps.logger)) ?? ctx.directory try { + if (resume && (!deps.sectionPlansRepo || !deps.reviewFindingsRepo)) { + return { ok: false, code: 'internal_error', message: 'Resume requires section and finding repositories' } + } + // Persist loop state const state: import('../loop/state').LoopState = { active: true, @@ -670,7 +681,7 @@ export async function attachLoopToSession( maxIterations, startedAt: new Date().toISOString(), prompt: isGoal ? undefined : planText, - phase: 'coding', + phase: resume ? resume.phase : 'coding', errorCount: 0, auditCount: 0, status: 'running', @@ -683,16 +694,32 @@ export async function attachLoopToSession( auditorVariant, workspaceId, hostSessionId: input.hostSessionId, - currentSectionIndex: 0, - totalSections: 0, - finalAuditDone: false, - ...(isGoal ? { kind: 'goal' as const, goal, executorSessionId } : {}), + currentSectionIndex: resume ? resume.currentSectionIndex : 0, + totalSections: resume ? resume.totalSections : 0, + finalAuditDone: resume ? resume.finalAuditDone : false, + ...(isGoal + ? { + kind: 'goal' as const, + goal: resume ? resume.goal ?? goal : goal, + executorSessionId: resume ? sessionId : executorSessionId, + } + : {}), } deps.loop.service.setState(loopName, state) deps.loop.service.registerLoopSession(sessionId, loopName) deps.loop.registerSessionReverseIndex(sessionId, loopName) + if (resume) { + restoreLoopResumeRows({ + projectId: ctx.projectId, + loopName, + snapshot: resume, + sectionPlansRepo: deps.sectionPlansRepo!, + reviewFindingsRepo: deps.reviewFindingsRepo!, + }) + } + deps.logger.log(`attachLoopToSession: state stored for loop=${loopName}`) onStarted?.({ @@ -705,7 +732,14 @@ export async function attachLoopToSession( // === Initial prompt === let promptText: string - if (isGoal) { + let resumePlan: ReturnType | undefined + if (resume) { + // A migrated loop must not be re-decomposed: its persisted section rows + // (including in-progress state and attempts) were restored above, and + // decomposition would reset section 0 / rewrite the rows. + resumePlan = buildResumePromptPlan({ service: deps.loop.service, config: deps.config, state }) + promptText = resumePlan.promptText + } else if (isGoal) { // Goal loops have no sections; the initial prompt is the same goal // continuation prompt used on every later iteration. promptText = deps.loop.service.buildContinuationPrompt(state) @@ -768,13 +802,16 @@ export async function attachLoopToSession( const sessionDir = worktreeDir const promptParts = [{ type: 'text' as const, text: promptText }] const workspaceParam = workspaceId ? { workspace: workspaceId } : {} + const promptAgent = resumePlan ? resumePlan.agent : 'code' as const + const promptVariant = resumePlan ? resumePlan.variant : undefined const promptResult = await sendLoopPrompt({ loopName, sessionId, - agent: 'code', + agent: promptAgent, logger: deps.logger, - primaryModel: loopModel, + primaryModel: resumePlan ? resumePlan.model : loopModel, + fallbackModel: resumePlan?.fallbackModel, useInFlightGuard: false, performPrompt: async (model) => { markPromptSent(loopName, sessionId, deps.logger) @@ -783,7 +820,8 @@ export async function attachLoopToSession( sessionID: sessionId, directory: sessionDir, parts: promptParts, - agent: 'code', + agent: promptAgent, + ...(promptVariant ? { variant: promptVariant } : {}), ...workspaceParam, ...(model ? { model } : {}), }) @@ -1832,6 +1870,12 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo const permissionOptions = await resolveLoopPermissionOptionsForWorkspace(deps.client, deps.config, stoppedState.workspaceId) const permissionRuleset = buildLoopPermissionRuleset(permissionOptions) + // The recreated session's permission ruleset is selected before the session + // swap, so the resume phase is resolved from the persisted (under-lock) + // state here. The full prompt plan is built after the optional plan + // decomposition below, mirroring the original prompt-selection ordering. + const resumePhase = resolveResumePhase(stoppedState.phase) + stoppedState.iteration = 1 // Create new session for restart @@ -1877,7 +1921,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo totalSections: stoppedState.totalSections ?? 0, }), directory: stoppedState.worktreeDir, - permission: stoppedState.phase === 'final_auditing' ? buildAuditSessionPermissionRuleset(permissionOptions) : permissionRuleset, + permission: resumePhase === 'final_auditing' ? buildAuditSessionPermissionRuleset(permissionOptions) : permissionRuleset, workspaceId: stoppedState.workspaceId, loopName: stoppedState.loopName, logPrefix: 'loop-restart', @@ -1924,14 +1968,17 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo // else: existing totalSections preserved as-is const effectiveSessionId = newSessionId! - // A stopped final_audit_fix loop is a coding pass (the fix session), not an - // auditor phase — restart it as coding with the code prompt agent. The other - // auditor phases (final_auditing, post_action) preserve their persisted phase. - const restartPhase = stoppedState.phase === 'final_auditing' - ? 'final_auditing' as const - : stoppedState.phase === 'post_action' - ? 'post_action' as const - : 'coding' as const + // Full resume plan after the optional in-restart plan decomposition, so a + // legacy non-sectioned loop resumes with the freshly decomposed section + // prompt and the prompt builders run exactly once, as before the refactor. + // Decomposition cannot change the resume phase; the assertion guards that + // invariant so the pre-swap permission selection can never drift from the + // phase the plan dispatches under. + const resume = buildResumePromptPlan({ service: deps.loop.service, config: deps.config, state: stoppedState }) + const restartPhase = resume.phase + if (resume.phase !== resumePhase) { + throw new Error(`resume phase changed after decomposition: ${resumePhase} -> ${resume.phase}`) + } const newState: import('../loop/state').LoopState = { active: true, @@ -1965,58 +2012,11 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo kind: stoppedState.kind, goal: stoppedState.goal, } - // Build appropriate prompt based on persisted state - let promptText: string - let postActionCfg: ResolvedPostActionConfig | undefined - - if (stoppedState.phase === 'post_action') { - postActionCfg = resolvePostActionConfig(deps.config) - promptText = deps.loop.service.buildPostActionPrompt(stoppedState, { skill: postActionCfg.skill, prompt: postActionCfg.prompt }) - } else if (stoppedState.kind === 'goal') { - // Goal loops have no plan, sections, or approval flow — restate the goal - // directly as a fresh coding pass. No initial audit findings on restart. - promptText = deps.loop.service.buildContinuationPrompt(stoppedState, undefined) - } else if (stoppedState.phase === 'final_audit_fix') { - // Resume fixing the final-audit findings rather than re-coding the last - // section: the persisted findings carry the remediation for this - // recovery path. - const outstandingBugs = deps.loop.service.getOutstandingFindings(stoppedState.loopName, 'bug') - promptText = deps.loop.service.buildFinalAuditFixPrompt(stoppedState, outstandingBugs) - } else if (stoppedState.totalSections > 0) { - // Use persisted section state to build the correct section prompt - if (stoppedState.phase === 'final_auditing') { - promptText = deps.loop.service.buildFinalAuditPrompt(stoppedState) - } else { - promptText = deps.loop.service.buildSectionInitialPrompt(stoppedState) - } - } else { - // Legacy non-sectioned prompt - promptText = stoppedState.prompt ?? '' - } - const restartAuditorState = { - ...stoppedState, - auditorModel: normalizeModelString(stoppedState.auditorModel ?? deps.config.auditorModel), - modelFailed: false, - auditorFallbackIndex: 0, - } - const restartAuditorChoice = auditorModelChoiceAt(buildAuditorModelChain(deps.config, restartAuditorState), 0) - const restartAuditorModel = restartAuditorChoice.model - const loopModel = stoppedState.phase === 'post_action' && postActionCfg?.model - ? parseModelString(postActionCfg.model) - : stoppedState.phase === 'final_auditing' || stoppedState.phase === 'post_action' - ? restartAuditorModel - : parseModelString(stoppedState.executionModel) ?? parseModelString(deps.config.executionModel) - // When a configured post-action model is used, fall back to the loop's auditor model if it fails. - const loopFallbackModel = stoppedState.phase === 'post_action' && postActionCfg?.model - ? restartAuditorModel - : undefined + const loopModel = resume.model + const loopFallbackModel = resume.fallbackModel const workspaceParam = stoppedState.workspaceId ? { workspace: stoppedState.workspaceId } : {} - // final_audit_fix is a coding-style phase: restart sends the final-audit fix - // prompt as the code agent (never the auditor-loop agent). - const promptAgent = stoppedState.phase === 'final_auditing' ? 'auditor-loop' as const : 'code' as const - deps.loopsRepo.restart(ctx.projectId, stoppedState.loopName, { sessionId: newState.sessionId, phase: newState.phase, @@ -2025,7 +2025,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo sandbox: newState.sandbox ?? false, sandboxContainer: newState.sandboxContainer ?? null, workspaceId: newState.workspaceId ?? null, - auditorModel: restartAuditorState.auditorModel ?? null, + auditorModel: resume.auditorModel ?? null, currentSectionIndex: newState.currentSectionIndex, totalSections: newState.totalSections, finalAuditDone: newState.finalAuditDone, @@ -2054,19 +2054,15 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo }) } - const restartVariant = promptAgent === 'auditor-loop' - ? restartAuditorChoice.variant - : stoppedState.executionVariant - const performRestartPrompt = async (model?: { providerID: string; modelID: string }): Promise<{ error?: unknown }> => { markPromptSent(stoppedState.loopName, effectiveSessionId, deps.logger) try { await deps.client.session.promptAsync({ sessionID: effectiveSessionId, directory: stoppedState.worktreeDir, - parts: [{ type: 'text' as const, text: promptText }], - agent: promptAgent, - ...(model ? { model, ...(restartVariant ? { variant: restartVariant } : {}) } : {}), + parts: [{ type: 'text' as const, text: resume.promptText }], + agent: resume.agent, + ...(model ? { model, ...(resume.variant ? { variant: resume.variant } : {}) } : {}), ...workspaceParam, }) return {} @@ -2085,7 +2081,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo const { result } = await sendLoopPrompt({ loopName: stoppedState.loopName, sessionId: effectiveSessionId, - agent: promptAgent, + agent: resume.agent, logger: deps.logger, primaryModel: loopModel, fallbackModel: loopFallbackModel, @@ -2113,7 +2109,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo // For a final-audit restart the session is reused by the auditor // fallback re-dispatch, so keep its reverse index intact. For a // coding-style restart the loop terminates and the session is orphaned. - const isAuditorRestart = promptAgent === 'auditor-loop' + const isAuditorRestart = resume.agent === 'auditor-loop' if (!isAuditorRestart) { deps.loop.unregisterSessionReverseIndex(effectiveSessionId) } @@ -2348,6 +2344,8 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo return handleLoopCancel(ctx, command) as Promise>> case 'loop.restart': return handleLoopRestart(ctx, command) as Promise>> + case 'loop.migrate': + return migrateLoopToRemote(deps, ctx, command) as Promise>> default: return fail('bad_request', 400, 'Unknown command type') as ForgeExecutionResponse> } diff --git a/src/services/loop-migration.ts b/src/services/loop-migration.ts new file mode 100644 index 000000000..c4244a411 --- /dev/null +++ b/src/services/loop-migration.ts @@ -0,0 +1,210 @@ +/** + * Loop migration execution command: moves a local loop's progress to a remote + * opencode server. Freezes the local loop with a `migrated` termination + * reason, pushes the loop branch tip to the shared git remote's forge sync + * ref, and launches the remote loop with a resume snapshot and a + * phase-appropriate first prompt. + * + * Every step composes the owners built in Phases 2-6: remote discovery via + * `connectRemoteProject`, sync-ref push/delete via `pushForgeSyncRef`/ + * `deleteForgeSyncRef`, loop-name reservation + workspace/session creation + * via `launchTuiLoop`, snapshot capture via `captureLoopResumeSnapshot`, and + * prompt selection via `buildResumePromptPlan`. This module only sequences + * them and owns the freeze/rollback semantics. + */ + +import { existsSync } from 'fs' +import type { ForgeExecutionServiceDeps, ForgeExecutionRequestContext, MigrateLoopCommand, LoopMigratedResult } from './execution' +import type { ForgeExecutionResponse, ForgeExecutionWarning } from './execution-response' +import { ok, fail } from './execution-response' +import { connectRemoteProject, pushForgeSyncRef, deleteForgeSyncRef } from '../utils/tui-remote-launch' +import { resolveRemoteLoopPermissionOptions } from '../constants/loop' +import { emitLoopPermissionConfigWarnings } from '../utils/loop-permission-warnings' +import { resolveDataDir } from '../utils/opencode-paths' +import { findPartialMatch } from '../utils/partial-match' +import { getRestartability } from '../loop/restartability' +import { loopBranchExists, forgeBranchName } from '../workspace/forge-naming' +import { resolvePostActionConfig } from '../loop/post-action-config' +import { terminationReasonToString } from '../loop' +import { captureLoopResumeSnapshot } from '../loop/resume-snapshot' +import { buildResumePromptPlan } from '../loop/resume-prompt' +import { reserveTuiLoopName, launchTuiLoop } from '../utils/tui-client' +import { forgeSyncRef } from '../utils/remote-config' +import { defaultGitService } from '../utils/git-service' + +export async function migrateLoopToRemote( + deps: ForgeExecutionServiceDeps, + ctx: ForgeExecutionRequestContext, + command: MigrateLoopCommand, +): Promise> { + // 1. Guards + if (!deps.loopHandler || !deps.sectionPlansRepo || !deps.reviewFindingsRepo) { + return fail('internal_error', 500, 'Loop migration requires the loop handler and section/finding repositories') + } + const debug = (message: string) => deps.logger.debug(message) + + // 2. Resolve the loop (active + recent, same matching as restart) + const allStates = [...deps.loop.listActive(), ...deps.loop.listRecent()] + const { match: state, candidates } = findPartialMatch(command.selector.name, allStates, (s) => [s.loopName, s.worktreeBranch]) + if (!state && candidates.length > 0) { + return fail('conflict', 409, `Multiple loops match "${command.selector.name}". Be more specific.`, undefined, candidates.map((s) => s.loopName)) + } + if (!state) { + return fail('not_found', 404, `No loop found for "${command.selector.name}".`, undefined, allStates.map((s) => s.loopName)) + } + + // 3. Restartability + post-action guard (failures leave the loop untouched) + const git = deps.git ?? defaultGitService + const restartability = getRestartability(state, { + worktreeExists: existsSync, + branchExists: () => loopBranchExists(state, ctx.directory, git), + }) + if (!restartability.restartable) { + return fail('conflict', 409, restartability.restartBlockedMessage ?? `Loop "${state.loopName}" cannot be migrated.`) + } + if (state.phase === 'post_action' && !resolvePostActionConfig(deps.config).enabled) { + return fail('conflict', 409, 'Loop implementation already completed; post-action is disabled — nothing to migrate.') + } + + // 4. Remote connection + project discovery (failures leave the loop untouched) + const connected = await connectRemoteProject( + { remoteName: command.remoteName, localProjectId: ctx.projectId, localDirectory: ctx.directory }, + { config: deps.config, createClient: deps.createRemoteClient, debug }, + ) + if ('error' in connected) { + return fail('bad_request', 400, connected.error) + } + const { remote, project: remoteProject, client: remoteClient } = connected + + // 5. Remote loop name + portable permission rules + const remoteLoopName = await reserveTuiLoopName(remoteClient, null, state.loopName) + const syncRef = forgeSyncRef(remoteLoopName) + const remotePermissionOptions = resolveRemoteLoopPermissionOptions(deps.config) + const warnings: ForgeExecutionWarning[] = [] + emitLoopPermissionConfigWarnings(deps.config, deps.config.dataDir || resolveDataDir(), ctx.directory, { + logger: { log: debug, error: debug, debug }, + onWarnings: (messages) => warnings.push(...messages.map((message) => ({ code: 'loop_permissions', message }))), + }) + + // 6. Freeze: terminate the local loop as migrated (worktree kept — the + // reason is not 'completed', so the teardown commits but preserves the branch). + const migratedReason = { kind: 'migrated' as const, message: remote.name } + if (state.active) { + await deps.loopHandler.terminateLoopByName(state.loopName, migratedReason) + } else { + deps.loop.service.terminate(state.loopName, { + status: 'cancelled', + reason: terminationReasonToString(migratedReason), + completedAt: Date.now(), + }) + } + const frozen = deps.loop.inspect(state.loopName) + if (!frozen) { + return fail('internal_error', 500, `Loop "${state.loopName}" vanished during migration freeze.`) + } + + // 7. Snapshot + branch-tip push. Any failure here rolls the freeze back. + const snapshot = captureLoopResumeSnapshot({ + projectId: ctx.projectId, + state: frozen, + sectionPlansRepo: deps.sectionPlansRepo, + reviewFindingsRepo: deps.reviewFindingsRepo, + }) + const branch = frozen.worktreeBranch || forgeBranchName(frozen.loopName) + const projectDir = frozen.projectDir || ctx.directory + const tipResult = git.revParseRef(projectDir, `refs/heads/${branch}`) + if (!tipResult.ok) { + return rollbackAfterFreeze(deps, frozen, `Failed to resolve loop branch ${branch}: ${tipResult.stderr || '(no stderr)'}`) + } + const tip = tipResult.stdout.trim() + + const pushResult = pushForgeSyncRef(git, { + cwd: projectDir, + gitRemote: remote.gitRemote, + sourceRef: `refs/heads/${branch}`, + syncRef, + }) + if (!pushResult.ok) { + return rollbackAfterFreeze(deps, frozen, `Failed to push loop branch to remote "${remote.name}": ${pushResult.error}`) + } + + // 8+9. Build the phase-appropriate resume prompt and launch the remote loop. + const resume = buildResumePromptPlan({ service: deps.loop.service, config: deps.config, state: frozen }) + const planText = frozen.prompt ?? '' + const launch = await launchTuiLoop({ + client: remoteClient, + directory: remoteProject.worktree, + projectId: remoteProject.id, + requestedLoopName: remoteLoopName, + loopNameReserved: true, + connectPollIntervalMs: 500, + title: frozen.loopName, + plan: planText, + executionModel: frozen.executionModel, + auditorModel: frozen.auditorModel, + executionVariant: frozen.executionVariant, + auditorVariant: frozen.auditorVariant, + extraWorkspaceFields: { + startRef: tip, + syncRef, + gitRemote: remote.gitRemote, + permissionRules: remotePermissionOptions.extraRules, + }, + forgeLoopOverrides: { + sandboxEnabled: remote.sandbox, + maxIterations: frozen.maxIterations, + resume: snapshot, + }, + permissionOptions: remotePermissionOptions, + initialPrompt: { + text: resume.promptText, + agent: resume.agent, + model: resume.model, + variant: resume.variant, + }, + debug, + }) + + // 10. Rollback on launch failure: drop the sync ref (best effort, after a + // successful push), relabel the local loop plain-cancelled (restartable again). + if ('error' in launch) { + deleteForgeSyncRef(git, { cwd: projectDir, gitRemote: remote.gitRemote, syncRef }) + return rollbackAfterFreeze(deps, frozen, launch.error) + } + + // 11. Success + return ok({ + operation: 'loop.migrate', + loopName: frozen.loopName, + remoteName: remote.name, + remoteLoopName: launch.loopName, + remoteSessionId: launch.sessionId, + startRef: tip, + syncRef, + phase: snapshot.phase, + currentSectionIndex: snapshot.currentSectionIndex, + totalSections: snapshot.totalSections, + }, warnings.length > 0 ? warnings : undefined) +} + +/** + * Post-freeze failure path: relabel the local loop plain-cancelled so it is + * restartable again and report the failure with the recovery instructions. + */ +function rollbackAfterFreeze( + deps: ForgeExecutionServiceDeps, + frozen: import('../loop/state').LoopState, + message: string, +): ForgeExecutionResponse { + deps.loop.service.terminate(frozen.loopName, { + status: 'cancelled', + reason: 'cancelled', + completedAt: Date.now(), + }) + deps.logger.error(`loop-migrate: migration of "${frozen.loopName}" failed after freeze: ${message}`) + return fail( + 'internal_error', + 502, + `${message}. Local loop "${frozen.loopName}" is cancelled and restartable with loop-status restart=true.`, + ) +} diff --git a/src/tools/loop.ts b/src/tools/loop.ts index 7fc30d4ab..00e4776de 100644 --- a/src/tools/loop.ts +++ b/src/tools/loop.ts @@ -71,6 +71,7 @@ export function createLoopTools(ctx: ToolContext): Record { + const { service, execCtx } = makeService() + const result = await service.dispatch(execCtx, { + type: 'loop.migrate', + selector: { kind: 'partial', name: args.name }, + remoteName: args.remote, + }) + if (!result.ok) { + const candidates = result.error.candidates + if (candidates?.length) return `${result.error.message}\n${candidates.map(c => `- ${c}`).join('\n')}` + return result.error.message + } + const d = result.data + const lines: string[] = [ + `Migrated loop "${d.loopName}" to ${d.remoteName}`, + '', + `Remote loop: ${d.remoteLoopName}`, + `Remote session: ${d.remoteSessionId}`, + d.totalSections > 0 + ? `Resumed at: phase ${d.phase}, section ${d.currentSectionIndex + 1}/${d.totalSections}` + : `Resumed at: phase ${d.phase}`, + `Pinned commit: ${d.startRef.slice(0, 7)} (${d.syncRef})`, + ] + for (const warning of result.warnings ?? []) { + lines.push(`Warning: ${warning.message}`) + } + lines.push('', 'The local loop is stopped as migrated and cannot be restarted here; use loop-status on the remote server.') + return lines.join('\n') + }, + }), + } } diff --git a/src/utils/tui-client.ts b/src/utils/tui-client.ts index 04ebc492c..47123eea5 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, type LoopPermissionRulesetOptions } from '../constants/loop' +import { buildLoopPermissionRuleset, buildAuditSessionPermissionRuleset, type LoopPermissionRulesetOptions } 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' @@ -248,6 +248,13 @@ function buildTuiLoopInitialPrompt(planText: string): string { }) } +export interface LaunchInitialPrompt { + text: string + agent: 'code' | 'auditor-loop' + model?: { providerID: string; modelID: string } + variant?: string +} + export interface LaunchTuiLoopOptions { client: ForgeClient directory: string | undefined @@ -262,6 +269,13 @@ export interface LaunchTuiLoopOptions { loopNameReserved?: boolean title: string plan: string + /** + * Phase-appropriate override for the first prompt of the launched loop. + * When present, `session.create` receives the matching agent's ruleset and + * `promptAsync` sends exactly this text/agent/model/variant. When absent, + * the deterministic section-0 prompt with the `code` agent is sent. + */ + initialPrompt?: LaunchInitialPrompt executionModel?: string auditorModel?: string executionVariant?: string @@ -345,22 +359,30 @@ export async function launchTuiLoop( } const parsedModel = parseModelString(opts.executionModel) - const permission = buildLoopPermissionRuleset(opts.permissionOptions) + const initial: LaunchInitialPrompt = opts.initialPrompt ?? { + text: buildTuiLoopInitialPrompt(opts.plan), + agent: 'code', + model: parsedModel, + variant: opts.executionVariant, + } + const permission = + initial.agent === 'auditor-loop' + ? buildAuditSessionPermissionRuleset(opts.permissionOptions) + : buildLoopPermissionRuleset(opts.permissionOptions) const session = await opts.client.session.create({ workspaceID: workspace.id, title: loopName, directory: workspace.directory ?? undefined, permission, }) - const promptText = buildTuiLoopInitialPrompt(opts.plan) const promptInput = { sessionID: session.id, directory: workspace.directory ?? undefined, workspace: workspace.id, - agent: 'code' as const, - parts: [{ type: 'text' as const, text: promptText }], - ...buildPromptModelSelection(parsedModel, opts.executionVariant), + agent: initial.agent, + parts: [{ type: 'text' as const, text: initial.text }], + ...buildPromptModelSelection(initial.model, initial.variant), } try { await opts.client.session.promptAsync(promptInput) diff --git a/src/utils/tui-remote-launch.ts b/src/utils/tui-remote-launch.ts index 6e22cf2d1..0c4e8a2ef 100644 --- a/src/utils/tui-remote-launch.ts +++ b/src/utils/tui-remote-launch.ts @@ -1,5 +1,5 @@ -import { resolveRemoteServer, listRemoteNames, forgeSyncRef } from './remote-config' -import { defaultGitService, type GitService } from './git-service' +import { resolveRemoteServer, listRemoteNames, forgeSyncRef, type ResolvedRemoteServer } from './remote-config' +import { defaultGitService, type GitService, type GitResult } from './git-service' import { createRemoteForgeClient, type RemoteClientOptions } from '../client/sdk-adapter' import type { ForgeClient } from '../client/port' import type { PluginConfig } from '../types' @@ -38,15 +38,29 @@ export type RemoteLaunchResult = | { loopName: string; sessionId: string; remoteName: string } | { error: string } -export async function executeRemoteLoop( - req: RemoteLoopRequest, - deps: RemoteLaunchDeps, -): Promise { - const debug = deps.debug ?? (() => {}) - const git = deps.git ?? defaultGitService - const createClient = deps.createClient ?? createRemoteForgeClient +export type ConnectRemoteProjectResult = + | { remote: ResolvedRemoteServer; project: { id: string; worktree: string }; client: ForgeClient } + | { error: string } - debug(`remote-launch: start remote="${req.remoteName}" dir="${req.localDirectory}" projectId="${req.localProjectId}" loop="${req.loopName}"`) +export interface ConnectRemoteProjectDeps + extends Pick { + /** + * Local preflight gate (e.g. git checks) run after the remote is resolved + * but before any network call, so a caller can keep its error precedence. + */ + beforeDiscovery?: () => { error: string } | void +} + +/** + * Shared remote-connection steps: resolve the configured remote server, + * discover the remote project matching the local OpenCode project id, and + * create the scoped client for the matched worktree. + */ +export async function connectRemoteProject( + req: { remoteName: string; localProjectId: string; localDirectory?: string }, + deps: ConnectRemoteProjectDeps, +): Promise { + const debug = deps.debug ?? (() => {}) // 1. Resolve remote server const remote = resolveRemoteServer(deps.config, req.remoteName) @@ -56,23 +70,8 @@ export async function executeRemoteLoop( } debug(`remote-launch: resolved remote name="${remote.name}" url="${remote.url}" gitRemote="${remote.gitRemote}" sandbox=${remote.sandbox}`) - // 2. Preflight git checks - if (!git.isInsideWorkTree(req.localDirectory)) { - return { error: `Not a git repository: ${req.localDirectory}` } - } - - const headResult = git.revParseHead(req.localDirectory) - if (!headResult.ok) { - return { error: `Failed to resolve HEAD in ${req.localDirectory}: ${headResult.stderr}` } - } - const sha = headResult.stdout.trim() - debug(`remote-launch: preflight ok HEAD=${sha}`) - - // Warn about dirty working tree but proceed - const statusResult = git.statusPorcelain(req.localDirectory) - if (statusResult.ok && statusResult.stdout.trim().length > 0) { - deps.onWarning?.(`Uncommitted changes are not included; remote loop starts from HEAD ${sha.substring(0, 7)}`) - } + const preflight = deps.beforeDiscovery?.() + if (preflight && 'error' in preflight) return preflight // 3. Discovery: find the remote project sharing this repo's OpenCode project // identity. OpenCode derives the same id for a given repo on every server @@ -80,10 +79,10 @@ export async function executeRemoteLoop( // id is location-independent — unlike worktree paths, which differ per // machine (e.g. a local checkout vs. a container workspace). if (!req.localProjectId) { - return { error: `Could not resolve the local OpenCode project id for ${req.localDirectory}; cannot match a remote project.` } + return { error: `Could not resolve the local OpenCode project id for ${req.localDirectory ?? req.remoteName}; cannot match a remote project.` } } - const discoveryClient = createClient({ + const discoveryClient = (deps.createClient ?? createRemoteForgeClient)({ url: remote.url, username: remote.username, password: remote.password, @@ -109,13 +108,81 @@ export async function executeRemoteLoop( debug(`remote-launch: matched project id=${matched.id} worktree="${matched.worktree}"`) // 4. Create scoped client for the matched directory - const remoteClient = createClient({ + const client = (deps.createClient ?? createRemoteForgeClient)({ url: remote.url, username: remote.username, password: remote.password, directory: matched.worktree, }) + return { remote, project: { id: matched.id, worktree: matched.worktree }, client } +} + +/** + * Push a local source ref to the shared git remote's forge sync ref. The sync + * ref is never fetched locally, so the push is forced. + */ +export function pushForgeSyncRef( + git: GitService, + args: { cwd: string; gitRemote: string; sourceRef: string; syncRef: string }, +): { ok: true } | { ok: false; error: string } { + const pushResult = git.push(args.cwd, args.gitRemote, `${args.sourceRef}:${args.syncRef}`, true) + if (!pushResult.ok) { + return { ok: false, error: pushResult.stderr || '(no stderr)' } + } + return { ok: true } +} + +/** Delete the forge sync ref on the shared git remote (launch-failure cleanup). */ +export function deleteForgeSyncRef( + git: GitService, + args: { cwd: string; gitRemote: string; syncRef: string }, +): GitResult { + return git.push(args.cwd, args.gitRemote, `:${args.syncRef}`, false) +} + +export async function executeRemoteLoop( + req: RemoteLoopRequest, + deps: RemoteLaunchDeps, +): Promise { + const debug = deps.debug ?? (() => {}) + const git = deps.git ?? defaultGitService + + debug(`remote-launch: start remote="${req.remoteName}" dir="${req.localDirectory}" projectId="${req.localProjectId}" loop="${req.loopName}"`) + + // 1+3+4. Resolve the remote server, discover the matching remote project, and + // create the scoped client. The git preflight is threaded through as a gate + // so the error precedence (unknown remote → git checks → discovery) is kept. + const connected = await connectRemoteProject( + { remoteName: req.remoteName, localProjectId: req.localProjectId, localDirectory: req.localDirectory }, + { + config: deps.config, + createClient: deps.createClient, + debug, + beforeDiscovery: () => { + if (!git.isInsideWorkTree(req.localDirectory)) { + return { error: `Not a git repository: ${req.localDirectory}` } + } + const headResult = git.revParseHead(req.localDirectory) + if (!headResult.ok) { + return { error: `Failed to resolve HEAD in ${req.localDirectory}: ${headResult.stderr}` } + } + debug(`remote-launch: preflight ok HEAD=${headResult.stdout.trim()}`) + return undefined + }, + }, + ) + if ('error' in connected) return connected + + const { remote, project: matched, client: remoteClient } = connected + const sha = git.revParseHead(req.localDirectory).stdout.trim() + + // Warn about dirty working tree but proceed + const statusResult = git.statusPorcelain(req.localDirectory) + if (statusResult.ok && statusResult.stdout.trim().length > 0) { + deps.onWarning?.(`Uncommitted changes are not included; remote loop starts from HEAD ${sha.substring(0, 7)}`) + } + // 5. Reserve a unique loop name (once; launchTuiLoop uses it verbatim below) const finalLoopName = await reserveTuiLoopName(remoteClient, null, req.loopName) const syncRef = forgeSyncRef(finalLoopName) @@ -133,10 +200,15 @@ export async function executeRemoteLoop( // 6. Push HEAD to remote ref debug(`remote-launch: pushing HEAD:${syncRef} to gitRemote="${remote.gitRemote}" from "${req.localDirectory}"`) - const pushResult = git.push(req.localDirectory, remote.gitRemote, `HEAD:${syncRef}`, true) + const pushResult = pushForgeSyncRef(git, { + cwd: req.localDirectory, + gitRemote: remote.gitRemote, + sourceRef: 'HEAD', + syncRef, + }) if (!pushResult.ok) { - debug(`remote-launch: push FAILED: ${pushResult.stderr || '(no stderr)'}`) - return { error: `Failed to push to remote "${remote.name}": ${pushResult.stderr || '(no stderr)'}` } + debug(`remote-launch: push FAILED: ${pushResult.error}`) + return { error: `Failed to push to remote "${remote.name}": ${pushResult.error}` } } debug(`remote-launch: push ok (ref ${syncRef} now on ${remote.gitRemote})`) @@ -173,7 +245,7 @@ export async function executeRemoteLoop( if ('error' in launchResult) { debug(`remote-launch: launchTuiLoop FAILED: ${launchResult.error}`) - const cleanup = git.push(req.localDirectory, remote.gitRemote, `:${syncRef}`, false) + const cleanup = deleteForgeSyncRef(git, { cwd: req.localDirectory, gitRemote: remote.gitRemote, syncRef }) debug(`remote-launch: sync ref cleanup ${cleanup.ok ? 'ok' : `failed: ${cleanup.stderr.trim() || 'unknown error'}`}`) return { error: launchResult.error } } diff --git a/test/config-commands.test.ts b/test/config-commands.test.ts index e610603a3..ea4a61d92 100644 --- a/test/config-commands.test.ts +++ b/test/config-commands.test.ts @@ -62,6 +62,12 @@ describe('createConfigHandler commands', () => { expect(loopCancel).toBeDefined() expect(loopCancel.template).toContain('Identify the Loop') expect(loopCancel.agent).toBe('code') + + const loopMigrate = commands['loop-migrate'] + expect(loopMigrate).toBeDefined() + expect(loopMigrate.template).toContain('Identify the Loop') + expect(loopMigrate.agent).toBe('code') + expect(loopMigrate.subtask).toBe(false) }) test('user command template overrides via promptsDir', async () => { diff --git a/test/config.test.ts b/test/config.test.ts index 497358db1..565d6cb8f 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -132,7 +132,7 @@ describe('createConfigHandler', () => { expect(architectAutoPermission.question).toBe('deny') expect(architectAutoTools.question).toBe(false) - for (const tool of ['execute-plan', 'execute-goal', 'launch-group', 'group-status', 'group-cancel', 'loop-status', 'loop-cancel']) { + for (const tool of ['execute-plan', 'execute-goal', 'launch-group', 'group-status', 'group-cancel', 'loop-status', 'loop-cancel', 'loop-migrate']) { expect(architectTools[tool]).toBeUndefined() expect(architectPermission[tool]).toBeUndefined() expect(architectAutoTools[tool]).toBe(false) diff --git a/test/constants/loop.test.ts b/test/constants/loop.test.ts index ffdcc1363..363c6fff5 100644 --- a/test/constants/loop.test.ts +++ b/test/constants/loop.test.ts @@ -53,12 +53,20 @@ describe('buildLoopPermissionRuleset', () => { { permission: 'question', pattern: '*', action: 'deny' }, { permission: 'loop-cancel', pattern: '*', action: 'deny' }, { permission: 'loop-status', pattern: '*', action: 'deny' }, + { permission: 'loop-migrate', pattern: '*', action: 'deny' }, { permission: 'launch-group', pattern: '*', action: 'deny' }, { permission: 'group-status', pattern: '*', action: 'deny' }, { permission: 'group-cancel', pattern: '*', action: 'deny' }, ]) }) + it('denies loop-migrate in both loop and audit rulesets so active sessions cannot migrate themselves', () => { + const loopRules = buildLoopPermissionRuleset() + const auditRules = buildAuditSessionPermissionRuleset() + expect(loopRules).toContainEqual({ permission: 'loop-migrate', pattern: '*', action: 'deny' }) + expect(auditRules).toContainEqual({ permission: 'loop-migrate', pattern: '*', action: 'deny' }) + }) + it('denies execute-goal in both loop and audit rulesets so active sessions cannot recurse', () => { const loopRules = buildLoopPermissionRuleset() const auditRules = buildAuditSessionPermissionRuleset() diff --git a/test/helpers/fake-remote-client.ts b/test/helpers/fake-remote-client.ts new file mode 100644 index 000000000..d13e6e3f8 --- /dev/null +++ b/test/helpers/fake-remote-client.ts @@ -0,0 +1,49 @@ +/** + * Shared remote-loop test fakes: a `ForgeClient` with remote-launch flow + * defaults (fixed workspace/session ids, `connected` workspace status, one + * matching project) plus a `vi.fn()` client factory that records produced + * clients. Used by both `test/utils/tui-remote-launch.test.ts` and + * `test/services/loop-migration.test.ts`. + */ + +import { vi } from 'vitest' +import type { ForgeClient } from '../../src/client/port' +import type { RemoteClientOptions } from '../../src/client/sdk-adapter' +import { createFakeForgeClient } from './fake-client' + +export const REMOTE_URL = 'http://remote:4096' +export const LOCAL_PROJECT_ID = 'proj_1' +export const REMOTE_PROJECT_WORKTREE = '/remote/my-project' +export const REMOTE_SESSION_ID = 'sess_remote' +export const REMOTE_WORKSPACE_ID = 'ws_remote' + +export function makeFakeRemoteClient(overrides?: Parameters[0]): ForgeClient { + const { client } = createFakeForgeClient({ + session: { + create: async () => ({ id: REMOTE_SESSION_ID }), + }, + workspace: { + create: async () => ({ id: REMOTE_WORKSPACE_ID, directory: REMOTE_PROJECT_WORKTREE, branch: null }), + status: async () => [{ workspaceID: REMOTE_WORKSPACE_ID, status: 'connected' }], + }, + project: { + list: async () => [{ id: LOCAL_PROJECT_ID, worktree: REMOTE_PROJECT_WORKTREE }], + }, + ...overrides, + }) + return client +} + +/** Create a `vi.fn()` based `createClient` factory that records produced clients. */ +export function createClientSpy(overrides?: Parameters[0]): { + spy: ReturnType + clients: ForgeClient[] +} { + const clients: ForgeClient[] = [] + const spy = vi.fn((_opts: RemoteClientOptions) => { + const client = makeFakeRemoteClient(overrides) + clients.push(client) + return client + }) + return { spy, clients } +} diff --git a/test/hooks/forge-session-attach.test.ts b/test/hooks/forge-session-attach.test.ts index 3271f5c5d..2cc4f0111 100644 --- a/test/hooks/forge-session-attach.test.ts +++ b/test/hooks/forge-session-attach.test.ts @@ -1311,6 +1311,62 @@ describe('createForgeSessionAttachHook', () => { expect(mockAttachLoop).not.toHaveBeenCalled() }) + test('chat.message fallback forwards resume snapshot without stored-plan lookup', async () => { + const resume = { + version: 1 as const, + kind: 'goal' as const, + goal: 'ship it', + phase: 'coding' as const, + currentSectionIndex: 0, + totalSections: 0, + finalAuditDone: false, + sections: [], + findings: [], + } + const plansRepoGetForSession = vi.fn().mockReturnValue({ content: 'SHOULD_NOT_BE_USED' }) + const deps = buildHookDeps({ + sessionGet: vi.fn().mockResolvedValue({ + id: 'new_sess', + workspaceID: 'ws_resume', + directory: '/tmp/wt/resume', + projectID: 'proj_1', + }), + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_resume', + type: 'forge', + directory: '/tmp/wt/resume', + extra: { + loopName: 'resume-loop', + projectDirectory: '/tmp/wt/resume', + forgeLoop: { + initialPromptOwner: 'tui', + pendingAttachStartedAt: Date.now(), + planSource: 'inline', + planText: '', + maxIterations: 12, + resume, + }, + }, + }, + ]), + plansRepoGetForSession, + loopsRepoGet: vi.fn().mockReturnValue(null), + }) + + const handler = createForgeSessionMessageAttachHook(deps as any) + + await handler({ sessionID: 'new_sess' }) + + expect(mockAttachLoop).toHaveBeenCalledTimes(1) + const [, , input] = mockAttachLoop.mock.calls[0] + expect(input.resume).toEqual(resume) + expect(input.maxIterations).toBe(12) + expect(input.planText).toBe('') + expect(input.sendInitialPrompt).toBe(false) + expect(plansRepoGetForSession).not.toHaveBeenCalled() + }) + test('attach hook prefers inline planText over stored plan when both are available', async () => { const plansRepoGetForSession = vi.fn().mockReturnValue({ content: 'STALE_PRIOR_PLAN_TEXT' }) const deps = buildHookDeps({ diff --git a/test/loop-permission-ruleset.test.ts b/test/loop-permission-ruleset.test.ts index b25252c95..d9630720f 100644 --- a/test/loop-permission-ruleset.test.ts +++ b/test/loop-permission-ruleset.test.ts @@ -41,6 +41,7 @@ describe('buildLoopPermissionRuleset', () => { { permission: 'question', pattern: '*', action: 'deny' }, { permission: 'loop-cancel', pattern: '*', action: 'deny' }, { permission: 'loop-status', pattern: '*', action: 'deny' }, + { permission: 'loop-migrate', pattern: '*', action: 'deny' }, { permission: 'launch-group', pattern: '*', action: 'deny' }, { permission: 'group-status', pattern: '*', action: 'deny' }, { permission: 'group-cancel', pattern: '*', action: 'deny' }, @@ -131,6 +132,7 @@ 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) + expect(rules.some(r => r.permission === 'loop-migrate' && 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) diff --git a/test/loop/restartability.test.ts b/test/loop/restartability.test.ts index 70ea1bd9f..4e995b659 100644 --- a/test/loop/restartability.test.ts +++ b/test/loop/restartability.test.ts @@ -93,4 +93,16 @@ describe('getRestartability', () => { expect(result.restartRequiresForce).toBe(true) expect(result.restartBlockedReason).toBe('active_requires_force') }) + + it('blocks restart of a loop migrated to a remote server', () => { + const state = makeState({ terminationReason: 'migrated: server1' }) + const result = getRestartability(state, { + worktreeExists: () => true, + branchExists: () => true, + }) + expect(result.restartable).toBe(false) + expect(result.restartRequiresForce).toBe(false) + expect(result.restartBlockedReason).toBe('migrated') + expect(result.restartBlockedMessage).toContain('server1') + }) }) diff --git a/test/loop/resume-prompt.test.ts b/test/loop/resume-prompt.test.ts new file mode 100644 index 000000000..748e455b7 --- /dev/null +++ b/test/loop/resume-prompt.test.ts @@ -0,0 +1,191 @@ +import { describe, test, expect, beforeEach, afterEach } from 'vitest' +import { Database } from 'bun:sqlite' +import { mkdtempSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +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 { createSectionPlansRepo } from '../../src/storage/repos/section-plans-repo' +import { createLoopService, type LoopService } from '../../src/loop/service' +import type { Logger } from '../../src/types' +import type { PluginConfig } from '../../src/types' +import type { LoopState } from '../../src/loop/state' +import { setupLoopsTestDb } from '../helpers/loops-test-db' +import { buildResumePromptPlan } from '../../src/loop/resume-prompt' + +const PROJECT_ID = 'test-project' +const noopLogger: Logger = { log: () => {}, error: () => {}, debug: () => {} } + +describe('buildResumePromptPlan', () => { + let db: Database + let service: LoopService + + beforeEach(() => { + const tempDir = mkdtempSync(join(tmpdir(), 'resume-prompt-test-')) + db = new Database(join(tempDir, 'test.db')) + setupLoopsTestDb(db) + service = createLoopService( + createLoopsRepo(db), + createPlansRepo(db), + createReviewFindingsRepo(db), + PROJECT_ID, + noopLogger, + undefined, + undefined, + createSectionPlansRepo(db), + ) + }) + + afterEach(() => { + try { db.close() } catch {} + }) + + function seedLoop(state: Partial & Pick): LoopState { + const base: LoopState = { + active: false, + sessionId: 'session-old', + worktreeDir: '/tmp/wt', + projectDir: '/tmp', + iteration: 3, + maxIterations: 10, + startedAt: new Date().toISOString(), + errorCount: 0, + auditCount: 0, + status: 'stalled', + worktree: false, + currentSectionIndex: 0, + totalSections: 0, + finalAuditDone: false, + ...state, + } as LoopState + service.setState(state.loopName, base) + service.setStatus(state.loopName, 'stalled') + return base + } + + function seedSections(loopName: string, total: number, inProgressIndex: number): void { + const sectionPlansRepo = createSectionPlansRepo(db) + sectionPlansRepo.bulkInsert({ + projectId: PROJECT_ID, + loopName, + sections: Array.from({ length: total }, (_, i) => ({ index: i, title: `S${i}`, content: `Section ${i} content` })), + }) + for (let i = 0; i < total; i++) { + sectionPlansRepo.setStatus(PROJECT_ID, loopName, i, i < inProgressIndex ? 'completed' : i === inProgressIndex ? 'in_progress' : 'pending') + } + } + + function seedLoopWithSections( + state: Partial & Pick, + totalSections: number, + ): LoopState { + // currentSectionIndex defaults to 0 (first section still in progress), matching a loop + // stopped mid-section; sections are seeded after the loop row (FK enforcement). + const seeded = seedLoop({ ...state, totalSections, currentSectionIndex: state.currentSectionIndex ?? 0 }) + seedSections(state.loopName, totalSections, state.currentSectionIndex ?? 0) + return seeded + } + + const baseConfig: PluginConfig = { + loop: { enabled: true }, + executionModel: 'prov/exec', + auditorModel: 'prov/aud', + } + + test('persisted auditing resumes as coding with the section-initial prompt', () => { + const state = seedLoopWithSections({ loopName: 'sectioned-auditing', phase: 'auditing' }, 2) + + const plan = buildResumePromptPlan({ service, config: baseConfig, state: service.getAnyState(state.loopName)! }) + + expect(plan.phase).toBe('coding') + expect(plan.agent).toBe('code') + expect(plan.permission).toBe('loop') + expect(plan.promptText).toBe(service.buildSectionInitialPrompt(state)) + expect(plan.promptText).toContain('Section 0 content') + }) + + test('legacy non-sectioned prompt falls back to the persisted prompt text', () => { + seedLoop({ loopName: 'legacy-auditing', phase: 'auditing', prompt: 'Legacy plan text' }) + + const plan = buildResumePromptPlan({ service, config: baseConfig, state: service.getAnyState('legacy-auditing')! }) + + expect(plan.phase).toBe('coding') + expect(plan.agent).toBe('code') + expect(plan.permission).toBe('loop') + expect(plan.promptText).toBe('Legacy plan text') + }) + + test('persisted final_auditing resumes the final audit with the auditor agent and audit permission', () => { + const state = seedLoopWithSections({ loopName: 'final-audit-loop', phase: 'final_auditing' }, 3) + + const plan = buildResumePromptPlan({ service, config: baseConfig, state: service.getAnyState(state.loopName)! }) + + expect(plan.phase).toBe('final_auditing') + expect(plan.agent).toBe('auditor-loop') + expect(plan.permission).toBe('audit') + expect(plan.promptText).toBe(service.buildFinalAuditPrompt(service.getAnyState(state.loopName)!)) + expect(plan.model).toEqual({ providerID: 'prov', modelID: 'aud' }) + }) + + test('persisted final_audit_fix resumes the fix pass as coding with the fix prompt', () => { + const state = seedLoop({ loopName: 'fix-loop', phase: 'final_audit_fix', prompt: 'Fix me' }) as LoopState & { prompt?: string } + + const plan = buildResumePromptPlan({ service, config: baseConfig, state: service.getAnyState(state.loopName)! }) + + expect(plan.phase).toBe('coding') + expect(plan.agent).toBe('code') + expect(plan.permission).toBe('loop') + expect(plan.promptText).toBe(service.buildFinalAuditFixPrompt(state, service.getOutstandingFindings(state.loopName, 'bug'))) + }) + + test('goal loop resumes with the continuation prompt restating the goal', () => { + const state = seedLoop({ loopName: 'goal-loop', phase: 'auditing', kind: 'goal', goal: 'Ship the endpoint.' }) + + const plan = buildResumePromptPlan({ service, config: baseConfig, state: service.getAnyState(state.loopName)! }) + + expect(plan.phase).toBe('coding') + expect(plan.agent).toBe('code') + expect(plan.permission).toBe('loop') + expect(plan.promptText).toBe(service.buildContinuationPrompt(state, undefined)) + expect(plan.promptText).toContain('Ship the endpoint.') + }) + + test('post_action with postAction enabled uses the post-action phase, config skill, and configured model with auditor fallback', () => { + const state = seedLoopWithSections({ loopName: 'pa-loop', phase: 'post_action' }, 2) + + const config: PluginConfig = { + loop: { enabled: true, postAction: { enabled: true, skill: 'pr-review', model: 'prov/pa' } }, + executionModel: 'prov/exec', + auditorModel: 'prov/aud', + } + + const plan = buildResumePromptPlan({ service, config, state: service.getAnyState(state.loopName)! }) + + expect(plan.phase).toBe('post_action') + expect(plan.agent).toBe('code') + expect(plan.promptText).toBe(service.buildPostActionPrompt(service.getAnyState(state.loopName)!, { skill: 'pr-review', prompt: undefined })) + expect(plan.model).toEqual({ providerID: 'prov', modelID: 'pa' }) + expect(plan.fallbackModel).toEqual({ providerID: 'prov', modelID: 'aud' }) + }) + + test('unset state.auditorModel falls back to config.auditorModel and normalizes whitespace', () => { + const state = seedLoopWithSections({ loopName: 'aud-model-loop', phase: 'final_auditing', auditorModel: ' prov/norm ' }, 2) + + const plan = buildResumePromptPlan({ service, config: baseConfig, state: service.getAnyState(state.loopName)! }) + + expect(plan.model).toEqual({ providerID: 'prov', modelID: 'norm' }) + expect(plan.auditorModel).toBe('prov/norm') + }) + + test('variant: auditor phase uses the persisted auditor variant, coding phases keep the execution variant', () => { + const auditState = seedLoopWithSections({ loopName: 'variant-loop', phase: 'final_auditing', executionVariant: 'exec-v', auditorVariant: 'audit-v' }, 2) + + const auditPlan = buildResumePromptPlan({ service, config: baseConfig, state: service.getAnyState(auditState.loopName)! }) + expect(auditPlan.variant).toBe('audit-v') + + const codeState = seedLoop({ loopName: 'variant-code-loop', phase: 'coding', executionVariant: 'exec-v', auditorVariant: 'audit-v', prompt: 'Legacy plan' }) + const codePlan = buildResumePromptPlan({ service, config: baseConfig, state: service.getAnyState(codeState.loopName)! }) + expect(codePlan.variant).toBe('exec-v') + }) +}) diff --git a/test/loop/resume-snapshot.test.ts b/test/loop/resume-snapshot.test.ts new file mode 100644 index 000000000..8379482a6 --- /dev/null +++ b/test/loop/resume-snapshot.test.ts @@ -0,0 +1,387 @@ +import { describe, test, expect, beforeEach, afterEach } from 'vitest' +import { Database } from 'bun:sqlite' +import { mkdtempSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +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 { createSectionPlansRepo } from '../../src/storage/repos/section-plans-repo' +import type { SectionPlanRow } from '../../src/storage/repos/section-plans-repo' +import type { ReviewFindingRow } from '../../src/storage/repos/review-findings-repo' +import { createLoopService } from '../../src/loop/service' +import type { Logger } from '../../src/types' +import type { LoopState } from '../../src/loop/state' +import { setupLoopsTestDb } from '../helpers/loops-test-db' +import { + captureLoopResumeSnapshot, + restoreLoopResumeRows, + isLoopResumeSnapshot, +} from '../../src/loop/resume-snapshot' + +const PROJECT_ID = 'test-project' +const noopLogger: Logger = { log: () => {}, error: () => {}, debug: () => {} } + +describe('captureLoopResumeSnapshot', () => { + let db: Database + let tempDir: string + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'resume-snapshot-test-')) + db = new Database(join(tempDir, 'test.db')) + setupLoopsTestDb(db) + }) + + afterEach(() => { + try { db.close() } catch {} + }) + + function buildRepos() { + const loopsRepo = createLoopsRepo(db) + const plansRepo = createPlansRepo(db) + const reviewFindingsRepo = createReviewFindingsRepo(db) + const sectionPlansRepo = createSectionPlansRepo(db) + const loopService = createLoopService( + loopsRepo, + plansRepo, + reviewFindingsRepo, + PROJECT_ID, + noopLogger, + undefined, + undefined, + sectionPlansRepo, + ) + return { loopsRepo, plansRepo, reviewFindingsRepo, sectionPlansRepo, loopService } + } + + function seedStoppedLoop(sectionPlansRepo: ReturnType): LoopState { + const state: LoopState = { + active: false, + sessionId: 'sess_old', + loopName: 'my-loop', + worktreeDir: '/tmp/wt', + projectDir: '/tmp', + iteration: 4, + maxIterations: 10, + startedAt: new Date().toISOString(), + prompt: '# My Plan', + phase: 'auditing', + errorCount: 1, + auditCount: 2, + status: 'cancelled', + worktree: false, + currentSectionIndex: 1, + totalSections: 3, + finalAuditDone: false, + } + const loopsRepo = createLoopsRepo(db) + const plansRepo = createPlansRepo(db) + const reviewFindingsRepo = createReviewFindingsRepo(db) + const service = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, noopLogger, undefined, undefined, sectionPlansRepo) + service.setState('my-loop', state) + service.setStatus('my-loop', 'cancelled') + + sectionPlansRepo.bulkInsert({ + projectId: PROJECT_ID, + loopName: 'my-loop', + sections: [ + { index: 0, title: 'Setup', content: 'Do setup' }, + { index: 1, title: 'Build', content: 'Do build' }, + { index: 2, title: 'Ship', content: 'Do ship' }, + ], + }) + sectionPlansRepo.setStatus(PROJECT_ID, 'my-loop', 0, 'completed') + sectionPlansRepo.setSummary(PROJECT_ID, 'my-loop', 0, { done: 'setup done' }) + sectionPlansRepo.setStatus(PROJECT_ID, 'my-loop', 1, 'in_progress') + sectionPlansRepo.incrementAttempts(PROJECT_ID, 'my-loop', 1) + sectionPlansRepo.incrementAttempts(PROJECT_ID, 'my-loop', 1) + + reviewFindingsRepo.write({ + projectId: PROJECT_ID, + loopName: 'my-loop', + file: 'a.ts', + line: 10, + severity: 'bug', + description: 'section finding', + sectionIndex: 1, + }) + reviewFindingsRepo.write({ + projectId: PROJECT_ID, + loopName: 'my-loop', + file: 'b.ts', + line: 20, + severity: 'warning', + description: 'cross-section finding', + }) + + return state + } + + test('captures phase pointers, section rows, and findings without host scoping', () => { + const { sectionPlansRepo, reviewFindingsRepo } = buildRepos() + const state = seedStoppedLoop(sectionPlansRepo) + + const snapshot = captureLoopResumeSnapshot({ + projectId: PROJECT_ID, + state, + sectionPlansRepo, + reviewFindingsRepo, + }) + + expect(snapshot.version).toBe(1) + expect(snapshot.kind).toBe('plan') + expect(snapshot.phase).toBe('coding') + expect(snapshot.currentSectionIndex).toBe(1) + expect(snapshot.totalSections).toBe(3) + expect(snapshot.finalAuditDone).toBe(false) + expect(snapshot.goal).toBeUndefined() + + expect(snapshot.sections).toHaveLength(3) + const byIndex = new Map(snapshot.sections.map((s) => [s.sectionIndex, s])) + expect(byIndex.get(0)!.status).toBe('completed') + expect(byIndex.get(0)!.summaryDone).toBe('setup done') + expect(byIndex.get(1)!.status).toBe('in_progress') + expect(byIndex.get(1)!.attempts).toBe(2) + expect(byIndex.get(2)!.status).toBe('pending') + for (const section of snapshot.sections) { + expect(section).not.toHaveProperty('projectId') + expect(section).not.toHaveProperty('loopName') + expect(section).not.toHaveProperty('createdAt') + } + + expect(snapshot.findings).toHaveLength(2) + const finding = snapshot.findings.find((f) => f.file === 'a.ts')! + expect(finding.sectionIndex).toBe(1) + expect(finding.severity).toBe('bug') + const cross = snapshot.findings.find((f) => f.file === 'b.ts')! + expect(cross.sectionIndex).toBeNull() + for (const f of snapshot.findings) { + expect(f).not.toHaveProperty('projectId') + expect(f).not.toHaveProperty('loopName') + expect(f).not.toHaveProperty('createdAt') + } + }) + + test('final_auditing phase persists as final_auditing and goal loops carry goal text', () => { + const { sectionPlansRepo, reviewFindingsRepo, loopsRepo } = buildRepos() + const baseState: LoopState = { + active: false, + sessionId: 'sess_old', + loopName: 'final-loop', + worktreeDir: '/tmp/wt', + projectDir: '/tmp', + iteration: 2, + maxIterations: 10, + startedAt: new Date().toISOString(), + phase: 'final_auditing', + errorCount: 0, + auditCount: 0, + status: 'cancelled', + worktree: false, + currentSectionIndex: 2, + totalSections: 3, + finalAuditDone: false, + kind: 'goal', + goal: 'Ship it.', + } + loopsRepo.insert( + { + projectId: PROJECT_ID, + loopName: 'final-loop', + status: 'cancelled', + currentSessionId: 'sess_old', + worktree: false, + worktreeDir: '/tmp/wt', + worktreeBranch: null, + projectDir: '/tmp', + maxIterations: 10, + iteration: 2, + auditCount: 0, + errorCount: 0, + phase: 'final_auditing', + executionModel: null, + auditorModel: null, + modelFailed: false, + sandbox: false, + sandboxContainer: null, + startedAt: Date.now(), + completedAt: null, + terminationReason: null, + completionSummary: null, + workspaceId: null, + hostSessionId: null, + currentSectionIndex: 2, + totalSections: 3, + finalAuditDone: 0, + executionVariant: null, + auditorVariant: null, + kind: 'goal', + }, + { lastAuditResult: null, goal: 'Ship it.' }, + ) + + const snapshot = captureLoopResumeSnapshot({ + projectId: PROJECT_ID, + state: baseState, + sectionPlansRepo, + reviewFindingsRepo, + }) + + expect(snapshot.phase).toBe('final_auditing') + expect(snapshot.kind).toBe('goal') + expect(snapshot.goal).toBe('Ship it.') + }) +}) + +describe('restoreLoopResumeRows', () => { + let db: Database + let tempDir: string + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'resume-snapshot-restore-test-')) + db = new Database(join(tempDir, 'test.db')) + setupLoopsTestDb(db) + }) + + afterEach(() => { + try { db.close() } catch {} + }) + + function makeSnapshot(): ReturnType { + const sectionRow = (i: number): Omit => ({ + sectionIndex: i, + title: `S${i}`, + content: `Section ${i} content`, + status: i === 0 ? 'completed' : i === 1 ? 'in_progress' : 'pending', + attempts: i === 1 ? 2 : 0, + summaryDone: i === 0 ? 'done zero' : null, + summaryDeviations: null, + summaryFollowUps: null, + startedAt: i === 1 ? 1234 : null, + completedAt: i === 0 ? 999 : null, + }) + return { + version: 1, + kind: 'plan', + phase: 'coding', + currentSectionIndex: 1, + totalSections: 3, + finalAuditDone: false, + sections: [sectionRow(0), sectionRow(1), sectionRow(2)], + findings: [ + { file: 'a.ts', line: 1, severity: 'bug', description: 'bug desc', scenario: 'how', sectionIndex: 1 }, + { file: 'b.ts', line: 2, severity: 'warning', description: 'warn desc', scenario: null, sectionIndex: null }, + ], + } + } + + test('restores section rows and findings under a new project/loop scope', () => { + const loopsRepo = createLoopsRepo(db) + const reviewFindingsRepo = createReviewFindingsRepo(db) + const sectionPlansRepo = createSectionPlansRepo(db) + + loopsRepo.insert({ + projectId: 'remote-proj', + loopName: 'moved', + status: 'running', + currentSessionId: 'sess_new', + worktree: false, + worktreeDir: '/tmp/wt', + worktreeBranch: null, + projectDir: '/tmp', + maxIterations: 10, + iteration: 1, + auditCount: 0, + errorCount: 0, + phase: 'coding', + executionModel: null, + auditorModel: null, + modelFailed: false, + sandbox: false, + sandboxContainer: null, + startedAt: Date.now(), + completedAt: null, + terminationReason: null, + completionSummary: null, + workspaceId: null, + hostSessionId: null, + currentSectionIndex: 1, + totalSections: 3, + finalAuditDone: 0, + executionVariant: null, + auditorVariant: null, + kind: 'plan', + }, { lastAuditResult: null }) + + restoreLoopResumeRows({ + projectId: 'remote-proj', + loopName: 'moved', + snapshot: makeSnapshot(), + sectionPlansRepo, + reviewFindingsRepo, + }) + + const restored = sectionPlansRepo.list('remote-proj', 'moved') + expect(restored).toHaveLength(3) + const byIndex = new Map(restored.map((s) => [s.sectionIndex, s])) + expect(byIndex.get(0)!.status).toBe('completed') + expect(byIndex.get(0)!.summaryDone).toBe('done zero') + expect(byIndex.get(1)!.status).toBe('in_progress') + expect(byIndex.get(1)!.attempts).toBe(2) + expect(byIndex.get(1)!.startedAt).toBe(1234) + expect(byIndex.get(2)!.status).toBe('pending') + expect(restored.every((s) => s.projectId === 'remote-proj' && s.loopName === 'moved')).toBe(true) + expect(restored.every((s) => typeof s.createdAt === 'number')).toBe(true) + + const findings = reviewFindingsRepo.listByLoopName('remote-proj', 'moved') + expect(findings).toHaveLength(2) + const bug = findings.find((f: ReviewFindingRow) => f.file === 'a.ts')! + expect(bug.severity).toBe('bug') + expect(bug.sectionIndex).toBe(1) + expect(bug.scenario).toBe('how') + const cross = findings.find((f: ReviewFindingRow) => f.file === 'b.ts')! + expect(cross.sectionIndex).toBeNull() + }) + + test('isLoopResumeSnapshot accepts a valid snapshot and rejects malformed values', () => { + expect(isLoopResumeSnapshot(makeSnapshot())).toBe(true) + + expect(isLoopResumeSnapshot(null)).toBe(false) + expect(isLoopResumeSnapshot('nope')).toBe(false) + expect(isLoopResumeSnapshot({})).toBe(false) + expect(isLoopResumeSnapshot({ version: 2 })).toBe(false) + expect(isLoopResumeSnapshot({ + ...makeSnapshot(), + phase: 'auditing', + })).toBe(false) + expect(isLoopResumeSnapshot({ + ...makeSnapshot(), + sections: 'nope', + })).toBe(false) + expect(isLoopResumeSnapshot({ + ...makeSnapshot(), + findings: 42, + })).toBe(false) + expect(isLoopResumeSnapshot({ + version: 1, + kind: 'goal', + phase: 'coding', + currentSectionIndex: 0, + totalSections: 0, + finalAuditDone: false, + sections: [], + findings: [], + })).toBe(true) + expect(isLoopResumeSnapshot({ + version: 1, + kind: 'goal', + phase: 'coding', + currentSectionIndex: 0, + totalSections: 0, + finalAuditDone: false, + goal: 'Ship it.', + sections: [], + findings: [], + })).toBe(true) + }) +}) diff --git a/test/loop/termination.test.ts b/test/loop/termination.test.ts index c26646357..c23239bd5 100644 --- a/test/loop/termination.test.ts +++ b/test/loop/termination.test.ts @@ -62,6 +62,10 @@ describe('terminationStatusFor', () => { it('maps provider_limit with message to errored', () => { expect(terminationStatusFor({ kind: 'provider_limit', message: 'usage limit hit' })).toBe('errored') }) + + it('maps migrated to cancelled', () => { + expect(terminationStatusFor({ kind: 'migrated', message: 'server1' })).toBe('cancelled') + }) }) describe('terminationReasonToString', () => { @@ -123,6 +127,11 @@ describe('terminationReasonToString', () => { const reason: TerminationReason = { kind: 'provider_limit', message: 'usage limit hit' } expect(terminationReasonToString(reason)).toBe('provider_limit: usage limit hit') }) + + it('stringifies migrated with message', () => { + const reason: TerminationReason = { kind: 'migrated', message: 'server1' } + expect(terminationReasonToString(reason)).toBe('migrated: server1') + }) }) describe('parseTerminationReasonString', () => { @@ -131,4 +140,10 @@ describe('parseTerminationReasonString', () => { const str = terminationReasonToString(reason) expect(parseTerminationReasonString(str)).toEqual(reason) }) + + it('round-trips migrated', () => { + const reason: TerminationReason = { kind: 'migrated', message: 'server1' } + const str = terminationReasonToString(reason) + expect(parseTerminationReasonString(str)).toEqual(reason) + }) }) diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 5348a6239..72fd6435d 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -161,6 +161,7 @@ describe('createForgePlugin', () => { expect(hooks.tool?.['execute-goal']).toBeDefined() expect(hooks.tool?.['loop-cancel']).toBeDefined() expect(hooks.tool?.['loop-status']).toBeDefined() + expect(hooks.tool?.['loop-migrate']).toBeDefined() }) test('Plugin does NOT register shadow glob or grep tools', async () => { diff --git a/test/services/attach-loop.test.ts b/test/services/attach-loop.test.ts index b3e83fba8..633e57cb4 100644 --- a/test/services/attach-loop.test.ts +++ b/test/services/attach-loop.test.ts @@ -626,4 +626,152 @@ describe('attachLoopToSession', () => { expect(row!.executionVariant).toBe('thinking-max') expect(row!.auditorVariant).toBe('audit-high') }) + + function makeResumeSnapshot(): import('../../src/loop/resume-snapshot').LoopResumeSnapshot { + return { + version: 1, + kind: 'plan', + phase: 'coding', + currentSectionIndex: 1, + totalSections: 3, + finalAuditDone: false, + sections: [ + { sectionIndex: 0, title: 'Setup', content: 'Do setup', status: 'completed', attempts: 1, summaryDone: 'setup done', summaryDeviations: null, summaryFollowUps: null, startedAt: 100, completedAt: 200 }, + { sectionIndex: 1, title: 'Build', content: 'Do build', status: 'in_progress', attempts: 2, summaryDone: null, summaryDeviations: null, summaryFollowUps: null, startedAt: 300, completedAt: null }, + { sectionIndex: 2, title: 'Ship', content: 'Do ship', status: 'pending', attempts: 0, summaryDone: null, summaryDeviations: null, summaryFollowUps: null, startedAt: null, completedAt: null }, + ], + findings: [ + { file: 'a.ts', line: 10, severity: 'bug', description: 'broken thing', scenario: 'when X', sectionIndex: 1 }, + ], + } + } + + test('attaches a migrated loop from a resume snapshot', async () => { + const { deps, loopsRepo, sectionPlansRepo, promptAsyncMock } = buildDeps() + const reviewFindingsRepo = createReviewFindingsRepo(db) + + const { attachLoopToSession } = await import('../../src/services/execution') + + const snapshot = makeResumeSnapshot() + + const result = await attachLoopToSession( + deps as any, + { surface: 'tui', projectId: PROJECT_ID, directory: '/tmp/test' }, + { + sessionId: 'sess_resume', + workspaceId: 'ws_resume', + worktreeDir: '/tmp/wt/resume', + loopName: 'moved', + displayName: 'Moved Loop', + executionName: 'moved', + hostSessionId: 'host-sess', + executionModel: 'prov/exec', + auditorModel: 'prov/aud', + maxIterations: 40, + sandboxEnabled: false, + planText: '# Stale plan text', + selectSession: false, + selectSessionTiming: 'after-prompt', + startWatchdog: false, + sendInitialPrompt: false, + resume: snapshot, + }, + ) + + expect(result.ok).toBe(true) + + // Loop row persists the snapshot's phase pointers. + const row = loopsRepo.get(PROJECT_ID, 'moved') + expect(row).not.toBeNull() + expect(row!.phase).toBe('coding') + expect(row!.currentSectionIndex).toBe(1) + expect(row!.totalSections).toBe(3) + expect(row!.finalAuditDone).toBe(0) + expect(row!.status).toBe('running') + + // Section rows match the snapshot exactly; section 1 stays in_progress + // (no applyPlanDecomposition reset to index 0). + const sections = sectionPlansRepo.list(PROJECT_ID, 'moved') + expect(sections).toHaveLength(3) + const byIndex = new Map(sections.map((s) => [s.sectionIndex, s])) + expect(byIndex.get(0)!.status).toBe('completed') + expect(byIndex.get(1)!.status).toBe('in_progress') + expect(byIndex.get(1)!.attempts).toBe(2) + expect(byIndex.get(2)!.status).toBe('pending') + expect(byIndex.get(1)!.content).toBe('Do build') + + // Findings were restored for the loop. + const findings = reviewFindingsRepo.listByLoopName(PROJECT_ID, 'moved') + expect(findings).toHaveLength(1) + expect(findings[0].file).toBe('a.ts') + expect(findings[0].sectionIndex).toBe(1) + + // No initial prompt is sent. + expect(promptAsyncMock).not.toHaveBeenCalled() + + // In-memory state reflects the snapshot too. + const state = (deps.loop.service as any).getActiveState('moved') + expect(state).not.toBeNull() + expect(state!.phase).toBe('coding') + expect(state!.currentSectionIndex).toBe(1) + expect(state!.totalSections).toBe(3) + expect(state!.prompt).toBe('# Stale plan text') + }) + + test('resume attach with sendInitialPrompt sends the resume prompt for the snapshot phase', async () => { + const { deps, promptAsyncMock } = buildDeps() + + const { attachLoopToSession } = await import('../../src/services/execution') + + const snapshot: import('../../src/loop/resume-snapshot').LoopResumeSnapshot = { + version: 1, + kind: 'plan', + phase: 'final_auditing', + currentSectionIndex: 2, + totalSections: 3, + finalAuditDone: false, + sections: [ + { sectionIndex: 0, title: 'Setup', content: 'Do setup', status: 'completed', attempts: 1, summaryDone: 'setup done', summaryDeviations: null, summaryFollowUps: null, startedAt: 100, completedAt: 200 }, + { sectionIndex: 1, title: 'Build', content: 'Do build', status: 'completed', attempts: 1, summaryDone: 'build done', summaryDeviations: null, summaryFollowUps: null, startedAt: 300, completedAt: 400 }, + { sectionIndex: 2, title: 'Ship', content: 'Do ship', status: 'completed', attempts: 1, summaryDone: 'ship done', summaryDeviations: null, summaryFollowUps: null, startedAt: 500, completedAt: 600 }, + ], + findings: [], + } + + const result = await attachLoopToSession( + deps as any, + { surface: 'tui', projectId: PROJECT_ID, directory: '/tmp/test' }, + { + sessionId: 'sess_resume_audit', + workspaceId: 'ws_resume_audit', + worktreeDir: '/tmp/wt/resume-audit', + loopName: 'moved-audit', + displayName: 'Moved Audit Loop', + executionName: 'moved-audit', + hostSessionId: 'host-sess', + executionModel: 'prov/exec', + auditorModel: 'prov/aud', + maxIterations: 40, + sandboxEnabled: false, + planText: '# Plan text', + selectSession: false, + selectSessionTiming: 'after-prompt', + startWatchdog: false, + sendInitialPrompt: true, + resume: snapshot, + }, + ) + + expect(result.ok).toBe(true) + + const state = (deps.loop.service as any).getActiveState('moved-audit') + expect(state).not.toBeNull() + expect(state!.phase).toBe('final_auditing') + + expect(promptAsyncMock).toHaveBeenCalledTimes(1) + const promptCallArgs = promptAsyncMock.mock.calls[0][0] + expect(promptCallArgs.agent).toBe('auditor-loop') + const expectedPrompt = deps.loop.service.buildFinalAuditPrompt(state) + expect(promptCallArgs.parts[0].text).toBe(expectedPrompt) + }) }) diff --git a/test/services/loop-migration.test.ts b/test/services/loop-migration.test.ts new file mode 100644 index 000000000..23804953d --- /dev/null +++ b/test/services/loop-migration.test.ts @@ -0,0 +1,390 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest' +import { Database } from 'bun:sqlite' +import { mkdtempSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +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 { createSectionPlansRepo } from '../../src/storage/repos/section-plans-repo' +import { createLoopTransitionsRepo } from '../../src/storage/repos/loop-transitions-repo' +import { createLoopService } from '../../src/loop/service' +import type { LoopService } from '../../src/loop/service' +import type { Logger } from '../../src/types' +import type { LoopsRepo } from '../../src/storage/repos/loops-repo' +import type { PlansRepo } from '../../src/storage/repos/plans-repo' +import type { ReviewFindingsRepo } from '../../src/storage/repos/review-findings-repo' +import type { SectionPlansRepo } from '../../src/storage/repos/section-plans-repo' +import type { LoopTransitionsRepo } from '../../src/storage/repos/loop-transitions-repo' +import type { LoopState } from '../../src/loop/state' +import type { GitService } from '../../src/utils/git-service' +import type { ForgeClient } from '../../src/client/port' +import type { PluginConfig } from '../../src/types' +import { setupLoopsTestDb } from '../helpers/loops-test-db' +import { createFakeForgeClient } from '../helpers/fake-client' +import { createFakeGitService } from '../helpers/fake-git' +import { createClientSpy, REMOTE_URL, LOCAL_PROJECT_ID } from '../helpers/fake-remote-client' + +// launchTuiLoop's transitive TUI/storage imports must not touch real state; +// the repos below use the bun:sqlite vitest shim directly, so the storage +// module that launchTuiLoop pulls in is mocked by name instead. +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/utils/tui-loop-store', () => ({ + fetchLoopsList: vi.fn().mockReturnValue([]), +})) +vi.mock('../../src/storage', () => ({ + resolveLogPath: vi.fn().mockReturnValue('/tmp/forge-test.log'), + resolveDataDir: vi.fn().mockReturnValue('/tmp/forge-test-data'), +})) + +const mockLogger: Logger = { log: () => {}, error: () => {}, debug: () => {} } +const noopFn = () => {} +// The local OpenCode project id must match the remote fake's project id so +// discovery finds it (migration matches by identity, not path). +const PROJECT_ID = LOCAL_PROJECT_ID +const SYNC_REF = 'refs/forge/my-loop' +const DEADBEEF = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' + +describe('migrateLoopToRemote', () => { + let db: Database + let tempDir: string + let loopsRepo: LoopsRepo + let plansRepo: PlansRepo + let reviewFindingsRepo: ReviewFindingsRepo + let sectionPlansRepo: SectionPlansRepo + let loopTransitionsRepo: LoopTransitionsRepo + let loopService: LoopService + + const mockWorkspaceStatusRegistry = { + awaitConnected: async () => ({ connected: true }), + } + + const mockPendingTeardowns = { + register: () => {}, + unregister: () => {}, + get: () => undefined, + } + + beforeEach(() => { + process.env.FORGE_TUI_WORKSPACE_SETTLE_MS = '0' + tempDir = mkdtempSync(join(tmpdir(), 'loop-migration-test-')) + db = new Database(join(tempDir, 'test.db')) + setupLoopsTestDb(db) + + loopsRepo = createLoopsRepo(db) + plansRepo = createPlansRepo(db) + reviewFindingsRepo = createReviewFindingsRepo(db) + sectionPlansRepo = createSectionPlansRepo(db) + loopTransitionsRepo = createLoopTransitionsRepo(db) + loopService = createLoopService( + loopsRepo, + plansRepo, + reviewFindingsRepo, + PROJECT_ID, + mockLogger, + undefined, + undefined, + sectionPlansRepo, + loopTransitionsRepo, + ) + }) + + afterEach(() => { + try { db.close() } catch {} + }) + + interface SeedOpts { + loopName?: string + active?: boolean + status?: 'running' | 'completed' | 'cancelled' | 'errored' | 'stalled' + terminationReason?: string + } + + function seedLoop(opts: SeedOpts = {}): LoopState { + const loopName = opts.loopName ?? 'my-loop' + const active = opts.active ?? true + const state: LoopState = { + active, + sessionId: 'sess_local', + loopName, + worktreeDir: '/local/wt', + projectDir: '/local/proj', + worktreeBranch: 'forge/my-loop', + iteration: 3, + maxIterations: 40, + startedAt: new Date().toISOString(), + prompt: '# My Plan\n\nStep one.', + phase: 'auditing', + errorCount: 0, + auditCount: 1, + status: opts.status ?? (active ? 'running' : 'cancelled'), + worktree: true, + sandbox: false, + executionModel: 'prov/exec', + auditorModel: 'prov/aud', + currentSectionIndex: 1, + totalSections: 3, + finalAuditDone: false, + ...(opts.terminationReason ? { terminationReason: opts.terminationReason } : {}), + } + loopService.setState(loopName, state) + + sectionPlansRepo.bulkInsert({ + projectId: PROJECT_ID, + loopName, + sections: [ + { index: 0, title: 'Setup', content: 'Do setup' }, + { index: 1, title: 'Build', content: 'Do build' }, + { index: 2, title: 'Ship', content: 'Do ship' }, + ], + }) + sectionPlansRepo.setStatus(PROJECT_ID, loopName, 0, 'completed') + sectionPlansRepo.setStatus(PROJECT_ID, loopName, 1, 'in_progress') + + reviewFindingsRepo.write({ + projectId: PROJECT_ID, + loopName, + file: 'a.ts', + line: 10, + severity: 'bug', + description: 'section finding', + sectionIndex: 1, + }) + + plansRepo.writeForLoop(PROJECT_ID, loopName, '# My Plan\n\nStep one.') + return state + } + + function happyConfig(): PluginConfig { + return { + remotes: [{ name: 'server1', url: REMOTE_URL, password: 'sekret' }], + } + } + + function happyGit(): GitService { + return createFakeGitService({ + branchExists: vi.fn(() => true), + revParseRef: vi.fn(() => ({ ok: true, status: 0, stdout: `${DEADBEEF}\n`, stderr: '' })), + }) + } + + async function buildDeps(opts: { + config?: PluginConfig + git?: GitService + createRemoteClient?: (o: unknown) => ForgeClient + onTerminateLoopByName?: (name: string, reason: unknown) => void + } = {}) { + const terminateCalls: Array<{ name: string; reason: string }> = [] + + const mockLoopHandler = { + runExclusive: async (_name: string, fn: () => Promise) => fn(), + startWatchdog: noopFn, + clearLoopTimers: noopFn, + terminateLoopByName: async (name: string, reason: { kind: string; message?: string }) => { + opts.onTerminateLoopByName?.(name, reason) + terminateCalls.push({ name, reason: `${reason.kind}${reason.message ? `: ${reason.message}` : ''}` }) + const state = loopService.getActiveState(name) + if (!state?.active) return false + loopService.terminate(name, { + status: 'cancelled', + reason: `${reason.kind}${reason.message ? `: ${reason.message}` : ''}`, + completedAt: Date.now(), + }) + return true + }, + } + + const localClient = createFakeForgeClient().client + const remoteSpy = opts.createRemoteClient ?? createClientSpy().spy + + const { createForgeExecutionService } = await import('../../src/services/execution') + const service = createForgeExecutionService({ + projectId: PROJECT_ID, + directory: '/local/proj', + config: opts.config ?? happyConfig(), + logger: mockLogger, + dataDir: '/tmp', + plansRepo, + loopsRepo, + loop: { + service: loopService, + inspect: (name: string) => loopService.getAnyState(name), + listActive: (...args: unknown[]) => (loopService.listActive as (...a: unknown[]) => LoopState[])(...args), + listRecent: (...args: unknown[]) => (loopService.listRecent as (...a: unknown[]) => LoopState[])(...args), + setPhase: (...args: unknown[]) => (loopService.setPhase as (...a: unknown[]) => void)(...args), + generateUniqueLoopName: (...args: unknown[]) => (loopService.generateUniqueLoopName as (...a: unknown[]) => string)(...args), + registerSessionReverseIndex: noopFn, + unregisterSessionReverseIndex: noopFn, + } as unknown as Parameters[0]['loop'], + loopHandler: mockLoopHandler as never, + sectionPlansRepo, + reviewFindingsRepo, + workspaceStatusRegistry: mockWorkspaceStatusRegistry as never, + client: localClient, + pendingTeardowns: mockPendingTeardowns as never, + git: opts.git, + createRemoteClient: remoteSpy as unknown as (o: unknown) => ForgeClient, + }) + + return { service, terminateCalls, remoteSpy: remoteSpy as unknown as ReturnType } + } + + const ctx = { surface: 'api' as const, projectId: PROJECT_ID, directory: '/local/proj' } + const migrateCommand = { type: 'loop.migrate' as const, selector: { kind: 'exact' as const, name: 'my-loop' }, remoteName: 'server1' } + + test('happy path: freezes the loop as migrated, pushes sync ref, launches remote loop with snapshot', async () => { + seedLoop() + const git = happyGit() + const { spy: createClient, clients } = createClientSpy() + const terminateReasons: Array<{ name: string; reason: unknown }> = [] + const { service } = await buildDeps({ + git, + createRemoteClient: createClient as never, + onTerminateLoopByName: (name, reason) => terminateReasons.push({ name, reason }), + }) + + const result = await service.dispatch(ctx, migrateCommand) + if (!result.ok) throw new Error(`expected ok, got: ${JSON.stringify(result.error)}`) + + expect(result.data).toEqual({ + operation: 'loop.migrate', + loopName: 'my-loop', + remoteName: 'server1', + remoteLoopName: 'my-loop', + remoteSessionId: 'sess_remote', + startRef: DEADBEEF, + syncRef: SYNC_REF, + phase: 'coding', + currentSectionIndex: 1, + totalSections: 3, + }) + + // Loop handler received the migrated reason; local row is cancelled/migrated. + expect(terminateReasons).toEqual([{ name: 'my-loop', reason: { kind: 'migrated', message: 'server1' } }]) + const row = loopsRepo.get(PROJECT_ID, 'my-loop') + expect(row?.status).toBe('cancelled') + expect(row?.terminationReason).toBe('migrated: server1') + + // git.push: exactly one forced push of the loop branch tip to the sync ref. + expect(git.push).toHaveBeenCalledTimes(1) + expect(git.push).toHaveBeenCalledWith('/local/proj', 'origin', 'refs/heads/forge/my-loop:refs/forge/my-loop', true) + + // The scoped remote client created the workspace with the migration extras. + const remoteClient = clients[1] + expect(remoteClient.workspace.create).toHaveBeenCalledTimes(1) + const createParams = (remoteClient.workspace.create as ReturnType).mock.calls[0][0] + expect(createParams.extra.startRef).toBe(DEADBEEF) + expect(createParams.extra.syncRef).toBe(SYNC_REF) + expect(createParams.extra.gitRemote).toBe('origin') + expect(createParams.extra.forgeLoop.maxIterations).toBe(40) + expect(createParams.extra.forgeLoop.resume).toBeDefined() + expect(createParams.extra.forgeLoop.resume.totalSections).toBe(3) + expect(createParams.extra.forgeLoop.resume.currentSectionIndex).toBe(1) + expect(createParams.extra.forgeLoop.resume.findings).toHaveLength(1) + expect(createParams.extra.forgeLoop.planText).toBe('# My Plan\n\nStep one.') + + // Phase-appropriate first prompt on the remote session. + expect(remoteClient.session.promptAsync).toHaveBeenCalledTimes(1) + const promptInput = (remoteClient.session.promptAsync as ReturnType).mock.calls[0][0] + expect(promptInput.agent).toBe('code') + }) + + test('first remote prompt equals buildSectionInitialPrompt of the frozen state', async () => { + seedLoop() + const git = happyGit() + const { spy: createClient, clients } = createClientSpy() + const { service } = await buildDeps({ git, createRemoteClient: createClient as never }) + + const result = await service.dispatch(ctx, migrateCommand) + expect(result.ok).toBe(true) + + const frozen = loopService.getAnyState('my-loop')! + const expectedPrompt = loopService.buildSectionInitialPrompt(frozen) + expect(expectedPrompt.length).toBeGreaterThan(0) + const promptInput = (clients[1].session.promptAsync as ReturnType).mock.calls[0][0] + expect(promptInput.parts[0].text).toBe(expectedPrompt) + expect(promptInput.agent).toBe('code') + }) + + test('unknown remote fails bad_request without touching the loop', async () => { + seedLoop() + const git = happyGit() + const { service, terminateCalls } = await buildDeps({ git, createRemoteClient: createClientSpy().spy as never }) + + const result = await service.dispatch(ctx, { ...migrateCommand, remoteName: 'nope' }) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected error') + expect(result.error.code).toBe('bad_request') + expect(terminateCalls).toHaveLength(0) + expect(git.push).not.toHaveBeenCalled() + }) + + test('remote workspace.create failure rolls back: cleanup push, loop cancelled and restartable', async () => { + seedLoop() + const git = happyGit() + const { spy: createClient, clients } = createClientSpy({ + workspace: { + create: async () => { throw new Error('remote exploded') }, + }, + }) + const { service } = await buildDeps({ git, createRemoteClient: createClient as never }) + + const result = await service.dispatch(ctx, migrateCommand) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected error') + expect(result.error.code).toBe('internal_error') + expect(result.error.message).toContain('restartable') + + // Best-effort sync-ref cleanup push happened (delete: ':refs/forge/my-loop'). + expect(git.push).toHaveBeenCalledWith('/local/proj', 'origin', ':refs/forge/my-loop', false) + + // The local loop is cancelled (not migrated) and restartable again. + const row = loopsRepo.get(PROJECT_ID, 'my-loop') + expect(row?.status).toBe('cancelled') + expect(row?.terminationReason).toBe('cancelled') + const state = loopService.getAnyState('my-loop')! + const { getRestartability } = await import('../../src/loop/restartability') + expect(getRestartability(state, { worktreeExists: () => true, branchExists: () => true }).restartable).toBe(true) + expect(clients[1].session.create).not.toHaveBeenCalled() + }) + + test('a loop already migrated elsewhere fails conflict naming the previous remote', async () => { + seedLoop({ active: false, terminationReason: 'migrated: elsewhere' }) + const git = happyGit() + const { service, terminateCalls } = await buildDeps({ git, createRemoteClient: createClientSpy().spy as never }) + + const result = await service.dispatch(ctx, migrateCommand) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected error') + expect(result.error.code).toBe('conflict') + expect(result.error.message).toContain('elsewhere') + expect(terminateCalls).toHaveLength(0) + expect(git.push).not.toHaveBeenCalled() + }) + + test('a completed loop fails conflict', async () => { + seedLoop({ active: false, status: 'completed', terminationReason: 'completed' }) + const git = happyGit() + const { service, terminateCalls } = await buildDeps({ git, createRemoteClient: createClientSpy().spy as never }) + + const result = await service.dispatch(ctx, migrateCommand) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected error') + expect(result.error.code).toBe('conflict') + expect(terminateCalls).toHaveLength(0) + expect(git.push).not.toHaveBeenCalled() + }) +}) diff --git a/test/tools/loop-migrate-tool.test.ts b/test/tools/loop-migrate-tool.test.ts new file mode 100644 index 000000000..9ae68abad --- /dev/null +++ b/test/tools/loop-migrate-tool.test.ts @@ -0,0 +1,304 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest' +import { Database } from 'bun:sqlite' +import { mkdtempSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +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 { createSectionPlansRepo } from '../../src/storage/repos/section-plans-repo' +import { createLoopTransitionsRepo } from '../../src/storage/repos/loop-transitions-repo' +import { createLoopService } from '../../src/loop/service' +import type { LoopService } from '../../src/loop/service' +import type { Logger } from '../../src/types' +import type { LoopsRepo } from '../../src/storage/repos/loops-repo' +import type { PlansRepo } from '../../src/storage/repos/plans-repo' +import type { ReviewFindingsRepo } from '../../src/storage/repos/review-findings-repo' +import type { SectionPlansRepo } from '../../src/storage/repos/section-plans-repo' +import type { LoopTransitionsRepo } from '../../src/storage/repos/loop-transitions-repo' +import type { LoopState } from '../../src/loop/state' +import type { GitService } from '../../src/utils/git-service' +import type { ForgeClient } from '../../src/client/port' +import type { PluginConfig } from '../../src/types' +import { createLoopTools } from '../../src/tools/loop' +import { setupLoopsTestDb } from '../helpers/loops-test-db' +import { createFakeForgeClient } from '../helpers/fake-client' +import { createFakeGitService } from '../helpers/fake-git' +import { createClientSpy, REMOTE_URL, LOCAL_PROJECT_ID, REMOTE_SESSION_ID } from '../helpers/fake-remote-client' +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/utils/tui-loop-store', () => ({ + fetchLoopsList: vi.fn().mockReturnValue([]), +})) +vi.mock('../../src/storage', () => ({ + resolveLogPath: vi.fn().mockReturnValue('/tmp/forge-test.log'), + resolveDataDir: vi.fn().mockReturnValue('/tmp/forge-test-data'), +})) + +// The tool's makeService leaves `git` at its default, so the default git +// service itself must be faked for the migration path to succeed in tests. +vi.mock('../../src/utils/git-service', async (importOriginal) => { + const original = await importOriginal() + const { createFakeGitService } = await import('../helpers/fake-git') + const { vi: viFromMock } = await import('vitest') + const fake = createFakeGitService({ + branchExists: viFromMock.fn(() => true), + revParseRef: viFromMock.fn(() => ({ ok: true, status: 0, stdout: `${DEADBEEF}\n`, stderr: '' })), + }) + return { ...original, defaultGitService: fake } +}) + +// Same for remote discovery: the default client factory must resolve to the +// shared fake remote client so no network access happens. +vi.mock('../../src/client/sdk-adapter', async (importOriginal) => { + const original = await importOriginal() + const { makeFakeRemoteClient } = await import('../helpers/fake-remote-client') + return { ...original, createRemoteForgeClient: () => makeFakeRemoteClient() } +}) + +const mockLogger: Logger = { log: () => {}, error: () => {}, debug: () => {} } +const noopFn = () => {} +const PROJECT_ID = LOCAL_PROJECT_ID +const SYNC_REF = 'refs/forge/my-loop' +const DEADBEEF = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' + +describe('loop-migrate tool', () => { + let db: Database + let tempDir: string + let loopsRepo: LoopsRepo + let plansRepo: PlansRepo + let reviewFindingsRepo: ReviewFindingsRepo + let sectionPlansRepo: SectionPlansRepo + let loopTransitionsRepo: LoopTransitionsRepo + let loopService: LoopService + + const mockWorkspaceStatusRegistry = { + awaitConnected: async () => ({ connected: true }), + } + + const mockPendingTeardowns = { + register: () => {}, + unregister: () => {}, + get: () => undefined, + } + + beforeEach(() => { + process.env.FORGE_TUI_WORKSPACE_SETTLE_MS = '0' + tempDir = mkdtempSync(join(tmpdir(), 'loop-migrate-tool-test-')) + db = new Database(join(tempDir, 'test.db')) + setupLoopsTestDb(db) + + loopsRepo = createLoopsRepo(db) + plansRepo = createPlansRepo(db) + reviewFindingsRepo = createReviewFindingsRepo(db) + sectionPlansRepo = createSectionPlansRepo(db) + loopTransitionsRepo = createLoopTransitionsRepo(db) + loopService = createLoopService( + loopsRepo, + plansRepo, + reviewFindingsRepo, + PROJECT_ID, + mockLogger, + undefined, + undefined, + sectionPlansRepo, + loopTransitionsRepo, + ) + }) + + afterEach(() => { + try { db.close() } catch {} + }) + + function seedLoop(): LoopState { + const state: LoopState = { + active: true, + sessionId: 'sess_local', + loopName: 'my-loop', + worktreeDir: '/local/wt', + projectDir: '/local/proj', + worktreeBranch: 'forge/my-loop', + iteration: 3, + maxIterations: 40, + startedAt: new Date().toISOString(), + prompt: '# My Plan\n\nStep one.', + phase: 'auditing', + errorCount: 0, + auditCount: 1, + status: 'running', + worktree: true, + sandbox: false, + executionModel: 'prov/exec', + auditorModel: 'prov/aud', + currentSectionIndex: 1, + totalSections: 3, + finalAuditDone: false, + } + loopService.setState('my-loop', state) + + sectionPlansRepo.bulkInsert({ + projectId: PROJECT_ID, + loopName: 'my-loop', + sections: [ + { index: 0, title: 'Setup', content: 'Do setup' }, + { index: 1, title: 'Build', content: 'Do build' }, + { index: 2, title: 'Ship', content: 'Do ship' }, + ], + }) + sectionPlansRepo.setStatus(PROJECT_ID, 'my-loop', 0, 'completed') + sectionPlansRepo.setStatus(PROJECT_ID, 'my-loop', 1, 'in_progress') + + reviewFindingsRepo.write({ + projectId: PROJECT_ID, + loopName: 'my-loop', + file: 'a.ts', + line: 10, + severity: 'bug', + description: 'section finding', + sectionIndex: 1, + }) + + plansRepo.writeForLoop(PROJECT_ID, 'my-loop', '# My Plan\n\nStep one.') + return state + } + + function happyConfig(): PluginConfig { + return { + remotes: [{ name: 'server1', url: REMOTE_URL, password: 'sekret' }], + } + } + + function happyGit(): GitService { + return createFakeGitService({ + branchExists: vi.fn(() => true), + revParseRef: vi.fn(() => ({ ok: true, status: 0, stdout: `${DEADBEEF}\n`, stderr: '' })), + }) + } + + async function buildTools(opts: { + config?: PluginConfig + git?: GitService + createRemoteClient?: (o: unknown) => ForgeClient + } = {}) { + const mockLoopHandler = { + runExclusive: async (_name: string, fn: () => Promise) => fn(), + startWatchdog: noopFn, + clearLoopTimers: noopFn, + terminateLoopByName: async (name: string, reason: { kind: string; message?: string }) => { + const state = loopService.getActiveState(name) + if (!state?.active) return false + loopService.terminate(name, { + status: 'cancelled', + reason: `${reason.kind}${reason.message ? `: ${reason.message}` : ''}`, + completedAt: Date.now(), + }) + return true + }, + } + + const localClient = createFakeForgeClient().client + const remoteSpy = opts.createRemoteClient ?? createClientSpy().spy + + const { createForgeExecutionService } = await import('../../src/services/execution') + const service = createForgeExecutionService({ + projectId: PROJECT_ID, + directory: '/local/proj', + config: opts.config ?? happyConfig(), + logger: mockLogger, + dataDir: '/tmp', + plansRepo, + loopsRepo, + loop: { + service: loopService, + inspect: (name: string) => loopService.getAnyState(name), + listActive: (...args: unknown[]) => (loopService.listActive as (...a: unknown[]) => LoopState[])(...args), + listRecent: (...args: unknown[]) => (loopService.listRecent as (...a: unknown[]) => LoopState[])(...args), + setPhase: (...args: unknown[]) => (loopService.setPhase as (...a: unknown[]) => void)(...args), + generateUniqueLoopName: (...args: unknown[]) => (loopService.generateUniqueLoopName as (...a: unknown[]) => string)(...args), + registerSessionReverseIndex: noopFn, + unregisterSessionReverseIndex: noopFn, + } as unknown as Parameters[0]['loop'], + loopHandler: mockLoopHandler as never, + sectionPlansRepo, + reviewFindingsRepo, + workspaceStatusRegistry: mockWorkspaceStatusRegistry as never, + client: localClient, + pendingTeardowns: mockPendingTeardowns as never, + git: opts.git, + createRemoteClient: remoteSpy as unknown as (o: unknown) => ForgeClient, + }) + + const tools = createLoopTools({ + client: localClient, + workspaceStatusRegistry: mockWorkspaceStatusRegistry as never, + pendingTeardowns: mockPendingTeardowns as never, + directory: '/local/proj', + config: opts.config ?? happyConfig(), + loopService, + loopHandler: mockLoopHandler as never, + logger: mockLogger, + plansRepo, + loopsRepo, + projectId: PROJECT_ID, + dataDir: '/tmp', + loop: { + service: loopService, + inspect: (name: string) => loopService.getAnyState(name), + listActive: () => loopService.listActive(), + listRecent: () => loopService.listRecent(), + resolveLoopName: () => null, + } as never, + sectionPlansRepo, + reviewFindingsRepo, + } as never) + + return tools + } + + test('dispatches loop.migrate with a partial selector and renders the success text', async () => { + seedLoop() + const tools = await buildTools({ git: happyGit(), createRemoteClient: createClientSpy().spy as never }) + + const result = await tools['loop-migrate'].execute( + { name: 'my-loop', remote: 'server1' }, + { sessionID: 'host-session' } as never, + ) + + expect(result).toContain('Migrated loop "my-loop" to server1') + expect(result).toContain(`Remote loop: my-loop`) + expect(result).toContain(`Remote session: ${REMOTE_SESSION_ID}`) + expect(result).toContain('Resumed at: phase coding, section 2/3') + expect(result).toContain('Pinned commit: deadbee (refs/forge/my-loop)') + expect(result).toContain('cannot be restarted here') + }) + + test('renders candidate loops when the name is ambiguous', async () => { + const seeded = seedLoop() + const other: LoopState = { + ...seeded, + loopName: 'my-loop-2', + worktreeBranch: 'forge/my-loop-2', + sessionId: 'sess_local_2', + } + loopService.setState('my-loop-2', other) + + const tools = await buildTools({ git: happyGit(), createRemoteClient: createClientSpy().spy as never }) + + const result = await tools['loop-migrate'].execute( + { name: 'my-loo', remote: 'server1' }, + { sessionID: 'host-session' } as never, + ) + + expect(result).toContain('Multiple loops match') + expect(result).toContain('my-loop') + expect(result).toContain('my-loop-2') + }) +}) diff --git a/test/utils/tui-client-launch-initial-prompt.test.ts b/test/utils/tui-client-launch-initial-prompt.test.ts new file mode 100644 index 000000000..da7ec8592 --- /dev/null +++ b/test/utils/tui-client-launch-initial-prompt.test.ts @@ -0,0 +1,166 @@ +import { describe, test, expect, beforeEach, vi } from 'vitest' + +// ── Module mocks required by the transitively-imported launchTuiLoop ────── + +vi.mock('bun:sqlite', () => ({ + Database: vi.fn(), +})) + +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/utils/tui-loop-store', () => ({ + fetchLoopsList: vi.fn().mockReturnValue([]), +})) + +vi.mock('../../src/storage', () => ({ + resolveLogPath: vi.fn().mockReturnValue('/tmp/forge-test.log'), +})) + +vi.mock('../../src/services/execution', () => ({ + ForgeLoopExtra: {}, +})) + +// ── SUT ─────────────────────────────────────────────────────────────────── + +import { launchTuiLoop } from '../../src/utils/tui-client' +import type { ForgeClient } from '../../src/client/port' +import { createFakeForgeClient } from '../helpers/fake-client' +import { buildAuditSessionPermissionRuleset, buildLoopPermissionRuleset } from '../../src/constants/loop' + +// ── Helpers ─────────────────────────────────────────────────────────────── + +/** + * Fake ForgeClient with remote-launch flow defaults: a fixed workspace/ + * session id pair and a `connected` status so the poll resolves immediately. + */ +function makeFakeClient(): ForgeClient { + const { client } = createFakeForgeClient({ + session: { + create: async () => ({ id: 'sess_remote' }), + }, + workspace: { + create: async () => ({ id: 'ws_remote', directory: '/remote/wt', branch: null }), + status: async () => [{ workspaceID: 'ws_remote', status: 'connected' }], + }, + }) + return client +} + +beforeEach(() => { + process.env.FORGE_TUI_WORKSPACE_SETTLE_MS = '0' +}) + +// ── Tests ───────────────────────────────────────────────────────────────── + +describe('launchTuiLoop initialPrompt', () => { + test('with initialPrompt: session uses audit ruleset and promptAsync sends the override text/agent/model/variant', async () => { + const client = makeFakeClient() + + const result = await launchTuiLoop({ + client, + directory: '/p', + projectId: null, + requestedLoopName: 'moved', + loopNameReserved: true, + title: 'Moved', + plan: '# Plan', + permissionOptions: {}, + initialPrompt: { + text: 'AUDIT NOW', + agent: 'auditor-loop', + model: { providerID: 'p', modelID: 'm' }, + variant: 'high', + }, + }) + + expect('error' in result).toBe(false) + + const createArgs = (client.session.create as ReturnType).mock.calls[0][0] + expect(createArgs.permission).toEqual(buildAuditSessionPermissionRuleset({})) + + const promptCalls = (client.session.promptAsync as ReturnType).mock.calls + expect(promptCalls).toHaveLength(1) + const promptArgs = promptCalls[0][0] + expect(promptArgs.agent).toBe('auditor-loop') + expect(promptArgs.parts[0].type).toBe('text') + expect(promptArgs.parts[0].text).toBe('AUDIT NOW') + expect(promptArgs.model).toEqual({ providerID: 'p', modelID: 'm' }) + expect(promptArgs.variant).toBe('high') + }) + + test('without initialPrompt: deterministic section-0 prompt with code agent (existing behavior)', async () => { + const client = makeFakeClient() + + const plan = [ + '', + '# My Plan', + '', + '## First Section', + 'Do first work.', + '', + '## Second Section', + 'Do second work.', + '', + ].join('\n') + + const result = await launchTuiLoop({ + client, + directory: '/p', + projectId: null, + requestedLoopName: 'moved', + loopNameReserved: true, + title: 'Moved', + plan, + permissionOptions: {}, + }) + + expect('error' in result).toBe(false) + + const createArgs = (client.session.create as ReturnType).mock.calls[0][0] + expect(createArgs.permission).toEqual(buildLoopPermissionRuleset({})) + + const promptCalls = (client.session.promptAsync as ReturnType).mock.calls + expect(promptCalls).toHaveLength(1) + const promptArgs = promptCalls[0][0] + expect(promptArgs.agent).toBe('code') + expect(promptArgs.parts[0].text).toContain('[Loop section 1/2 -- iteration 1/50]') + expect(promptArgs.parts[0].text).toContain('## First Section') + expect(promptArgs.parts[0].text).not.toContain('Second Section') + expect(promptArgs.model).toBeUndefined() + expect(promptArgs.variant).toBeUndefined() + }) + + test('without initialPrompt and without a sectioned plan: sends the raw plan as prompt text', async () => { + const client = makeFakeClient() + + const result = await launchTuiLoop({ + client, + directory: '/p', + projectId: null, + requestedLoopName: 'moved', + loopNameReserved: true, + title: 'Moved', + plan: '# Plain Plan', + permissionOptions: {}, + }) + + expect('error' in result).toBe(false) + + const promptCalls = (client.session.promptAsync as ReturnType).mock.calls + expect(promptCalls).toHaveLength(1) + const promptArgs = promptCalls[0][0] + expect(promptArgs.agent).toBe('code') + expect(promptArgs.parts[0].text).toBe('# Plain Plan') + }) +}) diff --git a/test/utils/tui-remote-launch.test.ts b/test/utils/tui-remote-launch.test.ts index 3287e8e51..08242683b 100644 --- a/test/utils/tui-remote-launch.test.ts +++ b/test/utils/tui-remote-launch.test.ts @@ -33,58 +33,27 @@ vi.mock('../../src/services/execution', () => ({ // ── SUT ─────────────────────────────────────────────────────────────────── -import { executeRemoteLoop } from '../../src/utils/tui-remote-launch' +import { executeRemoteLoop, connectRemoteProject, pushForgeSyncRef, deleteForgeSyncRef } from '../../src/utils/tui-remote-launch' import type { PluginConfig } from '../../src/types' import type { GitService, GitResult } from '../../src/utils/git-service' import type { ForgeClient } from '../../src/client/port' import type { RemoteClientOptions } from '../../src/client/sdk-adapter' import { createFakeForgeClient } from '../helpers/fake-client' import { createFakeGitService } from '../helpers/fake-git' +import { + makeFakeRemoteClient as makeFakeClient, + createClientSpy, + REMOTE_URL, + LOCAL_PROJECT_ID, +} from '../helpers/fake-remote-client' // ── Helpers ─────────────────────────────────────────────────────────────── const defaultOk: GitResult = { ok: true, status: 0, stdout: '', stderr: '' } -/** - * Fake ForgeClient (shared helper) with remote-loop flow defaults: a fixed - * workspace/session id pair and a `connected` status so the poll resolves - * immediately. - */ -function makeFakeClient(): ForgeClient { - const { client } = createFakeForgeClient({ - session: { - create: async () => ({ id: 'sess_remote' }), - }, - workspace: { - create: async () => ({ id: 'ws_remote', directory: '/remote/wt', branch: null }), - status: async () => [{ workspaceID: 'ws_remote', status: 'connected' }], - }, - project: { - list: async () => [{ id: 'proj_1', worktree: '/remote/my-project' }], - }, - }) - return client -} - -/** Create a `vi.fn()` based `createClient` factory that records produced clients. */ -function createClientSpy(): { - spy: ReturnType - clients: ForgeClient[] -} { - const clients: ForgeClient[] = [] - const spy = vi.fn((_opts: RemoteClientOptions) => { - const client = makeFakeClient() - clients.push(client) - return client - }) - return { spy, clients } -} - // ── Shared test values ──────────────────────────────────────────────────── const LOCAL_DIR = '/home/user/my-project' -const REMOTE_URL = 'http://remote:4096' -const LOCAL_PROJECT_ID = 'proj_1' function happyConfig(): PluginConfig { return { @@ -100,6 +69,12 @@ function happyGit(): GitService { }) } +/** True when the resolved client is the spy's second (scoped) client. */ +function clientsMatch(client: ForgeClient, spy: ReturnType): boolean { + const scoped = spy.mock.calls[1]?.[0] + return scoped !== undefined && spy.mock.results[1]?.value === client +} + // ── Tests ───────────────────────────────────────────────────────────────── beforeEach(() => { @@ -615,3 +590,197 @@ describe('executeRemoteLoop', () => { ]) }) }) + +describe('connectRemoteProject', () => { + test('resolves remote, discovers project by id, and creates a scoped client', async () => { + const config = happyConfig() + const { spy: createClient } = createClientSpy() + const debug = vi.fn() + + const result = await connectRemoteProject( + { remoteName: 'server1', localProjectId: LOCAL_PROJECT_ID }, + { config, createClient: createClient as any, debug }, + ) + + expect(result).toEqual({ + remote: { + name: 'server1', + url: REMOTE_URL, + password: 'sekret', + username: 'opencode', + gitRemote: 'origin', + sandbox: true, + }, + project: { id: 'proj_1', worktree: '/remote/my-project' }, + client: expect.anything(), + }) + const client = (result as { client: ForgeClient }).client + expect(clientsMatch(client, createClient)).toBe(true) + + // Discovery first (no directory), then scoped with the matched worktree + expect(createClient).toHaveBeenCalledTimes(2) + expect(createClient).toHaveBeenNthCalledWith(1, { + url: REMOTE_URL, + username: 'opencode', + password: 'sekret', + }) + expect(createClient).toHaveBeenNthCalledWith(2, { + url: REMOTE_URL, + username: 'opencode', + password: 'sekret', + directory: '/remote/my-project', + }) + }) + + test('returns error naming configured remotes for an unknown remote name', async () => { + const config = happyConfig() + const createClient = vi.fn() + + const result = await connectRemoteProject( + { remoteName: 'unknown', localProjectId: LOCAL_PROJECT_ID }, + { config, createClient: createClient as any }, + ) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain('unknown') + expect(result.error).toContain('server1') + } + expect(createClient).not.toHaveBeenCalled() + }) + + test('returns error when project.list fails', async () => { + const config = happyConfig() + const clients: ForgeClient[] = [] + const createClient = vi.fn((_opts: RemoteClientOptions) => { + const client = makeFakeClient() + client.project.list = vi.fn().mockRejectedValue(new Error('boom')) as any + clients.push(client) + return client + }) + + const result = await connectRemoteProject( + { remoteName: 'server1', localProjectId: LOCAL_PROJECT_ID }, + { config, createClient: createClient as any }, + ) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain('Failed to list projects on remote "server1"') + expect(result.error).toContain('boom') + } + }) + + test('returns error when no remote project matches the local project id', async () => { + const config = happyConfig() + const clients: ForgeClient[] = [] + const createClient = vi.fn((_opts: RemoteClientOptions) => { + const client = makeFakeClient() + client.project.list = vi.fn().mockResolvedValue([ + { id: 'proj_other', worktree: '/remote/other-project' }, + ]) as any + clients.push(client) + return client + }) + + const result = await connectRemoteProject( + { remoteName: 'server1', localProjectId: LOCAL_PROJECT_ID }, + { config, createClient: createClient as any }, + ) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain(LOCAL_PROJECT_ID) + expect(result.error).toContain('proj_other') + } + }) + + test('returns error when the local project id is missing', async () => { + const config = happyConfig() + const { spy: createClient } = createClientSpy() + + const result = await connectRemoteProject( + { remoteName: 'server1', localProjectId: '' }, + { config, createClient: createClient as any }, + ) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain('project id') + } + expect(createClient).not.toHaveBeenCalled() + }) + + test('calls beforeDiscovery after resolving the remote but before any client creation', async () => { + const config = happyConfig() + const createClient = vi.fn() + const order: string[] = [] + + const result = await connectRemoteProject( + { remoteName: 'server1', localProjectId: LOCAL_PROJECT_ID }, + { + config, + createClient: createClient as any, + beforeDiscovery: () => { + order.push('beforeDiscovery') + return { error: 'preflight failed' } + }, + }, + ) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toBe('preflight failed') + } + expect(order).toEqual(['beforeDiscovery']) + expect(createClient).not.toHaveBeenCalled() + }) +}) + +describe('pushForgeSyncRef / deleteForgeSyncRef', () => { + test('pushForgeSyncRef pushes sourceRef to syncRef with force and returns ok', () => { + const git = happyGit() + + const result = pushForgeSyncRef(git, { + cwd: LOCAL_DIR, + gitRemote: 'origin', + sourceRef: 'refs/heads/forge/moved', + syncRef: 'refs/forge/moved', + }) + + expect(result).toEqual({ ok: true }) + expect(git.push).toHaveBeenCalledTimes(1) + expect(git.push).toHaveBeenCalledWith(LOCAL_DIR, 'origin', 'refs/heads/forge/moved:refs/forge/moved', true) + }) + + test('pushForgeSyncRef returns ok:false with stderr on push failure', () => { + const git = happyGit() + git.push = vi.fn(() => ({ + ok: false, + status: 1, + stdout: '', + stderr: 'error: failed to push some refs', + })) as any + + const result = pushForgeSyncRef(git, { + cwd: LOCAL_DIR, + gitRemote: 'origin', + sourceRef: 'refs/heads/forge/moved', + syncRef: 'refs/forge/moved', + }) + + expect(result).toEqual({ + ok: false, + error: 'error: failed to push some refs', + }) + }) + + test('deleteForgeSyncRef issues a non-forced remote ref deletion', () => { + const git = happyGit() + + deleteForgeSyncRef(git, { cwd: LOCAL_DIR, gitRemote: 'origin', syncRef: 'refs/forge/moved' }) + + expect(git.push).toHaveBeenCalledTimes(1) + expect(git.push).toHaveBeenCalledWith(LOCAL_DIR, 'origin', ':refs/forge/moved', false) + }) +}) From 3831ea504b118621966faeaa22171a385128a884 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:28:19 -0400 Subject: [PATCH 2/2] fix(loop): harden loop-migrate rollback, eligibility, and migrated-loop force restart --- README.md | 4 +- docs/api/README.md | 8 +- docs/api/_media/agents-and-commands.md | 2 + docs/api/_media/architecture.md | 2 +- docs/api/_media/configuration.md | 13 +- docs/api/_media/loop-system.md | 3 +- docs/api/_media/tools.md | 23 ++ docs/api/functions/createForgePlugin.md | 2 +- .../functions/createParentSessionLookup.md | 2 +- .../functions/createSessionDirectoryLookup.md | 2 +- docs/api/interfaces/CompactionConfig.md | 6 +- .../CreateParentSessionLookupOptions.md | 10 +- .../CreateSessionDirectoryLookupOptions.md | 10 +- docs/api/interfaces/DashboardConfig.md | 6 +- docs/api/interfaces/PluginConfig.md | 36 +-- docs/api/variables/VERSION.md | 2 +- docs/api/variables/default.md | 2 +- docs/architecture.md | 2 +- docs/configuration.md | 4 +- docs/loop-system.md | 2 +- docs/modules.md | 5 +- docs/tools.md | 10 +- src/agents/architect-auto.ts | 21 +- src/constants/loop.ts | 14 + src/dashboard/app-bundle.ts | 2 +- src/hooks/forge-session-attach.ts | 25 +- src/loop/restartability.ts | 6 +- src/loop/resume-prompt.ts | 27 +- src/loop/resume-snapshot.ts | 33 +++ src/loop/runtime.ts | 7 + src/loop/service.ts | 7 + src/prompts/agents/code.md | 2 +- src/prompts/commands/loop-migrate.md | 2 +- src/sandbox/process.ts | 2 + src/services/execution.ts | 92 +++--- src/services/loop-migration.ts | 273 +++++++++++------- src/storage/repos/loops-repo.ts | 13 + src/tools/loop.ts | 26 +- src/utils/git-service.ts | 31 ++ src/utils/tui-client.ts | 8 +- src/utils/tui-remote-launch.ts | 215 ++++++++++---- src/workspace/forge-adapter.ts | 7 +- test/constants/loop.test.ts | 19 +- test/helpers/fake-git.ts | 3 + test/hooks/forge-session-attach.test.ts | 127 ++++++++ test/hooks/loop-section-advancement.test.ts | 1 + test/loop/restartability.test.ts | 7 +- test/loop/resume-prompt.test.ts | 21 +- test/loop/resume-snapshot.test.ts | 18 ++ test/services/execution-restart.test.ts | 228 +++++++++++++++ test/services/loop-migration.test.ts | 234 ++++++++++++++- test/services/resolve-named-loop.test.ts | 89 ++++++ test/tools/loop-migrate-tool.test.ts | 11 +- .../tui-client-launch-initial-prompt.test.ts | 30 ++ test/utils/tui-remote-launch.test.ts | 243 +++++++++++++++- 55 files changed, 1647 insertions(+), 353 deletions(-) create mode 100644 test/services/resolve-named-loop.test.ts diff --git a/README.md b/README.md index 8aa48e872..0d1f06246 100644 --- a/README.md +++ b/README.md @@ -475,8 +475,8 @@ On model errors during execution, automatic fallback to the default model kicks ### Management -- **Slash commands**: `/execute-plan` to start, `/loop-cancel` to cancel -- **Tools**: `execute-plan` to start with parameters, `loop-status` for checking progress (with restart capability), `loop-cancel` to cancel +- **Slash commands**: `/execute-plan` to start, `/loop-cancel` to cancel, `/loop-migrate` to move a loop to a remote server +- **Tools**: `execute-plan` to start with parameters, `loop-status` for checking progress (with restart capability), `loop-cancel` to cancel, `loop-migrate` to move a loop to a configured remote ### Loop termination diff --git a/docs/api/README.md b/docs/api/README.md index 46c8b23b5..d84d62300 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -161,7 +161,7 @@ Forge provides these tool groups: - **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` +- **Loop tools** — `execute-plan`, `execute-goal`, `loop-cancel`, `loop-status`, `loop-migrate` - **Sandbox routing** — native `bash`, `glob`, and `grep` tools route into msb for sandboxed sessions Loops always run in an isolated git worktree; msb is used when enabled, configured, and available. @@ -172,6 +172,7 @@ Loops always run in an isolated git worktree; msb is used when enabled, configur | `execute-goal` | Execute a free-text goal in rotating dedicated code and auditor sessions inside an isolated git worktree. Args: `goal` required; `title`, `loopName`, `maxIterations` optional. | | `loop-cancel` | Cancel an active loop by worktree name | | `loop-status` | List active/recent loops or get detailed status by worktree name, including cumulative token usage when available. Supports `restart=true` to restart any non-completed loop (`running`, `cancelled`, `errored`, `stalled`). Completed loops are history-only and cannot be restarted. | +| `loop-migrate` | Move a loop to a configured remote opencode server, preserving phase, section progress, section summaries, and review findings. | `execute-plan` reads the current session's captured plan when `plan` is omitted. `maxIterations`, execution model, auditor model, and sandbox behavior come from configuration or the TUI execution dialog, not direct `execute-plan` tool arguments. @@ -185,6 +186,7 @@ Loops always run in an isolated git worktree; msb is used when enabled, configur | `/execute-goal` | Execute a free-text goal in dedicated worktree sessions until an audit leaves no findings | code | | `/loop-status` | Check status of all active loops | code | | `/loop-cancel` | Cancel the active loop | code | +| `/loop-migrate` | Migrate a loop to a remote opencode server | code | | `/launch-group` | Decompose a PRD or feature list into features and launch them as parallel planning + development loops | code | ## Configuration @@ -475,8 +477,8 @@ On model errors during execution, automatic fallback to the default model kicks ### Management -- **Slash commands**: `/execute-plan` to start, `/loop-cancel` to cancel -- **Tools**: `execute-plan` to start with parameters, `loop-status` for checking progress (with restart capability), `loop-cancel` to cancel +- **Slash commands**: `/execute-plan` to start, `/loop-cancel` to cancel, `/loop-migrate` to move a loop to a remote server +- **Tools**: `execute-plan` to start with parameters, `loop-status` for checking progress (with restart capability), `loop-cancel` to cancel, `loop-migrate` to move a loop to a configured remote ### Loop termination diff --git a/docs/api/_media/agents-and-commands.md b/docs/api/_media/agents-and-commands.md index f83ace4b1..bf5f85759 100644 --- a/docs/api/_media/agents-and-commands.md +++ b/docs/api/_media/agents-and-commands.md @@ -39,6 +39,7 @@ Excluded tools: - `execute-goal` - `loop-cancel` - `loop-status` +- `loop-migrate` Source: [`AUDITOR_TOOL_EXCLUDES`](../src/agents/auditor.ts). @@ -52,5 +53,6 @@ Source: [`AUDITOR_TOOL_EXCLUDES`](../src/agents/auditor.ts). | `/execute-goal` | Execute a goal in rotating dedicated code and auditor sessions inside an isolated worktree. | `code` | no | | `/loop-status` | Check status of all active loops. | `code` | no | | `/loop-cancel` | Cancel the active loop. | `code` | no | +| `/loop-migrate` | Migrate a loop to a configured remote opencode server. | `code` | no | Source: [`buildPluginCommands()`](../src/config.ts). diff --git a/docs/api/_media/architecture.md b/docs/api/_media/architecture.md index 7d3578f5f..99c1b056d 100644 --- a/docs/api/_media/architecture.md +++ b/docs/api/_media/architecture.md @@ -157,7 +157,7 @@ OpenCode Forge integrates with OpenCode through several hook points. The plugin Loops are autonomous and cannot answer permission prompts, but OpenCode's default subagent ruleset falls back to `ask` for most tools. To prevent deadlocks, `createLoopPermissionRejectHook` listens for `session.created` events. When the new session resolves to an active loop, the hook calls `v2.session.update()` to overwrite the child session's `permission` ruleset: - If the parent session has an allow-all ruleset (e.g. an auditor subagent), the parent's ruleset is inherited so the child stays under the same constraints. -- Otherwise the default loop ruleset from `buildLoopPermissionRuleset()` (`src/constants/loop.ts`) is applied — blanket allow-all inside the worktree, with explicit structural denies for `external_directory`, `review-write`, `review-delete`, `plan`, `plan_enter`, `plan_exit`, `plan-write`, `plan-edit`, `execute-plan`, `execute-goal`, `question`, `loop-cancel`, `loop-status`, `launch-group`, `group-status`, `group-cancel`. User-configured `loop.permissions` rules are layered in after the external-directory allows and before these structural denies (via `resolveLoopPermissionOptions`), so they can tailor user tools without overriding a structural deny. +- Otherwise the default loop ruleset from `buildLoopPermissionRuleset()` (`src/constants/loop.ts`) is applied — blanket allow-all inside the worktree, with explicit structural denies for `external_directory`, `review-write`, `review-delete`, `plan`, `plan_enter`, `plan_exit`, `plan-write`, `plan-edit`, `execute-plan`, `execute-goal`, `question`, `loop-cancel`, `loop-status`, `loop-migrate`, `launch-group`, `group-status`, `group-cancel`. User-configured `loop.permissions` rules are layered in after the external-directory allows and before these structural denies (via `resolveLoopPermissionOptions`), so they can tailor user tools without overriding a structural deny. A `PATCHED_SESSIONS` set deduplicates retries. Audit-only subagents use the stricter `buildAuditSessionPermissionRuleset()` (blanket allow-all with structural denies for the direct mutation tools `edit`/`write`/`multiedit`/`apply_patch`, plus the shared plan/loop structural denies). diff --git a/docs/api/_media/configuration.md b/docs/api/_media/configuration.md index 58a12edf6..442e449e4 100644 --- a/docs/api/_media/configuration.md +++ b/docs/api/_media/configuration.md @@ -81,7 +81,7 @@ Configured rules are layered into the ruleset in this order: Because configured rules sit between the external-directory allows and Forge's structural denies, they can deny user tools but can never override a structural deny. -**Forge-managed permissions are rejected** (ignored with a warning at plugin load, surfaced in the log and a one-time TUI toast). These include `*`, `external_directory`, and the structural denies: `plan`, `plan_enter`, `plan_exit`, `plan-write`, `plan-edit`, `execute-plan`, `execute-goal`, `question`, `loop-cancel`, `loop-status`, `launch-group`, `group-status`, `group-cancel`, `review-write`, `review-delete`, `edit`, `write`, `multiedit`, and `apply_patch`. An unattended loop that can call `question` would hang forever; allowing `plan-write`/`review-write` would corrupt the plan-of-record and audit channels; and `execute-plan`/`loop-*`/`group-*` would let a loop recurse into itself or manage other loops. +**Forge-managed permissions are rejected** (ignored with a warning at plugin load, surfaced in the log and a one-time TUI toast). These include `*`, `external_directory`, and the structural denies: `plan`, `plan_enter`, `plan_exit`, `plan-write`, `plan-edit`, `execute-plan`, `execute-goal`, `question`, `loop-cancel`, `loop-status`, `loop-migrate`, `launch-group`, `group-status`, `group-cancel`, `review-write`, `review-delete`, `edit`, `write`, `multiedit`, and `apply_patch`. An unattended loop that can call `question` would hang forever; allowing `plan-write`/`review-write` would corrupt the plan-of-record and audit channels; and `execute-plan`/`loop-*`/`group-*` would let a loop recurse into itself or manage other loops. **Blanket denies of Forge-required permissions are rejected too**: `review-read`, `plan-read`, `section-read`, `plan-adjust`, `bash`, and `read` may not be denied outright. A loop that cannot read its findings, section plan, or plan-of-record — or cannot run `bash` or `read` at all — cannot do its job and would silently burn iterations to `maxIterations` with nothing pointing at the config. Only the blanket form (a bare tool name, or pattern `*`) is rejected; a scoped deny such as `{ "permission": "bash", "pattern": "git push *" }` is honoured. @@ -232,9 +232,18 @@ Example: 3. The remote server creates the loop worktree pinned to that exact SHA, fetching the sync ref when the commit is not yet in its clone. 4. On final loop teardown, the remote deletes the sync ref from the shared git remote (restart-preserving teardowns keep it). If a loop is deleted outside normal teardown, remove leftovers manually with `git push --delete refs/forge/`. +### Migrating a running loop + +The `loop-migrate` tool (and `/loop-migrate`) moves an existing local loop to a remote server. It reuses the same remote discovery and sync-ref mechanics as the TUI launch above, with two differences: + +- The pushed sync ref `refs/forge/` points at the **loop branch tip** (`refs/heads/`), not local `HEAD` — the work done inside the loop travels with the migration even though it was never merged. +- The remote worktree is pinned to that loop-branch SHA via `startRef`, and the loop resumes from a snapshot of its phase, section progress, and review findings. + +The local loop is terminated as `migrated: ` and further management (including `loop-status` and `loop-cancel`) happens on the remote server; see [Tools → `loop-migrate`](tools.md#loop-migrate) for eligibility, rollback, and local force-restart. The observability caveat below applies — the migrated loop disappears from the local sidebar, `loop-status`, and dashboard. + ### Caveats -- **Version skew**: the remote server must run a forge version with SHA-pin support (`startRef`/`syncRef` handling — the same release that introduced `remotes`, or newer). An older remote silently ignores the pin and runs the loop from its clone's current `HEAD` with no error on either side. +- **Version skew**: the remote server must run a forge version with SHA-pin support (`startRef`/`syncRef` handling — the same release that introduced `remotes`, or newer). An older remote silently ignores the pin and runs the loop from its clone's current `HEAD` with no error on either side. `loop-migrate` additionally requires the remote to understand the `forgeLoop.resume` snapshot (the release that introduced `loop-migrate`, or newer); an older remote ignores it and starts the loop from section 0 while the first prompt already targets the migrated phase. - **Sandbox mirroring**: `remotes[].sandbox` is a local assertion about the remote's capability. The launch bakes the session's shell permission ruleset from it; if it does not match the remote's real sandbox state, loop shell commands can be denied. - **Observability**: remote loops run entirely on the remote server. They do not appear in the local sidebar, `loop-status`, or dashboard. Results land on the `forge/` branch in the remote machine's clone; fetch or push that branch from the remote to retrieve them. diff --git a/docs/api/_media/loop-system.md b/docs/api/_media/loop-system.md index cde0e4f03..b8f600b92 100644 --- a/docs/api/_media/loop-system.md +++ b/docs/api/_media/loop-system.md @@ -15,13 +15,14 @@ The loop system provides autonomous iterative development with automatic code au - **Any non-completed loop is restartable** via explicit restart when the worktree is available. - Restartable statuses: `running`, `cancelled`, `errored`, `stalled`. - **Completed loops are history-only** and cannot be restarted. +- **`migrated` loops require force** — a loop migrated to a remote server with `loop-migrate` is terminated with the terminal reason `migrated: `; it restarts locally only with `force=true`, since a copy may be running on the remote. See [Tools → `loop-migrate`](tools.md#loop-migrate). - **Missing worktree blocks restart** — the worktree directory must exist for restart to proceed. ### Restart Semantics - Restart preserves loop identity, plan, worktree path, section progress, and review findings. - Restart resets iteration count and error budget. -- Restart creates a fresh session and resumes from the persisted phase and section index. +- Restart creates a fresh session and resumes from the persisted phase and section index. The phase/prompt selection for the resumed session is shared with the remote migration path: a loop migrated with `loop-migrate` starts on the remote from the same phase-appropriate prompt plan a local restart would build. ### Stale Workspace Sweep diff --git a/docs/api/_media/tools.md b/docs/api/_media/tools.md index 885e9683f..5478fe103 100644 --- a/docs/api/_media/tools.md +++ b/docs/api/_media/tools.md @@ -20,6 +20,7 @@ See also: [Agents and Slash Commands](agents-and-commands.md), [Configuration](c | `execute-goal` | Start a managed goal loop in a dedicated code session inside an isolated Forge worktree. | [`src/tools/loop.ts`](../src/tools/loop.ts) | | `loop-cancel` | Cancel an active loop. | [`src/tools/loop.ts`](../src/tools/loop.ts) | | `loop-status` | List loops, inspect one loop, or restart a restartable loop. | [`src/tools/loop.ts`](../src/tools/loop.ts) | +| `loop-migrate` | Move a loop to a configured remote opencode server, preserving its progress. | [`src/tools/loop.ts`](../src/tools/loop.ts) | | `launch-group` | Launch a group of features (from a PRD or a pre-split list), each planned and run as its own loop, scheduled with a concurrency cap. | [`src/tools/group.ts`](../src/tools/group.ts) | | `group-status` | List groups, inspect one group's per-feature stages, or restart a non-completed group. | [`src/tools/group.ts`](../src/tools/group.ts) | | `group-cancel` | Cancel a group, optionally cancelling its running loops. | [`src/tools/group.ts`](../src/tools/group.ts) | @@ -187,6 +188,28 @@ Arguments: Completed loops are history-only and cannot be restarted. See [Loop System](loop-system.md#restartability). +### `loop-migrate` + +Moves a loop to a configured remote opencode server (see [Configuration → Remotes](configuration.md#remotes)). The local loop is terminated with the terminal reason `migrated: ` and the loop continues on the remote from the pushed loop branch tip. The local worktree and branch stay in place; a migrated loop can still be restarted locally, but only with `loop-status restart=true force=true`, because a copy may be running on the remote. + +Eligibility: only worktree-backed loops that are not part of a feature group can be migrated. Project-directory loops (`execute-plan mode: new-session`) and feature-group loops are refused before any side effect, as are completed loops and loops already marked `migrated`. + +What is carried over: + +- The loop's phase, section pointers (`currentSectionIndex`/`totalSections`/`finalAuditDone`), section plan rows, section summaries, and review findings travel as a resume snapshot in the remote workspace's `forgeLoop` extra. The snapshot is validated on the remote; an invalid or incompatible snapshot removes the remote workspace instead of starting a fresh loop, and the local loop can then be force-restarted. +- The loop's original plan text is forwarded, so restartability display and legacy non-sectioned resume keep working on the remote. +- The execution/auditor models and variants are forwarded unchanged. +- The remote loop name is reserved (the local name is kept when available). The remote session's portable permission rules are resolved from the local loop's workspace — configured `loop.permissions` merged with any portable rules the loop itself arrived with — without host-specific external directories. + +Failure semantics: every pre-freeze failure (unknown remote, no matching project, unreachable server, ineligible loop) leaves the local loop untouched. The freeze re-checks the loop under its state lock and refuses if the loop changed state meanwhile. After the freeze, the loop's worktree must be clean (the teardown commit must have landed); a dirty worktree, a failed snapshot, branch-tip resolve, push, or remote launch rolls back: the sync-ref push is deleted (best effort) and the local loop is restored to its previous terminal status (`cancelled`, `errored`, or `stalled`) or relabelled `cancelled` when it was running, so it stays restartable with `loop-status restart=true`. Only the success path leaves the loop marked `migrated`. + +Arguments: + +| Argument | Description | +|---|---| +| `name` | Required loop name (or branch) to migrate. | +| `remote` | Required configured `remotes[].name` to migrate to. | + > Group, loop, and plan tools are denied inside loop and audit sessions so an in-flight loop cannot recursively spawn more work. ## Group Tools diff --git a/docs/api/functions/createForgePlugin.md b/docs/api/functions/createForgePlugin.md index 05a867284..3d5302c9c 100644 --- a/docs/api/functions/createForgePlugin.md +++ b/docs/api/functions/createForgePlugin.md @@ -8,7 +8,7 @@ > **createForgePlugin**(`config`): `Plugin` -Defined in: [index.ts:290](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L290) +Defined in: [index.ts:290](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L290) 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 920287076..c4f29aba9 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:91](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L91) +Defined in: [index.ts:91](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L91) ## Parameters diff --git a/docs/api/functions/createSessionDirectoryLookup.md b/docs/api/functions/createSessionDirectoryLookup.md index b7ed41736..d206725f4 100644 --- a/docs/api/functions/createSessionDirectoryLookup.md +++ b/docs/api/functions/createSessionDirectoryLookup.md @@ -8,7 +8,7 @@ > **createSessionDirectoryLookup**(`options`): (`sessionId`) => `Promise`\<`string` \| `null`\> -Defined in: [index.ts:193](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L193) +Defined in: [index.ts:193](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L193) ## Parameters diff --git a/docs/api/interfaces/CompactionConfig.md b/docs/api/interfaces/CompactionConfig.md index 1ec68ef75..9ce7ddc6d 100644 --- a/docs/api/interfaces/CompactionConfig.md +++ b/docs/api/interfaces/CompactionConfig.md @@ -6,7 +6,7 @@ # Interface: CompactionConfig -Defined in: [types.ts:189](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L189) +Defined in: [types.ts:189](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L189) Configuration for session compaction behavior. @@ -16,7 +16,7 @@ Configuration for session compaction behavior. > `optional` **customPrompt?**: `boolean` -Defined in: [types.ts:191](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L191) +Defined in: [types.ts:191](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L191) Use a custom compaction prompt. @@ -26,6 +26,6 @@ Use a custom compaction prompt. > `optional` **maxContextTokens?**: `number` -Defined in: [types.ts:193](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L193) +Defined in: [types.ts:193](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L193) Maximum context tokens for compaction. diff --git a/docs/api/interfaces/CreateParentSessionLookupOptions.md b/docs/api/interfaces/CreateParentSessionLookupOptions.md index aca48782c..6e37fd36e 100644 --- a/docs/api/interfaces/CreateParentSessionLookupOptions.md +++ b/docs/api/interfaces/CreateParentSessionLookupOptions.md @@ -6,7 +6,7 @@ # Interface: CreateParentSessionLookupOptions -Defined in: [index.ts:53](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L53) +Defined in: [index.ts:53](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L53) ## Properties @@ -14,7 +14,7 @@ Defined in: [index.ts:53](https://github.com/chriswritescode-dev/opencode-forge/ > **client**: `ForgeClient` -Defined in: [index.ts:54](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L54) +Defined in: [index.ts:54](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L54) *** @@ -22,7 +22,7 @@ Defined in: [index.ts:54](https://github.com/chriswritescode-dev/opencode-forge/ > **directory**: `string` -Defined in: [index.ts:55](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L55) +Defined in: [index.ts:55](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L55) *** @@ -30,7 +30,7 @@ Defined in: [index.ts:55](https://github.com/chriswritescode-dev/opencode-forge/ > **logger**: `object` -Defined in: [index.ts:57](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L57) +Defined in: [index.ts:57](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L57) #### debug @@ -92,4 +92,4 @@ Defined in: [index.ts:57](https://github.com/chriswritescode-dev/opencode-forge/ > **loop**: `Loop` -Defined in: [index.ts:56](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L56) +Defined in: [index.ts:56](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L56) diff --git a/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md b/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md index d96a55c67..0673d1ad1 100644 --- a/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md +++ b/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md @@ -6,7 +6,7 @@ # Interface: CreateSessionDirectoryLookupOptions -Defined in: [index.ts:139](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L139) +Defined in: [index.ts:139](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L139) ## Properties @@ -14,7 +14,7 @@ Defined in: [index.ts:139](https://github.com/chriswritescode-dev/opencode-forge > **client**: `ForgeClient` -Defined in: [index.ts:140](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L140) +Defined in: [index.ts:140](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L140) *** @@ -22,7 +22,7 @@ Defined in: [index.ts:140](https://github.com/chriswritescode-dev/opencode-forge > **directory**: `string` -Defined in: [index.ts:141](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L141) +Defined in: [index.ts:141](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L141) *** @@ -30,7 +30,7 @@ Defined in: [index.ts:141](https://github.com/chriswritescode-dev/opencode-forge > **loop**: `Loop` -Defined in: [index.ts:142](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L142) +Defined in: [index.ts:142](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L142) *** @@ -38,4 +38,4 @@ Defined in: [index.ts:142](https://github.com/chriswritescode-dev/opencode-forge > `optional` **negativeTtlMs?**: `number` -Defined in: [index.ts:143](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L143) +Defined in: [index.ts:143](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L143) diff --git a/docs/api/interfaces/DashboardConfig.md b/docs/api/interfaces/DashboardConfig.md index 47d6ae3d4..43b94dafa 100644 --- a/docs/api/interfaces/DashboardConfig.md +++ b/docs/api/interfaces/DashboardConfig.md @@ -6,7 +6,7 @@ # Interface: DashboardConfig -Defined in: [types.ts:225](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L225) +Defined in: [types.ts:225](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L225) Configuration for the read-only observability dashboard HTTP server. The dashboard is unauthenticated: binding to a non-loopback address exposes @@ -20,7 +20,7 @@ for the canonical warning text rendered by launch surfaces. > `optional` **host?**: `string` -Defined in: [types.ts:227](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L227) +Defined in: [types.ts:227](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L227) Bind hostname or IP. Defaults to "localhost". Use "0.0.0.0" to listen on all interfaces. @@ -30,6 +30,6 @@ Bind hostname or IP. Defaults to "localhost". Use "0.0.0.0" to listen on all int > `optional` **port?**: `number` -Defined in: [types.ts:229](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L229) +Defined in: [types.ts:229](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L229) Base bind port. Defaults to 4747. Consecutive ports are tried when busy. diff --git a/docs/api/interfaces/PluginConfig.md b/docs/api/interfaces/PluginConfig.md index afef143b8..2055940bb 100644 --- a/docs/api/interfaces/PluginConfig.md +++ b/docs/api/interfaces/PluginConfig.md @@ -6,7 +6,7 @@ # Interface: PluginConfig -Defined in: [types.ts:279](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L279) +Defined in: [types.ts:279](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L279) 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:311](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L311) +Defined in: [types.ts:311](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L311) Per-agent configuration overrides. @@ -26,7 +26,7 @@ Per-agent configuration overrides. > `optional` **auditorFallbackModels?**: (`string` \| `AuditorFallbackModel`)[] -Defined in: [types.ts:297](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L297) +Defined in: [types.ts:297](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L297) Ordered entries tried, in order, when the current auditor model hits a provider usage/auth limit mid-loop. Use a `"provider/model"` string, or `{ model, variant }` to pin a variant to that fallback; the primary `auditorVariant` is **not** inherited by fallback entries. @@ -36,7 +36,7 @@ Ordered entries tried, in order, when the current auditor model hits a provider > `optional` **auditorModel?**: `string` -Defined in: [types.ts:291](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L291) +Defined in: [types.ts:291](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L291) Model to use for code auditing. @@ -46,7 +46,7 @@ Model to use for code auditing. > `optional` **auditorVariant?**: `string` -Defined in: [types.ts:295](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L295) +Defined in: [types.ts:295](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L295) Default reasoning/thinking variant for the auditor model. @@ -56,7 +56,7 @@ Default reasoning/thinking variant for the auditor model. > `optional` **compaction?**: [`CompactionConfig`](CompactionConfig.md) -Defined in: [types.ts:285](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L285) +Defined in: [types.ts:285](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L285) Compaction behavior configuration. @@ -66,7 +66,7 @@ Compaction behavior configuration. > `optional` **completedLoopTtlMs?**: `number` -Defined in: [types.ts:305](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L305) +Defined in: [types.ts:305](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L305) TTL for completed/cancelled/errored/stalled loops before sweep. Default 7 days. @@ -76,7 +76,7 @@ TTL for completed/cancelled/errored/stalled loops before sweep. Default 7 days. > `optional` **dashboard?**: [`DashboardConfig`](DashboardConfig.md) -Defined in: [types.ts:309](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L309) +Defined in: [types.ts:309](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L309) Dashboard HTTP server bind configuration. @@ -86,7 +86,7 @@ Dashboard HTTP server bind configuration. > `optional` **dataDir?**: `string` -Defined in: [types.ts:281](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L281) +Defined in: [types.ts:281](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L281) Custom data directory for plugin storage. Defaults to platform data dir. @@ -96,7 +96,7 @@ Custom data directory for plugin storage. Defaults to platform data dir. > `optional` **executionModel?**: `string` -Defined in: [types.ts:289](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L289) +Defined in: [types.ts:289](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L289) Model to use for code execution. @@ -106,7 +106,7 @@ Model to use for code execution. > `optional` **executionVariant?**: `string` -Defined in: [types.ts:293](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L293) +Defined in: [types.ts:293](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L293) Default reasoning/thinking variant for the execution model. @@ -116,7 +116,7 @@ Default reasoning/thinking variant for the execution model. > `optional` **groupLaunch?**: `GroupLaunchConfig` -Defined in: [types.ts:301](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L301) +Defined in: [types.ts:301](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L301) Group launch configuration. @@ -126,7 +126,7 @@ Group launch configuration. > `optional` **logging?**: `LoggingConfig` -Defined in: [types.ts:283](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L283) +Defined in: [types.ts:283](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L283) Logging configuration. @@ -136,7 +136,7 @@ Logging configuration. > `optional` **loop?**: `LoopConfig` -Defined in: [types.ts:299](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L299) +Defined in: [types.ts:299](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L299) Loop behavior configuration. @@ -146,7 +146,7 @@ Loop behavior configuration. > `optional` **messagesTransform?**: `MessagesTransformConfig` -Defined in: [types.ts:287](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L287) +Defined in: [types.ts:287](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L287) Message transformation for architect agent. @@ -156,7 +156,7 @@ Message transformation for architect agent. > `optional` **remotes?**: `RemoteServerConfig`[] -Defined in: [types.ts:303](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L303) +Defined in: [types.ts:303](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L303) Remote opencode servers available as loop launch targets. @@ -166,7 +166,7 @@ Remote opencode servers available as loop launch targets. > `optional` **sandbox?**: `SandboxConfig` -Defined in: [types.ts:313](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L313) +Defined in: [types.ts:313](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L313) Sandbox execution configuration. @@ -176,6 +176,6 @@ Sandbox execution configuration. > `optional` **tui?**: `TuiConfig` -Defined in: [types.ts:307](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L307) +Defined in: [types.ts:307](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L307) TUI display configuration. diff --git a/docs/api/variables/VERSION.md b/docs/api/variables/VERSION.md index 3f410e8e4..33b58d732 100644 --- a/docs/api/variables/VERSION.md +++ b/docs/api/variables/VERSION.md @@ -8,4 +8,4 @@ > `const` **VERSION**: `"0.9.3"` = `'0.9.3'` -Defined in: [version.ts:1](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/version.ts#L1) +Defined in: [version.ts:1](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/version.ts#L1) diff --git a/docs/api/variables/default.md b/docs/api/variables/default.md index c80a54acb..c9510b506 100644 --- a/docs/api/variables/default.md +++ b/docs/api/variables/default.md @@ -8,7 +8,7 @@ > `const` **default**: `object` -Defined in: [index.ts:1021](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L1021) +Defined in: [index.ts:1021](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L1021) ## Type Declaration diff --git a/docs/architecture.md b/docs/architecture.md index 7d3578f5f..99c1b056d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -157,7 +157,7 @@ OpenCode Forge integrates with OpenCode through several hook points. The plugin Loops are autonomous and cannot answer permission prompts, but OpenCode's default subagent ruleset falls back to `ask` for most tools. To prevent deadlocks, `createLoopPermissionRejectHook` listens for `session.created` events. When the new session resolves to an active loop, the hook calls `v2.session.update()` to overwrite the child session's `permission` ruleset: - If the parent session has an allow-all ruleset (e.g. an auditor subagent), the parent's ruleset is inherited so the child stays under the same constraints. -- Otherwise the default loop ruleset from `buildLoopPermissionRuleset()` (`src/constants/loop.ts`) is applied — blanket allow-all inside the worktree, with explicit structural denies for `external_directory`, `review-write`, `review-delete`, `plan`, `plan_enter`, `plan_exit`, `plan-write`, `plan-edit`, `execute-plan`, `execute-goal`, `question`, `loop-cancel`, `loop-status`, `launch-group`, `group-status`, `group-cancel`. User-configured `loop.permissions` rules are layered in after the external-directory allows and before these structural denies (via `resolveLoopPermissionOptions`), so they can tailor user tools without overriding a structural deny. +- Otherwise the default loop ruleset from `buildLoopPermissionRuleset()` (`src/constants/loop.ts`) is applied — blanket allow-all inside the worktree, with explicit structural denies for `external_directory`, `review-write`, `review-delete`, `plan`, `plan_enter`, `plan_exit`, `plan-write`, `plan-edit`, `execute-plan`, `execute-goal`, `question`, `loop-cancel`, `loop-status`, `loop-migrate`, `launch-group`, `group-status`, `group-cancel`. User-configured `loop.permissions` rules are layered in after the external-directory allows and before these structural denies (via `resolveLoopPermissionOptions`), so they can tailor user tools without overriding a structural deny. A `PATCHED_SESSIONS` set deduplicates retries. Audit-only subagents use the stricter `buildAuditSessionPermissionRuleset()` (blanket allow-all with structural denies for the direct mutation tools `edit`/`write`/`multiedit`/`apply_patch`, plus the shared plan/loop structural denies). diff --git a/docs/configuration.md b/docs/configuration.md index 57584aed6..442e449e4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -239,11 +239,11 @@ The `loop-migrate` tool (and `/loop-migrate`) moves an existing local loop to a - The pushed sync ref `refs/forge/` points at the **loop branch tip** (`refs/heads/`), not local `HEAD` — the work done inside the loop travels with the migration even though it was never merged. - The remote worktree is pinned to that loop-branch SHA via `startRef`, and the loop resumes from a snapshot of its phase, section progress, and review findings. -The local worktree and branch stay in place; the local loop is terminated as `migrated: `, which blocks a local restart, and all further management (including `loop-status` and `loop-cancel`) happens on the remote server. The observability caveat below applies — the migrated loop disappears from the local sidebar, `loop-status`, and dashboard. +The local loop is terminated as `migrated: ` and further management (including `loop-status` and `loop-cancel`) happens on the remote server; see [Tools → `loop-migrate`](tools.md#loop-migrate) for eligibility, rollback, and local force-restart. The observability caveat below applies — the migrated loop disappears from the local sidebar, `loop-status`, and dashboard. ### Caveats -- **Version skew**: the remote server must run a forge version with SHA-pin support (`startRef`/`syncRef` handling — the same release that introduced `remotes`, or newer). An older remote silently ignores the pin and runs the loop from its clone's current `HEAD` with no error on either side. +- **Version skew**: the remote server must run a forge version with SHA-pin support (`startRef`/`syncRef` handling — the same release that introduced `remotes`, or newer). An older remote silently ignores the pin and runs the loop from its clone's current `HEAD` with no error on either side. `loop-migrate` additionally requires the remote to understand the `forgeLoop.resume` snapshot (the release that introduced `loop-migrate`, or newer); an older remote ignores it and starts the loop from section 0 while the first prompt already targets the migrated phase. - **Sandbox mirroring**: `remotes[].sandbox` is a local assertion about the remote's capability. The launch bakes the session's shell permission ruleset from it; if it does not match the remote's real sandbox state, loop shell commands can be denied. - **Observability**: remote loops run entirely on the remote server. They do not appear in the local sidebar, `loop-status`, or dashboard. Results land on the `forge/` branch in the remote machine's clone; fetch or push that branch from the remote to retrieve them. diff --git a/docs/loop-system.md b/docs/loop-system.md index fffde3a51..b8f600b92 100644 --- a/docs/loop-system.md +++ b/docs/loop-system.md @@ -15,7 +15,7 @@ The loop system provides autonomous iterative development with automatic code au - **Any non-completed loop is restartable** via explicit restart when the worktree is available. - Restartable statuses: `running`, `cancelled`, `errored`, `stalled`. - **Completed loops are history-only** and cannot be restarted. -- **`migrated` loops are not restartable locally** — a loop migrated to a remote server with `loop-migrate` is terminated with the terminal reason `migrated: ` and must be managed on that server. +- **`migrated` loops require force** — a loop migrated to a remote server with `loop-migrate` is terminated with the terminal reason `migrated: `; it restarts locally only with `force=true`, since a copy may be running on the remote. See [Tools → `loop-migrate`](tools.md#loop-migrate). - **Missing worktree blocks restart** — the worktree directory must exist for restart to proceed. ### Restart Semantics diff --git a/docs/modules.md b/docs/modules.md index 26c450b42..08571d102 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -176,6 +176,8 @@ type TerminationReason = | { kind: 'coding_no_assistant' } | { kind: 'worktree_failed'; message: string } | { kind: 'error_max_retries'; message: string } + | { kind: 'provider_limit'; message: string } + | { kind: 'migrated'; message: string } ``` ### Public API @@ -389,6 +391,7 @@ Implements tools callable by AI agents during conversations. | `execute-goal` | `loop.ts` | Execute a non-empty goal in a dedicated session inside a managed worktree. Args: `goal` required; `title`, `loopName`, `maxIterations`, `hostSessionId` optional. | | `loop-status` | `loop.ts` | List active/recent loops, show cumulative usage for detailed status, or restart loops with `restart`/`force` arguments | | `loop-cancel` | `loop.ts` | Cancel an active loop by worktree name | +| `loop-migrate` | `loop.ts` | Move a worktree-backed, non-group loop to a configured remote server, carrying phase, section rows, and findings as a resume snapshot; the local loop is marked `migrated` (force-restartable) | ### ToolContext @@ -454,7 +457,7 @@ Cross-cutting helpers (~25 files) organized by concern: | Loop | `loop-helpers.ts`, `loop-format.ts`, `loop-session.ts` | Loop model/format/session helpers | | Sessions | `audit-session.ts`, `session-titles.ts` | Session naming | | TUI | `tui-client.ts`, `tui-plan-store.ts`, `tui-loop-store.ts`, `tui-execution-preferences.ts`, `tui-execution-context-cache.ts`, `tui-models.ts` | TUI RPC, storage, preferences, models | -| Remote | `remote-config.ts`, `tui-remote-launch.ts` | Remote server config resolution and remote loop launch (see also `createRemoteForgeClient` in `client/sdk-adapter.ts`) | +| Remote | `remote-config.ts`, `tui-remote-launch.ts` | Remote server config resolution and remote loop launch: `connectRemoteProject`, `prepareRemoteLoopLaunch`, `pushAndLaunchRemoteLoop`, and the async `pushForgeSyncRef`/`deleteForgeSyncRef` sync-ref owners shared by TUI launch, `loop-migrate`, and the forge adapter's teardown (see also `createRemoteForgeClient` in `client/sdk-adapter.ts`) | | Workspace | `worktree-cleanup.ts`, `workspace-listing.ts`, `workspace-status-registry.ts` | Worktree/workspace lifecycle | | Misc | `partial-match.ts`, `model-fallback.ts`, `busy-guard.ts`, `sandbox-ready.ts`, `format.ts` | Various helpers | diff --git a/docs/tools.md b/docs/tools.md index 9109b2694..5478fe103 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -190,16 +190,18 @@ Completed loops are history-only and cannot be restarted. See [Loop System](loop ### `loop-migrate` -Moves a loop to a configured remote opencode server (see [Configuration → Remotes](configuration.md#remotes)). The local loop is terminated with the terminal reason `migrated: `, which makes it permanently non-restartable locally; the loop then continues on the remote from the pushed loop branch tip. +Moves a loop to a configured remote opencode server (see [Configuration → Remotes](configuration.md#remotes)). The local loop is terminated with the terminal reason `migrated: ` and the loop continues on the remote from the pushed loop branch tip. The local worktree and branch stay in place; a migrated loop can still be restarted locally, but only with `loop-status restart=true force=true`, because a copy may be running on the remote. + +Eligibility: only worktree-backed loops that are not part of a feature group can be migrated. Project-directory loops (`execute-plan mode: new-session`) and feature-group loops are refused before any side effect, as are completed loops and loops already marked `migrated`. What is carried over: -- The loop's phase, section pointers (`currentSectionIndex`/`totalSections`/`finalAuditDone`), section plan rows, section summaries, and review findings travel as a resume snapshot in the remote workspace's `forgeLoop` extra. +- The loop's phase, section pointers (`currentSectionIndex`/`totalSections`/`finalAuditDone`), section plan rows, section summaries, and review findings travel as a resume snapshot in the remote workspace's `forgeLoop` extra. The snapshot is validated on the remote; an invalid or incompatible snapshot removes the remote workspace instead of starting a fresh loop, and the local loop can then be force-restarted. - The loop's original plan text is forwarded, so restartability display and legacy non-sectioned resume keep working on the remote. - The execution/auditor models and variants are forwarded unchanged. -- The remote loop name is reserved (the local name is kept when available), and the remote session's permission rules come from the configured `loop.permissions` without host-specific external directories. +- The remote loop name is reserved (the local name is kept when available). The remote session's portable permission rules are resolved from the local loop's workspace — configured `loop.permissions` merged with any portable rules the loop itself arrived with — without host-specific external directories. -Failure semantics: every pre-freeze failure (unknown remote, no matching project, unreachable server) leaves the local loop untouched. A failure after the freeze — snapshot, branch-tip resolve, push, or remote launch — relabels the local loop as plain `cancelled` and rolls back the sync-ref push (best effort), so the loop stays restartable locally with `loop-status restart=true`. Only the success path leaves the loop non-restartable as `migrated`. +Failure semantics: every pre-freeze failure (unknown remote, no matching project, unreachable server, ineligible loop) leaves the local loop untouched. The freeze re-checks the loop under its state lock and refuses if the loop changed state meanwhile. After the freeze, the loop's worktree must be clean (the teardown commit must have landed); a dirty worktree, a failed snapshot, branch-tip resolve, push, or remote launch rolls back: the sync-ref push is deleted (best effort) and the local loop is restored to its previous terminal status (`cancelled`, `errored`, or `stalled`) or relabelled `cancelled` when it was running, so it stays restartable with `loop-status restart=true`. Only the success path leaves the loop marked `migrated`. Arguments: diff --git a/src/agents/architect-auto.ts b/src/agents/architect-auto.ts index f230067ab..5cb64067b 100644 --- a/src/agents/architect-auto.ts +++ b/src/agents/architect-auto.ts @@ -1,6 +1,14 @@ import type { AgentDefinition } from './types' import { loadPrompt } from '../prompts/loader' import { ARCHITECT_TOOL_EXCLUDES } from './architect' +import { PLAN_AUTHORING_TOOL_NAMES, SHARED_STRUCTURAL_DENY_PERMISSIONS } from '../constants/loop' + +export const ARCHITECT_AUTO_TOOL_EXCLUDES = [ + ...ARCHITECT_TOOL_EXCLUDES, + ...SHARED_STRUCTURAL_DENY_PERMISSIONS.filter( + (name) => !(ARCHITECT_TOOL_EXCLUDES as string[]).includes(name) && !(PLAN_AUTHORING_TOOL_NAMES as readonly string[]).includes(name), + ), +] export function buildArchitectAutoAgent(promptsDir?: string): AgentDefinition { return { @@ -10,18 +18,7 @@ export function buildArchitectAutoAgent(promptsDir?: string): AgentDefinition { mode: 'primary', hidden: true, tools: { - exclude: [ - ...ARCHITECT_TOOL_EXCLUDES, - 'question', - 'execute-plan', - 'execute-goal', - 'launch-group', - 'group-status', - 'group-cancel', - 'loop-status', - 'loop-cancel', - 'loop-migrate', - ], + exclude: ARCHITECT_AUTO_TOOL_EXCLUDES, }, systemPrompt: loadPrompt(['agents', 'architect-auto.md'], promptsDir), } diff --git a/src/constants/loop.ts b/src/constants/loop.ts index 127225bd1..ec7123f5e 100644 --- a/src/constants/loop.ts +++ b/src/constants/loop.ts @@ -269,6 +269,20 @@ export function buildLoopPermissionRuleset(options: LoopPermissionRulesetOptions return rules } +/** + * Selects the permission ruleset for a session from the agent that will run it: + * the audit ruleset for `auditor-loop`, the loop ruleset for every other agent. + * Single derivation so restart, TUI launch, and audit sessions cannot diverge. + */ +export function buildSessionPermissionRulesetForAgent( + agent: 'code' | 'auditor-loop', + options: LoopPermissionRulesetOptions = {}, +): PermissionRule[] { + return agent === 'auditor-loop' + ? buildAuditSessionPermissionRuleset(options) + : buildLoopPermissionRuleset(options) +} + /** * Builds the permission ruleset for audit sessions. * diff --git a/src/dashboard/app-bundle.ts b/src/dashboard/app-bundle.ts index fcc05da37..289be2df1 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 F={context:void 0,registry:void 0,effects:void 0,done:!1,getContextId(){return pn(this.context.count)},getNextContextId(){return pn(this.context.count++)}};function pn(e){let t=String(e),n=t.length-1;return F.context.id+(n?String.fromCharCode(96+n):\"\")+t}function wt(e){F.context=e}function $o(){return{...F.context,id:F.getNextContextId(),count:0}}var xo=!0,So=(e,t)=>e===t,oe=Symbol(\"solid-proxy\"),ko=typeof Proxy===\"function\",xt=Symbol(\"solid-track\"),yn=Symbol(\"solid-dev-component\"),et={equals:So},gn=null,wn=Tn,te=1,Oe=2,Lo={};var D=null,x=null,_e=null,Se=null,R=null,H=null,K=null,nt=0,re={afterUpdate:null,afterCreateOwner:null,afterCreateSignal:null,afterRegisterGraph:null};function $n(e,t){let n=R,s=D,o=e.length===0,r=t===void 0?s:t,a=o?{owned:null,cleanups:null,context:null,owner:null}:{owned:null,cleanups:null,context:r?r.context:null,owner:r},i=o?()=>e(()=>{throw Error(\"Dispose method must be an explicit argument to createRoot function\")}):()=>e(()=>z(()=>ge(a)));re.afterCreateOwner&&re.afterCreateOwner(a),D=a,R=null;try{return ae(i,!0)}finally{R=n,D=s}}function L(e,t){t=t?Object.assign({},et,t):et;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(kn(n),re.afterCreateSignal)re.afterCreateSignal(n)}let s=(o)=>{if(typeof o===\"function\")if(x&&x.running&&x.sources.has(n))o=o(n.tValue);else o=o(n.value);return kt(n,o)};return[Ln.bind(n),s]}function ce(e,t,n){let s=rt(e,t,!1,te,n);if(_e&&x&&x.running)H.push(s);else ke(s)}function G(e,t,n){wn=Co;let s=rt(e,t,!1,te,n),o=$t&&Ao($t);if(o)s.suspense=o;if(!n||!n.render)s.user=!0;K?K.push(s):ke(s)}function b(e,t,n){n=n?Object.assign({},et,n):et;let s=rt(e,t,!0,0,n);if(s.observers=null,s.observerSlots=null,s.comparator=n.equals||void 0,_e&&x&&x.running)s.tState=te,H.push(s);else ke(s);return Ln.bind(s)}function Ue(e){return ae(e,!1)}function z(e){if(!Se&&R===null)return e();let t=R;R=null;try{if(Se)return Se.untrack(e);return e()}finally{R=t}}function xn(e,t,n){let s=Array.isArray(e),o,r=n&&n.defer;return(a)=>{let i;if(s){i=Array(e.length);for(let u=0;ut(i,o,a));return o=i,l}}function Sn(e){G(()=>z(e))}function fe(e){if(D===null)console.warn(\"cleanups created outside a `createRoot` or `render` will never be run\");else if(D.cleanups===null)D.cleanups=[e];else D.cleanups.push(e);return e}function st(){return R}function ot(){return D}function St(e,t){let n=D,s=R;D=e,R=null;try{return ae(t,!0)}catch(o){at(o)}finally{D=n,R=s}}function To(e){if(x&&x.running)return e(),x.done;let t=R,n=D;return Promise.resolve().then(()=>{R=t,D=n;let s;if(_e||$t)s=x||(x={sources:new Set,effects:[],promises:new Set,disposed:new Set,queue:new Set,running:!0}),s.done||(s.done=new Promise((o)=>s.resolve=o)),s.running=!0;return ae(e,!1),R=D=null,s?s.done:void 0})}var[ca,mn]=L(!1);function hn(e,t){let n=rt(()=>z(()=>(Object.assign(e,{[yn]:!0}),e(t))),void 0,!0,0);return n.props=t,n.observers=null,n.observerSlots=null,n.name=e.name,n.component=e,ke(n),n.tValue!==void 0?n.tValue:n.value}function kn(e){if(D){if(D.sourceMap)D.sourceMap.push(e);else D.sourceMap=[e];e.graph=D}if(re.afterRegisterGraph)re.afterRegisterGraph(e)}function Ao(e){let t;return D&&D.context&&(t=D.context[e.id])!==void 0?t:e.defaultValue}var $t;function Ln(){let e=x&&x.running;if(this.sources&&(e?this.tState:this.state))if((e?this.tState:this.state)===te)ke(this);else{let t=H;H=null,ae(()=>tt(this),!1),H=t}if(R){let t=this.observers?this.observers.length:0;if(!R.sources)R.sources=[this],R.sourceSlots=[t];else R.sources.push(this),R.sourceSlots.push(t);if(!this.observers)this.observers=[R],this.observerSlots=[R.sources.length-1];else this.observers.push(R),this.observerSlots.push(R.sources.length-1)}if(e&&x.sources.has(this))return this.tValue;return this.value}function kt(e,t,n){let s=x&&x.running&&x.sources.has(e)?e.tValue:e.value;if(!e.comparator||!e.comparator(s,t)){if(x){let o=x.running;if(o||!n&&x.sources.has(e))x.sources.add(e),e.tValue=t;if(!o)e.value=t}else e.value=t;if(e.observers&&e.observers.length)ae(()=>{for(let o=0;o1e6){if(H=[],xo)throw Error(\"Potential Infinite Loop Detected.\");throw Error()}},!1)}return t}function ke(e){if(!e.fn)return;ge(e);let t=nt;if(bn(e,x&&x.running&&x.sources.has(e)?e.tValue:e.value,t),x&&!x.running&&x.sources.has(e))queueMicrotask(()=>{ae(()=>{x&&(x.running=!0),R=D=e,bn(e,e.tValue,t),R=D=null},!1)})}function bn(e,t,n){let s,o=D,r=R;R=D=e;try{s=e.fn(t)}catch(a){if(e.pure)if(x&&x.running)e.tState=te,e.tOwned&&e.tOwned.forEach(ge),e.tOwned=void 0;else e.state=te,e.owned&&e.owned.forEach(ge),e.owned=null;return e.updatedAt=n+1,at(a)}finally{R=r,D=o}if(!e.updatedAt||e.updatedAt<=n){if(e.updatedAt!=null&&\"observers\"in e)kt(e,s,!0);else if(x&&x.running&&e.pure){if(!x.sources.has(e))e.value=s;x.sources.add(e),e.tValue=s}else e.value=s;e.updatedAt=n}}function rt(e,t,n,s=te,o){let r={fn:e,state:s,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:D,context:D?D.context:null,pure:n};if(x&&x.running)r.state=0,r.tState=s;if(D===null)console.warn(\"computations created outside a `createRoot` or `render` will never be disposed\");else if(D!==Lo)if(x&&x.running&&D.pure)if(!D.tOwned)D.tOwned=[r];else D.tOwned.push(r);else if(!D.owned)D.owned=[r];else D.owned.push(r);if(o&&o.name)r.name=o.name;if(Se&&r.fn){let a=r.fn,[i,l]=L(void 0,{equals:!1}),u=Se.factory(a,l);fe(()=>u.dispose());let f,h=()=>To(l).then(()=>{if(f)f.dispose(),f=void 0});r.fn=(y)=>{if(i(),x&&x.running){if(!f)f=Se.factory(a,h);return f.track(y)}return u.track(y)}}return re.afterCreateOwner&&re.afterCreateOwner(r),r}function je(e){let t=x&&x.running;if((t?e.tState:e.state)===0)return;if((t?e.tState:e.state)===Oe)return tt(e);if(e.suspense&&z(e.suspense.inFallback))return e.suspense.effects.push(e);let n=[e];while((e=e.owner)&&(!e.updatedAt||e.updatedAt=0;s--){if(e=n[s],t){let o=e,r=n[s+1];while((o=o.owner)&&o!==r)if(x.disposed.has(o))return}if((t?e.tState:e.state)===te)ke(e);else if((t?e.tState:e.state)===Oe){let o=H;H=null,ae(()=>tt(e,n[0]),!1),H=o}}}function ae(e,t){if(H)return e();let n=!1;if(!t)H=[];if(K)n=!0;else K=[];nt++;try{let s=e();return Mo(n),s}catch(s){if(!n)K=null;H=null,at(s)}}function Mo(e){if(H){if(_e&&x&&x.running)Eo(H);else Tn(H);H=null}if(e)return;let t;if(x){if(!x.promises.size&&!x.queue.size){let{sources:s,disposed:o}=x;K.push.apply(K,x.effects),t=x.resolve;for(let r of K)\"tState\"in r&&(r.state=r.tState),delete r.tState;x=null,ae(()=>{for(let r of o)ge(r);for(let r of s){if(r.value=r.tValue,r.owned)for(let a=0,i=r.owned.length;awn(n),!1);else re.afterUpdate&&re.afterUpdate();if(t)t()}function Tn(e){for(let t=0;t{s.delete(n),ae(()=>{x.running=!0,je(n)},!1),x&&(x.running=!1)})}}function Co(e){let t,n=0;for(t=0;t=0;t--)ge(e.tOwned[t]);delete e.tOwned}if(x&&x.running&&e.pure)Mn(e,!0);else if(e.owned){for(t=e.owned.length-1;t>=0;t--)ge(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(x&&x.running)e.tState=0;else e.state=0;delete e.sourceMap}function Mn(e,t){if(!t)e.tState=0,x.disposed.add(e);if(e.owned)for(let n=0;n=0;i--){let l=yt(e[i])[a];if(l!==void 0)return l}},has(a){for(let i=e.length-1;i>=0;i--)if(a in yt(e[i]))return!0;return!1},keys(){let a=[];for(let i=0;i=0;a--){let i=e[a];if(!i)continue;let l=Object.getOwnPropertyNames(i);for(let u=l.length-1;u>=0;u--){let f=l[u];if(f===\"__proto__\"||f===\"constructor\")continue;let h=Object.getOwnPropertyDescriptor(i,f);if(!s[f])s[f]=h.get?{enumerable:!0,configurable:!0,get:Io.bind(n[f]=[h.get.bind(i)])}:h.value!==void 0?h:void 0;else{let y=n[f];if(y){if(h.get)y.push(h.get.bind(i));else if(h.value!==void 0)y.push(()=>h.value)}}}}let o={},r=Object.keys(s);for(let a=r.length-1;a>=0;a--){let i=r[a],l=s[i];if(l&&l.get)Object.defineProperty(o,i,l);else o[i]=l?l.value:void 0}return o}var En={hooks:re,writeSignal:kt,registerGraph:kn};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 Ro=[\"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\"],Mt=new Set([\"className\",\"value\",\"readOnly\",\"noValidate\",\"formNoValidate\",\"isMap\",\"noModule\",\"playsInline\",\"adAuctionHeaders\",\"allowFullscreen\",\"browsingTopics\",\"defaultChecked\",\"defaultMuted\",\"defaultSelected\",\"disablePictureInPicture\",\"disableRemotePlayback\",\"preservesPitch\",\"shadowRootClonable\",\"shadowRootCustomElementRegistry\",\"shadowRootDelegatesFocus\",\"shadowRootSerializable\",\"sharedStorageWritable\",...Ro]),Et=new Set([\"innerHTML\",\"textContent\",\"innerText\",\"children\"]),Ct=Object.assign(Object.create(null),{className:\"class\",htmlFor:\"for\"}),Oo=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 Nt(e,t){let n=Oo[e];return typeof n===\"object\"?n[t]?n.$:void 0:n}var Dt=new Set([\"beforeinput\",\"click\",\"dblclick\",\"contextmenu\",\"focusin\",\"focusout\",\"input\",\"keydown\",\"keyup\",\"mousedown\",\"mousemove\",\"mouseout\",\"mouseover\",\"mouseup\",\"pointerdown\",\"pointermove\",\"pointerout\",\"pointerover\",\"pointerup\",\"touchend\",\"touchmove\",\"touchstart\"]),In=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\"]),Pt={xlink:\"http://www.w3.org/1999/xlink\",xml:\"http://www.w3.org/XML/1998/namespace\"};function jo(e,t,n){let s=n.length,o=t.length,r=s,a=0,i=0,l=t[o-1].nextSibling,u=null;while(af-i){let c=t[a];while(i{o=r,t===document?e():Ut(t,e(),t.firstChild?null:void 0,n)},s.owner),()=>{o(),t.textContent=\"\"}}function It(e,t=window.document){let n=t[Cn]||(t[Cn]=new Set);for(let s=0,o=e.length;so.call(e,n[1],r))}else e.addEventListener(t,n,typeof n!==\"function\"&&n)}function jt(e,t,n={}){let s=Object.keys(t||{}),o=Object.keys(n),r,a;for(r=0,a=o.length;ro.children=Te(e,t.children,o.children));return ce(()=>typeof t.ref===\"function\"&&Fo(t.ref,e)),ce(()=>Bo(e,t,n,!0,o,!0)),o}function jn(e,t){let n=e[t];return Object.defineProperty(e,t,{get(){return n()},enumerable:!0}),e}function Fo(e,t,n){return z(()=>e(t,n))}function Ut(e,t,n,s){if(n!==void 0&&!s)s=[];if(typeof t!==\"function\")return Te(e,t,s,n);ce((o)=>Te(e,t(),o,n),s)}function Bo(e,t,n,s,o={},r=!1){t||(t={});for(let a in o)if(!(a in t)){if(a===\"children\")continue;o[a]=Dn(e,a,null,o[a],n,r,t)}for(let a in t){if(a===\"children\"){if(!s)Te(e,t.children);continue}let i=t[a];o[a]=Dn(e,a,i,o[a],n,r,t)}}function Ae(e){return!!F.context&&!F.done&&(!e||e.isConnected)}function Vo(e){return e.toLowerCase().replace(/-([a-z])/g,(t,n)=>n.toUpperCase())}function Nn(e,t,n){let s=t.trim().split(/\\s+/);for(let o=0,r=s.length;o-1&&Pt[t.split(\":\")[0]];if(y)Rt(e,y,t,n);else Fe(e,Ct[t]||t,n)}return n}function Ho(e){if(F.registry&&F.events){if(F.events.find(([l,u])=>u===e))return}let t=e.target,n=`$$${e.type}`,s=e.target,o=e.currentTarget,r=(l)=>Object.defineProperty(e,\"target\",{configurable:!0,value:l}),a=()=>{let l=t[n];if(l&&!t.disabled){let u=t[`${n}Data`];if(u!==void 0?l.call(t,u,e):l.call(t,e),e.cancelBubble)return}return t.host&&typeof t.host!==\"string\"&&!t.host._$host&&t.contains(e.target)&&r(t.host),!0},i=()=>{while(a()&&(t=t._$host||t.parentNode||t.host));};if(Object.defineProperty(e,\"currentTarget\",{configurable:!0,get(){return t||document}}),F.registry&&!F.done)F.done=_$HY.done=!0;if(e.composedPath){let l=e.composedPath();r(l[0]);for(let u=0;u{let l=t();while(typeof l===\"function\")l=l();n=Te(e,l,n,s)}),()=>n;else if(Array.isArray(t)){let l=[],u=n&&Array.isArray(n);if(At(l,t,n,o))return ce(()=>n=Te(e,l,n,s,!0)),()=>n;if(r){if(!l.length)return n;if(s===void 0)return n=[...e.childNodes];let f=l[0];if(f.parentNode!==e)return n;let h=[f];while((f=f.nextSibling)!==s)h.push(f);return n=h}if(l.length===0){if(n=Le(e,n,s),i)return n}else if(u)if(n.length===0)Pn(e,l,s);else jo(e,n,l);else n&&Le(e),Pn(e,l);n=l}else if(t.nodeType){if(r&&t.parentNode)return n=i?[t]:t;if(Array.isArray(n)){if(i)return n=Le(e,n,s,t);Le(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 At(e,t,n,s){let o=!1;for(let r=0,a=t.length;r=0;a--){let i=t[a];if(o!==i){let l=i.parentNode===e;if(!r&&!a)l?e.replaceChild(o,i):e.insertBefore(o,n);else l&&i.remove()}else r=!0}}else e.insertBefore(o,n);return[o]}var Sa=Symbol();var Ft=Symbol(\"store-raw\"),Ee=Symbol(\"store-node\"),pe=Symbol(\"store-has\"),Un=Symbol(\"store-self\"),_n={onStoreNodeUpdate:null};function Fn(e){let t=e[oe];if(!t){if(Object.defineProperty(e,oe,{value:t=new Proxy(e,Wo)}),!Array.isArray(e)){let n=Object.keys(e),s=Object.getOwnPropertyDescriptors(e);for(let o=0,r=n.length;oe[oe][t],n}function Bn(e){st()&&Ve(it(e,Ee),Un)()}function Go(e){return Bn(e),Reflect.ownKeys(e)}var Wo={get(e,t,n){if(t===Ft)return e;if(t===oe)return n;if(t===xt)return Bn(e),n;let s=it(e,Ee),o=s[t],r=o?o():e[t];if(t===Ee||t===pe||t===\"__proto__\")return r;if(!o){let a=Object.getOwnPropertyDescriptor(e,t);if(st()&&(typeof r!==\"function\"||e.hasOwnProperty(t))&&!(a&&a.get))r=Ve(s,t,r)()}return me(r)?Fn(r):r},has(e,t){if(t===Ft||t===oe||t===xt||t===Ee||t===pe||t===\"__proto__\")return!0;return st()&&Ve(it(e,pe),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:Go,getOwnPropertyDescriptor:qo};function ne(e,t,n,s=!1){if(!s&&e[t]===n)return;let o=e[t],r=e.length;if(_n.onStoreNodeUpdate&&_n.onStoreNodeUpdate(e,t,n,o),n===void 0){if(delete e[t],e[pe]&&e[pe][t]&&o!==void 0)e[pe][t].$()}else if(e[t]=n,e[pe]&&e[pe][t]&&o===void 0)e[pe][t].$();let a=it(e,Ee),i;if(i=Ve(a,t,o))i.$(()=>n);if(Array.isArray(e)&&e.length!==r){for(let l=e.length;l1){s=t.shift();let a=typeof s,i=Array.isArray(e);if(Array.isArray(s)){for(let l=0;l1){Be(e[s],t,[s].concat(n));return}o=e[s],n=[s].concat(n)}let r=t[0];if(typeof r===\"function\"){if(r=r(o,n),r===o)return}if(s===void 0&&r==null)return;if(r=Ce(r),s===void 0||me(o)&&me(r)&&!Array.isArray(r))Vn(o,r);else ne(e,s,r)}function Hn(...[e,t]){let n=Ce(e||{}),s=Array.isArray(n);if(typeof n!==\"object\"&&typeof n!==\"function\")throw Error(`Unexpected type ${typeof n} received when initializing 'createStore'. Expected an object.`);let o=Fn(n);En.registerGraph({value:n,name:t&&t.name});function r(...a){Ue(()=>{s&&a.length===1?Ko(n,a[0]):Be(n,a)})}return[o,r]}var Bt=Symbol(\"store-root\");function Me(e,t,n,s,o){let r=t[n];if(e===r)return;let a=Array.isArray(e);if(n!==Bt&&(!me(e)||!me(r)||a!==Array.isArray(r)||o&&e[o]!==r[o])){ne(t,n,e);return}if(a){if(e.length&&r.length&&(!s||o&&e[0]&&e[0][o]!=null)){let u,f,h,y,p,c,g,v;for(h=0,y=Math.min(r.length,e.length);h=h&&p>=h&&(r[y]===e[p]||o&&r[y]&&e[p]&&r[y][o]&&r[y][o]===e[p][o]);y--,p--)S[p]=r[y];if(h>p||h>y){for(f=h;f<=p;f++)ne(r,f,e[f]);for(;fe.length)ne(r,\"length\",e.length);return}g=Array(p+1);for(f=p;f>=h;f--)c=e[f],v=o&&c?c[o]:c,u=M.get(v),g[f]=u===void 0?-1:u,M.set(v,f);for(u=h;u<=y;u++)if(c=r[u],v=o&&c?c[o]:c,f=M.get(v),f!==void 0&&f!==-1)S[f]=r[u],f=g[f],M.set(v,f);for(f=h;fe.length)ne(r,\"length\",e.length);return}let i=Object.keys(e);for(let u=0,f=i.length;u{if(!me(r)||!me(o))return o;let a=Me(o,{[Bt]:r},Bt,n,s);return a===void 0?r:a}}var zo=/(?:|<(?:\"[^\"]*\"['\"]*|'[^']*'['\"]*|[^'\">])+>)/g,Yo=/(?:\\s(?[^/\\s><=]+?)(?=[\\s/>]))|(?:(?\\S+?)(?:\\s*=\\s*(?:(['\"])(?[\\s\\S]*?)\\3|(?[^\\s>]+))))/g,Xo={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 Qo(e){let t={type:\"tag\",name:\"\",voidElement:!1,attrs:[],children:[]},n=e.match(/<\\/?([^\\s]+?)[/\\s>]/);if(n){if(t.name=n[1],Xo[n[1].toLowerCase()]||e.charAt(e.length-2)===\"/\")t.voidElement=!0;if(t.name.startsWith(\"!--\")){let o=e.indexOf(\"-->\");return{type:\"comment\",comment:o!==-1?e.slice(4,o):\"\"}}}let s=new RegExp(Yo);for(let o of e.matchAll(s))if((o[1]||o[2]).startsWith(\"use:\"))t.attrs.push({type:\"directive\",name:o[1]||o[2],value:o[4]||o[5]||\"\"});else t.attrs.push({type:\"attr\",name:o[1]||o[2],value:o[4]||o[5]||\"\"});return t}function Gn(e,t,n){let s=t.indexOf(\"<\",n),o=t.slice(n,s===-1?void 0:s);if(!/^\\s*$/.test(o))e.push({type:\"text\",content:o})}function Wn(e,t){let n=t.replace(\"\",\"\");if(!/^\\s*$/.test(n))e.push({type:\"comment\",content:n})}function Jo(e){let t=[],n=void 0,s=-1,o=[],r={};return e.replace(zo,(a,i)=>{let l=a.charAt(1)!==\"/\",u=a.slice(0,4)===\"\"}}function er(e){return e.reduce(function(t,n){return t+Yn(\"\",n)},\"\")}var Kn=new Map,tr=/^(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$/i,lt=\" \\\\f\\\\n\\\\r\\\\t\",Xn=\"[^\"+lt+`\\\\/>\"'=]+`,Ht=\"[ \"+lt+\"]+(?:use:|\"+Xn+\")\",Qn=\"<([A-Za-z$#]+[A-Za-z0-9:_-]*)((?:\",Jn=`(?:\\\\s*=\\\\s*(?:'[^']*?'|\"[^\"]*?\"|\\\\([^)]*?\\\\)|<[^>]*?>|`+Xn+\"))?)\",nr=new RegExp(Qn+Ht+Jn+\"+)([ \"+lt+\"]*/?>)\",\"g\"),sr=new RegExp(\"(\"+Ht+`\\\\s*=\\\\s*)(|['\"(]([\\\\w\\\\s]*[\\\\w\\\\s]*)*['\")])`,\"gi\"),or=new RegExp(Qn+Ht+Jn+\"*)([ \"+lt+\"]*/>)\",\"g\"),zn=\"\",rr=new Set([\"class\",\"on\",\"oncapture\",\"style\",\"use\",\"prop\",\"attr\"]);function ar(e,t,n,s){return\"<\"+t+n.replace(sr,ir)+s}function ir(e,t,n){return t.replace(//g,\"###\")+(n[0]==='\"'||n[0]===\"'\"?n.replace(//g,\"###\"):'\"###\"')}function lr(e,t,n){return tr.test(t)?e:\"<\"+t+n+\">\"}function cr(e){return e.toLowerCase().replace(/-([a-z])/g,(t,n)=>n.toUpperCase())}function Vt(e,t,n,s){if(e===\"use:###\"&&t===\"###\"){let o=s.counter++;s.exprs.push(`typeof exprs[${o}] === \"function\" ? r.use(exprs[${o}], ${n}, exprs[${s.counter++}]) : (()=>{throw new Error(\"use:### must be a function\")})()`)}else throw Error(`Not support syntax ${e} must be use:{function}`)}function ur(e,{delegateEvents:t=!0,functionBuilder:n=(...s)=>Function(...s)}={}){let s=1;e.wrapProps=(p)=>{let c=Object.getOwnPropertyDescriptors(p);for(let g in c)if(typeof c[g].value===\"function\"&&!c[g].value.length)e.dynamicProperty(p,g);return p};function o(p,c){let g=0,v=\"\";for(;g\";v=v+p[g],v=[[or,lr],[/<()/g,\"<###\"],[/\\.\\.\\.()/g,\"###\"],[nr,ar],[/>\\n+\\s*/g,\">\"],[/\\n+\\s*\\s+/g,\"> \"]].reduce((P,I)=>P.replace(I[0],I[1]),v);let M=Jo(v),[T,k]=h(M,c.funcBuilder),A=[];for(let P=0;P-1){let J=O.split(zn).reduce((Y,C,j)=>(j&&Y.push(\"\"),Y.push(C),Y),[]);I[P].firstChild.replaceWith(...J)}}}return A[0].create=k,Kn.set(p,A),A}function r(p,c,g,v,S,M,T){let k=v===\"###\"?`!doNotWrap ? exprs[${T.counter}]() : exprs[${T.counter++}]`:v.split(\"###\").map((O,J)=>J?` + (typeof exprs[${T.counter}] === \"function\" ? exprs[${T.counter}]() : exprs[${T.counter++}]) + \"${O}\"`:`\"${O}\"`).join(\"\"),A,P;if((A=g.split(\":\"))&&A[1]&&rr.has(A[0]))g=A[1],P=A[0];let I=e.ChildProperties.has(g),V=e.Properties.has(g);if(g===\"style\"){let O=`_$v${s++}`;T.decl.push(`${O}={}`),T.exprs.push(`r.style(${c},${k},${O})`)}else if(g===\"classList\"){let O=`_$v${s++}`;T.decl.push(`${O}={}`),T.exprs.push(`r.classList(${c},${k},${O})`)}else if(P!==\"attr\"&&(I||!S&&(e.getPropAlias(g,p.name.toUpperCase())||V)||M||P===\"prop\")){if(M&&!I&&!V&&P!==\"prop\")g=cr(g);T.exprs.push(`${c}.${e.getPropAlias(g,p.name.toUpperCase())||g} = ${k}`)}else{let O=S&&g.indexOf(\":\")>-1&&e.SVGNamespace[g.split(\":\")[0]];if(O)T.exprs.push(`r.setAttributeNS(${c},\"${O}\",\"${g}\",${k})`);else T.exprs.push(`r.setAttribute(${c},\"${e.Aliases[g]||g}\",${k})`)}}function a(p,c,g,v,S,M,T){if(g.slice(0,2)===\"on\")if(!g.includes(\":\")){let k=g.slice(2).toLowerCase(),A=t&&e.DelegatedEvents.has(k);T.exprs.push(`r.addEventListener(${c},\"${k}\",exprs[${T.counter++}],${A})`),A&&T.delegatedEvents.add(k)}else{let k=g.startsWith(\"oncapture:\");T.exprs.push(`${c}.addEventListener(\"${g.slice(k?10:3)}\",exprs[${T.counter++}]${k?\",true\":\"\"})`)}else if(g===\"ref\")T.exprs.push(`exprs[${T.counter++}](${c})`);else{let k=Object.assign({},T,{exprs:[]}),A=T.counter;if(r(p,c,g,v,S,M,k),T.decl.push(`_fn${A} = (${v===\"###\"?\"doNotWrap\":\"\"}) => {\n${k.exprs.join(`;\n`)};\n}`),v===\"###\")T.exprs.push(`typeof exprs[${A}] === \"function\" ? r.effect(_fn${A}) : _fn${A}(true)`);else{let P=\"\";for(let I=A;I1)for(let S=0;S1?`r.mergeProps(${c.join(\",\")})`:c[0]}function u(p,c){let g=[],v=Object.keys(p.attrs),S=[g],M=c.counter++;for(let k=0;k exprs[${c.counter++}]`);else if(p.children.length){let k={type:\"fragment\",children:p.children},A=Object.assign({},c,{first:!0,decl:[],exprs:[],parent:!1});f(k,A),g.push(`children: () => { ${A.exprs.join(`;\n`)}}`),c.templateId=A.templateId,c.counter=A.counter}let T;if(c.multi)T=`_$el${s++}`,c.decl.push(`${T} = ${c.path}.${c.first?\"firstChild\":\"nextSibling\"}`);if(c.parent)c.exprs.push(`r.insert(${c.parent}, r.createComponent(exprs[${M}],${l(S)})${T?`, ${T}`:\"\"})`);else c.exprs.push(`${c.fragment?\"\":\"return \"}r.createComponent(exprs[${M}],${l(S)})`);c.path=T,c.first=!1}function f(p,c){if(p.type===\"fragment\"){let g=[];p.children.forEach((v)=>{if(v.type===\"tag\"){if(v.name===\"###\"){let T=Object.assign({},c,{first:!0,fragment:!0,decl:[],exprs:[]});u(v,T),g.push(T.exprs[0]),c.counter=T.counter,c.templateId=T.templateId;return}c.templateId++;let S=s,M=Object.assign({},c,{first:!0,decl:[],exprs:[]});c.templateNodes.push([v]),f(v,M),g.push(`function() { ${M.decl.join(`,\n`)+`;\n`+M.exprs.join(`;\n`)+`;\nreturn _$el${S};\n`}}()`),c.counter=M.counter,c.templateId=M.templateId}else if(v.type===\"text\")g.push(`\"${v.content}\"`);else if(v.type===\"comment\"){if(v.content===\"#\")g.push(`exprs[${c.counter++}]`);else if(v.content)for(let S=0;Sk.name===\"is\");if(c.hasCustomElement=T,c.isImportNode=(p.name===\"img\"||p.name===\"iframe\")&&p.attrs.some((k)=>k.name===\"loading\"&&k.value===\"lazy\"),p.attrs.some((k)=>k.name===\"###\")){let k=[],A=\"\",P=[];for(let I=0;I({${A}})`),A=\"\";k.push(`exprs[${c.counter++}]`)}else P.push(p.attrs[I]);else if(V===\"directive\")Vt(O,J,g,c)}if(p.attrs=P,A.length)k.push(`()=>({${A}})`);c.exprs.push(`r.spread(${g},${k.length===1?`typeof ${k[0]} === \"function\" ? r.mergeProps(${k[0]}) : ${k[0]}`:`r.mergeProps(${k.join(\",\")})`},${M},${!!p.children.length})`)}else for(let k=0;k document.importNode(tmpls[${S}].content.firstChild, true))`:`const ${g} = tmpls[${S}].content.firstChild.cloneNode(true)`}else if(p.type===\"text\"){let g=`_$el${s++}`;c.decl.push(`${g} = ${c.path}.${c.first?\"firstChild\":\"nextSibling\"}`),c.path=g,c.first=!1}else if(p.type===\"comment\"){let g=`_$el${s++}`;if(c.decl.push(`${g} = ${c.path}.${c.first?\"firstChild\":\"nextSibling\"}`),p.content===\"#\")if(c.multi)c.exprs.push(`r.insert(${c.parent}, exprs[${c.counter++}], ${g})`);else c.exprs.push(`r.insert(${c.parent}, exprs[${c.counter++}])`);c.path=g,c.first=!1}}function h(p,c){let g={path:\"\",decl:[],exprs:[],delegatedEvents:new Set,counter:0,first:!0,multi:!1,templateId:0,templateNodes:[]},v=s,S=p,M;if(p.length>1)p=[{type:\"fragment\",children:p}];if(p[0].name===\"###\")M=!0,u(p[0],g);else f(p[0],g);return e.delegateEvents(Array.from(g.delegatedEvents)),[[S].concat(g.templateNodes).map((k)=>er(k)),c(\"tmpls\",\"exprs\",\"r\",g.decl.join(`,\n`)+`;\n`+g.exprs.join(`;\n`)+(M?\"\":`;\nreturn _$el${v};\n`))]}function y(p,...c){let g=Kn.get(p)||o(p,{funcBuilder:n});return g[0].create(g,c,e)}return y}var d=ur({effect:ce,style:_t,insert:Ut,untrack:z,spread:On,createComponent:Lt,delegateEvents:It,classList:jt,mergeProps:Tt,dynamicProperty:jn,setAttribute:Fe,setAttributeNS:Rt,addEventListener:Ot,Aliases:Ct,getPropAlias:Nt,Properties:Mt,ChildProperties:Et,DelegatedEvents:Dt,SVGElements:In,SVGNamespace:Pt});function He(e){let t=Math.floor(e/3600),n=Math.floor(e%3600/60),s=e%60;if(t>0)return`${t}h ${n}m`;return n>0?`${n}m ${s}s`:`${s}s`}function ct(e,t){if(!e)return 0;let n=new Date(e).getTime(),s=t?new Date(t).getTime():Date.now();return Math.round((s-n)/1000)}function Zn(e){return e.toLowerCase().replace(/[^\\w\\s-]/g,\"\").trim().replace(/\\s+/g,\"-\").replace(/-+/g,\"-\")}var es=[\"code\",\"auditor\",\"unknown\"];var Gt=[\"overview\",\"live\",\"timeline\",\"sections\",\"findings\",\"plan\",\"usage\"],Wt=200,ns=300,ss=100;function ut(e,t,n){if(n||e.length<=t)return{rows:e,total:e.length,capped:!1};return{rows:e.slice(0,t),total:e.length,capped:!0}}function Kt(e,t){return e.length===t.length&&e.every((n,s)=>n===t[s])}var dr=new Set([\"loops\",\"groups\",\"findings\",\"plans\"]),fr=new Set(Gt);function ye(e){try{return decodeURIComponent(e)}catch{return e}}function pr(){return{projectId:null,section:\"loops\",loopName:null,tab:\"overview\",groupId:null,statuses:[],query:\"\"}}function qt(e,t){if(!t)return;for(let n of t.split(\"&\")){if(!n)continue;let s=n.indexOf(\"=\");if(s===-1)continue;let o=ye(n.slice(0,s)),r=ye(n.slice(s+1));if(o===\"status\")e.statuses=r.split(\",\").map((a)=>a.trim()).filter((a)=>a!==\"\");else if(o===\"q\")e.query=r}}function dt(e){let t=pr(),n=(e||\"\").replace(/^#/,\"\");if(!n)return t;let s=n.indexOf(\"?\"),o=s===-1?n:n.slice(0,s),r=s===-1?\"\":n.slice(s+1),a=o.indexOf(\"/\"),i,l;if(a===-1)i=o,l=\"\";else i=o.slice(0,a),l=o.slice(a+1);if(i)t.projectId=ye(i);if(!l)return qt(t,r),t;let u=l.split(\"/\").filter((h)=>h!==\"\");if(u.length===0)return qt(t,r),t;let f=ye(u[0]);if(f===\"loop\"){if(u.length>=2){if(t.loopName=ye(u[1]),u.length>=3){let h=ye(u[2]);if(fr.has(h))t.tab=h}}}else if(dr.has(f)){if(t.section=f,f===\"groups\"&&u.length>=2)t.groupId=ye(u[1])}else t.loopName=f;return qt(t,r),t}function ft(e){if(!e.projectId)return\"\";let t=\"#\"+encodeURIComponent(e.projectId);if(e.section===\"loops\"){if(e.loopName){if(t+=\"/loop/\"+encodeURIComponent(e.loopName),e.tab!==\"overview\")t+=\"/\"+e.tab}}else if(e.section===\"groups\"){if(t+=\"/groups\",e.groupId)t+=\"/\"+encodeURIComponent(e.groupId)}else t+=\"/\"+e.section;let n=[];if(e.statuses.length>0)n.push(\"status=\"+e.statuses.map((s)=>encodeURIComponent(s)).join(\",\"));if(e.query)n.push(\"q=\"+encodeURIComponent(e.query));if(n.length>0)t+=\"?\"+n.join(\"&\");return t}function os(e,t){let s=\"#\"+(location.hash||\"\").replace(/^#/,\"\"),o=\"#\"+e.replace(/^#/,\"\");if(s!==o)t.current=!0,location.hash=e}function Z(e){if(!e||e===0)return\"\";let t=new Date(e),n=(l)=>l<10?\"0\"+l:String(l),s=n(t.getMonth()+1),o=n(t.getDate()),r=t.getFullYear(),a=t.getHours(),i=a>=12?\"PM\":\"AM\";if(a=a%12,a===0)a=12;return s+\"-\"+o+\"-\"+r+\" \"+a+\":\"+n(t.getMinutes())+\" \"+i}function we(e){return\"status-badge status-\"+e}function zt(e){return\"section-status section-\"+e}function rs(e){return\"feature-stage feature-stage-\"+e}function as(e,t,n,s){if(!(t.size===0||t.has(e.status)))return!1;if(!n)return!0;let r=n.toLowerCase(),a=e.worktreeBranch??\"\";return((e.loopName||\"\")+\" \"+a+\" \"+(s||\"\")).toLowerCase().indexOf(r)!==-1}function is(e,t){if(t===\"recent\")return[...e];let n=[...e];if(t===\"cost\")n.sort((s,o)=>{let r=s.usage?.totalCost??null,a=o.usage?.totalCost??null;if(r===null&&a===null)return 0;if(r===null)return 1;if(a===null)return-1;return a-r});else if(t===\"duration\")n.sort((s,o)=>{let r=ct(s.loop.startedAt,s.loop.completedAt??void 0);return ct(o.loop.startedAt,o.loop.completedAt??void 0)-r});else if(t===\"findings\")n.sort((s,o)=>o.findings.length-s.findings.length);return n}function ls(e){return JSON.stringify(e.projects)}function Ne(e){return e.projectDir||e.projectId||\"\"}function De(e,t){let n=Ne(t);return e.get(n)??n}function Ge(e){return e.completedAt||e.startedAt||0}function cs(e){let t=new Map;for(let o of e){if(o===\"\"){t.set(o,[]);continue}let r=o.split(\"/\"),a=r.length;while(a>0&&r[a-1]===\"\")a--;t.set(o,a>0?r.slice(0,a):[o])}let n=new Map;for(let o of e){let r=t.get(o);n.set(o,r.length?r[r.length-1]:o)}let s=new Map;for(let o of e){let r=n.get(o),a=s.get(r);if(a)a.push(o);else s.set(r,[o])}for(let[,o]of s){let r=[...new Set(o)];if(r.length<2)continue;let a=1;while(a<1024){let i=new Set,l=!0,u=!0;for(let f of r){let h=t.get(f),y=Math.max(0,h.length-a),p=h.slice(y).join(\"/\");if(i.has(p))l=!1;else i.add(p);if(y>0)u=!1}if(l||u)break;a++}for(let i of r){let l=t.get(i),u=Math.max(0,l.length-a);n.set(i,l.slice(u).join(\"/\"))}}return n}function Yt(e){let t=[],n=[];for(let s of e)if(s.severity===\"bug\")t.push(s);else n.push(s);return{bugs:t,warnings:n}}function us(e){if(e.bugs.length>0)return\"bug\";if(e.warnings.length>0)return\"warn\";return\"clean\"}function Pe(e,t){return e+\" \"+t+(e===1?\"\":\"s\")}function se(e){return String(e+1)}function Xt(e,t){if(!t||t<=0)return 0;return Math.max(0,Math.min(100,e/t*100))}function ds(e){let t=e.file+\":\"+e.line+\" — \"+e.description;if(e.scenario)t+=\" (\"+e.scenario+\")\";return t}function fs(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 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 ps(e){let t=[{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)\"}],n=t.reduce((s,o)=>s+Math.max(0,o.value),0);return t.map((s)=>({...s,pct:n>0?Math.max(0,s.value)/n*100:0}))}var gr={code:\"execution\",auditor:\"auditor\",unknown:\"other\"};function gs(e){let n=es.map((o)=>{let r={role:gr[o],cost:0,messageCount:0,tokens:0,pct:0},a=e.byRole[o];if(!a)return r;return r.cost=a.cost,r.messageCount=a.messageCount,r.tokens=a.inputTokens+a.outputTokens+a.reasoningTokens,r}).filter((o)=>o.role!==\"other\"||o.cost>0||o.messageCount>0),s=n.reduce((o,r)=>Math.max(o,r.cost),0);for(let o of n)o.pct=s>0?o.cost/s*100:0;return n}function ms(e){let t=Object.keys(e.byModel).map((s)=>({model:s,...e.byModel[s]})),n=t.reduce((s,o)=>Math.max(s,o.cost),0);return t.sort((s,o)=>o.cost-s.cost).map((s)=>({model:s.model,cost:s.cost,inputTokens:s.inputTokens,outputTokens:s.outputTokens,messageCount:s.messageCount,pct:n>0?s.cost/n*100:0}))}function hs(e,t){if(!e)return\"\";let n=ct(e,t??void 0);return n>0?He(n):\"\"}function Qt(e){return e>=1000?He(Math.floor(e/1000)):e+\"ms\"}function he(e){if(!e||e===0)return\"\";let t=Date.now()-e,n=Math.floor(t/1000);if(n<60)return n+\"s ago\";let s=Math.floor(n/60);if(s<60)return s+\"m ago\";let o=Math.floor(s/60);if(o<24)return o+\"h ago\";let r=Math.floor(o/24);if(r<30)return r+\"d ago\";return Z(e)}var mr=/([\\s\\S]*?)<\\/h\\1>/g;function bs(e){return e.replace(/<[^>]+>/g,\"\").replace(/&[a-z#0-9]+;/gi,\"\").trim()}function hr(e){return Zn(bs(e)).replace(/[_-]+/g,\"-\").replace(/^-+|-+$/g,\"\")||\"heading\"}function br(e){if(!//.test(e))return{html:e,outline:[]};let t=[];return{html:e.replace(mr,(s,o,r)=>{let a=Number(o),i=hr(r);return t.push({depth:a,id:i,text:bs(r)}),`${r}`}),outline:t}}function vs(e){if(!e)return{html:\"\",outline:[]};let t=qe.get(e);if(t!==void 0)return t;let n=globalThis.marked;if(!n)return{html:\"\",outline:[]};let{html:s,outline:o}=br(n.parse(e)),r={html:s,outline:o};if(qe.size>=vr){let a=qe.keys().next().value;if(a!==void 0)qe.delete(a)}return qe.set(e,r),r}function ys(e){return vs(e).html}function ws(e){return vs(e)}var qe=new Map,vr=200;function pt(e){let t=[\"overview\"];if(e.loop.status===\"running\")t.push(\"live\");if(t.push(\"timeline\"),e.sectionCount>0)t.push(\"sections\");if(t.push(\"findings\"),e.hasPlan)t.push(\"plan\");return t.push(\"usage\"),t}var ts=4000;function yr(e){let t=B(e?.title);if(t)return t;let n=ie(e?.input);if(!n)return;let s=B(n.command)??B(n.filePath)??B(n.pattern)??B(n.path);if(s)return s;let o=Object.keys(n);return o.length>0?`${o.length} argument${o.length===1?\"\":\"s\"}`:void 0}function wr(e){let t=B(e?.output)??B(e?.error);if(!t)return;return t.length>ts?`…${t.slice(-ts)}`:t}function ie(e){return e&&typeof e===\"object\"&&!Array.isArray(e)?e:null}function B(e){return typeof e===\"string\"?e:void 0}function $s(e){let t=ie(e);if(!t)return null;let n=B(t.id),s=B(t.messageID),o=B(t.type);if(!n||!s||!o)return null;if(o===\"text\"){let r=B(t.text)??\"\";if(t.synthetic===!0)return null;return{id:n,messageID:s,type:o,text:r}}if(o===\"tool\"){let r=ie(t.state);return{id:n,messageID:s,type:o,tool:B(t.tool)??\"tool\",status:B(r?.status)??\"pending\",title:yr(r),output:wr(r)}}return null}function xs(e){if(!Array.isArray(e))return[];let t=[];for(let n of e){let s=ie(n),o=ie(s?.info),r=B(o?.id),a=B(o?.role);if(!r||!a)continue;let i=[],l=s?.parts;if(Array.isArray(l))for(let u of l){let f=$s(u);if(f){let{messageID:h,...y}=f;i.push(y)}}t.push({id:r,role:a,parts:i})}return t}function Ss(e,t){let n=ie(t),s=B(n?.type),o=ie(n?.properties);if(!s||!o)return e;if(s===\"message.updated\"){let r=ie(o.info),a=B(r?.id),i=B(r?.role);if(!a||!i)return e;let l=e.findIndex((f)=>f.id===a);if(l===-1)return[...e,{id:a,role:i,parts:[]}];if(e[l].role===i)return e;let u=[...e];return u[l]={...u[l],role:i},u}if(s===\"message.removed\"){let r=B(o.messageID);if(!r)return e;let a=e.filter((i)=>i.id!==r);return a.length===e.length?e:a}if(s===\"message.part.updated\"){let r=$s(o.part);if(!r)return e;let{messageID:a,...i}=r,l=e.findIndex((p)=>p.id===a);if(l===-1)return[...e,{id:a,role:\"assistant\",parts:[i]}];let u=e[l],f=u.parts.findIndex((p)=>p.id===i.id),h=f===-1?[...u.parts,i]:u.parts.map((p,c)=>c===f?i:p),y=[...e];return y[l]={...u,parts:h},y}if(s===\"message.part.removed\"){let r=B(o.messageID),a=B(o.partID);if(!r||!a)return e;let i=e.findIndex((h)=>h.id===r);if(i===-1)return e;let l=e[i],u=l.parts.filter((h)=>h.id!==a);if(u.length===l.parts.length)return e;let f=[...e];return f[i]={...l,parts:u},f}return e}function ks(e){let t=ie(e),n=B(t?.type),s=ie(t?.properties);if(n===\"session.idle\")return\"idle\";if(n===\"session.status\"){let o=ie(s?.status);return B(o?.type)??null}return null}function Ls(e,t,n,s,o=\"coding\"){let r=[],a=e.findIndex((h)=>h.createdAt>=t),i=a===-1?[]:e.slice(a);if(n!==null)i=i.filter((h)=>h.createdAt<=n);let l;if(i.length>0&&i[0].eventType===\"restart\"&&i[0].toPhase!==null)l=i[0].toPhase;else if(i.length>0&&i[0].eventType!==\"restart\")l=i[0].fromPhase;else l=o;let u=t;for(let h of i){if(h.eventType===\"restart\"){if(h.toPhase!==null)l=h.toPhase;continue}if(l===\"\")l=h.fromPhase,u=Math.max(u,h.createdAt);let y=h.createdAt,p=Math.max(u,y);if(r.push({phase:l,startedAt:u,endedAt:p,durationMs:p-u,open:!1}),h.toPhase===null)l=\"\",u=p;else l=h.toPhase,u=p}if(l!==\"\"){let y=Math.max(u,n??s);r.push({phase:l,startedAt:u,endedAt:n,durationMs:y-u,open:n===null})}let f=e.length>0&&e[0].createdAt>=t&&e[0].createdAt>t+1000;if(f&&r.length>0)r[0]={...r[0],phase:\"\"};return{spans:r,truncated:f}}function $r(e){let t={};for(let n of e){if(!n.phase)continue;t[n.phase]=(t[n.phase]??0)+n.durationMs}return t}var xr={coding:\"Coding\",auditing:\"Auditing\",final_auditing:\"Final audit\",final_audit_fix:\"Final audit fix\",post_action:\"Post-action\"};function be(e){if(e===\"\")return\"Unknown\";return xr[e]??e}function Ts(e){let t=$r(e),n=Object.keys(t).map((o)=>({phase:o,label:be(o),durationMs:t[o],pct:0})),s=n.reduce((o,r)=>o+r.durationMs,0);for(let o of n)o.pct=s>0?Math.round(o.durationMs/s*100):0;return n.sort((o,r)=>r.durationMs-o.durationMs)}function Sr(e,t,n){let s=[];for(let o=0;o=t)a=Math.max(0,r.createdAt-t);else if(r.createdAt=t)a=Math.max(0,r.createdAt-t);else a=null}s.push({transition:r,elapsedMs:a})}return s.reverse(),s}function As(e){if(e.length===0)return{count:0,lastAt:null,lastSection:null};let t=e[0];for(let n of e)if(n.createdAt>t.createdAt||n.createdAt===t.createdAt&&n.id>t.id)t=n;return{count:e.length,lastAt:t.createdAt,lastSection:t.appliedAtSection}}function Ms(e){let t=new Set;for(let n of e)t.add(n.appliedAtSection);return t}function Es(e,t,n,s){let o=[...Sr(e,n,s).map((r)=>({kind:\"transition\",transition:r.transition,elapsedMs:r.elapsedMs})),...t.map((r)=>({kind:\"amendment\",amendment:r,elapsedMs:null}))];return o.sort((r,a)=>{let i=r.kind===\"transition\"?r.transition.createdAt:r.amendment.createdAt,l=a.kind===\"transition\"?a.transition.createdAt:a.amendment.createdAt;if(l!==i)return l-i;if(r.kind!==a.kind)return r.kind===\"transition\"?-1:1;let u=r.kind===\"transition\"?r.transition.id:r.amendment.id;return(a.kind===\"transition\"?a.transition.id:a.amendment.id)-u}),o}var kr=[{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}],gt=110,mt=36,Cs=655,Ns=160,Lr=[{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→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 Tr(e){if(e.toPhase===null)return e.fromPhase+\"→terminal\";return e.fromPhase+\"→\"+e.toPhase}function Ar(e){return e.toPhase??e.status??\"\"}function Jt(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 Ds(e){let t=()=>e.loop(),n=b(()=>t().status===\"running\"),s=b(()=>{let i=new Map;for(let l of e.transitions()){let u=Tr(l);i.set(u,(i.get(u)??0)+1)}return i}),o=Lr.map((i)=>Jt(d`\n \n \n ${()=>{let l=s().get(i.key);return l?String(l):\"\"}}\n \n `)),r=kr.map((i)=>{let l=i.cx-gt/2,u=i.cy-mt/2;return Jt(d`\n \"mg-node\"+(t().phase===i.phase&&n()?\" mg-node-active\":\"\")}>\n \n ${i.label}\n \n `)}),a=Jt(d`\n \n \n ${()=>t().status}\n \n `);return d`
\n \n \n \n \n \n \n\n ${o}\n ${r}\n ${()=>n()?\"\":a}\n \n\n
\n
Transition history
\n
\n ${()=>e.transitions().slice(-20).reverse().map((i)=>d`
\n ${i.eventType}\n ${i.fromPhase} → ${Ar(i)}\n ${he(i.createdAt)}\n
`)}\n
\n
\n
`}function Is(e){let[t,n]=L(e.query()),s=null,o=(a)=>{let i=a.target.value;if(n(i),s)clearTimeout(s);s=setTimeout(()=>{s=null,e.onChangeQuery(i)},250)};fe(()=>{if(s)clearTimeout(s),s=null}),G(()=>{e.externalNav();let a=z(()=>e.query());if(s!==null)clearTimeout(s),s=null;n(a)});let r=b(()=>{let a=e.counts();return[{label:\"Running\",key:\"running\",value:a.running},{label:\"Completed\",key:\"completed\",value:a.completed},{label:\"Cancelled\",key:\"cancelled\",value:a.cancelled},{label:\"Errored\",key:\"errored\",value:a.errored},{label:\"Stalled\",key:\"stalled\",value:a.stalled}]});return d`
\n ${()=>r().map(({label:a,key:i,value:l})=>{let f=`badge badge-filter${e.statuses().has(i)?\" badge-active\":\"\"}`;return d`e.onToggleStatus(i)}>${a}: ${l}`})}\n \n \n
`}function Mr(e){return d`
${()=>{let t=e.generatedAt();return t>0?`Last updated: ${new Date(t).toLocaleString()}`:\"\"}}
`}function Rs(e){return d`
\n

Forge Dashboard

\n
${()=>e.breadcrumb()}
\n ${Mr({generatedAt:e.generatedAt})}\n
`}function Os(e){let t=b(()=>e.entries().map((n)=>{let s=n.loops.some((a)=>a.loop.status===\"running\"),o=Ne(n.proj),r=De(e.labels(),n.proj);return d`
e.onSelect(n.proj.projectId)}>\n ${s?d``:\"\"}\n ${r}\n ${n.loops.length}\n
`}));return d`
${()=>t()}
`}function Er(e,t){let n=0,s=0,o=0,r=[],a=[];for(let i of e){let l=De(t,i.proj);for(let u of i.loops){s++,o+=u.bugCount;let f=u.loop.status,h=Ge(u.loop);if(f===\"running\")n++,r.push({projectId:i.proj.projectId,label:l,loopName:u.loop.loopName,phase:u.loop.phase||\"\"});a.push({projectId:i.proj.projectId,label:l,loopName:u.loop.loopName,status:f,when:h})}}return a.sort((i,l)=>l.when-i.when),{runningCount:n,loopCount:s,bugCount:o,runningCards:r,recentRows:a.slice(0,8)}}function js(e){let t=b(()=>Er(e.entries(),e.labels())),n=b(()=>t().runningCount),s=b(()=>t().loopCount),o=b(()=>t().bugCount),r=b(()=>t().runningCards),a=b(()=>t().recentRows);return d`
\n
\n

All repositories

\n
${()=>`${n()} running · ${s()} loops · ${o()} open bugs`}
\n
\n ${()=>r().length>0?d`
\n

Running

\n
\n ${r().map((i)=>d`
e.onOpenLoop(i.projectId,i.loopName)}>\n ${i.label}\n ${i.loopName}\n ${be(i.phase)}\n
`)}\n
\n
`:\"\"}\n ${()=>a().length>0?d`
\n

Recent activity

\n
\n ${a().map((i)=>d`
e.onOpenLoop(i.projectId,i.loopName)}>\n we(i.status)}>${i.status}\n ${i.label}\n ${i.loopName}\n ${Z(i.when)}\n
`)}\n
\n
`:\"\"}\n
`}function Cr(e){let[t,n]=L(!1),[s,o]=L(\"\"),[r,a]=L(0),i=b(()=>{let v=s().trim().toLowerCase(),S=e.loops();return v?S.filter((M)=>M.name.toLowerCase().indexOf(v)!==-1):S}),l=b(()=>ut(i(),ss,!1)),u=()=>{if(t())return;o(\"\");let v=l().rows.findIndex((S)=>S.name===e.loopName);a(v<0?0:v),n(!0)},f=()=>{n(!1),o(\"\"),a(0)},h=(v)=>{if(f(),g.blur(),v!==e.loopName)e.onSelect(v)},y=d`t()?\"display:block\":\"display:none\"}\n onmousedown=${(v)=>v.preventDefault()}\n >\n ${()=>l().rows.map((v,S)=>d`\"loop-picker-option\"+(S===r()?\" loop-picker-option-active\":\"\")+(v.name===e.loopName?\" loop-picker-option-current\":\"\")}\n onclick=${()=>h(v.name)}\n >\n ${v.name}\n ${v.whenLabel}\n `)}\n ${()=>l().total===0?d`
No matching loops
`:\"\"}\n ${()=>l().capped?d`
${()=>\"Showing \"+l().rows.length+\" of \"+l().total+\" — type to filter\"}
`:\"\"}\n `,p=()=>{y.querySelector(\".loop-picker-option-active\")?.scrollIntoView?.({block:\"nearest\"})},c=(v)=>{if(v.key===\"ArrowDown\"||v.key===\"ArrowUp\"){if(v.preventDefault(),!t()){u();return}let S=l().rows.length;if(S===0)return;let M=v.key===\"ArrowDown\"?1:-1;a((T)=>(T+M+S)%S),p();return}if(v.key===\"Enter\"){v.preventDefault();let S=l().rows[r()];if(S)h(S.name);return}if(v.key===\"Escape\")v.preventDefault(),f(),g.blur()},g=d`t()?s():e.loopName}\n onfocus=${u}\n oninput=${(v)=>{o(v.target.value),a(0),n(!0)}}\n onkeydown=${c}\n onblur=${f}\n />`;return d`\n ${g}\n {if(v.preventDefault(),t())f(),g.blur();else g.focus()}}>▾\n ${y}\n `}function _s(e){if(e.loopName)return d`
\n ← ${e.repoLabel}\n /\n ${Cr({loopName:e.loopName,loops:e.loops,onSelect:e.onSelectLoop})}\n ${()=>{let t=e.loopNav();return t?d`\n \n ${t.index+1} of ${t.total}\n \n `:\"\"}}\n
`;return d`
\n ← Repositories\n /\n ${e.repoLabel}\n ${e.projectDir}\n
`}function Us(e){let t=[{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 d`
\n ${t.map((n)=>{let o=`section-nav-item${n.key===e.section?\" section-nav-active\":\"\"}`;return d`
e.onNavigate(n.key)}>\n ${n.label}\n ${n.count}\n
`})}\n
`}function nn(e){return d`
\n ${()=>\"Showing \"+e.shown()+\" of \"+e.total()+\" \"+e.noun}\n ${e.onShowAll?d``:\"\"}\n
`}function tn(e){let t=()=>Xt(e.current(),e.total());return d`\n \"width:\"+t()+\"%\"}>\n ${()=>e.formatCurrent?e.formatCurrent(e.current()):String(e.current())}/${()=>e.total()}\n `}var Nr={sm:4,md:14,lg:22};function sn(e){let t=Nr[e.variant],n=b(()=>e.spans().filter((r)=>r.phase!==\"\")),s=b(()=>Math.max(0,n().reduce((r,a)=>r+a.durationMs,0))),o=b(()=>e.spans().some((r)=>r.phase===\"\"));return d`
\"phase-bar phase-bar-\"+e.variant+(o()?\" phase-bar-trunc\":\"\")} data-truncated=${()=>o()?\"true\":\"false\"} style=${()=>\"height:\"+t+\"px\"}>\n ${()=>o()?d`\\u22ef`:\"\"}\n ${()=>{let r=n(),a=s();return r.map((i)=>{let l=a>0?i.durationMs/a*100:0,u=\"phase-seg\"+(i.open?\" phase-seg-open\":\"\"),f=Qt(i.durationMs);return d`
`})}}\n
`}function Fs(e){return d`
\"legend\"+(e.phase?\" legend-phase\":\"\")}>\n ${()=>e.items().map((t)=>d`
\n \n ${t.label}\n ${t.value}\n
`)}\n
`}function Bs(e){let t=b(()=>Ts(e.spans()));return Fs({phase:!0,items:()=>t().map((n)=>({phase:n.phase,label:n.label,value:Qt(n.durationMs)+\" · \"+n.pct+\"%\"}))})}function on(e,t){let n=e.loop;return Ls(e.transitions??[],n.startedAt,n.completedAt,n.completedAt===null?t():0,n.phase)}function Vs(e){let[t,n]=L(!1),s=b(()=>ut(e.loops(),Wt,t()));return d`
\n \n \n \n \n \n \n \n \n ${()=>s().rows.map((o)=>Dr({dashLoop:o,now:e.now,onOpen:e.onOpen}))}\n \n
StatusLoopPhasePhase spanIterSectionsFindingsCostDurationUpdated
\n ${()=>s().capped?nn({shown:()=>s().rows.length,total:()=>s().total,noun:\"loops\",onShowAll:()=>n(!0)}):\"\"}\n
`}function Dr(e){let t=()=>e.dashLoop.loop,n=()=>e.dashLoop,s=b(()=>Yt(n().findings)),o=b(()=>on(n(),e.now).spans);return d`e.onOpen(e.dashLoop.loop.loopName)}>\n we(t().status)}>${()=>t().status}\n ${()=>t().loopName}\n ${()=>t().completedAt===null?d`t().phase}>${()=>be(t().phase)}`:d``}\n ${()=>sn({spans:o,variant:\"sm\"})}\n ${tn({current:()=>t().iteration,total:()=>t().maxIterations})}\n ${()=>t().totalSections>0?tn({current:()=>t().currentSectionIndex,total:()=>t().totalSections,formatCurrent:se}):d``}\n ${()=>{let r=s();if(r.bugs.length===0&&r.warnings.length===0)return d``;return d`\n ${r.bugs.length>0?d`${Pe(r.bugs.length,\"bug\")}`:\"\"}\n ${r.bugs.length>0&&r.warnings.length>0?\" · \":\"\"}\n ${r.warnings.length>0?d`${Pe(r.warnings.length,\"warn\")}`:\"\"}\n `}}\n ${()=>n().usage?Ke(n().usage.totalCost):d``}\n ${()=>n().duration||\"\"}\n ${()=>Z(Ge(t()))}\n `}function ze(e){let t=()=>typeof e.label===\"function\"?e.label():e.label,[n,s]=L(!1),o=()=>s((i)=>!i),r=b(()=>ws(e.src()||\"\")),a=b(()=>r().outline.filter((i)=>i.depth<=3));return d`
\n
\n n()?\"false\":\"true\"}\n onclick=${o}\n onkeydown=${(i)=>{if(i.key!==\"Enter\"&&i.key!==\" \")return;i.preventDefault(),o()}}\n >\n ${()=>n()?\"▸\":\"▾\"}\n

${()=>t()}

\n
\n \"Copy \"+t()+\" as markdown\"}\n onclick=${(i)=>{i.stopPropagation();let l=i.target,u=\"Copy\";navigator.clipboard.writeText(e.src()||\"\").then(()=>{l.textContent=\"Copied!\"},()=>{l.textContent=\"Failed\"}).then(()=>{setTimeout(()=>{l.textContent=u},2000)})}}\n >Copy\n
\n
n()?\"display:none\":\"display:block\"}>\n ${()=>{let i=a();return i.length>=2?Pr({items:()=>i}):null}}\n
r().html}>
\n
\n `}function Pr(e){let t=(n,s)=>{n.preventDefault();let o=n.currentTarget.closest(\".markdown-body\"),r=o?Array.from(o.querySelectorAll(\"h1,h2,h3,h4,h5,h6\")).find((a)=>a.id===s):document.getElementById(s);if(r&&typeof r.scrollIntoView===\"function\")r.scrollIntoView({behavior:\"smooth\",block:\"start\"})};return d``}function Zt(e){return d`
\n
${e.label}
\n
ys(e.value()||\"\")}>
\n
`}function Ir(e){let t=e.sec,n=b(()=>!!t.startedAt||!!t.completedAt),s=b(()=>!!t.summaryDone),o=b(()=>!!t.summaryDeviations),r=b(()=>!!t.summaryFollowUps),a=b(()=>!!t.content),i=b(()=>n()||s()||o()||r()||a());return d`
\n ${()=>n()?d`
\n ${()=>t.startedAt?\"Started \"+Z(t.startedAt):\"Not started\"}\n ${()=>t.completedAt?d` → Completed ${()=>Z(t.completedAt)}`:\"\"}\n
`:\"\"}\n ${()=>s()?Zt({label:\"Done\",value:()=>t.summaryDone}):\"\"}\n ${()=>o()?Zt({label:\"Deviations\",value:()=>t.summaryDeviations}):\"\"}\n ${()=>r()?Zt({label:\"Follow-ups\",value:()=>t.summaryFollowUps}):\"\"}\n ${()=>a()?ze({label:\"Section Plan\",src:()=>t.content}):\"\"}\n ${()=>i()?\"\":d`
No details captured for this section yet.
`}\n
`}function Rr(e){let t=e.sec,n=b(()=>hs(t.startedAt,t.completedAt));return d`
\"section-list-row section-item-\"+t.status} onclick=${e.onOpen}>\n #${se(t.sectionIndex)}\n ${()=>t.title}\n zt(t.status)}>${()=>t.status}\n ${()=>e.adjusted()?d`adjusted`:\"\"}\n ${()=>n()?d`${()=>n()}`:\"\"}\n ${()=>t.attempts>0?d`${()=>t.attempts} attempts`:\"\"}\n \n
`}function Or(e){let[t,n]=L(null),s=b(()=>Ms(e.amendments())),o=b(()=>{let r=t();if(r===null)return null;return e.sections().find((a)=>a.sectionIndex===r)??null});return d`
\n

Sections

\n ${()=>{let r=o();if(r)return d`
\n
n(null)}>← Back to sections
\n
\n #${se(r.sectionIndex)}\n ${()=>r.title}\n zt(r.status)}>${()=>r.status}\n
\n ${Ir({sec:r})}\n
`;return d`
\n ${e.sections().map((a)=>Rr({sec:a,onOpen:()=>n(a.sectionIndex),adjusted:()=>s().has(a.sectionIndex)}))}\n
`}}\n
`}function Q(e){let t=()=>\"ldh-cell\"+(e.size===\"stat\"?\" ldh-cell-stat\":\"\")+(e.tone?\" ldh-cell-\"+e.tone():\"\"),n=()=>\"ldh-cell-value\"+(e.size===\"stat\"?\" ldh-cell-value-stat\":\"\");return d`
\n ${e.label}\n e.title?e.title():\"\"}>${()=>e.value()}\n
`}function en(e){return d`
\n
${e.label}
\n
${()=>e.children.map((t)=>t)}
\n
`}function Ps(e){let t=()=>Xt(e.current(),e.total());return d`
\n
\n ${e.label}\n ${()=>e.formatCurrent?e.formatCurrent(e.current()):String(e.current())} / ${()=>e.total()}\n
\n
\n
\"width:\"+t()+\"%\"}>
\n
\n
`}function jr(e){let t=()=>e.dashLoop.loop,n=()=>e.dashLoop,s=b(()=>!!t().completedAt),o=b(()=>t().completedAt===null),r=b(()=>t().totalSections>0),a=b(()=>As(n().amendments??[])),i=b(()=>{let y=t().terminationReason;if(!y)return null;return y.trim().toLowerCase()===t().status.toLowerCase()?null:y}),l=b(()=>us(e.split())),u=b(()=>!!e.dashLoop.usage),f=b(()=>{let y=e.split();if(y.bugs.length===0&&y.warnings.length===0)return\"No findings\";let p=[];if(y.bugs.length>0)p.push(Pe(y.bugs.length,\"bug\"));if(y.warnings.length>0)p.push(Pe(y.warnings.length,\"warning\"));return p.join(\" · \")}),h=b(()=>{let y=[Q({label:\"Started\",value:()=>Z(t().startedAt),size:\"stat\"})];if(s())y.push(Q({label:\"Completed\",value:()=>Z(t().completedAt),size:\"stat\"}));let p=[Q({label:\"Execution model\",value:()=>t().executionModel??\"—\",title:()=>t().executionModel??\"\",size:\"stat\"}),Q({label:\"Auditor model\",value:()=>{let g=t().auditorModel??\"—\",v=t().auditorFallbackIndex??0;return v>0?`${g} (fallback ${v})`:g},title:()=>t().auditorModel??\"\",size:\"stat\"})],c=[Q({label:\"Branch\",value:()=>t().worktreeBranch??\"—\",title:()=>t().worktreeBranch??\"\",size:\"stat\"}),Q({label:\"Sandbox\",value:()=>t().sandbox?\"on\":\"off\",size:\"stat\"}),Q({label:\"Kind\",value:()=>t().kind??\"—\",size:\"stat\"}),Q({label:\"Audits\",value:()=>String(t().auditCount),size:\"stat\"}),Q({label:\"Errors\",value:()=>String(t().errorCount),size:\"stat\"})];if(u())c.push(Q({label:\"Messages\",value:()=>String(e.dashLoop.usage.totalMessageCount),size:\"stat\"}));return[en({label:\"Timing\",children:y}),en({label:\"Models\",children:p}),en({label:\"Environment\",children:c})]});return d`
\n
\n we(t().status)}>${()=>t().status}\n

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

\n ${()=>a().count>0?d`e.onSelectTab(\"plan\")}\n >${()=>\"Plan adjusted \"+a().count+\"× · last \"+he(a().lastAt)+\" @ section \"+se(a().lastSection)}`:\"\"}\n ${()=>o()?d`${be(t().phase)}`:\"\"}\n
\n\n
\n ${Q({label:\"Duration\",value:()=>n().duration||\"—\"})}\n ${Q({label:\"Cost\",value:()=>u()?Ke(e.dashLoop.usage.totalCost):\"—\"})}\n ${Q({label:\"Iterations\",value:()=>t().iteration+\" / \"+t().maxIterations})}\n ${()=>r()?Q({label:\"Sections\",value:()=>se(t().currentSectionIndex)+\" / \"+t().totalSections}):\"\"}\n ${Q({label:\"Findings\",value:()=>f(),tone:()=>l()})}\n
\n\n
\n ${Ps({label:\"Iterations\",current:()=>t().iteration,total:()=>t().maxIterations})}\n ${()=>r()?Ps({label:\"Sections\",current:()=>t().currentSectionIndex,total:()=>t().totalSections,formatCurrent:se}):\"\"}\n
\n\n
${()=>h()}
\n\n ${()=>i()?d`
\"ldh-banner ldh-banner-\"+t().status}>${()=>i()}
`:\"\"}\n
`}function _r(e){let t=()=>e.usage(),n=b(()=>ps(t())),s=b(()=>ms(t())),o=b(()=>s().length>0),r=b(()=>gs(t()));return d`
\n

Usage

\n\n
\n
Role split
\n
\n ${()=>r().map((a)=>d`
a.role}>\n
\n a.role}>${a.role}\n ${()=>Ke(a.cost)}\n
\n
\n
\"width:\"+a.pct+\"%\"}>
\n
\n
${()=>a.messageCount+\" msg · \"+We(a.tokens)+\" tok\"}
\n
`)}\n
\n
\n\n
\n
Token composition
\n
\n ${()=>n().map((a)=>a.pct>0?d`
`:\"\")}\n
\n ${Fs({items:()=>n().map((a)=>({color:a.color,label:a.label,value:We(a.value)}))})}\n
\n\n ${()=>o()?d`
\n
Cost by model
\n
\n ${()=>s().map((a)=>d`
\n
\n ${a.model}\n ${Ke(a.cost)}\n
\n
\n
\n
\n
\n ${We(a.inputTokens)} in / ${We(a.outputTokens)} out · ${a.messageCount} msg\n
\n
`)}\n
\n
`:\"\"}\n `}function Ur(e){let t=e.amendment,[n,s]=L(null),[o,r]=L(!1),[a,i]=L(\"\"),[l,u]=L(!1);G(()=>{if(e.expanded()){if(l()||n()!==null)return;u(!0),i(\"\"),r(!0);let c=new URLSearchParams({project:t.projectId,loop:t.loopName,id:String(t.id)});fetch(\"/api/amendment?\"+c.toString()).then(async(g)=>{if(!g.ok){i(await g.text().catch(()=>\"\")||`Failed (status ${g.status})`);return}let v=await g.json();s(v)}).catch((g)=>i(g instanceof Error?g.message:String(g))).finally(()=>r(!1));return}if(l()&&!o()&&n()===null)u(!1)});let f=()=>{let c=[];if(t.summary.added>0)c.push({cls:\"amendment-count-add\",label:\"+\"+t.summary.added});if(t.summary.removed>0)c.push({cls:\"amendment-count-remove\",label:\"−\"+t.summary.removed});if(t.summary.modified>0)c.push({cls:\"amendment-count-modified\",label:\"~\"+t.summary.modified});return c},h=()=>(n()?.sections??[]).filter((c)=>c.change!==\"unchanged\"),y=()=>n()!==null&&h().length===0,p=(c)=>{if(c.kind===\"add\")return\"+ \"+c.text;if(c.kind===\"remove\")return\"- \"+c.text;if(c.kind===\"context\")return\" \"+c.text;return c.text};return d`
\n
\n ${()=>he(t.createdAt)}\n ${()=>\"applied @ section \"+se(t.appliedAtSection)}\n ${()=>t.source}\n ${()=>f().length>0?d`${f().map((c)=>d`${c.label}`)}`:\"\"}\n ${()=>t.rationale}\n ${()=>e.expanded()?\"▾\":\"▸\"}\n
\n
e.expanded()?\"display:block\":\"display:none\"}>\n
\n ${()=>o()?d`
Loading diff…
`:\"\"}\n ${()=>a()?d`
${a()}
`:\"\"}\n ${()=>y()?d`
No section changes recorded.
`:\"\"}\n ${()=>h().map((c)=>d`
\n
\n ${()=>\"#\"+se(c.index)}\n ${()=>c.change}\n ${()=>c.title}\n ${()=>c.previousTitle!==null?d`${()=>\"was: \"+c.previousTitle}`:\"\"}\n
\n ${()=>c.lines.length===0?d`
Title changed only.
`:c.lines.map((g)=>d`
${p(g)}
`)}\n
`)}\n
\n
\n
`}function Fr(e){let t=()=>e.amendments(),[n,s]=L(null);return d`
\n

Plan amendments

\n
\n ${()=>t().map((o)=>Ur({amendment:o,expanded:()=>n()===o.id,onToggle:()=>s(n()===o.id?null:o.id)}))}\n
\n
`}var Br={overview:\"Overview\",live:\"Live\",timeline:\"Timeline\",sections:\"Sections\",findings:\"Findings\",plan:\"Plan\",usage:\"Usage\"};function Vr(e){return d`
\n ${()=>e.tabs().map((t)=>{let n=Br[t];return d`\"tab-item\"+(e.activeTab()===t?\" tab-active\":\"\")} data-tab=\"${t}\" onclick=${()=>e.onSelect(t)}>${n}`})}\n
`}function Hr(e){let t=()=>e.dashLoop,n=()=>t().loop,s=b(()=>!!t().goal),o=b(()=>t().postActionReport??n().completionSummary??null),r=b(()=>t().postActionReport?\"Post-Action Report\":\"Completion Summary\"),a=b(()=>!!o()),i=b(()=>!!t().lastAuditResult),l=b(()=>on(t(),e.now));return d`
\n ${()=>sn({spans:()=>l().spans,variant:\"md\"})}\n ${()=>Bs({spans:()=>l().spans})}\n ${()=>s()?ze({label:\"Goal\",src:()=>t().goal}):\"\"}\n ${jr({dashLoop:e.dashLoop,split:e.split,onSelectTab:e.onSelectTab})}\n ${()=>a()?ze({label:r,src:()=>o()}):\"\"}\n ${()=>i()?ze({label:\"Last Audit Result\",src:()=>t().lastAuditResult}):\"\"}\n
`}function qr(e){let t=e.ev.transition,n=e.ev.elapsedMs,s=n===null?null:Math.floor(n/1000),o=t.toPhase===null?t.status??\"\":t.toPhase;return d`
\n ${he(t.createdAt)}\n ${be(t.fromPhase)} → ${be(o)}\n ${t.transitionKind}\n iter ${t.iteration}\n ${()=>t.sectionIndex===null?\"—\":\"sect \"+se(t.sectionIndex)}\n ${()=>s===null?\"—\":s>0?He(s):\"0s\"}\n
`}function Gr(e){let t=e.amendment;return d`
\n ${he(t.createdAt)}\n plan adjusted\n ${()=>\"section \"+se(t.appliedAtSection)}\n ${()=>t.rationale}\n
`}function Wr(e){let t=()=>e.dashLoop,n=()=>t().loop,s=()=>t().transitions??[],o=b(()=>on(t(),e.now)),r=b(()=>o().spans),a=b(()=>o().truncated),i=b(()=>Es(s(),t().amendments??[],n().startedAt,a())),[l,u]=L(!1),f=b(()=>{let y=i();return l()?y:y.slice(0,20)}),h=b(()=>l()?0:Math.max(0,i().length-20));return d`
\n ${Ds({loop:()=>n(),transitions:s})}\n ${()=>sn({spans:r,variant:\"lg\"})}\n ${()=>a()?d`
Earlier history was truncated by the 100-row fetch window.
`:\"\"}\n ${()=>Bs({spans:r})}\n\n
\n ${()=>f().map((y)=>y.kind===\"transition\"?qr({ev:{transition:y.transition,elapsedMs:y.elapsedMs}}):Gr({amendment:y.amendment}))}\n ${()=>h()>0?d`
u(!0)}>[ ${()=>h()} earlier events ]
`:\"\"}\n
\n
`}function Kr(e){return Or({sections:()=>e.dashLoop.sections,amendments:()=>e.dashLoop.amendments??[]})}function zr(e){let t=[],n=new Map;for(let s of e){let o=s.sectionIndex??null;if(!n.has(o))n.set(o,[]),t.push(o);n.get(o).push(s)}return t.sort((s,o)=>{if(s===null&&o===null)return 0;if(s===null)return 1;if(o===null)return-1;return s-o}),t.map((s)=>({key:s,label:s===null?\"cross-section\":\"sect \"+se(s),rows:n.get(s)}))}function Hs(e){return d`
\n ${e.rows.map((t)=>d`
\"finding finding-\"+t.severity}>\n ${()=>he(t.createdAt)}\n ${()=>ds(t)}\n
`)}\n
`}function qs(e){let t=()=>ft({projectId:e.projectId(),section:\"loops\",loopName:e.loopName,tab:\"overview\",groupId:null,statuses:[],query:\"\"});return d`{if(n.defaultPrevented||n.button!==0||n.metaKey||n.ctrlKey||n.shiftKey||n.altKey)return;n.preventDefault(),e.onOpen(e.loopName)}}>${e.loopName}`}function Yr(e){let t=()=>e.dashLoop,n=b(()=>t().findings.length>0),s=b(()=>zr(t().findings));return d`
\n ${()=>n()?s().map((o)=>d`
o.key===null?\"cross\":String(o.key)}>\n
${o.label}
\n ${Hs({rows:o.rows})}\n
`):d`
No findings recorded for this loop.
`}\n
`}function Xr(e){let t=()=>e.dashLoop,n=b(()=>t().hasPlan),s=b(()=>!!t().amendments&&t().amendments.length>0);return d`
\n ${()=>n()?ze({label:\"Plan\",src:()=>t().plan}):d`
No plan recorded for this loop.
`}\n ${()=>s()?Fr({amendments:()=>t().amendments}):\"\"}\n
`}function Qr(e){let t=()=>e.dashLoop,n=b(()=>!!t().usage);return d`
\n ${()=>n()?_r({usage:()=>t().usage}):d`
No usage recorded for this loop.
`}\n
`}function Jr(e){let t=()=>e.dashLoop.loop,[n,s]=L([]),[o,r]=L(\"\"),[a,i]=L(\"\"),[l,u]=L(\"\"),[f,h]=L(\"\"),[y,p]=L(\"\"),[c,g]=L(!1),[v,S]=L(\"\"),[M,T]=L(!1),[k,A]=L(!1),P=!1,I=[\"\",\"\",\"\",\"\"];G(()=>{let C=[t().executionModel??\"\",t().executionVariant??\"\",t().auditorModel??\"\",t().auditorVariant??\"\"];if(!P&&(C[0]!==I[0]||C[1]!==I[1]||C[2]!==I[2]||C[3]!==I[3]))i(C[0]),u(C[1]),h(C[2]),p(C[3]);I=C}),G(()=>{if(!k()||n().length>0)return;let C=new URLSearchParams({project:t().projectId,loop:t().loopName});fetch(\"/api/models?\"+C.toString()).then(async(j)=>{if(!j.ok){r(await j.text().catch(()=>\"\")||`Failed (status ${j.status})`);return}let X=await j.json();s(X.models??[]),r(\"\")}).catch((j)=>r(j instanceof Error?j.message:String(j)))});let V=(C)=>n().find((j)=>j.id===C)?.variants??[],O=async()=>{if(c())return;g(!0),S(\"\"),T(!1);try{let C=await fetch(\"/api/loop/models\",{method:\"POST\",headers:{\"content-type\":\"application/json\"},body:JSON.stringify({projectId:t().projectId,loopName:t().loopName,executionModel:a()||null,executionVariant:l()||null,auditorModel:f()||null,auditorVariant:y()||null})});if(!C.ok){S(await C.text().catch(()=>\"\")||`Failed (status ${C.status})`);return}let j=await C.json(),X=[j.executionModel??\"\",j.executionVariant??\"\",j.auditorModel??\"\",j.auditorVariant??\"\"];i(X[0]),u(X[1]),h(X[2]),p(X[3]),I=X,P=!1,T(!0)}catch(C){S(C instanceof Error?C.message:String(C))}finally{g(!1)}},J=(C,j,X)=>d`{P=!0,j(U.currentTarget.value),X()}}\n >\n \n ${()=>n().map((U)=>d``)}\n ${()=>C()&&!n().some((U)=>U.id===C())?d``:\"\"}\n `,Y=(C,j,X)=>d`V(C()).length===0}\n onchange=${(U)=>{P=!0,X(U.currentTarget.value)}}\n >\n \n ${()=>V(C()).map((U)=>d``)}\n `;return d`
\n \n ${()=>{if(!k())return\"\";return d`
\n ${()=>o()?d`
${()=>o()}
`:\"\"}\n \n \n
\n Applies to the next prompt, not the turn in flight.\n \n
\n ${()=>v()?d`
${()=>v()}
`:\"\"}\n ${()=>M()&&!v()?d`
Models updated.
`:\"\"}\n
`}}\n
`}function Zr(e){let t=()=>e.part,[n,s]=L(!1),o=()=>!!t().output;return d`
\"live-tool live-tool-\"+(t().status??\"pending\")}>\n \"live-tool-head\"+(o()?\" live-tool-head-clickable\":\"\")}\n disabled=${()=>!o()}\n aria-expanded=${()=>o()?n()?\"true\":\"false\":void 0}\n onclick=${()=>{if(o())s((r)=>!r)}}\n >\n ${()=>o()?n()?\"▾\":\"▸\":\"\"}\n ${()=>t().tool??\"tool\"}\n ${()=>t().title??\"\"}\n ${()=>t().status??\"pending\"}\n \n ${()=>n()&&o()?d`
${()=>t().output}
`:\"\"}\n
`}var ea=8000,ta=48;function na(){if(typeof document>\"u\")return()=>!0;let[e,t]=L(document.visibilityState!==\"hidden\"),n=()=>t(document.visibilityState!==\"hidden\");return document.addEventListener(\"visibilitychange\",n),fe(()=>document.removeEventListener(\"visibilitychange\",n)),e}function sa(e){let t=()=>e.dashLoop,n=()=>t().loop,[s,o]=L([]),[r,a]=L(\"connecting\"),[i,l]=L(\"\"),[u,f]=L(\"\"),[h,y]=L(\"\"),[p,c]=L(!1),[g,v]=L(\"\"),[S,M]=L(\"stream\"),[T,k]=L(0),[A,P]=L(0),I=na(),V=b(()=>e.visible()&&I());G(()=>{if(!V())return;let N=setInterval(()=>P((q)=>q+1),1000);fe(()=>clearInterval(N))});let O=b(()=>{if(A(),u()===\"busy\")return!0;let N=T();return N>0&&Date.now()-N\"/api/loop/stream?\"+new URLSearchParams({project:n().projectId,loop:n().loopName}).toString()),Y=\"\";G(()=>{let N=n().currentSessionId,q=J();if(!V()||!N||typeof EventSource>\"u\")return;if(N!==Y)Y=N,o([]);a(\"connecting\"),l(\"\"),M(\"stream\"),k(0);let W=new EventSource(q);W.addEventListener(\"snapshot\",(ve)=>{try{let de=JSON.parse(ve.data),xe=xs(de.messages);if(o(xe),a(\"live\"),de.reason===\"poll\")M(\"poll\"),k(Date.now())}catch{a(\"failed\"),l(\"Could not read the transcript snapshot.\")}}),W.addEventListener(\"event\",(ve)=>{try{let de=JSON.parse(ve.data);o((Xe)=>Ss(Xe,de)),M(\"stream\"),k(Date.now());let xe=ks(de);if(xe)f(xe)}catch{}}),W.addEventListener(\"failed\",(ve)=>{try{let de=JSON.parse(ve.data);l(de.message??\"Live stream ended.\")}catch{l(\"Live stream ended.\")}a(\"failed\"),W.close()}),W.onerror=()=>{if(W.readyState===EventSource.CLOSED)a(\"failed\")},fe(()=>W.close())});let C=async()=>{let N=h().trim();if(!N||p())return;c(!0),v(\"\");try{let q=await fetch(\"/api/loop/message\",{method:\"POST\",headers:{\"content-type\":\"application/json\"},body:JSON.stringify({projectId:n().projectId,loopName:n().loopName,text:N})});if(!q.ok){v(await q.text().catch(()=>\"\")||`Failed (status ${q.status})`);return}y(\"\")}catch(q){v(q instanceof Error?q.message:String(q))}finally{c(!1)}},j=b(()=>{if(r()===\"connecting\")return\"connecting\";if(r()===\"failed\")return\"disconnected\";if(O())return S()===\"poll\"?\"working (refreshing)\":\"working\";return S()===\"poll\"?\"refreshing\":u()||\"idle\"}),X=b(()=>{if(r()!==\"live\")return\"live-dot live-dot-\"+r();if(O())return\"live-dot live-dot-working\";return\"live-dot live-dot-idle\"}),U=d`
\n ${()=>{let N=s();if(N.length===0)return d`
${()=>r()===\"live\"?\"No messages in this session yet.\":\"Loading session…\"}
`;return N.map((q)=>d`
\n
${q.role}
\n
\n ${()=>q.parts.map((W)=>W.type===\"text\"?d`
${W.text??\"\"}
`:Zr({part:W}))}\n
\n
`)}}\n
`,[ue,$e]=L(!0),Ye=(N)=>N.scrollHeight-N.scrollTop-N.clientHeight$e(Ye(U))),G(()=>{if(s(),!ue())return;queueMicrotask(()=>{U.scrollTop=U.scrollHeight})}),d`
\n
\n X()}>\n ${()=>j()}\n ${()=>S()===\"poll\"?d`events unavailable — refreshing every 4s`:\"\"}\n ${()=>n().currentSessionId}\n
\n\n ${()=>i()?d`
${()=>i()}
`:\"\"}\n\n ${Jr({dashLoop:e.dashLoop})}\n\n ${U}\n\n ${()=>ue()?\"\":d``}\n\n
\n h()}\n oninput=${(N)=>y(N.currentTarget.value)}\n onkeydown=${(N)=>{if(N.key!==\"Enter\"||N.shiftKey)return;N.preventDefault(),C()}}\n >\n void C()}\n disabled=${()=>p()||h().trim().length===0}\n >${()=>p()?\"Sending…\":\"Send\"}\n
\n ${()=>g()?d`
${()=>g()}
`:\"\"}\n
`}function oa(e,t){switch(e.tab){case\"overview\":return Hr({dashLoop:e.dashLoop,split:e.split,now:e.now,onSelectTab:e.onSelectTab});case\"live\":return sa({dashLoop:e.dashLoop,visible:t});case\"timeline\":return Wr({dashLoop:e.dashLoop,now:e.now});case\"sections\":return Kr({dashLoop:e.dashLoop});case\"findings\":return Yr({dashLoop:e.dashLoop});case\"plan\":return Xr({dashLoop:e.dashLoop});case\"usage\":return Qr({dashLoop:e.dashLoop})}}function ra(e){let t=b(()=>e.tabs().includes(e.tab)&&e.activeTab()===e.tab),n=ot(),s=d`
t()?\"display:block\":\"display:none\"}>
`,o=!1;return G(()=>{if(!t()||o)return;o=!0;let r=St(n,()=>oa(e,t));if(r)s.appendChild(r)}),s}function aa(e){return d`
\n ${Gt.map((t)=>ra({tab:t,dashLoop:e.dashLoop,split:e.split,now:e.now,tabs:e.tabs,activeTab:e.activeTab,onSelectTab:e.onSelectTab}))}\n
`}function Gs(e){let t=()=>e.dashLoop,n=b(()=>Yt(t().findings)),s=b(()=>pt(t())),o=b(()=>{let r=e.routeTab();return s().includes(r)?r:\"overview\"});return d`
\n
\n ${Vr({tabs:s,activeTab:o,onSelect:e.onSelectTab})}\n ${aa({dashLoop:e.dashLoop,split:n,now:e.now,tabs:s,activeTab:o,onSelectTab:e.onSelectTab})}\n
\n
`}function rn(){return d`
No loops match the current filters.
`}var ia=new Set([\"completed\",\"cancelled\",\"errored\"]);function Ws(e){let t=e.length;return{completed:e.filter((s)=>s.stage===\"completed\").length,total:t}}function Ks(e,t){let n=ia.has(t);return e+(n?` ${e}-terminal`:` ${e}-active`)}function zs(e){return d`
\n

Groups

\n ${()=>{let t=e.groups();if(t.length===0)return d`
No feature groups for this repo.
`;return d`
\n ${t.map((n)=>{let s=n.group,o=b(()=>Ws(n.features));return d`
Ks(\"group-row\",s.status)} data-group-status=${()=>s.status} onclick=${()=>e.onOpen(s.groupId)}>\n we(s.status)}>${()=>s.status}\n ${()=>s.title}\n ${tn({current:()=>o().completed,total:()=>o().total})}\n max ${()=>s.maxConcurrent}\n \n Created ${()=>Z(s.createdAt)}\n ${()=>s.completedAt?d`Completed ${()=>Z(s.completedAt)}`:\"\"}\n \n
`})}\n
`}}\n
`}function Ys(e){return d`
\n
← Back to groups
\n ${()=>{let t=e.group();if(!t)return d`
Group not found.
`;let n=t.group,s=b(()=>Ws(t.features));return d`
\n
Ks(\"group-header\",n.status)} data-group-status=${()=>n.status}>\n
\n we(n.status)}>${()=>n.status}\n

${()=>n.title}

\n max ${()=>n.maxConcurrent}\n
\n
\n ${()=>\"Features \"+s().completed+\"/\"+s().total}\n Created ${()=>Z(n.createdAt)}\n ${()=>n.completedAt?d`Completed ${()=>Z(n.completedAt)}`:\"\"}\n ${()=>n.error?d`${()=>n.error}`:\"\"}\n
\n ${()=>n.prdPreview?d`
\n

PRD preview

\n
${()=>n.prdPreview||\"\"}
\n
`:\"\"}\n
\n
\n ${t.features.map((o)=>d`
o.stage}>\n #${()=>o.featureIndex}\n ${()=>o.title}\n rs(o.stage)}>${()=>o.stage}\n ${()=>o.attempts} attempts\n ${()=>!!o.loopName&&e.loopNames().has(o.loopName)?qs({className:\"feature-loop-link\",projectId:e.projectId,loopName:o.loopName,onOpen:e.onOpenLoop}):\"\"}\n ${()=>o.error?d`${()=>o.error}`:\"\"}\n
`)}\n
\n
`}}\n
`}function la(e,t,n){let s=[],o=0,r=n?1/0:t;for(let a of e){if(a.findings.length===0)continue;if(o+=a.findings.length,r<=0)continue;let i=Math.min(r,a.findings.length);s.push({loopName:a.loop.loopName,rows:a.findings.slice(0,i)}),r-=i}return{groups:s,total:o,capped:!n&&o>t}}function Xs(e){let[t,n]=L(!1),s=b(()=>la(e.loops(),ns,t()));return d`
\n

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

\n ${()=>s().capped?nn({shown:()=>s().groups.reduce((o,r)=>o+r.rows.length,0),total:()=>s().total,noun:\"findings\",onShowAll:()=>n(!0)}):\"\"}\n ${()=>{let o=s().groups;if(o.length===0)return d`
No findings recorded for this repo.
`;return d`
\n ${o.map((r)=>d`
r.loopName}>\n
\n ${qs({className:\"findings-loop-link\",projectId:e.projectId,loopName:r.loopName,onOpen:e.onOpenLoop})}\n ${()=>r.rows.length}\n
\n ${Hs({rows:r.rows})}\n
`)}\n
`}}\n
`}function Qs(e){let[t,n]=L(!1),s=b(()=>e.loops().filter((r)=>r.hasPlan)),o=b(()=>ut(s(),Wt,t()));return d`
\n

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

\n ${()=>o().capped?nn({shown:()=>o().rows.length,total:()=>o().total,noun:\"plans\",onShowAll:()=>n(!0)}):\"\"}\n ${()=>{let r=o().rows;if(r.length===0)return d`
No plans recorded for this repo.
`;return d`
\n ${r.map((a)=>d`
e.onOpenLoop(a.loop.loopName)}>\n we(a.loop.status)}>${()=>a.loop.status}\n ${()=>a.loop.loopName}\n \n ${()=>Pe(a.findings.length,\"finding\")}\n \n iter ${()=>a.loop.iteration}/${()=>a.loop.maxIterations}\n
`)}\n
`}}\n
`}function Js(){let[e,t]=Hn({generatedAt:0,projects:[]}),[n,s]=L(!1),o=dt(typeof location<\"u\"?location.hash:\"\"),[r,a]=L(o.projectId),[i,l]=L(o.section),[u,f]=L(o.loopName),[h,y]=L(o.tab),[p,c]=L(o.groupId),[g,v]=L(o.statuses,{equals:Kt}),[S,M]=L(o.query),T=b(()=>({projectId:r(),section:i(),loopName:u(),tab:h(),groupId:p(),statuses:g(),query:S()})),[k,A]=L(null),[P,I]=L(\"recent\"),[V,O]=L(0),[J,Y]=L(Date.now()),C={current:\"\"},j={current:!1},X=()=>{let m=z(T),w=new URLSearchParams;if(m.projectId)w.set(\"project\",m.projectId);if(m.loopName)w.set(\"loop\",m.loopName);let E=w.toString();return E?\"/api/data?\"+E:\"/api/data\"},U=0,ue={url:null,gen:0},$e=async()=>{let m=X();if(ue.url===m)return;let w=++U;ue.url=m,ue.gen=w;try{let E=await fetch(m,{cache:\"no-store\"});if(w!==U)return;let _=await E.json();if(w!==U)return;let ee=ls(_);if(ee!==C.current)C.current=ee,t(qn(_,{key:\"id\"})),s(!0),A(null);Y(Date.now())}catch(E){if(w!==U)return;let _=E instanceof Error?E.message:String(E);A(\"Failed to load dashboard data: \"+_)}finally{if(ue.gen===w)ue.url=null,ue.gen=0}},Ye=(m)=>{Ue(()=>{a(m.projectId),l(m.section),f(m.loopName),y(m.tab),c(m.groupId),v(m.statuses),M(m.query)})},N=(m)=>{Ue(()=>{let w=m.projectId!==void 0&&m.projectId!==r();if(m.projectId!==void 0)a(m.projectId);if(m.section!==void 0)l(m.section);if(m.loopName!==void 0)f(m.loopName);if(m.tab!==void 0)y(m.tab);if(m.groupId!==void 0)c(m.groupId);if(m.statuses!==void 0)v(m.statuses);if(m.query!==void 0)M(m.query);if(w)v([]),M(\"\")})},q=(m)=>{N({tab:m})},W=(m)=>{N({loopName:m,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})},ve=(m)=>{let w=new Set(g());if(w.has(m))w.delete(m);else w.add(m);N({statuses:[...w]})},de=(m)=>{N({query:m})},xe=(m)=>{I(m)},Xe=b(()=>new Set(g())),Qe=b(()=>cs(e.projects.map(Ne))),Je=b(()=>{if(!n()||e.projects.length===0)return[];let m=Xe(),w=S(),E=Qe(),_=[],ee=w.trim().toLowerCase();for(let Re of e.projects){let vt=[],dn=De(E,Re);for(let fn of Re.loops)if(as(fn.loop,m,w,dn))vt.push(fn);let wo=(Re.groups?.length??0)>0;if(vt.length>0)_.push({proj:Re,loops:vt});else if(wo&&(!ee||dn.toLowerCase().indexOf(ee)!==-1))_.push({proj:Re,loops:[]})}return _}),an=b(()=>r()===null),eo=b(()=>i()===\"groups\"),ln=b(()=>i()===\"loops\"),to=b(()=>i()===\"findings\"),no=b(()=>i()===\"plans\"),cn=b(()=>{let m=Je(),w=r();if(!w)return null;return m.find((E)=>E.proj.projectId===w)??null}),le=b(()=>{let m=r();if(!m)return null;return e.projects.find((w)=>w.projectId===m)??null}),ht=b(()=>{let m=r(),w=u();if(!m||!w)return null;let E=le();if(!E)return null;return E.loops.find((_)=>_.loop.loopName===w)??null}),so=b(()=>{let m=le(),w=m?.groups?.length??0;if(!m)return{loops:0,groups:w,findings:0,plans:0};let E=0,_=0;for(let ee of m.loops)if(E+=ee.findings.length,ee.hasPlan)_++;return{loops:m.loops.length,groups:w,findings:E,plans:_}}),Ie=b(()=>{let m=le();if(!m)return[];return m.loops.map((w)=>{let E=Ge(w.loop);return{name:w.loop.loopName,when:E,whenLabel:Z(E)}}).sort((w,E)=>E.when-w.when||w.name.localeCompare(E.name))}),bt=b(()=>{let m=u();if(!m)return-1;return Ie().findIndex((w)=>w.name===m)}),oo=b(()=>{let m=bt();if(m<0)return null;let w=Ie();if(w.length<=1)return null;return{index:m,total:w.length,onPrev:()=>{let E=bt(),_=Ie();if(E>0)N({loopName:_[E-1].name})},onNext:()=>{let E=bt(),_=Ie();if(E<_.length-1)N({loopName:_[E+1].name})}}},null,{equals:(m,w)=>m===w||!!m&&!!w&&m.index===w.index&&m.total===w.total}),ro=b(()=>{let m=le(),w={running:0,completed:0,cancelled:0,errored:0,stalled:0};if(!m)return w;for(let E of m.loops){let _=E.loop.status;if(_===\"running\")w.running++;else if(_===\"completed\")w.completed++;else if(_===\"cancelled\")w.cancelled++;else if(_===\"errored\")w.errored++;else if(_===\"stalled\")w.stalled++}return w}),ao=b(()=>{let m=u();if(!r())return\"\";let w=le();if(!w)return\"\";let E=Ne(w),_=De(Qe(),w);return z(()=>_s({repoLabel:_,projectDir:E,loopName:m,loops:Ie,loopNav:oo,onSelectLoop:(ee)=>N({loopName:ee}),onBackToRepos:()=>N({projectId:null,loopName:null,section:\"loops\"}),onBackToRepo:()=>N({loopName:null})}))}),io=b(()=>{let m=r(),w=u();if(!m||w)return\"\";return Us({section:i(),counts:so(),onNavigate:(E)=>N({section:E,loopName:null,groupId:null})})}),lo=b(()=>r()!==null&&u()===null&&i()===\"loops\"),co=b(()=>{if(!lo())return\"\";return z(()=>Is({counts:ro,statuses:Xe,query:S,sortMode:P,onToggleStatus:ve,onChangeQuery:de,onChangeSort:xe,externalNav:V}))}),uo=b(()=>{if(!ln())return\"\";let m=ht();return m?Gs({dashLoop:m,now:J,routeTab:h,onSelectTab:q}):\"\"}),fo=b(()=>{let m=cn();return m?is(m.loops,P()):[]},[],{equals:Kt}),po=b(()=>{if(!ln())return\"\";if(ht())return\"\";if(!r())return\"\";return d`
\n ${Vs({loops:fo,now:J,onOpen:(m)=>W(m)})}\n ${()=>cn()?\"\":rn()}\n
`}),go=b(()=>{if(i()!==\"groups\"||!p())return null;let m=le();if(!m)return null;return m.groups.find((w)=>w.group.groupId===p())??null}),mo=b(()=>{let m=le();if(!m)return new Set;return new Set(m.loops.map((w)=>w.loop.loopName))}),ho=b(()=>{if(!eo())return\"\";if(!r())return\"\";if(p())return Ys({group:go,loopNames:mo,projectId:()=>r(),onBack:()=>N({groupId:null}),onOpenLoop:W});return zs({groups:()=>le()?.groups??[],onOpen:(w)=>N({groupId:w})})}),un=b(()=>{let m=le();return m?m.loops:[]}),bo=b(()=>{if(!to())return\"\";if(!r())return\"\";return Xs({loops:un,projectId:()=>r(),onOpenLoop:W})}),vo=b(()=>{if(!no())return\"\";if(!r())return\"\";return Qs({loops:un,onOpenLoop:W})});G(()=>{if(!n())return;let m=r();if(m===null)return;if(!e.projects.some((E)=>E.projectId===m))a(null),f(null),l(\"loops\"),v([]),M(\"\")}),G(()=>{let m=r(),w=u();if(!m||!w)return;let E=le();if(!E)return;if(!E.loops.some((ee)=>ee.loop.loopName===w))f(null)}),G(()=>{let m=u(),w=h();if(!r()||!m||w===\"overview\")return;let E=ht();if(!E)return;if(!pt(E).includes(w))y(\"overview\")}),G(()=>{if(!n())return;os(ft(T()),j)});let yo=b(()=>(r()??\"\")+\"\\x00\"+(u()??\"\"));return G(xn(yo,()=>{$e()},{defer:!0})),Sn(()=>{Ye(dt(location.hash)),$e();let m=setInterval(()=>{$e()},5000),w=()=>{if(j.current){j.current=!1;return}Ye(dt(location.hash)),O((E)=>E+1)};window.addEventListener(\"hashchange\",w),fe(()=>{clearInterval(m),window.removeEventListener(\"hashchange\",w)})}),d`
\n ${Rs({generatedAt:()=>e.generatedAt,breadcrumb:ao})}\n\n ${()=>{let m=k();return m?d`
${m}
`:\"\"}}\n\n ${()=>{if(!n())return\"\";if(an()&&Je().length===0)return rn();return d`
\n ${()=>{if(an())return d`
\n ${Os({entries:()=>Je(),labels:()=>Qe(),onSelect:(m)=>N({projectId:m,loopName:null,section:\"loops\",groupId:null})})}\n ${js({entries:()=>Je(),labels:()=>Qe(),onOpenLoop:(m,w)=>N({projectId:m,loopName:w,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})})}\n
`;return d`
\n ${co}\n ${io}\n ${po}\n ${uo}\n ${ho}\n ${bo}\n ${vo}\n
`}}\n
`}}\n
`}var Zs=document.getElementById(\"forge-app-root\");if(Zs)Rn(Js,Zs);\n"; +export const DASHBOARD_APP_BUNDLE: string = "var X={context:void 0,registry:void 0,effects:void 0,done:!1,getContextId(){return yf(this.context.count)},getNextContextId(){return yf(this.context.count++)}};function yf(n){let e=String(n),f=e.length-1;return X.context.id+(f?String.fromCharCode(96+f):\"\")+e}function Pe(n){X.context=n}function Pi(){return{...X.context,id:X.getNextContextId(),count:0}}var Ei=!0,Vi=(n,e)=>n===e,tn=Symbol(\"solid-proxy\"),Ci=typeof Proxy===\"function\",Ve=Symbol(\"solid-track\"),kf=Symbol(\"solid-dev-component\"),ne={equals:Vi},bf=null,Pf=Yf,nn=1,Jn=2,Ai={};var F=null,E=null,qn=null,Cn=null,_=null,T=null,R=null,fe=0,un={afterUpdate:null,afterCreateOwner:null,afterCreateSignal:null,afterRegisterGraph:null};function Ef(n,e){let f=_,t=F,i=n.length===0,u=e===void 0?t:e,c=i?{owned:null,cleanups:null,context:null,owner:null}:{owned:null,cleanups:null,context:u?u.context:null,owner:u},l=i?()=>n(()=>{throw Error(\"Dispose method must be an explicit argument to createRoot function\")}):()=>n(()=>v(()=>bn(c)));un.afterCreateOwner&&un.afterCreateOwner(c),F=c,_=null;try{return cn(l,!0)}finally{_=f,F=t}}function A(n,e){e=e?Object.assign({},ne,e):ne;let f={value:n,observers:null,observerSlots:null,comparator:e.equals||void 0};{if(e.name)f.name=e.name;if(e.internal)f.internal=!0;else if(Af(f),un.afterCreateSignal)un.afterCreateSignal(f)}let t=(i)=>{if(typeof i===\"function\")if(E&&E.running&&E.sources.has(f))i=i(f.tValue);else i=i(f.value);return Ae(f,i)};return[Kf.bind(f),t]}function $n(n,e,f){let t=ue(n,e,!1,nn,f);if(qn&&E&&E.running)T.push(t);else An(t)}function D(n,e,f){Pf=zi;let t=ue(n,e,!1,nn,f),i=Ee&&Yi(Ee);if(i)t.suspense=i;if(!f||!f.render)t.user=!0;R?R.push(t):An(t)}function g(n,e,f){f=f?Object.assign({},ne,f):ne;let t=ue(n,e,!0,0,f);if(t.observers=null,t.observerSlots=null,t.comparator=f.equals||void 0,qn&&E&&E.running)t.tState=nn,T.push(t);else An(t);return Kf.bind(t)}function Xn(n){return cn(n,!1)}function v(n){if(!Cn&&_===null)return n();let e=_;_=null;try{if(Cn)return Cn.untrack(n);return n()}finally{_=e}}function Vf(n,e,f){let t=Array.isArray(n),i,u=f&&f.defer;return(c)=>{let l;if(t){l=Array(n.length);for(let d=0;de(l,i,c));return i=l,r}}function Cf(n){D(()=>v(n))}function wn(n){if(F===null)console.warn(\"cleanups created outside a `createRoot` or `render` will never be run\");else if(F.cleanups===null)F.cleanups=[n];else F.cleanups.push(n);return n}function te(){return _}function ie(){return F}function Ce(n,e){let f=F,t=_;F=n,_=null;try{return cn(e,!0)}catch(i){ce(i)}finally{F=f,_=t}}function Ki(n){if(E&&E.running)return n(),E.done;let e=_,f=F;return Promise.resolve().then(()=>{_=e,F=f;let t;if(qn||Ee)t=E||(E={sources:new Set,effects:[],promises:new Set,disposed:new Set,queue:new Set,running:!0}),t.done||(t.done=new Promise((i)=>t.resolve=i)),t.running=!0;return cn(n,!1),_=F=null,t?t.done:void 0})}var[rc,xf]=A(!1);function Of(n,e){let f=ue(()=>v(()=>{return Object.assign(n,{[kf]:!0}),n(e)}),void 0,!0,0);return f.props=e,f.observers=null,f.observerSlots=null,f.name=n.name,f.component=n,An(f),f.tValue!==void 0?f.tValue:f.value}function Af(n){if(F){if(F.sourceMap)F.sourceMap.push(n);else F.sourceMap=[n];n.graph=F}if(un.afterRegisterGraph)un.afterRegisterGraph(n)}function Yi(n){let e;return F&&F.context&&(e=F.context[n.id])!==void 0?e:n.defaultValue}var Ee;function Kf(){let n=E&&E.running;if(this.sources&&(n?this.tState:this.state))if((n?this.tState:this.state)===nn)An(this);else{let e=T;T=null,cn(()=>ee(this),!1),T=e}if(_){let e=this.observers?this.observers.length:0;if(!_.sources)_.sources=[this],_.sourceSlots=[e];else _.sources.push(this),_.sourceSlots.push(e);if(!this.observers)this.observers=[_],this.observerSlots=[_.sources.length-1];else this.observers.push(_),this.observerSlots.push(_.sources.length-1)}if(n&&E.sources.has(this))return this.tValue;return this.value}function Ae(n,e,f){let t=E&&E.running&&E.sources.has(n)?n.tValue:n.value;if(!n.comparator||!n.comparator(t,e)){if(E){let i=E.running;if(i||!f&&E.sources.has(n))E.sources.add(n),n.tValue=e;if(!i)n.value=e}else n.value=e;if(n.observers&&n.observers.length)cn(()=>{for(let i=0;i1e6){if(T=[],Ei)throw Error(\"Potential Infinite Loop Detected.\");throw Error()}},!1)}return e}function An(n){if(!n.fn)return;bn(n);let e=fe;if(gf(n,E&&E.running&&E.sources.has(n)?n.tValue:n.value,e),E&&!E.running&&E.sources.has(n))queueMicrotask(()=>{cn(()=>{E&&(E.running=!0),_=F=n,gf(n,n.tValue,e),_=F=null},!1)})}function gf(n,e,f){let t,i=F,u=_;_=F=n;try{t=n.fn(e)}catch(c){if(n.pure)if(E&&E.running)n.tState=nn,n.tOwned&&n.tOwned.forEach(bn),n.tOwned=void 0;else n.state=nn,n.owned&&n.owned.forEach(bn),n.owned=null;return n.updatedAt=f+1,ce(c)}finally{_=u,F=i}if(!n.updatedAt||n.updatedAt<=f){if(n.updatedAt!=null&&\"observers\"in n)Ae(n,t,!0);else if(E&&E.running&&n.pure){if(!E.sources.has(n))n.value=t;E.sources.add(n),n.tValue=t}else n.value=t;n.updatedAt=f}}function ue(n,e,f,t=nn,i){let u={fn:n,state:t,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:e,owner:F,context:F?F.context:null,pure:f};if(E&&E.running)u.state=0,u.tState=t;if(F===null)console.warn(\"computations created outside a `createRoot` or `render` will never be disposed\");else if(F!==Ai)if(E&&E.running&&F.pure)if(!F.tOwned)F.tOwned=[u];else F.tOwned.push(u);else if(!F.owned)F.owned=[u];else F.owned.push(u);if(i&&i.name)u.name=i.name;if(Cn&&u.fn){let c=u.fn,[l,r]=A(void 0,{equals:!1}),d=Cn.factory(c,r);wn(()=>d.dispose());let w,O=()=>Ki(r).then(()=>{if(w)w.dispose(),w=void 0});u.fn=(k)=>{if(l(),E&&E.running){if(!w)w=Cn.factory(c,O);return w.track(k)}return d.track(k)}}return un.afterCreateOwner&&un.afterCreateOwner(u),u}function Un(n){let e=E&&E.running;if((e?n.tState:n.state)===0)return;if((e?n.tState:n.state)===Jn)return ee(n);if(n.suspense&&v(n.suspense.inFallback))return n.suspense.effects.push(n);let f=[n];while((n=n.owner)&&(!n.updatedAt||n.updatedAt=0;t--){if(n=f[t],e){let i=n,u=f[t+1];while((i=i.owner)&&i!==u)if(E.disposed.has(i))return}if((e?n.tState:n.state)===nn)An(n);else if((e?n.tState:n.state)===Jn){let i=T;T=null,cn(()=>ee(n,f[0]),!1),T=i}}}function cn(n,e){if(T)return n();let f=!1;if(!e)T=[];if(R)f=!0;else R=[];fe++;try{let t=n();return Wi(f),t}catch(t){if(!f)R=null;T=null,ce(t)}}function Wi(n){if(T){if(qn&&E&&E.running)si(T);else Yf(T);T=null}if(n)return;let e;if(E){if(!E.promises.size&&!E.queue.size){let{sources:t,disposed:i}=E;R.push.apply(R,E.effects),e=E.resolve;for(let u of R)\"tState\"in u&&(u.state=u.tState),delete u.tState;E=null,cn(()=>{for(let u of i)bn(u);for(let u of t){if(u.value=u.tValue,u.owned)for(let c=0,l=u.owned.length;cPf(f),!1);else un.afterUpdate&&un.afterUpdate();if(e)e()}function Yf(n){for(let e=0;e{t.delete(f),cn(()=>{E.running=!0,Un(f)},!1),E&&(E.running=!1)})}}function zi(n){let e,f=0;for(e=0;e=0;e--)bn(n.tOwned[e]);delete n.tOwned}if(E&&E.running&&n.pure)sf(n,!0);else if(n.owned){for(e=n.owned.length-1;e>=0;e--)bn(n.owned[e]);n.owned=null}if(n.cleanups){for(e=n.cleanups.length-1;e>=0;e--)n.cleanups[e]();n.cleanups=null}if(E&&E.running)n.tState=0;else n.state=0;delete n.sourceMap}function sf(n,e){if(!e)n.tState=0,E.disposed.add(n);if(n.owned)for(let f=0;f=0;l--){let r=ke(n[l])[c];if(r!==void 0)return r}},has(c){for(let l=n.length-1;l>=0;l--)if(c in ke(n[l]))return!0;return!1},keys(){let c=[];for(let l=0;l=0;c--){let l=n[c];if(!l)continue;let r=Object.getOwnPropertyNames(l);for(let d=r.length-1;d>=0;d--){let w=r[d];if(w===\"__proto__\"||w===\"constructor\")continue;let O=Object.getOwnPropertyDescriptor(l,w);if(!t[w])t[w]=O.get?{enumerable:!0,configurable:!0,get:Zi.bind(f[w]=[O.get.bind(l)])}:O.value!==void 0?O:void 0;else{let k=f[w];if(k){if(O.get)k.push(O.get.bind(l));else if(O.value!==void 0)k.push(()=>O.value)}}}}let i={},u=Object.keys(t);for(let c=u.length-1;c>=0;c--){let l=u[c],r=t[l];if(r&&r.get)Object.defineProperty(i,l,r);else i[l]=r?r.value:void 0}return i}var zf={hooks:un,writeSignal:Ae,registerGraph:Af};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 _i=[\"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\"],se=new Set([\"className\",\"value\",\"readOnly\",\"noValidate\",\"formNoValidate\",\"isMap\",\"noModule\",\"playsInline\",\"adAuctionHeaders\",\"allowFullscreen\",\"browsingTopics\",\"defaultChecked\",\"defaultMuted\",\"defaultSelected\",\"disablePictureInPicture\",\"disableRemotePlayback\",\"preservesPitch\",\"shadowRootClonable\",\"shadowRootCustomElementRegistry\",\"shadowRootDelegatesFocus\",\"shadowRootSerializable\",\"sharedStorageWritable\",..._i]),ze=new Set([\"innerHTML\",\"textContent\",\"innerText\",\"children\"]),He=Object.assign(Object.create(null),{className:\"class\",htmlFor:\"for\"}),ji=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 Fe(n,e){let f=ji[n];return typeof f===\"object\"?f[e]?f.$:void 0:f}var Ge=new Set([\"beforeinput\",\"click\",\"dblclick\",\"contextmenu\",\"focusin\",\"focusout\",\"input\",\"keydown\",\"keyup\",\"mousedown\",\"mousemove\",\"mouseout\",\"mouseover\",\"mouseup\",\"pointerdown\",\"pointermove\",\"pointerout\",\"pointerover\",\"pointerup\",\"touchend\",\"touchmove\",\"touchstart\"]),_f=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\"]),Ze={xlink:\"http://www.w3.org/1999/xlink\",xml:\"http://www.w3.org/XML/1998/namespace\"};function Ji(n,e,f){let t=f.length,i=e.length,u=t,c=0,l=0,r=e[i-1].nextSibling,d=null;while(cw-l){let $=e[c];while(l{i=u,e===document?n():Xe(e,n(),e.firstChild?null:void 0,f)},t.owner),()=>{i(),e.textContent=\"\"}}function _e(n,e=window.document){let f=e[Hf]||(e[Hf]=new Set);for(let t=0,i=n.length;ti.call(n,f[1],u))}else n.addEventListener(e,f,typeof f!==\"function\"&&f)}function Ue(n,e,f={}){let t=Object.keys(e||{}),i=Object.keys(f),u,c;for(u=0,c=i.length;ui.children=Yn(n,e.children,i.children));return $n(()=>typeof e.ref===\"function\"&&Xi(e.ref,n)),$n(()=>Si(n,e,f,!0,i,!0)),i}function Uf(n,e){let f=n[e];return Object.defineProperty(n,e,{get(){return f()},enumerable:!0}),n}function Xi(n,e,f){return v(()=>n(e,f))}function Xe(n,e,f,t){if(f!==void 0&&!t)t=[];if(typeof e!==\"function\")return Yn(n,e,t,f);$n((i)=>Yn(n,e(),i,f),t)}function Si(n,e,f,t,i={},u=!1){e||(e={});for(let c in i)if(!(c in e)){if(c===\"children\")continue;i[c]=Gf(n,c,null,i[c],f,u,e)}for(let c in e){if(c===\"children\"){if(!t)Yn(n,e.children);continue}let l=e[c];i[c]=Gf(n,c,l,i[c],f,u,e)}}function Wn(n){return!!X.context&&!X.done&&(!n||n.isConnected)}function Qi(n){return n.toLowerCase().replace(/-([a-z])/g,(e,f)=>f.toUpperCase())}function Ff(n,e,f){let t=e.trim().split(/\\s+/);for(let i=0,u=t.length;i-1&&Ze[e.split(\":\")[0]];if(k)je(n,k,e,f);else Sn(n,He[e]||e,f)}return f}function Ti(n){if(X.registry&&X.events){if(X.events.find(([r,d])=>d===n))return}let e=n.target,f=`$$${n.type}`,t=n.target,i=n.currentTarget,u=(r)=>Object.defineProperty(n,\"target\",{configurable:!0,value:r}),c=()=>{let r=e[f];if(r&&!e.disabled){let d=e[`${f}Data`];if(d!==void 0?r.call(e,d,n):r.call(e,n),n.cancelBubble)return}return e.host&&typeof e.host!==\"string\"&&!e.host._$host&&e.contains(n.target)&&u(e.host),!0},l=()=>{while(c()&&(e=e._$host||e.parentNode||e.host));};if(Object.defineProperty(n,\"currentTarget\",{configurable:!0,get(){return e||document}}),X.registry&&!X.done)X.done=_$HY.done=!0;if(n.composedPath){let r=n.composedPath();u(r[0]);for(let d=0;d{let r=e();while(typeof r===\"function\")r=r();f=Yn(n,r,f,t)}),()=>f;else if(Array.isArray(e)){let r=[],d=f&&Array.isArray(f);if(We(r,e,f,i))return $n(()=>f=Yn(n,r,f,t,!0)),()=>f;if(u){if(!r.length)return f;if(t===void 0)return f=[...n.childNodes];let w=r[0];if(w.parentNode!==n)return f;let O=[w];while((w=w.nextSibling)!==t)O.push(w);return f=O}if(r.length===0){if(f=Kn(n,f,t),l)return f}else if(d)if(f.length===0)Zf(n,r,t);else Ji(n,f,r);else f&&Kn(n),Zf(n,r);f=r}else if(e.nodeType){if(u&&e.parentNode)return f=l?[e]:e;if(Array.isArray(f)){if(l)return f=Kn(n,f,t,e);Kn(n,f,null,e)}else if(f==null||f===\"\"||!n.firstChild)n.appendChild(e);else n.replaceChild(e,n.firstChild);f=e}else console.warn(\"Unrecognized value. Skipped inserting\",e);return f}function We(n,e,f,t){let i=!1;for(let u=0,c=e.length;u=0;c--){let l=e[c];if(i!==l){let r=l.parentNode===n;if(!u&&!c)r?n.replaceChild(i,l):n.insertBefore(i,f);else r&&l.remove()}else u=!0}}else n.insertBefore(i,f);return[i]}var Vc=Symbol();var Se=Symbol(\"store-raw\"),zn=Symbol(\"store-node\"),yn=Symbol(\"store-has\"),Xf=Symbol(\"store-self\"),qf={onStoreNodeUpdate:null};function Sf(n){let e=n[tn];if(!e){if(Object.defineProperty(n,tn,{value:e=new Proxy(n,Bi)}),!Array.isArray(n)){let f=Object.keys(n),t=Object.getOwnPropertyDescriptors(n);for(let i=0,u=f.length;in[tn][e],f}function Qf(n){te()&&Tn(le(n,zn),Xf)()}function Di(n){return Qf(n),Reflect.ownKeys(n)}var Bi={get(n,e,f){if(e===Se)return n;if(e===tn)return f;if(e===Ve)return Qf(n),f;let t=le(n,zn),i=t[e],u=i?i():n[e];if(e===zn||e===yn||e===\"__proto__\")return u;if(!i){let c=Object.getOwnPropertyDescriptor(n,e);if(te()&&(typeof u!==\"function\"||n.hasOwnProperty(e))&&!(c&&c.get))u=Tn(t,e,u)()}return xn(u)?Sf(u):u},has(n,e){if(e===Se||e===tn||e===Ve||e===zn||e===yn||e===\"__proto__\")return!0;return te()&&Tn(le(n,yn),e)(),e in n},set(){return console.warn(\"Cannot mutate a Store directly\"),!0},deleteProperty(){return console.warn(\"Cannot mutate a Store directly\"),!0},ownKeys:Di,getOwnPropertyDescriptor:Ii};function en(n,e,f,t=!1){if(!t&&n[e]===f)return;let i=n[e],u=n.length;if(qf.onStoreNodeUpdate&&qf.onStoreNodeUpdate(n,e,f,i),f===void 0){if(delete n[e],n[yn]&&n[yn][e]&&i!==void 0)n[yn][e].$()}else if(n[e]=f,n[yn]&&n[yn][e]&&i===void 0)n[yn][e].$();let c=le(n,zn),l;if(l=Tn(c,e,i))l.$(()=>f);if(Array.isArray(n)&&n.length!==u){for(let r=n.length;r1){t=e.shift();let c=typeof t,l=Array.isArray(n);if(Array.isArray(t)){for(let r=0;r1){Qn(n[t],e,[t].concat(f));return}i=n[t],f=[t].concat(f)}let u=e[0];if(typeof u===\"function\"){if(u=u(i,f),u===i)return}if(t===void 0&&u==null)return;if(u=Hn(u),t===void 0||xn(i)&&xn(u)&&!Array.isArray(u))Tf(i,u);else en(n,t,u)}function If(...[n,e]){let f=Hn(n||{}),t=Array.isArray(f);if(typeof f!==\"object\"&&typeof f!==\"function\")throw Error(`Unexpected type ${typeof f} received when initializing 'createStore'. Expected an object.`);let i=Sf(f);zf.registerGraph({value:f,name:e&&e.name});function u(...c){Xn(()=>{t&&c.length===1?Ri(f,c[0]):Qn(f,c)})}return[i,u]}var Qe=Symbol(\"store-root\");function sn(n,e,f,t,i){let u=e[f];if(n===u)return;let c=Array.isArray(n);if(f!==Qe&&(!xn(n)||!xn(u)||c!==Array.isArray(u)||i&&n[i]!==u[i])){en(e,f,n);return}if(c){if(n.length&&u.length&&(!t||i&&n[0]&&n[0][i]!=null)){let d,w,O,k,y,$,b,N;for(O=0,k=Math.min(u.length,n.length);O=O&&y>=O&&(u[k]===n[y]||i&&u[k]&&n[y]&&u[k][i]&&u[k][i]===n[y][i]);k--,y--)V[y]=u[k];if(O>y||O>k){for(w=O;w<=y;w++)en(u,w,n[w]);for(;wn.length)en(u,\"length\",n.length);return}b=Array(y+1);for(w=y;w>=O;w--)$=n[w],N=i&&$?$[i]:$,d=W.get(N),b[w]=d===void 0?-1:d,W.set(N,w);for(d=O;d<=k;d++)if($=u[d],N=i&&$?$[i]:$,w=W.get(N),w!==void 0&&w!==-1)V[w]=u[d],w=b[w],W.set(N,w);for(w=O;wn.length)en(u,\"length\",n.length);return}let l=Object.keys(n);for(let d=0,w=l.length;d{if(!xn(u)||!xn(i))return i;let c=sn(i,{[Qe]:u},Qe,f,t);return c===void 0?u:c}}var vi=/(?:|<(?:\"[^\"]*\"['\"]*|'[^']*'['\"]*|[^'\">])+>)/g,oi=/(?:\\s(?[^/\\s><=]+?)(?=[\\s/>]))|(?:(?\\S+?)(?:\\s*=\\s*(?:(['\"])(?[\\s\\S]*?)\\3|(?[^\\s>]+))))/g,Li={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(n){let e={type:\"tag\",name:\"\",voidElement:!1,attrs:[],children:[]},f=n.match(/<\\/?([^\\s]+?)[/\\s>]/);if(f){if(e.name=f[1],Li[f[1].toLowerCase()]||n.charAt(n.length-2)===\"/\")e.voidElement=!0;if(e.name.startsWith(\"!--\")){let i=n.indexOf(\"-->\");return{type:\"comment\",comment:i!==-1?n.slice(4,i):\"\"}}}let t=new RegExp(oi);for(let i of n.matchAll(t))if((i[1]||i[2]).startsWith(\"use:\"))e.attrs.push({type:\"directive\",name:i[1]||i[2],value:i[4]||i[5]||\"\"});else e.attrs.push({type:\"attr\",name:i[1]||i[2],value:i[4]||i[5]||\"\"});return e}function Bf(n,e,f){let t=e.indexOf(\"<\",f),i=e.slice(f,t===-1?void 0:t);if(!/^\\s*$/.test(i))n.push({type:\"text\",content:i})}function Rf(n,e){let f=e.replace(\"\",\"\");if(!/^\\s*$/.test(f))n.push({type:\"comment\",content:f})}function ai(n){let e=[],f=void 0,t=-1,i=[],u={};return n.replace(vi,(c,l)=>{let r=c.charAt(1)!==\"/\",d=c.slice(0,4)===\"\"}}function pi(n){return n.reduce(function(e,f){return e+Lf(\"\",f)},\"\")}var vf=new Map,nu=/^(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$/i,re=\" \\\\f\\\\n\\\\r\\\\t\",Mf=\"[^\"+re+`\\\\/>\"'=]+`,Ie=\"[ \"+re+\"]+(?:use:|\"+Mf+\")\",af=\"<([A-Za-z$#]+[A-Za-z0-9:_-]*)((?:\",mf=`(?:\\\\s*=\\\\s*(?:'[^']*?'|\"[^\"]*?\"|\\\\([^)]*?\\\\)|<[^>]*?>|`+Mf+\"))?)\",eu=new RegExp(af+Ie+mf+\"+)([ \"+re+\"]*/?>)\",\"g\"),fu=new RegExp(\"(\"+Ie+`\\\\s*=\\\\s*)(|['\"(]([\\\\w\\\\s]*[\\\\w\\\\s]*)*['\")])`,\"gi\"),tu=new RegExp(af+Ie+mf+\"*)([ \"+re+\"]*/>)\",\"g\"),of=\"\",iu=new Set([\"class\",\"on\",\"oncapture\",\"style\",\"use\",\"prop\",\"attr\"]);function uu(n,e,f,t){return\"<\"+e+f.replace(fu,cu)+t}function cu(n,e,f){return e.replace(//g,\"###\")+(f[0]==='\"'||f[0]===\"'\"?f.replace(//g,\"###\"):'\"###\"')}function lu(n,e,f){return nu.test(e)?n:\"<\"+e+f+\">\"}function ru(n){return n.toLowerCase().replace(/-([a-z])/g,(e,f)=>f.toUpperCase())}function Te(n,e,f,t){if(n===\"use:###\"&&e===\"###\"){let i=t.counter++;t.exprs.push(`typeof exprs[${i}] === \"function\" ? r.use(exprs[${i}], ${f}, exprs[${t.counter++}]) : (()=>{throw new Error(\"use:### must be a function\")})()`)}else throw Error(`Not support syntax ${n} must be use:{function}`)}function $u(n,{delegateEvents:e=!0,functionBuilder:f=(...t)=>Function(...t)}={}){let t=1;n.wrapProps=(y)=>{let $=Object.getOwnPropertyDescriptors(y);for(let b in $)if(typeof $[b].value===\"function\"&&!$[b].value.length)n.dynamicProperty(y,b);return y};function i(y,$){let b=0,N=\"\";for(;b\";N=N+y[b],N=[[tu,lu],[/<()/g,\"<###\"],[/\\.\\.\\.()/g,\"###\"],[eu,uu],[/>\\n+\\s*/g,\">\"],[/\\n+\\s*\\s+/g,\"> \"]].reduce((G,Z)=>{return G.replace(Z[0],Z[1])},N);let W=ai(N),[K,C]=O(W,$.funcBuilder),Y=[];for(let G=0;G-1){let a=j.split(of).reduce((o,z,J)=>{return J&&o.push(\"\"),o.push(z),o},[]);Z[G].firstChild.replaceWith(...a)}}}return Y[0].create=C,vf.set(y,Y),Y}function u(y,$,b,N,V,W,K){let C=N===\"###\"?`!doNotWrap ? exprs[${K.counter}]() : exprs[${K.counter++}]`:N.split(\"###\").map((j,a)=>a?` + (typeof exprs[${K.counter}] === \"function\" ? exprs[${K.counter}]() : exprs[${K.counter++}]) + \"${j}\"`:`\"${j}\"`).join(\"\"),Y,G;if((Y=b.split(\":\"))&&Y[1]&&iu.has(Y[0]))b=Y[1],G=Y[0];let Z=n.ChildProperties.has(b),Q=n.Properties.has(b);if(b===\"style\"){let j=`_$v${t++}`;K.decl.push(`${j}={}`),K.exprs.push(`r.style(${$},${C},${j})`)}else if(b===\"classList\"){let j=`_$v${t++}`;K.decl.push(`${j}={}`),K.exprs.push(`r.classList(${$},${C},${j})`)}else if(G!==\"attr\"&&(Z||!V&&(n.getPropAlias(b,y.name.toUpperCase())||Q)||W||G===\"prop\")){if(W&&!Z&&!Q&&G!==\"prop\")b=ru(b);K.exprs.push(`${$}.${n.getPropAlias(b,y.name.toUpperCase())||b} = ${C}`)}else{let j=V&&b.indexOf(\":\")>-1&&n.SVGNamespace[b.split(\":\")[0]];if(j)K.exprs.push(`r.setAttributeNS(${$},\"${j}\",\"${b}\",${C})`);else K.exprs.push(`r.setAttribute(${$},\"${n.Aliases[b]||b}\",${C})`)}}function c(y,$,b,N,V,W,K){if(b.slice(0,2)===\"on\")if(!b.includes(\":\")){let C=b.slice(2).toLowerCase(),Y=e&&n.DelegatedEvents.has(C);K.exprs.push(`r.addEventListener(${$},\"${C}\",exprs[${K.counter++}],${Y})`),Y&&K.delegatedEvents.add(C)}else{let C=b.startsWith(\"oncapture:\");K.exprs.push(`${$}.addEventListener(\"${b.slice(C?10:3)}\",exprs[${K.counter++}]${C?\",true\":\"\"})`)}else if(b===\"ref\")K.exprs.push(`exprs[${K.counter++}](${$})`);else{let C=Object.assign({},K,{exprs:[]}),Y=K.counter;if(u(y,$,b,N,V,W,C),K.decl.push(`_fn${Y} = (${N===\"###\"?\"doNotWrap\":\"\"}) => {\n${C.exprs.join(`;\n`)};\n}`),N===\"###\")K.exprs.push(`typeof exprs[${Y}] === \"function\" ? r.effect(_fn${Y}) : _fn${Y}(true)`);else{let G=\"\";for(let Z=Y;Z1)for(let V=0;V1?`r.mergeProps(${$.join(\",\")})`:$[0]}function d(y,$){let b=[],N=Object.keys(y.attrs),V=[b],W=$.counter++;for(let C=0;C exprs[${$.counter++}]`);else if(y.children.length){let C={type:\"fragment\",children:y.children},Y=Object.assign({},$,{first:!0,decl:[],exprs:[],parent:!1});w(C,Y),b.push(`children: () => { ${Y.exprs.join(`;\n`)}}`),$.templateId=Y.templateId,$.counter=Y.counter}let K;if($.multi)K=`_$el${t++}`,$.decl.push(`${K} = ${$.path}.${$.first?\"firstChild\":\"nextSibling\"}`);if($.parent)$.exprs.push(`r.insert(${$.parent}, r.createComponent(exprs[${W}],${r(V)})${K?`, ${K}`:\"\"})`);else $.exprs.push(`${$.fragment?\"\":\"return \"}r.createComponent(exprs[${W}],${r(V)})`);$.path=K,$.first=!1}function w(y,$){if(y.type===\"fragment\"){let b=[];y.children.forEach((N)=>{if(N.type===\"tag\"){if(N.name===\"###\"){let K=Object.assign({},$,{first:!0,fragment:!0,decl:[],exprs:[]});d(N,K),b.push(K.exprs[0]),$.counter=K.counter,$.templateId=K.templateId;return}$.templateId++;let V=t,W=Object.assign({},$,{first:!0,decl:[],exprs:[]});$.templateNodes.push([N]),w(N,W),b.push(`function() { ${W.decl.join(`,\n`)+`;\n`+W.exprs.join(`;\n`)+`;\nreturn _$el${V};\n`}}()`),$.counter=W.counter,$.templateId=W.templateId}else if(N.type===\"text\")b.push(`\"${N.content}\"`);else if(N.type===\"comment\"){if(N.content===\"#\")b.push(`exprs[${$.counter++}]`);else if(N.content)for(let V=0;VC.name===\"is\");if($.hasCustomElement=K,$.isImportNode=(y.name===\"img\"||y.name===\"iframe\")&&y.attrs.some((C)=>C.name===\"loading\"&&C.value===\"lazy\"),y.attrs.some((C)=>C.name===\"###\")){let C=[],Y=\"\",G=[];for(let Z=0;Z({${Y}})`),Y=\"\";C.push(`exprs[${$.counter++}]`)}else G.push(y.attrs[Z]);else if(Q===\"directive\")Te(j,a,b,$)}if(y.attrs=G,Y.length)C.push(`()=>({${Y}})`);$.exprs.push(`r.spread(${b},${C.length===1?`typeof ${C[0]} === \"function\" ? r.mergeProps(${C[0]}) : ${C[0]}`:`r.mergeProps(${C.join(\",\")})`},${W},${!!y.children.length})`)}else for(let C=0;C document.importNode(tmpls[${V}].content.firstChild, true))`:`const ${b} = tmpls[${V}].content.firstChild.cloneNode(true)`}else if(y.type===\"text\"){let b=`_$el${t++}`;$.decl.push(`${b} = ${$.path}.${$.first?\"firstChild\":\"nextSibling\"}`),$.path=b,$.first=!1}else if(y.type===\"comment\"){let b=`_$el${t++}`;if($.decl.push(`${b} = ${$.path}.${$.first?\"firstChild\":\"nextSibling\"}`),y.content===\"#\")if($.multi)$.exprs.push(`r.insert(${$.parent}, exprs[${$.counter++}], ${b})`);else $.exprs.push(`r.insert(${$.parent}, exprs[${$.counter++}])`);$.path=b,$.first=!1}}function O(y,$){let b={path:\"\",decl:[],exprs:[],delegatedEvents:new Set,counter:0,first:!0,multi:!1,templateId:0,templateNodes:[]},N=t,V=y,W;if(y.length>1)y=[{type:\"fragment\",children:y}];if(y[0].name===\"###\")W=!0,d(y[0],b);else w(y[0],b);return n.delegateEvents(Array.from(b.delegatedEvents)),[[V].concat(b.templateNodes).map((C)=>pi(C)),$(\"tmpls\",\"exprs\",\"r\",b.decl.join(`,\n`)+`;\n`+b.exprs.join(`;\n`)+(W?\"\":`;\nreturn _$el${N};\n`))]}function k(y,...$){let b=vf.get(y)||i(y,{funcBuilder:f});return b[0].create(b,$,n)}return k}var h=$u({effect:$n,style:qe,insert:Xe,untrack:v,spread:Jf,createComponent:Ke,delegateEvents:_e,classList:Ue,mergeProps:Ye,dynamicProperty:Uf,setAttribute:Sn,setAttributeNS:je,addEventListener:Je,Aliases:He,getPropAlias:Fe,Properties:se,ChildProperties:ze,DelegatedEvents:Ge,SVGElements:_f,SVGNamespace:Ze});function In(n){let e=Math.floor(n/3600),f=Math.floor(n%3600/60),t=n%60;if(e>0)return`${e}h ${f}m`;return f>0?`${f}m ${t}s`:`${t}s`}function $e(n,e){if(!n)return 0;let f=new Date(n).getTime(),t=e?new Date(e).getTime():Date.now();return Math.round((t-f)/1000)}function pf(n){return n.toLowerCase().replace(/[^\\w\\s-]/g,\"\").trim().replace(/\\s+/g,\"-\").replace(/-+/g,\"-\")}var nt=[\"code\",\"auditor\",\"unknown\"];var Be=[\"overview\",\"live\",\"timeline\",\"sections\",\"findings\",\"plan\",\"usage\"],Re=200,ft=300,tt=100;function de(n,e,f){if(f||n.length<=e)return{rows:n,total:n.length,capped:!1};return{rows:n.slice(0,e),total:n.length,capped:!0}}function ve(n,e){return n.length===e.length&&n.every((f,t)=>f===e[t])}var du=new Set([\"loops\",\"groups\",\"findings\",\"plans\"]),hu=new Set(Be);function kn(n){try{return decodeURIComponent(n)}catch{return n}}function wu(){return{projectId:null,section:\"loops\",loopName:null,tab:\"overview\",groupId:null,statuses:[],query:\"\"}}function De(n,e){if(!e)return;for(let f of e.split(\"&\")){if(!f)continue;let t=f.indexOf(\"=\");if(t===-1)continue;let i=kn(f.slice(0,t)),u=kn(f.slice(t+1));if(i===\"status\")n.statuses=u.split(\",\").map((c)=>c.trim()).filter((c)=>c!==\"\");else if(i===\"q\")n.query=u}}function he(n){let e=wu(),f=(n||\"\").replace(/^#/,\"\");if(!f)return e;let t=f.indexOf(\"?\"),i=t===-1?f:f.slice(0,t),u=t===-1?\"\":f.slice(t+1),c=i.indexOf(\"/\"),l,r;if(c===-1)l=i,r=\"\";else l=i.slice(0,c),r=i.slice(c+1);if(l)e.projectId=kn(l);if(!r)return De(e,u),e;let d=r.split(\"/\").filter((O)=>O!==\"\");if(d.length===0)return De(e,u),e;let w=kn(d[0]);if(w===\"loop\"){if(d.length>=2){if(e.loopName=kn(d[1]),d.length>=3){let O=kn(d[2]);if(hu.has(O))e.tab=O}}}else if(du.has(w)){if(e.section=w,w===\"groups\"&&d.length>=2)e.groupId=kn(d[1])}else e.loopName=w;return De(e,u),e}function we(n){if(!n.projectId)return\"\";let e=\"#\"+encodeURIComponent(n.projectId);if(n.section===\"loops\"){if(n.loopName){if(e+=\"/loop/\"+encodeURIComponent(n.loopName),n.tab!==\"overview\")e+=\"/\"+n.tab}}else if(n.section===\"groups\"){if(e+=\"/groups\",n.groupId)e+=\"/\"+encodeURIComponent(n.groupId)}else e+=\"/\"+n.section;let f=[];if(n.statuses.length>0)f.push(\"status=\"+n.statuses.map((t)=>encodeURIComponent(t)).join(\",\"));if(n.query)f.push(\"q=\"+encodeURIComponent(n.query));if(f.length>0)e+=\"?\"+f.join(\"&\");return e}function it(n,e){let t=\"#\"+(location.hash||\"\").replace(/^#/,\"\"),i=\"#\"+n.replace(/^#/,\"\");if(t!==i)e.current=!0,location.hash=n}function m(n){if(!n||n===0)return\"\";let e=new Date(n),f=(r)=>r<10?\"0\"+r:String(r),t=f(e.getMonth()+1),i=f(e.getDate()),u=e.getFullYear(),c=e.getHours(),l=c>=12?\"PM\":\"AM\";if(c=c%12,c===0)c=12;return t+\"-\"+i+\"-\"+u+\" \"+c+\":\"+f(e.getMinutes())+\" \"+l}function Pn(n){return\"status-badge status-\"+n}function oe(n){return\"section-status section-\"+n}function ut(n){return\"feature-stage feature-stage-\"+n}function ct(n,e,f,t){if(!(e.size===0||e.has(n.status)))return!1;if(!f)return!0;let u=f.toLowerCase(),c=n.worktreeBranch??\"\";return((n.loopName||\"\")+\" \"+c+\" \"+(t||\"\")).toLowerCase().indexOf(u)!==-1}function lt(n,e){if(e===\"recent\")return[...n];let f=[...n];if(e===\"cost\")f.sort((t,i)=>{let u=t.usage?.totalCost??null,c=i.usage?.totalCost??null;if(u===null&&c===null)return 0;if(u===null)return 1;if(c===null)return-1;return c-u});else if(e===\"duration\")f.sort((t,i)=>{let u=$e(t.loop.startedAt,t.loop.completedAt??void 0);return $e(i.loop.startedAt,i.loop.completedAt??void 0)-u});else if(e===\"findings\")f.sort((t,i)=>i.findings.length-t.findings.length);return f}function rt(n){return JSON.stringify(n.projects)}function Fn(n){return n.projectDir||n.projectId||\"\"}function Gn(n,e){let f=Fn(e);return n.get(f)??f}function Bn(n){return n.completedAt||n.startedAt||0}function $t(n){let e=new Map;for(let i of n){if(i===\"\"){e.set(i,[]);continue}let u=i.split(\"/\"),c=u.length;while(c>0&&u[c-1]===\"\")c--;e.set(i,c>0?u.slice(0,c):[i])}let f=new Map;for(let i of n){let u=e.get(i);f.set(i,u.length?u[u.length-1]:i)}let t=new Map;for(let i of n){let u=f.get(i),c=t.get(u);if(c)c.push(i);else t.set(u,[i])}for(let[,i]of t){let u=[...new Set(i)];if(u.length<2)continue;let c=1;while(c<1024){let l=new Set,r=!0,d=!0;for(let w of u){let O=e.get(w),k=Math.max(0,O.length-c),y=O.slice(k).join(\"/\");if(l.has(y))r=!1;else l.add(y);if(k>0)d=!1}if(r||d)break;c++}for(let l of u){let r=e.get(l),d=Math.max(0,r.length-c);f.set(l,r.slice(d).join(\"/\"))}}return f}function Le(n){let e=[],f=[];for(let t of n)if(t.severity===\"bug\")e.push(t);else f.push(t);return{bugs:e,warnings:f}}function dt(n){if(n.bugs.length>0)return\"bug\";if(n.warnings.length>0)return\"warn\";return\"clean\"}function Zn(n,e){return n+\" \"+e+(n===1?\"\":\"s\")}function fn(n){return String(n+1)}function Me(n,e){if(!e||e<=0)return 0;return Math.max(0,Math.min(100,n/e*100))}function ht(n){let e=n.file+\":\"+n.line+\" — \"+n.description;if(n.scenario)e+=\" (\"+n.scenario+\")\";return e}function wt(n,e){return\" \"+n+\": $\"+e.cost.toFixed(6)+\", \"+e.inputTokens+\" in / \"+e.outputTokens+\" out (reasoning: \"+e.reasoningTokens+\", cache R: \"+e.cacheReadTokens+\" W: \"+e.cacheWriteTokens+\"), messages: \"+e.messageCount}function Rn(n){if(!Number.isFinite(n)||n<=0)return\"0\";if(n>=1e6)return(n/1e6).toFixed(1).replace(/\\.0$/,\"\")+\"M\";if(n>=1000)return(n/1000).toFixed(1).replace(/\\.0$/,\"\")+\"k\";return String(n)}function vn(n){if(!Number.isFinite(n)||n<=0)return\"$0\";return\"$\"+(n>=1?n.toFixed(2):n.toFixed(4))}function yt(n){let e=[{label:\"Input\",value:n.totalInputTokens,color:\"var(--seg-input)\"},{label:\"Output\",value:n.totalOutputTokens,color:\"var(--seg-output)\"},{label:\"Reasoning\",value:n.totalReasoningTokens,color:\"var(--seg-reasoning)\"},{label:\"Cache R\",value:n.totalCacheReadTokens,color:\"var(--seg-cache-read)\"},{label:\"Cache W\",value:n.totalCacheWriteTokens,color:\"var(--seg-cache-write)\"}],f=e.reduce((t,i)=>t+Math.max(0,i.value),0);return e.map((t)=>({...t,pct:f>0?Math.max(0,t.value)/f*100:0}))}var yu={code:\"execution\",auditor:\"auditor\",unknown:\"other\"};function bt(n){let f=nt.map((i)=>{let u={role:yu[i],cost:0,messageCount:0,tokens:0,pct:0},c=n.byRole[i];if(!c)return u;return u.cost=c.cost,u.messageCount=c.messageCount,u.tokens=c.inputTokens+c.outputTokens+c.reasoningTokens,u}).filter((i)=>i.role!==\"other\"||i.cost>0||i.messageCount>0),t=f.reduce((i,u)=>Math.max(i,u.cost),0);for(let i of f)i.pct=t>0?i.cost/t*100:0;return f}function xt(n){let e=Object.keys(n.byModel).map((t)=>({model:t,...n.byModel[t]})),f=e.reduce((t,i)=>Math.max(t,i.cost),0);return e.sort((t,i)=>i.cost-t.cost).map((t)=>({model:t.model,cost:t.cost,inputTokens:t.inputTokens,outputTokens:t.outputTokens,messageCount:t.messageCount,pct:f>0?t.cost/f*100:0}))}function Ot(n,e){if(!n)return\"\";let f=$e(n,e??void 0);return f>0?In(f):\"\"}function ae(n){return n>=1000?In(Math.floor(n/1000)):n+\"ms\"}function On(n){if(!n||n===0)return\"\";let e=Date.now()-n,f=Math.floor(e/1000);if(f<60)return f+\"s ago\";let t=Math.floor(f/60);if(t<60)return t+\"m ago\";let i=Math.floor(t/60);if(i<24)return i+\"h ago\";let u=Math.floor(i/24);if(u<30)return u+\"d ago\";return m(n)}var bu=/([\\s\\S]*?)<\\/h\\1>/g;function gt(n){return n.replace(/<[^>]+>/g,\"\").replace(/&[a-z#0-9]+;/gi,\"\").trim()}function xu(n){return pf(gt(n)).replace(/[_-]+/g,\"-\").replace(/^-+|-+$/g,\"\")||\"heading\"}function Ou(n){if(!//.test(n))return{html:n,outline:[]};let e=[];return{html:n.replace(bu,(t,i,u)=>{let c=Number(i),l=xu(u);return e.push({depth:c,id:l,text:gt(u)}),`${u}`}),outline:e}}function Nt(n){if(!n)return{html:\"\",outline:[]};let e=Dn.get(n);if(e!==void 0)return e;let f=globalThis.marked;if(!f)return{html:\"\",outline:[]};let{html:t,outline:i}=Ou(f.parse(n)),u={html:t,outline:i};if(Dn.size>=gu){let c=Dn.keys().next().value;if(c!==void 0)Dn.delete(c)}return Dn.set(n,u),u}function kt(n){return Nt(n).html}function Pt(n){return Nt(n)}var Dn=new Map,gu=200;function ye(n){let e=[\"overview\"];if(n.loop.status===\"running\")e.push(\"live\");if(e.push(\"timeline\"),n.sectionCount>0)e.push(\"sections\");if(e.push(\"findings\"),n.hasPlan)e.push(\"plan\");return e.push(\"usage\"),e}var et=4000;function Nu(n){let e=S(n?.title);if(e)return e;let f=ln(n?.input);if(!f)return;let t=S(f.command)??S(f.filePath)??S(f.pattern)??S(f.path);if(t)return t;let i=Object.keys(f);return i.length>0?`${i.length} argument${i.length===1?\"\":\"s\"}`:void 0}function ku(n){let e=S(n?.output)??S(n?.error);if(!e)return;return e.length>et?`…${e.slice(-et)}`:e}function ln(n){return n&&typeof n===\"object\"&&!Array.isArray(n)?n:null}function S(n){return typeof n===\"string\"?n:void 0}function Et(n){let e=ln(n);if(!e)return null;let f=S(e.id),t=S(e.messageID),i=S(e.type);if(!f||!t||!i)return null;if(i===\"text\"){let u=S(e.text)??\"\";if(e.synthetic===!0)return null;return{id:f,messageID:t,type:i,text:u}}if(i===\"tool\"){let u=ln(e.state);return{id:f,messageID:t,type:i,tool:S(e.tool)??\"tool\",status:S(u?.status)??\"pending\",title:Nu(u),output:ku(u)}}return null}function Vt(n){if(!Array.isArray(n))return[];let e=[];for(let f of n){let t=ln(f),i=ln(t?.info),u=S(i?.id),c=S(i?.role);if(!u||!c)continue;let l=[],r=t?.parts;if(Array.isArray(r))for(let d of r){let w=Et(d);if(w){let{messageID:O,...k}=w;l.push(k)}}e.push({id:u,role:c,parts:l})}return e}function Ct(n,e){let f=ln(e),t=S(f?.type),i=ln(f?.properties);if(!t||!i)return n;if(t===\"message.updated\"){let u=ln(i.info),c=S(u?.id),l=S(u?.role);if(!c||!l)return n;let r=n.findIndex((w)=>w.id===c);if(r===-1)return[...n,{id:c,role:l,parts:[]}];if(n[r].role===l)return n;let d=[...n];return d[r]={...d[r],role:l},d}if(t===\"message.removed\"){let u=S(i.messageID);if(!u)return n;let c=n.filter((l)=>l.id!==u);return c.length===n.length?n:c}if(t===\"message.part.updated\"){let u=Et(i.part);if(!u)return n;let{messageID:c,...l}=u,r=n.findIndex((y)=>y.id===c);if(r===-1)return[...n,{id:c,role:\"assistant\",parts:[l]}];let d=n[r],w=d.parts.findIndex((y)=>y.id===l.id),O=w===-1?[...d.parts,l]:d.parts.map((y,$)=>$===w?l:y),k=[...n];return k[r]={...d,parts:O},k}if(t===\"message.part.removed\"){let u=S(i.messageID),c=S(i.partID);if(!u||!c)return n;let l=n.findIndex((O)=>O.id===u);if(l===-1)return n;let r=n[l],d=r.parts.filter((O)=>O.id!==c);if(d.length===r.parts.length)return n;let w=[...n];return w[l]={...r,parts:d},w}return n}function At(n){let e=ln(n),f=S(e?.type),t=ln(e?.properties);if(f===\"session.idle\")return\"idle\";if(f===\"session.status\"){let i=ln(t?.status);return S(i?.type)??null}return null}function Kt(n,e,f,t,i=\"coding\"){let u=[],c=n.findIndex((O)=>O.createdAt>=e),l=c===-1?[]:n.slice(c);if(f!==null)l=l.filter((O)=>O.createdAt<=f);let r;if(l.length>0&&l[0].eventType===\"restart\"&&l[0].toPhase!==null)r=l[0].toPhase;else if(l.length>0&&l[0].eventType!==\"restart\")r=l[0].fromPhase;else r=i;let d=e;for(let O of l){if(O.eventType===\"restart\"){if(O.toPhase!==null)r=O.toPhase;continue}if(r===\"\")r=O.fromPhase,d=Math.max(d,O.createdAt);let k=O.createdAt,y=Math.max(d,k);if(u.push({phase:r,startedAt:d,endedAt:y,durationMs:y-d,open:!1}),O.toPhase===null)r=\"\",d=y;else r=O.toPhase,d=y}if(r!==\"\"){let k=Math.max(d,f??t);u.push({phase:r,startedAt:d,endedAt:f,durationMs:k-d,open:f===null})}let w=n.length>0&&n[0].createdAt>=e&&n[0].createdAt>e+1000;if(w&&u.length>0)u[0]={...u[0],phase:\"\"};return{spans:u,truncated:w}}function Pu(n){let e={};for(let f of n){if(!f.phase)continue;e[f.phase]=(e[f.phase]??0)+f.durationMs}return e}var Eu={coding:\"Coding\",auditing:\"Auditing\",final_auditing:\"Final audit\",final_audit_fix:\"Final audit fix\",post_action:\"Post-action\"};function gn(n){if(n===\"\")return\"Unknown\";return Eu[n]??n}function Yt(n){let e=Pu(n),f=Object.keys(e).map((i)=>({phase:i,label:gn(i),durationMs:e[i],pct:0})),t=f.reduce((i,u)=>i+u.durationMs,0);for(let i of f)i.pct=t>0?Math.round(i.durationMs/t*100):0;return f.sort((i,u)=>u.durationMs-i.durationMs)}function Vu(n,e,f){let t=[];for(let i=0;i=e)c=Math.max(0,u.createdAt-e);else if(u.createdAt=e)c=Math.max(0,u.createdAt-e);else c=null}t.push({transition:u,elapsedMs:c})}return t.reverse(),t}function Wt(n){if(n.length===0)return{count:0,lastAt:null,lastSection:null};let e=n[0];for(let f of n)if(f.createdAt>e.createdAt||f.createdAt===e.createdAt&&f.id>e.id)e=f;return{count:n.length,lastAt:e.createdAt,lastSection:e.appliedAtSection}}function st(n){let e=new Set;for(let f of n)e.add(f.appliedAtSection);return e}function zt(n,e,f,t){let i=[...Vu(n,f,t).map((u)=>({kind:\"transition\",transition:u.transition,elapsedMs:u.elapsedMs})),...e.map((u)=>({kind:\"amendment\",amendment:u,elapsedMs:null}))];return i.sort((u,c)=>{let l=u.kind===\"transition\"?u.transition.createdAt:u.amendment.createdAt,r=c.kind===\"transition\"?c.transition.createdAt:c.amendment.createdAt;if(r!==l)return r-l;if(u.kind!==c.kind)return u.kind===\"transition\"?-1:1;let d=u.kind===\"transition\"?u.transition.id:u.amendment.id;return(c.kind===\"transition\"?c.transition.id:c.amendment.id)-d}),i}var Cu=[{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}],be=110,xe=36,Ht=655,Ft=160,Au=[{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→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 Ku(n){if(n.toPhase===null)return n.fromPhase+\"→terminal\";return n.fromPhase+\"→\"+n.toPhase}function Yu(n){return n.toPhase??n.status??\"\"}function me(n){let e=Array.isArray(n)?n[0]:n,f=e instanceof SVGSVGElement?e.firstElementChild:null;if(!(f instanceof SVGElement))throw Error(\"Expected an SVG child\");return f}function Gt(n){let e=()=>n.loop(),f=g(()=>e().status===\"running\"),t=g(()=>{let l=new Map;for(let r of n.transitions()){let d=Ku(r);l.set(d,(l.get(d)??0)+1)}return l}),i=Au.map((l)=>me(h`\n \n \n ${()=>{let r=t().get(l.key);return r?String(r):\"\"}}\n \n `)),u=Cu.map((l)=>{let r=l.cx-be/2,d=l.cy-xe/2;return me(h`\n \"mg-node\"+(e().phase===l.phase&&f()?\" mg-node-active\":\"\")}>\n \n ${l.label}\n \n `)}),c=me(h`\n \n \n ${()=>e().status}\n \n `);return h`
\n \n \n \n \n \n \n\n ${i}\n ${u}\n ${()=>f()?\"\":c}\n \n\n
\n
Transition history
\n
\n ${()=>n.transitions().slice(-20).reverse().map((l)=>h`
\n ${l.eventType}\n ${l.fromPhase} → ${Yu(l)}\n ${On(l.createdAt)}\n
`)}\n
\n
\n
`}function _t(n){let[e,f]=A(n.query()),t=null,i=(c)=>{let l=c.target.value;if(f(l),t)clearTimeout(t);t=setTimeout(()=>{t=null,n.onChangeQuery(l)},250)};wn(()=>{if(t)clearTimeout(t),t=null}),D(()=>{n.externalNav();let c=v(()=>n.query());if(t!==null)clearTimeout(t),t=null;f(c)});let u=g(()=>{let c=n.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 h`
\n ${()=>u().map(({label:c,key:l,value:r})=>{let w=`badge badge-filter${n.statuses().has(l)?\" badge-active\":\"\"}`;return h`n.onToggleStatus(l)}>${c}: ${r}`})}\n \n \n
`}function Wu(n){return h`
${()=>{let e=n.generatedAt();return e>0?`Last updated: ${new Date(e).toLocaleString()}`:\"\"}}
`}function jt(n){return h`
\n

Forge Dashboard

\n
${()=>n.breadcrumb()}
\n ${Wu({generatedAt:n.generatedAt})}\n
`}function Jt(n){let e=g(()=>n.entries().map((f)=>{let t=f.loops.some((c)=>c.loop.status===\"running\"),i=Fn(f.proj),u=Gn(n.labels(),f.proj);return h`
n.onSelect(f.proj.projectId)}>\n ${t?h``:\"\"}\n ${u}\n ${f.loops.length}\n
`}));return h`
${()=>e()}
`}function su(n,e){let f=0,t=0,i=0,u=[],c=[];for(let l of n){let r=Gn(e,l.proj);for(let d of l.loops){t++,i+=d.bugCount;let w=d.loop.status,O=Bn(d.loop);if(w===\"running\")f++,u.push({projectId:l.proj.projectId,label:r,loopName:d.loop.loopName,phase:d.loop.phase||\"\"});c.push({projectId:l.proj.projectId,label:r,loopName:d.loop.loopName,status:w,when:O})}}return c.sort((l,r)=>r.when-l.when),{runningCount:f,loopCount:t,bugCount:i,runningCards:u,recentRows:c.slice(0,8)}}function Ut(n){let e=g(()=>su(n.entries(),n.labels())),f=g(()=>e().runningCount),t=g(()=>e().loopCount),i=g(()=>e().bugCount),u=g(()=>e().runningCards),c=g(()=>e().recentRows);return h`
\n
\n

All repositories

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

Running

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

Recent activity

\n
\n ${c().map((l)=>h`
n.onOpenLoop(l.projectId,l.loopName)}>\n Pn(l.status)}>${l.status}\n ${l.label}\n ${l.loopName}\n ${m(l.when)}\n
`)}\n
\n
`:\"\"}\n
`}function zu(n){let[e,f]=A(!1),[t,i]=A(\"\"),[u,c]=A(0),l=g(()=>{let N=t().trim().toLowerCase(),V=n.loops();return N?V.filter((W)=>W.name.toLowerCase().indexOf(N)!==-1):V}),r=g(()=>de(l(),tt,!1)),d=()=>{if(e())return;i(\"\");let N=r().rows.findIndex((V)=>V.name===n.loopName);c(N<0?0:N),f(!0)},w=()=>{f(!1),i(\"\"),c(0)},O=(N)=>{if(w(),b.blur(),N!==n.loopName)n.onSelect(N)},k=h`e()?\"display:block\":\"display:none\"}\n onmousedown=${(N)=>N.preventDefault()}\n >\n ${()=>r().rows.map((N,V)=>h`\"loop-picker-option\"+(V===u()?\" loop-picker-option-active\":\"\")+(N.name===n.loopName?\" loop-picker-option-current\":\"\")}\n onclick=${()=>O(N.name)}\n >\n ${N.name}\n ${N.whenLabel}\n `)}\n ${()=>r().total===0?h`
No matching loops
`:\"\"}\n ${()=>r().capped?h`
${()=>\"Showing \"+r().rows.length+\" of \"+r().total+\" — type to filter\"}
`:\"\"}\n `,y=()=>{k.querySelector(\".loop-picker-option-active\")?.scrollIntoView?.({block:\"nearest\"})},$=(N)=>{if(N.key===\"ArrowDown\"||N.key===\"ArrowUp\"){if(N.preventDefault(),!e()){d();return}let V=r().rows.length;if(V===0)return;let W=N.key===\"ArrowDown\"?1:-1;c((K)=>(K+W+V)%V),y();return}if(N.key===\"Enter\"){N.preventDefault();let V=r().rows[u()];if(V)O(V.name);return}if(N.key===\"Escape\")N.preventDefault(),w(),b.blur()},b=h`e()?t():n.loopName}\n onfocus=${d}\n oninput=${(N)=>{i(N.target.value),c(0),f(!0)}}\n onkeydown=${$}\n onblur=${w}\n />`;return h`\n ${b}\n {if(N.preventDefault(),e())w(),b.blur();else b.focus()}}>▾\n ${k}\n `}function qt(n){if(n.loopName)return h`
\n ← ${n.repoLabel}\n /\n ${zu({loopName:n.loopName,loops:n.loops,onSelect:n.onSelectLoop})}\n ${()=>{let e=n.loopNav();return e?h`\n \n ${e.index+1} of ${e.total}\n \n `:\"\"}}\n
`;return h`
\n ← Repositories\n /\n ${n.repoLabel}\n ${n.projectDir}\n
`}function Xt(n){let e=[{key:\"loops\",label:\"Loops\",count:n.counts.loops},{key:\"groups\",label:\"Groups\",count:n.counts.groups},{key:\"findings\",label:\"Findings\",count:n.counts.findings},{key:\"plans\",label:\"Plans\",count:n.counts.plans}];return h`
\n ${e.map((f)=>{let i=`section-nav-item${f.key===n.section?\" section-nav-active\":\"\"}`;return h`
n.onNavigate(f.key)}>\n ${f.label}\n ${f.count}\n
`})}\n
`}function ff(n){return h`
\n ${()=>\"Showing \"+n.shown()+\" of \"+n.total()+\" \"+n.noun}\n ${n.onShowAll?h``:\"\"}\n
`}function ef(n){let e=()=>Me(n.current(),n.total());return h`\n \"width:\"+e()+\"%\"}>\n ${()=>n.formatCurrent?n.formatCurrent(n.current()):String(n.current())}/${()=>n.total()}\n `}var Hu={sm:4,md:14,lg:22};function tf(n){let e=Hu[n.variant],f=g(()=>n.spans().filter((u)=>u.phase!==\"\")),t=g(()=>Math.max(0,f().reduce((u,c)=>u+c.durationMs,0))),i=g(()=>n.spans().some((u)=>u.phase===\"\"));return h`
\"phase-bar phase-bar-\"+n.variant+(i()?\" phase-bar-trunc\":\"\")} data-truncated=${()=>i()?\"true\":\"false\"} style=${()=>\"height:\"+e+\"px\"}>\n ${()=>i()?h`\\u22ef`:\"\"}\n ${()=>{let u=f(),c=t();return u.map((l)=>{let r=c>0?l.durationMs/c*100:0,d=\"phase-seg\"+(l.open?\" phase-seg-open\":\"\"),w=ae(l.durationMs);return h`
`})}}\n
`}function St(n){return h`
\"legend\"+(n.phase?\" legend-phase\":\"\")}>\n ${()=>n.items().map((e)=>h`
\n \n ${e.label}\n ${e.value}\n
`)}\n
`}function Qt(n){let e=g(()=>Yt(n.spans()));return St({phase:!0,items:()=>e().map((f)=>({phase:f.phase,label:f.label,value:ae(f.durationMs)+\" · \"+f.pct+\"%\"}))})}function uf(n,e){let f=n.loop;return Kt(n.transitions??[],f.startedAt,f.completedAt,f.completedAt===null?e():0,f.phase)}function Tt(n){let[e,f]=A(!1),t=g(()=>de(n.loops(),Re,e()));return h`
\n \n \n \n \n \n \n \n \n ${()=>t().rows.map((i)=>Fu({dashLoop:i,now:n.now,onOpen:n.onOpen}))}\n \n
StatusLoopPhasePhase spanIterSectionsFindingsCostDurationUpdated
\n ${()=>t().capped?ff({shown:()=>t().rows.length,total:()=>t().total,noun:\"loops\",onShowAll:()=>f(!0)}):\"\"}\n
`}function Fu(n){let e=()=>n.dashLoop.loop,f=()=>n.dashLoop,t=g(()=>Le(f().findings)),i=g(()=>uf(f(),n.now).spans);return h`n.onOpen(n.dashLoop.loop.loopName)}>\n Pn(e().status)}>${()=>e().status}\n ${()=>e().loopName}\n ${()=>e().completedAt===null?h`e().phase}>${()=>gn(e().phase)}`:h``}\n ${()=>tf({spans:i,variant:\"sm\"})}\n ${ef({current:()=>e().iteration,total:()=>e().maxIterations})}\n ${()=>e().totalSections>0?ef({current:()=>e().currentSectionIndex,total:()=>e().totalSections,formatCurrent:fn}):h``}\n ${()=>{let u=t();if(u.bugs.length===0&&u.warnings.length===0)return h``;return h`\n ${u.bugs.length>0?h`${Zn(u.bugs.length,\"bug\")}`:\"\"}\n ${u.bugs.length>0&&u.warnings.length>0?\" · \":\"\"}\n ${u.warnings.length>0?h`${Zn(u.warnings.length,\"warn\")}`:\"\"}\n `}}\n ${()=>f().usage?vn(f().usage.totalCost):h``}\n ${()=>f().duration||\"\"}\n ${()=>m(Bn(e()))}\n `}function on(n){let e=()=>typeof n.label===\"function\"?n.label():n.label,[f,t]=A(!1),i=()=>t((l)=>!l),u=g(()=>Pt(n.src()||\"\")),c=g(()=>u().outline.filter((l)=>l.depth<=3));return h`
\n
\n f()?\"false\":\"true\"}\n onclick=${i}\n onkeydown=${(l)=>{if(l.key!==\"Enter\"&&l.key!==\" \")return;l.preventDefault(),i()}}\n >\n ${()=>f()?\"▸\":\"▾\"}\n

${()=>e()}

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

Sections

\n ${()=>{let u=i();if(u)return h`
\n
f(null)}>← Back to sections
\n
\n #${fn(u.sectionIndex)}\n ${()=>u.title}\n oe(u.status)}>${()=>u.status}\n
\n ${Zu({sec:u})}\n
`;return h`
\n ${n.sections().map((c)=>_u({sec:c,onOpen:()=>f(c.sectionIndex),adjusted:()=>t().has(c.sectionIndex)}))}\n
`}}\n
`}function M(n){let e=()=>\"ldh-cell\"+(n.size===\"stat\"?\" ldh-cell-stat\":\"\")+(n.tone?\" ldh-cell-\"+n.tone():\"\"),f=()=>\"ldh-cell-value\"+(n.size===\"stat\"?\" ldh-cell-value-stat\":\"\");return h`
\n ${n.label}\n n.title?n.title():\"\"}>${()=>n.value()}\n
`}function nf(n){return h`
\n
${n.label}
\n
${()=>n.children.map((e)=>e)}
\n
`}function Zt(n){let e=()=>Me(n.current(),n.total());return h`
\n
\n ${n.label}\n ${()=>n.formatCurrent?n.formatCurrent(n.current()):String(n.current())} / ${()=>n.total()}\n
\n
\n
\"width:\"+e()+\"%\"}>
\n
\n
`}function Ju(n){let e=()=>n.dashLoop.loop,f=()=>n.dashLoop,t=g(()=>!!e().completedAt),i=g(()=>e().completedAt===null),u=g(()=>e().totalSections>0),c=g(()=>Wt(f().amendments??[])),l=g(()=>{let k=e().terminationReason;if(!k)return null;return k.trim().toLowerCase()===e().status.toLowerCase()?null:k}),r=g(()=>dt(n.split())),d=g(()=>!!n.dashLoop.usage),w=g(()=>{let k=n.split();if(k.bugs.length===0&&k.warnings.length===0)return\"No findings\";let y=[];if(k.bugs.length>0)y.push(Zn(k.bugs.length,\"bug\"));if(k.warnings.length>0)y.push(Zn(k.warnings.length,\"warning\"));return y.join(\" · \")}),O=g(()=>{let k=[M({label:\"Started\",value:()=>m(e().startedAt),size:\"stat\"})];if(t())k.push(M({label:\"Completed\",value:()=>m(e().completedAt),size:\"stat\"}));let y=[M({label:\"Execution model\",value:()=>e().executionModel??\"—\",title:()=>e().executionModel??\"\",size:\"stat\"}),M({label:\"Auditor model\",value:()=>{let b=e().auditorModel??\"—\",N=e().auditorFallbackIndex??0;return N>0?`${b} (fallback ${N})`:b},title:()=>e().auditorModel??\"\",size:\"stat\"})],$=[M({label:\"Branch\",value:()=>e().worktreeBranch??\"—\",title:()=>e().worktreeBranch??\"\",size:\"stat\"}),M({label:\"Sandbox\",value:()=>e().sandbox?\"on\":\"off\",size:\"stat\"}),M({label:\"Kind\",value:()=>e().kind??\"—\",size:\"stat\"}),M({label:\"Audits\",value:()=>String(e().auditCount),size:\"stat\"}),M({label:\"Errors\",value:()=>String(e().errorCount),size:\"stat\"})];if(d())$.push(M({label:\"Messages\",value:()=>String(n.dashLoop.usage.totalMessageCount),size:\"stat\"}));return[nf({label:\"Timing\",children:k}),nf({label:\"Models\",children:y}),nf({label:\"Environment\",children:$})]});return h`
\n
\n Pn(e().status)}>${()=>e().status}\n

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

\n ${()=>c().count>0?h`n.onSelectTab(\"plan\")}\n >${()=>\"Plan adjusted \"+c().count+\"× · last \"+On(c().lastAt)+\" @ section \"+fn(c().lastSection)}`:\"\"}\n ${()=>i()?h`${gn(e().phase)}`:\"\"}\n
\n\n
\n ${M({label:\"Duration\",value:()=>f().duration||\"—\"})}\n ${M({label:\"Cost\",value:()=>d()?vn(n.dashLoop.usage.totalCost):\"—\"})}\n ${M({label:\"Iterations\",value:()=>e().iteration+\" / \"+e().maxIterations})}\n ${()=>u()?M({label:\"Sections\",value:()=>fn(e().currentSectionIndex)+\" / \"+e().totalSections}):\"\"}\n ${M({label:\"Findings\",value:()=>w(),tone:()=>r()})}\n
\n\n
\n ${Zt({label:\"Iterations\",current:()=>e().iteration,total:()=>e().maxIterations})}\n ${()=>u()?Zt({label:\"Sections\",current:()=>e().currentSectionIndex,total:()=>e().totalSections,formatCurrent:fn}):\"\"}\n
\n\n
${()=>O()}
\n\n ${()=>l()?h`
\"ldh-banner ldh-banner-\"+e().status}>${()=>l()}
`:\"\"}\n
`}function Uu(n){let e=()=>n.usage(),f=g(()=>yt(e())),t=g(()=>xt(e())),i=g(()=>t().length>0),u=g(()=>bt(e()));return h`
\n

Usage

\n\n
\n
Role split
\n
\n ${()=>u().map((c)=>h`
c.role}>\n
\n c.role}>${c.role}\n ${()=>vn(c.cost)}\n
\n
\n
\"width:\"+c.pct+\"%\"}>
\n
\n
${()=>c.messageCount+\" msg · \"+Rn(c.tokens)+\" tok\"}
\n
`)}\n
\n
\n\n
\n
Token composition
\n
\n ${()=>f().map((c)=>c.pct>0?h`
`:\"\")}\n
\n ${St({items:()=>f().map((c)=>({color:c.color,label:c.label,value:Rn(c.value)}))})}\n
\n\n ${()=>i()?h`
\n
Cost by model
\n
\n ${()=>t().map((c)=>h`
\n
\n ${c.model}\n ${vn(c.cost)}\n
\n
\n
\n
\n
\n ${Rn(c.inputTokens)} in / ${Rn(c.outputTokens)} out · ${c.messageCount} msg\n
\n
`)}\n
\n
`:\"\"}\n `}function qu(n){let e=n.amendment,[f,t]=A(null),[i,u]=A(!1),[c,l]=A(\"\"),[r,d]=A(!1);D(()=>{if(n.expanded()){if(r()||f()!==null)return;d(!0),l(\"\"),u(!0);let $=new URLSearchParams({project:e.projectId,loop:e.loopName,id:String(e.id)});fetch(\"/api/amendment?\"+$.toString()).then(async(b)=>{if(!b.ok){l(await b.text().catch(()=>\"\")||`Failed (status ${b.status})`);return}let N=await b.json();t(N)}).catch((b)=>l(b instanceof Error?b.message:String(b))).finally(()=>u(!1));return}if(r()&&!i()&&f()===null)d(!1)});let w=()=>{let $=[];if(e.summary.added>0)$.push({cls:\"amendment-count-add\",label:\"+\"+e.summary.added});if(e.summary.removed>0)$.push({cls:\"amendment-count-remove\",label:\"−\"+e.summary.removed});if(e.summary.modified>0)$.push({cls:\"amendment-count-modified\",label:\"~\"+e.summary.modified});return $},O=()=>(f()?.sections??[]).filter(($)=>$.change!==\"unchanged\"),k=()=>f()!==null&&O().length===0,y=($)=>{if($.kind===\"add\")return\"+ \"+$.text;if($.kind===\"remove\")return\"- \"+$.text;if($.kind===\"context\")return\" \"+$.text;return $.text};return h`
\n
\n ${()=>On(e.createdAt)}\n ${()=>\"applied @ section \"+fn(e.appliedAtSection)}\n ${()=>e.source}\n ${()=>w().length>0?h`${w().map(($)=>h`${$.label}`)}`:\"\"}\n ${()=>e.rationale}\n ${()=>n.expanded()?\"▾\":\"▸\"}\n
\n
n.expanded()?\"display:block\":\"display:none\"}>\n
\n ${()=>i()?h`
Loading diff…
`:\"\"}\n ${()=>c()?h`
${c()}
`:\"\"}\n ${()=>k()?h`
No section changes recorded.
`:\"\"}\n ${()=>O().map(($)=>h`
\n
\n ${()=>\"#\"+fn($.index)}\n ${()=>$.change}\n ${()=>$.title}\n ${()=>$.previousTitle!==null?h`${()=>\"was: \"+$.previousTitle}`:\"\"}\n
\n ${()=>$.lines.length===0?h`
Title changed only.
`:$.lines.map((b)=>h`
${y(b)}
`)}\n
`)}\n
\n
\n
`}function Xu(n){let e=()=>n.amendments(),[f,t]=A(null);return h`
\n

Plan amendments

\n
\n ${()=>e().map((i)=>qu({amendment:i,expanded:()=>f()===i.id,onToggle:()=>t(f()===i.id?null:i.id)}))}\n
\n
`}var Su={overview:\"Overview\",live:\"Live\",timeline:\"Timeline\",sections:\"Sections\",findings:\"Findings\",plan:\"Plan\",usage:\"Usage\"};function Qu(n){return h`
\n ${()=>n.tabs().map((e)=>{let f=Su[e];return h`\"tab-item\"+(n.activeTab()===e?\" tab-active\":\"\")} data-tab=\"${e}\" onclick=${()=>n.onSelect(e)}>${f}`})}\n
`}function Tu(n){let e=()=>n.dashLoop,f=()=>e().loop,t=g(()=>!!e().goal),i=g(()=>e().postActionReport??f().completionSummary??null),u=g(()=>e().postActionReport?\"Post-Action Report\":\"Completion Summary\"),c=g(()=>!!i()),l=g(()=>!!e().lastAuditResult),r=g(()=>uf(e(),n.now));return h`
\n ${()=>tf({spans:()=>r().spans,variant:\"md\"})}\n ${()=>Qt({spans:()=>r().spans})}\n ${()=>t()?on({label:\"Goal\",src:()=>e().goal}):\"\"}\n ${Ju({dashLoop:n.dashLoop,split:n.split,onSelectTab:n.onSelectTab})}\n ${()=>c()?on({label:u,src:()=>i()}):\"\"}\n ${()=>l()?on({label:\"Last Audit Result\",src:()=>e().lastAuditResult}):\"\"}\n
`}function Iu(n){let e=n.ev.transition,f=n.ev.elapsedMs,t=f===null?null:Math.floor(f/1000),i=e.toPhase===null?e.status??\"\":e.toPhase;return h`
\n ${On(e.createdAt)}\n ${gn(e.fromPhase)} → ${gn(i)}\n ${e.transitionKind}\n iter ${e.iteration}\n ${()=>e.sectionIndex===null?\"—\":\"sect \"+fn(e.sectionIndex)}\n ${()=>t===null?\"—\":t>0?In(t):\"0s\"}\n
`}function Du(n){let e=n.amendment;return h`
\n ${On(e.createdAt)}\n plan adjusted\n ${()=>\"section \"+fn(e.appliedAtSection)}\n ${()=>e.rationale}\n
`}function Bu(n){let e=()=>n.dashLoop,f=()=>e().loop,t=()=>e().transitions??[],i=g(()=>uf(e(),n.now)),u=g(()=>i().spans),c=g(()=>i().truncated),l=g(()=>zt(t(),e().amendments??[],f().startedAt,c())),[r,d]=A(!1),w=g(()=>{let k=l();return r()?k:k.slice(0,20)}),O=g(()=>r()?0:Math.max(0,l().length-20));return h`
\n ${Gt({loop:()=>f(),transitions:t})}\n ${()=>tf({spans:u,variant:\"lg\"})}\n ${()=>c()?h`
Earlier history was truncated by the 100-row fetch window.
`:\"\"}\n ${()=>Qt({spans:u})}\n\n
\n ${()=>w().map((k)=>k.kind===\"transition\"?Iu({ev:{transition:k.transition,elapsedMs:k.elapsedMs}}):Du({amendment:k.amendment}))}\n ${()=>O()>0?h`
d(!0)}>[ ${()=>O()} earlier events ]
`:\"\"}\n
\n
`}function Ru(n){return ju({sections:()=>n.dashLoop.sections,amendments:()=>n.dashLoop.amendments??[]})}function vu(n){let e=[],f=new Map;for(let t of n){let i=t.sectionIndex??null;if(!f.has(i))f.set(i,[]),e.push(i);f.get(i).push(t)}return e.sort((t,i)=>{if(t===null&&i===null)return 0;if(t===null)return 1;if(i===null)return-1;return t-i}),e.map((t)=>({key:t,label:t===null?\"cross-section\":\"sect \"+fn(t),rows:f.get(t)}))}function It(n){return h`
\n ${n.rows.map((e)=>h`
\"finding finding-\"+e.severity}>\n ${()=>On(e.createdAt)}\n ${()=>ht(e)}\n
`)}\n
`}function Dt(n){let e=()=>we({projectId:n.projectId(),section:\"loops\",loopName:n.loopName,tab:\"overview\",groupId:null,statuses:[],query:\"\"});return h`{if(f.defaultPrevented||f.button!==0||f.metaKey||f.ctrlKey||f.shiftKey||f.altKey)return;f.preventDefault(),n.onOpen(n.loopName)}}>${n.loopName}`}function ou(n){let e=()=>n.dashLoop,f=g(()=>e().findings.length>0),t=g(()=>vu(e().findings));return h`
\n ${()=>f()?t().map((i)=>h`
i.key===null?\"cross\":String(i.key)}>\n
${i.label}
\n ${It({rows:i.rows})}\n
`):h`
No findings recorded for this loop.
`}\n
`}function Lu(n){let e=()=>n.dashLoop,f=g(()=>e().hasPlan),t=g(()=>!!e().amendments&&e().amendments.length>0);return h`
\n ${()=>f()?on({label:\"Plan\",src:()=>e().plan}):h`
No plan recorded for this loop.
`}\n ${()=>t()?Xu({amendments:()=>e().amendments}):\"\"}\n
`}function Mu(n){let e=()=>n.dashLoop,f=g(()=>!!e().usage);return h`
\n ${()=>f()?Uu({usage:()=>e().usage}):h`
No usage recorded for this loop.
`}\n
`}function au(n){let e=()=>n.dashLoop.loop,[f,t]=A([]),[i,u]=A(\"\"),[c,l]=A(\"\"),[r,d]=A(\"\"),[w,O]=A(\"\"),[k,y]=A(\"\"),[$,b]=A(!1),[N,V]=A(\"\"),[W,K]=A(!1),[C,Y]=A(!1),G=!1,Z=[\"\",\"\",\"\",\"\"];D(()=>{let z=[e().executionModel??\"\",e().executionVariant??\"\",e().auditorModel??\"\",e().auditorVariant??\"\"];if(!G&&(z[0]!==Z[0]||z[1]!==Z[1]||z[2]!==Z[2]||z[3]!==Z[3]))l(z[0]),d(z[1]),O(z[2]),y(z[3]);Z=z}),D(()=>{if(!C()||f().length>0)return;let z=new URLSearchParams({project:e().projectId,loop:e().loopName});fetch(\"/api/models?\"+z.toString()).then(async(J)=>{if(!J.ok){u(await J.text().catch(()=>\"\")||`Failed (status ${J.status})`);return}let L=await J.json();t(L.models??[]),u(\"\")}).catch((J)=>u(J instanceof Error?J.message:String(J)))});let Q=(z)=>f().find((J)=>J.id===z)?.variants??[],j=async()=>{if($())return;b(!0),V(\"\"),K(!1);try{let z=await fetch(\"/api/loop/models\",{method:\"POST\",headers:{\"content-type\":\"application/json\"},body:JSON.stringify({projectId:e().projectId,loopName:e().loopName,executionModel:c()||null,executionVariant:r()||null,auditorModel:w()||null,auditorVariant:k()||null})});if(!z.ok){V(await z.text().catch(()=>\"\")||`Failed (status ${z.status})`);return}let J=await z.json(),L=[J.executionModel??\"\",J.executionVariant??\"\",J.auditorModel??\"\",J.auditorVariant??\"\"];l(L[0]),d(L[1]),O(L[2]),y(L[3]),Z=L,G=!1,K(!0)}catch(z){V(z instanceof Error?z.message:String(z))}finally{b(!1)}},a=(z,J,L)=>h`{G=!0,J(q.currentTarget.value),L()}}\n >\n \n ${()=>f().map((q)=>h``)}\n ${()=>z()&&!f().some((q)=>q.id===z())?h``:\"\"}\n `,o=(z,J,L)=>h`Q(z()).length===0}\n onchange=${(q)=>{G=!0,L(q.currentTarget.value)}}\n >\n \n ${()=>Q(z()).map((q)=>h``)}\n `;return h`
\n \n ${()=>{if(!C())return\"\";return h`
\n ${()=>i()?h`
${()=>i()}
`:\"\"}\n \n \n
\n Applies to the next prompt, not the turn in flight.\n \n
\n ${()=>N()?h`
${()=>N()}
`:\"\"}\n ${()=>W()&&!N()?h`
Models updated.
`:\"\"}\n
`}}\n
`}function mu(n){let e=()=>n.part,[f,t]=A(!1),i=()=>!!e().output;return h`
\"live-tool live-tool-\"+(e().status??\"pending\")}>\n \"live-tool-head\"+(i()?\" live-tool-head-clickable\":\"\")}\n disabled=${()=>!i()}\n aria-expanded=${()=>i()?f()?\"true\":\"false\":void 0}\n onclick=${()=>{if(i())t((u)=>!u)}}\n >\n ${()=>i()?f()?\"▾\":\"▸\":\"\"}\n ${()=>e().tool??\"tool\"}\n ${()=>e().title??\"\"}\n ${()=>e().status??\"pending\"}\n \n ${()=>f()&&i()?h`
${()=>e().output}
`:\"\"}\n
`}var pu=8000,nc=48;function ec(){if(typeof document>\"u\")return()=>!0;let[n,e]=A(document.visibilityState!==\"hidden\"),f=()=>e(document.visibilityState!==\"hidden\");return document.addEventListener(\"visibilitychange\",f),wn(()=>document.removeEventListener(\"visibilitychange\",f)),n}function fc(n){let e=()=>n.dashLoop,f=()=>e().loop,[t,i]=A([]),[u,c]=A(\"connecting\"),[l,r]=A(\"\"),[d,w]=A(\"\"),[O,k]=A(\"\"),[y,$]=A(!1),[b,N]=A(\"\"),[V,W]=A(\"stream\"),[K,C]=A(0),[Y,G]=A(0),Z=ec(),Q=g(()=>n.visible()&&Z());D(()=>{if(!Q())return;let H=setInterval(()=>G((I)=>I+1),1000);wn(()=>clearInterval(H))});let j=g(()=>{if(Y(),d()===\"busy\")return!0;let H=K();return H>0&&Date.now()-H{return\"/api/loop/stream?\"+new URLSearchParams({project:f().projectId,loop:f().loopName}).toString()}),o=\"\";D(()=>{let H=f().currentSessionId,I=a();if(!Q()||!H||typeof EventSource>\"u\")return;if(H!==o)o=H,i([]);c(\"connecting\"),r(\"\"),W(\"stream\"),C(0);let B=new EventSource(I);B.addEventListener(\"snapshot\",(Nn)=>{try{let hn=JSON.parse(Nn.data),Vn=Vt(hn.messages);if(i(Vn),c(\"live\"),hn.reason===\"poll\")W(\"poll\"),C(Date.now())}catch{c(\"failed\"),r(\"Could not read the transcript snapshot.\")}}),B.addEventListener(\"event\",(Nn)=>{try{let hn=JSON.parse(Nn.data);i((Mn)=>Ct(Mn,hn)),W(\"stream\"),C(Date.now());let Vn=At(hn);if(Vn)w(Vn)}catch{}}),B.addEventListener(\"failed\",(Nn)=>{try{let hn=JSON.parse(Nn.data);r(hn.message??\"Live stream ended.\")}catch{r(\"Live stream ended.\")}c(\"failed\"),B.close()}),B.onerror=()=>{if(B.readyState===EventSource.CLOSED)c(\"failed\")},wn(()=>B.close())});let z=async()=>{let H=O().trim();if(!H||y())return;$(!0),N(\"\");try{let I=await fetch(\"/api/loop/message\",{method:\"POST\",headers:{\"content-type\":\"application/json\"},body:JSON.stringify({projectId:f().projectId,loopName:f().loopName,text:H})});if(!I.ok){N(await I.text().catch(()=>\"\")||`Failed (status ${I.status})`);return}k(\"\")}catch(I){N(I instanceof Error?I.message:String(I))}finally{$(!1)}},J=g(()=>{if(u()===\"connecting\")return\"connecting\";if(u()===\"failed\")return\"disconnected\";if(j())return V()===\"poll\"?\"working (refreshing)\":\"working\";return V()===\"poll\"?\"refreshing\":d()||\"idle\"}),L=g(()=>{if(u()!==\"live\")return\"live-dot live-dot-\"+u();if(j())return\"live-dot live-dot-working\";return\"live-dot live-dot-idle\"}),q=h`
\n ${()=>{let H=t();if(H.length===0)return h`
${()=>u()===\"live\"?\"No messages in this session yet.\":\"Loading session…\"}
`;return H.map((I)=>h`
\n
${I.role}
\n
\n ${()=>I.parts.map((B)=>B.type===\"text\"?h`
${B.text??\"\"}
`:mu({part:B}))}\n
\n
`)}}\n
`,[dn,En]=A(!0),Ln=(H)=>H.scrollHeight-H.scrollTop-H.clientHeightEn(Ln(q))),D(()=>{if(t(),!dn())return;queueMicrotask(()=>{q.scrollTop=q.scrollHeight})}),h`
\n
\n L()}>\n ${()=>J()}\n ${()=>V()===\"poll\"?h`events unavailable — refreshing every 4s`:\"\"}\n ${()=>f().currentSessionId}\n
\n\n ${()=>l()?h`
${()=>l()}
`:\"\"}\n\n ${au({dashLoop:n.dashLoop})}\n\n ${q}\n\n ${()=>dn()?\"\":h``}\n\n
\n O()}\n oninput=${(H)=>k(H.currentTarget.value)}\n onkeydown=${(H)=>{if(H.key!==\"Enter\"||H.shiftKey)return;H.preventDefault(),z()}}\n >\n void z()}\n disabled=${()=>y()||O().trim().length===0}\n >${()=>y()?\"Sending…\":\"Send\"}\n
\n ${()=>b()?h`
${()=>b()}
`:\"\"}\n
`}function tc(n,e){switch(n.tab){case\"overview\":return Tu({dashLoop:n.dashLoop,split:n.split,now:n.now,onSelectTab:n.onSelectTab});case\"live\":return fc({dashLoop:n.dashLoop,visible:e});case\"timeline\":return Bu({dashLoop:n.dashLoop,now:n.now});case\"sections\":return Ru({dashLoop:n.dashLoop});case\"findings\":return ou({dashLoop:n.dashLoop});case\"plan\":return Lu({dashLoop:n.dashLoop});case\"usage\":return Mu({dashLoop:n.dashLoop})}}function ic(n){let e=g(()=>n.tabs().includes(n.tab)&&n.activeTab()===n.tab),f=ie(),t=h`
e()?\"display:block\":\"display:none\"}>
`,i=!1;return D(()=>{if(!e()||i)return;i=!0;let u=Ce(f,()=>tc(n,e));if(u)t.appendChild(u)}),t}function uc(n){return h`
\n ${Be.map((e)=>ic({tab:e,dashLoop:n.dashLoop,split:n.split,now:n.now,tabs:n.tabs,activeTab:n.activeTab,onSelectTab:n.onSelectTab}))}\n
`}function Bt(n){let e=()=>n.dashLoop,f=g(()=>Le(e().findings)),t=g(()=>ye(e())),i=g(()=>{let u=n.routeTab();return t().includes(u)?u:\"overview\"});return h`
\n
\n ${Qu({tabs:t,activeTab:i,onSelect:n.onSelectTab})}\n ${uc({dashLoop:n.dashLoop,split:f,now:n.now,tabs:t,activeTab:i,onSelectTab:n.onSelectTab})}\n
\n
`}function cf(){return h`
No loops match the current filters.
`}var cc=new Set([\"completed\",\"cancelled\",\"errored\"]);function Rt(n){let e=n.length;return{completed:n.filter((t)=>t.stage===\"completed\").length,total:e}}function vt(n,e){let f=cc.has(e);return n+(f?` ${n}-terminal`:` ${n}-active`)}function ot(n){return h`
\n

Groups

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

${()=>f.title}

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

PRD preview

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

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

\n ${()=>t().capped?ff({shown:()=>t().groups.reduce((i,u)=>i+u.rows.length,0),total:()=>t().total,noun:\"findings\",onShowAll:()=>f(!0)}):\"\"}\n ${()=>{let i=t().groups;if(i.length===0)return h`
No findings recorded for this repo.
`;return h`
\n ${i.map((u)=>h`
u.loopName}>\n
\n ${Dt({className:\"findings-loop-link\",projectId:n.projectId,loopName:u.loopName,onOpen:n.onOpenLoop})}\n ${()=>u.rows.length}\n
\n ${It({rows:u.rows})}\n
`)}\n
`}}\n
`}function at(n){let[e,f]=A(!1),t=g(()=>n.loops().filter((u)=>u.hasPlan)),i=g(()=>de(t(),Re,e()));return h`
\n

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

\n ${()=>i().capped?ff({shown:()=>i().rows.length,total:()=>i().total,noun:\"plans\",onShowAll:()=>f(!0)}):\"\"}\n ${()=>{let u=i().rows;if(u.length===0)return h`
No plans recorded for this repo.
`;return h`
\n ${u.map((c)=>h`
n.onOpenLoop(c.loop.loopName)}>\n Pn(c.loop.status)}>${()=>c.loop.status}\n ${()=>c.loop.loopName}\n \n ${()=>Zn(c.findings.length,\"finding\")}\n \n iter ${()=>c.loop.iteration}/${()=>c.loop.maxIterations}\n
`)}\n
`}}\n
`}function mt(){let[n,e]=If({generatedAt:0,projects:[]}),[f,t]=A(!1),i=he(typeof location<\"u\"?location.hash:\"\"),[u,c]=A(i.projectId),[l,r]=A(i.section),[d,w]=A(i.loopName),[O,k]=A(i.tab),[y,$]=A(i.groupId),[b,N]=A(i.statuses,{equals:ve}),[V,W]=A(i.query),K=g(()=>({projectId:u(),section:l(),loopName:d(),tab:O(),groupId:y(),statuses:b(),query:V()})),[C,Y]=A(null),[G,Z]=A(\"recent\"),[Q,j]=A(0),[a,o]=A(Date.now()),z={current:\"\"},J={current:!1},L=()=>{let x=v(K),P=new URLSearchParams;if(x.projectId)P.set(\"project\",x.projectId);if(x.loopName)P.set(\"loop\",x.loopName);let s=P.toString();return s?\"/api/data?\"+s:\"/api/data\"},q=0,dn={url:null,gen:0},En=async()=>{let x=L();if(dn.url===x)return;let P=++q;dn.url=x,dn.gen=P;try{let s=await fetch(x,{cache:\"no-store\"});if(P!==q)return;let U=await s.json();if(P!==q)return;let p=rt(U);if(p!==z.current)z.current=p,e(Df(U,{key:\"id\"})),t(!0),Y(null);o(Date.now())}catch(s){if(P!==q)return;let U=s instanceof Error?s.message:String(s);Y(\"Failed to load dashboard data: \"+U)}finally{if(dn.gen===P)dn.url=null,dn.gen=0}},Ln=(x)=>{Xn(()=>{c(x.projectId),r(x.section),w(x.loopName),k(x.tab),$(x.groupId),N(x.statuses),W(x.query)})},H=(x)=>{Xn(()=>{let P=x.projectId!==void 0&&x.projectId!==u();if(x.projectId!==void 0)c(x.projectId);if(x.section!==void 0)r(x.section);if(x.loopName!==void 0)w(x.loopName);if(x.tab!==void 0)k(x.tab);if(x.groupId!==void 0)$(x.groupId);if(x.statuses!==void 0)N(x.statuses);if(x.query!==void 0)W(x.query);if(P)N([]),W(\"\")})},I=(x)=>{H({tab:x})},B=(x)=>{H({loopName:x,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})},Nn=(x)=>{let P=new Set(b());if(P.has(x))P.delete(x);else P.add(x);H({statuses:[...P]})},hn=(x)=>{H({query:x})},Vn=(x)=>{Z(x)},Mn=g(()=>new Set(b())),an=g(()=>$t(n.projects.map(Fn))),mn=g(()=>{if(!f()||n.projects.length===0)return[];let x=Mn(),P=V(),s=an(),U=[],p=P.trim().toLowerCase();for(let jn of n.projects){let Ne=[],hf=Gn(s,jn);for(let wf of jn.loops)if(ct(wf.loop,x,P,hf))Ne.push(wf);let ki=(jn.groups?.length??0)>0;if(Ne.length>0)U.push({proj:jn,loops:Ne});else if(ki&&(!p||hf.toLowerCase().indexOf(p)!==-1))U.push({proj:jn,loops:[]})}return U}),lf=g(()=>u()===null),ni=g(()=>l()===\"groups\"),rf=g(()=>l()===\"loops\"),ei=g(()=>l()===\"findings\"),fi=g(()=>l()===\"plans\"),$f=g(()=>{let x=mn(),P=u();if(!P)return null;return x.find((s)=>s.proj.projectId===P)??null}),rn=g(()=>{let x=u();if(!x)return null;return n.projects.find((P)=>P.projectId===x)??null}),Oe=g(()=>{let x=u(),P=d();if(!x||!P)return null;let s=rn();if(!s)return null;return s.loops.find((U)=>U.loop.loopName===P)??null}),ti=g(()=>{let x=rn(),P=x?.groups?.length??0;if(!x)return{loops:0,groups:P,findings:0,plans:0};let s=0,U=0;for(let p of x.loops)if(s+=p.findings.length,p.hasPlan)U++;return{loops:x.loops.length,groups:P,findings:s,plans:U}}),_n=g(()=>{let x=rn();if(!x)return[];return x.loops.map((P)=>{let s=Bn(P.loop);return{name:P.loop.loopName,when:s,whenLabel:m(s)}}).sort((P,s)=>s.when-P.when||P.name.localeCompare(s.name))}),ge=g(()=>{let x=d();if(!x)return-1;return _n().findIndex((P)=>P.name===x)}),ii=g(()=>{let x=ge();if(x<0)return null;let P=_n();if(P.length<=1)return null;return{index:x,total:P.length,onPrev:()=>{let s=ge(),U=_n();if(s>0)H({loopName:U[s-1].name})},onNext:()=>{let s=ge(),U=_n();if(sx===P||!!x&&!!P&&x.index===P.index&&x.total===P.total}),ui=g(()=>{let x=rn(),P={running:0,completed:0,cancelled:0,errored:0,stalled:0};if(!x)return P;for(let s of x.loops){let U=s.loop.status;if(U===\"running\")P.running++;else if(U===\"completed\")P.completed++;else if(U===\"cancelled\")P.cancelled++;else if(U===\"errored\")P.errored++;else if(U===\"stalled\")P.stalled++}return P}),ci=g(()=>{let x=d();if(!u())return\"\";let P=rn();if(!P)return\"\";let s=Fn(P),U=Gn(an(),P);return v(()=>qt({repoLabel:U,projectDir:s,loopName:x,loops:_n,loopNav:ii,onSelectLoop:(p)=>H({loopName:p}),onBackToRepos:()=>H({projectId:null,loopName:null,section:\"loops\"}),onBackToRepo:()=>H({loopName:null})}))}),li=g(()=>{let x=u(),P=d();if(!x||P)return\"\";return Xt({section:l(),counts:ti(),onNavigate:(s)=>H({section:s,loopName:null,groupId:null})})}),ri=g(()=>{return u()!==null&&d()===null&&l()===\"loops\"}),$i=g(()=>{if(!ri())return\"\";return v(()=>_t({counts:ui,statuses:Mn,query:V,sortMode:G,onToggleStatus:Nn,onChangeQuery:hn,onChangeSort:Vn,externalNav:Q}))}),di=g(()=>{if(!rf())return\"\";let x=Oe();return x?Bt({dashLoop:x,now:a,routeTab:O,onSelectTab:I}):\"\"}),hi=g(()=>{let x=$f();return x?lt(x.loops,G()):[]},[],{equals:ve}),wi=g(()=>{if(!rf())return\"\";if(Oe())return\"\";if(!u())return\"\";return h`
\n ${Tt({loops:hi,now:a,onOpen:(x)=>B(x)})}\n ${()=>$f()?\"\":cf()}\n
`}),yi=g(()=>{if(l()!==\"groups\"||!y())return null;let x=rn();if(!x)return null;return x.groups.find((P)=>P.group.groupId===y())??null}),bi=g(()=>{let x=rn();if(!x)return new Set;return new Set(x.loops.map((P)=>P.loop.loopName))}),xi=g(()=>{if(!ni())return\"\";if(!u())return\"\";if(y())return Lt({group:yi,loopNames:bi,projectId:()=>u(),onBack:()=>H({groupId:null}),onOpenLoop:B});return ot({groups:()=>rn()?.groups??[],onOpen:(P)=>H({groupId:P})})}),df=g(()=>{let x=rn();return x?x.loops:[]}),Oi=g(()=>{if(!ei())return\"\";if(!u())return\"\";return Mt({loops:df,projectId:()=>u(),onOpenLoop:B})}),gi=g(()=>{if(!fi())return\"\";if(!u())return\"\";return at({loops:df,onOpenLoop:B})});D(()=>{if(!f())return;let x=u();if(x===null)return;if(!n.projects.some((s)=>s.projectId===x))c(null),w(null),r(\"loops\"),N([]),W(\"\")}),D(()=>{let x=u(),P=d();if(!x||!P)return;let s=rn();if(!s)return;if(!s.loops.some((p)=>p.loop.loopName===P))w(null)}),D(()=>{let x=d(),P=O();if(!u()||!x||P===\"overview\")return;let s=Oe();if(!s)return;if(!ye(s).includes(P))k(\"overview\")}),D(()=>{if(!f())return;it(we(K()),J)});let Ni=g(()=>(u()??\"\")+\"\\x00\"+(d()??\"\"));return D(Vf(Ni,()=>{En()},{defer:!0})),Cf(()=>{Ln(he(location.hash)),En();let x=setInterval(()=>{En()},5000),P=()=>{if(J.current){J.current=!1;return}Ln(he(location.hash)),j((s)=>s+1)};window.addEventListener(\"hashchange\",P),wn(()=>{clearInterval(x),window.removeEventListener(\"hashchange\",P)})}),h`
\n ${jt({generatedAt:()=>n.generatedAt,breadcrumb:ci})}\n\n ${()=>{let x=C();return x?h`
${x}
`:\"\"}}\n\n ${()=>{if(!f())return\"\";if(lf()&&mn().length===0)return cf();return h`
\n ${()=>{if(lf())return h`
\n ${Jt({entries:()=>mn(),labels:()=>an(),onSelect:(x)=>H({projectId:x,loopName:null,section:\"loops\",groupId:null})})}\n ${Ut({entries:()=>mn(),labels:()=>an(),onOpenLoop:(x,P)=>H({projectId:x,loopName:P,section:\"loops\",groupId:null,statuses:[],query:\"\",tab:\"overview\"})})}\n
`;return h`
\n ${$i}\n ${li}\n ${wi}\n ${di}\n ${xi}\n ${Oi}\n ${gi}\n
`}}\n
`}}\n
`}var pt=document.getElementById(\"forge-app-root\");if(pt)jf(mt,pt);\n"; export const DASHBOARD_APP_SOURCE_HASH: string = "8fd787d9c87fbecd1044b5404fd215677df04d8a07d4ee1952eeaca163c76acf"; diff --git a/src/hooks/forge-session-attach.ts b/src/hooks/forge-session-attach.ts index 4097dd1da..a95dcd04d 100644 --- a/src/hooks/forge-session-attach.ts +++ b/src/hooks/forge-session-attach.ts @@ -3,6 +3,7 @@ import type { ForgeClient } from '../client/port' import type { ForgeExecutionServiceDeps, ForgeLoopExtra, PlanSource } from '../services/execution' import { attachLoopToSession } from '../services/execution' import { isLoopResumeSnapshot } from '../loop/resume-snapshot' +import { parseTerminationReasonString } from '../loop' import { resolveSandboxContextForLoop, isSandboxEnabled } from '../sandbox/context' import { classifyForgeWorkspace, isPendingAttachWorkspace } from '../workspace/classify-stale' import { removeForgeWorkspaceWithContext } from '../workspace/remove-with-context' @@ -221,11 +222,16 @@ async function attachForgeSession( { client: deps.client, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, { workspaceId, loopName, action: 'remove-registration-only', reasonLabel: 'attach-safety-net-restartable' }, ) + const row = deps.execDeps.loopsRepo.get(sessionProjectId, loopName) + const parsedReason = row?.terminationReason ? parseTerminationReasonString(row.terminationReason) : null + const toastMessage = parsedReason?.kind === 'migrated' + ? `Loop "${loopName}" was migrated to remote "${parsedReason.message}". Restart locally only with loop-status restart=true force=true.` + : `Loop "${loopName}" is in terminal status. Use Loop-status restart to resume.` publishAttachFailureToast( deps, ws.directory ?? deps.directory, `Forge loop "${loopName}"`, - `Loop "${loopName}" is in terminal status. Use Loop-status restart to resume.`, + toastMessage, ) return } else { @@ -240,7 +246,22 @@ async function attachForgeSession( ? cfg.hostSessionId : sessionId - const resume = isLoopResumeSnapshot(cfg.resume) ? cfg.resume : undefined + if (cfg.resume !== undefined && !isLoopResumeSnapshot(cfg.resume)) { + deps.logger.error(`[forge-session-attach] invalid resume snapshot for loop=${loopName} workspace=${workspaceId}`) + publishAttachFailureToast( + deps, + ws.directory ?? deps.directory, + `Forge loop "${loopName}"`, + 'Migrated loop snapshot is invalid or from an incompatible forge version. Removed the workspace; re-run loop-migrate from a matching version.', + ) + await removeForgeWorkspaceWithContext( + { client: deps.client, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, + { workspaceId, loopName, action: 'remove-fully', reasonLabel: 'attach-bad-resume' }, + ) + return + } + + const resume = cfg.resume let planText: string if (resume !== undefined) { diff --git a/src/loop/restartability.ts b/src/loop/restartability.ts index 75d0873ee..c65804c42 100644 --- a/src/loop/restartability.ts +++ b/src/loop/restartability.ts @@ -64,10 +64,10 @@ export function getRestartability( } if (parsed.kind === 'migrated') { return { - restartable: false, - restartRequiresForce: false, + restartable: true, + restartRequiresForce: true, restartBlockedReason: 'migrated', - restartBlockedMessage: `Loop "${state.loopName}" was migrated to remote "${parsed.message}"; manage it on that server.`, + restartBlockedMessage: `Loop "${state.loopName}" was migrated to remote "${parsed.message}". A copy may be running there; use force=true to restart it locally anyway.`, } } } diff --git a/src/loop/resume-prompt.ts b/src/loop/resume-prompt.ts index fdb8ae4da..154393ea9 100644 --- a/src/loop/resume-prompt.ts +++ b/src/loop/resume-prompt.ts @@ -1,7 +1,6 @@ import type { PluginConfig } from '../types' import type { LoopService } from './service' import type { LoopState } from './state' -import type { ResolvedPostActionConfig } from './post-action-config' import { resolvePostActionConfig } from './post-action-config' import { auditorModelChoiceAt, buildAuditorModelChain } from '../utils/loop-helpers' import { parseModelString } from '../utils/model-fallback' @@ -12,15 +11,13 @@ export interface ResumePromptPlan { phase: ResumePhase promptText: string agent: 'code' | 'auditor-loop' - permission: 'loop' | 'audit' model?: { providerID: string; modelID: string } fallbackModel?: { providerID: string; modelID: string } variant?: string auditorModel?: string - postAction?: ResolvedPostActionConfig } -export function normalizeModelString(value: string | undefined): string | undefined { +function normalizeModelString(value: string | undefined): string | undefined { const trimmed = value?.trim() return trimmed ? trimmed : undefined } @@ -38,11 +35,21 @@ export function resolveResumePhase(phase: LoopState['phase']): ResumePhase { : 'coding' } +/** + * Single owner of the restart agent selection: a stopped final_audit_fix loop + * is a coding pass (the fix session), not an auditor phase — it re-dispatches + * as the code agent. Only the final_auditing phase re-dispatches the + * auditor-loop agent. + */ +export function resolveResumeAgent(phase: ResumePhase): 'code' | 'auditor-loop' { + return phase === 'final_auditing' ? 'auditor-loop' as const : 'code' as const +} + /** * Single owner of the restart prompt selection: maps a stopped loop's persisted - * state to the resume phase, prompt text, agent, permission, and model used to - * re-dispatch it. handleLoopRestart consumes the plan instead of inlining this - * chain so restart behavior has exactly one definition. + * state to the resume phase, prompt text, agent, and model used to re-dispatch + * it. handleLoopRestart consumes the plan instead of inlining this chain so + * restart behavior has exactly one definition. */ export function buildResumePromptPlan(input: { service: LoopService @@ -98,19 +105,15 @@ export function buildResumePromptPlan(input: { ? restartAuditorModel : undefined - // final_audit_fix is a coding-style phase: restart sends the final-audit fix - // prompt as the code agent (never the auditor-loop agent). - const agent = phase === 'final_auditing' ? 'auditor-loop' as const : 'code' as const + const agent = resolveResumeAgent(phase) return { phase, promptText, agent, - permission: phase === 'final_auditing' ? 'audit' : 'loop', model, fallbackModel, variant: agent === 'auditor-loop' ? restartAuditorChoice.variant : state.executionVariant, auditorModel: restartAuditorState.auditorModel, - postAction: postActionCfg, } } diff --git a/src/loop/resume-snapshot.ts b/src/loop/resume-snapshot.ts index 64a25f3bf..6b11fe73b 100644 --- a/src/loop/resume-snapshot.ts +++ b/src/loop/resume-snapshot.ts @@ -94,6 +94,37 @@ export function restoreLoopResumeRows(input: { }) } +const SECTION_STATUSES: readonly SectionPlanRow['status'][] = ['pending', 'in_progress', 'completed', 'failed'] + +function isValidResumeSection(value: unknown): value is ResumeSectionRow { + if (!isRecord(value)) return false + if (typeof value.sectionIndex !== 'number') return false + if (typeof value.title !== 'string') return false + if (typeof value.content !== 'string') return false + if (!SECTION_STATUSES.includes(value.status as SectionPlanRow['status'])) return false + if (typeof value.attempts !== 'number') return false + for (const key of ['summaryDone', 'summaryDeviations', 'summaryFollowUps'] as const) { + const item = value[key] + if (item !== null && item !== undefined && typeof item !== 'string') return false + } + for (const key of ['startedAt', 'completedAt'] as const) { + const item = value[key] + if (item !== null && item !== undefined && typeof item !== 'number') return false + } + return true +} + +function isValidResumeFinding(value: unknown): value is ResumeFindingRow { + if (!isRecord(value)) return false + if (typeof value.file !== 'string') return false + if (typeof value.line !== 'number') return false + if (value.severity !== 'bug' && value.severity !== 'warning') return false + if (typeof value.description !== 'string') return false + if (value.scenario !== null && value.scenario !== undefined && typeof value.scenario !== 'string') return false + if (value.sectionIndex !== null && value.sectionIndex !== undefined && typeof value.sectionIndex !== 'number') return false + return true +} + export function isLoopResumeSnapshot(value: unknown): value is LoopResumeSnapshot { if (!isRecord(value)) return false if (value.version !== 1) return false @@ -105,5 +136,7 @@ export function isLoopResumeSnapshot(value: unknown): value is LoopResumeSnapsho if (value.goal !== undefined && typeof value.goal !== 'string') return false if (!Array.isArray(value.sections)) return false if (!Array.isArray(value.findings)) return false + if (!value.sections.every(isValidResumeSection)) return false + if (!value.findings.every(isValidResumeFinding)) return false return true } diff --git a/src/loop/runtime.ts b/src/loop/runtime.ts index 1f4bbd877..fa28a3ae3 100644 --- a/src/loop/runtime.ts +++ b/src/loop/runtime.ts @@ -108,6 +108,8 @@ export interface Loop { inspect(name: string): LoopState | null listActive(): LoopState[] listRecent(): LoopState[] + /** All loop names for the project (any status) without hydrating plans. */ + listLoopNames(): string[] findMatchByName(name: string): { match: LoopState | null; candidates: LoopState[] } hasOutstandingFindings(loopName?: string, severity?: 'bug' | 'warning'): boolean terminateAll(): Promise @@ -2971,6 +2973,10 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { return loopService.listRecent() } + function listLoopNames(): string[] { + return loopService.listLoopNames() + } + function findMatchByName(name: string): { match: LoopState | null; candidates: LoopState[] } { return loopService.findMatchByName(name) } @@ -3103,6 +3109,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { inspect, listActive, listRecent, + listLoopNames, findMatchByName, hasOutstandingFindings, terminateAll, diff --git a/src/loop/service.ts b/src/loop/service.ts index 4983e52dc..0f1161741 100644 --- a/src/loop/service.ts +++ b/src/loop/service.ts @@ -62,6 +62,8 @@ export interface LoopService { buildAuditPrompt(state: LoopState): string listActive(): LoopState[] listRecent(): LoopState[] + /** All loop names for the project (any status) without hydrating plans. */ + listLoopNames(): string[] findMatchByName(name: string): { match: LoopState | null; candidates: LoopState[] } getStallTimeoutMs(): number getMaxConsecutiveStalls(): number @@ -305,6 +307,10 @@ export function createLoopService( }) } + function listLoopNames(): string[] { + return loopsRepo.listLoopNames(projectId) + } + function findMatchByName(name: string): { match: LoopState | null; candidates: LoopState[] } { const result = loopsRepo.findPartial(projectId, name) const mapResult = (row: LoopRow | null): LoopState | null => { @@ -870,6 +876,7 @@ export function createLoopService( buildAuditPrompt, listActive, listRecent, + listLoopNames, findMatchByName, getStallTimeoutMs, getMaxConsecutiveStalls, diff --git a/src/prompts/agents/code.md b/src/prompts/agents/code.md index 864fa7a69..b9df694cd 100644 --- a/src/prompts/agents/code.md +++ b/src/prompts/agents/code.md @@ -56,7 +56,7 @@ Mark todos as completed as soon as each task is done — do not batch completion - Use specialized tools (Read, Glob, Grep) instead of bash equivalents (cat, find, grep, sed, echo). ## Forge custom tools -- Do not call `execute-goal`, `execute-plan`, `launch-group`, or `loop-cancel` unless the user explicitly asks you to. They launch or stop loops and groups; never invoke them proactively. +- Do not call `execute-goal`, `execute-plan`, `launch-group`, `loop-cancel`, or `loop-migrate` unless the user explicitly asks you to. They launch, stop, or move loops and groups; never invoke them proactively. # Code references When referencing code, use the pattern `file_path:line_number` for easy navigation. diff --git a/src/prompts/commands/loop-migrate.md b/src/prompts/commands/loop-migrate.md index a688d4fe4..be53f813b 100644 --- a/src/prompts/commands/loop-migrate.md +++ b/src/prompts/commands/loop-migrate.md @@ -10,6 +10,6 @@ Run `loop-migrate` with: ## Step 3: Report the Migration -Confirm the loop was migrated: report the remote loop name and remote session id, and note that the local loop is stopped as migrated and cannot be restarted here. +Confirm the loop was migrated: report the remote loop name and remote session id, and note that the local loop is stopped as migrated; it restarts locally only with `loop-status restart=true force=true`. If the tool refused (project-directory loop, feature-group loop, or a loop that changed state), report the reason and do not retry automatically. $ARGUMENTS diff --git a/src/sandbox/process.ts b/src/sandbox/process.ts index ff9f3846f..48dca9b37 100644 --- a/src/sandbox/process.ts +++ b/src/sandbox/process.ts @@ -11,6 +11,7 @@ export interface RunCommandOpts { timeout?: number abort?: AbortSignal stdin?: string + cwd?: string logger: Logger logLabel?: string /** @@ -38,6 +39,7 @@ export function runCommand(command: string, args: string[], opts: RunCommandOpts const inner = new Promise((resolve) => { const stdioConfig: 'pipe' | 'ignore' = opts.stdin ? 'pipe' : 'ignore' const child: ChildProcess = spawn(command, args, { + cwd: opts.cwd, stdio: [stdioConfig, 'pipe', 'pipe'], }) diff --git a/src/services/execution.ts b/src/services/execution.ts index a1ac04ecf..f932b5540 100644 --- a/src/services/execution.ts +++ b/src/services/execution.ts @@ -19,7 +19,7 @@ import { classifyProviderLimit, extractErrorSignal } from '../loop/provider-limi import { formatLoopSessionTitle, formatPlanSessionTitle } from '../utils/session-titles' import { slugify } from '../utils/logger' -import { buildLoopPermissionRuleset, buildAuditSessionPermissionRuleset, resolveLoopPermissionOptions } from '../constants/loop' +import { buildLoopPermissionRuleset, buildSessionPermissionRulesetForAgent, resolveLoopPermissionOptions } from '../constants/loop' import { resolveLoopPermissionOptionsForWorkspace } from '../utils/loop-permission-options' import { findPartialMatch } from '../utils/partial-match' import { isSandboxEnabled } from '../sandbox/context' @@ -37,7 +37,7 @@ import { loopBranchExists } from '../workspace/forge-naming' import { getWorktreeProjectPreconditionError } from '../workspace/forge-worktree' import { resolveHostSessionDirectory } from '../utils/resolve-project-root' import { resolvePostActionConfig } from '../loop/post-action-config' -import { buildResumePromptPlan, resolveResumePhase } from '../loop/resume-prompt' +import { buildResumePromptPlan, resolveResumeAgent, resolveResumePhase } from '../loop/resume-prompt' import { restoreLoopResumeRows, type LoopResumeSnapshot } from '../loop/resume-snapshot' import { migrateLoopToRemote } from './loop-migration' import { @@ -291,13 +291,6 @@ export type ForgeExecutionCommand = | GetLoopStatusCommand | MigrateLoopCommand -// ============================================================================ -// Response/Error Types (single definition in ./execution-response) -// ============================================================================ - -export type { ForgeExecutionError, ForgeExecutionWarning, ForgeExecutionResponse } from './execution-response' -export { ok, fail } from './execution-response' - // ============================================================================ // Result Types per Command // ============================================================================ @@ -465,6 +458,7 @@ export interface ForgeExecutionServiceDeps { sandboxManager?: SandboxManager | null sectionPlansRepo?: import('../storage/repos/section-plans-repo').SectionPlansRepo reviewFindingsRepo?: import('../storage/repos/review-findings-repo').ReviewFindingsRepo + featureGroupsRepo?: import('../storage/repos/feature-groups-repo').FeatureGroupsRepo loopSessionUsageRepo?: import('../storage/repos/loop-session-usage-repo').LoopSessionUsageRepo workspaceStatusRegistry: import('../utils/workspace-status-registry').WorkspaceStatusRegistry pendingTeardowns: import('../workspace/pending-teardown').PendingTeardownRegistry @@ -474,12 +468,6 @@ export interface ForgeExecutionServiceDeps { createRemoteClient?: (opts: import('../client/sdk-adapter').RemoteClientOptions) => ForgeClient } -// ============================================================================ -// Helper Functions -// ============================================================================ - -// `ok`/`fail` are imported from ./execution-response. - // ============================================================================ // Plan Source Resolution // ============================================================================ @@ -527,6 +515,24 @@ async function resolvePlanSource( } } +// ============================================================================ +// Named-loop resolution +// ============================================================================ + +export function resolveNamedLoop( + deps: Pick, + name: string, +): { state: import('../loop/state').LoopState } | { response: ForgeExecutionResponse } { + const { match, candidates } = deps.loop.findMatchByName(name) + if (!match && candidates.length > 0) { + return { response: fail('conflict', 409, `Multiple loops match "${name}". Be more specific.`, undefined, candidates.map((s) => s.loopName)) } + } + if (!match) { + return { response: fail('not_found', 404, `No loop found for "${name}".`, undefined, deps.loop.listLoopNames()) } + } + return { state: match } +} + // ============================================================================ // Port-based helpers // ============================================================================ @@ -736,9 +742,11 @@ export async function attachLoopToSession( if (resume) { // A migrated loop must not be re-decomposed: its persisted section rows // (including in-progress state and attempts) were restored above, and - // decomposition would reset section 0 / rewrite the rows. - resumePlan = buildResumePromptPlan({ service: deps.loop.service, config: deps.config, state }) - promptText = resumePlan.promptText + // decomposition would reset section 0 / rewrite the rows. The resume + // prompt plan is built lazily after the sendInitialPrompt gate below — + // migrated loops always attach with sendInitialPrompt: false, so eager + // construction would compute the plan and discard it. + promptText = '' } else if (isGoal) { // Goal loops have no sections; the initial prompt is the same goal // continuation prompt used on every later iteration. @@ -798,6 +806,11 @@ export async function attachLoopToSession( return { ok: true, loopName } } + if (resume) { + resumePlan = buildResumePromptPlan({ service: deps.loop.service, config: deps.config, state }) + promptText = resumePlan.promptText + } + // Send initial prompt with fallback const sessionDir = worktreeDir const promptParts = [{ type: 'text' as const, text: promptText }] @@ -1744,16 +1757,9 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo } const name = command.selector.name - const active = deps.loop.listActive() - const recent = deps.loop.listRecent() - const allStates = [...active, ...recent] - const { match: stoppedState, candidates } = findPartialMatch(name, allStates, s => [s.loopName, s.worktreeBranch]) - if (!stoppedState && candidates.length > 0) { - return fail('conflict', 409, `Multiple loops match "${name}". Be more specific.`, undefined, candidates.map(s => s.loopName)) - } - if (!stoppedState) { - return fail('not_found', 404, `No loop found for "${name}".`, undefined, allStates.map(s => s.loopName)) - } + const resolved = resolveNamedLoop(deps, name) + if ('response' in resolved) return resolved.response + const stoppedState = resolved.state const restartability = getRestartability(stoppedState, { force: command.force, @@ -1868,13 +1874,13 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo } const permissionOptions = await resolveLoopPermissionOptionsForWorkspace(deps.client, deps.config, stoppedState.workspaceId) - const permissionRuleset = buildLoopPermissionRuleset(permissionOptions) // The recreated session's permission ruleset is selected before the session // swap, so the resume phase is resolved from the persisted (under-lock) // state here. The full prompt plan is built after the optional plan // decomposition below, mirroring the original prompt-selection ordering. const resumePhase = resolveResumePhase(stoppedState.phase) + const permission = buildSessionPermissionRulesetForAgent(resolveResumeAgent(resumePhase), permissionOptions) stoppedState.iteration = 1 @@ -1890,6 +1896,10 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo const preservedExtra = Object.fromEntries( Object.entries(previousEntry?.extra ?? {}).filter(([key]) => !['startRef', 'syncRef', 'gitRemote'].includes(key)), ) + if (preservedExtra.forgeLoop && typeof preservedExtra.forgeLoop === 'object') { + const { resume: _consumedResume, ...restForgeLoop } = preservedExtra.forgeLoop as Record + preservedExtra.forgeLoop = restForgeLoop + } const wsResult = await createBuiltinWorktreeWorkspace(deps.client, { loopName: stoppedState.loopName, directory: stoppedState.projectDir || ctx.directory, @@ -1921,7 +1931,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo totalSections: stoppedState.totalSections ?? 0, }), directory: stoppedState.worktreeDir, - permission: resumePhase === 'final_auditing' ? buildAuditSessionPermissionRuleset(permissionOptions) : permissionRuleset, + permission, workspaceId: stoppedState.workspaceId, loopName: stoppedState.loopName, logPrefix: 'loop-restart', @@ -1971,14 +1981,10 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo // Full resume plan after the optional in-restart plan decomposition, so a // legacy non-sectioned loop resumes with the freshly decomposed section // prompt and the prompt builders run exactly once, as before the refactor. - // Decomposition cannot change the resume phase; the assertion guards that - // invariant so the pre-swap permission selection can never drift from the - // phase the plan dispatches under. + // Decomposition cannot change the resume phase; `resolveResumePhase` is + // pure on `state.phase`, so the pre-swap permission selection above and + // the plan below always agree. const resume = buildResumePromptPlan({ service: deps.loop.service, config: deps.config, state: stoppedState }) - const restartPhase = resume.phase - if (resume.phase !== resumePhase) { - throw new Error(`resume phase changed after decomposition: ${resumePhase} -> ${resume.phase}`) - } const newState: import('../loop/state').LoopState = { active: true, @@ -1991,7 +1997,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo maxIterations: stoppedState.maxIterations, startedAt: new Date().toISOString(), prompt: stoppedState.prompt, - phase: restartPhase, + phase: resumePhase, errorCount: 0, auditCount: 0, status: 'running', @@ -2042,13 +2048,13 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo // already be in place so the transition log shows the real sequence // (phase change → terminal). We skip when persisted phase matches the // restart phase (final_auditing / post_action stay in place). - const restartPhaseChanged = restartPhase !== stoppedState.phase + const restartPhaseChanged = resumePhase !== stoppedState.phase if (restartPhaseChanged) { deps.loop.service.recordTransition(stoppedState.loopName, { eventType: 'restart', transitionKind: 'phase', fromPhase: stoppedState.phase, - toPhase: restartPhase, + toPhase: resumePhase, iteration: 1, sectionIndex: transitionSectionIndex(stoppedState), }) @@ -2169,14 +2175,14 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo restoreRow = previousState } deps.loop.service.restoreState(restoreRow.loopName, restoreRow) - const restartFromPhase = restartPhase + const restartFromPhase = resumePhase const restartToPhase = previousState.phase ?? 'coding' const iteration = previousState.iteration ?? 0 const sectionIndex = transitionSectionIndex(previousState) // Log the rollback restoration whenever the restart actually changed // the persisted phase, so the transition history stays continuous: - // previousPhase -> restartPhase (pre-prompt 'restart' phase row) - // restartPhase -> previousPhase (this 'rollback' row) + // previousPhase -> resumePhase (pre-prompt 'restart' phase row) + // resumePhase -> previousPhase (this 'rollback' row) // When the restart preserved the phase (final_auditing / post_action // stay in place), there is nothing to roll back phase-wise and no // rollback row is emitted. diff --git a/src/services/loop-migration.ts b/src/services/loop-migration.ts index c4244a411..4154b7493 100644 --- a/src/services/loop-migration.ts +++ b/src/services/loop-migration.ts @@ -17,56 +17,59 @@ import { existsSync } from 'fs' import type { ForgeExecutionServiceDeps, ForgeExecutionRequestContext, MigrateLoopCommand, LoopMigratedResult } from './execution' import type { ForgeExecutionResponse, ForgeExecutionWarning } from './execution-response' import { ok, fail } from './execution-response' -import { connectRemoteProject, pushForgeSyncRef, deleteForgeSyncRef } from '../utils/tui-remote-launch' -import { resolveRemoteLoopPermissionOptions } from '../constants/loop' -import { emitLoopPermissionConfigWarnings } from '../utils/loop-permission-warnings' +import { connectRemoteProject, prepareRemoteLoopLaunch, pushAndLaunchRemoteLoop, deleteForgeSyncRef } from '../utils/tui-remote-launch' +import { resolveLoopPermissionOptionsForWorkspace } from '../utils/loop-permission-options' import { resolveDataDir } from '../utils/opencode-paths' -import { findPartialMatch } from '../utils/partial-match' +import { resolveNamedLoop } from './execution' import { getRestartability } from '../loop/restartability' import { loopBranchExists, forgeBranchName } from '../workspace/forge-naming' import { resolvePostActionConfig } from '../loop/post-action-config' -import { terminationReasonToString } from '../loop' -import { captureLoopResumeSnapshot } from '../loop/resume-snapshot' +import { terminationStatusFor, terminationReasonToString, parseTerminationReasonString, type TerminationReason } from '../loop' +import { captureLoopResumeSnapshot, type LoopResumeSnapshot } from '../loop/resume-snapshot' import { buildResumePromptPlan } from '../loop/resume-prompt' -import { reserveTuiLoopName, launchTuiLoop } from '../utils/tui-client' -import { forgeSyncRef } from '../utils/remote-config' import { defaultGitService } from '../utils/git-service' +import { getForgeWorkspaceEntry } from '../workspace/forge-worktree' + +interface PreFreezeTerminalSnapshot { + status: import('../loop/state').LoopState['status'] + terminationReason?: string + completedAt?: string + completionSummary?: string +} export async function migrateLoopToRemote( deps: ForgeExecutionServiceDeps, ctx: ForgeExecutionRequestContext, command: MigrateLoopCommand, ): Promise> { - // 1. Guards if (!deps.loopHandler || !deps.sectionPlansRepo || !deps.reviewFindingsRepo) { return fail('internal_error', 500, 'Loop migration requires the loop handler and section/finding repositories') } const debug = (message: string) => deps.logger.debug(message) - // 2. Resolve the loop (active + recent, same matching as restart) - const allStates = [...deps.loop.listActive(), ...deps.loop.listRecent()] - const { match: state, candidates } = findPartialMatch(command.selector.name, allStates, (s) => [s.loopName, s.worktreeBranch]) - if (!state && candidates.length > 0) { - return fail('conflict', 409, `Multiple loops match "${command.selector.name}". Be more specific.`, undefined, candidates.map((s) => s.loopName)) + const resolved = resolveNamedLoop(deps, command.selector.name) + if ('response' in resolved) return resolved.response + const state = resolved.state + + if (!state.worktree) { + return fail('conflict', 409, `Loop "${state.loopName}" runs in the project directory (worktree: false) and cannot be migrated.`) } - if (!state) { - return fail('not_found', 404, `No loop found for "${command.selector.name}".`, undefined, allStates.map((s) => s.loopName)) + if (deps.featureGroupsRepo?.getFeatureByLoopName(ctx.projectId, state.loopName)) { + return fail('conflict', 409, `Loop "${state.loopName}" belongs to a feature group and cannot be migrated; groups are orchestrated locally.`) } - // 3. Restartability + post-action guard (failures leave the loop untouched) const git = deps.git ?? defaultGitService const restartability = getRestartability(state, { worktreeExists: existsSync, branchExists: () => loopBranchExists(state, ctx.directory, git), }) - if (!restartability.restartable) { + if (!restartability.restartable || restartability.restartBlockedReason === 'migrated') { return fail('conflict', 409, restartability.restartBlockedMessage ?? `Loop "${state.loopName}" cannot be migrated.`) } if (state.phase === 'post_action' && !resolvePostActionConfig(deps.config).enabled) { return fail('conflict', 409, 'Loop implementation already completed; post-action is disabled — nothing to migrate.') } - // 4. Remote connection + project discovery (failures leave the loop untouched) const connected = await connectRemoteProject( { remoteName: command.remoteName, localProjectId: ctx.projectId, localDirectory: ctx.directory }, { config: deps.config, createClient: deps.createRemoteClient, debug }, @@ -76,103 +79,145 @@ export async function migrateLoopToRemote( } const { remote, project: remoteProject, client: remoteClient } = connected - // 5. Remote loop name + portable permission rules - const remoteLoopName = await reserveTuiLoopName(remoteClient, null, state.loopName) - const syncRef = forgeSyncRef(remoteLoopName) - const remotePermissionOptions = resolveRemoteLoopPermissionOptions(deps.config) + const original: PreFreezeTerminalSnapshot = { + status: state.status, + terminationReason: state.terminationReason, + completedAt: state.completedAt, + completionSummary: state.completionSummary, + } + const wsOptions = await resolveLoopPermissionOptionsForWorkspace(deps.client, deps.config, state.workspaceId) + const remotePermissionOptions = { extraRules: wsOptions.extraRules } + const previousEntry = state.workspaceId + ? await getForgeWorkspaceEntry(deps.client, state.workspaceId).catch(() => undefined) + : undefined + const ownSyncRef = typeof previousEntry?.extra?.syncRef === 'string' ? previousEntry.extra.syncRef : undefined + const ownGitRemote = typeof previousEntry?.extra?.gitRemote === 'string' ? previousEntry.extra.gitRemote : undefined + const warnings: ForgeExecutionWarning[] = [] - emitLoopPermissionConfigWarnings(deps.config, deps.config.dataDir || resolveDataDir(), ctx.directory, { - logger: { log: debug, error: debug, debug }, + const remoteLoopName = await prepareRemoteLoopLaunch({ + client: remoteClient, + requestedLoopName: state.loopName, + permissionOptions: remotePermissionOptions, + config: deps.config, + dataDir: deps.config.dataDir || resolveDataDir(), + localDirectory: ctx.directory, + debug, onWarnings: (messages) => warnings.push(...messages.map((message) => ({ code: 'loop_permissions', message }))), }) + const syncRef = remoteLoopName.syncRef - // 6. Freeze: terminate the local loop as migrated (worktree kept — the - // reason is not 'completed', so the teardown commits but preserves the branch). - const migratedReason = { kind: 'migrated' as const, message: remote.name } - if (state.active) { - await deps.loopHandler.terminateLoopByName(state.loopName, migratedReason) + const migratedReason: TerminationReason = { kind: 'migrated', message: remote.name } + const fresh = await deps.loop.runExclusive(state.loopName, async () => deps.loop.inspect(state.loopName)) + if (!fresh) { + return fail('not_found', 404, `Loop "${state.loopName}" vanished during migration freeze.`) + } + let frozen: import('../loop/state').LoopState | null + if (fresh.active) { + const terminated = await deps.loopHandler.terminateLoopByName(fresh.loopName, migratedReason) + if (terminated === false) { + return fail('conflict', 409, `Loop "${state.loopName}" changed state during migration; retry.`) + } + frozen = deps.loop.inspect(fresh.loopName) } else { - deps.loop.service.terminate(state.loopName, { - status: 'cancelled', - reason: terminationReasonToString(migratedReason), - completedAt: Date.now(), + await deps.loop.runExclusive(state.loopName, async () => { + const latest = deps.loop.inspect(state.loopName) + if (latest && !latest.active) { + relabelInactiveLoop(deps, state.loopName, migratedReason) + } }) + frozen = deps.loop.inspect(state.loopName) } - const frozen = deps.loop.inspect(state.loopName) if (!frozen) { return fail('internal_error', 500, `Loop "${state.loopName}" vanished during migration freeze.`) } + if (frozen.active || parseTerminationReasonString(frozen.terminationReason ?? '').kind !== 'migrated') { + return fail('conflict', 409, `Loop "${state.loopName}" changed state during migration; retry.`) + } - // 7. Snapshot + branch-tip push. Any failure here rolls the freeze back. - const snapshot = captureLoopResumeSnapshot({ - projectId: ctx.projectId, - state: frozen, - sectionPlansRepo: deps.sectionPlansRepo, - reviewFindingsRepo: deps.reviewFindingsRepo, - }) - const branch = frozen.worktreeBranch || forgeBranchName(frozen.loopName) const projectDir = frozen.projectDir || ctx.directory - const tipResult = git.revParseRef(projectDir, `refs/heads/${branch}`) - if (!tipResult.ok) { - return rollbackAfterFreeze(deps, frozen, `Failed to resolve loop branch ${branch}: ${tipResult.stderr || '(no stderr)'}`) - } - const tip = tipResult.stdout.trim() - const pushResult = pushForgeSyncRef(git, { - cwd: projectDir, - gitRemote: remote.gitRemote, - sourceRef: `refs/heads/${branch}`, - syncRef, - }) - if (!pushResult.ok) { - return rollbackAfterFreeze(deps, frozen, `Failed to push loop branch to remote "${remote.name}": ${pushResult.error}`) + if (frozen.worktreeDir && existsSync(frozen.worktreeDir)) { + const statusResult = git.statusPorcelain(frozen.worktreeDir) + if (!statusResult.ok) { + return rollbackAfterFreeze( + deps, + frozen, + original, + `Worktree ${frozen.worktreeDir} status check failed after teardown: ${statusResult.stderr || '(no stderr)'}; migration aborted so no work is lost.`, + ) + } + if (statusResult.stdout.trim().length > 0) { + return rollbackAfterFreeze( + deps, + frozen, + original, + `Worktree ${frozen.worktreeDir} has uncommitted changes after teardown; migration aborted so no work is lost.`, + ) + } } - // 8+9. Build the phase-appropriate resume prompt and launch the remote loop. - const resume = buildResumePromptPlan({ service: deps.loop.service, config: deps.config, state: frozen }) - const planText = frozen.prompt ?? '' - const launch = await launchTuiLoop({ - client: remoteClient, - directory: remoteProject.worktree, - projectId: remoteProject.id, - requestedLoopName: remoteLoopName, - loopNameReserved: true, - connectPollIntervalMs: 500, - title: frozen.loopName, - plan: planText, - executionModel: frozen.executionModel, - auditorModel: frozen.auditorModel, - executionVariant: frozen.executionVariant, - auditorVariant: frozen.auditorVariant, - extraWorkspaceFields: { - startRef: tip, + let snapshot: LoopResumeSnapshot + let tip: string + let launch: { loopName: string; sessionId: string } + try { + snapshot = captureLoopResumeSnapshot({ + projectId: ctx.projectId, + state: frozen, + sectionPlansRepo: deps.sectionPlansRepo, + reviewFindingsRepo: deps.reviewFindingsRepo, + }) + const branch = frozen.worktreeBranch || forgeBranchName(frozen.loopName) + const tipResult = git.revParseRef(projectDir, `refs/heads/${branch}`) + if (!tipResult.ok) { + return rollbackAfterFreeze(deps, frozen, original, `Failed to resolve loop branch ${branch}: ${tipResult.stderr || '(no stderr)'}`) + } + tip = tipResult.stdout.trim() + + const resume = buildResumePromptPlan({ service: deps.loop.service, config: deps.config, state: frozen }) + const result = await pushAndLaunchRemoteLoop({ + git, + cwd: projectDir, + remote, + project: remoteProject, + client: remoteClient, + loopName: remoteLoopName.loopName, syncRef, - gitRemote: remote.gitRemote, - permissionRules: remotePermissionOptions.extraRules, - }, - forgeLoopOverrides: { - sandboxEnabled: remote.sandbox, - maxIterations: frozen.maxIterations, - resume: snapshot, - }, - permissionOptions: remotePermissionOptions, - initialPrompt: { - text: resume.promptText, - agent: resume.agent, - model: resume.model, - variant: resume.variant, - }, - debug, - }) + sourceRef: `refs/heads/${branch}`, + startRef: tip, + title: frozen.loopName, + plan: frozen.prompt ?? '', + executionModel: frozen.executionModel, + auditorModel: frozen.auditorModel, + executionVariant: frozen.executionVariant, + auditorVariant: frozen.auditorVariant, + permissionOptions: remotePermissionOptions, + forgeLoopOverrides: { + maxIterations: frozen.maxIterations, + resume: snapshot, + }, + initialPrompt: { + text: resume.promptText, + agent: resume.agent, + model: resume.model, + variant: resume.variant, + }, + pushErrorPrefix: `Failed to push loop branch to remote "${remote.name}": `, + debug, + }) - // 10. Rollback on launch failure: drop the sync ref (best effort, after a - // successful push), relabel the local loop plain-cancelled (restartable again). - if ('error' in launch) { - deleteForgeSyncRef(git, { cwd: projectDir, gitRemote: remote.gitRemote, syncRef }) - return rollbackAfterFreeze(deps, frozen, launch.error) + if ('error' in result) { + return rollbackAfterFreeze(deps, frozen, original, result.error) + } + launch = result + } catch (err) { + return rollbackAfterFreeze(deps, frozen, original, err instanceof Error ? err.message : String(err)) + } + + if (ownSyncRef && ownGitRemote) { + const cleanup = await deleteForgeSyncRef(git, { cwd: projectDir, gitRemote: ownGitRemote, syncRef: ownSyncRef }) + debug(`loop-migrate: previous sync-ref cleanup ${cleanup.ok ? 'ok' : `failed: ${cleanup.stderr.trim() || 'unknown error'}`}`) } - // 11. Success return ok({ operation: 'loop.migrate', loopName: frozen.loopName, @@ -187,24 +232,38 @@ export async function migrateLoopToRemote( }, warnings.length > 0 ? warnings : undefined) } -/** - * Post-freeze failure path: relabel the local loop plain-cancelled so it is - * restartable again and report the failure with the recovery instructions. - */ +function relabelInactiveLoop( + deps: ForgeExecutionServiceDeps, + loopName: string, + reason: TerminationReason, +): void { + deps.loop.service.terminate(loopName, { + status: terminationStatusFor(reason), + reason: terminationReasonToString(reason), + completedAt: Date.now(), + }) +} + function rollbackAfterFreeze( deps: ForgeExecutionServiceDeps, frozen: import('../loop/state').LoopState, + original: PreFreezeTerminalSnapshot, message: string, ): ForgeExecutionResponse { - deps.loop.service.terminate(frozen.loopName, { - status: 'cancelled', - reason: 'cancelled', - completedAt: Date.now(), - }) + if (original.status === 'cancelled' || original.status === 'errored' || original.status === 'stalled') { + deps.loop.service.terminate(frozen.loopName, { + status: original.status, + reason: original.terminationReason ?? 'cancelled', + completedAt: original.completedAt ? Date.parse(original.completedAt) : Date.now(), + summary: original.completionSummary, + }) + } else { + relabelInactiveLoop(deps, frozen.loopName, { kind: 'cancelled' }) + } deps.logger.error(`loop-migrate: migration of "${frozen.loopName}" failed after freeze: ${message}`) return fail( 'internal_error', 502, - `${message}. Local loop "${frozen.loopName}" is cancelled and restartable with loop-status restart=true.`, + `${message}. Local loop "${frozen.loopName}" is restartable with loop-status restart=true.`, ) } diff --git a/src/storage/repos/loops-repo.ts b/src/storage/repos/loops-repo.ts index c2d6c46c1..acb2e8fdb 100644 --- a/src/storage/repos/loops-repo.ts +++ b/src/storage/repos/loops-repo.ts @@ -55,6 +55,7 @@ export interface LoopsRepo { getBySessionId(projectId: string, sessionId: string): LoopRow | null listByStatus(projectId: string, statuses: LoopRow['status'][]): LoopRow[] listAll(projectId: string): LoopRow[] + listLoopNames(projectId: string): string[] updatePhase(projectId: string, loopName: string, phase: LoopRow['phase']): void updateIteration(projectId: string, loopName: string, iteration: number): void incrementError(projectId: string, loopName: string): number @@ -265,6 +266,13 @@ export function createLoopsRepo(db: Database): LoopsRepo { WHERE project_id = ? AND status IN ` + const listLoopNamesStmt = db.prepare(` + SELECT loop_name + FROM loops + WHERE project_id = ? + ORDER BY started_at DESC + `) + const updatePhaseStmt = db.prepare(` UPDATE loops SET phase = ? WHERE project_id = ? AND loop_name = ? `) @@ -545,6 +553,11 @@ export function createLoopsRepo(db: Database): LoopsRepo { return this.listByStatus(projectId, allStatuses) }, + listLoopNames(projectId: string): string[] { + const rows = listLoopNamesStmt.all(projectId) as { loop_name: string }[] + return rows.map((row) => row.loop_name) + }, + updatePhase(projectId: string, loopName: string, phase: LoopRow['phase']): void { updatePhaseStmt.run(phase, projectId, loopName) }, diff --git a/src/tools/loop.ts b/src/tools/loop.ts index 00e4776de..7553e398b 100644 --- a/src/tools/loop.ts +++ b/src/tools/loop.ts @@ -6,6 +6,7 @@ import { formatSessionOutput, formatAuditResult, formatCompletionSummary, format import { fetchSessionOutput, type LoopSessionOutput, MAX_RETRIES, type LoopState } from '../loop' import { formatDuration, computeElapsedSeconds, resolveUsageFallbackModelLabel, buildAuditorModelChain, auditorModelChoiceAt, usageRoleForPhase } from '../utils/loop-helpers' import { buildStartLoopCommand, createForgeExecutionService, type ForgeExecutionRequestContext, type PlanSource } from '../services/execution' +import type { ForgeExecutionError } from '../services/execution-response' import { resolveSessionPlanOfRecord } from '../services/plan-capture' import { formatLoopSessionTitle, formatPlanSessionTitle } from '../utils/session-titles' import { getRestartability } from '../loop/restartability' @@ -51,6 +52,13 @@ export function createLoopTools(ctx: ToolContext): Record `- ${c}`).join('\n')}` + } + return error.message + } + function makeService(sourceSessionId?: string) { const execCtx: ForgeExecutionRequestContext = { surface: 'tool', @@ -72,6 +80,7 @@ export function createLoopTools(ctx: ToolContext): Record `- ${c}`).join('\n')}` - return result.error.message + return formatExecutionError(result.error) } const d = result.data const branchInfo = d.worktreeBranch ? `\nBranch: ${d.worktreeBranch}` : '' @@ -299,10 +306,7 @@ export function createLoopTools(ctx: ToolContext): Record `- ${c}`).join('\n')}` - return result.error.message + return formatExecutionError(result.error) } const d = result.data const branchInfo = d.worktreeBranch ? `\nBranch: ${d.worktreeBranch}` : '' @@ -627,7 +631,7 @@ export function createLoopTools(ctx: ToolContext): Record `- ${c}`).join('\n')}` - return result.error.message + return formatExecutionError(result.error) } const d = result.data const lines: string[] = [ @@ -658,7 +660,7 @@ export function createLoopTools(ctx: ToolContext): Record fetchRef(cwd: string, remote: string, ref: string): GitResult worktreeAdd(cwd: string, directory: string, branch: string, createBranch: boolean, startPoint?: string): GitResult worktreeList(cwd: string): GitResult @@ -49,6 +57,24 @@ function runGit(args: string[], cwd: string): GitResult { return { ok: res.status === 0, status: res.status ?? -1, stdout: res.stdout ?? '', stderr: res.stderr ?? '' } } +const quietLogger: Logger = { + log: () => {}, + error: () => {}, + debug: () => {}, +} + +const GIT_PUSH_ASYNC_TIMEOUT_MS = 120000 + +async function runGitAsync(args: string[], cwd: string): Promise { + const res = await runCommand('git', [...HOOKS_DISABLED_ARGS, ...args], { + cwd, + logger: quietLogger, + logLabel: 'git', + timeout: GIT_PUSH_ASYNC_TIMEOUT_MS, + }) + return { ok: res.exitCode === 0, status: res.exitCode, stdout: res.stdout, stderr: res.stderr } +} + export function createGitService(): GitService { return { addAll(cwd: string): GitResult { @@ -112,6 +138,11 @@ export function createGitService(): GitService { return runGit(args, cwd) }, + async pushAsync(cwd: string, remote: string, refspec: string, force: boolean): Promise { + const args = force ? ['push', '--force', remote, refspec] : ['push', remote, refspec] + return runGitAsync(args, cwd) + }, + fetchRef(cwd: string, remote: string, ref: string): GitResult { return runGit(['fetch', remote, ref], cwd) }, diff --git a/src/utils/tui-client.ts b/src/utils/tui-client.ts index 47123eea5..70301e12e 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, buildAuditSessionPermissionRuleset, type LoopPermissionRulesetOptions } from '../constants/loop' +import { buildSessionPermissionRulesetForAgent, type LoopPermissionRulesetOptions } 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' @@ -365,10 +365,7 @@ export async function launchTuiLoop( model: parsedModel, variant: opts.executionVariant, } - const permission = - initial.agent === 'auditor-loop' - ? buildAuditSessionPermissionRuleset(opts.permissionOptions) - : buildLoopPermissionRuleset(opts.permissionOptions) + const permission = buildSessionPermissionRulesetForAgent(initial.agent, opts.permissionOptions) const session = await opts.client.session.create({ workspaceID: workspace.id, title: loopName, @@ -405,6 +402,7 @@ export async function launchTuiLoop( } } catch (err) { debug(`launchTuiLoop: post-create flow failed error=${err instanceof Error ? err.message : String(err)}`) + await opts.client.workspace.remove({ id: workspace.id }).catch(() => undefined) return { error: `Loop launch failed: ${err instanceof Error ? err.message : String(err)}` } } } diff --git a/src/utils/tui-remote-launch.ts b/src/utils/tui-remote-launch.ts index 0c4e8a2ef..6d79fc7f9 100644 --- a/src/utils/tui-remote-launch.ts +++ b/src/utils/tui-remote-launch.ts @@ -3,10 +3,11 @@ import { defaultGitService, type GitService, type GitResult } from './git-servic import { createRemoteForgeClient, type RemoteClientOptions } from '../client/sdk-adapter' import type { ForgeClient } from '../client/port' import type { PluginConfig } from '../types' -import { resolveRemoteLoopPermissionOptions } from '../constants/loop' +import { resolveRemoteLoopPermissionOptions, type LoopPermissionRulesetOptions } from '../constants/loop' import { emitLoopPermissionConfigWarnings } from './loop-permission-warnings' import { resolveDataDir } from './opencode-paths' -import { reserveTuiLoopName, launchTuiLoop } from './tui-client' +import { reserveTuiLoopName, launchTuiLoop, type LaunchInitialPrompt } from './tui-client' +import type { ForgeLoopExtra } from '../services/execution' export interface RemoteLoopRequest { remoteName: string @@ -122,11 +123,11 @@ export async function connectRemoteProject( * Push a local source ref to the shared git remote's forge sync ref. The sync * ref is never fetched locally, so the push is forced. */ -export function pushForgeSyncRef( +export async function pushForgeSyncRef( git: GitService, args: { cwd: string; gitRemote: string; sourceRef: string; syncRef: string }, -): { ok: true } | { ok: false; error: string } { - const pushResult = git.push(args.cwd, args.gitRemote, `${args.sourceRef}:${args.syncRef}`, true) +): Promise<{ ok: true } | { ok: false; error: string }> { + const pushResult = await git.pushAsync(args.cwd, args.gitRemote, `${args.sourceRef}:${args.syncRef}`, true) if (!pushResult.ok) { return { ok: false, error: pushResult.stderr || '(no stderr)' } } @@ -134,11 +135,134 @@ export function pushForgeSyncRef( } /** Delete the forge sync ref on the shared git remote (launch-failure cleanup). */ -export function deleteForgeSyncRef( +export async function deleteForgeSyncRef( git: GitService, args: { cwd: string; gitRemote: string; syncRef: string }, -): GitResult { - return git.push(args.cwd, args.gitRemote, `:${args.syncRef}`, false) +): Promise { + return git.pushAsync(args.cwd, args.gitRemote, `:${args.syncRef}`, false) +} + +export interface PrepareRemoteLoopLaunchInput { + client: ForgeClient + requestedLoopName: string + permissionOptions: LoopPermissionRulesetOptions + config: PluginConfig + dataDir: string + localDirectory: string + debug?: (message: string) => void + onWarnings?: (messages: string[]) => void +} + +export type PrepareRemoteLoopLaunchResult = { loopName: string; syncRef: string } + +export interface PushAndLaunchRemoteLoopInput { + git: GitService + cwd: string + remote: ResolvedRemoteServer + project: { id: string; worktree: string } + client: ForgeClient + loopName: string + syncRef: string + sourceRef: string + startRef: string + title: string + plan: string + executionModel?: string + auditorModel?: string + executionVariant?: string + auditorVariant?: string + permissionOptions: LoopPermissionRulesetOptions + forgeLoopOverrides?: Omit, 'sandboxEnabled'> + initialPrompt?: LaunchInitialPrompt + pushErrorPrefix?: string + debug?: (message: string) => void +} + +export type PushAndLaunchRemoteLoopResult = + | { loopName: string; sessionId: string } + | { error: string; pushed: boolean } + +export async function prepareRemoteLoopLaunch( + input: PrepareRemoteLoopLaunchInput, +): Promise { + const debug = input.debug ?? (() => {}) + const loopName = await reserveTuiLoopName(input.client, null, input.requestedLoopName) + const syncRef = forgeSyncRef(loopName) + debug(`remote-launch: reserved loop name="${loopName}" syncRef="${syncRef}"`) + emitLoopPermissionConfigWarnings(input.config, input.config.dataDir || input.dataDir, input.localDirectory, { + logger: { log: debug, error: debug, debug }, + onWarnings: (warnings) => input.onWarnings?.(warnings), + }) + return { loopName, syncRef } +} + +export async function pushAndLaunchRemoteLoop( + input: PushAndLaunchRemoteLoopInput, +): Promise { + const debug = input.debug ?? (() => {}) + const { remote } = input + + debug(`remote-launch: pushing ${input.sourceRef}:${input.syncRef} to gitRemote="${remote.gitRemote}" from "${input.cwd}"`) + const pushResult = await pushForgeSyncRef(input.git, { + cwd: input.cwd, + gitRemote: remote.gitRemote, + sourceRef: input.sourceRef, + syncRef: input.syncRef, + }) + if (!pushResult.ok) { + debug(`remote-launch: push FAILED: ${pushResult.error}`) + const message = input.pushErrorPrefix + ? `${input.pushErrorPrefix}${pushResult.error}` + : `Failed to push to remote "${remote.name}": ${pushResult.error}` + return { error: message, pushed: false } + } + debug(`remote-launch: push ok (ref ${input.syncRef} now on ${remote.gitRemote})`) + + let launchResult: Awaited> + try { + launchResult = await launchTuiLoop({ + client: input.client, + directory: input.project.worktree, + projectId: input.project.id, + requestedLoopName: input.loopName, + loopNameReserved: true, + connectPollIntervalMs: 500, + title: input.title, + plan: input.plan, + executionModel: input.executionModel, + auditorModel: input.auditorModel, + executionVariant: input.executionVariant, + auditorVariant: input.auditorVariant, + extraWorkspaceFields: { + startRef: input.startRef, + syncRef: input.syncRef, + gitRemote: remote.gitRemote, + permissionRules: input.permissionOptions.extraRules, + }, + forgeLoopOverrides: { + sandboxEnabled: remote.sandbox, + ...input.forgeLoopOverrides, + }, + permissionOptions: input.permissionOptions, + initialPrompt: input.initialPrompt, + debug, + }) + } catch (err) { + debug(`remote-launch: launchTuiLoop THREW: ${err instanceof Error ? err.message : String(err)}`) + const cleanup = await deleteForgeSyncRef(input.git, { cwd: input.cwd, gitRemote: remote.gitRemote, syncRef: input.syncRef }) + debug(`remote-launch: sync ref cleanup ${cleanup.ok ? 'ok' : `failed: ${cleanup.stderr.trim() || 'unknown error'}`}`) + throw err + } + + if ('error' in launchResult) { + debug(`remote-launch: launchTuiLoop FAILED: ${launchResult.error}`) + const cleanup = await deleteForgeSyncRef(input.git, { cwd: input.cwd, gitRemote: remote.gitRemote, syncRef: input.syncRef }) + debug(`remote-launch: sync ref cleanup ${cleanup.ok ? 'ok' : `failed: ${cleanup.stderr.trim() || 'unknown error'}`}`) + return { error: launchResult.error, pushed: false } + } + + debug(`remote-launch: launched loop="${launchResult.loopName}" session=${launchResult.sessionId} on "${remote.name}"`) + return { loopName: launchResult.loopName, sessionId: launchResult.sessionId } } export async function executeRemoteLoop( @@ -150,6 +274,8 @@ export async function executeRemoteLoop( debug(`remote-launch: start remote="${req.remoteName}" dir="${req.localDirectory}" projectId="${req.localProjectId}" loop="${req.loopName}"`) + let sha = '' + // 1+3+4. Resolve the remote server, discover the matching remote project, and // create the scoped client. The git preflight is threaded through as a gate // so the error precedence (unknown remote → git checks → discovery) is kept. @@ -167,7 +293,8 @@ export async function executeRemoteLoop( if (!headResult.ok) { return { error: `Failed to resolve HEAD in ${req.localDirectory}: ${headResult.stderr}` } } - debug(`remote-launch: preflight ok HEAD=${headResult.stdout.trim()}`) + sha = headResult.stdout.trim() + debug(`remote-launch: preflight ok HEAD=${sha}`) return undefined }, }, @@ -175,7 +302,6 @@ export async function executeRemoteLoop( if ('error' in connected) return connected const { remote, project: matched, client: remoteClient } = connected - const sha = git.revParseHead(req.localDirectory).stdout.trim() // Warn about dirty working tree but proceed const statusResult = git.statusPorcelain(req.localDirectory) @@ -183,77 +309,46 @@ export async function executeRemoteLoop( deps.onWarning?.(`Uncommitted changes are not included; remote loop starts from HEAD ${sha.substring(0, 7)}`) } - // 5. Reserve a unique loop name (once; launchTuiLoop uses it verbatim below) - const finalLoopName = await reserveTuiLoopName(remoteClient, null, req.loopName) - const syncRef = forgeSyncRef(finalLoopName) - debug(`remote-launch: reserved loop name="${finalLoopName}" syncRef="${syncRef}"`) - // Resolve the portable configured rules once for both the persisted workspace // field and the launch options. Host-specific directory grants stay omitted // because they do not exist on the remote machine. const remotePermissionOptions = resolveRemoteLoopPermissionOptions(deps.config) - emitLoopPermissionConfigWarnings(deps.config, deps.config.dataDir || resolveDataDir(), req.localDirectory, { - logger: { log: debug, error: debug, debug }, + const prepared = await prepareRemoteLoopLaunch({ + client: remoteClient, + requestedLoopName: req.loopName, + permissionOptions: remotePermissionOptions, + config: deps.config, + dataDir: deps.config.dataDir || resolveDataDir(), + localDirectory: req.localDirectory, + debug, onWarnings: (warnings) => deps.onWarning?.(warnings.join(' ')), }) - // 6. Push HEAD to remote ref - debug(`remote-launch: pushing HEAD:${syncRef} to gitRemote="${remote.gitRemote}" from "${req.localDirectory}"`) - const pushResult = pushForgeSyncRef(git, { + const launched = await pushAndLaunchRemoteLoop({ + git, cwd: req.localDirectory, - gitRemote: remote.gitRemote, - sourceRef: 'HEAD', - syncRef, - }) - if (!pushResult.ok) { - debug(`remote-launch: push FAILED: ${pushResult.error}`) - return { error: `Failed to push to remote "${remote.name}": ${pushResult.error}` } - } - debug(`remote-launch: push ok (ref ${syncRef} now on ${remote.gitRemote})`) - - // 7. Launch the remote loop - const launchResult = await launchTuiLoop({ + remote, + project: matched, client: remoteClient, - directory: matched.worktree, - projectId: matched.id, - requestedLoopName: finalLoopName, - loopNameReserved: true, - connectPollIntervalMs: 500, + loopName: prepared.loopName, + syncRef: prepared.syncRef, + sourceRef: 'HEAD', + startRef: sha, title: req.title, plan: req.plan, executionModel: req.executionModel, auditorModel: req.auditorModel, executionVariant: req.executionVariant, auditorVariant: req.auditorVariant, - extraWorkspaceFields: { - startRef: sha, - syncRef, - gitRemote: remote.gitRemote, - // Persist the portable configured rules so every subsequent session of the - // remote loop (rotations, audits, post-actions) keeps them: those sessions - // rebuild rulesets from the remote server's own config, which lacks the - // launching machine's loop.permissions. - permissionRules: remotePermissionOptions.extraRules, - }, - forgeLoopOverrides: { - sandboxEnabled: remote.sandbox, - }, permissionOptions: remotePermissionOptions, debug, }) + if ('error' in launched) return { error: launched.error } - if ('error' in launchResult) { - debug(`remote-launch: launchTuiLoop FAILED: ${launchResult.error}`) - const cleanup = deleteForgeSyncRef(git, { cwd: req.localDirectory, gitRemote: remote.gitRemote, syncRef }) - debug(`remote-launch: sync ref cleanup ${cleanup.ok ? 'ok' : `failed: ${cleanup.stderr.trim() || 'unknown error'}`}`) - return { error: launchResult.error } - } - - debug(`remote-launch: launched loop="${launchResult.loopName}" session=${launchResult.sessionId} on "${remote.name}"`) return { - loopName: launchResult.loopName, - sessionId: launchResult.sessionId, + loopName: launched.loopName, + sessionId: launched.sessionId, remoteName: remote.name, } } diff --git a/src/workspace/forge-adapter.ts b/src/workspace/forge-adapter.ts index 48f69a8c0..739bcc257 100644 --- a/src/workspace/forge-adapter.ts +++ b/src/workspace/forge-adapter.ts @@ -8,6 +8,7 @@ import { forgeBranchName, forgeWorktreeDir, forgeWorktreeSlug } from './forge-na import { cleanupLoopWorktree } from '../utils/worktree-cleanup' import { defaultGitService, type GitService } from '../utils/git-service' import { forgeSyncRef, DEFAULT_GIT_REMOTE } from '../utils/remote-config' +import { deleteForgeSyncRef } from '../utils/tui-remote-launch' import { writeWorktreeOpencodeConfig, WORKTREE_OPENCODE_CONFIG_FILENAME } from './worktree-opencode-config' import { commitWorktreeChanges } from './worktree-commit' import { sandboxContainerName } from '../sandbox/msb' @@ -194,13 +195,13 @@ export function createForgeWorkspaceAdapter(deps: ForgeAdapterDeps): WorkspaceAd * commit locally, so the shared ref is no longer needed. Restart-preserving * teardowns keep the ref in place. */ - function stepDeleteSyncRef(info: WorkspaceInfo, loopName: string, ctx: TeardownContext): void { + async function stepDeleteSyncRef(info: WorkspaceInfo, loopName: string, ctx: TeardownContext): Promise { if (!ctx.doRemoveWorktree) return const pin = deriveSyncPin(info, loopName) if (!pin) return try { const projectDir = deriveProjectDirectory(info) - const res = git.push(projectDir, pin.gitRemote, `:${pin.syncRef}`, false) + const res = await deleteForgeSyncRef(git, { cwd: projectDir, gitRemote: pin.gitRemote, syncRef: pin.syncRef }) if (res.ok) { logger.log(`forge-adapter: deleted sync ref ${pin.syncRef} on ${pin.gitRemote}`) } else { @@ -369,7 +370,7 @@ export function createForgeWorkspaceAdapter(deps: ForgeAdapterDeps): WorkspaceAd await stepRemoveWorktree(info.directory, ctx) // Remote-launched loops: drop the sync ref from the shared git remote. - stepDeleteSyncRef(info, loopName, ctx) + await stepDeleteSyncRef(info, loopName, ctx) // Branches are never deleted — `forge/*` scratch branches stay in place for potential restart. }, diff --git a/test/constants/loop.test.ts b/test/constants/loop.test.ts index 363c6fff5..e7b6c635f 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, resolveLoopPermissionOptions, MAX_TOTAL_SECTIONS, PLAN_AUTHORING_TOOL_NAMES, FORGE_MANAGED_PERMISSIONS } from '../../src/constants/loop' +import { buildLoopPermissionRuleset, buildAuditSessionPermissionRuleset, buildSessionPermissionRulesetForAgent, resolveLoopAllowedDirectories, resolveLoopPermissionOptions, MAX_TOTAL_SECTIONS, PLAN_AUTHORING_TOOL_NAMES, FORGE_MANAGED_PERMISSIONS } from '../../src/constants/loop' import { resolveOpencodeToolOutputDir, resolveOpencodeTmpDir } from '../../src/utils/opencode-paths' const TOOL_OUTPUT_DIR = resolveOpencodeToolOutputDir() @@ -257,3 +257,20 @@ describe('config -> resolveLoopPermissionOptions -> ruleset composition', () => }) } }) + +describe('buildSessionPermissionRulesetForAgent', () => { + const options = { allowDirectories: ['/vault'], extraRules: [{ permission: 'webfetch', pattern: '*', action: 'deny' as const }] } + + it('auditor-loop agent gets exactly the audit ruleset', () => { + expect(buildSessionPermissionRulesetForAgent('auditor-loop', options)).toEqual(buildAuditSessionPermissionRuleset(options)) + }) + + it('code agent gets exactly the loop ruleset', () => { + expect(buildSessionPermissionRulesetForAgent('code', options)).toEqual(buildLoopPermissionRuleset(options)) + }) + + it('defaults options like the underlying builders when omitted', () => { + expect(buildSessionPermissionRulesetForAgent('code')).toEqual(buildLoopPermissionRuleset()) + expect(buildSessionPermissionRulesetForAgent('auditor-loop')).toEqual(buildAuditSessionPermissionRuleset()) + }) +}) diff --git a/test/helpers/fake-git.ts b/test/helpers/fake-git.ts index c7855ef28..bea3139dc 100644 --- a/test/helpers/fake-git.ts +++ b/test/helpers/fake-git.ts @@ -19,6 +19,9 @@ export function createFakeGitService(overrides?: Partial): GitServic revParseRef: vi.fn<[string, string], GitResult>(() => ({ ...defaultOk })), commitExists: vi.fn<[string, string], boolean>(() => false), push: vi.fn<[string, string, string, boolean], GitResult>(() => ({ ...defaultOk })), + pushAsync: vi.fn<[string, string, string, boolean], Promise>(async function (this: { push: (cwd: string, remote: string, refspec: string, force: boolean) => GitResult }, cwd, remote, refspec, force) { + return this.push(cwd, remote, refspec, force) + }), fetchRef: vi.fn<[string, string, string], GitResult>(() => ({ ...defaultOk })), worktreeAdd: vi.fn<[string, string, string, boolean, string?], GitResult>(() => ({ ...defaultOk })), worktreeList: vi.fn<[string], GitResult>(() => ({ ...defaultOk })), diff --git a/test/hooks/forge-session-attach.test.ts b/test/hooks/forge-session-attach.test.ts index 2cc4f0111..4fa8ba9d7 100644 --- a/test/hooks/forge-session-attach.test.ts +++ b/test/hooks/forge-session-attach.test.ts @@ -1367,6 +1367,133 @@ describe('createForgeSessionAttachHook', () => { expect(plansRepoGetForSession).not.toHaveBeenCalled() }) + test('chat.message fallback removes workspace and publishes toast when resume snapshot is invalid', async () => { + const workspaceRemove = vi.fn().mockResolvedValue(undefined) + const tuiPublish = vi.fn().mockResolvedValue(undefined) + const loggerErrorSpy = vi.fn() + const plansRepoGetForSession = vi.fn().mockReturnValue({ content: 'SHOULD_NOT_BE_USED' }) + const deps = buildHookDeps({ + sessionGet: vi.fn().mockResolvedValue({ + id: 'new_sess', + workspaceID: 'ws_bad_resume', + directory: '/tmp/wt/bad-resume', + projectID: 'proj_1', + }), + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_bad_resume', + type: 'forge', + directory: '/tmp/wt/bad-resume', + extra: { + loopName: 'bad-resume-loop', + projectDirectory: '/tmp/wt/bad-resume', + forgeLoop: { + initialPromptOwner: 'tui', + pendingAttachStartedAt: Date.now(), + planSource: 'inline', + planText: '', + maxIterations: 12, + resume: { version: 2 }, + }, + }, + }, + ]), + plansRepoGetForSession, + loopsRepoGet: vi.fn().mockReturnValue(null), + workspaceRemove, + tuiPublish, + loggerErrorSpy, + }) + + const handler = createForgeSessionMessageAttachHook(deps as any) + + await handler({ sessionID: 'new_sess' }) + + expect(mockAttachLoop).not.toHaveBeenCalled() + expect(plansRepoGetForSession).not.toHaveBeenCalled() + expect(loggerErrorSpy).toHaveBeenCalledWith(expect.stringContaining('invalid resume snapshot')) + expect(workspaceRemove).toHaveBeenCalledWith({ id: 'ws_bad_resume' }) + expect(deps.execDeps.pendingTeardowns.set).toHaveBeenCalledWith( + 'bad-resume-loop', + expect.objectContaining({ doRemoveWorktree: true, doCommit: false }), + ) + expect(tuiPublish).toHaveBeenCalledWith(expect.objectContaining({ + directory: '/tmp/wt/bad-resume', + body: expect.objectContaining({ + type: 'tui.toast.show', + properties: expect.objectContaining({ + title: 'Forge loop "bad-resume-loop"', + message: expect.stringContaining('Migrated loop snapshot is invalid'), + variant: 'error', + }), + }), + })) + }) + + test('terminal restartable toast mentions remote server when terminationReason kind is migrated', async () => { + const tuiPublish = vi.fn().mockResolvedValue(undefined) + const workspaceRemove = vi.fn().mockResolvedValue(undefined) + const loopsRepoGetMock = vi.fn().mockReturnValue({ + projectId: 'proj_1', + loopName: 'migrated-row-loop', + status: 'cancelled', + terminationReason: 'migrated: https://remote.example.com', + }) + const deps = buildHookDeps({ + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_migrated_row', + type: 'forge', + directory: '/tmp/wt/migrated-row', + extra: { + loopName: 'migrated-row-loop', + projectDirectory: '/tmp/wt/migrated-row', + forgeLoop: { + title: 'Migrated Row', + planSource: 'inline', + planText: '# Plan', + }, + }, + }, + ]), + loopsRepoGet: loopsRepoGetMock, + workspaceRemove, + tuiPublish, + }) + + const handler = createForgeSessionAttachHook(deps as any) + + await handler({ + event: { + type: 'session.created', + properties: { + info: { id: 'sess_migrated_row', workspaceID: 'ws_migrated_row' }, + }, + }, + }) + + expect(mockAttachLoop).not.toHaveBeenCalled() + expect(workspaceRemove).toHaveBeenCalledWith({ id: 'ws_migrated_row' }) + expect(deps.execDeps.pendingTeardowns.set).toHaveBeenCalledWith( + 'migrated-row-loop', + expect.objectContaining({ doRemoveWorktree: false, doCommit: false }), + ) + expect(tuiPublish).toHaveBeenCalledWith(expect.objectContaining({ + body: expect.objectContaining({ + properties: expect.objectContaining({ + message: expect.stringContaining('was migrated to remote "https://remote.example.com"'), + }), + }), + })) + expect(tuiPublish).toHaveBeenCalledWith(expect.objectContaining({ + body: expect.objectContaining({ + properties: expect.objectContaining({ + message: expect.stringContaining('loop-status restart=true force=true'), + }), + }), + })) + }) + test('attach hook prefers inline planText over stored plan when both are available', async () => { const plansRepoGetForSession = vi.fn().mockReturnValue({ content: 'STALE_PRIOR_PLAN_TEXT' }) const deps = buildHookDeps({ diff --git a/test/hooks/loop-section-advancement.test.ts b/test/hooks/loop-section-advancement.test.ts index 011cbcbf0..846f0aa04 100644 --- a/test/hooks/loop-section-advancement.test.ts +++ b/test/hooks/loop-section-advancement.test.ts @@ -606,6 +606,7 @@ describe('Loop Section Advancement', () => { return { ...ok } }, isPathTracked: () => false, + pushAsync: async () => ({ ...ok }), } as unknown as import('../../src/utils/git-service').GitService return { git, commits } } diff --git a/test/loop/restartability.test.ts b/test/loop/restartability.test.ts index 4e995b659..0668dfca7 100644 --- a/test/loop/restartability.test.ts +++ b/test/loop/restartability.test.ts @@ -94,15 +94,16 @@ describe('getRestartability', () => { expect(result.restartBlockedReason).toBe('active_requires_force') }) - it('blocks restart of a loop migrated to a remote server', () => { + it('allows force-restart of a loop migrated to a remote server', () => { const state = makeState({ terminationReason: 'migrated: server1' }) const result = getRestartability(state, { worktreeExists: () => true, branchExists: () => true, }) - expect(result.restartable).toBe(false) - expect(result.restartRequiresForce).toBe(false) + expect(result.restartable).toBe(true) + expect(result.restartRequiresForce).toBe(true) expect(result.restartBlockedReason).toBe('migrated') expect(result.restartBlockedMessage).toContain('server1') + expect(result.restartBlockedMessage).toContain('force=true') }) }) diff --git a/test/loop/resume-prompt.test.ts b/test/loop/resume-prompt.test.ts index 748e455b7..9cd2d1e58 100644 --- a/test/loop/resume-prompt.test.ts +++ b/test/loop/resume-prompt.test.ts @@ -12,7 +12,7 @@ import type { Logger } from '../../src/types' import type { PluginConfig } from '../../src/types' import type { LoopState } from '../../src/loop/state' import { setupLoopsTestDb } from '../helpers/loops-test-db' -import { buildResumePromptPlan } from '../../src/loop/resume-prompt' +import { buildResumePromptPlan, resolveResumeAgent } from '../../src/loop/resume-prompt' const PROJECT_ID = 'test-project' const noopLogger: Logger = { log: () => {}, error: () => {}, debug: () => {} } @@ -100,7 +100,6 @@ describe('buildResumePromptPlan', () => { expect(plan.phase).toBe('coding') expect(plan.agent).toBe('code') - expect(plan.permission).toBe('loop') expect(plan.promptText).toBe(service.buildSectionInitialPrompt(state)) expect(plan.promptText).toContain('Section 0 content') }) @@ -112,18 +111,17 @@ describe('buildResumePromptPlan', () => { expect(plan.phase).toBe('coding') expect(plan.agent).toBe('code') - expect(plan.permission).toBe('loop') expect(plan.promptText).toBe('Legacy plan text') }) - test('persisted final_auditing resumes the final audit with the auditor agent and audit permission', () => { + test('persisted final_auditing resumes the final audit with the auditor agent and audit ruleset', () => { const state = seedLoopWithSections({ loopName: 'final-audit-loop', phase: 'final_auditing' }, 3) const plan = buildResumePromptPlan({ service, config: baseConfig, state: service.getAnyState(state.loopName)! }) expect(plan.phase).toBe('final_auditing') expect(plan.agent).toBe('auditor-loop') - expect(plan.permission).toBe('audit') + expect(resolveResumeAgent(plan.phase)).toBe('auditor-loop') expect(plan.promptText).toBe(service.buildFinalAuditPrompt(service.getAnyState(state.loopName)!)) expect(plan.model).toEqual({ providerID: 'prov', modelID: 'aud' }) }) @@ -135,7 +133,6 @@ describe('buildResumePromptPlan', () => { expect(plan.phase).toBe('coding') expect(plan.agent).toBe('code') - expect(plan.permission).toBe('loop') expect(plan.promptText).toBe(service.buildFinalAuditFixPrompt(state, service.getOutstandingFindings(state.loopName, 'bug'))) }) @@ -146,7 +143,6 @@ describe('buildResumePromptPlan', () => { expect(plan.phase).toBe('coding') expect(plan.agent).toBe('code') - expect(plan.permission).toBe('loop') expect(plan.promptText).toBe(service.buildContinuationPrompt(state, undefined)) expect(plan.promptText).toContain('Ship the endpoint.') }) @@ -189,3 +185,14 @@ describe('buildResumePromptPlan', () => { expect(codePlan.variant).toBe('exec-v') }) }) + +describe('resolveResumeAgent', () => { + test('final_auditing maps to the auditor-loop agent', () => { + expect(resolveResumeAgent('final_auditing')).toBe('auditor-loop') + }) + + test('coding and post_action map to the code agent', () => { + expect(resolveResumeAgent('coding')).toBe('code') + expect(resolveResumeAgent('post_action')).toBe('code') + }) +}) diff --git a/test/loop/resume-snapshot.test.ts b/test/loop/resume-snapshot.test.ts index 8379482a6..7def3c5e9 100644 --- a/test/loop/resume-snapshot.test.ts +++ b/test/loop/resume-snapshot.test.ts @@ -362,6 +362,24 @@ describe('restoreLoopResumeRows', () => { ...makeSnapshot(), findings: 42, })).toBe(false) + expect(isLoopResumeSnapshot({ + ...makeSnapshot(), + findings: [ + { file: 'a.ts', line: 1, severity: 'critical', description: 'bad severity', scenario: null, sectionIndex: 1 }, + ], + })).toBe(false) + expect(isLoopResumeSnapshot({ + ...makeSnapshot(), + findings: [ + { file: 'a.ts', line: 1, severity: 'bug', description: 'bad sectionIndex', scenario: null, sectionIndex: 'one' }, + ], + })).toBe(false) + expect(isLoopResumeSnapshot({ + ...makeSnapshot(), + sections: [ + { sectionIndex: 0, title: 'S0', content: 'Section 0 content', status: 'archived', attempts: 0, summaryDone: null, summaryDeviations: null, summaryFollowUps: null, startedAt: null, completedAt: null }, + ], + })).toBe(false) expect(isLoopResumeSnapshot({ version: 1, kind: 'goal', diff --git a/test/services/execution-restart.test.ts b/test/services/execution-restart.test.ts index e3813a10e..899c85eed 100644 --- a/test/services/execution-restart.test.ts +++ b/test/services/execution-restart.test.ts @@ -202,6 +202,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: noopFn, @@ -256,6 +258,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -323,6 +329,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: noopFn, @@ -377,6 +385,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -422,6 +434,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: noopFn, @@ -478,6 +492,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -525,6 +543,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: noopFn, @@ -597,6 +617,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -631,6 +655,152 @@ describe('handleLoopRestart from stall_timeout', () => { ) }) + test('restart drops the resume snapshot from the preserved forgeLoop extra but keeps the other forgeLoop fields', async () => { + insertLoop({ + loopName: 'snapshot-loop', + status: 'errored', + terminationReason: 'max_iterations', + iteration: 10, + worktree: true, + workspaceId: 'ws_old', + }) + + const noopFn = () => {} + const mockLoopService: Partial = { + listActive: () => loopService.listActive(), + listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), + getActiveState: (name) => loopService.getActiveState(name), + getAnyState: (name) => loopService.getAnyState(name), + registerLoopSession: noopFn, + setState: (name, state) => loopService.setState(name, state), + deleteState: (name) => loopService.deleteState(name), + setPhase: noopFn, + buildSectionInitialPrompt: () => 'section prompt', + buildFinalAuditPrompt: () => 'audit prompt', + recordTransition: (name, entry) => loopService.recordTransition(name, entry), + recordTerminalTransition: (name, entry) => loopService.recordTerminalTransition(name, entry), + restoreState: (name, state) => loopService.restoreState(name, state), + getOutstandingFindings: (name, severity) => loopService.getOutstandingFindings(name, severity), + generateUniqueLoopName: () => 'snapshot-loop', + } + + const { client } = createFakeForgeClient({ + session: { + create: async () => ({ id: 'new-sess-snapshot' }), + get: async () => ({}), + promptAsync: async () => {}, + abort: async () => {}, + delete: async () => {}, + messages: async () => [], + status: async () => ({}), + }, + workspace: { + create: async () => ({ id: 'ws_new', directory: '/tmp', branch: 'forge/snapshot-loop' }), + list: async () => [ + { + id: 'ws_old', + type: 'forge', + name: 'snapshot-loop', + branch: null, + directory: '/tmp', + projectID: PROJECT_ID, + extra: { + startRef: 'abc123', + syncRef: 'refs/forge/snapshot-loop', + gitRemote: 'origin', + forgeLoop: { + hostSessionId: 'host-sess', + title: 'Snapshot Loop', + executionModel: 'prov/exec', + planSource: 'stored', + planText: '# Snapshot plan', + maxIterations: 40, + resume: { + version: 1, + kind: 'plan', + phase: 'coding', + currentSectionIndex: 1, + totalSections: 3, + finalAuditDone: false, + sections: [], + findings: [], + }, + }, + }, + }, + ], + remove: async () => {}, + warp: async () => {}, + syncList: async () => {}, + }, + tui: { publish: async () => {}, selectSession: async () => {} }, + sync: { start: async () => {} }, + }) + + const mockLoopHandler = { + runExclusive: async (name: string, fn: () => Promise) => fn(), + startWatchdog: noopFn, + clearLoopTimers: noopFn, + } + + const { createForgeExecutionService } = await import('../../src/services/execution') + const service = createForgeExecutionService({ + projectId: PROJECT_ID, + directory: '/tmp/test', + config: { loop: { enabled: true }, executionModel: 'prov/exec', auditorModel: 'prov/aud' }, + logger: mockLogger, + dataDir: '/tmp', + + plansRepo, + loopsRepo, + loop: { + service: mockLoopService, + listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), + listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), + generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), + registerSessionReverseIndex: () => {}, + unregisterSessionReverseIndex: () => {}, + } as any, + loopHandler: mockLoopHandler as any, + sectionPlansRepo, + workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, + client, + pendingTeardowns: mockPendingTeardowns as any, + }) + + const result = await service.dispatch( + { surface: 'api', projectId: PROJECT_ID, directory: '/tmp/test' }, + { type: 'loop.restart' as const, selector: { kind: 'exact' as const, name: 'snapshot-loop' } }, + ) + + expect(result.ok).toBe(true) + + expect(client.workspace.create).toHaveBeenCalledWith( + expect.objectContaining({ + extra: { + forgeLoop: { + hostSessionId: 'host-sess', + title: 'Snapshot Loop', + executionModel: 'prov/exec', + planSource: 'stored', + planText: '# Snapshot plan', + maxIterations: 40, + }, + loopName: 'snapshot-loop', + projectDirectory: '/tmp', + workspaceCreatedAt: expect.any(Number), + }, + }), + ) + }) + test('restart from stall_timeout routes to final_auditing when persisted phase is final_auditing', async () => { insertLoop({ loopName: 'final-audit-loop', @@ -664,6 +834,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: noopFn, @@ -718,6 +890,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -781,6 +957,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: noopFn, @@ -835,6 +1013,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -892,6 +1074,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: noopFn, @@ -947,6 +1131,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -1009,6 +1197,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: (sid: string, name: string) => loopService.registerLoopSession(sid, name), @@ -1061,6 +1251,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -1136,6 +1330,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: (sid: string, name: string) => loopService.registerLoopSession(sid, name), @@ -1170,6 +1366,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -1244,6 +1444,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => { if (!transitioned) { transitioned = true @@ -1316,6 +1518,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -1402,6 +1608,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => { if (!transitioned) { transitioned = true @@ -1476,6 +1684,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -1550,6 +1762,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: noopFn, @@ -1607,6 +1821,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -1767,6 +1985,8 @@ describe('handleLoopRestart restartability rules', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: noopFn, @@ -1879,6 +2099,10 @@ describe('handleLoopRestart restartability rules', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -2992,6 +3216,8 @@ describe('handleLoopRestart restartability rules', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: noopFn, @@ -3066,6 +3292,8 @@ describe('handleLoopRestart restartability rules', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, diff --git a/test/services/loop-migration.test.ts b/test/services/loop-migration.test.ts index 23804953d..44c3a2ac2 100644 --- a/test/services/loop-migration.test.ts +++ b/test/services/loop-migration.test.ts @@ -1,12 +1,13 @@ -import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest' +import { describe, test, expect, beforeEach, afterEach, vi, type Mock } from 'vitest' import { Database } from 'bun:sqlite' -import { mkdtempSync } from 'fs' +import { mkdtempSync, mkdirSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' 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 { createSectionPlansRepo } from '../../src/storage/repos/section-plans-repo' +import { createFeatureGroupsRepo, type FeatureGroupsRepo } from '../../src/storage/repos/feature-groups-repo' import { createLoopTransitionsRepo } from '../../src/storage/repos/loop-transitions-repo' import { createLoopService } from '../../src/loop/service' import type { LoopService } from '../../src/loop/service' @@ -25,9 +26,6 @@ import { createFakeForgeClient } from '../helpers/fake-client' import { createFakeGitService } from '../helpers/fake-git' import { createClientSpy, REMOTE_URL, LOCAL_PROJECT_ID } from '../helpers/fake-remote-client' -// launchTuiLoop's transitive TUI/storage imports must not touch real state; -// the repos below use the bun:sqlite vitest shim directly, so the storage -// module that launchTuiLoop pulls in is mocked by name instead. vi.mock('../../src/utils/tui-execution-preferences', () => ({ deriveExecutionPreferencesFromWorkspaces: vi.fn().mockReturnValue(null), })) @@ -45,6 +43,14 @@ vi.mock('../../src/storage', () => ({ resolveLogPath: vi.fn().mockReturnValue('/tmp/forge-test.log'), resolveDataDir: vi.fn().mockReturnValue('/tmp/forge-test-data'), })) +vi.mock('../../src/loop/resume-snapshot', async (importOriginal) => { + const original = await importOriginal() + return { ...original, captureLoopResumeSnapshot: vi.fn(original.captureLoopResumeSnapshot) } +}) +vi.mock('../../src/loop/resume-prompt', async (importOriginal) => { + const original = await importOriginal() + return { ...original, buildResumePromptPlan: vi.fn(original.buildResumePromptPlan) } +}) const mockLogger: Logger = { log: () => {}, error: () => {}, debug: () => {} } const noopFn = () => {} @@ -62,6 +68,7 @@ describe('migrateLoopToRemote', () => { let reviewFindingsRepo: ReviewFindingsRepo let sectionPlansRepo: SectionPlansRepo let loopTransitionsRepo: LoopTransitionsRepo + let featureGroupsRepo: FeatureGroupsRepo let loopService: LoopService const mockWorkspaceStatusRegistry = { @@ -85,6 +92,7 @@ describe('migrateLoopToRemote', () => { reviewFindingsRepo = createReviewFindingsRepo(db) sectionPlansRepo = createSectionPlansRepo(db) loopTransitionsRepo = createLoopTransitionsRepo(db) + featureGroupsRepo = createFeatureGroupsRepo(db) loopService = createLoopService( loopsRepo, plansRepo, @@ -107,6 +115,9 @@ describe('migrateLoopToRemote', () => { active?: boolean status?: 'running' | 'completed' | 'cancelled' | 'errored' | 'stalled' terminationReason?: string + worktreeDir?: string + worktree?: boolean + workspaceId?: string } function seedLoop(opts: SeedOpts = {}): LoopState { @@ -116,7 +127,7 @@ describe('migrateLoopToRemote', () => { active, sessionId: 'sess_local', loopName, - worktreeDir: '/local/wt', + worktreeDir: opts.worktreeDir ?? '/local/wt', projectDir: '/local/proj', worktreeBranch: 'forge/my-loop', iteration: 3, @@ -127,13 +138,14 @@ describe('migrateLoopToRemote', () => { errorCount: 0, auditCount: 1, status: opts.status ?? (active ? 'running' : 'cancelled'), - worktree: true, + worktree: opts.worktree ?? true, sandbox: false, executionModel: 'prov/exec', auditorModel: 'prov/aud', currentSectionIndex: 1, totalSections: 3, finalAuditDone: false, + ...(opts.workspaceId ? { workspaceId: opts.workspaceId } : {}), ...(opts.terminationReason ? { terminationReason: opts.terminationReason } : {}), } loopService.setState(loopName, state) @@ -182,6 +194,9 @@ describe('migrateLoopToRemote', () => { git?: GitService createRemoteClient?: (o: unknown) => ForgeClient onTerminateLoopByName?: (name: string, reason: unknown) => void + terminateLoopByNameReturnsFalse?: boolean + featureGroupsRepo?: FeatureGroupsRepo + localWorkspaceList?: () => Promise>> } = {}) { const terminateCalls: Array<{ name: string; reason: string }> = [] @@ -191,6 +206,7 @@ describe('migrateLoopToRemote', () => { clearLoopTimers: noopFn, terminateLoopByName: async (name: string, reason: { kind: string; message?: string }) => { opts.onTerminateLoopByName?.(name, reason) + if (opts.terminateLoopByNameReturnsFalse) return false terminateCalls.push({ name, reason: `${reason.kind}${reason.message ? `: ${reason.message}` : ''}` }) const state = loopService.getActiveState(name) if (!state?.active) return false @@ -203,7 +219,9 @@ describe('migrateLoopToRemote', () => { }, } - const localClient = createFakeForgeClient().client + const localClient = createFakeForgeClient( + opts.localWorkspaceList ? { workspace: { list: opts.localWorkspaceList } } : undefined, + ).client const remoteSpy = opts.createRemoteClient ?? createClientSpy().spy const { createForgeExecutionService } = await import('../../src/services/execution') @@ -220,14 +238,18 @@ describe('migrateLoopToRemote', () => { inspect: (name: string) => loopService.getAnyState(name), listActive: (...args: unknown[]) => (loopService.listActive as (...a: unknown[]) => LoopState[])(...args), listRecent: (...args: unknown[]) => (loopService.listRecent as (...a: unknown[]) => LoopState[])(...args), + listLoopNames: (...args: unknown[]) => (loopService.listLoopNames as (...a: unknown[]) => string[])(...args), + findMatchByName: (...args: unknown[]) => (loopService.findMatchByName as (...a: unknown[]) => { match: LoopState | null; candidates: LoopState[] })(...args), setPhase: (...args: unknown[]) => (loopService.setPhase as (...a: unknown[]) => void)(...args), generateUniqueLoopName: (...args: unknown[]) => (loopService.generateUniqueLoopName as (...a: unknown[]) => string)(...args), + runExclusive: async (_name: string, fn: () => Promise) => fn(), registerSessionReverseIndex: noopFn, unregisterSessionReverseIndex: noopFn, } as unknown as Parameters[0]['loop'], loopHandler: mockLoopHandler as never, sectionPlansRepo, reviewFindingsRepo, + featureGroupsRepo: opts.featureGroupsRepo, workspaceStatusRegistry: mockWorkspaceStatusRegistry as never, client: localClient, pendingTeardowns: mockPendingTeardowns as never, @@ -387,4 +409,200 @@ describe('migrateLoopToRemote', () => { expect(terminateCalls).toHaveLength(0) expect(git.push).not.toHaveBeenCalled() }) + + test('a worktree:false loop is refused before any remote call', async () => { + seedLoop({ worktree: false }) + const git = happyGit() + const { spy: createClient } = createClientSpy() + const { service } = await buildDeps({ git, createRemoteClient: createClient as never }) + + const result = await service.dispatch(ctx, migrateCommand) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected error') + expect(result.error.code).toBe('conflict') + expect(result.error.message).toContain('worktree: false') + expect(createClient).not.toHaveBeenCalled() + expect(git.push).not.toHaveBeenCalled() + }) + + test('a feature-group loop is refused', async () => { + seedLoop() + featureGroupsRepo.createGroup({ projectId: PROJECT_ID, groupId: 'g1', title: 'Group', status: 'running' }) + featureGroupsRepo.insertFeatures(PROJECT_ID, 'g1', [{ title: 'Feature', description: 'Do it' }]) + featureGroupsRepo.setFeatureLoopName(PROJECT_ID, 'g1', 0, 'my-loop') + const git = happyGit() + const { spy: createClient } = createClientSpy() + const { service } = await buildDeps({ git, createRemoteClient: createClient as never, featureGroupsRepo }) + + const result = await service.dispatch(ctx, migrateCommand) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected error') + expect(result.error.code).toBe('conflict') + expect(result.error.message).toContain('feature group') + expect(createClient).not.toHaveBeenCalled() + expect(git.push).not.toHaveBeenCalled() + }) + + test('terminateLoopByName returning false fails conflict with no push', async () => { + seedLoop() + const git = happyGit() + const { spy: createClient } = createClientSpy() + const { service } = await buildDeps({ + git, + createRemoteClient: createClient as never, + terminateLoopByNameReturnsFalse: true, + }) + + const result = await service.dispatch(ctx, migrateCommand) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected error') + expect(result.error.code).toBe('conflict') + expect(result.error.message).toContain('changed state during migration') + expect(git.push).not.toHaveBeenCalled() + const row = loopsRepo.get(PROJECT_ID, 'my-loop') + expect(row?.status).toBe('running') + }) + + test('dirty worktree after freeze rolls back to cancelled and never pushes', async () => { + const worktreeDir = join(tempDir, 'wt') + mkdirSync(worktreeDir, { recursive: true }) + seedLoop({ worktreeDir }) + const git = happyGit() + const statusPorcelain = vi.fn(() => ({ ok: true, status: 0, stdout: ' M a.ts\n', stderr: '' })) + git.statusPorcelain = statusPorcelain as never + const { spy: createClient } = createClientSpy() + const { service } = await buildDeps({ git, createRemoteClient: createClient as never }) + + const result = await service.dispatch(ctx, migrateCommand) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected error') + expect(result.error.code).toBe('internal_error') + expect(result.error.message).toContain('uncommitted changes') + expect(statusPorcelain).toHaveBeenCalledWith(worktreeDir) + expect(git.push).not.toHaveBeenCalled() + const row = loopsRepo.get(PROJECT_ID, 'my-loop') + expect(row?.status).toBe('cancelled') + expect(row?.terminationReason).toBe('cancelled') + }) + + test('a throw from captureLoopResumeSnapshot after freeze rolls back without pushing', async () => { + seedLoop() + const git = happyGit() + const { captureLoopResumeSnapshot } = await import('../../src/loop/resume-snapshot') + ;(captureLoopResumeSnapshot as Mock).mockImplementationOnce(() => { throw new Error('snapshot boom') }) + const { spy: createClient } = createClientSpy() + const { service } = await buildDeps({ git, createRemoteClient: createClient as never }) + + const result = await service.dispatch(ctx, migrateCommand) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected error') + expect(result.error.code).toBe('internal_error') + expect(result.error.message).toContain('snapshot boom') + expect(result.error.message).toContain('restartable') + expect(git.push).not.toHaveBeenCalled() + const row = loopsRepo.get(PROJECT_ID, 'my-loop') + expect(row?.status).toBe('cancelled') + expect(row?.terminationReason).toBe('cancelled') + }) + + test('a throw from buildResumePromptPlan rolls back without pushing', async () => { + seedLoop() + const git = happyGit() + const { buildResumePromptPlan } = await import('../../src/loop/resume-prompt') + ;(buildResumePromptPlan as Mock).mockImplementationOnce(() => { throw new Error('prompt boom') }) + const { service, remoteSpy } = await buildDeps({ git, createRemoteClient: createClientSpy().spy as never }) + + const result = await service.dispatch(ctx, migrateCommand) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected error') + expect(result.error.message).toContain('prompt boom') + expect(git.push).not.toHaveBeenCalled() + expect(remoteSpy).toHaveBeenCalledTimes(2) + const row = loopsRepo.get(PROJECT_ID, 'my-loop') + expect(row?.status).toBe('cancelled') + }) + + test('an inactive errored loop restored to errored with its original reason on post-freeze failure', async () => { + seedLoop({ active: false, status: 'errored', terminationReason: 'error_max_retries: too many' }) + const git = happyGit() + const { spy: createClient, clients } = createClientSpy({ + workspace: { + create: async () => { throw new Error('remote exploded') }, + }, + }) + const { service } = await buildDeps({ git, createRemoteClient: createClient as never }) + + const result = await service.dispatch(ctx, migrateCommand) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected error') + const row = loopsRepo.get(PROJECT_ID, 'my-loop') + expect(row?.status).toBe('errored') + expect(row?.terminationReason).toBe('error_max_retries: too many') + expect(clients[1].session.create).not.toHaveBeenCalled() + }) + + test('portable permission rules merge the workspace extras with the config rules', async () => { + seedLoop({ workspaceId: 'ws_local' }) + const git = happyGit() + const config = happyConfig() + config.loop = { permissions: { deny: [{ permission: 'bash', pattern: 'git push *' }] } } + const { spy: createClient, clients } = createClientSpy() + const { service } = await buildDeps({ + config, + git, + createRemoteClient: createClient as never, + localWorkspaceList: async () => [ + { + id: 'ws_local', + type: 'forge', + extra: { + permissionRules: [{ permission: 'webfetch', pattern: 'example.com/*', action: 'deny' }], + }, + }, + ], + }) + + const result = await service.dispatch(ctx, migrateCommand) + expect(result.ok).toBe(true) + + const remoteClient = clients[1] + const createParams = (remoteClient.workspace.create as ReturnType).mock.calls[0][0] + expect(createParams.extra.permissionRules).toEqual([ + { permission: 'bash', pattern: 'git push *', action: 'deny' }, + { permission: 'webfetch', pattern: 'example.com/*', action: 'deny' }, + ]) + }) + + test('successful migration deletes the loop own previous sync pin', async () => { + seedLoop({ + workspaceId: 'ws_local', + }) + const git = happyGit() + const { spy: createClient } = createClientSpy() + const { service } = await buildDeps({ + git, + createRemoteClient: createClient as never, + localWorkspaceList: async () => [ + { + id: 'ws_local', + type: 'forge', + extra: { syncRef: 'refs/forge/old-pin', gitRemote: 'upstream' }, + }, + ], + }) + + const result = await service.dispatch(ctx, migrateCommand) + expect(result.ok).toBe(true) + + expect(git.push).toHaveBeenCalledTimes(2) + expect(git.push).toHaveBeenCalledWith('/local/proj', 'origin', 'refs/heads/forge/my-loop:refs/forge/my-loop', true) + expect(git.push).toHaveBeenCalledWith('/local/proj', 'upstream', ':refs/forge/old-pin', false) + }) }) diff --git a/test/services/resolve-named-loop.test.ts b/test/services/resolve-named-loop.test.ts new file mode 100644 index 000000000..dbc292cd8 --- /dev/null +++ b/test/services/resolve-named-loop.test.ts @@ -0,0 +1,89 @@ +import { describe, test, expect } from 'vitest' +import { resolveNamedLoop } from '../../src/services/execution' +import type { LoopState } from '../../src/loop/state' +import type { Loop } from '../../src/loop/runtime' + +function makeLoopState(loopName: string, worktreeBranch?: string): LoopState { + return { + active: false, + sessionId: `sess-${loopName}`, + loopName, + worktreeDir: `/tmp/${loopName}`, + ...(worktreeBranch ? { worktreeBranch } : {}), + iteration: 1, + maxIterations: 5, + startedAt: new Date().toISOString(), + errorCount: 0, + auditCount: 0, + status: 'completed', + phase: 'coding', + currentSectionIndex: 0, + totalSections: 0, + finalAuditDone: false, + } +} + +describe('resolveNamedLoop', () => { + const name = 'my-loop' + + test('returns the matching state', () => { + const state = makeLoopState(name) + const loop = { + findMatchByName: () => ({ match: state, candidates: [] }), + listLoopNames: () => [name], + } as unknown as Loop + + const result = resolveNamedLoop({ loop }, name) + expect(result).toEqual({ state }) + }) + + test('ambiguous name returns conflict with candidate loop names', () => { + const loop = { + findMatchByName: () => ({ + match: null, + candidates: [makeLoopState('loop-a', 'forge/loop-a'), makeLoopState('loop-b', 'forge/loop-b')], + }), + listLoopNames: () => ['loop-a', 'loop-b', 'loop-c'], + } as unknown as Loop + + const result = resolveNamedLoop({ loop }, name) + expect('response' in result && result.response.ok).toBe(false) + if (!('response' in result)) throw new Error('expected response') + expect(result.response.error).toEqual({ + code: 'conflict', + status: 409, + message: `Multiple loops match "${name}". Be more specific.`, + details: undefined, + candidates: ['loop-a', 'loop-b'], + }) + }) + + test('unknown name returns not_found with available loop names without hydrating plans', () => { + const listLoopNames = () => ['loop-a', 'loop-b'] + const loop = { + findMatchByName: () => ({ match: null, candidates: [] }), + listLoopNames, + } as unknown as Loop + + const result = resolveNamedLoop({ loop }, name) + expect('response' in result && result.response.ok).toBe(false) + if (!('response' in result)) throw new Error('expected response') + expect(result.response.error).toEqual({ + code: 'not_found', + status: 404, + message: `No loop found for "${name}".`, + details: undefined, + candidates: ['loop-a', 'loop-b'], + }) + }) + + test('not-found path never calls plan-hydrating listings', () => { + const loop = { + findMatchByName: () => ({ match: null, candidates: [] }), + listLoopNames: () => [], + } as unknown as Loop + + const result = resolveNamedLoop({ loop }, name) + expect('response' in result && result.response.error.candidates).toEqual([]) + }) +}) diff --git a/test/tools/loop-migrate-tool.test.ts b/test/tools/loop-migrate-tool.test.ts index 9ae68abad..b6436cba9 100644 --- a/test/tools/loop-migrate-tool.test.ts +++ b/test/tools/loop-migrate-tool.test.ts @@ -7,6 +7,7 @@ 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 { createSectionPlansRepo } from '../../src/storage/repos/section-plans-repo' +import { createFeatureGroupsRepo } from '../../src/storage/repos/feature-groups-repo' import { createLoopTransitionsRepo } from '../../src/storage/repos/loop-transitions-repo' import { createLoopService } from '../../src/loop/service' import type { LoopService } from '../../src/loop/service' @@ -78,6 +79,7 @@ describe('loop-migrate tool', () => { let reviewFindingsRepo: ReviewFindingsRepo let sectionPlansRepo: SectionPlansRepo let loopTransitionsRepo: LoopTransitionsRepo + let featureGroupsRepo: ReturnType let loopService: LoopService const mockWorkspaceStatusRegistry = { @@ -101,6 +103,7 @@ describe('loop-migrate tool', () => { reviewFindingsRepo = createReviewFindingsRepo(db) sectionPlansRepo = createSectionPlansRepo(db) loopTransitionsRepo = createLoopTransitionsRepo(db) + featureGroupsRepo = createFeatureGroupsRepo(db) loopService = createLoopService( loopsRepo, plansRepo, @@ -221,8 +224,11 @@ describe('loop-migrate tool', () => { inspect: (name: string) => loopService.getAnyState(name), listActive: (...args: unknown[]) => (loopService.listActive as (...a: unknown[]) => LoopState[])(...args), listRecent: (...args: unknown[]) => (loopService.listRecent as (...a: unknown[]) => LoopState[])(...args), + listLoopNames: (...args: unknown[]) => (loopService.listLoopNames as (...a: unknown[]) => string[])(...args), + findMatchByName: (...args: unknown[]) => (loopService.findMatchByName as (...a: unknown[]) => { match: LoopState | null; candidates: LoopState[] })(...args), setPhase: (...args: unknown[]) => (loopService.setPhase as (...a: unknown[]) => void)(...args), generateUniqueLoopName: (...args: unknown[]) => (loopService.generateUniqueLoopName as (...a: unknown[]) => string)(...args), + runExclusive: async (_name: string, fn: () => Promise) => fn(), registerSessionReverseIndex: noopFn, unregisterSessionReverseIndex: noopFn, } as unknown as Parameters[0]['loop'], @@ -254,10 +260,13 @@ describe('loop-migrate tool', () => { inspect: (name: string) => loopService.getAnyState(name), listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + runExclusive: async (_name: string, fn: () => Promise) => fn(), resolveLoopName: () => null, } as never, sectionPlansRepo, reviewFindingsRepo, + featureGroupsRepo, } as never) return tools @@ -277,7 +286,7 @@ describe('loop-migrate tool', () => { expect(result).toContain(`Remote session: ${REMOTE_SESSION_ID}`) expect(result).toContain('Resumed at: phase coding, section 2/3') expect(result).toContain('Pinned commit: deadbee (refs/forge/my-loop)') - expect(result).toContain('cannot be restarted here') + expect(result).toContain('Restart it locally only with loop-status restart=true force=true') }) test('renders candidate loops when the name is ambiguous', async () => { diff --git a/test/utils/tui-client-launch-initial-prompt.test.ts b/test/utils/tui-client-launch-initial-prompt.test.ts index da7ec8592..aceb77e9f 100644 --- a/test/utils/tui-client-launch-initial-prompt.test.ts +++ b/test/utils/tui-client-launch-initial-prompt.test.ts @@ -163,4 +163,34 @@ describe('launchTuiLoop initialPrompt', () => { expect(promptArgs.agent).toBe('code') expect(promptArgs.parts[0].text).toBe('# Plain Plan') }) + + test('removes the created workspace when session.create throws', async () => { + const { client } = createFakeForgeClient({ + session: { + create: async () => { + throw new Error('session create exploded') + }, + }, + workspace: { + create: async () => ({ id: 'ws_orphan', directory: '/remote/wt', branch: null }), + status: async () => [{ workspaceID: 'ws_orphan', status: 'connected' }], + }, + }) + + const result = await launchTuiLoop({ + client, + directory: '/p', + projectId: null, + requestedLoopName: 'moved', + loopNameReserved: true, + title: 'Moved', + plan: '# Plan', + permissionOptions: {}, + }) + + expect(result).toEqual({ error: 'Loop launch failed: session create exploded' }) + expect(client.workspace.remove).toHaveBeenCalledTimes(1) + expect(client.workspace.remove).toHaveBeenCalledWith({ id: 'ws_orphan' }) + expect(client.session.promptAsync).not.toHaveBeenCalled() + }) }) diff --git a/test/utils/tui-remote-launch.test.ts b/test/utils/tui-remote-launch.test.ts index 08242683b..9a5befef6 100644 --- a/test/utils/tui-remote-launch.test.ts +++ b/test/utils/tui-remote-launch.test.ts @@ -31,9 +31,21 @@ vi.mock('../../src/services/execution', () => ({ ForgeLoopExtra: {}, })) +vi.mock('../../src/utils/tui-client', async (importOriginal) => { + const original = await importOriginal() + return { ...original, launchTuiLoop: vi.fn(original.launchTuiLoop) } +}) + // ── SUT ─────────────────────────────────────────────────────────────────── -import { executeRemoteLoop, connectRemoteProject, pushForgeSyncRef, deleteForgeSyncRef } from '../../src/utils/tui-remote-launch' +import { + executeRemoteLoop, + connectRemoteProject, + pushForgeSyncRef, + deleteForgeSyncRef, + prepareRemoteLoopLaunch, + pushAndLaunchRemoteLoop, +} from '../../src/utils/tui-remote-launch' import type { PluginConfig } from '../../src/types' import type { GitService, GitResult } from '../../src/utils/git-service' import type { ForgeClient } from '../../src/client/port' @@ -161,6 +173,31 @@ describe('executeRemoteLoop', () => { expect(remoteClient.session.promptAsync).toHaveBeenCalledTimes(1) }) + test('resolves HEAD exactly once in the preflight and reuses it as startRef', async () => { + const config = happyConfig() + const git = happyGit() + const { spy: createClient, clients } = createClientSpy() + + const result = await executeRemoteLoop( + { + remoteName: 'server1', + localDirectory: LOCAL_DIR, + localProjectId: LOCAL_PROJECT_ID, + title: 'Test Plan', + loopName: 'test-loop', + plan: '# Test Plan\n\nDo work.', + }, + { config, git, createClient: createClient as any }, + ) + + expect('error' in result).toBe(false) + expect(git.revParseHead).toHaveBeenCalledTimes(1) + expect(git.revParseHead).toHaveBeenCalledWith(LOCAL_DIR) + + const createParams = (clients[1].workspace.create as ReturnType).mock.calls[0][0] + expect(createParams.extra.startRef).toBe('abc123def456abc123def456abc123def456abc1') + }) + // ── Error: unknown remote name ────────────────────────────────────────── test('returns error for unknown remote name', async () => { @@ -266,7 +303,7 @@ describe('executeRemoteLoop', () => { test('returns error when git push fails', async () => { const config = happyConfig() const git = happyGit() - git.push = vi.fn(() => ({ + git.pushAsync = vi.fn(async () => ({ ok: false, status: 1, stdout: '', @@ -294,7 +331,7 @@ describe('executeRemoteLoop', () => { // createClient WAS called (discovery + scoped), but workspace.create was NOT called // because push failure stops before launchTuiLoop - expect(git.push).toHaveBeenCalledTimes(1) + expect(git.pushAsync).toHaveBeenCalledTimes(1) expect(clients.length).toBeGreaterThanOrEqual(1) // The scoped client (last one) should NOT have workspace.create called @@ -738,10 +775,10 @@ describe('connectRemoteProject', () => { }) describe('pushForgeSyncRef / deleteForgeSyncRef', () => { - test('pushForgeSyncRef pushes sourceRef to syncRef with force and returns ok', () => { + test('pushForgeSyncRef pushes sourceRef to syncRef with force and returns ok', async () => { const git = happyGit() - const result = pushForgeSyncRef(git, { + const result = await pushForgeSyncRef(git, { cwd: LOCAL_DIR, gitRemote: 'origin', sourceRef: 'refs/heads/forge/moved', @@ -749,20 +786,20 @@ describe('pushForgeSyncRef / deleteForgeSyncRef', () => { }) expect(result).toEqual({ ok: true }) - expect(git.push).toHaveBeenCalledTimes(1) - expect(git.push).toHaveBeenCalledWith(LOCAL_DIR, 'origin', 'refs/heads/forge/moved:refs/forge/moved', true) + expect(git.pushAsync).toHaveBeenCalledTimes(1) + expect(git.pushAsync).toHaveBeenCalledWith(LOCAL_DIR, 'origin', 'refs/heads/forge/moved:refs/forge/moved', true) }) - test('pushForgeSyncRef returns ok:false with stderr on push failure', () => { + test('pushForgeSyncRef returns ok:false with stderr on push failure', async () => { const git = happyGit() - git.push = vi.fn(() => ({ + git.pushAsync = vi.fn(async () => ({ ok: false, status: 1, stdout: '', stderr: 'error: failed to push some refs', })) as any - const result = pushForgeSyncRef(git, { + const result = await pushForgeSyncRef(git, { cwd: LOCAL_DIR, gitRemote: 'origin', sourceRef: 'refs/heads/forge/moved', @@ -775,12 +812,190 @@ describe('pushForgeSyncRef / deleteForgeSyncRef', () => { }) }) - test('deleteForgeSyncRef issues a non-forced remote ref deletion', () => { + test('deleteForgeSyncRef issues a non-forced remote ref deletion', async () => { const git = happyGit() - deleteForgeSyncRef(git, { cwd: LOCAL_DIR, gitRemote: 'origin', syncRef: 'refs/forge/moved' }) + await deleteForgeSyncRef(git, { cwd: LOCAL_DIR, gitRemote: 'origin', syncRef: 'refs/forge/moved' }) - expect(git.push).toHaveBeenCalledTimes(1) - expect(git.push).toHaveBeenCalledWith(LOCAL_DIR, 'origin', ':refs/forge/moved', false) + expect(git.pushAsync).toHaveBeenCalledTimes(1) + expect(git.pushAsync).toHaveBeenCalledWith(LOCAL_DIR, 'origin', ':refs/forge/moved', false) + }) +}) + +function pushAndLaunchInput(git: GitService, client: ForgeClient, overrides?: Partial[0]>): Parameters[0] { + return { + git, + cwd: LOCAL_DIR, + remote: { name: 'server1', url: REMOTE_URL, username: 'opencode', gitRemote: 'origin', sandbox: true }, + project: { id: LOCAL_PROJECT_ID, worktree: '/remote/my-project' }, + client, + loopName: 'my-loop', + syncRef: 'refs/forge/my-loop', + sourceRef: 'HEAD', + startRef: 'abc123', + title: 'Title', + plan: '# Plan', + permissionOptions: {}, + ...overrides, + } +} + +describe('prepareRemoteLoopLaunch', () => { + test('reserves the loop name, derives the sync ref, and forwards permission warnings', async () => { + const config: PluginConfig = { + remotes: [{ name: 'server1', url: REMOTE_URL, password: 'sekret' }], + loop: { + permissions: { deny: ['*'] }, + }, + } + const client = makeFakeClient() + const onWarnings = vi.fn() + const debug = vi.fn() + + const result = await prepareRemoteLoopLaunch({ + client, + requestedLoopName: 'my-loop', + permissionOptions: { extraRules: [] }, + config, + dataDir: '/tmp/forge-data', + localDirectory: LOCAL_DIR, + debug, + onWarnings, + }) + + expect(result).toEqual({ loopName: 'my-loop', syncRef: 'refs/forge/my-loop' }) + expect(onWarnings).toHaveBeenCalledTimes(1) + expect(onWarnings.mock.calls[0][0]).toEqual([ + expect.stringContaining('loop.permissions.deny entry "*" is ignored'), + ]) + expect(debug).toHaveBeenCalledWith(expect.stringContaining('reserved loop name="my-loop"')) + }) + + test('does not call onWarnings when loop.permissions is clean', async () => { + const config = happyConfig() + const client = makeFakeClient() + const onWarnings = vi.fn() + + const result = await prepareRemoteLoopLaunch({ + client, + requestedLoopName: 'my-loop', + permissionOptions: {}, + config, + dataDir: '/tmp/forge-data', + localDirectory: LOCAL_DIR, + onWarnings, + }) + + expect(result).toEqual({ loopName: 'my-loop', syncRef: 'refs/forge/my-loop' }) + expect(onWarnings).not.toHaveBeenCalled() + }) +}) + +describe('pushAndLaunchRemoteLoop', () => { + test('pushes the source ref and launches with the reserved loop name and sync ref', async () => { + const git = happyGit() + const client = makeFakeClient() + const debug = vi.fn() + + const result = await pushAndLaunchRemoteLoop(pushAndLaunchInput(git, client, { debug })) + + expect(result).toEqual({ loopName: 'my-loop', sessionId: 'sess_remote' }) + expect(git.pushAsync).toHaveBeenCalledTimes(1) + expect(git.pushAsync).toHaveBeenCalledWith(LOCAL_DIR, 'origin', 'HEAD:refs/forge/my-loop', true) + const createParams = (client.workspace.create as ReturnType).mock.calls[0][0] + expect(createParams.extra.startRef).toBe('abc123') + expect(createParams.extra.syncRef).toBe('refs/forge/my-loop') + expect(createParams.extra.gitRemote).toBe('origin') + expect(createParams.extra.forgeLoop.sandboxEnabled).toBe(true) + }) + + test('returns pushed:false without deleting the sync ref when the push fails', async () => { + const git = happyGit() + git.pushAsync = vi.fn(async () => ({ + ok: false, + status: 1, + stdout: '', + stderr: 'error: failed to push some refs', + })) as any + const client = makeFakeClient() + + const result = await pushAndLaunchRemoteLoop(pushAndLaunchInput(git, client)) + + expect(result).toEqual({ + error: 'Failed to push to remote "server1": error: failed to push some refs', + pushed: false, + }) + expect(git.pushAsync).toHaveBeenCalledTimes(1) + expect(client.workspace.create).not.toHaveBeenCalled() + }) + + test('returns pushed:false with the caller pushErrorPrefix when the push fails', async () => { + const git = happyGit() + git.pushAsync = vi.fn(async () => ({ + ok: false, + status: 1, + stdout: '', + stderr: 'error: failed to push some refs', + })) as any + const client = makeFakeClient() + + const result = await pushAndLaunchRemoteLoop(pushAndLaunchInput(git, client, { + pushErrorPrefix: 'Failed to push loop branch to remote "server1": ', + })) + + expect(result).toEqual({ + error: 'Failed to push loop branch to remote "server1": error: failed to push some refs', + pushed: false, + }) + expect(git.pushAsync).toHaveBeenCalledTimes(1) + }) + + test('deletes the sync ref exactly once when launchTuiLoop fails', async () => { + const git = happyGit() + const client = makeFakeClient({ + workspace: { + create: async () => { throw new Error('remote exploded') }, + }, + }) + const debug = vi.fn() + + const result = await pushAndLaunchRemoteLoop(pushAndLaunchInput(git, client, { debug })) + + expect('error' in result && result.pushed === false).toBe(true) + expect('error' in result && result.error).toContain('remote exploded') + expect(git.pushAsync).toHaveBeenCalledTimes(2) + expect(git.pushAsync).toHaveBeenCalledWith(LOCAL_DIR, 'origin', 'HEAD:refs/forge/my-loop', true) + expect(git.pushAsync).toHaveBeenCalledWith(LOCAL_DIR, 'origin', ':refs/forge/my-loop', false) + expect(debug).toHaveBeenCalledWith(expect.stringContaining('launchTuiLoop FAILED')) + }) + + test('deletes the sync ref exactly once and rethrows when launchTuiLoop throws', async () => { + const git = happyGit() + const client = makeFakeClient() + const { launchTuiLoop } = await import('../../src/utils/tui-client') + ;(launchTuiLoop as ReturnType).mockImplementationOnce(async () => { + throw new Error('launch boom') + }) + + await expect(pushAndLaunchRemoteLoop(pushAndLaunchInput(git, client))).rejects.toThrow('launch boom') + + expect(git.pushAsync).toHaveBeenCalledTimes(2) + expect(git.pushAsync).toHaveBeenCalledWith(LOCAL_DIR, 'origin', 'HEAD:refs/forge/my-loop', true) + expect(git.pushAsync).toHaveBeenCalledWith(LOCAL_DIR, 'origin', ':refs/forge/my-loop', false) + }) + + test('sets sandboxEnabled from remote.sandbox over any forgeLoopOverrides', async () => { + const git = happyGit() + const client = makeFakeClient() + + const result = await pushAndLaunchRemoteLoop(pushAndLaunchInput(git, client, { + remote: { name: 'server1', url: REMOTE_URL, username: 'opencode', gitRemote: 'origin', sandbox: false }, + forgeLoopOverrides: { maxIterations: 40 }, + })) + + expect(result).toEqual({ loopName: 'my-loop', sessionId: 'sess_remote' }) + const createParams = (client.workspace.create as ReturnType).mock.calls[0][0] + expect(createParams.extra.forgeLoop.sandboxEnabled).toBe(false) + expect(createParams.extra.forgeLoop.maxIterations).toBe(40) }) })