@hecer/yoke 1.9.0 → 1.10.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 (41) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +22 -1
  4. package/README.md +8 -6
  5. package/canon/loop/prd.schema.md +6 -0
  6. package/dist/agents/process-incarnation.js +1 -1
  7. package/dist/agents/process.js +74 -6
  8. package/dist/agents/supervision.js +153 -0
  9. package/dist/agents/windows-launch.js +80 -0
  10. package/dist/change/inbox.js +21 -5
  11. package/dist/cli.js +11 -2
  12. package/dist/dashboard/page.js +102 -8
  13. package/dist/dashboard/panels.js +84 -8
  14. package/dist/goals/command.js +2 -0
  15. package/dist/loop/git.js +12 -4
  16. package/dist/loop/loop.js +8 -4
  17. package/dist/loop/parallel-adapters.js +2 -3
  18. package/dist/loop/parallel-command.js +5 -0
  19. package/dist/loop/prd.js +2 -0
  20. package/dist/loop/reporter.js +4 -1
  21. package/dist/loop/run-command.js +11 -2
  22. package/dist/loop/runner.js +22 -26
  23. package/dist/loop/watchdog.js +87 -11
  24. package/dist/loop/worker.js +5 -3
  25. package/dist/prd/assess.js +145 -0
  26. package/dist/prd/command.js +59 -21
  27. package/dist/retrofit/config.js +11 -0
  28. package/dist/routing/assessment.js +1 -1
  29. package/dist/routing/capability.js +25 -13
  30. package/dist/routing/contracts.js +60 -0
  31. package/dist/routing/planning.js +12 -0
  32. package/dist/routing/router.js +50 -15
  33. package/dist/setup/command.js +2 -0
  34. package/docs/BATCH-PLANNING-VALIDATION.md +67 -0
  35. package/docs/CAPABILITY-ROUTING.md +31 -3
  36. package/docs/DASHBOARD-EVOLUTION.md +33 -0
  37. package/docs/PRODUCT-DIRECTION-2026-09-05.md +18 -0
  38. package/docs/WINDOWS-RUNNER-VALIDATION.md +104 -0
  39. package/docs/assets/yoke-logo.png +0 -0
  40. package/gemini-extension.json +1 -1
  41. package/package.json +1 -1
@@ -5,15 +5,19 @@ export function dashboardPage(token, nonce) {
5
5
  return `<!doctype html>
6
6
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Yoke · Project workspace</title>
7
7
  <style nonce="${nonce}">
8
- :root{color-scheme:light;--ink:#15242d;--muted:#647077;--line:#dce1df;--paper:#f4f5f0;--accent:#c34c27;--green:#167452}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 system-ui,sans-serif}button{font:inherit;cursor:pointer}button:focus-visible,a:focus-visible{outline:3px solid #dc734a;outline-offset:3px}.layout{display:grid;grid-template-columns:250px minmax(0,1fr);min-height:100vh}aside{background:#142b32;color:#eef5f1;padding:34px 22px;display:flex;flex-direction:column;gap:30px}.brand{font-size:31px;font-weight:780;letter-spacing:-1.5px}.brand span{color:#ffb185}aside p{color:#afc3c6;font-size:13px}.label{text-transform:uppercase;font-size:11px;letter-spacing:1.7px;font-weight:750;color:var(--muted)}aside .label{color:#91a9ad}nav{display:grid;gap:7px}.nav-button{border:0;background:transparent;color:#cad8d9;text-align:left;padding:11px 12px;border-radius:8px;overflow-wrap:anywhere}.nav-button[aria-current=true]{background:#29444b;color:#fff}.aside-foot{margin-top:auto;border-top:1px solid #355057;padding-top:20px}main{max-width:1500px;width:100%;padding:40px 5vw 70px}.top{display:flex;justify-content:space-between;align-items:center;gap:20px}.local{font-size:12px;color:var(--green);background:#e2ece1;border-radius:30px;padding:6px 12px}.button{border:1px solid #bcc7c5;background:#fff;border-radius:7px;padding:9px 15px;color:var(--ink)}.button.primary{background:var(--ink);color:#fff;border-color:var(--ink)}h1{font-size:clamp(27px,3vw,40px);line-height:1.2;margin:17px 0 8px;letter-spacing:-1.4px}h2{font-size:18px;margin:0 0 18px}h3{font-size:16px;margin:0 0 8px}p{margin:6px 0}.muted{color:var(--muted)}.summary{max-width:850px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin:28px 0}.metric,.panel,.project-card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:22px}.metric strong{display:block;font-size:28px;letter-spacing:-.8px;margin:8px 0 4px}.metric p{font-size:12px;color:var(--muted)}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:18px;margin:28px 0}.project-card{display:flex;flex-direction:column;align-items:flex-start;gap:12px}.project-card p{color:var(--muted);overflow-wrap:anywhere}.project-card button{margin-top:auto}.columns{display:grid;grid-template-columns:1.2fr 1fr;gap:22px}.panel{margin-bottom:22px}.row{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;padding:13px 0;border-top:1px solid #edf0eb}.row:first-child{border-top:0}.row-main{min-width:0;overflow-wrap:anywhere}.row small{display:block;color:var(--muted);margin-top:4px}.badge{font-size:11px;font-weight:650;background:#edf0ed;padding:4px 9px;border-radius:20px;white-space:nowrap}.badge.good{color:#166343;background:#e4f0e7}.badge.attention{color:#a43e22;background:#fff0e7}.notice{padding:13px 16px;margin:18px 0;border-left:3px solid var(--accent);background:#fff2e9;overflow-wrap:anywhere}.actions{display:flex;align-items:center;gap:12px;margin:22px 0}.path{font:12px/1.5 ui-monospace,monospace;word-break:break-all}.empty{padding:26px 0;color:var(--muted)}details{font-size:12px;margin-top:8px}details p{white-space:pre-wrap;max-height:220px;overflow:auto}.timeline .row{font-size:13px}.loading{padding:50px;color:var(--muted)}@media(max-width:900px){.layout{grid-template-columns:1fr}aside{padding:16px 24px;gap:12px}.brand{font-size:25px}aside>p,.aside-foot,aside>.label{display:none}nav{display:flex;overflow:auto}.nav-button{white-space:nowrap}.metrics{gap:9px}.metric{padding:15px}.columns{grid-template-columns:1fr}main{padding:25px 22px}.metric strong{font-size:23px}}@media(max-width:520px){.metrics{grid-template-columns:1fr}.top{align-items:flex-start}.local{white-space:nowrap}.metric strong{font-size:26px}.metric p{font-size:13px}}
8
+ :root{color-scheme:light;--ink:#15242d;--muted:#647077;--line:#dce1df;--paper:#f4f5f0;--accent:#c34c27;--green:#167452}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 system-ui,sans-serif}button{font:inherit;cursor:pointer}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #dc734a;outline-offset:3px}.layout{display:grid;grid-template-columns:250px minmax(0,1fr);min-height:100vh}aside{background:#142b32;color:#eef5f1;padding:34px 22px;display:flex;flex-direction:column;gap:30px}.brand{font-size:31px;font-weight:780;letter-spacing:-1.5px}.brand span{color:#ffb185}aside p{color:#afc3c6;font-size:13px}.label{text-transform:uppercase;font-size:11px;letter-spacing:1.7px;font-weight:750;color:var(--muted)}aside .label{color:#91a9ad}nav{display:grid;gap:7px}.nav-button{border:0;background:transparent;color:#cad8d9;text-align:left;padding:11px 12px;border-radius:8px;overflow-wrap:anywhere}.nav-button[aria-current=true]{background:#29444b;color:#fff}.aside-foot{margin-top:auto;border-top:1px solid #355057;padding-top:20px}main{max-width:1500px;width:100%;padding:40px 5vw 70px}.top{display:flex;justify-content:space-between;align-items:center;gap:20px}.local{font-size:12px;color:var(--green);background:#e2ece1;border-radius:30px;padding:6px 12px}.button{border:1px solid #bcc7c5;background:#fff;border-radius:7px;padding:9px 15px;color:var(--ink)}.button.primary{background:var(--ink);color:#fff;border-color:var(--ink)}h1{font-size:clamp(27px,3vw,40px);line-height:1.2;margin:17px 0 8px;letter-spacing:-1.4px}h2{font-size:18px;margin:0 0 18px}h3{font-size:16px;margin:0 0 8px}p{margin:6px 0}.muted{color:var(--muted)}.summary{max-width:850px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin:28px 0}.metric,.panel,.project-card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:22px}.metric strong{display:block;font-size:28px;letter-spacing:-.8px;margin:8px 0 4px}.metric p{font-size:12px;color:var(--muted)}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:18px;margin:28px 0}.project-card{display:flex;flex-direction:column;align-items:flex-start;gap:12px}.project-card p{color:var(--muted);overflow-wrap:anywhere}.project-card button{margin-top:auto}.columns{display:grid;grid-template-columns:1.2fr 1fr;gap:22px}.panel{margin-bottom:22px}.row{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;padding:13px 0;border-top:1px solid #edf0eb}.row:first-child{border-top:0}.row-main{min-width:0;overflow-wrap:anywhere}.row small{display:block;color:var(--muted);margin-top:4px}.badge{font-size:11px;font-weight:650;background:#edf0ed;padding:4px 9px;border-radius:20px;white-space:nowrap}.badge.good{color:#166343;background:#e4f0e7}.badge.attention{color:#a43e22;background:#fff0e7}.notice{padding:13px 16px;margin:18px 0;border-left:3px solid var(--accent);background:#fff2e9;overflow-wrap:anywhere}.actions{display:flex;align-items:center;gap:12px;margin:22px 0}.path{font:12px/1.5 ui-monospace,monospace;word-break:break-all}.empty{padding:26px 0;color:var(--muted)}details{font-size:12px;margin-top:8px}details p{white-space:pre-wrap;max-height:220px;overflow:auto}.timeline .row{font-size:13px}.loading{padding:50px;color:var(--muted)}.overview-tools{display:flex;flex-wrap:wrap;align-items:end;gap:14px;margin:24px 0}.field{display:grid;gap:5px;flex:1 1 260px}.field input{margin:0;width:100%}.filter-group{display:flex;flex-wrap:wrap;gap:7px}.filter-group .button[aria-pressed=true]{background:var(--ink);color:#fff}.state-line{font-size:13px}.comparison{font-weight:650}.chart-gap-note{margin-top:12px}@media(max-width:900px){.layout{grid-template-columns:1fr}aside{padding:16px 24px;gap:12px}.brand{font-size:25px}aside>p,.aside-foot,aside>.label{display:none}nav{display:flex;overflow:auto}.nav-button{white-space:nowrap}.metrics{gap:9px}.metric{padding:15px}.columns{grid-template-columns:1fr}main{padding:25px 22px}.metric strong{font-size:23px}}@media(max-width:520px){.metrics{grid-template-columns:1fr}.top{align-items:flex-start;flex-direction:column}.local{white-space:nowrap}.metric strong{font-size:26px}.metric p{font-size:13px}.overview-tools{align-items:stretch}.filter-group{display:grid;grid-template-columns:1fr}}
9
9
  main,aside,.panel{min-width:0}.actions{flex-wrap:wrap}.table-scroll{overflow-x:auto;max-width:100%}table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:10px;border-bottom:1px solid var(--line)}select,input{font:inherit;padding:8px;margin:4px;border:1px solid var(--line);border-radius:6px;max-width:100%}</style></head><body><div class="layout"><aside><div class="brand">yoke<span>.</span></div><p>Work you can verify.<br>Projects you can pick up again.</p><div class="label">Your workspace</div><nav id="navigation" aria-label="Projects"></nav><div class="aside-foot"><div class="label">Local workspace</div><p>Project data stays on this machine.</p></div></aside><main><div class="top"><div class="label">Project workspace</div><div><span class="local">● Local session</span> <button id="refresh" class="button">Refresh</button></div></div><div id="content" aria-live="polite"><p class="loading">Loading your projects…</p></div></main></div>
10
10
  <script nonce="${nonce}">
11
11
  const sessionToken = ${JSON.stringify(token)};
12
12
  const content=document.getElementById('content'), navigation=document.getElementById('navigation');
13
- let selected=null, projects=[];
13
+ const defaultNavigation={screen:'overview',project:null,view:'now',period:30,group:'day',from:'',to:''};
14
+ let navigationState=parseNavigationHash(typeof location==='undefined'?'':location.hash);
15
+ let selected=navigationState.screen==='project'?navigationState.project:null, projects=[];
16
+ let projectView=navigationState.view,periodDays=navigationState.period,bucket=navigationState.group,customFrom=navigationState.from,customTo=navigationState.to;
17
+ let overviewQuery='',overviewFilter='all',requestVersion=0,activeController=null;
14
18
  function el(tag,text,cls){const node=document.createElement(tag);if(text!==undefined)node.textContent=String(text);if(cls)node.className=cls;return node}
15
19
  function append(parent,...children){for(const child of children)parent.append(child);return parent}
16
- function badge(state){return el('span',state||'unknown','badge '+(['complete','passed'].includes(state)?'good':['blocked','failed','paused','unverified'].includes(state)?'attention':''))}
20
+ function badge(state){return el('span',state||'unknown','badge '+(['complete','passed'].includes(state)?'good':['blocked','failed','paused','unverified','unconfirmed','unavailable'].includes(state)?'attention':''))}
17
21
  function button(label,action,primary=false){const node=el('button',label,'button'+(primary?' primary':''));node.addEventListener('click',()=>Promise.resolve().then(action).catch(error=>content.append(el('p',error.message,'notice'))));return node}
18
22
  function duration(ms){if(!Number.isFinite(ms))return 'Unknown';if(ms<60000)return Math.round(ms/1000)+'s';if(ms<3600000)return Math.round(ms/60000)+'m';return (ms/3600000).toFixed(1)+'h'}
19
23
  function tokenText(value){return Number.isFinite(value)?value.toLocaleString('en-US'):'Unknown'}
@@ -21,14 +25,104 @@ function taskTiming(task,estimate){const planned=estimate?.available?estimate.ta
21
25
  function unknownCallCount(status){return Number.isFinite(status?.measurement?.unknownCalls)?status.measurement.unknownCalls:Array.isArray(status?.tokens?.calls)?status.tokens.calls.filter(call=>call.usageAvailable!==true).length:undefined}
22
26
  function renderUsage(project){const status=project.status,usage=status?.tokens,measurement=status?.measurement;const panel=append(el('section',undefined,'panel'),el('h2','Token usage'));panel.append(el('p','Recorded input: '+tokenText(usage?.inputTokens)+' · recorded output: '+tokenText(usage?.outputTokens)));panel.append(el('p','Calls with unknown usage: '+tokenText(unknownCallCount(status))+' · unmeasured attempts: '+tokenText(measurement?.unmeasuredAttempts),'muted'));if(usage?.measurementComplete===false)panel.append(el('p','Partial measurement. These counts are the reported portion; total usage is unknown.','muted'));if(!usage)panel.append(el('p','No token totals have been reported.','muted'));content.append(panel)}
23
27
  function metric(title,value,note){return append(el('div',undefined,'metric'),el('div',title,'label'),el('strong',value),el('p',note))}
24
- async function api(path,options){const response=await fetch(path,options);const data=await response.json();if(!response.ok)throw Error(data.error||'Request failed');return data}
25
- function nav(){navigation.replaceChildren();const all=el('button','All projects','nav-button');all.setAttribute('aria-current',String(selected===null));all.onclick=()=>{selected=null;renderOverview()};navigation.append(all);for(const project of projects){const item=el('button',project.name,'nav-button');item.setAttribute('aria-current',String(selected===project.id));item.onclick=()=>showProject(project.id);navigation.append(item)}}
28
+ function validDate(value){if(!/^\\d{4}-\\d{2}-\\d{2}$/u.test(value))return false;const time=Date.parse(value+'T00:00:00Z');return Number.isFinite(time)&&new Date(time).toISOString().slice(0,10)===value}
29
+ function parseNavigationHash(hash){
30
+ const params=new URLSearchParams(String(hash||'').replace(/^#/u,''));
31
+ const screen=params.get('screen')||'overview',project=params.get('project'),view=params.get('view')||'now';
32
+ const period=Number(params.get('period')||30),group=params.get('group')||'day',from=params.get('from')||'',to=params.get('to')||'';
33
+ const datesValid=(!from&&!to)||(validDate(from)&&validDate(to)&&from<=to&&Date.parse(to+'T00:00:00Z')-Date.parse(from+'T00:00:00Z')<=365*86400000);
34
+ if(!['overview','workspace','project'].includes(screen)||!['now','usage','results'].includes(view)||![1,7,30,90,365].includes(period)||!['day','week','month'].includes(group)||!datesValid||(screen==='project'&&!/^[a-f0-9]{32}$/u.test(project||'')))return {...defaultNavigation};
35
+ return {screen,project:screen==='project'?project:null,view,period,group,from,to};
36
+ }
37
+ function serializeNavigationHash(state){
38
+ const params=new URLSearchParams({screen:state.screen,view:state.view,period:String(state.period),group:state.group});
39
+ if(state.project)params.set('project',state.project);
40
+ if(state.from&&state.to){params.set('from',state.from);params.set('to',state.to)}
41
+ return '#'+params.toString();
42
+ }
43
+ function setNavigation(change,replace=false){
44
+ const next=parseNavigationHash(serializeNavigationHash({...navigationState,...change}));
45
+ navigationState=next;selected=next.screen==='project'?next.project:null;projectView=next.view;periodDays=next.period;bucket=next.group;customFrom=next.from;customTo=next.to;
46
+ if(typeof history!=='undefined'&&typeof location!=='undefined')history[replace?'replaceState':'pushState'](null,'',serializeNavigationHash(next));
47
+ renderRoute();
48
+ }
49
+ function restoreNavigation(){
50
+ const restored=parseNavigationHash(location.hash);
51
+ // Back/forward can emit both popstate and hashchange for one transition.
52
+ if(serializeNavigationHash(restored)===serializeNavigationHash(navigationState))return;
53
+ navigationState=restored;selected=navigationState.screen==='project'?navigationState.project:null;projectView=navigationState.view;periodDays=navigationState.period;bucket=navigationState.group;customFrom=navigationState.from;customTo=navigationState.to;
54
+ if(selected&&!projects.some(project=>project.id===selected)){navigationState={...navigationState,screen:'overview',project:null};selected=null}
55
+ if(typeof history!=='undefined'&&location.hash!==serializeNavigationHash(navigationState))history.replaceState(null,'',serializeNavigationHash(navigationState));
56
+ renderRoute();
57
+ }
58
+ function beginRequest(){requestVersion++;if(activeController)activeController.abort();activeController=new AbortController();return {version:requestVersion,signal:activeController.signal}}
59
+ function currentRequest(version){return version===requestVersion}
60
+ function isAbort(error){return error&&error.name==='AbortError'}
61
+ async function api(path,options={},signal){const response=await fetch(path,{...options,signal});const data=await response.json();if(!response.ok)throw Error(data.error||'Request failed');return data}
62
+ function projectStatus(project,now=Date.now()){
63
+ const goal=project.goal?.status||null,loop=project.status?.state||null,attentionStates=['blocked','failed','paused'],activeStates=['active','running'];
64
+ const observed=(project.status?.supervision||[]).filter(p=>p.liveness==='alive'&&now-Date.parse(p.heartbeatAt)<15000).map(p=>Date.parse(p.lastProgressAt||'')).filter(Number.isFinite);
65
+ const updated=Math.max(Date.parse(project.status?.updatedAt||''),...observed),stale=activeStates.includes(loop)&&Number.isFinite(updated)&&now-updated>20*60000;
66
+ const attention=Boolean(project.errors?.length)||attentionStates.includes(loop)||attentionStates.includes(goal)||stale;
67
+ const active=activeStates.includes(loop)||activeStates.includes(goal);
68
+ let primary=project.errors?.length?'unavailable':attentionStates.includes(loop)?loop:attentionStates.includes(goal)?goal:activeStates.includes(loop)?loop:activeStates.includes(goal)?goal:goal||loop||'ready';
69
+ if(stale&&!project.errors?.length&&!attentionStates.includes(goal))primary='unconfirmed';
70
+ return {primary,goal,loop,attention,active,stale};
71
+ }
72
+ function visibleProjects(values,query,filter,now=Date.now()){
73
+ const needle=query.trim().toLowerCase();
74
+ return values.filter(project=>{
75
+ const status=projectStatus(project,now),matches=!needle||[project.name,project.root,project.goal?.objective].some(value=>String(value||'').toLowerCase().includes(needle));
76
+ return matches&&(filter==='all'||filter==='active'&&status.active||filter==='attention'&&status.attention);
77
+ }).map((project,index)=>({project,index,status:projectStatus(project,now)})).sort((a,b)=>Number(b.status.attention)-Number(a.status.attention)||Number(b.status.active)-Number(a.status.active)||a.index-b.index).map(item=>item.project);
78
+ }
79
+ function nav(){
80
+ navigation.replaceChildren();
81
+ const all=el('button','All projects','nav-button');all.setAttribute('aria-current',String(navigationState.screen==='overview'));all.onclick=()=>setNavigation({screen:'overview',project:null});navigation.append(all);
82
+ const compare=el('button','Compare consumption','nav-button');compare.setAttribute('aria-current',String(navigationState.screen==='workspace'));compare.onclick=()=>setNavigation({screen:'workspace',project:null});navigation.append(compare);
83
+ for(const project of projects){const item=el('button',project.name,'nav-button');item.setAttribute('aria-current',String(navigationState.screen==='project'&&selected===project.id));item.onclick=()=>setNavigation({screen:'project',project:project.id});navigation.append(item)}
84
+ }
26
85
  function heading(title,subtitle){content.replaceChildren(el('h1',title),el('p',subtitle,'summary muted'))}
27
- function renderOverview(){nav();heading('A clear view of the work.','Goals, independent checks and the next thing that needs your attention.');const blocked=projects.filter(p=>p.errors.length||['blocked','paused'].includes(p.goal?.status||p.status?.state)).length;const active=projects.filter(p=>['active','running'].includes(p.goal?.status||p.status?.state)).length;content.append(append(el('div',undefined,'metrics'),metric('Projects',projects.length,'Registered on this machine'),metric('In progress',active,'Active goals and story loops'),metric('Needs attention',blocked,'Blocked, paused or unavailable')));content.append(button('Compare consumption',showWorkspaceUsage));const cards=el('div',undefined,'cards');for(const p of projects){const card=append(el('article',undefined,'project-card'),badge(p.goal?.status||p.status?.state||(p.errors.length?'unavailable':'ready')),el('h2',p.name),el('p',p.goal?.objective||'No active objective yet.'),el('p',p.root,'path'));if(p.errors.length)card.append(el('p',p.errors.join('; '),'notice'));card.append(button('Open project →',()=>showProject(p.id)));cards.append(card)}if(!projects.length)cards.append(el('p','No registered projects. Run yoke dashboard from a project directory.','empty'));content.append(cards)}
86
+ function renderOverview(){
87
+ beginRequest();nav();heading('A clear view of the work.','Goals, loops, independent checks and the next thing that needs your attention.');
88
+ const statuses=projects.map(project=>projectStatus(project)),attention=statuses.filter(status=>status.attention).length,active=statuses.filter(status=>status.active).length;
89
+ content.append(append(el('div',undefined,'metrics'),metric('Projects',projects.length,'Registered on this machine'),metric('In progress',active,'Active goals or reported loops'),metric('Needs attention',attention,'Blocked, paused, stale or unavailable')));
90
+ const tools=el('div',undefined,'overview-tools'),field=el('label',undefined,'field'),searchLabel=el('span','Search projects','label'),search=el('input');search.type='search';search.value=overviewQuery;search.placeholder='Name, path or objective';field.append(searchLabel,search);tools.append(field);
91
+ const filters=el('div',undefined,'filter-group');filters.setAttribute('role','group');filters.setAttribute('aria-label','Project status filter');
92
+ const cards=el('div',undefined,'cards');
93
+ function draw(){
94
+ cards.replaceChildren();
95
+ const visible=visibleProjects(projects,overviewQuery,overviewFilter);
96
+ for(const project of visible){const status=projectStatus(project),card=append(el('article',undefined,'project-card'),badge(status.primary),el('h2',project.name),el('p',project.goal?.objective||'No active objective yet.'),el('p',project.root,'path'));
97
+ if(status.goal&&status.loop&&status.goal!==status.loop)card.append(el('p','Goal: '+status.goal+' · Loop: '+(status.stale?'unconfirmed (last reported '+status.loop+')':status.loop),'state-line'));
98
+ else if(status.stale)card.append(el('p','Loop activity is unconfirmed; last report was '+status.loop+'.','state-line notice'));
99
+ if(['active','running'].includes(status.loop)&&project.status?.storyTitle)card.append(el('p','Reported task: '+project.status.storyTitle,'state-line'));
100
+ const reason=['blocked','failed','paused'].includes(status.loop)?project.status?.reason:['blocked','paused'].includes(status.goal)?project.goal?.reason:null;
101
+ if(reason)card.append(el('p',reason,'notice'));
102
+ if(project.errors.length)card.append(el('p',project.errors.join('; '),'notice'));
103
+ card.append(button('Open project →',()=>setNavigation({screen:'project',project:project.id})));cards.append(card);
104
+ }
105
+ if(!visible.length){const empty=append(el('div',undefined,'empty'),el('p',projects.length?'No projects match this search and filter.':'No registered projects. Run yoke dashboard from a project directory.'));if(projects.length)empty.append(button('Clear search and filters',()=>{overviewQuery='';overviewFilter='all';renderOverview()}));cards.append(empty)}
106
+ for(const child of filters.children)child.setAttribute('aria-pressed',String(child.dataset.filter===overviewFilter));
107
+ }
108
+ for(const [value,label] of [['all','All'],['active','Active'],['attention','Needs attention']]){const control=button(label,()=>{overviewFilter=value;draw()});control.dataset.filter=value;filters.append(control)}
109
+ search.oninput=()=>{overviewQuery=search.value;draw()};tools.append(filters);content.append(tools,cards);draw();
110
+ }
28
111
  function row(title,note,state){return append(el('div',undefined,'row'),append(el('div',undefined,'row-main'),el('div',title),el('small',note)),badge(state))}
29
112
 
30
113
  ${dashboardPanels()}
31
- async function refresh(){try{projects=await api('/api/projects');if(selected)await showProject(selected);else renderOverview()}catch(error){heading('Workspace unavailable',error.message)}}
32
- document.getElementById('refresh').onclick=refresh;refresh();
114
+ function renderRoute(){if(navigationState.screen==='workspace')showWorkspaceUsage();else if(navigationState.screen==='project'&&selected&&projects.some(project=>project.id===selected))showProject(selected);else renderOverview()}
115
+ async function refresh(){
116
+ const request=beginRequest();
117
+ try{
118
+ const loaded=await api('/api/projects',{},request.signal);if(!currentRequest(request.version))return;projects=loaded;
119
+ if(selected&&!projects.some(project=>project.id===selected)){navigationState={...navigationState,screen:'overview',project:null};selected=null}
120
+ if(typeof location!=='undefined'&&typeof history!=='undefined'&&location.hash!==serializeNavigationHash(navigationState))history.replaceState(null,'',serializeNavigationHash(navigationState));
121
+ renderRoute();
122
+ }catch(error){if(currentRequest(request.version)&&!isAbort(error))heading('Workspace unavailable',error.message)}
123
+ }
124
+ document.getElementById('refresh').onclick=refresh;
125
+ if(typeof window!=='undefined'){window.addEventListener('popstate',restoreNavigation);window.addEventListener('hashchange',restoreNavigation)}
126
+ refresh();
33
127
  </script></body></html>`;
34
128
  }
@@ -1,19 +1,95 @@
1
1
  /** Static script; all project-controlled values are inserted through textContent. */
2
2
  export function dashboardPanels() {
3
3
  return String.raw `
4
- let projectView='now', periodDays=30, bucket='day', customFrom='', customTo='', requestVersion=0;
5
4
  function panel(title){return append(el('section',undefined,'panel'),el('h2',title))}
6
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}
7
6
  function costText(t){return t.costState==='unknown'?'Unknown':'$'+t.reportedCostUsd.toFixed(3)+(t.costState==='partial'?' reported (partial)':' reported')}
7
+ function historyCost(total,errors){return errors.length&&total.costState==='measured'?{...total,costState:'partial'}:total}
8
8
  function rate(value){return Number.isFinite(value)?value.toLocaleString('en-US',{maximumFractionDigits:1}):'Unknown'}
9
- 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+' periods; exact values in the following table');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}
10
- async function showWorkspaceUsage(){selected=null;const version=++requestVersion;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);const rows=[];const query=queryPeriod();for(const project of projects){try{const a=await api('/api/projects/'+project.id+'/analytics?'+query);rows.push([project.name,tokenText(a.total.inputTokens),tokenText(a.total.outputTokens),costText(a.total),a.total.accepted,a.total.unknownCalls,a.errors.length?'Partial history':'Recorded portion'])}catch(error){rows.push([project.name,'Unknown','Unknown','Unknown','Unknown','Unknown',error.message])}if(version!==requestVersion||selected!==null)return}p.replaceChildren(el('h2','Projects'),table(['Project','Input','Output','Cost','Accepted','Unknown calls','Coverage'],rows))}
11
- function queryPeriod(){const end=customTo?new Date(customTo+'T00:00:00Z').getTime()+86400000:Date.now();const start=customFrom?Date.parse(customFrom+'T00:00:00Z'):end-periodDays*86400000;return new URLSearchParams({from:new Date(start).toISOString(),to:new Date(end).toISOString(),bucket}).toString()}
12
- function periodControls(id){const p=panel('Period · UTC');const select=el('select');select.setAttribute('aria-label','Period');for(const [value,label] of [[1,'Last 24 hours'],[7,'Last 7 days'],[30,'Last 30 days'],[90,'Last 90 days'],[365,'Last 365 days']]){const o=el('option',label);o.value=String(value);o.selected=value===periodDays;select.append(o)}select.onchange=()=>{periodDays=Number(select.value);customFrom='';customTo='';(id?showProject(id):showWorkspaceUsage())};p.append(select);const grouping=el('select');grouping.setAttribute('aria-label','Group by');for(const value of ['day','week','month']){const o=el('option',value);o.value=value;o.selected=value===bucket;grouping.append(o)}grouping.onchange=()=>{bucket=grouping.value;(id?showProject(id):showWorkspaceUsage())};p.append(grouping);const from=el('input'),to=el('input');from.type=to.type='date';from.value=customFrom;to.value=customTo;from.setAttribute('aria-label','From date UTC');to.setAttribute('aria-label','Through date UTC');p.append(from,to,button('Apply dates',()=>{if(!from.value||!to.value||from.value>to.value){p.append(el('p','Choose both dates in chronological order.','notice'));return}customFrom=from.value;customTo=to.value;(id?showProject(id):showWorkspaceUsage())}));p.append(el('p','Calendar dates include the entire end date. Maximum range: 366 days.','muted'));content.append(p)}
13
- function renderNow(p){const status=p.status||{},goal=p.goal||{},workers=status.parallel?.workers||[];const age=status.updatedAt?Date.now()-Date.parse(status.updatedAt):undefined;const stale=Number.isFinite(age)&&age>20*60000;const summary=panel('Now');summary.append(badge(goal.status||status.state||'unknown'));summary.append(el('p',goal.reason||status.reason||'No reported blocker.'));summary.append(el('p','Last status: '+(status.updatedAt||'Unknown')+(stale?' · stale; activity is not confirmed':''),'muted'));summary.append(el('p','Reported workers: '+workers.length+' / '+(status.parallel?.maxConcurrency||1)+' · queue: '+tokenText(status.parallel?.queuedCandidates),'muted'));if(goal.pendingAttempt)summary.append(row('Goal attempt',goal.pendingAttempt.provider+' · requested model '+(goal.pendingAttempt.model||'provider default')+' · started '+goal.pendingAttempt.startedAt,'running'));if(status.execution&&status.state==='running')summary.append(el('p',status.execution.provider+' · requested model '+(status.execution.requestedModel||'provider default')+' · elapsed '+duration(Date.now()-Date.parse(status.execution.startedAt))));if(status.story&&!workers.length)summary.append(row(status.storyTitle||status.story,status.phase||'Phase unknown',status.state));for(const w of workers)summary.append(row(w.storyTitle||w.story,[w.selectedProvider||w.provider,'requested model '+(w.selectedModel||w.model||'provider default'),w.phase||w.lifecycle||'working',w.startedAt?'elapsed '+duration(Date.now()-Date.parse(w.startedAt)):null,w.quality?'repair '+w.quality.usedRepairs:null].filter(Boolean).join(' · '),w.lifecycle||'running'));if(status.parallel?.integrator){const w=status.parallel.integrator;summary.append(row('Integration: '+(w.storyTitle||w.story),w.phase||'checking','integrating'))}if(!workers.length&&!status.story&&!goal.pendingAttempt)summary.append(el('p','No task currently reported.','empty'));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);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(w=>w.story===task.id)?'running':'open'));content.append(tasks);const routing=panel('Model selection');for(const [id,d] of Object.entries(status.routingDecisions||{})){routing.append(row(id,d.provider+' / '+(d.model||'provider default')+(d.reasoningEffort?' · '+d.reasoningEffort:''),d.profile));routing.append(el('p',d.reason));routing.append(el('p','Next escalation: '+d.next,'muted'))}if(!Object.keys(status.routingDecisions||{}).length)routing.append(el('p','No recorded model selection for this run.','empty'));content.append(routing);const activity=panel('Recent activity');for(const e of (p.events||[]).slice(-16).reverse())activity.append(row(e.storyId||e.type,e.timestamp+(e.phase?' · '+e.phase:'')+(Number.isFinite(e.durationMs)?' · '+duration(e.durationMs):''),e.outcome||e.type));content.append(activity)}
14
- function renderUsageHistory(a){const t=a.total;content.append(append(el('div',undefined,'metrics'),metric('Recorded input',tokenText(t.inputTokens),'Output: '+tokenText(t.outputTokens)),metric('Reported cost',costText(t),'Missing charges are not estimated'),metric('Tokens / elapsed minute',rate(t.tokensPerElapsedMinute),'Input + output / entire selected period')));const notes=panel('Measurement coverage');notes.append(el('p',a.coverage));notes.append(el('p','First record in period: '+(a.earliest||'none')+' · latest: '+(a.latest||'none'),'muted'));notes.append(el('p','Measured calls: '+t.measuredCalls+' · calls with unknown usage: '+t.unknownCalls+' · unmeasured attempts: '+t.unmeasuredAttempts));notes.append(el('p','Tokens / recorded call minute: '+rate(t.tokensPerCallMinute)+' · summed call time: '+duration(t.callDurationMs)+'. Parallel calls overlap; this is consumption intensity, not generation speed.'));notes.append(el('p','Cache reads: '+tokenText(t.cachedInputTokens)+' · cache writes: '+tokenText(t.cacheWriteInputTokens)+'. These are reported categories and are not added again to input totals.','muted'));for(const error of a.errors)notes.append(el('p',error,'notice'));content.append(notes);const series=panel('Consumption over time');series.append(usageChart(a.buckets));series.append(el('p','Dark: input · orange: output. Chart shows up to 31 latest periods; table includes all measured periods.','muted'));series.append(table(['UTC '+a.bucket,'Input','Output','Cache reads','Reported cost'],a.buckets.map(b=>[b.label,tokenText(b.inputTokens),tokenText(b.outputTokens),tokenText(b.cachedInputTokens),costText(b)])));if(!a.buckets.length)series.append(el('p','No measurements in this period.','empty'));content.append(series);const models=panel('Reported models in this period');models.append(table(['Provider','Actual model','Role','Input','Output','Call time','Cost'],a.models.map(m=>[m.provider,m.model,m.role,tokenText(m.inputTokens),tokenText(m.outputTokens),duration(m.callDurationMs),costText(m)])));content.append(models);const modelTimeline=panel('Models over time');modelTimeline.append(table(['UTC '+a.bucket,'Provider','Actual model','Role','Input','Output'],a.modelBuckets.map(m=>[m.label,m.provider,m.model,m.role,tokenText(m.inputTokens),tokenText(m.outputTokens)])));content.append(modelTimeline);const tasks=panel('Consumption by task');tasks.append(table(['Task','Input','Output','Attempt time','Cost'],a.tasks.map(t=>[t.storyId,tokenText(t.inputTokens),tokenText(t.outputTokens),duration(t.attemptDurationMs),costText(t)])));content.append(tasks);const phases=panel('Recorded phase time');phases.append(table(['Phase','Summed duration'],a.phases.map(p=>[p.phase,duration(p.durationMs)])));phases.append(el('p','Overlapping worker time is summed. Unrecorded queue time and human waiting time remain unknown.','muted'));content.append(phases)}
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'}
10
+ 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'}
12
+ 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
+ function queryPeriod(range){return new URLSearchParams({from:new Date(range.from).toISOString(),to:new Date(range.to).toISOString(),bucket}).toString()}
14
+ async function mapLimited(items,limit,shouldContinue,worker){
15
+ const results=[],next={value:0};
16
+ async function run(){while(shouldContinue()&&next.value<items.length){const index=next.value++;const value=await worker(items[index],index);results[index]=value}}
17
+ await Promise.all(Array.from({length:Math.min(limit,items.length)},run));
18
+ return results.filter(value=>value!==undefined);
19
+ }
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}
21
+ 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));
26
+ }
27
+ function periodControls(id){
28
+ const p=panel('Period · UTC'),select=el('select');select.setAttribute('aria-label','Period');
29
+ for(const [value,label] of [[1,'Last 24 hours'],[7,'Last 7 days'],[30,'Last 30 days'],[90,'Last 90 days'],[365,'Last 365 days']]){const option=el('option',label);option.value=String(value);option.selected=value===periodDays;select.append(option)}
30
+ select.onchange=()=>setNavigation({period:Number(select.value),from:'',to:''});p.append(select);
31
+ const grouping=el('select');grouping.setAttribute('aria-label','Group by');for(const value of ['day','week','month']){const option=el('option',value);option.value=value;option.selected=value===bucket;grouping.append(option)}grouping.onchange=()=>setNavigation({group:grouping.value});p.append(grouping);
32
+ const from=el('input'),to=el('input');from.type=to.type='date';from.value=customFrom;to.value=customTo;from.setAttribute('aria-label','From date UTC');to.setAttribute('aria-label','Through date UTC');
33
+ p.append(from,to,button('Apply dates',()=>{if(!validDate(from.value)||!validDate(to.value)||from.value>to.value||Date.parse(to.value+'T00:00:00Z')-Date.parse(from.value+'T00:00:00Z')>365*86400000){p.append(el('p','Choose both dates in chronological order within 366 days.','notice'));return}setNavigation({from:from.value,to:to.value})}));
34
+ p.append(el('p','Calendar dates include the entire end date. Maximum range: 366 days.','muted'));content.append(p);
35
+ }
36
+ function renderNow(p){
37
+ const status=p.status||{},goal=p.goal||{},workers=status.parallel?.workers||[],state=projectStatus(p),reportedState=state.stale?'unconfirmed':status.state;
38
+ const summary=panel('Now');summary.append(badge(state.primary));
39
+ if(state.goal&&state.loop&&state.goal!==state.loop)summary.append(el('p','Goal: '+state.goal+' · Loop: '+(state.stale?'unconfirmed (last reported '+state.loop+')':state.loop),'state-line'));
40
+ summary.append(el('p',status.reason||goal.reason||'No reported blocker.'));
41
+ summary.append(el('p','Last status: '+(status.updatedAt||'Unknown')+(state.stale?' · stale; activity is not confirmed':''),'muted'));
42
+ if(status.progress)summary.append(el('p','Backlog: '+status.progress.passed+'/'+status.progress.total+' tasks accepted · not overall product completion','muted'));
43
+ for(const proc of status.supervision||[])summary.append(row('Provider PID '+(proc.childPid||'not started'),['Attempt: '+((proc.retry||0)+1),'Supervisor heartbeat: '+proc.heartbeatAt,'Last output: '+(proc.lastOutputAt||'none'),'Last successful tool/edit: '+(proc.lastProgressAt||'none'),proc.reason].filter(Boolean).join(' · '),proc.state+' · '+(proc.liveness||'identity unverified')));
44
+ summary.append(el('p','Reported workers: '+workers.length+' / '+(status.parallel?.maxConcurrency||1)+' · queue: '+tokenText(status.parallel?.queuedCandidates),'muted'));
45
+ 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
+ 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
+ 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'));
49
+ 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
+ if(!workers.length&&!status.story&&!goal.pendingAttempt)summary.append(el('p','No task currently reported.','empty'));
51
+ 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
+ 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);
55
+ }
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',' ');
62
+ 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);
72
+ }
15
73
  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)}
16
- async function showProject(id){const version=++requestVersion;selected=id;nav();try{const p=await api('/api/projects/'+id);const a=projectView==='now'?null:await api('/api/projects/'+id+'/analytics?'+queryPeriod());if(version!==requestVersion||selected!==id)return;heading(p.name,p.goal?.objective||'Saved project work');content.append(el('p',p.root,'path'));for(const error of p.errors)content.append(el('p',error,'notice'));const tabs=el('div',undefined,'actions');for(const [view,label] of [['now','Now'],['usage','Usage & time'],['results','Results']]){const b=button(label,()=>{projectView=view;showProject(id)},projectView===view);b.setAttribute('aria-pressed',String(projectView===view));tabs.append(b)}if(p.goal&&!['complete','paused'].includes(p.goal.status))tabs.append(button('Request pause',async()=>{await api('/api/projects/'+id+'/pause',{method:'POST',headers:{'x-yoke-token':sessionToken}});showProject(id)}));content.append(tabs);if(projectView==='now'){const tasks=p.stories||[];content.append(append(el('div',undefined,'metrics'),metric('Accepted tasks',tasks.filter(t=>t.passes).length+' / '+tasks.length,'Saved acceptance state'),metric('Remaining time',p.estimate?.available?duration(p.estimate.lowerMs)+' – '+duration(p.estimate.upperMs):'Unknown',p.estimate?.available?p.estimate.sampleCount+' samples · empirical range':'No reliable duration history'),metric('Project status',p.goal?.status||p.status?.state||'unknown','Latest reported state')));renderNow(p)}else{periodControls(id);if(projectView==='usage')renderUsageHistory(a);else renderResults(p,a)}}catch(error){if(version===requestVersion&&selected===id)heading('Project unavailable',error.message)}}
74
+ async function showProject(id){
75
+ const request=beginRequest();selected=id;nav();
76
+ try{
77
+ const bounds=periodWindows(Date.now()),detailRequest=api('/api/projects/'+id,{},request.signal);
78
+ let project,current=null,previous=null;
79
+ if(projectView==='usage'){
80
+ [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
+ }else if(projectView==='results'){
82
+ [project,current]=await Promise.all([detailRequest,api('/api/projects/'+id+'/analytics?'+queryPeriod(bounds.current),{},request.signal)]);
83
+ }else project=await detailRequest;
84
+ 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);
88
+ if(projectView==='now'){
89
+ 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)}
92
+ }
17
93
  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);
18
94
  `;
19
95
  }
@@ -1,5 +1,6 @@
1
1
  import { loadConfig } from "../retrofit/config.js";
2
2
  import { makeAsyncAdaptiveRunner } from "../routing/router.js";
3
+ import { resolvePlanner } from '../routing/planning.js';
3
4
  import { randomUUID } from 'node:crypto';
4
5
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync, readdirSync } from 'node:fs';
5
6
  import { join } from 'node:path';
@@ -138,6 +139,7 @@ export async function runProjectGoal(root, options = {}) {
138
139
  if (!config?.routing?.enabled || config.routing.strategy !== "capability")
139
140
  return rawExecute(input);
140
141
  const routed = makeAsyncAdaptiveRunner({ parent: provider, parentSelection: input.selection, projectRoot: root, workers: config.routing.workers, strategy: "capability", maxCandidates: config.routing.maxCandidates, maxAttempts: Math.min(goal.maxAttempts, config.routing.maxAttempts ?? 5),
142
+ planner: resolvePlanner(config, provider, input.selection), fallback: config.routing.fallback, maxTier: config.routing.maxTier,
141
143
  captureRoute: async (agent, context, prompt, selection) => {
142
144
  const run = await startProviderProcess(agent, buildProviderInvocation(agent, prompt, root, "read-only", selection), { signal: input.signal, idleTimeoutMs: 20 * 60_000 }).completion;
143
145
  return { success: run.kind === "succeeded", summary: run.kind, output: run.stdout, tokens: run.telemetry.tokens };
package/dist/loop/git.js CHANGED
@@ -1,15 +1,23 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { sanitizeCommitMessage } from './identity.js';
3
- export const RUNTIME_PATHS = ['.yoke/artifacts', '.yoke/events', '.yoke/history', '.yoke/routing', '.yoke/checks', '.yoke/goal.json', '.yoke/goal.pause'];
4
- export const RUNTIME_EXCLUDES = RUNTIME_PATHS.map(path => `:(exclude)${path}${path.endsWith('.json') || path.endsWith('.pause') ? '' : '/**'}`);
3
+ export const RUNTIME_PATHS = [".yoke/supervision", ".yoke/provider-processes", '.yoke/artifacts', '.yoke/events', '.yoke/history', '.yoke/routing', '.yoke/checks', '.yoke/goal.json', '.yoke/goal.pause', '.yoke/loop-status.json', '.yoke/loop.lock', '.yoke/loop.lock.takeover', '.yoke/loop.lock.takeover.recovery', '.yoke/runner.pid', '.yoke/story-durations.json'];
4
+ // Directory pathspecs include descendants and also exclude the directory entry.
5
+ export const RUNTIME_EXCLUDES = RUNTIME_PATHS.map(path => `:(exclude)${path}`);
6
+ export function stageImplementation(dir) {
7
+ execFileSync('git', ['reset', '--quiet', '--', ...RUNTIME_PATHS], { cwd: dir, stdio: 'pipe' });
8
+ // Negative ignored pathspecs can make git add fail. Enumerate eligible files
9
+ // first, including tracked deletions, and preserve literal names with NULs.
10
+ const paths = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '.', ...RUNTIME_EXCLUDES], { cwd: dir, maxBuffer: 16 * 1024 * 1024 });
11
+ if (paths.length)
12
+ execFileSync('git', ['--literal-pathspecs', 'add', '-A', '--pathspec-from-file=-', '--pathspec-file-nul'], { cwd: dir, input: paths, stdio: ['pipe', 'pipe', 'pipe'] });
13
+ }
5
14
  export const realGitOps = {
6
15
  isClean(dir) {
7
16
  const out = execFileSync('git', ['status', '--porcelain', '--untracked-files=all', '--', '.', ...RUNTIME_EXCLUDES], { cwd: dir }).toString();
8
17
  return out.trim() === '';
9
18
  },
10
19
  commitAll(dir, message, identity) {
11
- execFileSync('git', ['reset', '--quiet', '--', ...RUNTIME_PATHS], { cwd: dir, stdio: 'pipe' });
12
- execFileSync('git', ['add', '-A', '--', '.', ...RUNTIME_EXCLUDES], { cwd: dir, stdio: 'pipe' });
20
+ stageImplementation(dir);
13
21
  const staged = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: dir }).toString().trim();
14
22
  if (staged === '') {
15
23
  throw new Error('nothing to commit after agent run');
package/dist/loop/loop.js CHANGED
@@ -307,7 +307,9 @@ export function runLoop(opts) {
307
307
  iterations++;
308
308
  if (result.tokens)
309
309
  reporter.addTokens(result.tokens);
310
- if (result.routing?.blocked) {
310
+ if (result.infrastructureFailure || result.routing?.blocked) {
311
+ if (result.infrastructureFailure)
312
+ result.routing?.recordOutcome(false, "infrastructure");
311
313
  reporter.blocked(result.summary);
312
314
  return { status: "blocked", iterations, reason: result.summary, finalProgress: progress(stories) };
313
315
  }
@@ -442,7 +444,9 @@ export function runLoop(opts) {
442
444
  iterations++;
443
445
  if (result.tokens)
444
446
  reporter.addTokens(result.tokens);
445
- if (result.routing?.blocked) {
447
+ if (result.infrastructureFailure || result.routing?.blocked) {
448
+ if (result.infrastructureFailure)
449
+ result.routing?.recordOutcome(false, "infrastructure");
446
450
  reporter.blocked(result.summary);
447
451
  return { status: "blocked", iterations, reason: result.summary, finalProgress: progress(stories) };
448
452
  }
@@ -602,8 +606,8 @@ function runImplementation(opts, dir, story, reporter) {
602
606
  if (verdict.passed)
603
607
  return result;
604
608
  if (knownInfrastructureFailure(verdict.summary)) {
605
- result.routing.recordOutcome(false, "infrastructure");
606
- return result;
609
+ result.routing.recordOutcome(false, 'infrastructure');
610
+ return { ...result, success: false, infrastructureFailure: true, summary: verdict.summary, routing: { ...result.routing, blocked: true, canRetry: false } };
607
611
  }
608
612
  result.routing.recordOutcome(false);
609
613
  if (result.tokens)
@@ -2,7 +2,7 @@ import { execFileSync, spawnSync } from 'node:child_process';
2
2
  import { mkdirSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { isProviderTreeAlive, reapProviderProcesses } from './cleanup.js';
5
- import { realGitOps, RUNTIME_EXCLUDES, RUNTIME_PATHS } from './git.js';
5
+ import { realGitOps, stageImplementation } from './git.js';
6
6
  import { isPidAlive } from './lock.js';
7
7
  import { storyPathSegment } from './prd.js';
8
8
  import { killProcessTreeForCleanup } from './watchdog.js';
@@ -130,8 +130,7 @@ function rebaseCandidate(targetDir, input) {
130
130
  }
131
131
  let candidateTree;
132
132
  try {
133
- execFileSync('git', ['reset', '--quiet', '--', ...RUNTIME_PATHS], { cwd: input.worktree.path, stdio: 'pipe' });
134
- execFileSync('git', ['add', '-A', '--', '.', ...RUNTIME_EXCLUDES], { cwd: input.worktree.path, stdio: 'pipe' });
133
+ stageImplementation(input.worktree.path);
135
134
  const unstaged = spawnSync('git', ['diff', '--quiet', '--ignore-submodules=none'], { cwd: input.worktree.path, stdio: 'pipe' });
136
135
  if (unstaged.error || unstaged.status !== 0)
137
136
  return { kind: 'reopen', reason: 'candidate contains changes Git cannot snapshot' };
@@ -1,6 +1,7 @@
1
1
  import { existsSync, rmSync } from 'node:fs';
2
2
  import { acceptanceProtectionProblem } from '../check/command.js';
3
3
  import { makeAsyncAdaptiveRunner } from '../routing/router.js';
4
+ import { resolvePlanner } from '../routing/planning.js';
4
5
  import { startProviderProcess } from '../agents/process.js';
5
6
  import { runnerInvocation, isAgentAvailable } from './runner.js';
6
7
  import { createDispatcher } from './dispatcher.js';
@@ -261,6 +262,10 @@ function asyncRunner(input, provider, signal, workerId) {
261
262
  strategy: input.routing.strategy,
262
263
  maxCandidates: input.routing.maxCandidates,
263
264
  maxAttempts: input.routing.maxAttempts,
265
+ planner: resolvePlanner({ planning: input.planning }, input.runnerAgent, input.selection),
266
+ assessmentPolicy: input.routing.assessmentPolicy,
267
+ fallback: input.routing.fallback,
268
+ maxTier: input.routing.maxTier,
264
269
  onDecision: (id, decision) => input.reporter.routingDecision?.(id, decision),
265
270
  orchestratorSelection: input.routing.orchestrator,
266
271
  isAvailable: input.isAvailable ?? isAgentAvailable,
package/dist/loop/prd.js CHANGED
@@ -47,6 +47,8 @@ export const StorySchema = z.object({
47
47
  sourceChange: z.string().min(1).optional(),
48
48
  quality: StoryQualityDeclarationSchema.optional(),
49
49
  assessment: AssessmentSchema.optional(),
50
+ /** Binding to the task, upstream contracts and approved planning brief. */
51
+ assessmentFor: z.string().regex(/^[a-f0-9]{64}$/).optional(),
50
52
  }).superRefine((story, ctx) => {
51
53
  const structured = story.acceptance.filter(isAcceptanceCriterion);
52
54
  const ids = structured.map(criterion => criterion.id);
@@ -1,3 +1,4 @@
1
+ import { readSupervision } from "../agents/supervision.js";
1
2
  import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, appendFileSync, statSync, rmSync } from 'node:fs';
2
3
  import { join } from 'node:path';
3
4
  import { randomUUID } from 'node:crypto';
@@ -76,7 +77,9 @@ export function readStatus(dir) {
76
77
  if (!existsSync(file))
77
78
  return null;
78
79
  try {
79
- return JSON.parse(readFileSync(file, 'utf8'));
80
+ const status = JSON.parse(readFileSync(file, 'utf8'));
81
+ const supervision = readSupervision(dir, status.startedAt);
82
+ return supervision.length ? { ...status, supervision } : status;
80
83
  }
81
84
  catch {
82
85
  return null;
@@ -17,6 +17,7 @@ import { buildTrustedDecisionResumeState, clearDecisionResume, decisionProcessin
17
17
  import { makeAdaptiveRunner } from '../routing/router.js';
18
18
  import { makeActionRunner } from '../execution/actions.js';
19
19
  import { runChangeApply } from '../change/inbox.js';
20
+ import { resolvePlanner } from '../routing/planning.js';
20
21
  import { createQualityCommandHooks } from '../quality/command.js';
21
22
  import { resolveQualityPolicy } from '../quality/types.js';
22
23
  import { runParallelLoopCommand } from './parallel-command.js';
@@ -61,9 +62,12 @@ export function loopStatus(targetDir, now = () => new Date()) {
61
62
  return `Loop: ${enabled ? 'enabled' : 'disabled'}\nPRD: ${prog}`;
62
63
  const head = `Loop: ${st.state.toUpperCase()}${st.story ? ` on ${st.story}${st.storyTitle ? ` "${st.storyTitle}"` : ''}` : ''}`;
63
64
  const pct = st.percent !== undefined ? ` (${st.percent}%)` : '';
64
- const meta = [st.phase, `iteration ${st.iteration}`, `${st.progress.passed}/${st.progress.total}${pct}`, `updated ${relativeTime(st.updatedAt, now())}`]
65
+ const meta = [st.phase, `iteration ${st.iteration}`, `backlog ${st.progress.passed}/${st.progress.total}${pct}`, `updated ${relativeTime(st.updatedAt, now())}`]
65
66
  .filter(Boolean).join(' · ');
66
67
  const lines = [head, ` ${meta}`];
68
+ for (const process of st.supervision ?? []) {
69
+ lines.push(` provider PID ${process.childPid ?? 'not started'}: ${process.state} · attempt ${process.retry + 1} · identity/liveness ${process.liveness ?? 'unknown'} · supervisor heartbeat ${relativeTime(process.heartbeatAt, now())} · last output ${process.lastOutputAt ? relativeTime(process.lastOutputAt, now()) : 'none'} · last successful tool/edit ${process.lastProgressAt ? relativeTime(process.lastProgressAt, now()) : 'none'}${process.reason ? ' · ' + process.reason : ''}`);
70
+ }
67
71
  if (st.state === 'running' && st.eta && st.eta.remainingStories > 0) {
68
72
  lines.push(` ~${fmtDuration(st.eta.etaMs)} remaining (Ø ${fmtDuration(st.eta.avgStoryMs)}/story)`);
69
73
  }
@@ -262,7 +266,7 @@ export function runLoopCommand(targetDir, opts) {
262
266
  }
263
267
  const permissions = opts.permissions ?? config.runner?.permissions ?? 'safe';
264
268
  const routingRequested = opts.routing ?? config.routing?.enabled ?? true;
265
- const routingEnabled = routingRequested && Boolean(config.routing?.workers.length);
269
+ const routingEnabled = routingRequested && Boolean(config.routing && (config.routing.workers.length || config.routing.fallback === 'block' || config.routing.maxTier || config.routing.assessmentPolicy === 'prepared'));
266
270
  const runnerSelection = {
267
271
  model: config.runner?.model,
268
272
  reasoningEffort: config.runner?.reasoningEffort,
@@ -351,6 +355,10 @@ export function runLoopCommand(targetDir, opts) {
351
355
  strategy: config.routing.strategy,
352
356
  maxCandidates: config.routing.maxCandidates,
353
357
  maxAttempts: config.routing.maxAttempts,
358
+ planner: resolvePlanner(config, runnerAgent, runnerSelection),
359
+ assessmentPolicy: config.routing.assessmentPolicy,
360
+ fallback: config.routing.fallback,
361
+ maxTier: config.routing.maxTier,
354
362
  onDecision: (id, decision) => executionReporter?.routingDecision?.(id, decision),
355
363
  idleTimeoutMs: idleMs,
356
364
  permissions,
@@ -482,6 +490,7 @@ export function runLoopCommand(targetDir, opts) {
482
490
  providers: parallelProviders,
483
491
  affinityProviders: parallelAffinityProviders,
484
492
  routing: routingEnabled ? config.routing : undefined,
493
+ planning: config.planning,
485
494
  isAvailable: available,
486
495
  onAmbiguity: ambiguityPolicy,
487
496
  git: opts.git,