@hecer/yoke 1.12.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.claude-plugin/plugin.json +3 -3
  2. package/.codex-plugin/plugin.json +2 -2
  3. package/CHANGELOG.md +41 -0
  4. package/README.md +42 -30
  5. package/canon/loop/prd.schema.md +2 -2
  6. package/canon/manifest.yaml +2 -2
  7. package/canon/skills/authoring-prd/SKILL.md +1 -1
  8. package/canon/skills/yoke-retrofit/SKILL.md +2 -2
  9. package/canon/skills/yoke-workflow/SKILL.md +1 -1
  10. package/canon/tools/graphify.md +1 -1
  11. package/canon/tools/playwright-mcp.md +1 -1
  12. package/canon/tools/serena.md +1 -1
  13. package/dist/agents/catalog.js +7 -0
  14. package/dist/agents/contracts.js +3 -1
  15. package/dist/agents/host.js +4 -0
  16. package/dist/agents/process-streams.js +62 -0
  17. package/dist/agents/process.js +43 -3
  18. package/dist/agents/providers.js +45 -3
  19. package/dist/agents/telemetry.js +99 -2
  20. package/dist/canon/manifest.js +2 -1
  21. package/dist/change/inbox.js +1 -1
  22. package/dist/cli.js +22 -24
  23. package/dist/dashboard/analytics.js +193 -29
  24. package/dist/dashboard/contracts.js +23 -0
  25. package/dist/dashboard/page.js +39 -94
  26. package/dist/dashboard/panels.js +87 -33
  27. package/dist/dashboard/server.js +190 -15
  28. package/dist/goals/command.js +3 -2
  29. package/dist/loop/claims.js +2 -1
  30. package/dist/loop/decision.js +3 -2
  31. package/dist/loop/parallel-command.js +4 -2
  32. package/dist/loop/prd.js +2 -1
  33. package/dist/loop/reporter.js +1 -0
  34. package/dist/loop/run-command.js +31 -10
  35. package/dist/observability/events.js +1 -1
  36. package/dist/observability/history.js +1 -1
  37. package/dist/prd/command.js +3 -3
  38. package/dist/quality/candidate-comparison.js +6 -1
  39. package/dist/quality/command.js +17 -2
  40. package/dist/quality/types.js +6 -1
  41. package/dist/retrofit/apply.js +87 -1
  42. package/dist/retrofit/config.js +8 -0
  43. package/dist/retrofit/detect.js +6 -0
  44. package/dist/retrofit/plan.js +6 -0
  45. package/dist/retrofit/planners/kilo.js +44 -0
  46. package/dist/retrofit/planners/opencode.js +44 -0
  47. package/dist/retrofit/planners/pi.js +24 -0
  48. package/dist/retrofit/skill-actions.js +3 -0
  49. package/dist/retrofit/tools.js +8 -0
  50. package/dist/review/command.js +3 -2
  51. package/dist/review/verdict.js +1 -1
  52. package/dist/routing/capability.js +2 -2
  53. package/dist/routing/planning.js +2 -0
  54. package/dist/routing/registry.js +3 -1
  55. package/dist/routing/router.js +7 -3
  56. package/dist/setup/command.js +13 -3
  57. package/docs/DASHBOARD-EVOLUTION.md +16 -2
  58. package/docs/DASHBOARD-OVERHAUL.md +146 -0
  59. package/docs/HARNESSES.md +81 -0
  60. package/gemini-extension.json +2 -2
  61. package/package.json +6 -2
@@ -3,12 +3,64 @@ export function dashboardPanels() {
3
3
  return String.raw `
4
4
  function panel(title){return append(el('section',undefined,'panel'),el('h2',title))}
5
5
  function table(headers,rows){const wrap=el('div',undefined,'table-scroll'),t=el('table'),head=el('thead'),hr=el('tr');for(const title of headers)hr.append(el('th',title));head.append(hr);t.append(head);const body=el('tbody');for(const values of rows){const r=el('tr');for(const value of values)r.append(el('td',String(value)));body.append(r)}t.append(body);wrap.append(t);return wrap}
6
- function costText(t){return t.costState==='unknown'?'Unknown':'$'+t.reportedCostUsd.toFixed(3)+(t.costState==='partial'?' reported (partial)':' reported')}
6
+ function metric(label,value,note){return append(el('div',undefined,'metric'),el('span',label,'label'),el('strong',value),el('p',note))}
7
+ function safeMetricTotal(values){return values.every(Number.isFinite)?values.reduce((sum,value)=>sum+value,0):null}
8
+ function chartValues(buckets,key){return buckets.map(bucket=>{if(key==='tokens')return bucket.tokens?.total??safeMetricTotal([bucket.inputTokens,bucket.outputTokens]);if(key==='calls')return bucket.calls?.total??(Number.isFinite(bucket.measuredCalls)&&Number.isFinite(bucket.unknownCalls)?bucket.measuredCalls+bucket.unknownCalls:null);if(key==='duration')return bucket.time?.attemptDurationMs??bucket.attemptDurationMs??null;if(key==='outcomes')return bucket.outcomes?.accepted??bucket.accepted??null;return bucket.cost?.reportedUsd??bucket.reportedCostUsd??null})}
9
+ function dimensionLabel(value){if(value===undefined||value===null||value==='')return 'Not reported';if(String(value).toLowerCase()==='unknown')return 'Unknown';return String(value)}
10
+ function rankRows(rows,key='tokens'){return [...rows].sort((left,right)=>{const a=Number.isFinite(left[key])?left[key]:null,b=Number.isFinite(right[key])?right[key]:null;if(a===null&&b!==null)return 1;if(a!==null&&b===null)return -1;if(a!==b)return (b??0)-(a??0);return dimensionLabel(left.name).localeCompare(dimensionLabel(right.name))})}
11
+ function coverageMessages(data,project={}){const messages=[];if(project.unavailable||data?.projects?.some(value=>value.errors?.length&&!value.analytics))messages.push('Unavailable telemetry: this project could not be read.');if(data?.errors?.length)messages.push('Corrupt or partial history: '+data.errors.join(' · '));const total=data?.total||{};if(total.costState==='partial'||total.cost?.state==='partial'||total.unknownCalls>0||total.unmeasuredAttempts>0||total.incompleteCosts>0||total.cost?.incompleteCalls>0)messages.push('Partial telemetry: some calls or costs are not measured.');if(project.stale)messages.push('Stale telemetry: the latest active status is unconfirmed.');return messages}
12
+ let lastSuccessfulSync='',composeFeedbackState=null;
13
+ function liveFreshness(project,now=Date.now()){
14
+ const status=project.status||{},times=[status.updatedAt,...(status.supervision||[]).flatMap(value=>[value.heartbeatAt,value.lastProgressAt])].map(value=>Date.parse(value||'')).filter(Number.isFinite),latest=Math.max(...times);
15
+ if(!Number.isFinite(latest))return 'unknown';
16
+ if(now-latest<=20*60000)return 'fresh';
17
+ return ['running','active'].includes(status.state)?'unconfirmed':'stale'
18
+ }
19
+ function workerMetadata(worker){return [worker.provider?'Agent '+worker.provider:null,worker.selectedProvider&&worker.selectedProvider!==worker.provider?'Selected provider '+worker.selectedProvider:null,'Model '+(worker.selectedModel||worker.model||'provider default'),worker.selectedVariant?'Variant '+worker.selectedVariant:null,worker.role?'Role '+worker.role:null,'Phase '+(worker.phase||worker.lifecycle||'working')].filter(Boolean).join(' · ')}
20
+ function timelineEvents(events,limit=100){return [...events].sort((left,right)=>String(left.timestamp).localeCompare(String(right.timestamp))).slice(-limit)}
21
+ function composeFeedbackStatus(result){if(result?.status==='pending')return 'pending';if(['note-added','applied'].includes(result?.status))return 'applied';if(result?.status==='validation'||/invalid|validation|required|empty/iu.test(String(result?.error||'')))return 'validation';return 'failure'}
22
+ function localAndUtc(timestamp){const date=new Date(timestamp);return 'Local time: '+date.toLocaleString()+' · UTC: '+date.toISOString()}
23
+ function timelineDetail(title,value){const detail=el('details'),summary=el('summary',title);detail.append(summary,el('p',value||'Unknown'));return detail}
24
+ function timelineItem(event){
25
+ const item=el('details'),data=event.data||{},input=data.inputTokens??event.inputTokens,output=data.outputTokens??event.outputTokens,usage=data.usageAvailable===false||event.usageAvailable===false?'unavailable':input!==undefined||output!==undefined?'reported':'not recorded';
26
+ item.append(el('summary',event.type+' · '+localAndUtc(event.timestamp)),timelineDetail('Run details',event.runId),timelineDetail('Story details',event.storyId),timelineDetail('Phase details',event.phase),timelineDetail('Agent details',[event.agent,event.provider,event.model,event.variant,event.role].filter(Boolean).join(' · ')),timelineDetail('Token details','Input '+(input===undefined?'Unknown':input)+' · output '+(output===undefined?'Unknown':output)+' · '+usage));
27
+ return item
28
+ }
29
+ function feedbackText(state){return state.status+': '+state.message}
30
+ function feedbackNode(state){return el('p',feedbackText(state),'compose-feedback '+state.status)}
31
+ function renderTimeline(project){
32
+ const view=panel('Event timeline');view.append(el('p','Bounded to the latest 100 events in chronological order. Each event expands run, story, phase, agent, and token details.','muted'));
33
+ const events=timelineEvents(project.events||[]);if(!events.length)view.append(el('p','No recorded events.','empty'));else{const list=el('div',undefined,'timeline');for(const event of events)list.append(timelineItem(event));view.append(list)}content.append(view)
34
+ }
35
+ function renderOperatorInput(project){
36
+ const view=panel('Operator input');if(composeFeedbackState)view.append(feedbackNode(composeFeedbackState));
37
+ const noteLabel=el('label','Add a note','field'),note=el('textarea');note.rows=3;note.placeholder='Record context for the next worker or handoff';note.setAttribute('aria-label','Operator note');noteLabel.append(note);view.append(noteLabel,button('Add note',()=>submitOperatorInput(project,'note',note.value,view)));
38
+ const changeLabel=el('label','Queue a change','field'),change=el('textarea');change.rows=3;change.placeholder='Describe a change for the pending inbox';change.setAttribute('aria-label','Change request');changeLabel.append(change);view.append(changeLabel,button('Queue change',()=>submitOperatorInput(project,'change',change.value,view)));content.append(view)
39
+ }
40
+ async function submitOperatorInput(project,kind,value,view){
41
+ const text=value.trim();if(!text){composeFeedbackState={status:'validation',message:kind==='note'?'Enter a note before adding it.':'Describe a change before queueing it.'};view.append(feedbackNode(composeFeedbackState));return}
42
+ try{
43
+ const data=await api('/api/projects/'+project.id+'/'+(kind==='note'?'notes':'changes'),{method:'POST',headers:{'content-type':'application/json','x-yoke-token':sessionToken},body:JSON.stringify(kind==='note'?{note:text}:{request:text})});
44
+ const status=composeFeedbackStatus(data),message=status==='pending'?'Change queued as '+data.requestId+'.': 'Note applied to the live timeline.';composeFeedbackState={status,message};await showProject(project.id)
45
+ }catch(error){composeFeedbackState={status:composeFeedbackStatus({error:error.message}),message:error.message};view.append(feedbackNode(composeFeedbackState))}
46
+ }
47
+ async function safeLiveControl(project,action){
48
+ try{const data=await api('/api/projects/'+project.id+'/'+action,{method:'POST',headers:{'content-type':'application/json','x-yoke-token':sessionToken},body:JSON.stringify({action})});composeFeedbackState={status:'pending',message:data.status==='pause-requested'?'Pause requested at the next safe boundary.':'Resume requested at the next safe boundary.'};await showProject(project.id)}
49
+ catch(error){composeFeedbackState={status:composeFeedbackStatus({error:error.message}),message:error.message};await showProject(project.id)}
50
+ }
51
+ function renderLive(project){
52
+ const status=project.status||{},goal=project.goal||{},workers=status.parallel?.workers||[],state=projectStatus(project),freshness=liveFreshness(project),tasks=project.stories||[],progress=status.progress||{passed:tasks.filter(task=>task.passes).length,total:tasks.length};
53
+ const view=panel('Live operations');view.append(row('Goal state',goal.status||'not reported',state.goal||'unknown'),row('Loop state',status.state||'not reported',state.loop||'unknown'),row('Freshness',freshness,'Last status '+(status.updatedAt||'unknown')),row('Objective progress',progress.total?progress.passed+'/'+progress.total+' tasks accepted':'Unknown','Saved acceptance state'),row('Last successful sync',lastSuccessfulSync?localAndUtc(lastSuccessfulSync):'Not synced yet','Dashboard refresh'));
54
+ const metadata=panel('Worker metadata');if(!workers.length)metadata.append(el('p','No current workers reported.','empty'));else for(const worker of workers)metadata.append(row(worker.storyTitle||worker.story,workerMetadata(worker),state.stale?'unconfirmed':worker.lifecycle||'running'));view.append(metadata);
55
+ const controls=panel('Safe controls');if(state.active)controls.append(button('Request pause',()=>safeLiveControl(project,'pause')));if(state.goal!=='complete'&&(!state.active||state.loop==='paused'||state.goal==='paused'))controls.append(button('Resume',()=>safeLiveControl(project,'resume'),true));if(!state.active&&!['paused','complete'].includes(state.goal||''))controls.append(el('p','Resume starts through the existing goal or loop boundary.','muted'));view.append(controls);content.append(view)
56
+ renderOperatorInput(project);renderTimeline(project)
57
+ }
58
+ function costText(t){const state=t.costState||t.cost?.state||'unknown',value=t.reportedCostUsd??t.cost?.reportedUsd??(Number.isFinite(t.cost)?t.cost:null);return !Number.isFinite(value)||state==='unknown'?'Unknown':'$'+value.toFixed(3)+(state==='partial'?' reported (partial)':' reported')}
7
59
  function historyCost(total,errors){return errors.length&&total.costState==='measured'?{...total,costState:'partial'}:total}
8
60
  function rate(value){return Number.isFinite(value)?value.toLocaleString('en-US',{maximumFractionDigits:1}):'Unknown'}
9
- function comparisonText(current,previous){if(previous===0)return current===0?'No change; both periods recorded 0':'New recorded activity; previous period recorded 0';const change=(current-previous)/previous*100;return (change>0?'+':'')+change.toFixed(1)+'% vs previous period'}
61
+ function comparisonText(current,previous){if(!Number.isFinite(current)||!Number.isFinite(previous))return 'Comparison unavailable; one or both periods are not fully measured';if(previous===0)return current===0?'No change; both periods recorded 0':'New recorded activity; previous period recorded 0';const change=(current-previous)/previous*100;return (change>0?'+':'')+change.toFixed(1)+'% vs previous period'}
10
62
  function costComparisonText(current,previous){if(current.costState!=='measured'||previous.costState!=='measured')return 'Percentage unavailable because cost coverage is incomplete in one or both periods';return comparisonText(current.reportedCostUsd,previous.reportedCostUsd)}
11
- function usageCoverage(total){const missing=total.unknownCalls+total.unmeasuredAttempts;return missing?missing+' call'+(missing===1?'':'s')+' or attempt'+(missing===1?'':'s')+' missing usage; totals are recorded portions':'No missing usage recorded in this period'}
63
+ function usageCoverage(total){const missing=(Number.isFinite(total.unknownCalls)?total.unknownCalls:0)+(Number.isFinite(total.unmeasuredAttempts)?total.unmeasuredAttempts:0);return missing?missing+' call'+(missing===1?'':'s')+' or attempt'+(missing===1?'':'s')+' missing usage; totals are recorded portions':'No missing usage recorded in this period'}
12
64
  function periodWindows(now=Date.now(),days=periodDays,from=customFrom,to=customTo){const end=to?Date.parse(to+'T00:00:00Z')+86400000:now,start=from?Date.parse(from+'T00:00:00Z'):end-days*86400000,duration=end-start;return {current:{from:start,to:end},previous:{from:start-duration,to:start}}}
13
65
  function queryPeriod(range){return new URLSearchParams({from:new Date(range.from).toISOString(),to:new Date(range.to).toISOString(),bucket}).toString()}
14
66
  async function mapLimited(items,limit,shouldContinue,worker){
@@ -17,12 +69,20 @@ async function mapLimited(items,limit,shouldContinue,worker){
17
69
  await Promise.all(Array.from({length:Math.min(limit,items.length)},run));
18
70
  return results.filter(value=>value!==undefined);
19
71
  }
20
- function usageChart(buckets){const data=buckets.slice(-31);const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');svg.setAttribute('viewBox','0 0 800 220');svg.setAttribute('role','img');svg.setAttribute('aria-label','Recorded input and output tokens over the latest '+data.length+' non-empty periods; omitted periods mean no recorded activity, not measured zero');const max=Math.max(1,...data.map(d=>d.inputTokens+d.outputTokens));const step=760/Math.max(1,data.length);data.forEach((d,i)=>{let y=180;for(const [value,color] of [[d.inputTokens,'#29444b'],[d.outputTokens,'#c34c27']]){const h=value/max*160;y-=h;const r=document.createElementNS(svg.namespaceURI,'rect');for(const [k,v] of Object.entries({x:20+i*step,y,width:Math.max(1,step-4),height:h,fill:color}))r.setAttribute(k,String(v));const title=document.createElementNS(svg.namespaceURI,'title');title.textContent=d.label+': input '+d.inputTokens+', output '+d.outputTokens;r.append(title);svg.append(r)}if(i===0||i===data.length-1){const label=document.createElementNS(svg.namespaceURI,'text');label.setAttribute('x',String(i===0?20:780));label.setAttribute('y','205');label.setAttribute('text-anchor',i===0?'start':'end');label.setAttribute('font-size','12');label.textContent=d.label;svg.append(label)}});return svg}
72
+ function numericChart(buckets,key,title){const data=(buckets||[]).slice(-31),values=chartValues(data,key),finiteValues=values.filter(Number.isFinite),max=Math.max(1,...finiteValues),svg=document.createElementNS('http://www.w3.org/2000/svg','svg');svg.setAttribute('viewBox','0 0 800 220');svg.setAttribute('role','img');svg.setAttribute('aria-label',title+' over the selected UTC period; unknown measurements are omitted, not plotted as zero');const step=760/Math.max(1,data.length);data.forEach((d,i)=>{const value=values[i];if(Number.isFinite(value)){const h=value/max*160,r=document.createElementNS(svg.namespaceURI,'rect');for(const [k,v] of Object.entries({x:20+i*step,y:180-h,width:Math.max(1,step-4),height:h,fill:'#29444b'}))r.setAttribute(k,String(v));const tip=document.createElementNS(svg.namespaceURI,'title');tip.textContent=d.label+': '+value;r.append(tip);svg.append(r)}if(i===0||i===data.length-1){const label=document.createElementNS(svg.namespaceURI,'text');label.setAttribute('x',String(i===0?20:780));label.setAttribute('y','205');label.setAttribute('text-anchor',i===0?'start':'end');label.setAttribute('font-size','12');label.textContent=d.label;svg.append(label)}});return svg}
73
+ function usageChart(buckets){const data=buckets.slice(-31),svg=numericChart(data,'tokens','Recorded tokens');svg.setAttribute('aria-label','Recorded input and output tokens over the latest '+data.length+' non-empty periods; omitted periods mean no recorded activity, not measured zero');return svg}
74
+ function renderAnalyticsCharts(data){const buckets=data.buckets||[],view=panel('Analytics over time'),grid=el('div',undefined,'analytics-chart-grid');for(const [key,title] of [['tokens','Tokens over time'],['calls','Calls over time'],['duration','Duration over time']]){const chart=append(el('div',undefined,'analytics-chart'),el('h3',title),numericChart(buckets,key,title));grid.append(chart)}const outcomes=append(el('div',undefined,'analytics-chart'),el('h3','Outcomes over time'),numericChart(buckets,'outcomes','Outcomes'),table(['UTC '+(data.bucket||'period'),'Attempts','Successful','Accepted'],buckets.map(value=>[value.label,rankingText(value.outcomes?.attempts??value.attempts),rankingText(value.outcomes?.successfulAttempts??value.successfulAttempts),rankingText(value.outcomes?.accepted??value.accepted)])));grid.append(outcomes);const cost=append(el('div',undefined,'analytics-chart'),el('h3','Cost state over time'),table(['UTC '+(data.bucket||'period'),'Cost state','Reported cost'],buckets.map(value=>[value.label,value.cost?.state||value.costState||'Unknown',costText(value)])));grid.append(cost);view.append(el('p','Each series uses recorded measurements for the selected UTC window. Unknown values are left blank and are never treated as zero.','muted'),grid);return view}
75
+ function rowTotal(row){return row.total||row}
76
+ function normalizedRankingRows(rows){return rows.map(row=>{const total=rowTotal(row),reportedCost=row.reportedCostUsd??(Number.isFinite(row.cost)?row.cost:total.cost?.reportedUsd??null);return {...row,name:dimensionLabel(row.name),reportedCostUsd:reportedCost,tokens:row.tokens??total.tokens?.total??null,calls:row.calls?.total??total.calls?.total??null,cost:row.cost??total.cost?.reportedUsd??null,outcomes:row.outcomes?.accepted??total.outcomes?.accepted??null,duration:row.duration??total.time?.attemptDurationMs??null,costState:row.costState||total.costState||total.cost?.state||'unknown'}})}
77
+ function rankingText(value){return Number.isFinite(value)?String(value):'Unknown'}
78
+ function renderDimensionTables(data){const view=panel('Analytics dimensions'),rankings=data.rankings||{};for(const [key,title] of [['agents','Agents'],['providers','Providers'],['models','Models'],['roles','Roles'],['phases','Phases'],['projects','Projects'],['runs','Runs']]){if(!rankings[key])continue;const rows=rankRows(normalizedRankingRows(rankings[key].tokens||rankings[key]),'tokens');view.append(el('h3',title),table(['Rank','Name','Tokens','Calls','Cost','Cost state','Accepted','Duration'],rows.map((row,index)=>[index+1,dimensionLabel(row.name),rankingText(row.tokens),rankingText(row.calls),costText(row),dimensionLabel(row.costState),rankingText(row.outcomes),duration(row.duration)])))}return view}
79
+ function renderCoverage(data,project={}){const view=panel('Measurement coverage');view.append(el('p',data.coverage||'Recorded measurements only; missing telemetry is not estimated.'));for(const message of coverageMessages(data,project))view.append(el('p',message,'notice'));if(!coverageMessages(data,project).length)view.append(el('p','No telemetry gaps reported for this selected window.','muted'));return view}
80
+ function renderAnalytics(data,project={}){const view=el('div',undefined,'analytics-view'),total=data.total||{};view.append(append(el('div',undefined,'metric-strip'),metric('Recorded tokens',tokenText(total.tokens?.total),'Input '+tokenText(total.tokens?.input)+' · output '+tokenText(total.tokens?.output)),metric('Calls',rankingText(total.calls?.total),'Measured '+rankingText(total.calls?.measured)+' · unknown '+rankingText(total.calls?.unknown)),metric('Cost state',dimensionLabel(total.cost?.state||total.costState),'Reported charges only'),metric('Duration',duration(total.time?.attemptDurationMs),'Summed recorded attempt time')),renderCoverage(data,project),renderAnalyticsCharts(data),renderDimensionTables(data));return view}
21
81
  async function showWorkspaceUsage(){
22
- const request=beginRequest();selected=null;nav();heading('Compare project consumption','Recorded usage in the selected period; missing measurements remain unknown.');periodControls(null);const p=panel('Projects');p.append(el('p','Loading…','muted'));content.append(p);
23
- const bounds=periodWindows(Date.now()),query=queryPeriod(bounds.current),valid=()=>currentRequest(request.version)&&navigationState.screen==='workspace'&&!request.signal.aborted;
24
- const rows=await mapLimited(projects,3,valid,async project=>{try{const a=await api('/api/projects/'+project.id+'/analytics?'+query,{},request.signal);return [project.name,tokenText(a.total.inputTokens),tokenText(a.total.outputTokens),costText(a.total),a.total.accepted,a.total.unknownCalls+a.total.unmeasuredAttempts,a.errors.length?'Partial history':usageCoverage(a.total)]}catch(error){if(isAbort(error))return undefined;return [project.name,'Unknown','Unknown','Unknown','Unknown','Unknown',error.message]}});
25
- if(valid())p.replaceChildren(el('h2','Projects'),table(['Project','Input','Output','Cost','Accepted','Missing usage','Coverage'],rows));
82
+ const request=beginRequest();selected=null;nav();heading('Workspace analytics','Time-bucketed recorded usage, outcomes, and coverage for the selected UTC window.');periodControls(null);const p=panel('Workspace analytics');p.append(el('p','Loading…','muted'));content.append(p);
83
+ const bounds=periodWindows(Date.now(),periodDays,customFrom,customTo),query=queryPeriod(bounds.current)+'&sort='+encodeURIComponent(overviewSort),valid=()=>currentRequest(request.version)&&navigationState.screen==='workspace'&&!request.signal.aborted;
84
+ try{const data=await api('/api/workspace/analytics?'+query,{},request.signal);if(valid())p.replaceChildren(el('h2','Workspace analytics'),renderAnalytics(data,{stale:projects.some(project=>projectStatus(project).stale)}));}
85
+ catch(error){if(valid()&&!isAbort(error))p.replaceChildren(el('h2','Workspace analytics'),el('p','Offline or unavailable: '+error.message,'notice'),el('p','Retry when the local Yoke process is available.','muted'))}
26
86
  }
27
87
  function periodControls(id){
28
88
  const p=panel('Period · UTC'),select=el('select');select.setAttribute('aria-label','Period');
@@ -45,50 +105,44 @@ function renderNow(p){
45
105
  if(goal.pendingAttempt)summary.append(row('Goal attempt',goal.pendingAttempt.provider+' · requested model '+(goal.pendingAttempt.model||'provider default')+' · started '+goal.pendingAttempt.startedAt,state.stale?'unconfirmed':'running'));
46
106
  if(status.execution&&status.state==='running')summary.append(el('p',state.stale?'Execution was reported but is now unconfirmed.':status.execution.provider+' · requested model '+(status.execution.requestedModel||'provider default')+' · elapsed '+duration(Date.now()-Date.parse(status.execution.startedAt))));
47
107
  if(status.story&&!workers.length)summary.append(row(status.storyTitle||status.story,status.phase||'Phase unknown',reportedState));
48
- for(const worker of workers)summary.append(row(worker.storyTitle||worker.story,[worker.selectedProvider||worker.provider,'requested model '+(worker.selectedModel||worker.model||'provider default'),worker.phase||worker.lifecycle||'working',worker.startedAt?(state.stale?'last started '+worker.startedAt:'elapsed '+duration(Date.now()-Date.parse(worker.startedAt))):null,worker.quality?'repair '+worker.quality.usedRepairs:null].filter(Boolean).join(' · '),state.stale?'unconfirmed':worker.lifecycle||'running'));
108
+ for(const worker of workers)summary.append(row(worker.storyTitle||worker.story,[worker.provider+(worker.selectedProvider&&worker.selectedProvider!==worker.provider?' ('+worker.selectedProvider+')':''),'requested model '+(worker.selectedModel||worker.model||'provider default'),worker.selectedVariant?'variant '+worker.selectedVariant:null,worker.phase||worker.lifecycle||'working',worker.startedAt?(state.stale?'last started '+worker.startedAt:'elapsed '+duration(Date.now()-Date.parse(worker.startedAt))):null,worker.quality?'repair '+worker.quality.usedRepairs:null].filter(Boolean).join(' · '),state.stale?'unconfirmed':worker.lifecycle||'running'));
49
109
  if(status.parallel?.integrator){const worker=status.parallel.integrator;summary.append(row('Integration: '+(worker.storyTitle||worker.story),worker.phase||'checking',state.stale?'unconfirmed':'integrating'))}
50
110
  if(!workers.length&&!status.story&&!goal.pendingAttempt)summary.append(el('p','No task currently reported.','empty'));
51
111
  summary.append(el('p','Status refreshes every 5 seconds. Requested models are not proof of the model actually used; reported model identities appear under Usage & time.','muted'));content.append(summary);
52
112
  const tasks=panel('Tasks');for(const task of p.stories||[])tasks.append(row(task.title,task.id+(task.needs?.length?' · after '+task.needs.join(', '):'')+' · '+taskTiming(task,p.estimate),task.passes?'passed':workers.some(worker=>worker.story===task.id)?state.stale?'unconfirmed':'running':'open'));content.append(tasks);
53
- const routing=panel('Model selection');for(const [id,decision] of Object.entries(status.routingDecisions||{})){routing.append(row(id,decision.provider+' / '+(decision.model||'provider default')+(decision.reasoningEffort?' · '+decision.reasoningEffort:''),decision.profile));routing.append(el('p',decision.reason));routing.append(el('p','Next escalation: '+decision.next,'muted'))}if(!Object.keys(status.routingDecisions||{}).length)routing.append(el('p','No recorded model selection for this run.','empty'));content.append(routing);
54
- const activity=panel('Recent activity');for(const event of (p.events||[]).slice(-16).reverse())activity.append(row(event.storyId||event.type,event.timestamp+(event.phase?' · '+event.phase:'')+(Number.isFinite(event.durationMs)?' · '+duration(event.durationMs):''),event.outcome||event.type));content.append(activity);
113
+ const routing=panel('Model selection');for(const [id,decision] of Object.entries(status.routingDecisions||{})){routing.append(row(id,decision.provider+(decision.providerModel?' ('+decision.providerModel+')':'')+' / '+(decision.model||'provider default')+(decision.reasoningEffort?' · '+decision.reasoningEffort:'')+(decision.variant?' · variant '+decision.variant:''),decision.profile));routing.append(el('p',decision.reason));routing.append(el('p','Next escalation: '+decision.next,'muted'))}if(!Object.keys(status.routingDecisions||{}).length)routing.append(el('p','No recorded model selection for this run.','empty'));content.append(routing);
114
+ renderLive(p);
55
115
  }
56
- function renderUsageHistory(current,previous){
57
- const total=current.total,prior=previous.total,currentTokens=total.inputTokens+total.outputTokens,previousTokens=prior.inputTokens+prior.outputTokens;
58
- const currentCoverage=usageCoverage(total)+(current.errors.length?' · measurement history is incomplete':''),previousCoverage=usageCoverage(prior)+(previous.errors.length?' · measurement history is incomplete':'');
59
- const currentCost=historyCost(total,current.errors),previousCost=historyCost(prior,previous.errors);
60
- const comparison=panel('Compared with the immediately preceding equal period');
61
- const boundary=value=>new Date(value).toISOString().slice(0,16).replace('T',' ');
116
+ function renderUsageHistory(current,previous,project={}){
117
+ const total=current.total,prior=previous.total,currentTokens=total.tokens?.total??safeMetricTotal([total.inputTokens,total.outputTokens]),previousTokens=prior.tokens?.total??safeMetricTotal([prior.inputTokens,prior.outputTokens]);
118
+ const currentCoverage=usageCoverage(total)+(current.errors.length?' · measurement history is incomplete':''),previousCoverage=usageCoverage(prior)+(previous.errors.length?' · measurement history is incomplete':''),currentCost=historyCost(total,current.errors),previousCost=historyCost(prior,previous.errors);
119
+ const comparison=panel('Compared with the immediately preceding equal period'),boundary=value=>new Date(value).toISOString().slice(0,16).replace('T',' ');
62
120
  comparison.append(el('p','Selected: '+boundary(current.from)+' → '+boundary(current.to)+' UTC','muted'),el('p','Previous: '+boundary(previous.from)+' → '+boundary(previous.to)+' UTC · end boundaries excluded','muted'));
63
- comparison.append(append(el('div',undefined,'metrics'),metric('Recorded tokens',tokenText(currentTokens),'Previous: '+tokenText(previousTokens)+' · '+comparisonText(currentTokens,previousTokens)+' · '+currentCoverage),metric('Accepted tasks',tokenText(total.accepted),'Previous: '+tokenText(prior.accepted)+' · '+comparisonText(total.accepted,prior.accepted)+' · recorded acceptance events'),metric('Reported cost',costText(currentCost),'Previous: '+costText(previousCost)+' · '+costComparisonText(currentCost,previousCost))));
64
- comparison.append(el('p','Current coverage: '+currentCoverage+'. Previous coverage: '+previousCoverage+'. Missing measurements are not estimated and recorded totals are not treated as complete totals.','muted'));for(const error of previous.errors)comparison.append(el('p','Previous period: '+error,'notice'));content.append(comparison);
65
- content.append(append(el('div',undefined,'metrics'),metric('Recorded input',tokenText(total.inputTokens),'Output: '+tokenText(total.outputTokens)),metric('Reported cost',costText(currentCost),'Missing charges are not estimated'),metric('Tokens / elapsed minute',rate(total.tokensPerElapsedMinute),'Input + output / entire selected period')));
66
- const notes=panel('Measurement coverage');notes.append(el('p',current.coverage));notes.append(el('p','First record in period: '+(current.earliest||'none')+' · latest: '+(current.latest||'none'),'muted'));notes.append(el('p','Measured calls: '+total.measuredCalls+' · calls with unknown usage: '+total.unknownCalls+' · unmeasured attempts: '+total.unmeasuredAttempts));notes.append(el('p','Tokens / recorded call minute: '+rate(total.tokensPerCallMinute)+' · summed call time: '+duration(total.callDurationMs)+'. Parallel calls overlap; this is consumption intensity, not generation speed.'));notes.append(el('p','Cache reads: '+tokenText(total.cachedInputTokens)+' · cache writes: '+tokenText(total.cacheWriteInputTokens)+'. These are reported categories and are not added again to input totals.','muted'));for(const error of current.errors)notes.append(el('p',error,'notice'));content.append(notes);
67
- const series=panel('Consumption over time');series.append(usageChart(current.buckets));series.append(el('p','Dark: input · orange: output. Chart shows up to 31 latest non-empty periods; the table includes every recorded period. Periods with no recorded events are omitted, so calendar gaps mean no recorded activity and do not assert a measured zero.','muted chart-gap-note'));series.append(table(['UTC '+current.bucket,'Input','Output','Cache reads','Reported cost'],current.buckets.map(value=>[value.label,tokenText(value.inputTokens),tokenText(value.outputTokens),tokenText(value.cachedInputTokens),costText(value)])));if(!current.buckets.length)series.append(el('p','No recorded activity in this period; measurement coverage may still be incomplete.','empty'));content.append(series);
68
- const models=panel('Reported models in this period');models.append(table(['Provider','Actual model','Role','Input','Output','Call time','Cost'],current.models.map(value=>[value.provider,value.model,value.role,tokenText(value.inputTokens),tokenText(value.outputTokens),duration(value.callDurationMs),costText(value)])));content.append(models);
69
- const modelTimeline=panel('Models over time');modelTimeline.append(table(['UTC '+current.bucket,'Provider','Actual model','Role','Input','Output'],current.modelBuckets.map(value=>[value.label,value.provider,value.model,value.role,tokenText(value.inputTokens),tokenText(value.outputTokens)])));content.append(modelTimeline);
70
- const tasks=panel('Consumption by task');tasks.append(table(['Task','Input','Output','Attempt time','Cost'],current.tasks.map(value=>[value.storyId,tokenText(value.inputTokens),tokenText(value.outputTokens),duration(value.attemptDurationMs),costText(value)])));content.append(tasks);
71
- const phases=panel('Recorded phase time');phases.append(table(['Phase','Summed duration'],current.phases.map(value=>[value.phase,duration(value.durationMs)])));phases.append(el('p','Overlapping worker time is summed. Unrecorded queue time and human waiting time remain unknown.','muted'));content.append(phases);
121
+ comparison.append(append(el('div',undefined,'metrics'),metric('Recorded tokens',tokenText(currentTokens),'Previous: '+tokenText(previousTokens)+' · '+comparisonText(currentTokens,previousTokens)+' · '+currentCoverage),metric('Accepted tasks',tokenText(total.accepted),'Previous: '+comparisonText(total.accepted,prior.accepted)+' · recorded acceptance events'),metric('Reported cost',costText(currentCost),'Previous: '+costText(previousCost)+' · '+costComparisonText(currentCost,previousCost))));
122
+ comparison.append(el('p','Current coverage: '+currentCoverage+'. Previous coverage: '+previousCoverage+'. Missing measurements are not estimated and recorded totals are not treated as complete totals.','muted'));for(const error of previous.errors)comparison.append(el('p','Previous period: '+error,'notice'));content.append(comparison,renderAnalytics(current,project));
72
123
  }
73
124
  function renderResults(p,a){const t=a.total;content.append(append(el('div',undefined,'metrics'),metric('Accepted in period',t.accepted,'Recorded acceptance events'),metric('Repair phases',t.repairs,'Routing escalations: '+t.escalations),metric('Tokens / acceptance',rate(t.tokensPerAccepted),'Reported input + output, including failed work')));const attempts=panel('Measured outcomes');attempts.append(el('p','Ended attempts: '+t.attempts+' · explicitly successful attempts: '+t.successfulAttempts+' · average summed attempt time per acceptance: '+duration(t.timePerAcceptedMs)));attempts.append(el('p','Worker termination alone is not counted as successful acceptance. Rates only describe recorded activity in the selected period.','muted'));attempts.append(table(['Task','Attempts','Accepted','Repairs','Escalations','Tokens / acceptance'],a.tasks.map(v=>[v.storyId,v.attempts,v.accepted,v.repairs,v.escalations,rate(v.tokensPerAccepted)])));content.append(attempts);const checks=panel('Latest saved acceptance evidence');if(p.check){checks.append(el('p',p.check.summary));for(const c of p.check.criteria){const item=row(c.text,c.id,c.status);item.firstChild.append(append(el('details'),el('summary','View evidence'),el('p',c.summary)));checks.append(item)}checks.append(el('p','Checked '+p.check.generatedAt+' · historical evidence; independent of the selected statistics period.','muted'))}else checks.append(el('p','No saved independent check.','empty'));content.append(checks);const goals=panel('Saved goal attempts');for(const attempt of p.goal?.attempts||[])goals.append(row(attempt.provider||'Agent',(attempt.summary||'')+' · '+duration(attempt.durationMs)+' · input '+tokenText(attempt.inputTokens)+' / output '+tokenText(attempt.outputTokens),attempt.success?'finished':'failed'));content.append(goals)}
125
+ function renderHistoryExplorer(project,history){const state=projectStatus(project),view=panel('History explorer');view.append(el('p','Recorded events and completed runs in the selected UTC window. The list is bounded to the latest 100 events.','muted'));const events=timelineEvents(history.events||[]);if(!events.length)view.append(el('p','No recorded history in this period.','empty'));else{const list=el('div',undefined,'timeline');for(const event of events)list.append(timelineItem(event));view.append(list)}const runs=panel('Run history');if(!(history.runs||[]).length)runs.append(el('p','No recorded runs in this period.','empty'));else runs.append(table(['Run','Started','Ended','Agent','Provider','Model','Tokens','Accepted','Outcome'],history.runs.map(run=>[dimensionLabel(run.runId),run.startedAt||'Not reported',run.endedAt||'Not reported',dimensionLabel(run.agent),dimensionLabel(run.provider),dimensionLabel(run.model),rankingText(run.total?.tokens?.total),rankingText(run.total?.outcomes?.accepted),rankingText(run.total?.outcomes?.successfulAttempts)])));content.append(renderCoverage(history,{stale:state.stale,unavailable:false}),runs,view)}
74
126
  async function showProject(id){
75
127
  const request=beginRequest();selected=id;nav();
76
128
  try{
77
- const bounds=periodWindows(Date.now()),detailRequest=api('/api/projects/'+id,{},request.signal);
78
- let project,current=null,previous=null;
129
+ const bounds=periodWindows(Date.now(),periodDays,customFrom,customTo),detailRequest=api('/api/projects/'+id,{},request.signal);
130
+ let project,current=null,previous=null,history=null;
79
131
  if(projectView==='usage'){
80
132
  [project,current,previous]=await Promise.all([detailRequest,api('/api/projects/'+id+'/analytics?'+queryPeriod(bounds.current),{},request.signal),api('/api/projects/'+id+'/analytics?'+queryPeriod(bounds.previous),{},request.signal)]);
81
133
  }else if(projectView==='results'){
82
134
  [project,current]=await Promise.all([detailRequest,api('/api/projects/'+id+'/analytics?'+queryPeriod(bounds.current),{},request.signal)]);
135
+ }else if(projectView==='history'){
136
+ [project,history]=await Promise.all([detailRequest,api('/api/projects/'+id+'/history?'+queryPeriod(bounds.current)+'&limit=100',{},request.signal)]);
83
137
  }else project=await detailRequest;
84
138
  if(!currentRequest(request.version)||navigationState.screen!=='project'||selected!==id)return;
85
- heading(project.name,project.goal?.objective||'Saved project work');content.append(el('p',project.root,'path'));for(const error of project.errors)content.append(el('p',error,'notice'));
86
- const tabs=el('div',undefined,'actions');for(const [view,label] of [['now','Now'],['usage','Usage & time'],['results','Results']]){const control=button(label,()=>setNavigation({view}),projectView===view);control.setAttribute('aria-pressed',String(projectView===view));tabs.append(control)}
87
- if(project.goal&&!['complete','paused'].includes(project.goal.status))tabs.append(button('Request pause',async()=>{await api('/api/projects/'+id+'/pause',{method:'POST',headers:{'x-yoke-token':sessionToken}},request.signal);await refresh()}));content.append(tabs);
139
+ lastSuccessfulSync=new Date().toISOString();heading(project.name,project.goal?.objective||'Saved project work');content.append(el('p',project.root,'path'));for(const error of project.errors)content.append(el('p',error,'notice'));
140
+ const tabs=el('div',undefined,'actions');for(const [view,label] of [['now','Now'],['usage','Usage & time'],['results','Results'],['history','History']]){const control=button(label,()=>setNavigation({view}),projectView===view);control.setAttribute('aria-pressed',String(projectView===view));tabs.append(control)}
141
+ content.append(tabs);
88
142
  if(projectView==='now'){
89
143
  const tasks=project.stories||[],state=projectStatus(project);content.append(append(el('div',undefined,'metrics'),metric('Accepted tasks',tasks.filter(task=>task.passes).length+' / '+tasks.length,'Saved acceptance state'),metric('Remaining time',project.estimate?.available?duration(project.estimate.lowerMs)+' – '+duration(project.estimate.upperMs):'Unknown',project.estimate?.available?project.estimate.sampleCount+' samples · empirical range':'No reliable duration history'),metric('Project status',state.primary,state.goal&&state.loop&&state.goal!==state.loop?'Goal '+state.goal+' · loop '+(state.stale?'unconfirmed':state.loop):'Latest reported state')));renderNow(project);
90
- }else{periodControls(id);if(projectView==='usage')renderUsageHistory(current,previous);else renderResults(project,current)}
91
- }catch(error){if(currentRequest(request.version)&&navigationState.screen==='project'&&selected===id&&!isAbort(error))heading('Project unavailable',error.message)}
144
+ }else{periodControls(id);if(projectView==='usage')renderUsageHistory(current,previous,projectStatus(project));else if(projectView==='history')renderHistoryExplorer(project,history);else renderResults(project,current)}
145
+ }catch(error){if(currentRequest(request.version)&&navigationState.screen==='project'&&selected===id&&!isAbort(error)){heading('Project unavailable','Offline or unavailable: '+error.message);content.append(renderCoverage({errors:[],coverage:'Historical analytics are unavailable for this project.'},{unavailable:true}))}}
92
146
  }
93
147
  if(typeof setInterval==='function')setInterval(()=>{if(selected&&projectView==='now'&&typeof document.hidden!=='undefined'&&!document.hidden&&!['INPUT','SELECT','TEXTAREA'].includes(document.activeElement?.tagName)&&!document.activeElement?.matches?.(':focus-visible'))showProject(selected)},5000);
94
148
  `;
@@ -1,6 +1,6 @@
1
1
  import { randomBytes, timingSafeEqual } from 'node:crypto';
2
2
  import { createServer } from 'node:http';
3
- import { lstatSync, readFileSync, readdirSync } from 'node:fs';
3
+ import { lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { parse } from 'yaml';
6
6
  import { z } from 'zod';
@@ -8,8 +8,14 @@ import { listProjects } from './registry.js';
8
8
  import { dashboardPage } from './page.js';
9
9
  import { readEvents } from '../observability/events.js';
10
10
  import { estimateSchedule } from '../estimation/schedule.js';
11
- import { pauseProjectGoal } from '../goals/command.js';
12
- import { parsePeriod, projectAnalytics } from './analytics.js';
11
+ import { pauseProjectGoal, runProjectGoal } from '../goals/command.js';
12
+ import { runLoopCommand } from '../loop/run-command.js';
13
+ import { isPidAlive, readLock } from '../loop/lock.js';
14
+ import { appendEvent } from '../observability/events.js';
15
+ import { queueChange } from '../change/inbox.js';
16
+ import { statePath } from '../workspace/state.js';
17
+ import { parsePeriod, parseRanking, projectAnalytics, projectHistory, workspaceAnalytics } from './analytics.js';
18
+ import { DASHBOARD_LIMITS, DashboardChangePayloadSchema, DashboardControlPayloadSchema, DashboardNotePayloadSchema, DashboardResumePayloadSchema, parseDashboardLimit, } from './contracts.js';
13
19
  const text = z.string().max(16000);
14
20
  const number = z.number().finite().nonnegative();
15
21
  const Goal = z.object({ objective: text, status: z.enum(['active', 'running', 'paused', 'blocked', 'complete']), reason: text.optional(), pendingAttempt: z.object({ provider: text, model: text.optional(), startedAt: text }).optional(), attempts: z.array(z.object({ durationMs: number.optional(), provider: text.optional(), success: z.boolean().optional(), summary: text.optional(), inputTokens: number.optional(), outputTokens: number.optional() })).max(200).default([]) });
@@ -17,8 +23,11 @@ const Status = z.object({ state: text, phase: text.optional(), reason: text.opti
17
23
  const Stories = z.array(z.object({ id: text, title: text, passes: z.boolean(), priority: number.optional(), area: text.optional(), writes: z.array(text).optional(), needs: z.array(text).optional() })).max(2000);
18
24
  const Check = z.object({ id: text, status: z.enum(['passed', 'failed', 'unverified']), generatedAt: text, summary: text, criteria: z.array(z.object({ id: text, text, status: z.enum(['passed', 'failed', 'unverified']), summary: text })).max(500) });
19
25
  const Durations = z.array(z.object({ storyId: text, ms: number.positive() })).max(5000);
20
- const MAX_FILE = 1_048_576;
26
+ const MAX_FILE = DASHBOARD_LIMITS.fileBytes;
27
+ const activeResumes = new Set();
21
28
  function safeFile(root, relative) {
29
+ if (!relative || relative.startsWith('/') || relative.includes('\\') || relative.split('/').some(part => !part || part === '.' || part === '..'))
30
+ throw new Error('Unsafe project path');
22
31
  let file = root;
23
32
  try {
24
33
  for (const part of relative.split('/')) {
@@ -39,6 +48,62 @@ function safeFile(root, relative) {
39
48
  throw error;
40
49
  }
41
50
  }
51
+ function requestBody(req, empty = { action: 'pause' }) {
52
+ const length = req.headers['content-length'];
53
+ if (typeof length === 'string' && Number.isFinite(Number(length)) && Number(length) > DASHBOARD_LIMITS.requestBytes) {
54
+ req.resume();
55
+ return Promise.reject(new Error('Dashboard request body too large'));
56
+ }
57
+ return new Promise((resolve, reject) => {
58
+ const chunks = [];
59
+ let size = 0;
60
+ let rejected = false;
61
+ req.on('data', chunk => {
62
+ size += Buffer.byteLength(chunk);
63
+ if (size > DASHBOARD_LIMITS.requestBytes) {
64
+ rejected = true;
65
+ req.resume();
66
+ reject(new Error('Dashboard request body too large'));
67
+ return;
68
+ }
69
+ chunks.push(Buffer.from(chunk));
70
+ });
71
+ req.on('end', () => {
72
+ if (rejected)
73
+ return;
74
+ const body = Buffer.concat(chunks).toString('utf8').trim();
75
+ if (!body) {
76
+ resolve(empty);
77
+ return;
78
+ }
79
+ try {
80
+ resolve(JSON.parse(body));
81
+ }
82
+ catch {
83
+ reject(new Error('Invalid dashboard request body'));
84
+ }
85
+ });
86
+ req.on('error', reject);
87
+ });
88
+ }
89
+ function writePauseSignal(root) {
90
+ const directory = statePath(root);
91
+ const path = statePath(root, 'loop.pause');
92
+ mkdirSync(directory, { recursive: true });
93
+ try {
94
+ writeFileSync(path, 'pause\n', { flag: 'wx', mode: 0o600 });
95
+ }
96
+ catch (error) {
97
+ if (error.code !== 'EEXIST')
98
+ throw error;
99
+ }
100
+ }
101
+ function projectIsRunning(root) {
102
+ if (activeResumes.has(root))
103
+ return true;
104
+ const lock = readLock(root);
105
+ return Boolean(lock && isPidAlive(lock.pid));
106
+ }
42
107
  function snapshot(project, detail) {
43
108
  const errors = project.error ? [project.error] : [];
44
109
  const read = (file, schema, yaml = false) => {
@@ -75,13 +140,13 @@ function snapshot(project, detail) {
75
140
  errors.push(error.message);
76
141
  }
77
142
  }
78
- return { ...project, goal, status, errors, ...(detail ? { stories, check, events: project.error ? [] : readEvents(project.root, 100), estimate: estimateSchedule(stories, Math.max(1, status?.parallel?.maxConcurrency ?? 1), history) } : {}) };
143
+ return { ...project, goal, status, errors, ...(detail ? { stories, check, events: project.error ? [] : readEvents(project.root, DASHBOARD_LIMITS.events), estimate: estimateSchedule(stories, Math.max(1, status?.parallel?.maxConcurrency ?? 1), history) } : {}) };
79
144
  }
80
145
  export async function startDashboard(options = {}) {
81
146
  const token = randomBytes(32).toString('hex');
82
147
  const nonce = randomBytes(24).toString('base64');
83
148
  let origin = '';
84
- const server = createServer((req, res) => {
149
+ const server = createServer(async (req, res) => {
85
150
  res.setHeader('Cache-Control', 'no-store');
86
151
  res.setHeader('X-Content-Type-Options', 'nosniff');
87
152
  res.setHeader('Referrer-Policy', 'no-referrer');
@@ -91,14 +156,19 @@ export async function startDashboard(options = {}) {
91
156
  send(403, { error: 'Untrusted request origin' });
92
157
  return;
93
158
  }
94
- let path;
159
+ let requested;
95
160
  try {
96
- path = new URL(req.url ?? '/', origin).pathname;
161
+ requested = new URL(req.url ?? '/', origin);
97
162
  }
98
163
  catch {
99
164
  send(400, { error: 'Invalid URL' });
100
165
  return;
101
166
  }
167
+ if (requested.origin !== origin) {
168
+ send(403, { error: 'Untrusted request origin' });
169
+ return;
170
+ }
171
+ const path = requested.pathname;
102
172
  if (req.method === 'POST') {
103
173
  const supplied = req.headers['x-yoke-token'];
104
174
  if (req.headers.origin !== origin || typeof supplied !== 'string' || Buffer.byteLength(supplied) !== token.length || !timingSafeEqual(Buffer.from(supplied), Buffer.from(token))) {
@@ -121,7 +191,20 @@ export async function startDashboard(options = {}) {
121
191
  send(200, listProjects().map(project => snapshot(project, false)));
122
192
  return;
123
193
  }
124
- const match = /^\/api\/projects\/([a-f0-9]{32})(\/pause|\/analytics)?$/u.exec(path);
194
+ if (req.method === 'GET' && path === '/api/workspace/analytics') {
195
+ let period, sort;
196
+ try {
197
+ period = parsePeriod(requested.searchParams);
198
+ sort = parseRanking(requested.searchParams);
199
+ }
200
+ catch (error) {
201
+ send(400, { error: error.message });
202
+ return;
203
+ }
204
+ send(200, workspaceAnalytics(listProjects(), period, sort));
205
+ return;
206
+ }
207
+ const match = /^\/api\/projects\/([a-f0-9]{32})(\/pause|\/resume|\/notes|\/changes|\/analytics|\/history|\/events)?$/u.exec(path);
125
208
  if (match) {
126
209
  const project = listProjects().find(project => project.id === match[1]);
127
210
  if (!project) {
@@ -135,7 +218,7 @@ export async function startDashboard(options = {}) {
135
218
  }
136
219
  let period;
137
220
  try {
138
- period = parsePeriod(new URL(req.url, origin).searchParams);
221
+ period = parsePeriod(requested.searchParams);
139
222
  }
140
223
  catch (error) {
141
224
  send(400, { error: error.message });
@@ -144,18 +227,110 @@ export async function startDashboard(options = {}) {
144
227
  send(200, projectAnalytics(project.root, period));
145
228
  return;
146
229
  }
230
+ if (req.method === 'GET' && (match[2] === '/history' || match[2] === '/events')) {
231
+ if (project.error) {
232
+ send(409, { error: project.error });
233
+ return;
234
+ }
235
+ let period, limit;
236
+ try {
237
+ period = parsePeriod(requested.searchParams);
238
+ limit = parseDashboardLimit(requested.searchParams.get('limit'));
239
+ }
240
+ catch (error) {
241
+ send(400, { error: error.message });
242
+ return;
243
+ }
244
+ send(200, projectHistory(project.root, period, limit));
245
+ return;
246
+ }
147
247
  if (req.method === 'GET' && !match[2]) {
148
248
  send(200, snapshot(project, true));
149
249
  return;
150
250
  }
151
251
  if (req.method === 'POST' && match[2] === '/pause') {
152
- if (project.error || !snapshot(project, false).goal) {
153
- send(409, { error: 'No readable project goal' });
252
+ try {
253
+ DashboardControlPayloadSchema.parse(await requestBody(req, { action: 'pause' }));
254
+ }
255
+ catch (error) {
256
+ send(error.message === 'Dashboard request body too large' ? 413 : 400, { error: error.message === 'Dashboard request body too large' ? error.message : 'Invalid dashboard control payload' });
257
+ return;
258
+ }
259
+ const current = snapshot(project, false);
260
+ if (project.error) {
261
+ send(409, { error: project.error });
262
+ return;
263
+ }
264
+ if (current.goal)
265
+ pauseProjectGoal(project.root);
266
+ writePauseSignal(project.root);
267
+ const response = { status: 'pause-requested' };
268
+ send(200, response);
269
+ return;
270
+ }
271
+ if (req.method === 'POST' && match[2] === '/resume') {
272
+ try {
273
+ DashboardResumePayloadSchema.parse(await requestBody(req, { action: 'resume' }));
274
+ }
275
+ catch (error) {
276
+ send(error.message === 'Dashboard request body too large' ? 413 : 400, { error: error.message === 'Dashboard request body too large' ? error.message : 'Invalid dashboard resume payload' });
277
+ return;
278
+ }
279
+ const current = snapshot(project, false);
280
+ if (project.error) {
281
+ send(409, { error: project.error });
282
+ return;
283
+ }
284
+ if (projectIsRunning(project.root)) {
285
+ send(409, { status: 'already-running', error: 'Project is already running' });
286
+ return;
287
+ }
288
+ const goal = current.goal !== null && current.goal.status !== 'complete';
289
+ activeResumes.add(project.root);
290
+ void Promise.resolve().then(async () => {
291
+ if (goal)
292
+ await runProjectGoal(project.root);
293
+ else
294
+ await runLoopCommand(project.root, {});
295
+ }).catch(() => undefined).finally(() => { activeResumes.delete(project.root); });
296
+ const response = { status: 'resume-requested' };
297
+ send(202, response);
298
+ return;
299
+ }
300
+ if (req.method === 'POST' && match[2] === '/notes') {
301
+ let payload;
302
+ try {
303
+ payload = DashboardNotePayloadSchema.parse(await requestBody(req, {}));
304
+ }
305
+ catch (error) {
306
+ send(error.message === 'Dashboard request body too large' ? 413 : 400, { error: error.message === 'Dashboard request body too large' ? error.message : 'Invalid dashboard note payload' });
307
+ return;
308
+ }
309
+ if (project.error) {
310
+ send(409, { error: project.error });
311
+ return;
312
+ }
313
+ appendEvent(project.root, { runId: 'dashboard', timestamp: new Date().toISOString(), type: 'operator-note', data: { note: payload.note } });
314
+ const response = { status: 'note-added' };
315
+ send(200, response);
316
+ return;
317
+ }
318
+ if (req.method === 'POST' && match[2] === '/changes') {
319
+ let payload;
320
+ try {
321
+ payload = DashboardChangePayloadSchema.parse(await requestBody(req, {}));
322
+ }
323
+ catch (error) {
324
+ send(error.message === 'Dashboard request body too large' ? 413 : 400, { error: error.message === 'Dashboard request body too large' ? error.message : 'Invalid dashboard change payload' });
325
+ return;
326
+ }
327
+ if (project.error) {
328
+ send(409, { error: project.error });
154
329
  return;
155
330
  }
156
- safeFile(project.root, '.yoke/goal.pause');
157
- pauseProjectGoal(project.root);
158
- send(200, { status: 'pause-requested' });
331
+ const change = queueChange(project.root, payload.request);
332
+ const response = { status: 'pending', requestId: change.id };
333
+ send(200, response);
159
334
  return;
160
335
  }
161
336
  }
@@ -10,10 +10,11 @@ import { acquireLock, releaseLock, isPidAlive } from '../loop/lock.js';
10
10
  import { reapProviderProcesses, isProviderTreeAlive } from '../loop/cleanup.js';
11
11
  import { killProcessTreeForCleanup } from '../loop/watchdog.js';
12
12
  import { buildProviderInvocation, startProviderProcess } from '../agents/providers.js';
13
+ import { AgentSchema } from '../agents/contracts.js';
13
14
  import { appendEvent } from '../observability/events.js';
14
15
  import { statePath } from '../workspace/state.js';
15
- const Attempt = z.object({ provider: z.enum(['codex', 'claude', 'gemini', 'qwen']), model: z.string().optional(), startedAt: z.string(), durationMs: z.number().nonnegative(), success: z.boolean(), summary: z.string(), checkId: z.string(), inputTokens: z.number().nonnegative().optional(), outputTokens: z.number().nonnegative().optional() });
16
- const Goal = z.object({ version: z.literal(1), id: z.string().uuid(), objective: z.string().trim().min(1).max(16000), status: z.enum(['active', 'running', 'paused', 'blocked', 'complete']), createdAt: z.string(), updatedAt: z.string(), maxAttempts: z.number().int().min(1).max(20), maxMinutes: z.number().positive().max(1440), tokenBudget: z.number().int().positive().optional(), attempts: z.array(Attempt), reason: z.string().optional(), lastCheck: z.string().optional(), pendingAttempt: z.object({ provider: z.enum(['codex', 'claude', 'gemini', 'qwen']), model: z.string().optional(), startedAt: z.string().datetime() }).optional() }).strict();
16
+ const Attempt = z.object({ provider: AgentSchema, model: z.string().optional(), startedAt: z.string(), durationMs: z.number().nonnegative(), success: z.boolean(), summary: z.string(), checkId: z.string(), inputTokens: z.number().nonnegative().optional(), outputTokens: z.number().nonnegative().optional() });
17
+ const Goal = z.object({ version: z.literal(1), id: z.string().uuid(), objective: z.string().trim().min(1).max(16000), status: z.enum(['active', 'running', 'paused', 'blocked', 'complete']), createdAt: z.string(), updatedAt: z.string(), maxAttempts: z.number().int().min(1).max(20), maxMinutes: z.number().positive().max(1440), tokenBudget: z.number().int().positive().optional(), attempts: z.array(Attempt), reason: z.string().optional(), lastCheck: z.string().optional(), pendingAttempt: z.object({ provider: AgentSchema, model: z.string().optional(), startedAt: z.string().datetime() }).optional() }).strict();
17
18
  const goalPath = (root) => statePath(root, 'goal.json');
18
19
  function save(root, goal) {
19
20
  const parsed = Goal.parse({ ...goal, updatedAt: new Date().toISOString() });
@@ -4,6 +4,7 @@ import { z } from 'zod';
4
4
  import { isPidAlive } from './lock.js';
5
5
  import { publishClaimFile, replaceClaimFile, withClaimOperations } from './claim-lease.js';
6
6
  import { storyPathSegment } from './prd.js';
7
+ import { AgentSchema } from '../agents/contracts.js';
7
8
  export const CLAIM_SCHEMA_VERSION = 2;
8
9
  const LegacyClaimSchema = z.object({
9
10
  storyId: z.string().min(1),
@@ -18,7 +19,7 @@ const RichClaimSchema = LegacyClaimSchema.extend({
18
19
  heartbeatAt: z.string().min(1),
19
20
  baseCommit: z.string().min(1).optional(),
20
21
  worktree: z.string().min(1).optional(),
21
- provider: z.enum(['claude', 'codex', 'gemini', 'qwen']).optional(),
22
+ provider: AgentSchema.optional(),
22
23
  model: z.string().min(1).optional(),
23
24
  role: z.enum(['implementation', 'quality', 'repair', 'review']).optional(),
24
25
  cancellation: z.object({
@@ -10,6 +10,7 @@ import { resolveCommitIdentity } from './identity.js';
10
10
  import { commitPaths, realGitOps } from './git.js';
11
11
  import { acquireLock, releaseLock } from './lock.js';
12
12
  import { loadPrd, selectNextStory } from './prd.js';
13
+ import { AgentSchema } from '../agents/contracts.js';
13
14
  const singleLine = (max) => z.string().trim().min(1).max(max).refine(value => !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value), 'must be a single line without control or bidi-formatting characters');
14
15
  const MAX_STATE_BYTES = 256 * 1024;
15
16
  const DecisionOptionSchema = z.object({
@@ -60,9 +61,9 @@ export const DecisionResumeSchema = z.object({
60
61
  answered: z.boolean().optional(),
61
62
  /** Absent preserves the default unlimited run across a critical-decision resume. */
62
63
  maxIterations: z.number().int().positive().optional(),
63
- agent: z.enum(['claude', 'codex', 'gemini', 'qwen']).optional(),
64
+ agent: AgentSchema.optional(),
64
65
  isolate: z.boolean().optional(),
65
- reviewer: z.enum(['claude', 'codex', 'gemini', 'qwen']).optional(),
66
+ reviewer: AgentSchema.optional(),
66
67
  review: z.boolean().optional(),
67
68
  allowSelfReview: z.boolean().optional(),
68
69
  timeoutMinutes: z.number().nonnegative().optional(),
@@ -255,7 +255,7 @@ function asyncRunner(input, provider, signal, workerId) {
255
255
  if (input.routing) {
256
256
  const routed = makeAsyncAdaptiveRunner({
257
257
  parent: input.routing.strategy === 'capability' ? input.runnerAgent : provider.provider,
258
- parentSelection: input.routing.strategy === 'capability' ? input.selection : { ...input.selection, model: provider.model, reasoningEffort: provider.reasoningEffort, nativeMultiAgent: false },
258
+ parentSelection: input.routing.strategy === 'capability' ? input.selection : { ...input.selection, provider: provider.providerModel, model: provider.model, reasoningEffort: provider.reasoningEffort, variant: provider.variant, nativeMultiAgent: false },
259
259
  projectRoot: input.targetDir,
260
260
  workers: input.routing.workers,
261
261
  rules: input.routing.rules,
@@ -276,7 +276,7 @@ function asyncRunner(input, provider, signal, workerId) {
276
276
  makeWorker: (agent, selection) => async (context) => {
277
277
  if (signal?.aborted)
278
278
  return { success: false, summary: 'Routing cancelled before worker start' };
279
- input.reporter.parallelWorker?.({ story: context.story.id, storyTitle: context.story.title, provider: agent, selectedProvider: agent, selectedModel: selection.model, phase: 'implementing' });
279
+ input.reporter.parallelWorker?.({ story: context.story.id, storyTitle: context.story.title, provider: agent, selectedProvider: selection.provider ?? agent, selectedModel: selection.model, selectedVariant: selection.variant, phase: 'implementing' });
280
280
  const run = makeAsyncRunner(agent, { onAmbiguity: input.onAmbiguity, permissions: input.permissions, selection,
281
281
  process: { idleTimeoutMs: input.idleMs, signal, workerId } });
282
282
  return providerProcessResultToAgentResult(agent, context.story.id, await run(context).completion);
@@ -292,10 +292,12 @@ function asyncRunner(input, provider, signal, workerId) {
292
292
  permissions: input.permissions,
293
293
  selection: {
294
294
  ...selection,
295
+ ...(provider.providerModel ? { provider: provider.providerModel } : {}),
295
296
  ...(provider.provider !== 'gemini' && globalBare !== undefined ? { bare: globalBare } : {}),
296
297
  nativeMultiAgent: false,
297
298
  ...(provider.model ? { model: provider.model } : {}),
298
299
  ...(provider.reasoningEffort ? { reasoningEffort: provider.reasoningEffort } : {}),
300
+ ...(provider.variant ? { variant: provider.variant } : {}),
299
301
  },
300
302
  process: { idleTimeoutMs: input.idleMs, signal, workerId },
301
303
  });