runlist 0.0.0 → 0.94.2
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.
- package/LICENSE +21 -0
- package/README.md +1112 -6
- package/assets/app/app.css +283 -0
- package/assets/app/app.mjs +381 -0
- package/assets/app/block-editor.mjs +182 -0
- package/assets/app/block-model.mjs +52 -0
- package/assets/app/document-create.mjs +86 -0
- package/assets/app/document-lifecycle.mjs +36 -0
- package/assets/app/document-stages.mjs +22 -0
- package/assets/app/document-views.mjs +33 -0
- package/assets/app/document-yardstick.mjs +258 -0
- package/assets/app/editor-navigation.mjs +51 -0
- package/assets/app/filing-navigation.mjs +138 -0
- package/assets/app/git-changes.mjs +80 -0
- package/assets/app/git-commit.mjs +46 -0
- package/assets/app/index.html +70 -0
- package/assets/app/library-navigation.mjs +93 -0
- package/assets/app/outline.mjs +18 -0
- package/assets/app/quick-navigation.mjs +52 -0
- package/assets/app/record-navigation.mjs +78 -0
- package/assets/app/recovery-center.mjs +53 -0
- package/assets/app/semantic-search.mjs +14 -0
- package/assets/app/settings.mjs +15 -0
- package/assets/app/shared.mjs +104 -0
- package/assets/app/stage-source.mjs +75 -0
- package/assets/app/template-editor.mjs +47 -0
- package/assets/app/theme.mjs +25 -0
- package/assets/app/transport.mjs +43 -0
- package/assets/contracts/source-editing-v1.md +232 -0
- package/assets/opencode/plugin.js +162 -0
- package/assets/schemas/runlist-record-v1.schema.json +912 -0
- package/bin/dotmd.mjs +5 -0
- package/bin/runlist.mjs +2714 -0
- package/package.json +53 -6
- package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
- package/plugins/runlist-codex/bin/runlist-hook +86 -0
- package/plugins/runlist-codex/hooks/hooks.json +16 -0
- package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
- package/runlist.config.example.mjs +370 -0
- package/scripts/postinstall.mjs +56 -0
- package/src/agent-context.mjs +201 -0
- package/src/ai.mjs +30 -0
- package/src/app-create.mjs +169 -0
- package/src/app-filing.mjs +139 -0
- package/src/app-git-commits.mjs +48 -0
- package/src/app-git.mjs +180 -0
- package/src/app-library.mjs +152 -0
- package/src/app-lifecycle-worker.mjs +12 -0
- package/src/app-lifecycle.mjs +79 -0
- package/src/app-records.mjs +134 -0
- package/src/app-search.mjs +80 -0
- package/src/app-semantic-search.mjs +105 -0
- package/src/app-service.mjs +208 -0
- package/src/app-yardstick.mjs +269 -0
- package/src/app.mjs +102 -0
- package/src/atomic-mutation.mjs +1736 -0
- package/src/baton.mjs +435 -0
- package/src/body-link.mjs +48 -0
- package/src/bulk-tag.mjs +174 -0
- package/src/check-collapse.mjs +75 -0
- package/src/claude-commands.mjs +75 -0
- package/src/cli-editor.mjs +8 -0
- package/src/code-refs.mjs +545 -0
- package/src/codex-integration.mjs +81 -0
- package/src/color.mjs +14 -0
- package/src/commands.mjs +374 -0
- package/src/completions.mjs +93 -0
- package/src/config-edit.mjs +606 -0
- package/src/config.mjs +705 -0
- package/src/decision-action.mjs +58 -0
- package/src/decision.mjs +220 -0
- package/src/decisions.mjs +787 -0
- package/src/deps.mjs +317 -0
- package/src/desktop-install.mjs +158 -0
- package/src/desktop-runtime.mjs +4 -0
- package/src/desktop.mjs +25 -0
- package/src/diff.mjs +101 -0
- package/src/doctor.mjs +591 -0
- package/src/durable-rename.mjs +55 -0
- package/src/errors-read.mjs +44 -0
- package/src/export.mjs +496 -0
- package/src/extractors.mjs +101 -0
- package/src/filing.mjs +255 -0
- package/src/fix-membership.mjs +134 -0
- package/src/fix-refs.mjs +174 -0
- package/src/flags.mjs +318 -0
- package/src/frontmatter-fix.mjs +195 -0
- package/src/frontmatter.mjs +252 -0
- package/src/git-commit-filters.mjs +32 -0
- package/src/git-commit-protocol.mjs +198 -0
- package/src/git-commit-signing.mjs +75 -0
- package/src/git-commit-store.mjs +26 -0
- package/src/git-commit-support.mjs +16 -0
- package/src/git-commit-worker.mjs +32 -0
- package/src/git.mjs +909 -0
- package/src/glossary-check.mjs +35 -0
- package/src/glossary.mjs +243 -0
- package/src/graph.mjs +298 -0
- package/src/guard.mjs +488 -0
- package/src/health.mjs +212 -0
- package/src/hints.mjs +129 -0
- package/src/host-integration.mjs +339 -0
- package/src/hub-membership.mjs +284 -0
- package/src/hub.mjs +296 -0
- package/src/hud.mjs +436 -0
- package/src/index-file.mjs +163 -0
- package/src/index.mjs +478 -0
- package/src/init.mjs +512 -0
- package/src/install.mjs +222 -0
- package/src/journal-read.mjs +116 -0
- package/src/journal.mjs +472 -0
- package/src/lifecycle.mjs +1444 -0
- package/src/lint.mjs +386 -0
- package/src/managed-path.mjs +192 -0
- package/src/markdown-code-spans.mjs +58 -0
- package/src/migrate-prompts.mjs +171 -0
- package/src/migrate-template.mjs +170 -0
- package/src/migrate.mjs +90 -0
- package/src/misuse-read.mjs +61 -0
- package/src/model-request.mjs +33 -0
- package/src/model.mjs +523 -0
- package/src/modules.mjs +355 -0
- package/src/naming.mjs +55 -0
- package/src/native-action.mjs +59 -0
- package/src/native-record.mjs +244 -0
- package/src/new.mjs +1321 -0
- package/src/output-identity.mjs +111 -0
- package/src/parent-plan.mjs +58 -0
- package/src/parse-cache.mjs +99 -0
- package/src/path-containment.mjs +15 -0
- package/src/pickup-card.mjs +293 -0
- package/src/pickup.mjs +671 -0
- package/src/prompt.mjs +25 -0
- package/src/prompts.mjs +585 -0
- package/src/public-inspection.mjs +19 -0
- package/src/query.mjs +977 -0
- package/src/record-action.mjs +11 -0
- package/src/record-create.mjs +82 -0
- package/src/reference-planner.mjs +388 -0
- package/src/reference-values.mjs +11 -0
- package/src/rename.mjs +107 -0
- package/src/render.mjs +678 -0
- package/src/roadmap.mjs +184 -0
- package/src/runlist.mjs +944 -0
- package/src/section.mjs +225 -0
- package/src/semantic-search-client.mjs +58 -0
- package/src/ship.mjs +166 -0
- package/src/show.mjs +101 -0
- package/src/skill-drift.mjs +98 -0
- package/src/source-editor.mjs +491 -0
- package/src/stages.mjs +73 -0
- package/src/state-migration.mjs +123 -0
- package/src/stats.mjs +200 -0
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +721 -0
- package/src/summary.mjs +74 -0
- package/src/surfaces.mjs +28 -0
- package/src/sync-status.mjs +316 -0
- package/src/template-store.mjs +62 -0
- package/src/update.mjs +338 -0
- package/src/use.mjs +58 -0
- package/src/util.mjs +337 -0
- package/src/validate.mjs +795 -0
- package/src/watch.mjs +56 -0
- package/src/xref.mjs +406 -0
- package/src/yardstick-command.mjs +178 -0
- package/src/yardstick.mjs +112 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Block boundaries retain the original source. Only the block being edited is
|
|
2
|
+
// rewritten; gaps, comments, unsupported syntax, and managed history stay raw.
|
|
3
|
+
export function parseBlocks(body) {
|
|
4
|
+
const lines = body.match(/[^\n]*\n|[^\n]+$/g) ?? [];
|
|
5
|
+
const blocks = []; let i = 0, id = 0;
|
|
6
|
+
const line = n => (lines[n] ?? '').replace(/\n$/, '');
|
|
7
|
+
const add = (kind, start, end, extra = {}) => blocks.push({id:String(id++),kind,raw:lines.slice(start,end).join(''),...extra});
|
|
8
|
+
const special = s => /^(?: {0,3}(?:#{1,6}\s|[-*+] |\d+[.)] |`{3,}|~{3,})|>|\s*<!--|\s*$)/.test(s);
|
|
9
|
+
while (i < lines.length) {
|
|
10
|
+
const start = i, s = line(i), heading = s.match(/^( {0,3})(#{1,6})(\s+)(.*?)(\s+#+\s*)?$/);
|
|
11
|
+
if (heading && heading[2].length === 2 && /^Version History\s*$/i.test(heading[4])) { add('history',i,lines.length,{locked:true}); break; }
|
|
12
|
+
if (!s.trim()) { while (i < lines.length && !line(i).trim()) i++; add('gap',start,i); continue; }
|
|
13
|
+
if (/^\s*<!--/.test(s)) { while (i < lines.length && !line(i++).includes('-->')) {} add('comment',start,i); continue; }
|
|
14
|
+
const fence = s.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
|
|
15
|
+
if (fence) {
|
|
16
|
+
i++; while (i < lines.length && !new RegExp(`^ {0,3}${fence[1][0]}{${fence[1].length},}\\s*$`).test(line(i))) i++;
|
|
17
|
+
// Unclosed fences remain source-editable; do not invent a closing fence.
|
|
18
|
+
if (i === lines.length) { add('source',start,i); continue; }
|
|
19
|
+
const end = i++; add('code',start,i,{prefix:lines[start],text:lines.slice(start+1,end).join('').replace(/\n$/,''),suffix:'\n'+lines[end]}); continue;
|
|
20
|
+
}
|
|
21
|
+
if (s.includes('|') && /^\s*\|?\s*:?-{3,}/.test(line(i+1))) { i+=2; while (i<lines.length && line(i).includes('|') && line(i).trim()) i++; add('source',start,i); continue; }
|
|
22
|
+
if (heading) { i++; add('h'+heading[2].length,start,i,{prefix:heading[1]+heading[2]+heading[3],text:heading[4],suffix:(heading[5]??'')+(lines[start].endsWith('\n')?'\n':'')}); continue; }
|
|
23
|
+
const item = s.match(/^( {0,3})([-*+] |\d+[.)] )(?:\[([ xX])\] )?(.*)$/);
|
|
24
|
+
if (item) {
|
|
25
|
+
i++; // Multiline/nested items need a source fallback, rather than losing indentation.
|
|
26
|
+
while (i<lines.length && /^\s{2,}\S/.test(line(i))) i++;
|
|
27
|
+
if (i>start+1) { add('source',start,i); continue; }
|
|
28
|
+
add(item[3]!==undefined?'task':/\d/.test(item[2])?'number':'bullet',start,i,{prefix:item[1]+item[2]+(item[3]!==undefined?`[${item[3]}] `:''),text:item[4],checked:item[3]!==undefined&&item[3]!==' ',suffix:lines[start].endsWith('\n')?'\n':''}); continue;
|
|
29
|
+
}
|
|
30
|
+
if (/^>\s?/.test(s)) { i++; add('quote',start,i,{prefix:s.match(/^>\s?/)[0],text:s.replace(/^>\s?/,''),suffix:lines[start].endsWith('\n')?'\n':''}); continue; }
|
|
31
|
+
if (/^\s*(?:---+|\*\*\*+|___+)\s*$/.test(s)) { i++; add('divider',start,i); continue; }
|
|
32
|
+
i++; while (i<lines.length && !special(line(i)) && !(line(i).includes('|') && /^\s*\|?\s*:?-{3,}/.test(line(i+1)))) i++;
|
|
33
|
+
const raw = lines.slice(start,i).join('');
|
|
34
|
+
add(/^ {4}|^\t|^\s*</.test(s)?'source':'paragraph',start,i,{prefix:'',text:raw.replace(/\n$/,''),suffix:raw.endsWith('\n')?'\n':''});
|
|
35
|
+
}
|
|
36
|
+
return blocks;
|
|
37
|
+
}
|
|
38
|
+
export const blocksSource = blocks => blocks.map(b=>b.raw).join('');
|
|
39
|
+
export function rewriteBlock(block, text) {
|
|
40
|
+
if (block.locked) throw new Error('Managed history is read only.');
|
|
41
|
+
block.text = text;
|
|
42
|
+
block.raw = block.prefix + text + block.suffix;
|
|
43
|
+
return block;
|
|
44
|
+
}
|
|
45
|
+
export const blockPrefixes = {paragraph:'',h1:'# ',h2:'## ',h3:'### ',bullet:'- ',number:'1. ',task:'- [ ] ',quote:'> '};
|
|
46
|
+
export function convertBlock(block, kind) {
|
|
47
|
+
if (!(kind in blockPrefixes) || block.locked || ['source','code','history'].includes(block.kind)) return false;
|
|
48
|
+
block.kind=kind; block.prefix=blockPrefixes[kind]; block.checked=false;
|
|
49
|
+
// ATX heading closing markers belong to the previous block kind.
|
|
50
|
+
block.suffix=block.suffix.endsWith('\n')?'\n':'';
|
|
51
|
+
rewriteBlock(block,block.text); return true;
|
|
52
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import {markdownHtml,escapeHtml as esc,splitSource} from './shared.mjs';
|
|
2
|
+
import {nativeContent} from './record-navigation.mjs';
|
|
3
|
+
|
|
4
|
+
export function documentCreation({$,api,checkout,state,beforeNavigate,open,library,notice,createdRecord}) {
|
|
5
|
+
const dialog=$('create-dialog');let catalog,review=null,request=null,filenameEdited=false,scope='',sequence=0;
|
|
6
|
+
$('create-preview').addEventListener('click',event=>{if(event.target.closest('a'))event.preventDefault();});
|
|
7
|
+
const key=()=>`runlist:create:${scope||checkout()}`;
|
|
8
|
+
const current=()=>{if(scope!==checkout())throw new Error('The checkout changed. This creation review is retained for its original checkout.');};
|
|
9
|
+
const selected=()=>catalog?.templates.find(t=>t.name===$('create-template').value);
|
|
10
|
+
const alternatives=()=>[...$('create-options').children].map(row=>({label:row.querySelector('[data-label]').value,description:row.querySelector('[data-description]').value,consequences:[...row.querySelectorAll('[data-consequence]')].flatMap(field=>field.value.trim()?[{kind:field.dataset.consequence,text:field.value}]:[])}));
|
|
11
|
+
const form=()=>({template:$('create-template').value,title:$('create-name').value,folder:$('create-folder').value,filename:$('create-filename').value,status:$('create-status').value,body:$('create-body').value,...(selected()?.name==='flag'?{severity:$('create-severity').value}:selected()?.name==='decision'?{options:alternatives()}:{})});
|
|
12
|
+
function optionRow(option={}){
|
|
13
|
+
const row=document.createElement('section');row.className='create-alternative';
|
|
14
|
+
row.innerHTML=`<div class="create-alternative-heading"><h3>Alternative</h3><button type="button" class="quiet small" data-remove>Remove alternative</button></div><label>Label<input data-label required maxlength="120" value="${esc(option.label??'')}" placeholder="A short name for this approach"></label><label>Description<textarea data-description required maxlength="4000" rows="3">${esc(option.description??'')}</textarea></label><details><summary>Consequences <span class="muted">optional</span></summary><div class="create-consequences">${['benefit','cost','risk','follow-up'].map(kind=>`<label>${{benefit:'Benefit',cost:'Cost',risk:'Risk','follow-up':'Follow-up'}[kind]}<textarea data-consequence="${kind}" maxlength="4000" rows="2">${esc(option.consequences?.filter(c=>c.kind===kind).map(c=>c.text).join('\n')??'')}</textarea></label>`).join('')}</div></details>`;
|
|
15
|
+
row.querySelector('[data-remove]').onclick=()=>{if($('create-options').children.length>2){row.remove();numberOptions();keep();}};
|
|
16
|
+
for(const field of row.querySelectorAll('input,textarea'))field.oninput=keep;
|
|
17
|
+
return row;
|
|
18
|
+
}
|
|
19
|
+
function numberOptions(){for(const [i,row] of [...$('create-options').children].entries()){row.querySelector('h3').textContent=`Alternative ${i+1}`;row.querySelector('[data-remove]').disabled=$('create-options').children.length<=2;} $('create-option-add').disabled=$('create-options').children.length>=12;}
|
|
20
|
+
function setOptions(options=[{},{}]){$('create-options').replaceChildren(...options.map(optionRow));numberOptions();}
|
|
21
|
+
$('create-option-add').onclick=()=>{if($('create-options').children.length<12){const row=optionRow();$('create-options').append(row);numberOptions();keep();row.querySelector('input').focus();}};
|
|
22
|
+
$('create-severity').onchange=keep;
|
|
23
|
+
function keep(){try{localStorage.setItem(key(),JSON.stringify({form:form(),review,request,at:Date.now()}));return true;}catch{feedback('This new document draft could not be preserved locally. Keep this dialog open or copy its content.',true);return false;}}
|
|
24
|
+
function feedback(message,error=false){$('create-feedback').textContent=message;$('create-feedback').classList.toggle('error',error);}
|
|
25
|
+
function chooseTemplate(){
|
|
26
|
+
const selected=catalog.templates.find(t=>t.name===$('create-template').value);if(!selected)return;
|
|
27
|
+
$('create-status').replaceChildren(...selected.statuses.map(status=>new Option(status,status)));
|
|
28
|
+
$('create-status').value=selected.defaultStatus;$('create-folder').value=selected.folder;
|
|
29
|
+
const native=selected.native===true;
|
|
30
|
+
$('create-name-label').textContent=selected.name==='flag'?'Finding':selected.name==='decision'?'Question':'Title';
|
|
31
|
+
$('create-name').placeholder=native?(selected.name==='flag'?'What did you observe?':'What needs deciding?'):'What is this document about?';
|
|
32
|
+
$('create-folder').readOnly=native;
|
|
33
|
+
for(const [id,type] of [['create-flag-fields','flag'],['create-decision-fields','decision']]){const active=selected.name===type;$(id).hidden=!active;for(const input of $(id).querySelectorAll('input,select,textarea'))input.disabled=!active;}
|
|
34
|
+
if(native&&!$('create-filename').value){$('create-filename').value=crypto.randomUUID()+'.md';filenameEdited=true;}
|
|
35
|
+
$('create-title').textContent=native?`New ${selected.name}`:'New document';
|
|
36
|
+
$('create-preview-button').textContent=native?`Preview ${selected.name}`:'Preview document';
|
|
37
|
+
$('create-origin').textContent=`${selected.origin} · ${selected.description}${selected.note?' '+selected.note:''}`;
|
|
38
|
+
}
|
|
39
|
+
function renderReview(){
|
|
40
|
+
$('create-form').hidden=!!review;$('create-review').hidden=!review;
|
|
41
|
+
if(!review)return;
|
|
42
|
+
$('create-path').textContent=review.path;$('create-source').textContent=review.source;
|
|
43
|
+
// Creation preview has no active document context or privileged link navigation.
|
|
44
|
+
$('create-preview').innerHTML=review.record?`<p class="record-eyebrow">Open${review.record.type==='flag'?` · ${esc(review.record.record_data.severity)} · Unreviewed`:''}</p><h1>${esc(review.record.finding??review.record.question)}</h1>${nativeContent({data:review.record.record_data})}${markdownHtml(splitSource(review.source).body)}`:markdownHtml(review.source);
|
|
45
|
+
$('create-setup').hidden=!review.setup;$('create-setup-source').textContent=review.setup?.source??'';
|
|
46
|
+
$('create-diagnostics').replaceChildren(...(review.diagnostics??[]).map(d=>{const row=document.createElement('li');row.textContent=`${d.level}: ${d.message}`;return row;}));
|
|
47
|
+
$('create-confirm').textContent=review.state==='committed'?(review.record?'Open created record':'Open created document'):review.state==='prepared'?'Retry reviewed creation':review.record?`Create ${review.record.type}`:'Create document';
|
|
48
|
+
$('create-back').disabled=review.state!=='reviewed';$('create-discard').disabled=review.state!=='reviewed';
|
|
49
|
+
$('create-confirm').disabled=review.state==='discarded'||review.state==='prepared'&&review.currentRevision!=null&&review.currentRevision!==review.revision;
|
|
50
|
+
}
|
|
51
|
+
async function show({operationId,template}={}) {
|
|
52
|
+
if(state.busy)throw new Error('Wait for the current operation before creating a record.');
|
|
53
|
+
const own=++sequence,owner=checkout();await beforeNavigate();const result=await api('create');if(own!==sequence||owner!==checkout())return;catalog=result;scope=owner;
|
|
54
|
+
if(template&&!catalog.templates.some(t=>t.name===template))throw new Error(catalog.recordError??'This record template is unavailable.');
|
|
55
|
+
$('create-template').replaceChildren(...catalog.templates.map(t=>new Option(({hub:'Hub',plan:'Plan',flag:'Flag',decision:'Decision'})[t.name]??'Document',t.name)));
|
|
56
|
+
$('create-folders').replaceChildren(...catalog.roots.map(root=>{const option=document.createElement('option');option.value=root;return option;}));
|
|
57
|
+
$('create-form').reset();setOptions();chooseTemplate();filenameEdited=false;review=null;request=null;feedback('');$('create-diagnostics').replaceChildren();
|
|
58
|
+
try {
|
|
59
|
+
const stored=JSON.parse(localStorage.getItem(key())??'null');
|
|
60
|
+
if(stored){if(catalog.templates.some(t=>t.name===stored.form?.template)){$('create-template').value=stored.form.template;chooseTemplate();}for(const [field,id] of Object.entries({title:'create-name',folder:'create-folder',filename:'create-filename',status:'create-status',body:'create-body',severity:'create-severity'}))if(typeof stored.form?.[field]==='string')$(id).value=stored.form[field];if(Array.isArray(stored.form?.options)){setOptions(stored.form.options);chooseTemplate();}review=stored.review;request=stored.request;filenameEdited=!!stored.form?.filename;}
|
|
61
|
+
}catch{feedback('A local new-document draft could not be read. Its stored entry has been kept.',true);}
|
|
62
|
+
if(operationId||review?.operationId){const inspected=await api('create/inspect',{operationId:operationId??review.operationId});if(own!==sequence||owner!==checkout())return;review=inspected;if(!review)feedback('No creation review was found. Your form is retained; preview it again.',true);}
|
|
63
|
+
if(template&&template!==$('create-template').value){if(review||$('create-name').value)feedback('Your retained creation draft is open. Finish or discard it before starting another record.');else{$('create-template').value=template;chooseTemplate();}}
|
|
64
|
+
if(operationId&&review?.record&&!request){$('create-template').value=review.record.type;chooseTemplate();$('create-name').value=review.record.finding??review.record.question;$('create-body').value=splitSource(review.source).body;$('create-folder').value=review.path.slice(0,review.path.lastIndexOf('/'));$('create-filename').value=review.path.split('/').at(-1);if(review.record.type==='flag')$('create-severity').value=review.record.record_data.severity;else setOptions(review.record.record_data.options);}
|
|
65
|
+
$('create-preview-button').disabled=!catalog.templates.length;renderReview();dialog.showModal();(review?$('create-confirm'):$('create-name')).focus();
|
|
66
|
+
if(!catalog.templates.length){$('create-preview-button').disabled=true;feedback('This checkout has no supported document templates. Configure a plan or doc type through the CLI.',true);}
|
|
67
|
+
}
|
|
68
|
+
$('create-template').onchange=()=>{chooseTemplate();keep();};
|
|
69
|
+
$('create-filename').oninput=()=>{filenameEdited=true;keep();};
|
|
70
|
+
$('create-name').oninput=()=>{if(!filenameEdited&&!selected()?.native){const slug=$('create-name').value.toLowerCase().replace(/[\s_]+/g,'-').replace(/[^a-z0-9-]/g,'').replace(/-+/g,'-').replace(/^-|-$/g,'').slice(0,120);$('create-filename').value=slug?slug+'.md':'';}keep();};
|
|
71
|
+
for(const id of ['create-status','create-folder','create-body'])$(id).oninput=keep;
|
|
72
|
+
async function busy(work){if(state.busy)return;if(scope!==checkout()){feedback('The checkout changed. Resume this creation in its original checkout.',true);return;}state.busy=true;$('create-close').disabled=true;for(const button of dialog.querySelectorAll('button'))button.disabled=true;try{await work();}catch(error){feedback(error.message,true);if(error.details?.diagnostics)$('create-diagnostics').replaceChildren(...error.details.diagnostics.map(d=>{const li=document.createElement('li');li.textContent=d.message;return li;}));notice(error.message,true);}finally{state.busy=false;for(const button of dialog.querySelectorAll('button'))button.disabled=false;$('create-preview-button').disabled=!catalog?.templates.length;numberOptions();renderReview();if(scope!==checkout())dialog.close();}}
|
|
73
|
+
$('create-form').onsubmit=event=>{event.preventDefault();return busy(async()=>{$('create-diagnostics').replaceChildren();const fields=form(),{operationId,...previous}=request??{};request={...fields,operationId:operationId&&JSON.stringify(previous)===JSON.stringify(fields)?operationId:crypto.randomUUID()};if(!keep())throw new Error('Preserve the new document draft before reviewing it.');const result=await api('create/preview',request);current();review=result;keep();renderReview();feedback(`Review the new file, then choose ${$('create-confirm').textContent}.`);$('create-confirm').focus();});};
|
|
74
|
+
$('create-back').onclick=()=>busy(async()=>{if(review)await api('create/discard',{operationId:review.operationId});current();review=null;request=null;keep();renderReview();$('create-name').focus();});
|
|
75
|
+
$('create-inspect').onclick=()=>busy(async()=>{const inspected=await api('create/inspect',{operationId:review.operationId});current();review=inspected;keep();feedback(review?`Creation is ${review.state}.${review.currentRevision&&review.currentRevision!==review.revision?' The destination changed and will not be overwritten.':''}`:'No creation review found.');});
|
|
76
|
+
$('create-confirm').onclick=()=>busy(async()=>{
|
|
77
|
+
if(review.state!=='committed') {const committed=await api('create/commit',{operationId:review.operationId});current();review=committed;keep();}
|
|
78
|
+
current();const path=review.path,record=review.record;localStorage.removeItem(key());dialog.close();state.busy=false;await library.load({refresh:true});current();if(record&&createdRecord)await createdRecord(record);else await open(path);notice(record?`Created an open ${record.type}. Ready for review.`:'Created local Markdown. Ready to edit.');
|
|
79
|
+
});
|
|
80
|
+
$('create-discard').onclick=()=>busy(async()=>{if(review)await api('create/discard',{operationId:review.operationId});current();localStorage.removeItem(key());review=null;request=null;dialog.close();notice('New document draft discarded.');});
|
|
81
|
+
$('create-close').onclick=()=>{if(!state.busy&&keep()){sequence++;dialog.close();}};
|
|
82
|
+
dialog.addEventListener('cancel',event=>{if(state.busy||!keep())event.preventDefault();});
|
|
83
|
+
document.addEventListener('keydown',event=>{if(dialog.open&&(event.metaKey||event.ctrlKey)&&event.key.toLowerCase()==='s'){event.preventDefault();if(!state.busy){if(review)$('create-confirm').focus();else $('create-form').requestSubmit();}}});
|
|
84
|
+
$('new-document').onclick=()=>show().catch(error=>notice(error.message,true));
|
|
85
|
+
return {show};
|
|
86
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function documentLifecycle({state,$,api,checkout,open,library,notice}){
|
|
2
|
+
const key=path=>`runlist:lifecycle:${checkout()}:${encodeURIComponent(path)}`,dialog=$('lifecycle-dialog');let pending=null,blocked=false,formEnabled=true;
|
|
3
|
+
const retained=path=>{try{const direct=JSON.parse(localStorage.getItem(key(path))??'null');if(direct)return direct;for(let i=0;i<localStorage.length;i++){const location=localStorage.key(i);if(location.startsWith(`runlist:lifecycle:${checkout()}:`)){const value=JSON.parse(localStorage.getItem(location));if(value?.newPath===path)return value;}}}catch{}return null;};
|
|
4
|
+
async function resolveInitial(initial){const old=retained(initial);if(old&&old.path===initial){const result=await api('lifecycle/inspect',{operationId:old.operationId});if(result?.state==='committed'){library.relocated(old.path,result.newPath);localStorage.removeItem(key(old.path));return result.newPath;}if(result?.current&&result.newPath!==initial)return result.newPath;}return initial;}
|
|
5
|
+
async function show(){if(!state.doc||state.busy)return;const doc=state.doc,info=await api(`lifecycle?path=${encodeURIComponent(doc.path)}`);$('lifecycle-path').textContent=`${doc.title} · ${doc.path}`;const root=$('lifecycle-content');root.replaceChildren();
|
|
6
|
+
const label=document.createElement('p');label.className='muted';label.textContent=`Current status: ${info.current}. Status changes use the CLI’s lifecycle rules, including moves, reference repairs and history.`;root.append(label);
|
|
7
|
+
const form=document.createElement('form');form.id='lifecycle-form';form.innerHTML='<label>New status<select id="lifecycle-status"></select></label><label>Reason<textarea id="lifecycle-note" required rows="3" maxlength="4000" placeholder="What changed and why?"></textarea></label><button id="lifecycle-review-button" class="primary">Review status change</button>';
|
|
8
|
+
for(const status of info.statuses.filter(s=>s!==info.current))form.querySelector('select').add(new Option(status,status));root.append(form);
|
|
9
|
+
blocked=!info.enabled||state.dirty||state.pending;formEnabled=info.enabled;form.hidden=!formEnabled;const message=document.createElement('p');message.id='lifecycle-unavailable';message.className='inline-note';message.setAttribute('role','status');if(blocked){message.textContent=!info.enabled?info.reason:'Save or discard the document draft before changing its lifecycle status.';root.append(message);for(const input of form.querySelectorAll('input,select,textarea,button'))input.disabled=true;}
|
|
10
|
+
const review=document.createElement('section');review.id='lifecycle-review';review.hidden=true;root.append(review);pending=retained(doc.path);if(pending&&![pending.path,pending.newPath].includes(doc.path))pending=null;if(pending)renderReview();
|
|
11
|
+
form.onsubmit=async event=>{event.preventDefault();if(state.busy||blocked)return;state.busy=true;$('lifecycle-close').disabled=true;$('lifecycle-review-button').disabled=true;try{const request={path:doc.path,status:$('lifecycle-status').value,note:$('lifecycle-note').value,expectedRevision:doc.revision,operationId:crypto.randomUUID()};pending={...request,report:'Review not yet received.'};localStorage.setItem(key(pending.path),JSON.stringify(pending));const result=await api('lifecycle/preview',request);pending={...pending,...result};localStorage.setItem(key(pending.path),JSON.stringify(pending));renderReview();}catch(error){if(pending){renderReview();$('lifecycle-feedback').textContent=`${error.message} Inspect this operation, or return to the form to review again.`;}notice(error.message,true);}finally{state.busy=false;$('lifecycle-close').disabled=false;$('lifecycle-review-button').disabled=false;}};
|
|
12
|
+
dialog.showModal();
|
|
13
|
+
}
|
|
14
|
+
function renderReview(){$('lifecycle-form').hidden=true;const root=$('lifecycle-review');root.hidden=false;root.replaceChildren();const heading=document.createElement('h3');heading.textContent=`Change to ${pending.status}`;const report=document.createElement('pre');report.className='lifecycle-report';report.textContent=pending.report;const confirm=document.createElement('button');confirm.id='lifecycle-confirm';confirm.className='primary';confirm.textContent='Apply status change';confirm.disabled=blocked;const back=document.createElement('button');back.className='quiet';back.textContent='Back to form';back.id='lifecycle-back';const inspect=document.createElement('button');inspect.className='quiet';inspect.textContent='Inspect operation';inspect.id='lifecycle-inspect';const feedback=document.createElement('p');feedback.id='lifecycle-feedback';feedback.setAttribute('role','status');root.append(heading,report,confirm,back,inspect,feedback);
|
|
15
|
+
back.onclick=()=>{const previous=pending;localStorage.removeItem(key(pending.path));pending=null;root.hidden=true;$('lifecycle-form').hidden=!formEnabled;$('lifecycle-status').value=previous.status;$('lifecycle-note').value=previous.note;};
|
|
16
|
+
inspect.onclick=async()=>{try{const result=await api('lifecycle/inspect',{operationId:pending.operationId});report.textContent=result?.report??'No lifecycle review was retained. Return to the form and review again.';feedback.textContent=result?`Operation is ${result.state}.`:'No operation found.';if(result?.failure)feedback.textContent+=` ${result.failure.message}`;if(result?.state==='running'){feedback.textContent+=' Inspect CLI transactions before starting another change.';if(result.canAcknowledge)acknowledge(result,root,feedback);else feedback.textContent+=' The runner is still active or cannot be verified as stopped.';}}catch(error){feedback.textContent=error.message;}};
|
|
17
|
+
confirm.onclick=async()=>{if(state.busy||blocked)return;state.busy=true;for(const button of root.querySelectorAll('button'))button.disabled=true;$('lifecycle-close').disabled=true;
|
|
18
|
+
try{const result=await api('lifecycle/commit',{operationId:pending.operationId}),old=pending.path;library.relocated(old,result.newPath);localStorage.removeItem(key(pending.path));pending=null;state.busy=false;dialog.close();await open(result.newPath);await library.load({refresh:true});notice('Lifecycle status saved. Source, references and history follow the CLI rules.');}
|
|
19
|
+
catch(error){feedback.textContent=pending?`${error.message} Your review is retained. Inspect the operation or reload the document before reviewing again.`:`Status saved, but the view could not be refreshed. ${error.message}`;notice(feedback.textContent,true);}
|
|
20
|
+
finally{state.busy=false;for(const button of root.querySelectorAll('button'))button.disabled=false;confirm.disabled=blocked;$('lifecycle-close').disabled=false;}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function acknowledge(result,root,feedback){
|
|
24
|
+
root.querySelector('#lifecycle-acknowledge')?.remove();
|
|
25
|
+
if(!result.current)return;
|
|
26
|
+
const form=document.createElement('form');form.id='lifecycle-acknowledge';
|
|
27
|
+
form.innerHTML='<label>What did you verify?<textarea required maxlength="4000" rows="3" placeholder="Describe the source and CLI transactions you inspected."></textarea></label><p class="muted">Acknowledge this interrupted review after inspecting its source and CLI transactions. Its outcome stays marked uncertain; this will not replay the operation.</p><button class="quiet">Acknowledge inspected outcome</button>';
|
|
28
|
+
root.append(form);form.onsubmit=async event=>{event.preventDefault();if(state.busy)return;state.busy=true;const button=form.querySelector('button');button.disabled=true;$('lifecycle-close').disabled=true;
|
|
29
|
+
try{const settled=await api('lifecycle/settle',{operationId:pending.operationId,expectedRevision:result.current.revision,note:form.querySelector('textarea').value});localStorage.removeItem(key(pending.path));pending=null;state.busy=false;dialog.close();await open(settled.current.path);notice('Interrupted review acknowledged without replay. Its recorded outcome remains uncertain.');}
|
|
30
|
+
catch(error){feedback.textContent=error.message;}
|
|
31
|
+
finally{state.busy=false;button.disabled=false;$('lifecycle-close').disabled=false;}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
async function resume(operationId){const result=await api('lifecycle/inspect',{operationId});if(!result)throw new Error('The lifecycle operation is unavailable; its recovery entry has been kept.');await open(result.current?.path??result.newPath??result.path);if(result.state==='committed'){notice('The status change was already saved.');return;}localStorage.setItem(key(result.path),JSON.stringify(result));await show();}
|
|
35
|
+
$('lifecycle-open').onclick=()=>show().catch(error=>notice(error.message,true));$('lifecycle-close').onclick=()=>{if(!state.busy)dialog.close();};dialog.addEventListener('cancel',e=>{if(state.busy)e.preventDefault();});return {resolveInitial,resume};
|
|
36
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readSourceStage } from './shared.mjs';
|
|
2
|
+
|
|
3
|
+
// Stage is authored metadata. A choice only changes the existing editor draft;
|
|
4
|
+
// the ordinary source review/save flow publishes it with its usual guards.
|
|
5
|
+
export function documentStages({state,$,changed}) {
|
|
6
|
+
const select=$('document-stage'),meaning=$('document-stage-meaning');
|
|
7
|
+
let current={word:null,invalid:false};
|
|
8
|
+
select.onchange=()=>changed(select.value?select.value.slice(5):null);
|
|
9
|
+
return {update(source) {
|
|
10
|
+
const plan=state.doc?.kind==='plan';$('document-stage-fields').hidden=!plan;
|
|
11
|
+
if(!plan)return;
|
|
12
|
+
current=readSourceStage(source);
|
|
13
|
+
const definitions=state.doc.stageDefinitions??[],known=definitions.some(stage=>stage.word===current.word);
|
|
14
|
+
select.replaceChildren(new Option('Unset · no stage',''),...definitions.map(stage=>new Option(stage.word,`word:${stage.word}`)));
|
|
15
|
+
if(current.word&&!known){const option=new Option(`${current.word} · unknown`,`word:${current.word}`);option.disabled=true;select.add(option);}
|
|
16
|
+
if(current.invalid){const option=new Option('Invalid metadata','@invalid');option.disabled=true;select.add(option);}
|
|
17
|
+
select.value=current.invalid?'@invalid':current.word?`word:${current.word}`:'';
|
|
18
|
+
select.disabled=!state.doc.editable||!!state.pending||state.busy;
|
|
19
|
+
const definition=definitions.find(stage=>stage.word===current.word);
|
|
20
|
+
meaning.textContent=current.invalid?'This stage has unsupported metadata. Choose a configured stage or Unset, then review the change.':current.word&&!known?'This stage is not configured in this repository. Its current value is preserved until you choose a replacement.':definition?.meaning||(!definitions.length?'No stages are configured. Define taxonomy.milestones in runlist.config.mjs to choose a stage.':'Stage describes when this plan should ship. Changes stay in your draft until saved.');
|
|
21
|
+
}};
|
|
22
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const panels = {read:'reading', edit:'editing', review:'reviewing', source:'source-view'};
|
|
2
|
+
const modes = Object.keys(panels);
|
|
3
|
+
|
|
4
|
+
// Keep the active tab focusable, associate each tab with its panel, and provide
|
|
5
|
+
// the conventional arrow/Home/End navigation without capturing editor keys.
|
|
6
|
+
export function documentViews({$, view}) {
|
|
7
|
+
$('document-tabs').addEventListener('keydown', event => {
|
|
8
|
+
const current = modes.findIndex(mode => event.target === $('tab-' + mode));
|
|
9
|
+
if (current < 0) return;
|
|
10
|
+
let next;
|
|
11
|
+
if (event.key === 'ArrowRight') next = (current + 1) % modes.length;
|
|
12
|
+
else if (event.key === 'ArrowLeft') next = (current + modes.length - 1) % modes.length;
|
|
13
|
+
else if (event.key === 'Home') next = 0;
|
|
14
|
+
else if (event.key === 'End') next = modes.length - 1;
|
|
15
|
+
else return;
|
|
16
|
+
event.preventDefault();
|
|
17
|
+
// Read-only documents disable editing; skip unavailable tabs in order.
|
|
18
|
+
const step = event.key === 'ArrowLeft' || event.key === 'End' ? -1 : 1;
|
|
19
|
+
for (let tried = 0; tried < modes.length; tried++) {
|
|
20
|
+
const tab = $('tab-' + modes[next]);
|
|
21
|
+
if (!tab.disabled) { view(modes[next]); tab.focus(); return; }
|
|
22
|
+
next = (next + step + modes.length) % modes.length;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
return {update(mode) {
|
|
26
|
+
for (const name of modes) {
|
|
27
|
+
const selected = name === mode, tab = $('tab-' + name);
|
|
28
|
+
tab.setAttribute('aria-selected', String(selected));
|
|
29
|
+
tab.tabIndex = selected ? 0 : -1;
|
|
30
|
+
$(panels[name]).hidden = !selected;
|
|
31
|
+
}
|
|
32
|
+
}};
|
|
33
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Saved Markdown and explicit human choices; no inferred ranking or assessment.
|
|
2
|
+
export function documentYardstick({ state, $, api, checkout, open, library, notice }) {
|
|
3
|
+
const panel = $('yardstick-panel');
|
|
4
|
+
let info = null, pending = null, working = false, sequence = 0, dialog = null, activePath = null;
|
|
5
|
+
const labels = { serves: 'Serves the goal', clear: 'Clear assessment', fold: 'Fold into another plan', close: 'Close plan' };
|
|
6
|
+
const isPlan = () => state.doc?.type ? state.doc.type === 'plan' : state.doc?.kind === 'plan';
|
|
7
|
+
const excerpt = (text, limit = 2000) => text?.length > limit
|
|
8
|
+
? `${text.slice(0, limit).trimEnd()}… [shortened; read the source for the full text]` : text;
|
|
9
|
+
const key = path => `runlist:yardstick:${checkout()}:${encodeURIComponent(path)}`;
|
|
10
|
+
const node = (tag, text, className) => { const element = document.createElement(tag); if (text !== undefined) element.textContent = text; if (className) element.className = className; return element; };
|
|
11
|
+
const retained = path => {
|
|
12
|
+
try {
|
|
13
|
+
const direct = JSON.parse(localStorage.getItem(key(path)) ?? 'null');
|
|
14
|
+
if (direct) return direct;
|
|
15
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
16
|
+
const location = localStorage.key(i);
|
|
17
|
+
if (location.startsWith(`runlist:yardstick:${checkout()}:`)) {
|
|
18
|
+
const value = JSON.parse(localStorage.getItem(location));
|
|
19
|
+
if (value?.newPath === path) return value;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
} catch { /* Disk receipts remain discoverable in Recovery. */ }
|
|
23
|
+
return null;
|
|
24
|
+
};
|
|
25
|
+
const remember = () => {
|
|
26
|
+
try { localStorage.setItem(key(pending.path), JSON.stringify(pending)); }
|
|
27
|
+
catch { notice('This review is retained on disk. Reopen it from Recovery if this window closes.', true); }
|
|
28
|
+
};
|
|
29
|
+
const forget = path => { try { localStorage.removeItem(key(path)); } catch {} };
|
|
30
|
+
const blocked = () => state.dirty || state.pending || state.recovery && (state.recovery.source !== state.recovery.baseSource || state.recovery.pending);
|
|
31
|
+
const unavailable = action => !info?.enabled || !info.actions.includes(action) || blocked() || state.busy || working;
|
|
32
|
+
|
|
33
|
+
function update() {
|
|
34
|
+
if (!panel || !info || activePath !== state.doc?.path) return;
|
|
35
|
+
const note = panel.querySelector('.yardstick-availability');
|
|
36
|
+
if (note) note.textContent = blocked() ? 'Save or discard the plan draft before recording an assessment.' : info.reason ?? '';
|
|
37
|
+
for (const button of panel.querySelectorAll('[data-yardstick-action]')) button.disabled = unavailable(button.dataset.yardstickAction);
|
|
38
|
+
if (dialog?.open) {
|
|
39
|
+
const apply = dialog.querySelector('[data-yardstick-apply]');
|
|
40
|
+
if (apply) apply.disabled = !pending?.comparison || pending.state !== 'reviewed' || unavailable(pending.action);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function render() {
|
|
44
|
+
panel.replaceChildren();
|
|
45
|
+
panel.hidden = !isPlan() || !info?.comparison.goal.enabled;
|
|
46
|
+
if (panel.hidden) return;
|
|
47
|
+
const { goal, delivers, assessment } = info.comparison;
|
|
48
|
+
const details = node('details', undefined, 'yardstick-summary');
|
|
49
|
+
const assessmentLabel = assessment.issues.length ? 'Assessment needs repair'
|
|
50
|
+
: ({ serves: 'Serves the goal', folded: 'Folded into another plan', closed: 'Closed' }[assessment.disposition] ?? 'Not reviewed');
|
|
51
|
+
const summary = node('summary', `Product goal · ${assessmentLabel}`);
|
|
52
|
+
const goalExcerpt = goal.statement?.length > 2000
|
|
53
|
+
? `${goal.statement.slice(0, 2000).trimEnd()}… [shortened; open the goal document for the full text]` : goal.statement;
|
|
54
|
+
const goalText = node('p', goal.state === 'ready' ? goalExcerpt : `Goal unavailable: ${goal.message}`, 'yardstick-goal');
|
|
55
|
+
details.append(summary, goalText);
|
|
56
|
+
if (goal.source) {
|
|
57
|
+
const source = node('button', `Open goal · ${goal.source}`, 'quiet small'); source.type = 'button';
|
|
58
|
+
source.onclick = () => { if (state.busy || working) return; open(goal.source).catch(error => notice(error.message, true)); };
|
|
59
|
+
details.append(source);
|
|
60
|
+
}
|
|
61
|
+
details.append(node('h3', 'This saved plan delivers'), node('p', delivers || 'Unset', 'yardstick-delivers'), node('h3', 'Owner assessment'), node('p', assessmentLabel));
|
|
62
|
+
if (assessment.reason) details.append(node('p', assessment.reason, 'yardstick-reason'));
|
|
63
|
+
if (assessment.into) {
|
|
64
|
+
const replacement = node('button', `Replacement · ${assessment.into}`, 'quiet small'); replacement.type = 'button';
|
|
65
|
+
replacement.onclick = async () => {
|
|
66
|
+
try { const target = await api(`link?${new URLSearchParams({ ref: assessment.into, from: state.doc.path })}`); if (!target) throw new Error('The replacement plan is unavailable.'); await open(target.path); }
|
|
67
|
+
catch (error) { notice(error.message, true); }
|
|
68
|
+
};
|
|
69
|
+
details.append(replacement);
|
|
70
|
+
}
|
|
71
|
+
if (assessment.reviewedEarlierGoal) details.append(node('p', 'Reviewed against an earlier goal. Review it again before keeping this assessment.', 'inline-note'));
|
|
72
|
+
for (const issue of assessment.issues) details.append(node('p', issue, 'inline-note'));
|
|
73
|
+
const actions = node('div', undefined, 'yardstick-actions');
|
|
74
|
+
for (const action of ['serves', 'clear', 'fold', 'close']) {
|
|
75
|
+
const button = node('button', labels[action], action === 'close' || action === 'fold' ? 'quiet' : 'quiet small');
|
|
76
|
+
button.type = 'button'; button.dataset.yardstickAction = action;
|
|
77
|
+
button.onclick = () => show(action).catch(error => notice(error.message, true)); actions.append(button);
|
|
78
|
+
}
|
|
79
|
+
const availability = node('p', undefined, 'yardstick-availability inline-note'); availability.setAttribute('role', 'status');
|
|
80
|
+
details.append(actions, availability); panel.append(details); update();
|
|
81
|
+
}
|
|
82
|
+
async function opened() {
|
|
83
|
+
const path = state.doc?.path, own = ++sequence;
|
|
84
|
+
info = null; activePath = path;
|
|
85
|
+
panel.replaceChildren(); panel.hidden = true;
|
|
86
|
+
if (!isPlan()) return;
|
|
87
|
+
try {
|
|
88
|
+
const result = await api(`yardstick?path=${encodeURIComponent(path)}`);
|
|
89
|
+
if (own !== sequence || state.doc?.path !== path) return;
|
|
90
|
+
info = result; render();
|
|
91
|
+
} catch (error) { if (own === sequence) notice(`Product goal unavailable. ${error.message}`, true); }
|
|
92
|
+
}
|
|
93
|
+
function makeDialog() {
|
|
94
|
+
if (dialog) return dialog;
|
|
95
|
+
dialog = node('dialog', undefined, 'yardstick-dialog'); dialog.id = 'yardstick-dialog';
|
|
96
|
+
dialog.setAttribute('aria-labelledby', 'yardstick-dialog-title'); document.body.append(dialog);
|
|
97
|
+
dialog.addEventListener('cancel', event => { if (working) event.preventDefault(); });
|
|
98
|
+
return dialog;
|
|
99
|
+
}
|
|
100
|
+
function closeDialog() { if (!working) dialog.close(); }
|
|
101
|
+
function dialogShell(title) {
|
|
102
|
+
const root = makeDialog(); root.replaceChildren();
|
|
103
|
+
const heading = node('h2', title); heading.id = 'yardstick-dialog-title';
|
|
104
|
+
const close = node('button', 'Close', 'quiet'); close.type = 'button'; close.dataset.yardstickDismiss = ''; close.onclick = closeDialog;
|
|
105
|
+
const header = node('div', undefined, 'dialog-header'); header.append(heading, close);
|
|
106
|
+
root.append(header, node('p', `${state.doc.title} · ${state.doc.path}`, 'muted'));
|
|
107
|
+
return root;
|
|
108
|
+
}
|
|
109
|
+
async function busyRun(task) {
|
|
110
|
+
working = true; state.busy = true; update();
|
|
111
|
+
for (const button of dialog.querySelectorAll('button')) button.disabled = true;
|
|
112
|
+
try { return await task(); }
|
|
113
|
+
finally { working = false; state.busy = false; for (const button of dialog.querySelectorAll('button')) button.disabled = false; update(); }
|
|
114
|
+
}
|
|
115
|
+
async function show(action, saved = null) {
|
|
116
|
+
if (state.busy || working || !state.doc) return;
|
|
117
|
+
if (saved) { pending = saved; renderReview(); dialog.showModal(); return; }
|
|
118
|
+
if (unavailable(action)) return;
|
|
119
|
+
pending = retained(state.doc.path);
|
|
120
|
+
if (pending) { renderReview(); dialog.showModal(); return; }
|
|
121
|
+
form(action); dialog.showModal(); dialog.querySelector('textarea')?.focus();
|
|
122
|
+
}
|
|
123
|
+
function form(action, previous = {}) {
|
|
124
|
+
const root = dialogShell(labels[action]), form = node('form'); form.className = 'yardstick-form';
|
|
125
|
+
const description = node('p', action === 'clear' ? 'Clear the owner assessment. An archived plan stays archived; execution status is unchanged.'
|
|
126
|
+
: action === 'serves' ? 'Record your assessment that this plan serves the product goal. Execution status is unchanged.'
|
|
127
|
+
: action === 'fold' ? 'Archive this plan and link it to an existing replacement plan. Review the move and reference repairs before applying.'
|
|
128
|
+
: 'Archive this plan with your reason. Review the move and reference repairs before applying.', 'muted');
|
|
129
|
+
const reasonLabel = node('label', action === 'serves' || action === 'clear' ? 'Owner reason (optional)' : 'Owner reason');
|
|
130
|
+
const reason = node('textarea'); reason.rows = 3; reason.maxLength = 4000; reason.required = action === 'fold' || action === 'close'; reason.value = previous.reason ?? ''; reasonLabel.append(reason);
|
|
131
|
+
form.append(description);
|
|
132
|
+
if (action !== 'clear') form.append(reasonLabel);
|
|
133
|
+
let into = null;
|
|
134
|
+
if (action === 'fold') {
|
|
135
|
+
const searchLabel = node('label', 'Find replacement plan'), query = node('input'); query.type = 'search'; query.maxLength = 300; searchLabel.append(query);
|
|
136
|
+
const find = node('button', 'Find plans', 'quiet'); find.type = 'button';
|
|
137
|
+
const targetLabel = node('label', 'Replacement plan'), select = node('select'); select.required = true;
|
|
138
|
+
select.add(new Option('Choose a plan', '')); targetLabel.append(select); form.append(searchLabel, find, targetLabel);
|
|
139
|
+
if (previous.into) select.add(new Option(previous.into, previous.into)); select.value = previous.into ?? '';
|
|
140
|
+
let searchSequence = 0;
|
|
141
|
+
find.onclick = async () => {
|
|
142
|
+
const own = ++searchSequence; find.disabled = true;
|
|
143
|
+
try {
|
|
144
|
+
const result = await api(`library?${new URLSearchParams({ kind: 'plans', q: query.value, limit: '50', archived: '1' })}`);
|
|
145
|
+
if (own !== searchSequence || !dialog.open) return;
|
|
146
|
+
select.replaceChildren(new Option('Choose a plan', ''), ...result.documents.filter(doc => doc.path !== state.doc.path && doc.type === 'plan').map(doc => new Option(`${doc.title} · ${doc.path}`, doc.path)));
|
|
147
|
+
feedback.textContent = result.hasMore ? 'Showing 50 matching plans. Narrow the search to find another.' : result.documents.length ? '' : 'No plans matched this search.';
|
|
148
|
+
} catch (error) { feedback.textContent = error.message; }
|
|
149
|
+
finally { if (own === searchSequence) find.disabled = false; }
|
|
150
|
+
};
|
|
151
|
+
into = select;
|
|
152
|
+
}
|
|
153
|
+
const review = node('button', 'Review assessment', 'primary'); review.type = 'submit';
|
|
154
|
+
const feedback = node('p', undefined, 'inline-note'); feedback.setAttribute('role', 'status'); form.append(review, feedback); root.append(form);
|
|
155
|
+
form.onsubmit = async event => {
|
|
156
|
+
event.preventDefault(); if (unavailable(action)) return;
|
|
157
|
+
const request = { path: state.doc.path, action, expectedRevision: state.doc.revision, operationId: crypto.randomUUID(),
|
|
158
|
+
...(reason.value.trim() ? { reason: reason.value.trim() } : {}), ...(into ? { into: into.value } : {}) };
|
|
159
|
+
pending = { ...request, newPath: request.path, state: 'preparing', report: 'Review not yet received.' }; remember();
|
|
160
|
+
try {
|
|
161
|
+
await busyRun(async () => { const result = await api('yardstick/preview', request); pending = { ...pending, ...result }; remember(); renderReview(); });
|
|
162
|
+
dialog.querySelector('[data-yardstick-apply]')?.focus();
|
|
163
|
+
}
|
|
164
|
+
catch (error) { renderReview(); dialog.querySelector('.yardstick-feedback').textContent = `${error.message} Inspect the retained review or return to the form.`; notice(error.message, true); }
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function renderReview() {
|
|
168
|
+
const root = dialogShell(labels[pending.action] ?? 'Yardstick assessment');
|
|
169
|
+
if (pending.comparison) {
|
|
170
|
+
const { goal, delivers, assessment } = pending.comparison;
|
|
171
|
+
const reviewed = node('section', undefined, 'yardstick-reviewed-comparison');
|
|
172
|
+
reviewed.append(node('h3', 'Product goal for this review'), node('p', goal.state === 'ready' ? excerpt(goal.statement)
|
|
173
|
+
: goal.enabled ? `Goal unavailable: ${goal.message}` : 'Not configured', 'yardstick-reviewed-goal'));
|
|
174
|
+
if (goal.source) reviewed.append(node('p', `Goal source · ${goal.source}`, 'muted'));
|
|
175
|
+
reviewed.append(node('h3', 'This saved plan delivers'), node('p', excerpt(delivers, 1000) || 'Unset', 'yardstick-reviewed-delivers'),
|
|
176
|
+
node('h3', 'Assessment to record'), node('p', pending.action === 'clear' ? 'Clear the assessment; execution status is unchanged.'
|
|
177
|
+
: ({ serves: 'Serves the goal', folded: 'Folded into another plan', closed: 'Closed' }[assessment.disposition] ?? 'Not reviewed'), 'yardstick-reviewed-assessment'));
|
|
178
|
+
root.append(reviewed);
|
|
179
|
+
} else root.append(node('p', 'The prepared comparison is unavailable. Return to the form and review again before applying.', 'inline-note'));
|
|
180
|
+
const report = node('pre', pending.report ?? 'Review not yet received.', 'lifecycle-report');
|
|
181
|
+
const feedback = node('p', undefined, 'yardstick-feedback inline-note'); feedback.setAttribute('role', 'status');
|
|
182
|
+
const apply = node('button', pending.action === 'fold' ? 'Archive and fold plan' : pending.action === 'close' ? 'Archive and close plan' : 'Apply assessment', 'primary');
|
|
183
|
+
apply.type = 'button'; apply.dataset.yardstickApply = '';
|
|
184
|
+
const back = node('button', 'Back to form', 'quiet'); back.type = 'button';
|
|
185
|
+
const inspect = node('button', 'Inspect operation', 'quiet'); inspect.type = 'button';
|
|
186
|
+
root.append(report, apply, back, inspect, feedback);
|
|
187
|
+
back.onclick = () => {
|
|
188
|
+
if (working) return;
|
|
189
|
+
if (pending.state === 'running') { feedback.textContent = 'Inspect this interrupted operation before starting another review.'; return; }
|
|
190
|
+
const prior = pending; forget(prior.path); pending = null; form(prior.action, prior); update();
|
|
191
|
+
};
|
|
192
|
+
inspect.onclick = async () => {
|
|
193
|
+
if (working) return;
|
|
194
|
+
try {
|
|
195
|
+
await busyRun(async () => {
|
|
196
|
+
const result = await api('yardstick/inspect', { operationId: pending.operationId });
|
|
197
|
+
if (!result) { pending.state = 'missing'; feedback.textContent = 'No review was retained. Return to the form and review again.'; return; }
|
|
198
|
+
pending = { ...pending, ...result }; remember(); report.textContent = result.report;
|
|
199
|
+
feedback.textContent = `Operation is ${result.state}.${result.failure ? ` ${result.failure.message}` : ''}`;
|
|
200
|
+
if (result.state === 'committed') {
|
|
201
|
+
const old = pending.path, destination = result.newPath; forget(old); pending = null;
|
|
202
|
+
library.relocated(old, destination); dialog.close(); working = false; state.busy = false;
|
|
203
|
+
await open(destination); await library.load({ refresh: true }); notice('This assessment was already saved.');
|
|
204
|
+
} else if (result.state === 'running') {
|
|
205
|
+
feedback.textContent += ' Inspect the source and CLI transactions before starting another change.';
|
|
206
|
+
if (result.canAcknowledge && result.current) acknowledgement(result, root, feedback);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
} catch (error) { feedback.textContent = error.message; }
|
|
210
|
+
};
|
|
211
|
+
apply.onclick = async () => {
|
|
212
|
+
if (!pending?.comparison || pending.state !== 'reviewed' || unavailable(pending.action)) return;
|
|
213
|
+
try {
|
|
214
|
+
await busyRun(async () => {
|
|
215
|
+
const result = await api('yardstick/commit', { operationId: pending.operationId });
|
|
216
|
+
const old = pending.path; library.relocated(old, result.newPath); forget(old); pending = null; dialog.close();
|
|
217
|
+
working = false; state.busy = false; await open(result.newPath); await library.load({ refresh: true });
|
|
218
|
+
notice('Owner assessment saved. The Markdown source follows the CLI rules.');
|
|
219
|
+
});
|
|
220
|
+
} catch (error) { if (pending) { pending.state = 'uncertain'; remember(); } feedback.textContent = `${error.message} Inspect the retained operation before retrying.`; notice(feedback.textContent, true); }
|
|
221
|
+
};
|
|
222
|
+
update();
|
|
223
|
+
}
|
|
224
|
+
function acknowledgement(result, root, feedback) {
|
|
225
|
+
root.querySelector('.yardstick-acknowledgement')?.remove();
|
|
226
|
+
const form = node('form', undefined, 'yardstick-acknowledgement'), label = node('label', 'What did you verify?');
|
|
227
|
+
const note = node('textarea'); note.required = true; note.maxLength = 4000; note.rows = 3; label.append(note);
|
|
228
|
+
form.append(label, node('p', 'Acknowledge the inspected outcome without replaying the operation. It remains marked uncertain.', 'muted'));
|
|
229
|
+
const submit = node('button', 'Acknowledge inspected outcome', 'quiet'); submit.type = 'submit'; form.append(submit); root.append(form);
|
|
230
|
+
form.onsubmit = async event => {
|
|
231
|
+
event.preventDefault(); if (working) return;
|
|
232
|
+
try {
|
|
233
|
+
await busyRun(async () => {
|
|
234
|
+
const settled = await api('yardstick/settle', { operationId: pending.operationId, expectedRevision: result.current.revision, note: note.value });
|
|
235
|
+
forget(pending.path); pending = null; dialog.close(); working = false; state.busy = false;
|
|
236
|
+
await open(settled.current.path); notice('Interrupted assessment acknowledged without replay.');
|
|
237
|
+
});
|
|
238
|
+
} catch (error) { feedback.textContent = error.message; }
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
async function resolveInitial(path) {
|
|
242
|
+
const old = retained(path);
|
|
243
|
+
if (!old) return path;
|
|
244
|
+
const result = await api('yardstick/inspect', { operationId: old.operationId });
|
|
245
|
+
if (result?.state === 'committed') { library.relocated(old.path, result.newPath); forget(old.path); return result.newPath; }
|
|
246
|
+
return result?.current?.path ?? path;
|
|
247
|
+
}
|
|
248
|
+
async function resume(item) {
|
|
249
|
+
const operationId = typeof item === 'string' ? item : item.operationId;
|
|
250
|
+
const result = await api('yardstick/inspect', { operationId });
|
|
251
|
+
if (!result) throw new Error('The assessment is unavailable; its recovery entry has been kept.');
|
|
252
|
+
await open(result.current?.path ?? result.newPath ?? result.path);
|
|
253
|
+
if (result.state === 'committed') { forget(result.path); notice('The owner assessment was already saved.'); return; }
|
|
254
|
+
pending = result; remember(); await show(result.action, result);
|
|
255
|
+
}
|
|
256
|
+
function beforeLeave() { if (working || dialog?.open) throw new Error('Close the current yardstick review before switching folders or quitting.'); }
|
|
257
|
+
return { opened, update, resolveInitial, resume, beforeLeave, busy: () => working };
|
|
258
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { documentOutline } from './outline.mjs';
|
|
2
|
+
export function editorNavigation({state,editor,checkout,$,view,notice}) {
|
|
3
|
+
let entries=[], bookmarks=[], path='', active=null, previous=[],previousModel;
|
|
4
|
+
const storageKey=()=>`runlist:bookmarks:${checkout()}:${encodeURIComponent(state.doc.path)}`;
|
|
5
|
+
function persist() { try { localStorage.setItem(storageKey(),JSON.stringify(bookmarks)); } catch { notice('Bookmarks could not be saved in local editor storage.',true); } }
|
|
6
|
+
function jump(entry) {
|
|
7
|
+
if (!entry || !state.doc) return;
|
|
8
|
+
if (!['read','edit'].includes(state.mode)) view('edit');
|
|
9
|
+
let target;
|
|
10
|
+
if (state.mode==='read') target=[...$('reading').querySelectorAll('h1,h2,h3,h4,h5,h6')][entries.indexOf(entry)];
|
|
11
|
+
else if (!$('markdown-fallback').hidden) { target=$('body-editor'); target.focus(); target.setSelectionRange(entry.offset,entry.offset); const lineHeight=parseFloat(getComputedStyle(target).lineHeight)||22; target.scrollTop=Math.max(0,(entry.line-3)*lineHeight); }
|
|
12
|
+
else { target=[...editor.root.querySelectorAll('[data-block]')].find(e=>e.dataset.block===entry.id); if (!entry.locked) editor.focus(entry.id); }
|
|
13
|
+
target?.scrollIntoView({block:'center',behavior:'smooth'}); active=entry.key; highlight(); $('evidence').classList.remove('outline-open');
|
|
14
|
+
}
|
|
15
|
+
function highlight() { for (const button of $('outline').querySelectorAll('[data-section]')) { const selected=button.dataset.section===active; button.setAttribute('aria-current',String(selected)); } }
|
|
16
|
+
function toggle(entry) { const index=bookmarks.findIndex(b=>b.key===entry.key); if(index<0) bookmarks.push({key:entry.key,title:entry.title}); else bookmarks.splice(index,1); persist(); render(); }
|
|
17
|
+
function row(entry, saved=false) {
|
|
18
|
+
const div=document.createElement('div'); div.className='outline-row'; div.style.setProperty('--heading-level',entry.level ?? 1);
|
|
19
|
+
const button=document.createElement('button'); button.className='section-link quiet'; button.textContent=entry.title; button.dataset.section=entry.key;
|
|
20
|
+
const found=entries.find(e=>e.key===entry.key); button.disabled=!found; button.title=found ? `Jump to ${entry.title} · line ${found.line}` : 'This heading was renamed or removed. Remove this bookmark or bookmark the new heading.';
|
|
21
|
+
button.onclick=()=>jump(found);
|
|
22
|
+
const star=document.createElement('button'); star.className='section-bookmark quiet'; const marked=bookmarks.some(b=>b.key===entry.key); star.textContent=marked?'★':'☆'; star.setAttribute('aria-label',`${marked?'Remove bookmark for':'Bookmark'} ${entry.title}`);star.setAttribute('aria-pressed',String(marked));star.onclick=()=>{if(found)toggle(found);else{bookmarks=bookmarks.filter(b=>b.key!==entry.key);persist();render();}};
|
|
23
|
+
div.append(button,star); if(saved&&!found) {const small=document.createElement('small');small.textContent='Heading unavailable';div.append(small);}return div;
|
|
24
|
+
}
|
|
25
|
+
function render() {
|
|
26
|
+
const query=$('section-filter').value.toLowerCase();
|
|
27
|
+
$('outline').replaceChildren(...entries.filter(e=>e.title.toLowerCase().includes(query)).map(e=>row(e)));
|
|
28
|
+
if(!$('outline').children.length)$('outline').textContent=query?'No matching headings.':'Add a heading to create a section.';
|
|
29
|
+
$('bookmarks').replaceChildren(...bookmarks.map(b=>row(b,true)));$('bookmark-empty').hidden=bookmarks.length>0;
|
|
30
|
+
$('section-count').textContent=entries.length;highlight();
|
|
31
|
+
}
|
|
32
|
+
function update() {
|
|
33
|
+
if(!state.doc)return;
|
|
34
|
+
const samePath=path===state.doc.path;
|
|
35
|
+
if(!samePath){path=state.doc.path;active=null;previous=[];try{const stored=JSON.parse(localStorage.getItem(storageKey())??'[]');bookmarks=Array.isArray(stored)?stored.filter(b=>typeof b.key==='string'&&typeof b.title==='string'):[];}catch{bookmarks=[];}$('section-filter').value='';}
|
|
36
|
+
const model=editor.source()===state.body?editor.blocks:undefined;
|
|
37
|
+
entries=documentOutline(state.body,model);
|
|
38
|
+
// Inline block identity survives typing and insertions, so heading renames
|
|
39
|
+
// also update their bookmarks. Source replacements leave unmatched bookmarks explicit.
|
|
40
|
+
if(samePath&&model&&model===previousModel){let changed=false;for(const b of bookmarks){const old=previous.find(e=>e.key===b.key),fresh=old&&entries.find(e=>e.id===old.id);if(fresh&&fresh.key!==b.key){b.key=fresh.key;b.title=fresh.title;changed=true;}}if(changed)persist();}
|
|
41
|
+
previous=entries;previousModel=model;render();
|
|
42
|
+
}
|
|
43
|
+
$('section-filter').oninput=render;
|
|
44
|
+
$('toggle-outline').onclick=()=>{$('evidence').classList.toggle('outline-open');$('section-filter').focus();};
|
|
45
|
+
$('close-outline').onclick=()=>$('evidence').classList.remove('outline-open');
|
|
46
|
+
$('add-section').onclick=()=>{if(!state.doc||state.busy||state.pending||!state.doc.editable)return;view('edit');if(!$('markdown-fallback').hidden)$('toggle-markdown').click();editor.mutate(()=>{const last=editor.blocks.filter(b=>!b.locked&&!['gap','comment'].includes(b.kind)).at(-1);const fresh=editor.insertAfter(last,'h2','New section');editor.render();editor.focus(fresh.id);});update();jump(entries.find(e=>e.id===editor.activeId));};
|
|
47
|
+
document.addEventListener('keydown',event=>{if(!state.doc||['home','records','filing'].includes(state.mode)||document.querySelector('dialog[open]'))return;if((event.metaKey||event.ctrlKey)&&event.shiftKey&&event.key.toLowerCase()==='j'){event.preventDefault();$('evidence').classList.add('outline-open');$('section-filter').focus();}if(event.altKey&&['PageDown','PageUp'].includes(event.key)){event.preventDefault();const index=entries.findIndex(e=>e.key===active);jump(entries[Math.max(0,Math.min(entries.length-1,index+(event.key==='PageDown'?1:-1)))]);}if(event.key==='Escape')$('evidence').classList.remove('outline-open');});
|
|
48
|
+
$('section-filter').onkeydown=event=>{if(event.key==='Enter'){event.preventDefault();jump(entries.find(e=>e.title.toLowerCase().includes($('section-filter').value.toLowerCase())));}};
|
|
49
|
+
let scheduled=false;window.addEventListener('scroll',()=>{if(scheduled||!state.doc||!['read','edit'].includes(state.mode)||!$('markdown-fallback').hidden)return;scheduled=true;requestAnimationFrame(()=>{scheduled=false;const nodes=state.mode==='read'?[...$('reading').querySelectorAll('h1,h2,h3,h4,h5,h6')]:entries.map(e=>[...editor.root.querySelectorAll('[data-block]')].find(n=>n.dataset.block===e.id));let current=entries[0];for(let i=0;i<nodes.length;i++)if(nodes[i]?.getBoundingClientRect().top<260)current=entries[i];if(current){active=current.key;highlight();}});},{passive:true});
|
|
50
|
+
return {update,jumpTo:section=>{update();const entry=entries.find(e=>e.line===section.line&&e.title.slice(0,200)===section.title)||entries.find(e=>e.title.slice(0,200)===section.title);if(entry)jump(entry);else notice('This section changed since the search. Use the document outline to find it.',true);}};
|
|
51
|
+
}
|