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
package/src/app-git.mjs
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Read-only checkout Git operations. Commit execution has a separate feasibility gate.
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { constants } from 'node:fs';
|
|
5
|
+
import { open, lstat, realpath, stat } from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { authorizeManagedSource, authorizeManagedDestination } from './managed-path.mjs';
|
|
8
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
9
|
+
import { SourceEditError } from './source-editor.mjs';
|
|
10
|
+
import { localCommitSupport } from './git-commit-support.mjs';
|
|
11
|
+
|
|
12
|
+
const MAX_OUTPUT=16*1024*1024, MAX_FILE=8*1024*1024, MAX_DIFF=1024*1024, MAX_LINES=20_000;
|
|
13
|
+
const fail=(code,message)=>{throw new SourceEditError(code,message);};
|
|
14
|
+
const decoder=new TextDecoder('utf-8',{fatal:true});
|
|
15
|
+
const decode=bytes=>{try{return decoder.decode(bytes);}catch{fail('git-encoding','This Git output is not valid UTF-8. Review it with Git.');}};
|
|
16
|
+
const inside=(root,file)=>{const rel=path.relative(root,file);return rel!== '..'&&!rel.startsWith(`..${path.sep}`)&&!path.isAbsolute(rel);};
|
|
17
|
+
|
|
18
|
+
export function parseGitChanges(bytes) {
|
|
19
|
+
const fields=decode(bytes).split('\0'),rows=[];
|
|
20
|
+
for(let i=0;i<fields.length;i++) {
|
|
21
|
+
const field=fields[i];if(!field)continue;
|
|
22
|
+
const type=field[0];
|
|
23
|
+
if(type==='?'||type==='!'){rows.push({path:field.slice(2),xy:type==='?'?'??':'!!',kind:type==='?'?'Added':'Local only',ignored:type==='!'});continue;}
|
|
24
|
+
const parts=field.split(' '),count=type==='1'?8:type==='2'?9:type==='u'?10:0;
|
|
25
|
+
if(!count||parts.length<=count)fail('git-output','Git returned an unsupported change record.');
|
|
26
|
+
const file=parts.slice(count).join(' '),xy=parts[1],original=type==='2'?fields[++i]:null;
|
|
27
|
+
if(type==='2'&&!original)fail('git-output','Git returned an incomplete rename.');
|
|
28
|
+
const kind=type==='u'?'Conflicted':type==='2'?'Renamed':xy.includes('D')?'Deleted':xy.includes('A')?'Added':'Modified';
|
|
29
|
+
rows.push({path:file,original,xy,kind,conflict:type==='u',partial:xy[0]!=='.'&&xy[1]!=='.',staged:xy[0]!=='.',indexMode:parts[4],headMode:parts[3]});
|
|
30
|
+
}
|
|
31
|
+
return rows;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function gitEnvironment() {
|
|
35
|
+
const result={};
|
|
36
|
+
for(const key of ['HOME','USER','LOGNAME','TMPDIR','USERPROFILE','USERNAME','HOMEDRIVE','HOMEPATH','APPDATA','LOCALAPPDATA','ProgramData','SystemRoot','WINDIR','TEMP','TMP','XDG_CONFIG_HOME','GNUPGHOME'])if(process.env[key])result[key]=process.env[key];
|
|
37
|
+
result.PATH=(process.env.PATH??'').split(path.delimiter).filter(value=>path.isAbsolute(value)).join(path.delimiter);
|
|
38
|
+
return {...result,LANG:'C',LC_ALL:'C',GIT_TERMINAL_PROMPT:'0',GIT_OPTIONAL_LOCKS:'0',GIT_NO_LAZY_FETCH:'1'};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Fixed commands only; the renderer never chooses a binary, argument or cwd.
|
|
42
|
+
function runner(config,deadline) {
|
|
43
|
+
return (args,{allow=[0],input,max=MAX_OUTPUT,configOverrides=[]}={})=>new Promise((resolve,reject)=>{
|
|
44
|
+
const remaining=deadline-Date.now();if(remaining<=0){reject(new SourceEditError('git-timeout','Git inspection timed out. Narrow the checkout or try Refresh.'));return;}
|
|
45
|
+
const child=spawn('git',['--no-optional-locks','--literal-pathspecs','-c','core.fsmonitor=false','-c','core.untrackedCache=false',...configOverrides.flatMap(v=>['-c',v]),...args],{cwd:config.repoRoot,env:gitEnvironment(),windowsHide:true,stdio:['pipe','pipe','pipe']});
|
|
46
|
+
const output=[],errors=[];let size=0,errorSize=0,failed=false;
|
|
47
|
+
const stop=(code,message)=>{if(failed)return;failed=true;child.kill('SIGKILL');reject(new SourceEditError(code,message));};
|
|
48
|
+
const timer=setTimeout(()=>stop('git-timeout','Git inspection timed out. Try Refresh.'),remaining);
|
|
49
|
+
child.on('error',error=>{clearTimeout(timer);stop(error.code==='ENOENT'?'git-missing':'git-unavailable','Git is unavailable. Document editing is still available.');});
|
|
50
|
+
child.stdout.on('data',chunk=>{size+=chunk.length;if(size>max)stop('git-limit','Git output exceeds the review limit. Review this checkout with Git.');else output.push(chunk);});
|
|
51
|
+
child.stderr.on('data',chunk=>{errorSize+=chunk.length;if(errorSize>64*1024)stop('git-limit','Git diagnostics exceed the review limit.');else errors.push(chunk);});
|
|
52
|
+
child.stdin.on('error',()=>{});child.stdin.end(input);
|
|
53
|
+
child.on('close',code=>{clearTimeout(timer);if(failed)return;if(!allow.includes(code)){reject(new SourceEditError('git-unavailable','Git could not inspect this checkout. Review its state with Git and try Refresh.'));return;}resolve({code,bytes:Buffer.concat(output),text:()=>decode(Buffer.concat(output)).trim()});});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createAppGit({config,actor,authenticate,library}) {
|
|
58
|
+
let active=0;
|
|
59
|
+
function identity(req) {const user=authenticate(req);if(user?.kind!=='human'||user.id!==actor.id)fail('forbidden','Git inspection belongs to the authenticated checkout owner.');}
|
|
60
|
+
function scope(input,{disk=true}={}) {
|
|
61
|
+
if(typeof input!=='string'||!input||input.includes('\0')||input.includes('\\')||path.posix.isAbsolute(input)||path.posix.normalize(input)!==input||input.split('/').includes('..'))fail('forbidden','Choose a document in this checkout.');
|
|
62
|
+
const file=path.resolve(config.repoRoot,input),parts=input.split('/');
|
|
63
|
+
if(!input.endsWith('.md')||!inside(config.repoRoot,file)||file===config.indexPath||parts.some(p=>['.git','.runlist','.dotmd','prompts','drafts','receipts','before-images','credentials'].includes(p)||config.excludeDirs?.has(p)))fail('forbidden','Private or excluded files are unavailable in Changes.');
|
|
64
|
+
const roots=(config.docsRoots??[config.docsRoot]).filter(root=>inside(root,file)).sort((a,b)=>b.length-a.length);
|
|
65
|
+
if(!roots.length)fail('forbidden','Choose a document in a configured document root.');
|
|
66
|
+
const scoped={...config,docsRoots:[roots[0]],docsRoot:roots[0]};
|
|
67
|
+
// Destination authorization also checks existing ancestors for deleted paths.
|
|
68
|
+
try{if(disk)authorizeManagedDestination(file,scoped);}catch{fail('forbidden','This document escapes its configured root.');}
|
|
69
|
+
return {file,scoped};
|
|
70
|
+
}
|
|
71
|
+
async function saved(input) {
|
|
72
|
+
const {file,scoped}=scope(input);let stamp;
|
|
73
|
+
try{stamp=await lstat(file);}catch(error){if(error.code==='ENOENT')return null;throw error;}
|
|
74
|
+
if(stamp.isSymbolicLink()||!stamp.isFile())fail('forbidden','Symlinks and non-document files are unavailable in Changes.');
|
|
75
|
+
try{authorizeManagedSource(file,scoped);}catch{fail('forbidden','This document escapes its configured root.');}
|
|
76
|
+
if(stamp.size>MAX_FILE)fail('git-file-limit','This file exceeds the 8 MiB review limit.');
|
|
77
|
+
const fd=await open(file,constants.O_RDONLY|(constants.O_NOFOLLOW??0));
|
|
78
|
+
try{const opened=await fd.stat();if(opened.dev!==stamp.dev||opened.ino!==stamp.ino||opened.size>MAX_FILE)fail('git-review-changed','The file changed. Refresh Changes.');const bytes=Buffer.alloc(opened.size+1);const {bytesRead}=await fd.read(bytes,0,bytes.length,0);const after=await fd.stat();if(bytesRead!==opened.size||after.size!==opened.size||after.mtimeMs!==opened.mtimeMs||after.ctimeMs!==opened.ctimeMs)fail('git-review-changed','The file changed while reading. Refresh Changes.');const source=decode(bytes.subarray(0,bytesRead));if(source.includes('\0'))fail('git-binary','This document is not reviewable text.');return source;}finally{await fd.close();}
|
|
79
|
+
}
|
|
80
|
+
const privateType=source=>parseSimpleFrontmatter(extractFrontmatter(source??'').frontmatter).type==='prompt';
|
|
81
|
+
async function historical(git,head,input) {
|
|
82
|
+
scope(input);if(!head)return null;
|
|
83
|
+
const record=(await git(['ls-tree','-z',head,'--',input])).bytes;
|
|
84
|
+
if(!record.length)return null;
|
|
85
|
+
const entry=decode(record).split('\0')[0],match=entry.match(/^(100644|100755) blob ([a-f0-9]{40,64})\t/);
|
|
86
|
+
if(!match)fail('forbidden','Symlinks and submodules are unavailable in Changes.');
|
|
87
|
+
const length=Number((await git(['cat-file','-s',match[2]])).text());if(length>MAX_FILE)fail('git-file-limit','This historical file exceeds the review limit.');
|
|
88
|
+
const source=decode((await git(['cat-file','blob',match[2]],{max:MAX_FILE})).bytes);
|
|
89
|
+
if(source.includes('\0'))fail('git-binary','This document is not reviewable text.');if(privateType(source))fail('forbidden','Saved prompts are unavailable in Changes.');return source;
|
|
90
|
+
}
|
|
91
|
+
async function inspect(req,git,params) {
|
|
92
|
+
identity(req);
|
|
93
|
+
const top=await git(['rev-parse','--show-toplevel'],{allow:[0,128]});
|
|
94
|
+
if(top.code!==0)return {available:false,reason:'This folder is not a Git checkout.',code:'not-git',rows:[]};
|
|
95
|
+
const [a,b]=await Promise.all([stat(await realpath(top.text())),stat(await realpath(config.repoRoot))]);
|
|
96
|
+
if(a.dev!==b.dev||a.ino!==b.ino)return {available:false,reason:'Open the Git checkout root to review its changes.',code:'git-root',rows:[]};
|
|
97
|
+
const headResult=await git(['rev-parse','--verify','HEAD'],{allow:[0,128]}),head=headResult.code===0?headResult.text():null;
|
|
98
|
+
const branchResult=await git(['symbolic-ref','--quiet','--short','HEAD'],{allow:[0,1]}),branch=branchResult.code===0?branchResult.text():null;
|
|
99
|
+
// Listing and saved-source inspection never execute clean/process drivers.
|
|
100
|
+
// Filtered candidates require the separate, explicit Git-tree commit review.
|
|
101
|
+
const filterRecords=decode((await git(['config','--null','--get-regexp','^filter.*[.](clean|process)$'],{allow:[0,1]})).bytes).split('\0');
|
|
102
|
+
const filters=new Set(filterRecords.flatMap(record=>{const at=record.indexOf('\n'),key=record.slice(0,at),value=record.slice(at+1);const name=key.match(/^filter[.](.*)[.](clean|process)$/)?.[1];return name&&value.trim()?[name]:[];}));
|
|
103
|
+
const candidates=decode((await git(['ls-files','--cached','--others','--exclude-standard','-z'])).bytes).split('\0').filter(Boolean).filter(file=>{try{scope(file,{disk:false});return true;}catch{return false;}});
|
|
104
|
+
await library.refresh(true);
|
|
105
|
+
const catalog=await library.all(),metadata=new Map(catalog.map(doc=>[doc.path,doc]));
|
|
106
|
+
const filteredCandidates=candidates;
|
|
107
|
+
const attrs=filteredCandidates.length?decode((await git(['check-attr','-z','--stdin','filter','working-tree-encoding'],{input:Buffer.from(filteredCandidates.join('\0')+'\0')})).bytes).split('\0'):[];
|
|
108
|
+
const filtered=new Set(),encoded=new Set();for(let i=0;i+2<attrs.length;i+=3){if(attrs[i+1]==='filter'&&filters.has(attrs[i+2]))filtered.add(attrs[i]);if(attrs[i+1]==='working-tree-encoding'&&!['unspecified','unset'].includes(attrs[i+2]))encoded.add(attrs[i]);}
|
|
109
|
+
// This metadata scan can over-report filtered candidates. Eligibility and
|
|
110
|
+
// exact filtered blobs are resolved by explicit private-index review.
|
|
111
|
+
const configOverrides=[...filters].flatMap(name=>[`filter.${name}.clean=`,`filter.${name}.process=`,`filter.${name}.required=false`]);
|
|
112
|
+
const raw=parseGitChanges((await git(['status','--porcelain=v2','-z','--untracked-files=all','--ignored=matching','--renames'],{configOverrides})).bytes);
|
|
113
|
+
const reported=new Set(raw.map(row=>row.path));for(const file of filtered)if(!reported.has(file))raw.push({path:file,xy:'.M',kind:'Review required',staged:false,partial:false});
|
|
114
|
+
const tracked=new Set(decode((await git(['ls-files','--cached','-z'])).bytes).split('\0'));
|
|
115
|
+
let reason=!head?'Create the first commit with Git before reviewing local commits.':!branch?'This checkout has a detached HEAD. Choose a branch with Git.':null;
|
|
116
|
+
for(const marker of ['MERGE_HEAD','CHERRY_PICK_HEAD','REVERT_HEAD','rebase-merge','rebase-apply']) {
|
|
117
|
+
const file=(await git(['rev-parse','--git-path',marker])).text();try{await lstat(path.resolve(config.repoRoot,file));reason='Finish the current merge, rebase or cherry-pick with Git first.';}catch(error){if(error.code!=='ENOENT')throw error;}
|
|
118
|
+
}
|
|
119
|
+
const unrelatedStaged=raw.filter(row=>row.staged).filter(row=>{try{scope(row.path,{disk:false});if(row.original)scope(row.original,{disk:false});return false;}catch{return true;}}).length;
|
|
120
|
+
const ignored=raw.filter(row=>row.ignored).map(row=>row.path),rows=[];
|
|
121
|
+
for(const rawRow of raw.filter(row=>!row.ignored)) {
|
|
122
|
+
try {
|
|
123
|
+
scope(rawRow.path,{disk:false});if(rawRow.original)scope(rawRow.original);
|
|
124
|
+
if(['120000','160000'].includes(rawRow.indexMode)||['120000','160000'].includes(rawRow.headMode))continue;
|
|
125
|
+
const meta=metadata.get(rawRow.path);let fm={},archived=meta?.archived??false;
|
|
126
|
+
if(!meta) {
|
|
127
|
+
// An existing file absent from the freshly authorized library is
|
|
128
|
+
// excluded or private. Only genuine deletions use historical scope.
|
|
129
|
+
const file=path.resolve(config.repoRoot,rawRow.path);
|
|
130
|
+
try{await lstat(file);continue;}catch(error){if(error.code!=='ENOENT')throw error;}
|
|
131
|
+
const old=await historical(git,head,rawRow.original??rawRow.path);
|
|
132
|
+
fm=parseSimpleFrontmatter(extractFrontmatter(old??'').frontmatter);
|
|
133
|
+
archived=fm.status==='archived'||rawRow.path.split('/').includes(path.basename(config.archiveDir??'archived'));
|
|
134
|
+
} else if(rawRow.original)await historical(git,head,rawRow.original);
|
|
135
|
+
if(archived&¶ms.get('archived')!=='1')continue;
|
|
136
|
+
const filterReviewRequired=filtered.has(rawRow.path)||filtered.has(rawRow.original);
|
|
137
|
+
const blocked=reason??(encoded.has(rawRow.path)?'Working-tree encoding requires Git.':rawRow.conflict?'Resolve the conflict with Git.':rawRow.partial&&!filterReviewRequired?'Staged content differs from the saved file. Whole-file selection would replace partial staging.':meta?.bytes>MAX_FILE?'Exceeds the 8 MiB review limit.':null);
|
|
138
|
+
rows.push({...meta,...rawRow,filterReviewRequired,title:String(meta?.title??fm.title??path.posix.basename(rawRow.path)).slice(0,300),archived,eligible:!blocked,blockedReason:blocked});
|
|
139
|
+
}catch(error){if(error instanceof SourceEditError&&error.code==='forbidden')continue;throw error;}
|
|
140
|
+
}
|
|
141
|
+
// Git collapses ignored directories. Expand only freshly authorized
|
|
142
|
+
// library metadata, never ignored private storage or document bodies.
|
|
143
|
+
const visible=new Set(rows.map(row=>row.path));
|
|
144
|
+
for(const doc of catalog)if(!tracked.has(doc.path)&&(params.get('archived')==='1'||!doc.archived)&&ignored.some(prefix=>doc.path===prefix||prefix.endsWith('/')&&doc.path.startsWith(prefix))&&!visible.has(doc.path)) {
|
|
145
|
+
try{scope(doc.path,{disk:false});rows.push({...doc,kind:'Local only',ignored:true,eligible:false,blockedReason:'Ignored by Git. This document stays local.'});}catch{}
|
|
146
|
+
}
|
|
147
|
+
rows.sort((a,b)=>a.path.localeCompare(b.path));
|
|
148
|
+
const finalHead=(await git(['rev-parse','--verify','HEAD'],{allow:[0,128]})).text(),finalBranch=(await git(['symbolic-ref','--quiet','--short','HEAD'],{allow:[0,1]})).text();
|
|
149
|
+
if(finalHead!==(head??'')||finalBranch!==(branch??''))fail('git-review-changed','The branch changed during inspection. Refresh Changes.');
|
|
150
|
+
const commitSupport=localCommitSupport((await git(['--version'])).text());
|
|
151
|
+
identity(req);return {available:true,reason,branch,head,unrelatedStaged,rows,commitSupport,commitEnabled:false};
|
|
152
|
+
}
|
|
153
|
+
async function operation(req,fn) {identity(req);if(active>=2)fail('git-busy','Git inspection is already running. Try again when it finishes.');active++;try{return await fn(runner(config,Date.now()+10_000));}finally{active--;}}
|
|
154
|
+
async function status(req,params=new URLSearchParams()) {return operation(req,async git=>{
|
|
155
|
+
let result;try{result=await inspect(req,git,params);}catch(error){if(!['git-missing','git-unavailable','git-limit','git-timeout'].includes(error.code))throw error;return {available:false,code:error.code,reason:error.message,changes:[],total:0,commitEnabled:false};}
|
|
156
|
+
const query=(params.get('q')??'').slice(0,500).toLowerCase(),matches=result.rows.filter(row=>`${row.title??''} ${row.path} ${row.original??''}`.toLowerCase().includes(query));
|
|
157
|
+
const limit=100,requested=Math.max(0,Math.floor(Number(params.get('offset'))||0)),offset=Math.min(requested,Math.max(0,Math.floor((matches.length-1)/limit)*limit));
|
|
158
|
+
const {rows,...info}=result;return {...info,changes:matches.slice(offset,offset+limit),total:matches.length,changedDocuments:rows.filter(r=>!r.ignored).length,localOnly:rows.filter(r=>r.ignored).length,offset,limit,hasMore:offset+limit<matches.length,commitEnabled:false};
|
|
159
|
+
});}
|
|
160
|
+
async function diff(req,params) {scope(params.get('path'));return operation(req,async git=>{
|
|
161
|
+
const result=await inspect(req,git,params);if(!result.available)fail(result.code??'git-unavailable',result.reason);
|
|
162
|
+
const row=result.rows.find(row=>row.path===params.get('path'));if(!row)fail('git-review-changed','This change is no longer available. Refresh Changes.');
|
|
163
|
+
const before=await historical(git,result.head,row.original??row.path),after=await saved(row.path);if(privateType(after))fail('forbidden','Saved prompts are unavailable in Changes.');
|
|
164
|
+
if(Buffer.byteLength(before??'')+Buffer.byteLength(after??'')>MAX_DIFF)fail('git-limit','This diff exceeds the inline review limit. Review it with Git.');
|
|
165
|
+
for(const source of [before??'',after??'']){let count=1,at=-1;while((at=source.indexOf('\n',at+1))!==-1)if(++count>MAX_LINES)fail('git-limit','This diff has too many lines for inline review. Review it with Git.');}
|
|
166
|
+
identity(req);return {change:row,branch:result.branch,head:result.head,before:before??'',after:after??'',basis:row.filterReviewRequired?'Saved Markdown against HEAD; commit review applies the clean filter':'Saved Markdown against HEAD',commitEnabled:false};
|
|
167
|
+
});}
|
|
168
|
+
async function selection(req,chosen) {return operation(req,async git=>{
|
|
169
|
+
if(!Array.isArray(chosen)||!chosen.length||chosen.length>100||new Set(chosen).size!==chosen.length)fail('invalid-request','Select between 1 and 100 distinct saved documents.');
|
|
170
|
+
const result=await inspect(req,git,new URLSearchParams({archived:'1'}));
|
|
171
|
+
if(!result.available||result.reason)fail(result.code??'git-unavailable',result.reason);
|
|
172
|
+
const rows=chosen.map(file=>{scope(file);const row=result.rows.find(row=>row.path===file);if(!row||!row.eligible)fail('git-review-changed',row?.blockedReason??'A selected document changed or is unavailable. Refresh Changes.');return row;});
|
|
173
|
+
// Read/authorize both sides now, including historical private types.
|
|
174
|
+
for(const row of rows)await historical(git,result.head,row.original??row.path);
|
|
175
|
+
const paths=[...new Set(rows.flatMap(row=>row.original?[row.path,row.original]:[row.path]))],authorizedSources={};
|
|
176
|
+
for(const file of paths){const source=await saved(file);if(privateType(source))fail('forbidden','Saved prompts are unavailable in Changes.');authorizedSources[file]=source===null?null:createHash('sha256').update(source).digest('hex');}
|
|
177
|
+
identity(req);return {paths,authorizedSources,rows,head:result.head,branch:result.branch};
|
|
178
|
+
});}
|
|
179
|
+
return {status,diff,selection};
|
|
180
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { open, lstat } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { setImmediate as yieldThread } from 'node:timers/promises';
|
|
5
|
+
import { collectDocFiles } from './index.mjs';
|
|
6
|
+
import { authorizeManagedSource } from './managed-path.mjs';
|
|
7
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
8
|
+
import { extractFirstHeading } from './extractors.mjs';
|
|
9
|
+
import { isHubDoc, detectBodyRunlistRefs } from './hub.mjs';
|
|
10
|
+
import { normalizeStringList } from './util.mjs';
|
|
11
|
+
import { getStageDefinitions, readPlanStage, readShipsFrontmatter } from './stages.mjs';
|
|
12
|
+
import { referenceValues } from './reference-values.mjs';
|
|
13
|
+
|
|
14
|
+
const list = value => normalizeStringList(value).map(v=>v.slice(0,200)).slice(0,40);
|
|
15
|
+
const repoPath = (file,config) => path.relative(config.repoRoot,file).split(path.sep).join('/');
|
|
16
|
+
async function readHeader(fd,size) {
|
|
17
|
+
const limit=Math.min(128*1024,size),chunks=[];let length=0,source='';
|
|
18
|
+
while(length<limit){
|
|
19
|
+
const bytes=Buffer.alloc(Math.min(4096,limit-length)),{bytesRead}=await fd.read(bytes,0,bytes.length,length);
|
|
20
|
+
if(!bytesRead)break;
|
|
21
|
+
chunks.push(bytes.subarray(0,bytesRead));length+=bytesRead;
|
|
22
|
+
source=Buffer.concat(chunks,length).toString('utf8');
|
|
23
|
+
const parts=extractFrontmatter(source),complete=parts.bodyLineOffset>0||!/^---\r?\n/.test(source);
|
|
24
|
+
if(complete&&(parseSimpleFrontmatter(parts.frontmatter).title||extractFirstHeading(parts.body.slice(0,parts.body.lastIndexOf('\n')+1))))break;
|
|
25
|
+
}
|
|
26
|
+
return {source,bytesRead:length};
|
|
27
|
+
}
|
|
28
|
+
export function libraryKind(fm,documentPath) {
|
|
29
|
+
return isHubDoc({type:fm.type,path:documentPath,executionMode:fm.execution_mode,refFields:{runlist:list(fm.runlist)}}) ? 'hub' : fm.type==='plan' ? 'plan' : 'document';
|
|
30
|
+
}
|
|
31
|
+
export function matchesLibraryStage(row, stage) {
|
|
32
|
+
return !stage || row.kind==='plan' && (stage==='@unset'?row.stage===null&&!row.stageInvalid:stage==='@invalid'?row.stageInvalid:row.stage===(stage.startsWith('word:')?stage.slice(5):stage));
|
|
33
|
+
}
|
|
34
|
+
export function libraryStageComparator(config) {
|
|
35
|
+
const order=new Map(getStageDefinitions(config).map((stage,index)=>[stage.word,index]));
|
|
36
|
+
const rank=row=>row.stageInvalid?order.size+2:row.stage===null?order.size+1:order.get(row.stage)??order.size;
|
|
37
|
+
return (a,b)=>rank(a)-rank(b)||(rank(a)===order.size?String(a.stage).localeCompare(String(b.stage)):0);
|
|
38
|
+
}
|
|
39
|
+
export function sortLibraryRows(rows,params,config) {
|
|
40
|
+
const sort=params.get('sort'),compare=sort==='title'?(a,b)=>a.title.localeCompare(b.title)||a.path.localeCompare(b.path):sort==='path'?(a,b)=>a.path.localeCompare(b.path):(a,b)=>String(b.updated??'').localeCompare(String(a.updated??''))||a.path.localeCompare(b.path);
|
|
41
|
+
const stage=params.get('group')==='stage'&¶ms.get('kind')==='plans'?libraryStageComparator(config):()=>0;
|
|
42
|
+
return rows.sort((a,b)=>stage(a,b)||compare(a,b));
|
|
43
|
+
}
|
|
44
|
+
export function createDocumentLibrary(config,{refreshMs=15_000}={}) {
|
|
45
|
+
const roots=[...(config.docsRoots??[config.docsRoot])].sort((a,b)=>b.length-a.length);
|
|
46
|
+
const sourceConfig=file=>{const root=roots.find(root=>{const rel=path.relative(root,file);return rel!== '..'&&!rel.startsWith(`..${path.sep}`)&&!path.isAbsolute(rel);});return root?{repoRoot:config.repoRoot,docsRoots:[root],docsRoot:root}:config;};
|
|
47
|
+
let rows=[],byPath=new Map(),cache=new Map(),pending,pendingMode,lastScan=0,generation=0,stats={};
|
|
48
|
+
let fullDirty=true,knownFiles=new Set(),unavailable=new Set();
|
|
49
|
+
const dirty=new Set();
|
|
50
|
+
function excluded(documentPath) {return documentPath.split('/').some(p=>p==='prompts'||p==='.git'||config.excludeDirs?.has(p)) || path.resolve(config.repoRoot,documentPath)===config.indexPath;}
|
|
51
|
+
function invalidate(documentPaths) {
|
|
52
|
+
if(documentPaths===undefined){fullDirty=true;dirty.clear();return;}
|
|
53
|
+
for(const input of Array.isArray(documentPaths)?documentPaths:[documentPaths]) {
|
|
54
|
+
if(typeof input!=='string'){fullDirty=true;dirty.clear();return;}
|
|
55
|
+
const file=path.resolve(config.repoRoot,input),relative=repoPath(file,config);
|
|
56
|
+
if(!file.endsWith('.md')||excluded(relative)||!roots.some(root=>{const rel=path.relative(root,file);return rel!== '..'&&!rel.startsWith(`..${path.sep}`)&&!path.isAbsolute(rel);}))continue;
|
|
57
|
+
dirty.add(file);
|
|
58
|
+
// A burst of changes falls back to discovery instead of retaining an
|
|
59
|
+
// unbounded queue. Ordinary saves retain only their changed paths.
|
|
60
|
+
if(dirty.size>512){fullDirty=true;dirty.clear();return;}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function refresh(force=false) {
|
|
64
|
+
// Join an existing full discovery. A force request during a targeted read
|
|
65
|
+
// still schedules discovery after it finishes.
|
|
66
|
+
if(force&&pendingMode!=='full')fullDirty=true;
|
|
67
|
+
for(;;) {
|
|
68
|
+
if(pending){await pending;continue;}
|
|
69
|
+
const full=fullDirty||!lastScan||Date.now()-lastScan>=refreshMs;
|
|
70
|
+
if(!full&&!dirty.size)return;
|
|
71
|
+
const changed=[...dirty];dirty.clear();
|
|
72
|
+
if(full)fullDirty=false;
|
|
73
|
+
pendingMode=full?'full':'changed';
|
|
74
|
+
pending=(async()=>{
|
|
75
|
+
const start=performance.now();let headersRead=0,headerBytesRead=0,failures=0,discoveryErrors=0;
|
|
76
|
+
const files=full?collectDocFiles(config,{onError:()=>discoveryErrors++}).filter(file=>!excluded(repoPath(file,config))):changed;
|
|
77
|
+
const next=full?new Map():new Map(cache),nextKnown=full?new Set(files):new Set(knownFiles),nextUnavailable=full?new Set():new Set(unavailable);let index=0;
|
|
78
|
+
const worker=async()=>{while(index<files.length){const file=files[index++],relative=repoPath(file,config);let fd;try{
|
|
79
|
+
const stamp=await lstat(file);if(!stamp.isFile())throw Error('Not a regular document.');
|
|
80
|
+
nextKnown.add(file);nextUnavailable.delete(file);
|
|
81
|
+
const key=`${stamp.dev}:${stamp.ino}:${stamp.size}:${stamp.mtimeMs}:${stamp.ctimeMs}`;
|
|
82
|
+
const cached=cache.get(file);if(cached?.key===key){next.set(file,cached);continue;}
|
|
83
|
+
const authorized=authorizeManagedSource(file,sourceConfig(file));fd=await open(authorized.path,constants.O_RDONLY|(constants.O_NOFOLLOW??0));
|
|
84
|
+
const same=(a,b)=>['dev','ino','size','mtimeMs','ctimeMs','mode'].every(key=>a[key]===b[key]);
|
|
85
|
+
if(!same(stamp,await fd.stat()))throw Error('Document changed while opening.');
|
|
86
|
+
const {source,bytesRead}=await readHeader(fd,stamp.size);headersRead++;headerBytesRead+=bytesRead;
|
|
87
|
+
if(!same(stamp,await fd.stat())||!same(stamp,await lstat(file)))throw Error('Document changed while reading.');
|
|
88
|
+
const parts=extractFrontmatter(source),warnings=[],fm=parseSimpleFrontmatter(parts.frontmatter,warnings);
|
|
89
|
+
let row=null;
|
|
90
|
+
if(fm.type!=='prompt') {
|
|
91
|
+
const type=typeof fm.type==='string'?fm.type.slice(0,100):'untyped';
|
|
92
|
+
row={path:relative,bytes:stamp.size,title:String((fm.record_schema==='runlist.record/v1'&&(fm.finding||fm.question))||fm.title||extractFirstHeading(parts.body)||path.basename(file,'.md')).slice(0,300),type,kind:libraryKind(fm,relative),status:String(fm.status||'unknown').slice(0,100),updated:typeof fm.updated==='string'?fm.updated:null,folder:path.posix.dirname(relative),modules:list(fm.modules),surfaces:list(fm.surfaces),parents:normalizeStringList(fm.parent_plan),archived:fm.status==='archived'||relative.split('/').includes(path.basename(config.archiveDir??'archived')),untyped:type==='untyped',metadataWarnings:warnings.length};
|
|
93
|
+
}
|
|
94
|
+
if(row?.kind==='plan'){const stage=readPlanStage(readShipsFrontmatter(parts.frontmatter));row.stage=stage.word;row.stageInvalid=stage.invalid;}
|
|
95
|
+
if(row&&fm.record_schema==='runlist.record/v1')try{const id=JSON.parse(fm.record_data).repository_id;if(/^repo:[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(id))row.recordRepositoryId=id;}catch{}
|
|
96
|
+
// Parsed headings/frontmatter can be V8 substring views into the whole
|
|
97
|
+
// 128 KiB header. Detach the small metadata record before caching it;
|
|
98
|
+
// otherwise a short title/parent can retain an entire document buffer.
|
|
99
|
+
const refFields={};
|
|
100
|
+
for(const field of [...(config.referenceFields?.bidirectional??[]),...(config.referenceFields?.unidirectional??[])])refFields[field]=referenceValues(fm[field]).paths;
|
|
101
|
+
const filing=row?{path:relative,repoPath:relative,title:row.title,type:typeof fm.type==='string'?fm.type:null,status:typeof fm.status==='string'?fm.status:null,executionMode:typeof fm.execution_mode==='string'?fm.execution_mode:null,refFields,stamp:key,metadataComplete:!warnings.length&&(!/^---\r?\n/.test(source)||parts.bodyLineOffset>0)}:null;
|
|
102
|
+
next.set(file,{key,row:row?JSON.parse(JSON.stringify(row)):null,filing:filing?JSON.parse(JSON.stringify(filing)):null});
|
|
103
|
+
}catch(error){next.delete(file);if(error.code==='ENOENT'){nextKnown.delete(file);nextUnavailable.delete(file);}else{failures++;nextUnavailable.add(file);}}finally{await fd?.close();}await yieldThread();}};
|
|
104
|
+
await Promise.all(Array.from({length:12},worker));cache=next;knownFiles=nextKnown;unavailable=nextUnavailable;rows=[...next.values()].flatMap(v=>v.row?[v.row]:[]);byPath=new Map(rows.map(r=>[r.path,r]));
|
|
105
|
+
// Targeted reads must not postpone periodic discovery of external edits.
|
|
106
|
+
if(full)lastScan=Date.now();generation++;
|
|
107
|
+
const previous=stats;
|
|
108
|
+
stats={files:knownFiles.size,checkedFiles:files.length,refreshMode:pendingMode,fullScans:(previous.fullScans??0)+(full?1:0),incrementalRefreshes:(previous.incrementalRefreshes??0)+(full?0:1),headersRead,headerBytesRead,unavailable:unavailable.size,discoveryErrors:full?discoveryErrors:(previous.discoveryErrors??0),refreshFailures:failures,elapsedMs:Math.round(performance.now()-start),indexedAt:new Date(lastScan).toISOString(),generation};
|
|
109
|
+
})().catch(error=>{fullDirty=true;throw error;}).finally(()=>{pending=null;pendingMode=null;});
|
|
110
|
+
// Invalidations received during any await stay queued for the next pass;
|
|
111
|
+
// callers never return a scan that silently consumed a newer mutation.
|
|
112
|
+
await pending;
|
|
113
|
+
if(refreshMs<=0&&!fullDirty&&!dirty.size)return;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function resolve(ref,from) {
|
|
117
|
+
const clean=String(ref).replace(/^>\s*/,'').split('#')[0].trim();if(!clean.endsWith('.md'))return null;
|
|
118
|
+
const candidates=[path.posix.normalize(path.posix.join(path.posix.dirname(from),clean)),path.posix.normalize(clean.replace(/^\//,''))];
|
|
119
|
+
return candidates.map(p=>byPath.get(p)).find(Boolean)??null;
|
|
120
|
+
}
|
|
121
|
+
function relations(documentPath,fm,body) {
|
|
122
|
+
const parentRefs=normalizeStringList(fm.parent_plan),members=[...new Set([...normalizeStringList(fm.runlist),...(libraryKind(fm,documentPath)==='hub'?detectBodyRunlistRefs(body):[])])];
|
|
123
|
+
const groups=[['Parent hub',parentRefs],['Hub members',members],['Related plans',normalizeStringList(fm.related_plans)],['Related documents',normalizeStringList(fm.related_docs)]];
|
|
124
|
+
const result=groups.flatMap(([label,refs])=>refs.map(ref=>({label,ref,target:resolve(ref,documentPath)})));
|
|
125
|
+
// Parent back-pointers are membership evidence, not arbitrary body links.
|
|
126
|
+
if(libraryKind(fm,documentPath)==='hub')for(const row of rows)if(row.parents.some(ref=>resolve(ref,row.path)?.path===documentPath)&&!result.some(r=>r.label==='Hub members'&&r.target?.path===row.path))result.push({label:'Hub members',ref:row.path,target:row});
|
|
127
|
+
return result.map(r=>({...r,target:r.target?{path:r.target.path,title:r.target.title,status:r.target.status,kind:r.target.kind,type:r.target.type}:null}));
|
|
128
|
+
}
|
|
129
|
+
async function query(params=new URLSearchParams()) {
|
|
130
|
+
await refresh(params.get('refresh')==='1');
|
|
131
|
+
const includeArchived=params.get('archived')==='1',scope=rows.filter(r=>includeArchived||!r.archived),kind=params.get('kind')??'all',status=params.get('status')??'',folder=params.get('folder')??'',type=params.get('type')??'',stage=params.get('stage')??'';
|
|
132
|
+
const words=(params.get('q')??'').slice(0,500).toLowerCase().trim().split(/\s+/).filter(Boolean);
|
|
133
|
+
let matches=scope.filter(r=>(kind==='all'||kind==='documents'&&r.kind==='document'||kind==='hubs'&&r.kind==='hub'||kind==='plans'&&r.kind==='plan')&&matchesLibraryStage(r,stage)&&(!status||r.status===status)&&(!type||r.type===type)&&(!folder||r.folder===folder||r.folder.startsWith(folder+'/'))&&words.every(w=>`${r.title} ${r.path} ${r.status} ${r.type} ${r.modules.join(' ')} ${r.surfaces.join(' ')}`.toLowerCase().includes(w)));
|
|
134
|
+
sortLibraryRows(matches,params,config);
|
|
135
|
+
const number=(key,fallback,max)=>{const n=Number(params.get(key)??fallback);return Number.isInteger(n)&&n>=0?Math.min(n,max):fallback;};
|
|
136
|
+
const limit=Math.max(1,number('limit',50,100)),offset=Math.min(number('offset',0,10_000_000),Math.max(0,Math.floor((matches.length-1)/limit)*limit));
|
|
137
|
+
const counts={all:scope.length,hubs:scope.filter(r=>r.kind==='hub').length,plans:scope.filter(r=>r.kind==='plan').length,documents:scope.filter(r=>r.kind==='document').length};
|
|
138
|
+
const facets=key=>{const counts=new Map();for(const r of scope)counts.set(r[key],(counts.get(r[key])??0)+1);return [...counts].sort(([a],[b])=>a.localeCompare(b)).map(([value,count])=>({value,count}));};
|
|
139
|
+
const stageDefinitions=getStageDefinitions(config),planRows=scope.filter(r=>r.kind==='plan'),stageCounts=new Map();for(const row of planRows){const value=row.stageInvalid?'@invalid':row.stage===null?'@unset':`word:${row.stage}`;stageCounts.set(value,(stageCounts.get(value)??0)+1);}
|
|
140
|
+
const configured=new Set(stageDefinitions.map(s=>`word:${s.word}`)),stages=[...stageDefinitions.map(s=>({value:`word:${s.word}`,label:s.word,meaning:s.meaning,count:stageCounts.get(`word:${s.word}`)??0})),...[...stageCounts].filter(([value])=>!configured.has(value)&&!['@unset','@invalid'].includes(value)).sort(([a],[b])=>a.localeCompare(b)).map(([value,count])=>({value,label:value.slice(5),count,unknown:true})),{value:'@unset',label:'Unset · no stage',count:stageCounts.get('@unset')??0},...(stageCounts.has('@invalid')?[{value:'@invalid',label:'Invalid metadata',count:stageCounts.get('@invalid')}]:[])];
|
|
141
|
+
return {stageDefinitions,group:params.get('group')==='stage'&&kind==='plans'?'stage':null,documents:matches.slice(offset,offset+limit),total:matches.length,inventoryTotal:rows.length,counts,offset,limit,hasMore:offset+limit<matches.length,facets:{stages,statuses:facets('status'),types:facets('type'),folders:facets('folder')},stats};
|
|
142
|
+
}
|
|
143
|
+
async function filingInventory({refresh:force=false}={}) {
|
|
144
|
+
await refresh(force);
|
|
145
|
+
const documents=[...cache.values()].flatMap(entry=>entry.filing?[structuredClone(entry.filing)]:[]);
|
|
146
|
+
const failures=[...unavailable].map(file=>({path:repoPath(file,config),message:'Document metadata could not be read safely.'}));
|
|
147
|
+
for(const doc of documents)if(!doc.metadataComplete)failures.push({path:doc.path,message:'Document reference metadata is incomplete or malformed.'});
|
|
148
|
+
if(stats.discoveryErrors)failures.push({path:'',message:'Some document roots could not be fully discovered.'});
|
|
149
|
+
return {documents,generation,complete:failures.length===0,failures};
|
|
150
|
+
}
|
|
151
|
+
return {query,refresh,filingInventory,async all(){await refresh();return rows.map(row=>({...row}));},resolve,relations,invalidate,get stats(){return stats;}};
|
|
152
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Trusted child runner: CLI lifecycle behavior, without borrowing agent identity.
|
|
2
|
+
import {resolveConfig} from './config.mjs';import {runSet} from './lifecycle.mjs';
|
|
3
|
+
import {runYardstick,assertYardstickReviewGuards} from './yardstick-command.mjs';
|
|
4
|
+
import {isBundledRunlistRuntime} from './desktop-runtime.mjs';
|
|
5
|
+
if (isBundledRunlistRuntime(process.execPath)) process.title='RunlistLifecycle';
|
|
6
|
+
process.on('message',async request=>{
|
|
7
|
+
try{if(request.kind==='yardstick')assertYardstickReviewGuards(request.guards);const config=await resolveConfig(request.repoRoot,request.configPath??undefined);const options={dryRun:request.dryRun,note:request.note,guards:request.guards??[],expectedDestination:request.expectedDestination};
|
|
8
|
+
const result=request.kind==='yardstick'?await runYardstick(request.argv,config,{...options,captureGuards:true}):await runSet([request.status,request.file],config,options);
|
|
9
|
+
process.send({ok:true,result,...(request.kind==='yardstick'?{guards:result.preparedGuards}: {})});}
|
|
10
|
+
catch(error){process.send({ok:false,code:error.code??(/changed|appeared|owned|claim/i.test(error.message)?'lifecycle-conflict':'lifecycle-failed'),message:error.message});}
|
|
11
|
+
finally{process.disconnect();}
|
|
12
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {fork} from 'node:child_process';import {createHash} from 'node:crypto';
|
|
2
|
+
import {existsSync,readFileSync,lstatSync,mkdirSync,readdirSync,openSync,fsyncSync,closeSync} from 'node:fs';import path from 'node:path';
|
|
3
|
+
import {authorizeRepoGeneratedPath,authorizeManagedDestination} from './managed-path.mjs';import {currentProcessOwner,processStartIdentity,processOwnerLiveness,inspectTransactions,withPathLocks,snapshotFile,replaceSnapshot,createFileExclusive} from './atomic-mutation.mjs';
|
|
4
|
+
import {readPlanOwnership,canonicalPlanIdentity} from './pickup.mjs';import {stateDir} from './naming.mjs';import {SourceEditError,sourceRevision,assertPrivateEditorStorage} from './source-editor.mjs';
|
|
5
|
+
import {containingRoot} from './path-containment.mjs';
|
|
6
|
+
import {isBundledRunlistRuntime} from './desktop-runtime.mjs';
|
|
7
|
+
const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
|
|
8
|
+
const fail=(code,message)=>{throw new SourceEditError(code,message);};const json=value=>JSON.stringify(value,null,2)+'\n';
|
|
9
|
+
const activeJobs=new Set();
|
|
10
|
+
function worker(request,onStart){return new Promise((resolve,reject)=>{
|
|
11
|
+
const env={...process.env};for(const key of Object.keys(env))if(/SESSION|^CODEX_|^OPENCODE_/.test(key))delete env[key];
|
|
12
|
+
const child=fork(new URL('./app-lifecycle-worker.mjs',import.meta.url),[],{env,windowsHide:true,stdio:['ignore','pipe','pipe','ipc'],execArgv:isBundledRunlistRuntime(process.execPath)?['--max-old-space-size=256','--max-semi-space-size=4']:[]});let report='',result;
|
|
13
|
+
const timer=setTimeout(()=>{child.kill();reject(new SourceEditError('lifecycle-uncertain','The lifecycle runner timed out. Inspect its retained operation before retrying.'));},60_000);
|
|
14
|
+
const append=data=>{if(report.length<128000)report+=data;};child.stdout.on('data',append);child.stderr.on('data',append);child.on('message',value=>{result=value;});child.on('error',error=>{clearTimeout(timer);reject(error);});child.on('exit',()=>{clearTimeout(timer);if(!result)return reject(new SourceEditError('lifecycle-uncertain','The lifecycle runner stopped. Inspect its retained operation before retrying.'));if(!result.ok)return reject(new SourceEditError(result.code,result.message,{report}));resolve({result:result.result,report:report.replace(/\x1b\[[0-9;]*m/g,'')});});try{onStart?.({...currentProcessOwner(),pid:child.pid,processStartIdentity:processStartIdentity(child.pid),processStartedAt:new Date().toISOString()});child.send(request);}catch(error){child.kill();clearTimeout(timer);reject(error);}
|
|
15
|
+
});}
|
|
16
|
+
export function createAppLifecycle({config,read,actor}) {
|
|
17
|
+
const directory=path.join(stateDir(config.repoRoot),'editor','lifecycle');
|
|
18
|
+
const safe=input=>{const file=authorizeRepoGeneratedPath(input,config,{kind:'Lifecycle receipt'}).path;let current=config.repoRoot;for(const part of path.relative(config.repoRoot,file).split(path.sep)){current=path.join(current,part);if(existsSync(current)&&lstatSync(current).isSymbolicLink())fail('unsafe-local-state','Lifecycle receipts may not traverse symlinks.');}return file;};
|
|
19
|
+
const location=id=>{if(!uuid.test(id??''))fail('invalid-id','A UUID v4 operation is required.');return safe(path.join(directory,`${id}.json`));};
|
|
20
|
+
function storage(){assertPrivateEditorStorage(config.repoRoot,directory);
|
|
21
|
+
const make=dir=>{safe(dir);if(existsSync(dir)){if(!lstatSync(dir).isDirectory())fail('unsafe-local-state','Receipt storage must be a directory.');return;}make(path.dirname(dir));mkdirSync(dir,{mode:0o700});let fd;try{fd=openSync(path.dirname(dir),'r');fsyncSync(fd);}catch(error){if(!['EINVAL','ENOTSUP','EPERM','EACCES','EBADF','EISDIR'].includes(error.code))throw error;}finally{if(fd!==undefined)closeSync(fd);}};make(directory);
|
|
22
|
+
}
|
|
23
|
+
function receipt(id){const file=location(id);if(!existsSync(file))return null;if(!lstatSync(file).isFile()||lstatSync(file).size>16*1024*1024)fail('state-corrupt','Inspect the lifecycle receipt before continuing.');try{const value=JSON.parse(readFileSync(file,'utf8'));if(value.id!==id||value.schema!==1||!['reviewed','running','committed','failed','settled-unknown'].includes(value.state)||typeof value.path!=='string'||typeof value.newPath!=='string'||value.file!==path.resolve(config.repoRoot,value.path)||!value.actor||!Array.isArray(value.guards)||value.guards[0]?.path!==value.file||sourceRevision(value.guards[0]?.expectedContent)!==value.expectedRevision)throw new Error('invalid');return value;}catch{fail('state-corrupt','Inspect the lifecycle receipt before continuing.');}}
|
|
24
|
+
function write(value){const file=location(value.id);if(existsSync(file))replaceSnapshot(snapshotFile(file),json(value),{repoRoot:config.repoRoot,locked:true});else createFileExclusive(file,json(value),{repoRoot:config.repoRoot,locked:true,mode:0o600});}
|
|
25
|
+
const own=(context,value)=>{const current=actor(context);if(current.kind!==value.actor.kind||current.id!==value.actor.id)fail('forbidden','This lifecycle review belongs to another actor.');};
|
|
26
|
+
function eligible(context,input){const doc=read(context,input);if(!doc.editable||['flag','decision','prompt'].includes(doc.type))fail('lifecycle-unavailable','Use this document’s domain actions or repair its source before changing lifecycle status.');if(doc.metadata?.record_schema)fail('lifecycle-unavailable','Status changes for structured plans are not available yet. You can continue editing this plan’s text; its current status and history are kept.');const ownership=readPlanOwnership(doc.path,config);if(ownership?.corrupt||ownership?.state==='owned'||doc.status==='in-session')fail('claim-conflict','This source is claimed. Coordinate or release its claim through the CLI first.');
|
|
27
|
+
if(existsSync(directory)){safe(directory);for(const name of readdirSync(directory).filter(n=>n.endsWith('.json'))){const value=receipt(name.slice(0,-5));if(value?.state==='running'&&[value.path,value.newPath].includes(doc.path))fail('lifecycle-repair-required','A lifecycle operation for this document may be interrupted. Inspect its receipt and CLI transactions before starting another change.');}}
|
|
28
|
+
const edits=safe(path.join(stateDir(config.repoRoot),'editor','operations'));if(existsSync(edits))for(const name of readdirSync(edits).filter(n=>n.endsWith('.json'))){const file=safe(path.join(edits,name));let value;try{value=JSON.parse(readFileSync(file,'utf8'));}catch{fail('state-corrupt','Inspect private editor operations before changing lifecycle status.');}if(value.state==='prepared'&&value.path===path.resolve(config.repoRoot,doc.path))fail('lifecycle-repair-required','Inspect the pending editor save before moving this source or changing its lifecycle status.');}
|
|
29
|
+
return doc;}
|
|
30
|
+
function statuses(doc){const rootLabel=path.relative(config.repoRoot,containingRoot(path.resolve(config.repoRoot,doc.path),config.docsRoots??[config.docsRoot])??config.docsRoot).split(path.sep).join('/');return [...(config.typeStatuses?.get(doc.type)??config.rootValidStatuses?.get(rootLabel)??config.validStatuses)].filter(s=>s!=='in-session');}
|
|
31
|
+
function information(context,input){const doc=read(context,input);try{eligible(context,input);return {statuses:statuses(doc),current:doc.status,enabled:true};}catch(error){return {statuses:[],current:doc.status,enabled:false,reason:error.message};}}
|
|
32
|
+
async function preview(context,request){const doc=eligible(context,request.path),who=actor(context);if(who.kind!=='human')fail('forbidden','The application lifecycle runner is human scoped.');if(!statuses(doc).includes(request.status)||request.status===doc.status)fail('invalid-status','Choose a different configured status.');if(doc.revision!==request.expectedRevision)fail('revision-conflict','The document changed. Reload it before reviewing lifecycle changes.');if(typeof request.note!=='string'||!request.note.trim()||request.note.length>4000)fail('invalid-request','Give a reason, up to 4,000 characters.');
|
|
33
|
+
const file=path.resolve(config.repoRoot,doc.path),owner=path.join(stateDir(config.repoRoot),'ownership',canonicalPlanIdentity(file,config).key+'.json');
|
|
34
|
+
const guards=[{path:file,expectedContent:doc.source},existsSync(owner)?{path:owner,expectedContent:readFileSync(owner,'utf8')}:{path:owner,absent:true}];if(config.configPath)guards.push({path:config.configPath,expectedContent:readFileSync(config.configPath,'utf8')});
|
|
35
|
+
const requestHash=createHash('sha256').update(JSON.stringify([doc.path,doc.revision,request.status,request.note,who.id])).digest('hex');
|
|
36
|
+
const note=`${request.note.trim().replace(/[\r\n]+/g,' ')} — ${who.label??who.id} (human)`;
|
|
37
|
+
const prepared=await worker({repoRoot:config.repoRoot,configPath:config.configPath,file,status:request.status,note,dryRun:true});storage();
|
|
38
|
+
return withPathLocks([location(request.operationId)],{repoRoot:config.repoRoot},()=>{const prior=receipt(request.operationId);if(prior){own(context,prior);if(prior.requestHash!==requestHash)fail('operation-reused','This ID already belongs to another lifecycle review.');return publicReview(prior);}
|
|
39
|
+
const value={schema:1,id:request.operationId,state:'reviewed',actor:who,requestHash,path:doc.path,file,status:request.status,note,guards,expectedRevision:doc.revision,report:prepared.report,newPath:prepared.result?.newRepoPath??doc.path,at:new Date().toISOString()};write(value);return publicReview(value);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function publicReview(value){return {operationId:value.id,path:value.path,newPath:value.newPath,status:value.status,report:value.report,state:value.state,result:value.result??null};}
|
|
43
|
+
async function commit(context,{operationId}){storage();const current=withPathLocks([location(operationId)],{repoRoot:config.repoRoot},()=>{const value=receipt(operationId);if(!value)fail('operation-missing','Review this lifecycle change first.');own(context,value);if(value.state==='committed')return value;if(value.state==='running')fail('lifecycle-repair-required','This lifecycle operation may be running or interrupted. Inspect it before starting another change.');if(value.state==='settled-unknown')fail('lifecycle-settled','This operation was acknowledged without replay. Review a new change against the current source.');if(value.state==='failed')fail(value.failure.code,value.failure.message);const doc=eligible(context,value.path);if(doc.revision!==value.expectedRevision)fail('revision-conflict','The source changed since this review. Your review is kept; reload and review it again.');value.state='running';value.parentOwner=currentProcessOwner();write(value);return value;});if(current.state==='committed')return {...publicReview(current),replayed:true};
|
|
44
|
+
const activeKey=config.repoRoot+':'+operationId;activeJobs.add(activeKey);
|
|
45
|
+
try{const outcome=await worker({repoRoot:config.repoRoot,configPath:config.configPath,file:current.file,status:current.status,note:current.note,guards:current.guards,expectedDestination:path.resolve(config.repoRoot,current.newPath),dryRun:false},owner=>withPathLocks([location(operationId)],{repoRoot:config.repoRoot},()=>{const value=receipt(operationId);value.workerOwner=owner;write(value);}));return withPathLocks([location(operationId)],{repoRoot:config.repoRoot},()=>{const value=receipt(operationId);if(value.state!=='running')fail('lifecycle-repair-required','The retained lifecycle outcome changed while the runner was finishing. Inspect it before continuing.');value.state='committed';value.result=outcome.result;value.report=outcome.report;write(value);return publicReview(value);});}
|
|
46
|
+
catch(error){withPathLocks([location(operationId)],{repoRoot:config.repoRoot},()=>{const value=receipt(operationId);value.failure={code:error.code??'lifecycle-failed',message:error.message};if(error.code!=='lifecycle-uncertain'&&existsSync(current.file)&&sourceRevision(readFileSync(current.file,'utf8'))===current.expectedRevision&&inspectTransactions(config.repoRoot).every(tx=>tx.generation==='old'&&tx.resolvable)){value.state='failed';}write(value);});throw error;}
|
|
47
|
+
finally{activeJobs.delete(activeKey);}
|
|
48
|
+
}
|
|
49
|
+
function currentDocument(context,value){const target=path.resolve(config.repoRoot,value.newPath);return existsSync(target)?read(context,value.newPath):existsSync(value.file)?read(context,value.path):null;}
|
|
50
|
+
const running=value=>activeJobs.has(config.repoRoot+':'+value.id)||(value.workerOwner?processOwnerLiveness(value.workerOwner)!=='dead':value.parentOwner&&processOwnerLiveness(value.parentOwner)!=='dead');
|
|
51
|
+
function inspect(context,{operationId}){const value=receipt(operationId);if(!value)return null;own(context,value);return {...publicReview(value),canAcknowledge:value.state==='running'&&!running(value),failure:value.failure??null,settlement:value.settlement??null,current:currentDocument(context,value)};}
|
|
52
|
+
function settle(context,{operationId,expectedRevision,note}){
|
|
53
|
+
storage();const existing=receipt(operationId);if(!existing)fail('operation-missing','There is no retained lifecycle operation.');own(context,existing);
|
|
54
|
+
return withPathLocks([location(operationId),existing.file,path.resolve(config.repoRoot,existing.newPath)],{repoRoot:config.repoRoot},()=>{
|
|
55
|
+
const value=receipt(operationId);own(context,value);if(value.state!=='running')fail('invalid-request','Only an uncertain lifecycle operation can be acknowledged.');
|
|
56
|
+
if(running(value))fail('operation-running','The lifecycle runner is still active or cannot be verified as stopped. Wait before acknowledging its outcome.');
|
|
57
|
+
if(actor(context).kind!=='human')fail('forbidden','A human must acknowledge an inspected lifecycle outcome.');
|
|
58
|
+
if(typeof note!=='string'||!note.trim()||note.length>4000)fail('invalid-request','Record what you inspected, up to 4,000 characters.');
|
|
59
|
+
if(inspectTransactions(config.repoRoot).length)fail('lifecycle-repair-required','Inspect and resolve retained CLI transactions with runlist doctor --transactions before acknowledging this operation.');
|
|
60
|
+
const current=currentDocument(context,value);if(!current||current.revision!==expectedRevision)fail('revision-conflict','The current source changed. Inspect the operation again before acknowledging it.');
|
|
61
|
+
value.state='settled-unknown';value.settlement={by:actor(context),at:new Date().toISOString(),reason:note.trim(),outcome:'unknown',current:{path:current.path,revision:current.revision,status:current.status}};write(value);
|
|
62
|
+
return {...publicReview(value),current,settlement:value.settlement};
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function list(context){
|
|
66
|
+
safe(directory);if(!existsSync(directory))return {items:[],unavailable:0};
|
|
67
|
+
const items=[];let unavailable=0;
|
|
68
|
+
for(const name of readdirSync(directory).filter(n=>n.endsWith('.json')&&uuid.test(n.slice(0,-5))))try{
|
|
69
|
+
const value=receipt(name.slice(0,-5));if(value.actor.id!==actor(context).id||!['reviewed','running','failed'].includes(value.state))continue;own(context,value);
|
|
70
|
+
// Use the same path authorization as document opening, including exclusions.
|
|
71
|
+
authorizeManagedDestination(path.resolve(config.repoRoot,value.newPath),config);
|
|
72
|
+
let doc=null;try{doc=currentDocument(context,value);}catch{unavailable++;continue;}
|
|
73
|
+
if(!doc){unavailable++;continue;}
|
|
74
|
+
items.push({kind:'lifecycle',path:doc.path,operationId:value.id,state:value.state,at:value.at,available:true,newPath:value.newPath});
|
|
75
|
+
}catch{unavailable++;}
|
|
76
|
+
return {items,unavailable};
|
|
77
|
+
}
|
|
78
|
+
return {information,preview,commit,inspect,settle,list};
|
|
79
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { setImmediate as yieldThread } from 'node:timers/promises';
|
|
3
|
+
import { analyzeDecisionItems, blocksOf, decisionSettings, parseDecisionItems, pendingRows, dispositionOf, openWork } from './decisions.mjs';
|
|
4
|
+
import { deriveFlags, flagRevision, flagsFile, readFlagEvents } from './flags.mjs';
|
|
5
|
+
import { authorizeRepoGeneratedPath } from './managed-path.mjs';
|
|
6
|
+
import { parseNativeRecord } from './native-record.mjs';
|
|
7
|
+
import { readPlanOwnership } from './pickup.mjs';
|
|
8
|
+
import { SourceEditError } from './source-editor.mjs';
|
|
9
|
+
|
|
10
|
+
const nativeKey=doc=>`${doc}#native`,decisionKey=row=>`${row.doc}#${row.line}:${row.id??''}`;
|
|
11
|
+
const detached=value=>JSON.parse(JSON.stringify(value));
|
|
12
|
+
const escapePattern=value=>value.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
|
|
13
|
+
// Match all known flag IDs in one pass per document, rather than one regex
|
|
14
|
+
// scan per flag over the entire corpus. Paths retain their substring semantics.
|
|
15
|
+
function flagIndexer(rows){
|
|
16
|
+
const byId=new Map(),byPath=new Map(),links=new Map();
|
|
17
|
+
for(const row of rows){
|
|
18
|
+
const key=JSON.stringify([row.id,row.path]);links.set(key,[]);
|
|
19
|
+
for(const [map,value] of [[byId,String(row.id)],[byPath,row.path]]){if(!map.has(value))map.set(value,[]);map.get(value).push(key);}
|
|
20
|
+
}
|
|
21
|
+
const patterns=[...byId].map(([id,keys])=>({id,keys,pattern:new RegExp(`(?<![A-Za-z0-9])${escapePattern(id)}(?![A-Za-z0-9])`)}));
|
|
22
|
+
const matchingKeys=new Map(patterns.map(({id})=>[id,patterns.filter(entry=>entry.pattern.test(id)).flatMap(entry=>entry.keys)]));
|
|
23
|
+
const ids=byId.size?new RegExp(`(?<![A-Za-z0-9])(?:${[...byId.keys()].sort((a,b)=>b.length-a.length).map(escapePattern).join('|')})(?![A-Za-z0-9])`,'g'):null;
|
|
24
|
+
// A zero-width lookahead also finds paths overlapping at different offsets.
|
|
25
|
+
// Same-offset prefixes are included by the longest match's containment map.
|
|
26
|
+
const files=[...byPath.keys()].filter(Boolean),paths=files.length?new RegExp(`(?=(${files.sort((a,b)=>b.length-a.length).map(escapePattern).join('|')}))`,'g'):null;
|
|
27
|
+
const pathKeys=new Map(files.map(file=>[file,files.filter(other=>file.includes(other)).flatMap(other=>byPath.get(other))]));
|
|
28
|
+
return {links,add(doc,source){
|
|
29
|
+
const keys=new Set();
|
|
30
|
+
for(const key of byPath.get('')??[])keys.add(key);
|
|
31
|
+
if(ids)for(const match of source.matchAll(ids))for(const key of matchingKeys.get(match[0]))keys.add(key);
|
|
32
|
+
if(paths)for(const match of source.matchAll(paths))for(const key of pathKeys.get(match[1]))keys.add(key);
|
|
33
|
+
for(const key of keys)links.get(key).push(doc);
|
|
34
|
+
}};
|
|
35
|
+
}
|
|
36
|
+
export function createRecordLibrary({config,library,readSource,evidence}) {
|
|
37
|
+
const settings=decisionSettings(config.raw?.decisions??{});
|
|
38
|
+
let documents=[],sourcePaths=[],decisions=[],flagLinks=new Map(),lastScan=0,pending,unavailable=0;
|
|
39
|
+
const flagKey=row=>JSON.stringify([row.id,row.path]);
|
|
40
|
+
const flagPattern=row=>new RegExp(`(?<![A-Za-z0-9])${String(row.id).replace(/[.*+?^${}()|[\]\\]/g,'\\$&')}(?![A-Za-z0-9])`);
|
|
41
|
+
const allowed=file=>typeof file==='string'&&!path.isAbsolute(file)&&!file.split(/[\\/]/).some(p=>p==='..'||p==='prompts'||p==='.git'||config.excludeDirs?.has(p));
|
|
42
|
+
function journal(){const file=flagsFile(config);authorizeRepoGeneratedPath(file,config,{kind:'Flag journal'});return readFlagEvents(file).filter(e=>e.event!=='add'||allowed(e.file));}
|
|
43
|
+
async function refresh(context,force=false){
|
|
44
|
+
if(pending){await pending;return refresh(context);}
|
|
45
|
+
if(!force&&lastScan&&Date.now()-lastScan<15_000)return;
|
|
46
|
+
const observedInvalidation=invalidation;
|
|
47
|
+
pending=(async()=>{
|
|
48
|
+
await library.refresh(force);const nextDocuments=await library.all(),sources=[],records=[],native=[];let nextUnavailable=0,indexer;
|
|
49
|
+
// Native summaries first, so their IDs are available to the flag index.
|
|
50
|
+
const ordered=[...nextDocuments.filter(doc=>['decision','flag'].includes(doc.type)),...nextDocuments.filter(doc=>!['decision','flag'].includes(doc.type))];
|
|
51
|
+
for(const [index,doc] of ordered.entries()){
|
|
52
|
+
const legacy=(settings.types.includes(doc.type)||doc.type==='untyped')&&!doc.archived&&(!settings.paths||[].concat(settings.paths).some(p=>doc.path===p||doc.path.startsWith(p.replace(/\/$/,'')+'/')));
|
|
53
|
+
if(!legacy&&!['decision','flag'].includes(doc.type))continue;
|
|
54
|
+
try{
|
|
55
|
+
const opened=readSource(context,doc.path);
|
|
56
|
+
if(['decision','flag'].includes(doc.type)){
|
|
57
|
+
const parsed=parseNativeRecord(opened.source),relations=parsed.record?.record_data?.relations;
|
|
58
|
+
native.push({key:parsed.record?.id??nativeKey(doc.path),native:true,kind:doc.type,id:parsed.record?.id??doc.path,title:parsed.record?.question??parsed.record?.finding??doc.title,question:parsed.record?.question??doc.title,status:parsed.record?.status==='rejected'?'closed':parsed.record?.status??doc.status,severity:parsed.record?.record_data?.severity,triage:parsed.record?.record_data?.triage?.disposition,hints:(Array.isArray(relations)?relations:[]).map(r=>r?.target?.path).filter(allowed),path:doc.path,line:1,editable:false,diagnostics:parsed.diagnostics});
|
|
59
|
+
}else{
|
|
60
|
+
indexer??=flagIndexer([...flags().filter(row=>!row.native),...native.filter(row=>row.kind==='flag')]);
|
|
61
|
+
indexer.add(doc.path,opened.source);
|
|
62
|
+
// Parse and detach this file's useful fragments before moving on.
|
|
63
|
+
// Neither sliced strings nor a corpus-sized source array survive.
|
|
64
|
+
records.push(...detached(parseDecisionItems(opened.source,settings).map(item=>({...item,doc:doc.path,docTitle:doc.title}))));
|
|
65
|
+
sources.push(detached({path:doc.path,work:openWork(opened.source)}));
|
|
66
|
+
}
|
|
67
|
+
}catch{nextUnavailable++;}
|
|
68
|
+
if(index%12===0)await yieldThread();
|
|
69
|
+
}
|
|
70
|
+
const identities=new Map();for(const row of native)identities.set(row.id,(identities.get(row.id)??0)+1);
|
|
71
|
+
for(const row of native)if(identities.get(row.id)>1){row.duplicate=true;row.key=nativeKey(row.path);}
|
|
72
|
+
const items=analyzeDecisionItems(records,settings),blocks=blocksOf(items,sources,items),byLocation=new Map(items.map(i=>[`${i.doc}:${i.line}`,i]));
|
|
73
|
+
const nextDecisions=pendingRows(items,{all:true,blocks}).map(row=>({...row,key:decisionKey(row),kind:'decision',path:row.doc,searchText:byLocation.get(`${row.doc}:${row.line}`)?.text,peers:(byLocation.get(`${row.doc}:${row.line}`)?.peers??[]).map(p=>({doc:p.doc,line:p.line,id:p.id,status:p.effective})),title:row.question||row.id,status:row.disposition??'unknown'}));
|
|
74
|
+
for(const item of items.filter(i=>!i.id))nextDecisions.push({key:decisionKey(item),kind:'decision',path:item.doc,line:item.line,id:null,title:'Unnamed decision',question:'Unnamed decision',status:item.effective??'unknown',missing:item.missing});
|
|
75
|
+
nextDecisions.push(...native.filter(row=>row.kind==='decision'));
|
|
76
|
+
const nextNativeFlags=native.filter(row=>row.kind==='flag');
|
|
77
|
+
indexer??=flagIndexer([...flags().filter(row=>!row.native),...nextNativeFlags]);
|
|
78
|
+
// Detach substring-backed record text too. Peer bodies are read on demand;
|
|
79
|
+
// the analysis graph and full documents belong only to this refresh.
|
|
80
|
+
decisions=nextDecisions.map(detached);
|
|
81
|
+
nativeFlags=nextNativeFlags.map(detached);
|
|
82
|
+
documents=nextDocuments;sourcePaths=sources.map(doc=>doc.path);flagLinks=indexer.links;unavailable=nextUnavailable;lastScan=observedInvalidation===invalidation?Date.now():0;
|
|
83
|
+
})().finally(()=>{pending=null;});await pending;if(!lastScan)return refresh(context);
|
|
84
|
+
}
|
|
85
|
+
let nativeFlags=[];
|
|
86
|
+
function flags(){return [...deriveFlags(journal()).map(flag=>({...flag,key:flag.id,kind:'flag',path:flag.file,title:flag.text,status:flag.state,revision:flagRevision(flag)})),...nativeFlags];}
|
|
87
|
+
function connections(row,context){
|
|
88
|
+
const paths=new Set([row.path,...(row.hints??[]),...(row.blocks??[]).map(b=>b.doc),...(row.peers??[]).map(p=>p.doc)]);
|
|
89
|
+
if(row.kind==='flag'){
|
|
90
|
+
const key=flagKey(row);
|
|
91
|
+
// Journal additions are visible between document refreshes. Resolve a new
|
|
92
|
+
// finding once using the same authenticated source reader, then cache paths.
|
|
93
|
+
if(!flagLinks.has(key)){
|
|
94
|
+
const pattern=flagPattern(row),matches=[];
|
|
95
|
+
for(const sourcePath of sourcePaths)try{const {source}=readSource(context,sourcePath);if(source.includes(row.path)||pattern.test(source))matches.push(sourcePath);}catch{/* Missing/unsafe sources supply no connection. */}
|
|
96
|
+
flagLinks.set(key,matches);
|
|
97
|
+
}
|
|
98
|
+
for(const sourcePath of flagLinks.get(key))paths.add(sourcePath);
|
|
99
|
+
}
|
|
100
|
+
const links=documents.filter(doc=>paths.has(doc.path));
|
|
101
|
+
for(const doc of [...links])for(const relation of library.relations(doc.path,{parent_plan:doc.parents},''))if(relation.label==='Parent hub'&&relation.target&&!links.some(l=>l.path===relation.target.path))links.push(relation.target);
|
|
102
|
+
return links.map(({path,title,type,kind,status})=>({path,title,type,kind,status}));
|
|
103
|
+
}
|
|
104
|
+
async function query(context,kind,params){
|
|
105
|
+
await refresh(context,params.get('refresh')==='1');
|
|
106
|
+
const flagRows=flags(),all=kind==='flags'?flagRows:decisions,words=(params.get('q')??'').slice(0,500).toLowerCase().trim().split(/\s+/).filter(Boolean),status=params.get('status')??'pending',severity=params.get('severity')??'',scope=params.get('scope')??'';
|
|
107
|
+
let rows=all.filter(row=>(!status||status==='pending'?(!status||['open','held'].includes(row.status)):row.status===status)&&(!severity||row.severity===severity)&&(!scope||row.path===scope||connections(row,context).some(c=>c.path===scope))&&words.every(word=>`${row.id} ${row.title} ${row.question??''} ${row.path} ${row.status} ${row.text??''} ${row.searchText??''}`.toLowerCase().includes(word)));
|
|
108
|
+
rows.sort(kind==='flags'?(a,b)=>['problem','warn','info'].indexOf(a.severity)-['problem','warn','info'].indexOf(b.severity)||String(b.at??'').localeCompare(String(a.at??'')):(a,b)=>String(a.id??'').localeCompare(String(b.id??''),'en',{numeric:true})||a.path.localeCompare(b.path));
|
|
109
|
+
const sourcePaths=new Set(all.flatMap(row=>[row.path,...(row.peers??[]).map(p=>p.doc),...(row.blocks??[]).map(b=>b.doc),...(row.hints??[])]));
|
|
110
|
+
const offset=Math.max(0,Math.min(Number(params.get('offset'))||0,Math.max(0,Math.floor((rows.length-1)/50)*50))),limit=50;
|
|
111
|
+
return {records:rows.slice(offset,offset+limit).map(row=>({key:row.key,id:row.id,title:row.title,status:row.status,severity:row.severity,triage:row.triage,path:row.path,line:row.line,native:row.native,missing:row.missing})),total:rows.length,offset,hasMore:offset+limit<rows.length,counts:{flags:flagRows.filter(f=>f.status==='open').length,decisions:decisions.filter(d=>['open','held'].includes(d.status)).length},scopes:documents.filter(d=>sourcePaths.has(d.path)||d.kind==='hub').map(d=>({path:d.path,title:d.title})),unavailable};
|
|
112
|
+
}
|
|
113
|
+
async function detail(context,kind,key){
|
|
114
|
+
await refresh(context);const row=(kind==='flags'?flags():decisions).find(row=>row.key===key);
|
|
115
|
+
if(!row)throw new SourceEditError('record-not-found','This record is unavailable. Refresh the list.');
|
|
116
|
+
const peers=kind==='decisions'&&!row.native?(row.peers??[]):[];
|
|
117
|
+
const links=connections(row,context);
|
|
118
|
+
if(row.native){const opened=readSource(context,row.path),parsed=parseNativeRecord(opened.source),claim=readPlanOwnership(row.path,config);return {...row,status:parsed.record?.status==='rejected'?'closed':parsed.record?.status??row.status,revision:opened.revision,body:parsed.body??'',data:parsed.record?.record_data,diagnostics:row.duplicate?[...parsed.diagnostics,{code:'duplicate-record-id',message:'This ID is declared in multiple documents. Reconcile their identities before recording an action.'}]:parsed.diagnostics,connections:links,editable:parsed.ok&&!row.duplicate&&!claim?.corrupt&&claim?.state!=='owned',readOnlyReason:row.duplicate?'This record ID is declared in multiple documents. Reconcile their identities first.':claim?.corrupt?'Ownership needs repair through the CLI.':claim?.state==='owned'?'This record is claimed by another session. Coordinate through the CLI first.':null};}
|
|
119
|
+
if(kind==='flags')return {...row,...evidence(row),connections:links,editable:row.state==='open'};
|
|
120
|
+
const opened=readSource(context,row.path),item=parseDecisionItems(opened.source,settings).find(i=>i.id===row.id&&i.line===row.line);
|
|
121
|
+
if(!item)throw new SourceEditError('decision-revision-conflict','This decision moved. Refresh the list to find its current record.');
|
|
122
|
+
const peerSources=new Map([[row.path,parseDecisionItems(opened.source,settings)]]);
|
|
123
|
+
const peerDetails=peers.map(peer=>{
|
|
124
|
+
if(!peerSources.has(peer.doc))peerSources.set(peer.doc,parseDecisionItems(readSource(context,peer.doc).source,settings));
|
|
125
|
+
const current=peerSources.get(peer.doc).find(item=>item.id===peer.id&&item.line===peer.line);
|
|
126
|
+
if(!current)throw new SourceEditError('decision-revision-conflict','A related decision moved. Refresh the list to find its current record.');
|
|
127
|
+
return {path:peer.doc,line:peer.line,id:peer.id,status:peer.status,body:current.text};
|
|
128
|
+
});
|
|
129
|
+
const ownership=readPlanOwnership(row.path,config),claimBlocked=ownership?.corrupt||ownership?.state==='owned';
|
|
130
|
+
return {...row,claim:ownership?{state:ownership.state,corrupt:ownership.corrupt,sessionId:ownership.sessionId}:null,readOnlyReason:claimBlocked?(ownership.corrupt?'Ownership needs repair through the CLI before recording an outcome.':`This source is owned by session ${ownership.sessionId}. Coordinate or release that claim through the CLI before recording an outcome.`):null,status:dispositionOf(item,settings)??row.status,revision:opened.revision,body:item.text,blocks:row.blocks??[],missing:row.missing??[],connections:links,peers:peerDetails,editable:opened.editable&&!!row.id&&!claimBlocked,source:opened.source};
|
|
131
|
+
}
|
|
132
|
+
let invalidation=0;
|
|
133
|
+
return {query,detail,refresh,invalidate(){invalidation++;lastScan=0;}};
|
|
134
|
+
}
|