runlist 0.96.0 → 0.97.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/README.md +170 -43
  2. package/assets/app/app.css +189 -3
  3. package/assets/app/app.mjs +49 -31
  4. package/assets/app/document-create.mjs +3 -2
  5. package/assets/app/document-lifecycle.mjs +17 -8
  6. package/assets/app/document-stages.mjs +3 -1
  7. package/assets/app/document-views.mjs +23 -1
  8. package/assets/app/editor-navigation.mjs +16 -23
  9. package/assets/app/filing-navigation.mjs +18 -16
  10. package/assets/app/git-changes.mjs +11 -2
  11. package/assets/app/index.html +34 -23
  12. package/assets/app/library-navigation.mjs +29 -16
  13. package/assets/app/record-navigation.mjs +68 -23
  14. package/assets/app/recovery-center.mjs +8 -7
  15. package/assets/app/settings.mjs +48 -10
  16. package/assets/app/template-editor.mjs +22 -12
  17. package/assets/app/transport.mjs +1 -2
  18. package/bin/runlist.mjs +64 -15
  19. package/package.json +1 -1
  20. package/plugins/runlist-codex/.codex-plugin/plugin.json +1 -1
  21. package/plugins/runlist-codex/skills/runlist/SKILL.md +3 -1
  22. package/src/app.mjs +9 -4
  23. package/src/baton.mjs +49 -11
  24. package/src/brief.mjs +145 -0
  25. package/src/commands.mjs +4 -3
  26. package/src/config.mjs +16 -26
  27. package/src/doctor.mjs +15 -0
  28. package/src/extractors.mjs +7 -14
  29. package/src/flags.mjs +5 -3
  30. package/src/hud.mjs +32 -63
  31. package/src/index.mjs +2 -0
  32. package/src/install.mjs +6 -4
  33. package/src/installation.mjs +117 -0
  34. package/src/new.mjs +27 -7
  35. package/src/prompt-handoff.mjs +132 -0
  36. package/src/prompts.mjs +112 -16
  37. package/src/section.mjs +46 -11
  38. package/src/show.mjs +132 -16
  39. package/src/template-store.mjs +16 -5
  40. package/src/update.mjs +7 -28
  41. package/src/util.mjs +7 -0
@@ -21,58 +21,103 @@ export function nativeContent(record) {
21
21
  }
22
22
 
23
23
  export function recordNavigation({state,$,api,open,showLibrary,checkout,preferences=()=>({records:'pending'}),notice,create=async()=>{}}) {
24
- const root=$('records-home');let kind='flags',selected=null,offset=0,sequence=0,timer,filters={q:'',status:'pending',severity:'',scope:''},pending=null;
24
+ const root=$('records-home'),drafts=new Map();let kind='flags',selected=null,selectedCheckout=null,offset=0,sequence=0,timer,filters={q:'',status:'pending',severity:'',scope:''},pending=null,draft=null,storageError='',storageBlocked=false;
25
25
  const title=()=>kind==='flags'?'Flags':'Decisions';
26
- const recoveryKey=()=>`runlist:record-action:${checkout()}:${kind}:${selected?.key}`;
26
+ const recoveryKey=()=>`runlist:record-action:${selectedCheckout}:${kind}:${selected?.key}`;
27
+ const draftKey=()=>`runlist:record-draft:${selectedCheckout}:${kind}:${selected?.key}`;
28
+ const formValue=()=>({action:$('record-action').value,note:$('record-note').value,choice:$('record-choice').value});
29
+ const storageMessage='Draft not stored. Keep this window open and retry.';
30
+ function draftStatus(){
31
+ if(!$('record-draft-status'))return;
32
+ const stale=draft&&draft.revision!==selected.revision;
33
+ $('record-draft-status').textContent=storageError||(stale?'Record changed since this draft. Check the current record before continuing.':draft?'Draft':'');
34
+ $('record-draft-status').className=storageError||stale?'inline-note':'muted';
35
+ $('record-draft-current').hidden=!stale||!!pending||!selected.editable;
36
+ $('record-draft-discard').hidden=!draft||!!pending;
37
+ }
38
+ function retainForm(){
39
+ if(!selected||pending||!$('record-action-form')||$('record-action-form').hidden)return;
40
+ draft={...draft,version:1,form:formValue(),revision:draft?.revision??selected.revision,path:selected.path,at:Date.now()};drafts.set(draftKey(),draft);
41
+ try{if(storageBlocked)throw new Error(storageError);localStorage.setItem(draftKey(),JSON.stringify(draft));storageError='';}catch{storageError=storageMessage;}
42
+ draftStatus();
43
+ if(storageError)throw new Error(storageError);
44
+ }
45
+ function beforeLeave(){if(state.mode!=='records'||root.hidden)return;if(selected&&!pending&&$('record-action-form')&&!$('record-action-form').hidden&&(draft||$('record-note').value||$('record-choice').value))retainForm();}
46
+ function restoreForm(form){
47
+ if(!$('record-action-form')||!form)return;
48
+ for(const [id,value] of [['record-action',form.action],['record-choice',form.choice]]){const field=$(id);if(field.tagName==='SELECT'&&value&&!Array.from(field.options).some(o=>o.value===value)){const option=new Option(`${value} (unavailable)`,value);field.append(option);}field.value=value??'';}
49
+ $('record-note').value=form.note??'';updateChoice();
50
+ }
51
+ function updateChoice(){const ruled=$('record-action').value==='ruled';$('record-choice-label').hidden=!ruled;$('record-choice').required=ruled;}
52
+ function changed(){try{retainForm();}catch{}}
53
+
27
54
  const url=key=>{const u=new URL(location.href);u.searchParams.set('view',kind);if(key)u.searchParams.set('record',key);else u.searchParams.delete('record');history.replaceState(null,'',u);};
28
55
  const run=fn=>async()=>{try{await fn();}catch(error){notice(error.message,true);}};
29
56
  function deactivate(){for(const k of ['flags','decisions'])$('library-'+k).setAttribute('aria-pressed','false');}
30
57
  function activate(){state.mode='records';$('empty').hidden=true;$('document').hidden=true;root.hidden=false;for(const k of ['flags','decisions'])$('library-'+k).setAttribute('aria-pressed',String(kind===k));for(const k of ['all','hubs','plans','documents'])$('library-'+k).setAttribute('aria-pressed','false');$('doc-title').textContent=title();}
31
58
  function setCounts(counts){for(const k of ['flags','decisions'])$('library-'+k+'-count').textContent=counts[k].toLocaleString();}
32
59
  async function counts(){const result=await api('records?kind=flags&status=pending');setCounts(result.counts);}
33
- async function show(next,key){await showLibrary();kind=next;selected=null;offset=0;filters={q:'',status:preferences().records??'pending',severity:'',scope:''};activate();url(key);if(key)await detail(key);else await load();}
60
+ async function show(next,key){beforeLeave();await showLibrary();kind=next;selected=null;offset=0;filters={q:'',status:preferences().records??'pending',severity:'',scope:''};activate();url(key);if(key)await detail(key);else await load();}
61
+ function clearResults(message,{error,retry}={}){
62
+ if(!$('record-results'))return;
63
+ const status=document.createElement('p');status.className='library-no-results';status.textContent=error?`${message}: ${error.message}`:message;status.setAttribute('role',error?'alert':'status');
64
+ $('record-results').replaceChildren(status);$('record-range').textContent=message;$('record-unavailable').textContent='';
65
+ $('record-previous').disabled=true;$('record-next').disabled=true;
66
+ if(retry){const button=document.createElement('button');button.id='record-retry';button.className='quiet';button.textContent='Retry';button.onclick=run(()=>load(false,false));$('record-results').append(button);}
67
+ }
68
+ function searchChanged(){clearTimeout(timer);sequence++;clearResults('Loading records…');$('record-results').setAttribute('aria-busy','false');}
34
69
  function shell(){root.innerHTML=`<div class="library-heading"><div><h1>${title()}</h1><p>${kind==='flags'?'Findings to triage, with original evidence and a history of what happened.':'Questions and outcomes across plans and registers, connected to their source.'}</p></div><div class="record-heading-actions"><button id="records-new" class="primary">New ${kind==='flags'?'flag':'decision'}</button><button id="records-refresh" class="quiet small">↻ Refresh</button></div></div><div class="record-controls"><label class="sr-only" for="record-search">Search ${title()}</label><input id="record-search" class="search" type="search" placeholder="Search ID, question, source…" value="${esc(filters.q)}"><label>Status<select id="record-status"><option value="pending">Needs attention</option><option value="">All statuses</option>${(kind==='flags'?['open','closed','resolved']:['open','held','ruled','closed','unknown']).map(s=>`<option value="${s}">${s==='closed'&&kind==='flags'?'Rejected':s[0].toUpperCase()+s.slice(1)}</option>`).join('')}</select></label>${kind==='flags'?'<label>Severity<select id="record-severity"><option value="">All severities</option><option>problem</option><option>warn</option><option>info</option></select></label>':''}<label>Plan or hub<select id="record-scope"><option value="">All sources</option></select></label></div><div class="library-pagination"><span id="record-range" role="status">Loading records…</span><button id="record-previous" class="quiet small">← Previous</button><button id="record-next" class="quiet small">Next →</button></div><nav id="record-results" aria-label="${title()} list" aria-busy="true"></nav><p id="record-unavailable" class="muted"></p>`;
35
70
  $('record-status').value=filters.status;if(kind==='flags')$('record-severity').value=filters.severity;
36
- $('record-search').oninput=()=>{filters.q=$('record-search').value;clearTimeout(timer);timer=setTimeout(()=>{offset=0;run(()=>load(false,false))();},180);};
37
- for(const [id,key] of [['record-status','status'],['record-severity','severity'],['record-scope','scope']])if($(id))$(id).onchange=()=>{filters[key]=$(id).value;offset=0;run(()=>load(false,false))();};
71
+ $('record-search').oninput=()=>{filters.q=$('record-search').value;searchChanged();timer=setTimeout(()=>{offset=0;run(()=>load(false,false))();},180);};
72
+ for(const [id,key] of [['record-status','status'],['record-severity','severity'],['record-scope','scope']])if($(id))$(id).onchange=()=>{filters[key]=$(id).value;offset=0;searchChanged();run(()=>load(false,false))();};
38
73
  $('records-new').onclick=run(()=>create(kind==='flags'?'flag':'decision'));$('records-refresh').onclick=run(()=>load(true,false));$('record-previous').onclick=run(()=>{offset=Math.max(0,offset-50);return load(false,false);});$('record-next').onclick=run(()=>{offset+=50;return load(false,false);});
39
74
  }
40
- async function load(refresh=false,rebuild=true){if(state.busy)throw new Error('Wait for the current save before navigating.');selected=null;pending=null;if(rebuild)shell();activate();url();const own=++sequence;$('record-results').setAttribute('aria-busy','true');$('record-range').textContent='Loading records…';
75
+ async function load(refresh=false,rebuild=true){beforeLeave();if(state.busy)throw new Error('Wait for the current save before navigating.');selected=null;pending=null;if(rebuild)shell();activate();url();const own=++sequence;clearResults('Loading records…');$('record-results').setAttribute('aria-busy','true');
41
76
  try{const params=new URLSearchParams({kind,...filters,offset:String(offset)});if(refresh)params.set('refresh','1');const result=await api(`records?${params}`);if(own!==sequence||state.mode!=='records'||selected)return;setCounts(result.counts);offset=result.offset;
42
77
  $('record-scope').replaceChildren(new Option('All sources',''),...result.scopes.map(s=>new Option(s.title,s.path)));$('record-scope').value=filters.scope;
43
78
  $('record-range').textContent=result.total?`${offset+1}–${offset+result.records.length} of ${result.total.toLocaleString()}`:'0 records';$('record-previous').disabled=!offset;$('record-next').disabled=!result.hasMore;
44
79
  $('record-results').replaceChildren(...result.records.map(row=>{const b=document.createElement('button');b.className='record-row quiet';b.innerHTML=`<span class="record-row-label"><strong>${esc(row.title||row.id||'Unnamed decision')}</strong><small>${esc(row.path)}${row.line?`:${row.line}`:''}</small></span><span class="record-row-meta"><span class="record-id">${esc(row.id??'No ID')}</span><span class="status-badge">${esc(row.status)}</span>${row.severity?`<span class="severity ${esc(row.severity)}">${esc(row.severity)}</span>`:''}${row.triage==='accepted'?'<small>Accepted</small>':''}</span>`;b.onclick=run(()=>detail(row.key));return b;}));
45
80
  if(!result.records.length)$('record-results').innerHTML='<p class="library-no-results">No records match these filters. Choose All statuses or try another search.</p>';
46
- $('record-unavailable').textContent=result.unavailable?`${result.unavailable} unavailable sources. Refresh to retry.`:'';notice(`${title()} ready.`);
47
- }catch(error){if(own===sequence&&$('record-range'))$('record-range').textContent='Records unavailable · try Refresh';throw error;}finally{if(own===sequence&&$('record-results'))$('record-results').setAttribute('aria-busy','false');}
81
+ $('record-unavailable').textContent=result.unavailable?`${result.unavailable} unavailable sources. Refresh to retry.`:'';
82
+ }catch(error){if(own===sequence&&state.mode==='records'&&!selected)clearResults(filters.q?'Search unavailable':'Records unavailable',{error,retry:true});}finally{if(own===sequence&&$('record-results'))$('record-results').setAttribute('aria-busy','false');}
48
83
  }
49
84
  function connectionHtml(links){return links.length?`<div class="record-connections">${links.map((link,i)=>`<button class="quiet small" data-connection="${i}" title="${esc(link.path)}">${esc(link.title)} <span class="muted">${esc(link.kind==='hub'?'Hub':link.type??'Doc')}</span></button>`).join('')}</div>`:'<p class="muted">No linked documents were found in this library.</p>';}
50
85
  function historyHtml(history){return history?.length?`<ol class="record-history">${[...history].reverse().map(h=>`<li><strong>${esc(h.event??h.kind??'Recorded')}</strong> <span class="muted">${esc(h.by?.name??h.by?.label??h.by?.id??'')} · ${esc(h.at??'')}</span><p>${esc(h.note??h.reason??'')}</p></li>`).join('')}</ol>`:'<p class="muted">No later actions have been recorded.</p>';}
51
- async function detail(key,{allowBusy=false}={}){if(state.busy&&!allowBusy)throw new Error('Wait for the current save before navigating.');const own=++sequence,wantedKind=kind;clearTimeout(timer);notice('Opening record…');const row=await api(`records?${new URLSearchParams({kind,key})}`);if(own!==sequence||kind!==wantedKind||state.mode!=='records')return;selected=row;pending=null;try{pending=JSON.parse(localStorage.getItem(recoveryKey())??'null');}catch{}activate();url(key);renderDetail();window.scrollTo(0,0);notice(pending?'An interrupted review is retained. Retry its exact request or keep it while reloading the record.':`${title()} record ready.`);}
86
+ async function detail(key,{allowBusy=false,committed=false}={}){if(!committed)beforeLeave();if(state.busy&&!allowBusy)throw new Error('Wait for the current save before navigating.');const own=++sequence,wantedKind=kind,wantedCheckout=checkout();clearTimeout(timer);const row=await api(`records?${new URLSearchParams({kind,key})}`);if(own!==sequence||kind!==wantedKind||checkout()!==wantedCheckout||state.mode!=='records')return;selected=row;selectedCheckout=wantedCheckout;pending=null;draft=null;storageError='';storageBlocked=false;
87
+ const unreadable=()=>{storageBlocked=true;storageError='Retained record data could not be read. Keep this window open and check local storage.';};
88
+ try{pending=JSON.parse(localStorage.getItem(recoveryKey())??'null');if(pending&&(!pending.request||!['native/action','flags/triage','save'].includes(pending.route)||typeof pending.request.operationId!=='string'))throw new Error('Invalid review');}catch{pending=null;unreadable();}
89
+ try{draft=drafts.get(draftKey())??JSON.parse(localStorage.getItem(draftKey())??'null');if(draft&&(!draft.form||typeof draft.form.note!=='string'||typeof draft.form.action!=='string'||typeof draft.form.choice!=='string'))throw new Error('Invalid draft');}catch{draft=drafts.get(draftKey())??null;unreadable();}
90
+ activate();url(key);renderDetail();window.scrollTo(0,0);
91
+ }
52
92
  function renderDetail(){const r=selected;
53
93
  let content;
54
94
  if(r.native)content=nativeContent(r)+`<article class="markdown record-body">${markdownHtml(r.body)}</article><details class="record-structured"><summary>Structured record details</summary><pre>${esc(JSON.stringify(r.data??{},null,2))}</pre></details>`;
55
95
  else if(kind==='flags')content=`<div class="record-evidence"><section><h2>Original observation</h2><p class="muted">${esc(r.original.file)}${r.original.line?`:${r.original.line}`:''} · ${esc(r.original.at)}</p><pre>${esc(r.original.quote||'File-level finding; no quote was recorded.')}</pre><p class="muted">${esc(r.original.revisionReason)}</p></section><section><h2>Current evidence <span class="status-badge">${esc(r.current.status)}</span></h2><p class="muted">${r.current.line?`Line ${r.current.line}`:'Location unavailable'}</p><pre>${esc(r.current.quote??'Evidence is unavailable or ambiguous.')}</pre><p class="muted">The original observation stays in the record.</p></section></div><h2>History</h2>${historyHtml(r.history)}`;
56
96
  else content=decisionContent(r.body)+`${r.missing?.length?`<p class="inline-note">Record gaps: ${esc(r.missing.join(', '))}. Open the source to fill them in.</p>`:''}${r.blocks?.length?`<h2>Blocking work</h2><ul>${r.blocks.map(b=>`<li>${esc(typeof b==='string'?b:b.text)}${b.doc?` <small class="muted">${esc(b.doc)}:${b.line}</small>`:''}</li>`).join('')}</ul>`:''}${r.peers?.length?`<h2>Related records</h2>${r.peers.map(p=>`<details><summary>${esc(p.id)} · ${esc(p.status)} · ${esc(p.path)}</summary><article class="markdown">${markdownHtml(p.body)}</article></details>`).join('')}`:''}`;
57
- root.innerHTML=`<div class="record-back"><button id="records-back" class="quiet small">← ${title()}</button><button id="record-reload" class="quiet small">↻ Reload record</button></div><div class="record-heading"><div class="record-eyebrow">${esc(r.id??'Unnamed decision')} · <span class="status-badge">${esc(r.status)}</span> ${r.severity?`· ${esc(r.severity)}`:''}</div><h1>${esc(r.title)}</h1><p class="muted">${esc(r.path)}${r.line?`:${r.line}`:''}</p></div><section class="record-links"><h2>Connected work</h2>${connectionHtml(r.connections??[])}</section>${content}<section class="record-action"><h2>${kind==='flags'?'Triage this flag':'Record an outcome'}</h2>${r.editable?`<form id="record-action-form"><div class="record-action-fields"><label>${kind==='flags'?'Action':'Disposition'}<select id="record-action">${(kind==='flags'?(r.native&&r.status!=='open'?['reopen']:['accept','reject','resolve']):['held','ruled','closed','open']).map(v=>`<option value="${v}">${v[0].toUpperCase()+v.slice(1)}</option>`).join('')}</select></label><label id="record-choice-label" hidden>Selected answer${r.native&&kind==='decisions'?`<select id="record-choice"><option value="">Choose an existing alternative</option>${(r.data?.options??[]).map(o=>`<option value="${esc(o.id)}">${esc(o.label)}</option>`).join('')}</select>`:'<input id="record-choice" maxlength="4000" placeholder="Which answer was chosen?">'}</label></div><label>Reason<textarea id="record-note" required maxlength="4000" rows="3" placeholder="Why is this the right next step?"></textarea></label><p class="muted">${kind==='flags'?'Accept keeps the flag open. Reject closes it. Resolve records a manual resolution; it does not claim a passing check. Reopen retains earlier history.':'The new outcome is recorded with your identity and date. Previous outcomes and the original record are retained.'}</p><button id="record-review-button" class="primary">Review ${kind==='flags'?'triage':'outcome'}</button></form>`:`<p class="muted">${r.readOnlyReason?esc(r.readOnlyReason):r.native?'Repair the native record diagnostics before recording an action.':kind==='flags'?'This flag has already been closed. Its observation and history are retained.':'This record is read only or has no stable ID. Open its source to inspect or complete it.'}</p>`}<div id="record-review" hidden></div></section>`;
97
+ root.innerHTML=`<div class="record-back"><button id="records-back" class="quiet small">← ${title()}</button><button id="record-reload" class="quiet small">↻ Reload record</button></div><div class="record-heading"><div class="record-eyebrow">${esc(r.id??'Unnamed decision')} · <span class="status-badge">${esc(r.status)}</span> ${r.severity?`· ${esc(r.severity)}`:''}</div><h1>${esc(r.title)}</h1><p class="muted">${esc(r.path)}${r.line?`:${r.line}`:''}</p></div><section class="record-links"><h2>Connected work</h2>${connectionHtml(r.connections??[])}</section>${content}<section class="record-action"><h2>${kind==='flags'?'Triage this flag':'Record an outcome'}</h2>${r.editable?`<form id="record-action-form"><div class="record-action-fields"><label>${kind==='flags'?'Action':'Disposition'}<select id="record-action">${(kind==='flags'?(r.native&&r.status!=='open'?['reopen']:['accept','reject','resolve']):['held','ruled','closed','open']).map(v=>`<option value="${v}">${v[0].toUpperCase()+v.slice(1)}</option>`).join('')}</select></label><label id="record-choice-label" hidden>Selected answer${r.native&&kind==='decisions'?`<select id="record-choice"><option value="">Choose an existing alternative</option>${(r.data?.options??[]).map(o=>`<option value="${esc(o.id)}">${esc(o.label)}</option>`).join('')}</select>`:'<input id="record-choice" maxlength="4000" placeholder="Which answer was chosen?">'}</label></div><label>Reason<textarea id="record-note" required maxlength="4000" rows="3" placeholder="Why is this the right next step?"></textarea></label><button id="record-review-button" class="primary">Review ${kind==='flags'?'triage':'outcome'}</button></form>`:`<p class="muted">${r.readOnlyReason?esc(r.readOnlyReason):r.native?'Repair the native record diagnostics before recording an action.':kind==='flags'?'This flag has already been closed. Its observation and history are retained.':'This record is read only or has no stable ID. Open its source to inspect or complete it.'}</p>`}<p id="record-draft-status" class="muted" role="status"></p><div class="record-review-actions"><button id="record-draft-current" class="quiet small" hidden>Use current record</button><button id="record-draft-discard" class="quiet small" hidden>Discard draft</button></div><div id="record-review" hidden></div></section>`;
58
98
  $('records-back').onclick=run(()=>load());$('record-reload').onclick=run(async()=>{await api(`records?kind=${kind}&refresh=1`);await detail(r.key);});
59
- for(const b of root.querySelectorAll('[data-connection]'))b.onclick=run(()=>open(r.connections[Number(b.dataset.connection)].path));
60
- if(r.editable){$('record-action').onchange=()=>{const ruled=$('record-action').value==='ruled';$('record-choice-label').hidden=!ruled;$('record-choice').required=ruled;};$('record-action-form').onsubmit=e=>{e.preventDefault();run(review)();};}
61
- if(pending)renderReview();
99
+ for(const b of root.querySelectorAll('[data-connection]'))b.onclick=run(()=>{beforeLeave();return open(r.connections[Number(b.dataset.connection)].path);});
100
+ if(r.editable){$('record-action').onchange=()=>{updateChoice();changed();};for(const id of ['record-note','record-choice']){$(id).oninput=changed;$(id).onchange=changed;}$('record-action-form').onsubmit=e=>{e.preventDefault();return run(review)();};restoreForm(draft?.form);}
101
+ else if(draft){const retained=document.createElement('pre');retained.className='record-body';retained.textContent=`Draft · ${draft.form.action}${draft.form.choice?` · ${draft.form.choice}`:''}\n${draft.form.note}`;$('record-draft-status').before(retained);}
102
+ $('record-draft-current').onclick=run(async()=>{if(storageBlocked)throw new Error(storageError);draft.revision=selected.revision;retainForm();});
103
+ $('record-draft-discard').onclick=run(async()=>{if(storageBlocked)throw new Error(storageError);try{localStorage.removeItem(draftKey());}catch{throw new Error('Draft could not be discarded. Try again.');}drafts.delete(draftKey());draft=null;storageError='';renderDetail();});
104
+ draftStatus();if(pending)renderReview();
62
105
  }
63
- async function review(){if(state.busy)return;const action=$('record-action').value,note=$('record-note').value,choice=$('record-choice').value;
64
- if(selected.native){const preview=await api('native/preview',{path:selected.path,expectedRevision:selected.revision,operationId:crypto.randomUUID(),action,note,optionId:choice});pending={form:{action,note,choice},route:'native/action',request:preview.request,before:preview.before,label:`Record ${action}`,note};}
65
- else if(kind==='flags')pending={form:{action,note,choice},route:'flags/triage',request:{key:selected.key,action,note,expectedRevision:selected.revision,operationId:crypto.randomUUID()},label:`${action[0].toUpperCase()+action.slice(1)} ${selected.id}`,note};
66
- else {const preview=await api('decisions/preview',{key:selected.key,expectedRevision:selected.revision,disposition:action,note,choice});pending={form:{action,note,choice},route:'save',request:{path:preview.path,source:preview.source,expectedRevision:preview.expectedRevision,operationId:crypto.randomUUID()},before:preview.before,label:`Record ${action} for ${selected.id}`,note};}
106
+ async function review(){if(state.busy)return;if(selectedCheckout!==checkout())throw new Error('Open this record in its original checkout.');if(storageBlocked)throw new Error(storageError);retainForm();if(draft.revision!==selected.revision)throw new Error('Check the changed record, then choose Use current record.');const allowed=kind==='flags'?(selected.native&&selected.status!=='open'?['reopen']:['accept','reject','resolve']):['held','ruled','closed','open'];if(!allowed.includes($('record-action').value))throw new Error('Choose an available action.');if(selected.native&&$('record-action').value==='ruled'&&!(selected.data?.options??[]).some(o=>o.id===$('record-choice').value))throw new Error('Choose an available alternative.');const action=$('record-action').value,note=$('record-note').value,choice=$('record-choice').value,own=sequence,wantedCheckout=checkout(),record=selected;let reviewed;
107
+ if(record.native){const preview=await api('native/preview',{path:record.path,expectedRevision:record.revision,operationId:crypto.randomUUID(),action,note,optionId:choice});reviewed={form:{action,note,choice},route:'native/action',request:preview.request,before:preview.before,label:`Record ${action}`,note};}
108
+ else if(kind==='flags')reviewed={form:{action,note,choice},route:'flags/triage',request:{key:record.key,action,note,expectedRevision:record.revision,operationId:crypto.randomUUID()},label:`${action[0].toUpperCase()+action.slice(1)} ${record.id}`,note};
109
+ else {const preview=await api('decisions/preview',{key:record.key,expectedRevision:record.revision,disposition:action,note,choice});reviewed={form:{action,note,choice},route:'save',request:{path:preview.path,source:preview.source,expectedRevision:preview.expectedRevision,operationId:crypto.randomUUID()},before:preview.before,label:`Record ${action} for ${record.id}`,note};}
67
110
  // Retain exactly the reviewed request before any write, including its UUID.
68
- try{localStorage.setItem(recoveryKey(),JSON.stringify(pending));}catch{pending=null;throw new Error('The review could not be retained in local editor storage. Free some storage and try again.');}renderReview();
111
+ if(own!==sequence||wantedCheckout!==checkout()||state.mode!=='records')return;pending={...reviewed,path:record.path,at:Date.now()};
112
+ try{localStorage.setItem(recoveryKey(),JSON.stringify(pending));}catch{pending=null;storageError='Review not stored. Keep this window open and retry.';draftStatus();throw new Error(storageError);}renderReview();
69
113
  }
70
- function renderReview(){if($('record-action-form'))$('record-action-form').hidden=true;$('record-review').hidden=false;$('record-review').innerHTML=`<h3>${esc(pending.label)}</h3><p>${esc(pending.note)}</p>${pending.before?`<div class="diff">${diffHtml(pending.before,pending.request.source)}</div>`:'<p class="muted">This appends a dated action to the flag history. The original evidence is retained.</p>'}<div class="record-review-actions"><button id="record-confirm" class="primary">Confirm ${kind==='flags'?'triage':'outcome'}</button><button id="record-change" class="quiet">Back to form</button></div><p id="record-action-feedback" role="status"></p>`;$('record-confirm').onclick=run(confirm);$('record-change').onclick=()=>{localStorage.removeItem(recoveryKey());const form=pending.form;pending=null;$('record-review').hidden=true;if($('record-action-form')){$('record-action-form').hidden=false;if(form){$('record-action').value=form.action;$('record-note').value=form.note;$('record-choice').value=form.choice;$('record-action').onchange();}}};}
71
- async function confirm(){if(state.busy)return;state.busy=true;$('record-confirm').disabled=true;$('record-change').disabled=true;
72
- try{await api(pending.route,pending.request);localStorage.removeItem(recoveryKey());pending=null;await counts();await detail(selected.key,{allowBusy:true});notice('Saved. The source and history are available through the CLI.');}
73
- catch(error){if($('record-action-feedback'))$('record-action-feedback').textContent=`${error.message} Your reviewed request is retained. Reload the record to inspect it, or retry this exact request.`;throw error;}
114
+ function renderReview(){draftStatus();if($('record-action-form'))$('record-action-form').hidden=true;$('record-review').hidden=false;$('record-review').innerHTML=`<h3>${esc(pending.label)}</h3><p>${esc(pending.note)}</p>${pending.before?`<div class="diff">${diffHtml(pending.before,pending.request.source)}</div>`:''}<div class="record-review-actions"><button id="record-confirm" class="primary">Confirm ${kind==='flags'?'triage':'outcome'}</button><button id="record-change" class="quiet">Back to form</button></div><p id="record-action-feedback" role="status"></p>`;$('record-confirm').onclick=run(confirm);$('record-change').onclick=run(async()=>{const form=pending.form??draft?.form;if(!selected.editable)throw new Error('The record is read only. Keep this reviewed request for inspection.');if(storageBlocked)throw new Error(storageError);const restored={version:1,form,revision:pending.request.expectedRevision??draft?.revision??selected.revision,path:selected.path,at:Date.now()};try{localStorage.setItem(draftKey(),JSON.stringify(restored));localStorage.removeItem(recoveryKey());}catch{throw new Error('The review is retained. Local storage must be available before returning to the form.');}draft=restored;drafts.set(draftKey(),draft);pending=null;$('record-review').hidden=true;$('record-action-form').hidden=false;restoreForm(form);draftStatus();});}
115
+ async function confirm(){if(state.busy||!pending)return;state.busy=true;$('record-confirm').disabled=true;$('record-change').disabled=true;
116
+ try{if(selectedCheckout!==checkout())throw new Error('Open this review in its original checkout.');await api(pending.route,pending.request);try{localStorage.removeItem(draftKey());localStorage.removeItem(recoveryKey());}catch{throw new Error('Saved, but retained local data could not be cleared. Retry safely with the same request.');}drafts.delete(draftKey());draft=null;pending=null;try{await detail(selected.key,{allowBusy:true,committed:true});await counts();}catch(error){notice(`Saved; refresh unavailable. ${error.message}`,true);}}
117
+ catch(error){if($('record-action-feedback'))$('record-action-feedback').textContent=`${error.message} Reviewed request retained; retry when ready.`;throw error;}
74
118
  finally{state.busy=false;if($('record-confirm'))$('record-confirm').disabled=false;if($('record-change'))$('record-change').disabled=false;}
75
119
  }
76
120
  for(const k of ['flags','decisions'])$('library-'+k).onclick=run(()=>show(k));
77
- return {show,counts,deactivate};
121
+ window.addEventListener('beforeunload',event=>{try{beforeLeave();}catch{event.preventDefault();event.returnValue='';}});
122
+ return {show,counts,deactivate,beforeLeave};
78
123
  }
@@ -1,29 +1,30 @@
1
1
  export function recoveryCenter({$,api,checkout,beforeNavigate,resume,notice}) {
2
2
  const dialog=$('recovery-dialog');let rows=[],offset=0,hasMore=false,sequence=0;
3
3
  function localItems(){
4
- const items=[];let unreadable=0;
4
+ const items=[],reviewed=new Set();let unreadable=0;
5
5
  try { for(let i=0;i<localStorage.length;i++) {
6
- const key=localStorage.key(i),prefix=`runlist:recovery:${checkout()}:`,lifecycle=`runlist:lifecycle:${checkout()}:`,record=`runlist:record-action:${checkout()}:`,yardstick=`runlist:yardstick:${checkout()}:`;
7
- if(!key.startsWith(prefix)&&!key.startsWith(lifecycle)&&!key.startsWith(record)&&!key.startsWith(yardstick)&&key!==`runlist:create:${checkout()}`)continue;
6
+ const key=localStorage.key(i),prefix=`runlist:recovery:${checkout()}:`,lifecycle=`runlist:lifecycle:${checkout()}:`,record=`runlist:record-action:${checkout()}:`,recordDraft=`runlist:record-draft:${checkout()}:`,yardstick=`runlist:yardstick:${checkout()}:`;
7
+ if(!key.startsWith(prefix)&&!key.startsWith(lifecycle)&&!key.startsWith(record)&&!key.startsWith(recordDraft)&&!key.startsWith(yardstick)&&key!==`runlist:create:${checkout()}`)continue;
8
8
  try {
9
9
  const value=JSON.parse(localStorage.getItem(key));if(!value)continue;
10
10
  if(key.startsWith(prefix)&&typeof value.path==='string'&&typeof value.source==='string'&&typeof value.baseSource==='string'&&(value.source!==value.baseSource||value.pending))items.push({kind:'local-draft',path:value.path,location:key,draftId:value.draft?.id,operationId:value.pending?.operationId,at:new Date(value.at).toISOString(),stale:!!value.conflicted,state:value.pending?'interrupted':value.conflicted?'conflict':'draft',available:true});
11
11
  else if(key.startsWith(yardstick))items.push({kind:'yardstick',path:value.path,operationId:value.operationId,at:'',available:true,state:value.state??'retained'});
12
12
  else if(key.startsWith(lifecycle))items.push({kind:'lifecycle',path:value.path,operationId:value.operationId,at:'',available:true,state:'retained'});
13
- else if(key.startsWith(record)){const suffix=key.slice(record.length),colon=suffix.indexOf(':');items.push({kind:'record',recordKind:suffix.slice(0,colon),recordKey:suffix.slice(colon+1),path:value.request?.path??'Flag or decision',at:'',available:true,state:'retained'});}
13
+ else if(key.startsWith(record)){if(!value.request||!['native/action','flags/triage','save'].includes(value.route)||typeof value.request.operationId!=='string')throw new Error('Invalid review');const suffix=key.slice(record.length),colon=suffix.indexOf(':');reviewed.add(key);items.push({kind:'record',recordKind:suffix.slice(0,colon),recordKey:suffix.slice(colon+1),location:key,path:value.path??value.request.path??value.label??'Flag or decision',operationId:value.request.operationId,at:value.at?new Date(value.at).toISOString():'',available:true,state:'retained'});}
14
+ else if(key.startsWith(recordDraft)&&value.form&&typeof value.form.note==='string'){const suffix=key.slice(recordDraft.length),colon=suffix.indexOf(':');items.push({kind:'record',reviewLocation:record+suffix,recordKind:suffix.slice(0,colon),recordKey:suffix.slice(colon+1),location:key,path:value.path??'Flag or decision',at:new Date(value.at).toISOString(),available:true,state:'draft'});}
14
15
  else if(key===`runlist:create:${checkout()}`&&(value.form?.title||value.form?.body||value.review))items.push({kind:'creation',operationId:value.review?.operationId,path:value.review?.path??`${value.form?.folder??''}/${value.form?.filename??'New document'}`,state:value.review?.state??'draft',at:new Date(value.at).toISOString(),available:true});
15
16
  }catch{unreadable++;}
16
17
  }
17
18
  } catch {unreadable++;}
18
- return {items,unreadable};
19
+ return {items:items.filter(row=>!reviewed.has(row.reviewLocation)),unreadable};
19
20
  }
20
- const label=row=>row.kind==='git-commit'?`Local Git commit · ${row.state}`:row.kind==='creation'?'New document draft':row.kind==='yardstick'?`Product goal assessment · ${row.state}`:row.kind==='lifecycle'?`Status change · ${row.state}`:row.kind==='record'?'Flag or decision review':row.kind==='operation'?'Interrupted save · inspect outcome':row.operationId||row.pendingOperation?'Interrupted save · draft preserved locally':row.stale?'File changed · compare draft':'Draft preserved locally';
21
+ const label=row=>row.kind==='git-commit'?`Local Git commit · ${row.state}`:row.kind==='creation'?'New document draft':row.kind==='yardstick'?`Product goal assessment · ${row.state}`:row.kind==='lifecycle'?`Status change · ${row.state}`:row.kind==='record'?`${row.recordKind==='flags'?'Flag':'Decision'} ${row.state==='draft'?'draft':'review'}`:row.kind==='operation'?'Interrupted save · inspect outcome':row.operationId||row.pendingOperation?'Interrupted save · draft preserved locally':row.stale?'File changed · compare draft':'Draft preserved locally';
21
22
  function render(){
22
23
  const query=$('recovery-filter').value.toLowerCase(),visible=rows.filter(row=>`${row.path} ${label(row)}`.toLowerCase().includes(query));
23
24
  $('recovery-items').replaceChildren(...visible.map(row=>{
24
25
  const section=document.createElement('section');section.className='recovery-item';
25
26
  const info=document.createElement('div'),title=document.createElement('strong'),detail=document.createElement('p'),date=document.createElement('small');title.textContent=row.path;detail.textContent=label(row);date.textContent=row.at?new Date(row.at).toLocaleString():'';info.append(title,detail,date);
26
- const button=document.createElement('button');button.className='quiet';button.textContent=!row.available?'File unavailable':row.kind==='creation'?'Resume creation':row.kind==='git-commit'||row.kind==='yardstick'||row.kind==='lifecycle'||row.kind==='operation'?'Inspect operation':row.kind==='record'?'Resume review':'Resume draft';button.disabled=!row.available;
27
+ const button=document.createElement('button');button.className='quiet';button.textContent=!row.available?'File unavailable':row.kind==='creation'?'Resume creation':row.kind==='git-commit'||row.kind==='yardstick'||row.kind==='lifecycle'||row.kind==='operation'?'Inspect operation':row.kind==='record'&&row.state!=='draft'?'Resume review':'Resume draft';button.disabled=!row.available;
27
28
  button.onclick=async()=>{try{dialog.close();await resume(row);}catch(error){notice(error.message,true);}};section.append(info,button);return section;
28
29
  }));
29
30
  if(!visible.length){const message=document.createElement('p');message.className='library-no-results';message.textContent=query?'No recovery items match this filter.':'No unfinished work in this checkout.';$('recovery-items').append(message);}
@@ -1,15 +1,53 @@
1
1
  import {desktop} from './transport.mjs';
2
2
  import {escapeHtml as esc} from './shared.mjs';
3
- export function appSettings({$,api,checkout,library,notice}){
4
- const key=()=>`runlist:preferences:${checkout()}`;
3
+ export function appSettings({$,api,checkout,library}){
4
+ const key=owner=>`runlist:preferences:${owner}`;
5
5
  const defaults={sort:'updated',archived:false,records:'pending'};
6
- const read=()=>{try{const p=JSON.parse(localStorage.getItem(key())??'{}');return {sort:['updated','title','path'].includes(p.sort)?p.sort:'updated',archived:p.archived===true,records:p.records===''?'':'pending'};}catch{return {...defaults};}};
7
- async function show(){const info=await api('settings'),tool=await api('semantic/settings'),prefs=read();$('settings-checkout').textContent=info.checkout;$('settings-content').innerHTML=`<section class="settings-section"><h3>${desktop?'This computer':'This browser'}</h3><p class="muted">These preferences stay ${desktop?'in Runlist on this computer':'in this browser'}. They apply when you open this checkout.</p><form id="settings-preferences"><div class="settings-fields"><label>Appearance<select id="settings-theme"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select></label><label>Default document order<select id="settings-sort"><option value="updated">Recently updated</option><option value="title">Title</option><option value="path">File path</option></select></label><label>Default records view<select id="settings-records"><option value="pending">Needs attention</option><option value="">All statuses</option></select></label><label class="settings-check"><input id="settings-archived" type="checkbox"> Include archived documents by default</label></div><button class="primary">Save preferences</button><span id="settings-feedback" role="status"></span></form></section><section class="settings-section"><h3>Semantic search · experimental</h3><p class="muted">${esc(tool.message)} No gmax dependency is bundled with Runlist. Leave both paths blank to use automatic discovery.</p><form id="settings-semantic"><div class="settings-fields"><label>External Node executable<input id="settings-semantic-node" autocomplete="off" placeholder="Absolute path to node"></label><label>gmax entry file<input id="settings-semantic-entry" autocomplete="off" placeholder="Absolute path to grepmax/dist/bin.js"></label></div><button class="quiet">Save tool paths</button><span id="settings-semantic-feedback" role="status"></span></form></section><section class="settings-section"><h3>Templates</h3><p class="muted">View, customize and review the scaffolds for new documents. Saved templates are repository files and can be shared through Git.</p><button id="settings-templates" class="quiet">Open templates</button></section><section class="settings-section"><h3>Checkout rules</h3><p class="muted">Loaded from ${esc(info.configFile??'built-in defaults')}. Edit repository configuration through the CLI or its source; this view shows the effective rules.</p><dl class="settings-paths"><dt>Document roots</dt><dd>${info.roots.map(p=>`<code>${esc(p)}</code>`).join(' ')}</dd><dt>Excluded folders</dt><dd>${info.exclusions.map(p=>`<code>${esc(p)}</code>`).join(' ')}</dd></dl>${info.statuses.typeOrder.map(type=>`<details class="settings-rules"><summary>${esc(type)} · ${info.statuses.byType[type].length} statuses</summary><div class="settings-table"><table><thead><tr><th>Status</th><th>Context</th><th>Behavior</th></tr></thead><tbody>${info.statuses.byType[type].map(s=>`<tr><td>${esc(s.name)}</td><td>${esc(s.context)}</td><td>${esc([s.startable?'Can start':null,s.terminal?'Terminal':null,s.archive?'Archives':null,s.filed?`Files in ${s.filed}`:null,s.staleDays?`Stale after ${s.staleDays} days`:null].filter(Boolean).join(' · ')||'Ordinary')}</td></tr>`).join('')}</tbody></table></div></details>`).join('')}</section><section class="settings-section"><h3>Where changes live</h3><dl class="settings-paths">${Object.entries(info.sharing).map(([name,value])=>`<dt>${esc(name)}</dt><dd>${esc(value)}</dd>`).join('')}</dl></section>`;
8
- $('settings-semantic-node').value=tool.nodePath;$('settings-semantic-entry').value=tool.entryPath;
9
- $('settings-semantic').onsubmit=async event=>{event.preventDefault();try{const saved=await api('semantic/settings',{nodePath:$('settings-semantic-node').value.trim(),entryPath:$('settings-semantic-entry').value.trim()});$('settings-semantic-feedback').textContent=saved.message;}catch(error){$('settings-semantic-feedback').textContent=error.message;}};
10
- $('settings-theme').value=$('theme-mode').value;$('settings-sort').value=prefs.sort;$('settings-archived').checked=prefs.archived;$('settings-records').value=prefs.records;
11
- $('settings-preferences').onsubmit=async event=>{event.preventDefault();try{const preferences={sort:$('settings-sort').value,archived:$('settings-archived').checked,records:$('settings-records').value};localStorage.setItem(key(),JSON.stringify(preferences));$('theme-mode').value=$('settings-theme').value;$('theme-mode').dispatchEvent(new Event('change'));await library.preferences(preferences);$('settings-feedback').textContent='Preferences saved.';}catch(error){notice(error.message,true);$('settings-feedback').textContent='Preferences could not be saved.';}};
12
- $('settings-templates').onclick=()=>{$('settings-dialog').close();$('open-templates').click();};$('settings-dialog').showModal();
6
+ let sequence=0;
7
+ function readPreferences(owner){try{const p=JSON.parse(localStorage.getItem(key(owner))??'{}');return {value:{sort:['updated','title','path'].includes(p.sort)?p.sort:'updated',archived:p.archived===true,records:p.records===''?'':'pending'}};}catch(error){return {value:{...defaults},error};}}
8
+ const read=()=>readPreferences(checkout()).value;
9
+ const detail=error=>error?.message||'Please try again.';
10
+ async function show(){
11
+ const own=++sequence,owner=checkout(),prefs=readPreferences(owner),current=()=>own===sequence&&owner===checkout()&&$('settings-dialog').open;
12
+ $('settings-checkout').textContent=$('checkout').textContent;
13
+ $('settings-content').innerHTML=`<section class="settings-section"><h3>Preferences</h3><form id="settings-preferences"><div class="settings-fields"><label>Appearance<select id="settings-theme"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select></label><label>Default document order<select id="settings-sort"><option value="updated">Recently updated</option><option value="title">Title</option><option value="path">File path</option></select></label><label>Default records view<select id="settings-records"><option value="pending">Needs attention</option><option value="">All statuses</option></select></label><label class="settings-check"><input id="settings-archived" type="checkbox"> Include archived documents by default</label></div><button class="primary">Save preferences</button><p id="settings-feedback" role="status"></p></form></section><section class="settings-section"><h3>Templates</h3><button id="settings-templates" class="quiet">Open templates</button></section><details id="settings-advanced" class="settings-section"><summary>Advanced</summary><p class="muted">Preferences stay ${desktop?'on this computer':'in this browser'} for this checkout.</p><section id="settings-semantic-section" class="settings-section"></section><section id="settings-repository-section" class="settings-section"></section></details>`;
14
+ const feedback=$('settings-feedback'),form=$('settings-preferences'),save=form.querySelector('button');
15
+ $('settings-theme').value=$('theme-mode').value;$('settings-sort').value=prefs.value.sort;$('settings-archived').checked=prefs.value.archived;$('settings-records').value=prefs.value.records;
16
+ if(prefs.error)feedback.textContent=`Saved preferences could not be read: ${detail(prefs.error)} Defaults are shown. Try saving again.`;
17
+ form.onsubmit=async event=>{
18
+ event.preventDefault();if(!current()||save.disabled)return;
19
+ const preferences={sort:$('settings-sort').value,archived:$('settings-archived').checked,records:$('settings-records').value},theme=$('settings-theme').value;
20
+ save.disabled=true;feedback.textContent='';
21
+ try{localStorage.setItem(key(owner),JSON.stringify(preferences));}
22
+ catch(error){feedback.textContent=`Preferences could not be saved: ${detail(error)} Try again.`;save.disabled=false;return;}
23
+ let appearanceError;
24
+ try{localStorage.setItem('runlist:appearance',theme);$('theme-mode').value=theme;$('theme-mode').dispatchEvent(new Event('change'));}catch(error){appearanceError=error;}
25
+ let refreshError;try{await library.preferences(preferences);}catch(error){refreshError=error;}
26
+ if(current()){
27
+ feedback.textContent=[appearanceError?`Document preferences saved. Appearance could not be saved: ${detail(appearanceError)} Try again.`:'Preferences saved.',refreshError?`Library could not refresh: ${detail(refreshError)} Use Refresh in the library.`:''].filter(Boolean).join(' ');
28
+ save.disabled=false;
29
+ }
30
+ };
31
+ $('settings-templates').onclick=()=>{sequence++;$('settings-dialog').close();$('open-templates').click();};
32
+ $('settings-dialog').showModal();
33
+ async function optional(sectionId,title,route,render){
34
+ const section=$(sectionId);section.innerHTML=`<h3>${title}</h3><p role="status">Loading…</p>`;
35
+ try{const value=await api(route);if(current())render(section,value);}
36
+ catch(error){if(!current())return;section.innerHTML=`<h3>${title}</h3><p id="${sectionId}-feedback" role="status">Could not load: ${esc(detail(error))}</p><button id="${sectionId}-retry" class="quiet">Retry</button>`;section.querySelector('button').onclick=()=>optional(sectionId,title,route,render);}
37
+ }
38
+ const tool=optional('settings-semantic-section','Semantic search · experimental','semantic/settings',(section,tool)=>{
39
+ section.innerHTML=`<h3>Semantic search · experimental</h3><p class="muted">${esc(tool.message)} Leave paths blank for automatic discovery.</p><form id="settings-semantic"><div class="settings-fields"><label>External Node executable<input id="settings-semantic-node" autocomplete="off" placeholder="Absolute path to node"></label><label>gmax entry file<input id="settings-semantic-entry" autocomplete="off" placeholder="Absolute path to grepmax/dist/bin.js"></label></div><button class="quiet">Save tool paths</button><p id="settings-semantic-feedback" role="status"></p></form>`;
40
+ const node=$('settings-semantic-node'),entry=$('settings-semantic-entry'),form=$('settings-semantic'),feedback=$('settings-semantic-feedback'),save=form.querySelector('button');node.value=tool.nodePath??'';entry.value=tool.entryPath??'';
41
+ form.onsubmit=async event=>{event.preventDefault();if(!current()||save.disabled)return;save.disabled=true;feedback.textContent='';try{const saved=await api('semantic/settings',{nodePath:node.value.trim(),entryPath:entry.value.trim()});if(current())feedback.textContent=saved.message;}catch(error){if(current())feedback.textContent=`Tool paths could not be saved: ${detail(error)} Try again.`;}finally{if(current())save.disabled=false;}};
42
+ });
43
+ const repository=optional('settings-repository-section','Checkout rules','settings',(section,info)=>{
44
+ if(info.checkout)$('settings-checkout').textContent=info.checkout;
45
+ section.innerHTML=`<h3>Checkout rules</h3><p class="muted">${esc(info.configFile??'Built-in defaults')}</p><dl class="settings-paths"><dt>Document roots</dt><dd>${info.roots.map(p=>`<code>${esc(p)}</code>`).join(' ')}</dd><dt>Excluded folders</dt><dd>${info.exclusions.map(p=>`<code>${esc(p)}</code>`).join(' ')}</dd></dl>${info.statuses.typeOrder.map(type=>`<details class="settings-rules"><summary>${esc(type)} · ${info.statuses.byType[type].length} statuses</summary><div class="settings-table"><table><thead><tr><th>Status</th><th>Context</th><th>Behavior</th></tr></thead><tbody>${info.statuses.byType[type].map(s=>`<tr><td>${esc(s.name)}</td><td>${esc(s.context)}</td><td>${esc([s.startable?'Can start':null,s.terminal?'Terminal':null,s.archive?'Archives':null,s.filed?`Files in ${s.filed}`:null,s.staleDays?`Stale after ${s.staleDays} days`:null].filter(Boolean).join(' · ')||'Ordinary')}</td></tr>`).join('')}</tbody></table></div></details>`).join('')}<h3>Where changes live</h3><dl class="settings-paths">${Object.entries(info.sharing).map(([name,value])=>`<dt>${esc(name)}</dt><dd>${esc(value)}</dd>`).join('')}</dl>`;
46
+ });
47
+ await Promise.all([tool,repository]);
13
48
  }
14
- $('open-settings').onclick=()=>show().catch(error=>notice(error.message,true));$('settings-close').onclick=()=>$('settings-dialog').close();return {read};
49
+ $('open-settings').onclick=show;
50
+ $('settings-close').onclick=()=>{sequence++;$('settings-dialog').close();};
51
+ $('settings-dialog').addEventListener('cancel',()=>sequence++);
52
+ return {read};
15
53
  }
@@ -4,20 +4,28 @@ export function templateEditor({api,$,checkout,notice}) {
4
4
  const drafts=new Map();
5
5
  const key=name=>`runlist:template-draft:${checkout()}:${name}`;
6
6
  const current=()=>drafts.get(selected.name);
7
- function keep() {if(!selected?.editable)return;try{localStorage.setItem(key(selected.name),JSON.stringify(current()));}catch{feedback('Template draft could not be saved in local editor storage. Keep this dialog open.',true);}}
8
- function feedback(text,error=false){$('template-feedback').textContent=text;$('template-feedback').classList.toggle('error',error);}
7
+ function keep() {if(!selected?.editable)return;try{localStorage.setItem(key(selected.name),JSON.stringify(current()));return true;}catch{feedback('Template draft could not be saved in local editor storage. Keep this dialog open.',true);return false;}}
8
+ function feedback(text,error=false){$('template-feedback').hidden=!text;$('template-feedback').textContent=text;$('template-feedback').classList.toggle('error',error);}
9
9
  function dirty(){const d=current();return selected?.editable&&(d.source!==d.base||d.reset);}
10
10
  function render() {
11
11
  if(!selected)return;
12
- const d=current();$('template-name').textContent=selected.name;$('template-origin').textContent=selected.origin;$('template-description').textContent=selected.description;
12
+ const d=current(), handoff=selected.kind==='handoff';$('template-name').textContent=selected.label??selected.name;$('template-origin').textContent=selected.origin;$('template-description').textContent=selected.description;
13
13
  $('template-config-note').hidden=!selected.configuredSource;$('template-config').hidden=!selected.configuredSource;$('template-config-source').textContent=selected.configuredSource??'';
14
+ $('template-config-label').textContent=selected.configLabel??'Repository JavaScript source';
15
+ $('template-config-note').textContent=selected.configNote??'Saving here replaces the configured template with this Markdown.';
16
+ $('template-tokens').textContent=handoff
17
+ ? selected.name==='handoff:subagent'?'Keep parent_prompt: {{parent_prompt}} in frontmatter. The CLI fills it from --parent or RUNLIST_PARENT_PROMPT.':'No placeholders required. Keep only the context the next session needs.'
18
+ :'Placeholders: {{title}}, {{status}}, {{date}}, {{body}}, {{version}}. Keep the metadata fields and body placeholder.';
19
+ $('template-save-note').textContent=handoff
20
+ ?'Used for future handoff drafts. Saved in runlist.templates.json.'
21
+ :'Used for new documents. Saved in runlist.templates.json.';
14
22
  $('template-source').value=d.source;$('template-source').readOnly=!selected.editable;$('template-source').hidden=preview;
15
23
  $('template-preview').hidden=!preview;
16
24
  $('template-preview-tab').disabled=!selected.editable;
17
25
  $('template-edit-tab').setAttribute('aria-pressed',String(!preview));$('template-preview-tab').setAttribute('aria-pressed',String(preview));
18
- const example=d.source.replace(/\{\{(title|status|date|body|version)\}\}/g,(_,token)=>({title:'Example document',status:selected.name==='prompt'?'pending':selected.name==='plan'?'planned':'active',date:new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),body:'Authored content goes here.',version:catalog.version})[token]);
26
+ const example=d.source.replace(/\{\{(title|status|date|body|version|parent_prompt)\}\}/g,(_,token)=>({title:'Example document',status:selected.name==='prompt'?'pending':selected.name==='plan'?'planned':'active',date:new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),body:'Authored content goes here.',version:catalog.version,parent_prompt:'resume-example-session'})[token]);
19
27
  $('template-preview').innerHTML=markdownHtml(example);
20
- $('template-review-button').disabled=!dirty()||busy;$('template-reset').hidden=!selected.editable||(!selected.overridden&&!selected.configuredSource);
28
+ $('template-review-button').disabled=!dirty()||busy;$('template-reset').hidden=!selected.editable||(!selected.overridden&&(!selected.configuredSource||handoff));
21
29
  $('template-discard').disabled=!dirty()||busy;$('template-tokens').hidden=!selected.editable;
22
30
  $('template-review-button').hidden=!selected.editable;$('template-save').hidden=true;$('template-review').hidden=true;
23
31
  $('template-save').disabled=busy;$('template-source').disabled=busy;
@@ -30,18 +38,20 @@ export function templateEditor({api,$,checkout,notice}) {
30
38
  let saved;try{saved=JSON.parse(localStorage.getItem(key(template.name)));}catch{}
31
39
  drafts.set(template.name,saved&&typeof saved.source==='string'&&typeof saved.base==='string'&&('revision'in saved)?saved:{source:template.source,base:template.source,revision:catalog.revision,reset:false});
32
40
  }
33
- render();feedback(!template.editable?'JavaScript template · edit this source in runlist.config.mjs.':dirty()?'Your template draft is kept. Review it before saving.':template.configuredSource&&!template.overridden?'The editor contains a Markdown override starter; the JavaScript template stays active until you save.':'Choose Edit Markdown or Preview example.');
41
+ // Saving a different template changes the store revision, not this base.
42
+ if(current().base===template.source)current().revision=catalog.revision;
43
+ render();feedback(!template.editable?'Edit this JavaScript template in runlist.config.mjs.':dirty()?'Unsaved changes':'');
34
44
  }
35
- function list(){ $('template-list').replaceChildren(...catalog.templates.map(template=>{const button=document.createElement('button');button.className='quiet';button.dataset.name=template.name;button.textContent=template.name;button.onclick=()=>select(template);return button;})); }
45
+ function list(){ $('template-list').replaceChildren(...catalog.templates.map(template=>{const button=document.createElement('button');button.className='quiet';button.dataset.name=template.name;button.textContent=template.label??template.name;button.onclick=()=>select(template);return button;})); }
36
46
  $('open-templates').onclick=async()=>{try{catalog=await api('templates');list();select(catalog.templates.find(t=>t.name===selected?.name)??catalog.templates.find(t=>t.name==='plan')??catalog.templates[0]);$('template-dialog').showModal();}catch(error){notice(error.message,true);}};
37
47
  $('close-templates').onclick=()=>{$('template-dialog').close();};
38
48
  $('template-dialog').addEventListener('cancel',event=>{if(busy)event.preventDefault();});
39
- $('template-source').oninput=()=>{const d=current();d.source=$('template-source').value;d.reset=false;keep();$('template-save').hidden=true;$('template-review').hidden=true;$('template-review-button').disabled=!dirty();feedback('Template draft kept in local editor storage.');};
49
+ $('template-source').oninput=()=>{const d=current();d.source=$('template-source').value;d.reset=false;const kept=keep();$('template-save').hidden=true;$('template-review').hidden=true;$('template-review-button').hidden=false;$('template-review-button').disabled=!dirty();$('template-discard').disabled=!dirty();if(kept)feedback(dirty()?'Unsaved changes':'');};
40
50
  $('template-edit-tab').onclick=()=>{preview=false;render();};$('template-preview-tab').onclick=()=>{preview=true;render();};
41
- $('template-review-button').onclick=()=>{const d=current();$('template-diff').innerHTML=diffHtml(d.base,d.source);$('template-review').hidden=false;$('template-review').open=true;$('template-save').hidden=false;$('template-save').disabled=!dirty();feedback(d.reset?'Save will remove the Markdown override and use the original template.':'Review this template before saving it for future documents.');};
42
- $('template-reset').onclick=()=>{const d=current();d.source=selected.defaultSource;d.reset=true;keep();render();feedback('Review and save to remove the Markdown override and use the original template.');};
51
+ $('template-review-button').onclick=()=>{const d=current();$('template-diff').innerHTML=diffHtml(d.base,d.source);$('template-review').hidden=false;$('template-review').open=true;$('template-save').hidden=false;$('template-save').disabled=!dirty();$('template-review-button').hidden=true;feedback(d.reset?'Saving restores the original template.':'');};
52
+ $('template-reset').onclick=()=>{const d=current();d.source=selected.defaultSource;d.reset=true;const kept=keep();render();if(kept)feedback('Review and save to restore the original template.');};
43
53
  $('template-discard').onclick=()=>{drafts.set(selected.name,{source:selected.source,base:selected.source,revision:catalog.revision,reset:false});localStorage.removeItem(key(selected.name));render();feedback('Template draft discarded.');};
44
- $('template-reload').onclick=async()=>{try{catalog=await api('templates');const name=selected.name;selected=catalog.templates.find(t=>t.name===name);const d=current();d.base=selected.source;d.revision=catalog.revision;keep();list();render();$('template-reload').hidden=true;feedback('Saved template reloaded. Your draft is kept; compare and review it before saving.');}catch(error){feedback(error.message,true);}};
45
- $('template-save').onclick=async()=>{if(busy||!dirty())return;const name=selected.name,d=current();busy=true;$('template-save').disabled=true;$('template-dialog').querySelector('.template-layout').inert=true;$('close-templates').disabled=true;try{catalog=await api('templates/save',{name,source:d.source,expectedRevision:d.revision,reset:d.reset});selected=catalog.templates.find(t=>t.name===name);drafts.delete(name);localStorage.removeItem(key(name));busy=false;list();select(selected);feedback('Template saved to runlist.templates.json. New documents use it.');}catch(error){feedback(error.message,true);keep();if(error.code==='template-conflict')$('template-reload').hidden=false;}finally{busy=false;$('template-dialog').querySelector('.template-layout').inert=false;$('template-save').disabled=false;$('close-templates').disabled=false;}};
54
+ $('template-reload').onclick=async()=>{try{catalog=await api('templates');const name=selected.name;selected=catalog.templates.find(t=>t.name===name);const d=current();d.base=selected.source;d.revision=catalog.revision;keep();list();render();$('template-reload').hidden=true;feedback('Saved template reloaded. Your draft is kept.');}catch(error){feedback(error.message,true);}};
55
+ $('template-save').onclick=async()=>{if(busy||!dirty())return;const name=selected.name,d=current();busy=true;$('template-save').disabled=true;$('template-dialog').querySelector('.template-layout').inert=true;$('close-templates').disabled=true;try{catalog=await api('templates/save',{name,source:d.source,expectedRevision:d.revision,reset:d.reset});selected=catalog.templates.find(t=>t.name===name);drafts.delete(name);localStorage.removeItem(key(name));busy=false;list();select(selected);feedback('Template saved.');}catch(error){feedback(error.message,true);keep();if(error.code==='template-conflict')$('template-reload').hidden=false;}finally{busy=false;$('template-dialog').querySelector('.template-layout').inert=false;$('template-save').disabled=false;$('close-templates').disabled=false;}};
46
56
  document.addEventListener('keydown',event=>{if($('template-dialog').open&&(event.metaKey||event.ctrlKey)&&event.key==='s'){event.preventDefault();$('template-review-button').click();}});
47
57
  }
@@ -21,8 +21,7 @@ export async function mountDesktop({beforeLeave,bootstrap,notice}) {
21
21
  const $=id=>document.getElementById(id);
22
22
  $('connect-panel').hidden=true;$('desktop-welcome').hidden=false;$('desktop-open').hidden=false;
23
23
  $('disconnect').textContent='Close folder';
24
- $('bookmark-empty').textContent='Star a heading to keep it here. Bookmarks stay in Runlist on this computer.';
25
- const footer=document.querySelector('.sidebar-footer');footer.replaceChildren(document.createTextNode('Local files · This computer only'),document.createElement('br'));const label=document.createElement('span');label.textContent='Save here. Review local commits in Changes.';footer.append(label);
24
+ document.querySelector('.sidebar-footer').textContent='';
26
25
  async function recent(){const state=await invoke('desktop_status');const root=$('desktop-recents');root.replaceChildren();for(const item of state.recent){const button=document.createElement('button');button.className='quiet recent-checkout';button.textContent=item.path;button.onclick=()=>choose(item.id);root.append(button);}return state;}
27
26
  let moving=false;
28
27
  async function choose(id){if(moving)return;moving=true;try{
package/bin/runlist.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  import { existsSync, readFileSync, statSync } from 'node:fs';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import path from 'node:path';
6
- import { resolveConfig } from '../src/config.mjs';
6
+ import { resolveConfig, locateConfig } from '../src/config.mjs';
7
7
  import { readPlanStage } from '../src/stages.mjs';
8
8
  import { scopeFilingCoverage } from '../src/filing.mjs';
9
9
  import { die, warn, levenshtein, isArchivedPath, toRepoPath } from '../src/util.mjs';
@@ -285,7 +285,7 @@ Analyze:
285
285
  record <file> <action> --note <reason> Act on a native flag or decision
286
286
  decision <file> <id> --disposition <state> --note <reason> Record an outcome
287
287
  decisions [doc|id] [--all|--check] Open and held decisions, read from the corpus
288
- show <file...> [--json] One document's card: status, next step, related plans and docs, links
288
+ show <file...> [--section <h>] One document's card, outline and open boxes with line numbers; one section
289
289
  xref <file...> | --check [--flag] Cross-references: what names it, its decisions, its cited code
290
290
 
291
291
  Validate & Fix:
@@ -893,6 +893,8 @@ Modes:
893
893
  when a verb says "No authoritative session identity",
894
894
  or on any host runlist has never been tried on.
895
895
  --session --json Machine-readable identity + host-integration state.
896
+ --prompts Read-only parent-link findings: linked, inferred, missing, archived,
897
+ ambiguous or invalid. Supports --json; never consumes or repairs.
896
898
  --statuses Read-only diagnostic: detect overloaded status
897
899
  buckets where one status holds plans pursuing
898
900
  multiple distinct unstuck-actions. Suggests how
@@ -1295,6 +1297,8 @@ Subcommands:
1295
1297
  show <file-or-slug> Read-only peek: print the body WITHOUT consuming
1296
1298
  (triage). \`peek\` is an alias.
1297
1299
  archive <file-or-slug> Archive a prompt without printing its body
1300
+ link <child...> --parent <resume-name>
1301
+ Attach existing children without replacing their handoffs
1298
1302
  new <slug> [body] Create a new prompt (alias for
1299
1303
  \`runlist new prompt <slug> [body]\`)
1300
1304
 
@@ -1308,6 +1312,14 @@ Pass \`--no-claim\` to read and archive the prompt without starting the plan.
1308
1312
 
1309
1313
  Default prompt statuses: pending, archived.
1310
1314
 
1315
+ The default list shows [+ N subagent prompts] on their resume-* parent's line.
1316
+ Use --verbose to expand their names and targets.
1317
+ Record the parent with \`baton --subagent <task> --parent resume-<name>\`;
1318
+ legacy child names sharing the parent's name stem also group. Missing-parent
1319
+ children share a separate count. Grouping does not change which prompt is consumed next.
1320
+ Showing or consuming a parent also lists its pending children for inspection;
1321
+ the children remain pending until explicitly consumed or archived.
1322
+
1311
1323
  Examples:
1312
1324
  runlist prompts # pending prompts (default)
1313
1325
  runlist prompts list --verbose # one row per prompt + target plan ref
@@ -1350,6 +1362,8 @@ Options:
1350
1362
  --message / --body Inline body (one-liners; prefer @path or stdin)
1351
1363
  --force Recover another session's plan (explicit path required)
1352
1364
  --subagent Save subagent-<name> instead of the parent default resume-<name>
1365
+ --parent <resume-name> Group this --subagent handoff under its parent in prompts listings
1366
+ --template Print a resume draft (or --subagent draft), using saved Markdown settings
1353
1367
  --replace Replace exactly one pending handoff; archive its prior text
1354
1368
  --done Report a plan handoff done once next_step changed after the prompt was saved
1355
1369
  --json Structured repository/session/generated file result
@@ -1362,9 +1376,12 @@ Repo-specific commit hint (runlist.config.mjs):
1362
1376
  The function returns argv; baton shell-quotes each argument before printing.
1363
1377
 
1364
1378
  Examples:
1379
+ runlist baton --template > /tmp/resume.md
1365
1380
  runlist baton @/tmp/draft.md
1366
1381
  runlist baton checkout-fixes @/tmp/draft.md
1367
1382
  runlist baton --subagent totals-review @/tmp/draft.md
1383
+ runlist baton --subagent totals-review --parent resume-session-name @/tmp/draft.md
1384
+ runlist baton --subagent --template --parent resume-session-name > /tmp/child.md
1368
1385
  runlist baton docs/plans/auth.md @/tmp/draft.md
1369
1386
  runlist baton docs/plans/auth.md @/tmp/new-draft.md --replace
1370
1387
  runlist baton --status paused --note "blocked on review" @/tmp/d.md
@@ -1396,6 +1413,15 @@ than releasing the parent plan. A child owning its own plan can hand it off.
1396
1413
  Matching prefixes are kept once; conflicting or nested prefixes are refused.
1397
1414
  Existing prompts keep their names. --subagent --replace refuses a parent-named
1398
1415
  handoff rather than reclassifying it.
1416
+ Use --parent resume-<name> to record the owning parent prompt (it may be saved
1417
+ later). Prompt listings show linked child counts on that parent's line; legacy child
1418
+ names sharing its name stem also group. Consumption order is unchanged.
1419
+ The child draft template covers Task, State, Next, Evidence and Boundaries.
1420
+ Its parent_prompt field travels with the draft. A launcher can instead pass
1421
+ RUNLIST_PARENT_PROMPT to the child; explicit --parent wins and replacement
1422
+ preserves a retained link. An unlinked child save warns with a repair command.
1423
+ Keep the full record at a durable repo-relative path, with commits and checked
1424
+ results. On completion, say what the parent must record and what remains.
1399
1425
 
1400
1426
  Slug mode (no plan involved) saves resume-<slug> by default without a status change or
1401
1427
  commit. A bare word that names a plan is treated as that plan.
@@ -1545,18 +1571,32 @@ remain owned by their CLI sessions; coordinate/release them before a human save.
1545
1571
  Metadata and lifecycle changes continue through the CLI. Ctrl+C stops the app.
1546
1572
  --dry-run previews startup without opening a server or writing editor state.
1547
1573
  `,
1548
- show: `runlist show <file...> — one document's card, read-only
1549
-
1550
- runlist show <file> title, status, next step, blockers, checklist,
1551
- related plans and docs, documents linked from the body
1552
- runlist show <a> <b> --json [{ path, type, title, status, summary, currentState,
1553
- nextStep, blockers, checklist, updated,
1554
- related: [{ field, ref, path, exists, title, status, type }],
1555
- links: [{ path, title, status, type }] }]
1556
-
1557
- A file is a path, filename or slug, as for \`use\`. Nothing is claimed or
1558
- written. With --json a file that is not found is { path, error } rather than
1559
- an exit.`,
1574
+ show: `runlist show <file...> — a document in a few KB, read-only
1575
+
1576
+ runlist show <file> card (title, status, next step, blockers, related
1577
+ plans and docs, body links), then the outline and
1578
+ open boxes with line numbers, then the other
1579
+ frontmatter fields by name
1580
+ runlist show <file> --section "<heading>"
1581
+ that section, every line numbered, to the next
1582
+ heading at the same or a higher level
1583
+ --from <line> continue a section read that was cut
1584
+ --all every open box, not the first 30
1585
+ --card the card only
1586
+ --frontmatter the frontmatter as written, long values collapsed
1587
+ --json the same as data; a section read is
1588
+ { path, query, heading, from, to, next, rows, others }
1589
+
1590
+ Read a plan or long doc this way, then one section at a time, never whole. A
1591
+ heading matches case-insensitively: exact first, then as a prefix, then as a
1592
+ substring; other matches are named. A section read stops at 500 lines or
1593
+ 60 KB and prints the command that continues it. Headings and boxes inside code
1594
+ fences are ignored. Several files at once get a shorter outline each.
1595
+
1596
+ A file is a path, filename or slug, as for \`use\`, and any Markdown file in
1597
+ the repo works. Nothing is claimed or written. With --json a file that is not
1598
+ found is { path, error } rather than an exit; a heading that matches nothing
1599
+ exits 1 and lists the headings.`,
1560
1600
 
1561
1601
  model: `runlist model — the local model behind summaries and lint
1562
1602
 
@@ -1909,7 +1949,7 @@ async function main() {
1909
1949
  const doctorSubMode = command === 'doctor' && (
1910
1950
  args.includes('--statuses') || args.includes('--migrate-template')
1911
1951
  || args.includes('--migrate-prompts') || args.includes('--frontmatter-fix')
1912
- || args.includes('--project')
1952
+ || args.includes('--project') || args.includes('--prompts')
1913
1953
  );
1914
1954
  const doctorExplicitApply = args.includes('--apply') || args.includes('--yes');
1915
1955
  const effectiveDryRun = dryRun || (command === 'doctor' && !doctorSubMode && !doctorExplicitApply);
@@ -1948,6 +1988,15 @@ async function main() {
1948
1988
  return;
1949
1989
  }
1950
1990
 
1991
+ if (command === 'baton' && restArgs.includes('--template')) {
1992
+ _suppressObservability = true;
1993
+ requireCommandPolicy(command, dispatchPolicy);
1994
+ try { restArgs = validateCommandArgs(command, restArgs); } catch (err) { die(err.message); }
1995
+ const { runBaton } = await import('../src/baton.mjs');
1996
+ await runBaton(restArgs, locateConfig(process.cwd(), explicitConfig), { dryRun });
1997
+ return;
1998
+ }
1999
+
1951
2000
  if (command === 'desktop') {
1952
2001
  requireCommandPolicy(command, dispatchPolicy);
1953
2002
  try { restArgs=validateCommandArgs(command,restArgs); } catch(err) { die(err.message); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runlist",
3
- "version": "0.96.0",
3
+ "version": "0.97.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runlist-codex",
3
- "version": "0.96.0",
3
+ "version": "0.97.0",
4
4
  "description": "Runlist workflow skill, session orientation, and tool guard for Codex",
5
5
  "author": {
6
6
  "name": "Reo Owens"