@hecer/yoke 1.8.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 (44) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +34 -1
  4. package/README.md +8 -6
  5. package/canon/loop/prd.schema.md +6 -0
  6. package/canon/skills/authoring-prd/SKILL.md +7 -0
  7. package/dist/agents/process-incarnation.js +1 -1
  8. package/dist/agents/process.js +74 -6
  9. package/dist/agents/supervision.js +153 -0
  10. package/dist/agents/windows-launch.js +80 -0
  11. package/dist/change/inbox.js +23 -5
  12. package/dist/cli.js +18 -2
  13. package/dist/dashboard/page.js +102 -8
  14. package/dist/dashboard/panels.js +84 -8
  15. package/dist/goals/command.js +29 -4
  16. package/dist/loop/git.js +12 -4
  17. package/dist/loop/loop.js +48 -3
  18. package/dist/loop/parallel-adapters.js +2 -3
  19. package/dist/loop/parallel-command.js +14 -7
  20. package/dist/loop/prd.js +4 -0
  21. package/dist/loop/reporter.js +8 -1
  22. package/dist/loop/run-command.js +25 -5
  23. package/dist/loop/runner.js +27 -31
  24. package/dist/loop/watchdog.js +87 -11
  25. package/dist/loop/worker.js +30 -1
  26. package/dist/prd/assess.js +145 -0
  27. package/dist/prd/command.js +59 -21
  28. package/dist/quality/command.js +11 -5
  29. package/dist/retrofit/config.js +15 -1
  30. package/dist/retrofit/gitignore.js +1 -1
  31. package/dist/routing/assessment.js +66 -0
  32. package/dist/routing/capability.js +91 -0
  33. package/dist/routing/contracts.js +60 -0
  34. package/dist/routing/planning.js +12 -0
  35. package/dist/routing/router.js +85 -2
  36. package/dist/setup/command.js +23 -9
  37. package/docs/BATCH-PLANNING-VALIDATION.md +67 -0
  38. package/docs/CAPABILITY-ROUTING.md +82 -0
  39. package/docs/DASHBOARD-EVOLUTION.md +33 -0
  40. package/docs/PRODUCT-DIRECTION-2026-09-05.md +28 -0
  41. package/docs/WINDOWS-RUNNER-VALIDATION.md +104 -0
  42. package/docs/assets/yoke-logo.png +0 -0
  43. package/gemini-extension.json +1 -1
  44. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -13,6 +13,7 @@ import { runReview } from './review/command.js';
13
13
  import { scanDir } from './scan/design.js';
14
14
  import { runNew } from './new/command.js';
15
15
  import { runPrdDraft, runPrdCheck } from './prd/command.js';
16
+ import { runPrdAssess } from './prd/assess.js';
16
17
  import { runLoopCleanup } from './loop/cleanup.js';
17
18
  import { runFlowSmoke } from './smoke/command.js';
18
19
  import { maybeNotifyUpdate, currentYokeVersion } from './update/check.js';
@@ -134,10 +135,17 @@ export function main(argv) {
134
135
  }
135
136
  const loop = rest.includes('--loop') ? true : rest.includes('--no-loop') ? false : undefined;
136
137
  const routing = rest.includes('--routing') ? true : rest.includes('--no-routing') ? false : undefined;
138
+ const routingStrategy = rest.find(a => a.startsWith('--routing-strategy='))?.slice('--routing-strategy='.length);
139
+ if (routingStrategy && !['capability', 'balanced', 'cost', 'speed', 'quality'].includes(routingStrategy)) {
140
+ console.error('Invalid routing strategy');
141
+ return 1;
142
+ }
137
143
  return runSetup(targetDir, {
138
144
  host: hostArg, agents, runner: runnerArg,
139
145
  codeGraph: graphArg,
140
146
  loop, routing, decisionPolicy: policyArg,
147
+ routingStrategy: routingStrategy,
148
+ routingPreset: rest.includes('--routing-preset'),
141
149
  interactive: rest.includes('--yes') ? false : undefined,
142
150
  });
143
151
  }
@@ -543,6 +551,14 @@ export function main(argv) {
543
551
  case 'prd': {
544
552
  const sub = rest[0];
545
553
  const targetDir = rest.slice(1).find(a => !a.startsWith('-')) ?? '.';
554
+ if (sub === 'assess') {
555
+ const runner = rest.find(a => a.startsWith('--runner='))?.slice('--runner='.length);
556
+ if (runner && !['codex', 'claude', 'gemini'].includes(runner)) {
557
+ console.error('Invalid planning runner');
558
+ return 1;
559
+ }
560
+ return runPrdAssess(targetDir, { runner: runner, story: rest.find(a => a.startsWith('--story='))?.slice('--story='.length), reassess: rest.includes('--reassess') });
561
+ }
546
562
  if (sub === 'draft') {
547
563
  const idea = rest.find(a => a.startsWith('--idea='))?.slice('--idea='.length);
548
564
  if (!idea) {
@@ -570,7 +586,7 @@ export function main(argv) {
570
586
  }
571
587
  if (sub === 'check')
572
588
  return runPrdCheck(targetDir);
573
- console.log('usage: yoke prd <draft|check> [dir] [--idea="..."] [--runner=<claude|codex|gemini>] [--force] [--timeout=<minutes>]');
589
+ console.log('usage: yoke prd <draft|check|assess> [dir] [--idea="..."] [--runner=<claude|codex|gemini>] [--story=<id>] [--reassess] [--force] [--timeout=<minutes>]');
574
590
  return 1;
575
591
  }
576
592
  case 'context': {
@@ -635,7 +651,7 @@ export function main(argv) {
635
651
  return runUpgrade();
636
652
  default:
637
653
  console.log('Project workflows: yoke check [dir] [--json|--protect] | goal set|run|resume|pause|status|handoff|budget [dir] | projects add|list|remove | dashboard [dir] [--port=N]');
638
- console.log('usage: yoke <setup [dir] | new <dir> [--idea="..."] | validate [canonDir] | retrofit [targetDir] [--agent=claude,codex,gemini|all] [--code-graph=graphify|serena] [--loop] | change <add|status> [dir] | prd <draft|check> [dir] | loop <on|off|status|decision|answer|resume|run|cleanup> | context <init|status> | review [dir] [--reviewer=<claude|codex|gemini>] [--base=<ref>] [--focus="..."] | design-scan [dir] [--max=N] [--report] | flow-smoke [dir] [--url=<baseUrl>] [--label=<name>] | upgrade>');
654
+ console.log('usage: yoke <setup [dir] | new <dir> [--idea="..."] | validate [canonDir] | retrofit [targetDir] [--agent=claude,codex,gemini|all] [--code-graph=graphify|serena] [--loop] | change <add|status> [dir] | prd <draft|check|assess> [dir] | loop <on|off|status|decision|answer|resume|run|cleanup> | context <init|status> | review [dir] [--reviewer=<claude|codex|gemini>] [--base=<ref>] [--focus="..."] | design-scan [dir] [--max=N] [--report] | flow-smoke [dir] [--url=<baseUrl>] [--label=<name>] | upgrade>');
639
655
  return cmd ? 1 : 0;
640
656
  }
641
657
  }
@@ -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 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,3 +1,6 @@
1
+ import { loadConfig } from "../retrofit/config.js";
2
+ import { makeAsyncAdaptiveRunner } from "../routing/router.js";
3
+ import { resolvePlanner } from '../routing/planning.js';
1
4
  import { randomUUID } from 'node:crypto';
2
5
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync, readdirSync } from 'node:fs';
3
6
  import { join } from 'node:path';
@@ -97,7 +100,7 @@ export function goalHandoff(root) {
97
100
  async function executeAgent(input) {
98
101
  const handle = startProviderProcess(input.provider, buildProviderInvocation(input.provider, input.prompt, input.root, 'safe', input.selection), { signal: input.signal, idleTimeoutMs: 20 * 60_000 });
99
102
  const result = await handle.completion;
100
- return { success: result.kind === 'succeeded', summary: result.kind === 'succeeded' ? 'Agent finished; independently checked below' : `${result.kind}: ${result.stderr.slice(-3000)}`, ...result.telemetry.tokens };
103
+ return { success: result.kind === 'succeeded', summary: result.kind === 'succeeded' ? 'Agent finished; independently checked below' : `${result.kind}: ${result.stderr.slice(-3000)}`, ...result.telemetry.tokens, tokens: result.telemetry.tokens };
101
104
  }
102
105
  export async function runProjectGoal(root, options = {}) {
103
106
  const lock = acquireLock(root);
@@ -130,7 +133,25 @@ export async function runProjectGoal(root, options = {}) {
130
133
  throw error;
131
134
  }
132
135
  const provider = options.provider ?? 'codex';
133
- const execute = options.execute ?? executeAgent;
136
+ const rawExecute = options.execute ?? executeAgent;
137
+ const config = loadConfig(root);
138
+ const execute = async (input) => {
139
+ if (!config?.routing?.enabled || config.routing.strategy !== "capability")
140
+ return rawExecute(input);
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,
143
+ captureRoute: async (agent, context, prompt, selection) => {
144
+ const run = await startProviderProcess(agent, buildProviderInvocation(agent, prompt, root, "read-only", selection), { signal: input.signal, idleTimeoutMs: 20 * 60_000 }).completion;
145
+ return { success: run.kind === "succeeded", summary: run.kind, output: run.stdout, tokens: run.telemetry.tokens };
146
+ },
147
+ makeWorker: (agent, selection) => async (context) => {
148
+ const result = await rawExecute({ ...input, provider: agent, selection, prompt: input.prompt + "\nPlanner approach:\n" + (context.story.assessment?.approach ?? "") });
149
+ return { ...result, infrastructureFailure: !result.success, tokens: result.tokens ?? (result.inputTokens !== undefined && result.outputTokens !== undefined ? { inputTokens: result.inputTokens, outputTokens: result.outputTokens, model: result.model } : undefined) };
150
+ }, });
151
+ const result = await routed({ targetDir: root, story: { id: goal.id, title: goal.objective, priority: 1, passes: false, agent: provider, acceptance: manifest.criteria.map(c => c.text) } });
152
+ const last = result.tokens?.calls?.at(-1);
153
+ return { ...result, provider: last?.provider ?? provider, model: last?.actualModel, inputTokens: result.tokens?.measurementComplete ? result.tokens.inputTokens : undefined, outputTokens: result.tokens?.measurementComplete ? result.tokens.outputTokens : undefined };
154
+ };
134
155
  let report = checkProject(root);
135
156
  while (report.status !== 'passed') {
136
157
  const protectionProblem = acceptanceProtectionProblem(root);
@@ -167,14 +188,18 @@ export async function runProjectGoal(root, options = {}) {
167
188
  const agentDurationMs = Date.now() - started;
168
189
  const checkStarted = Date.now();
169
190
  report = checkProject(root);
191
+ if (!result.routing?.blocked)
192
+ result.routing?.recordOutcome(report.status === 'passed');
170
193
  appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'phase-ended', phase: 'verify', durationMs: Date.now() - checkStarted, outcome: report.status });
171
- goal.attempts.push({ provider, model: result.model ?? options.selection?.model, startedAt: new Date(started).toISOString(), durationMs: agentDurationMs, success: result.success, summary: result.summary.slice(0, 8000), checkId: report.id, inputTokens: result.inputTokens, outputTokens: result.outputTokens });
194
+ goal.attempts.push({ provider: result.provider ?? provider, model: result.model ?? options.selection?.model, startedAt: new Date(started).toISOString(), durationMs: agentDurationMs, success: result.success, summary: result.summary.slice(0, 8000), checkId: report.id, inputTokens: result.inputTokens, outputTokens: result.outputTokens });
172
195
  const usageAvailable = result.inputTokens !== undefined && result.outputTokens !== undefined;
173
- appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'tokens', attemptId: `${goal.id}:${goal.attempts.length}`, durationMs: agentDurationMs, data: { provider, role: 'parent', model: result.model, inputTokens: result.inputTokens, outputTokens: result.outputTokens, usageAvailable } });
196
+ appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'tokens', attemptId: `${goal.id}:${goal.attempts.length}`, durationMs: agentDurationMs, data: { ...result.tokens, provider: result.provider ?? provider, role: 'parent', model: result.model, inputTokens: result.inputTokens, outputTokens: result.outputTokens, usageAvailable } });
174
197
  appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'attempt-ended', attemptId: `${goal.id}:${goal.attempts.length}`, durationMs: Date.now() - started, outcome: report.status, data: { provider, model: result.model, usageAvailable } });
175
198
  if (report.status === 'passed')
176
199
  appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'accepted', attemptId: `${goal.id}:${goal.attempts.length}` });
177
200
  goal = save(root, { ...goal, lastCheck: report.id, pendingAttempt: undefined });
201
+ if (result.routing?.blocked)
202
+ return save(root, { ...goal, status: "blocked", reason: result.summary });
178
203
  if (controller.signal.aborted)
179
204
  return save(root, { ...goal, status: 'blocked', reason: 'Time budget exceeded; work retained' });
180
205
  }
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/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
@@ -1,3 +1,4 @@
1
+ import { knownInfrastructureFailure } from "../routing/capability.js";
1
2
  import { existsSync, unlinkSync, readFileSync } from 'node:fs';
2
3
  import { acceptanceProtectionProblem } from '../check/command.js';
3
4
  import { join, relative } from 'node:path';
@@ -302,10 +303,16 @@ export function runLoop(opts) {
302
303
  let landed = null;
303
304
  try {
304
305
  opts.git.addWorktree(opts.targetDir, wt);
305
- const result = opts.runner({ targetDir: wt, story });
306
+ const result = runImplementation(opts, wt, story, reporter);
306
307
  iterations++;
307
308
  if (result.tokens)
308
309
  reporter.addTokens(result.tokens);
310
+ if (result.infrastructureFailure || result.routing?.blocked) {
311
+ if (result.infrastructureFailure)
312
+ result.routing?.recordOutcome(false, "infrastructure");
313
+ reporter.blocked(result.summary);
314
+ return { status: "blocked", iterations, reason: result.summary, finalProgress: progress(stories) };
315
+ }
309
316
  let decision;
310
317
  try {
311
318
  decision = consumeDecisionRequest(wt, opts.targetDir, story.id);
@@ -398,7 +405,6 @@ export function runLoop(opts) {
398
405
  reporter.blocked(protection);
399
406
  return { status: 'blocked', iterations, reason: protection, finalProgress: progress(stories) };
400
407
  }
401
- result.routing?.recordOutcome(true);
402
408
  // The worktree is a checkout of committed HEAD, so the agent above reads
403
409
  // context from HEAD's .yoke/context — commit context changes for --isolate
404
410
  // to honour them. We write the decision here so `integrate` carries it back.
@@ -412,6 +418,7 @@ export function runLoop(opts) {
412
418
  savePrd(wtPrd, updated);
413
419
  opts.git.commitAll(wt, `yoke: complete ${story.id} ${story.title}`, opts.commitIdentity);
414
420
  opts.git.integrate(opts.targetDir, wt);
421
+ result.routing?.recordOutcome(true);
415
422
  landed = progress(updated);
416
423
  }
417
424
  catch (e) {
@@ -433,10 +440,16 @@ export function runLoop(opts) {
433
440
  reporter.storyDone({ id: story.id, title: story.title }, landed);
434
441
  continue;
435
442
  }
436
- const result = opts.runner({ targetDir: opts.targetDir, story });
443
+ const result = runImplementation(opts, opts.targetDir, story, reporter);
437
444
  iterations++;
438
445
  if (result.tokens)
439
446
  reporter.addTokens(result.tokens);
447
+ if (result.infrastructureFailure || result.routing?.blocked) {
448
+ if (result.infrastructureFailure)
449
+ result.routing?.recordOutcome(false, "infrastructure");
450
+ reporter.blocked(result.summary);
451
+ return { status: "blocked", iterations, reason: result.summary, finalProgress: progress(stories) };
452
+ }
440
453
  let decision;
441
454
  try {
442
455
  decision = consumeDecisionRequest(opts.targetDir, opts.targetDir, story.id);
@@ -570,3 +583,35 @@ export function runLoop(opts) {
570
583
  reporter.storyDone({ id: story.id, title: story.title }, progress(updated));
571
584
  }
572
585
  }
586
+ function runImplementation(opts, dir, story, reporter) {
587
+ let feedback;
588
+ for (let attempt = 0;; attempt++) {
589
+ const result = opts.runner({ targetDir: dir, story, feedback });
590
+ if (!result.routing?.canRetry || result.routing.blocked || attempt >= 7)
591
+ return result;
592
+ if (["decision-request.yaml", "ambiguity.md", "loop.pause"].some(name => existsSync(join(dir, ".yoke", name))) || existsSync(pauseFilePath(opts.targetDir)))
593
+ return result;
594
+ const protection = acceptanceProtectionProblem(dir, opts.targetDir);
595
+ if (protection)
596
+ return result;
597
+ const criteria = runCriterionGates(opts, dir, story);
598
+ const gates = [opts.verify, opts.design, opts.perf, opts.audit].filter((gate) => Boolean(gate));
599
+ let verdict = criteria;
600
+ if (verdict.passed)
601
+ for (const gate of gates) {
602
+ verdict = runGate(gate, dir, story.id);
603
+ if (!verdict.passed)
604
+ break;
605
+ }
606
+ if (verdict.passed)
607
+ return result;
608
+ if (knownInfrastructureFailure(verdict.summary)) {
609
+ result.routing.recordOutcome(false, 'infrastructure');
610
+ return { ...result, success: false, infrastructureFailure: true, summary: verdict.summary, routing: { ...result.routing, blocked: true, canRetry: false } };
611
+ }
612
+ result.routing.recordOutcome(false);
613
+ if (result.tokens)
614
+ reporter.addTokens(result.tokens);
615
+ feedback = verdict.summary;
616
+ }
617
+ }