@supersuit/transcript-md 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ // Keep informational commands ahead of imports: even schema/config IO is unnecessary there.
3
+ const commands=['list','read','validate','sources','resolve','cite','seek','create','annotate','revise','recover'];
4
+ const args=process.argv.slice(2);
5
+ if(args.length===1&&args[0]==='--help') {
6
+ console.log('Conversation records\n\nlist [--workspace DIR | --transcripts-dir DIR] [--include-sections] [--json]\nread FILE [--workspace DIR] [--include-body] [--json]\nvalidate FILE [--workspace DIR] [--json]\nsources FILE [--workspace DIR] [--json]\nresolve ID [--workspace DIR] [--json]\ncite FILE --turn ID [--turn ID] [--workspace DIR] [--json]\nseek FILE --turn ID [--capture ID] [--workspace DIR] [--json]\ncreate --workspace DIR --spec FILE [--json]\nannotate FILE --spec FILE --expected-sha SHA [--workspace DIR] [--json]\nrevise FILE --spec FILE --expected-sha SHA [--workspace DIR] [--json]\nrecover FILE --revision-id ID --action resume|reverse [--workspace DIR] [--json]\n--capabilities --json');
7
+ } else if(args.length===2&&args.includes('--capabilities')&&args.includes('--json')) {
8
+ console.log(JSON.stringify({api_version:1,schema_versions:[1],commands,revision:null}));
9
+ } else {
10
+ const json=args.includes('--json');
11
+ const diagnostic=code=>({code,severity:'error',path:null,field:null,message:'Conversation command could not complete; check arguments and record diagnostics.'});
12
+ const conflict=code=>['LOCKED','STALE_PIN','STALE_TURN_PIN','STALE_REVISION_RETRY','RECOVERY_THIRD_STATE','REVISION_INCOMPLETE','REVISION_ID_COLLISION'].includes(code)||code.endsWith('_COLLISION');
13
+ const exitFor=diagnostics=>diagnostics.some(d=>d.code==='PRIVACY_REFUSED')?4:diagnostics.some(d=>conflict(d.code))?3:diagnostics.some(d=>['ID_COLLISION','AMBIGUOUS_ALIAS'].includes(d.code))?3:diagnostics.some(d=>d.severity==='error'&&d.code!=='NOT_FOUND')?2:diagnostics.some(d=>d.code==='NOT_FOUND')?1:0;
14
+ try {
15
+ const command=args[0],options={},positionals=[];
16
+ if(!commands.includes(command))throw new Error('INVALID_ARGUMENTS');
17
+ for(let i=1;i<args.length;i++) {
18
+ const arg=args[i];
19
+ if(arg==='--turn') {
20
+ if(!args[i+1]||args[i+1].startsWith('--'))throw new Error('INVALID_ARGUMENTS');(options[arg]??=[]).push(args[++i]);
21
+ } else if(['--workspace','--transcripts-dir','--capture','--spec','--expected-sha','--revision-id','--action'].includes(arg)) {
22
+ if(Object.hasOwn(options,arg)||!args[i+1]||args[i+1].startsWith('--'))throw new Error('INVALID_ARGUMENTS');options[arg]=args[++i];
23
+ } else if(['--json','--include-body','--include-sections'].includes(arg)) {
24
+ if(Object.hasOwn(options,arg))throw new Error('INVALID_ARGUMENTS');options[arg]=true;
25
+ } else if(arg.startsWith('--'))throw new Error('INVALID_ARGUMENTS');
26
+ else positionals.push(arg);
27
+ }
28
+ if(options['--workspace']&&options['--transcripts-dir']||options['--transcripts-dir']&&command!=='list'||options['--include-body']&&command!=='read'||options['--include-sections']&&command!=='list'||positionals.length!==(['list','create'].includes(command)?0:1))throw new Error('INVALID_ARGUMENTS');
29
+ if(options['--turn']&&!['cite','seek'].includes(command)||options['--capture']&&command!=='seek'||['cite','seek'].includes(command)&&!options['--turn']?.length||command==='seek'&&options['--turn'].length!==1)throw new Error('INVALID_ARGUMENTS');
30
+ const mutations=['create','annotate','revise','recover'];
31
+ if(options['--spec']&&!['create','annotate','revise'].includes(command)||options['--expected-sha']&&!['annotate','revise'].includes(command)||options['--revision-id']&&command!=='recover'||options['--action']&&command!=='recover')throw new Error('INVALID_ARGUMENTS');
32
+ if(mutations.includes(command)) {
33
+ if(['create','annotate','revise'].includes(command)&&!options['--spec']||['annotate','revise'].includes(command)&&!options['--expected-sha']||command==='recover'&&(!options['--revision-id']||!['resume','reverse'].includes(options['--action'])))throw new Error('INVALID_ARGUMENTS');
34
+ const writer=await import('./freedom-conversation-write.mjs');
35
+ const {readFileSync}=await import('node:fs');
36
+ const {resolveTrackedRoot}=await import('./freedom-workspace.mjs');
37
+ const root=options['--workspace']?resolveTrackedRoot(options['--workspace']):command==='create'?resolveTrackedRoot(process.cwd()):undefined;
38
+ let spec;if(options['--spec'])try{spec=JSON.parse(readFileSync(options['--spec'],'utf8'));}catch{throw new Error('INVALID_ARGUMENTS');}
39
+ const common={pid:process.pid,by:'conversation-cli',root};
40
+ const result=command==='create'?writer.createConversation({...common,spec}):command==='annotate'?writer.annotateConversation({...common,file:positionals[0],spec,expectedSha:options['--expected-sha']}):command==='revise'?writer.reviseConversation({...common,file:positionals[0],spec,expectedSha:options['--expected-sha']}):writer.recoverConversationRevision({...common,file:positionals[0],revisionId:options['--revision-id'],action:options['--action']});
41
+ console.log(JSON.stringify({api_version:1,...result}));
42
+ }else {
43
+ const door=await import('./freedom-conversations.mjs');
44
+ const {resolveTrackedRoot}=await import('./freedom-workspace.mjs');
45
+ let root;const workspace=()=>root??=(resolveTrackedRoot(options['--workspace']??process.cwd()));
46
+ let records=[],diagnostics=[];
47
+ if(command==='list')({records,diagnostics}=options['--transcripts-dir']?door.listConversationDirectory(options['--transcripts-dir']):door.listConversations(workspace()));
48
+ else if(command==='resolve') {
49
+ const listed=door.listConversations(workspace()),result=door.resolveConversation(listed.records,positionals[0],{root});
50
+ records=result.record?[result.record]:[];diagnostics=[...listed.diagnostics,...result.diagnostics];
51
+ }else {
52
+ let record=door.readConversation(positionals[0],options['--workspace']?{root:workspace()}:{});
53
+ if(record.diagnostics.some(d=>d.code==='REDIRECT_WORKSPACE_REQUIRED'))record=door.readConversation(positionals[0],{root:workspace()});
54
+ records=[record];diagnostics=record.diagnostics;
55
+ }
56
+ diagnostics=[...new Map(diagnostics.map(d=>[JSON.stringify(d),d])).values()];
57
+ const metadata=record=>{
58
+ const h=record.header?.schema_version===1?record.header:{};
59
+ const data={id:h.id??null,path:record.file,file:record.file,relativePath:record.relativePath,layout:record.layout,date:h.date??null,title:h.title??null,participants:h.participants??[],sources:h.sources??[],review:h.review??null,body_format:h.body_format??null,aliases:h.aliases??[],fileSha256:record.fileSha256,bodySha256:record.bodySha256,gaps:record.gaps,diagnostics:record.diagnostics};
60
+ if(command==='sources')data.sources=door.conversationSourceRefs(record);
61
+ if(options['--include-body']&&!record.diagnostics.some(d=>d.code==='INVALID_UTF8'))Object.assign(data,{header:h,legacyFields:record.legacyFields,body:record.body.toString('utf8'),rawHeader:record.rawHeader?.toString('utf8')??null,turns:record.turns.map(t=>({...t,text:t.text.toString('utf8')}))});
62
+ if(options['--include-body']||options['--include-sections'])data.sections=record.sections.map(s=>({...s,bytes:s.bytes.toString('utf8')}));
63
+ return data;
64
+ };
65
+ let output={api_version:1,records:records.map(metadata),diagnostics};
66
+ if(['cite','seek'].includes(command)&&!diagnostics.some(d=>d.severity==='error')) {
67
+ const citations=await import('./freedom-conversation-citations.mjs');
68
+ if(command==='cite')output=citations.citeConversation(records[0],{turnIds:options['--turn']});
69
+ else {
70
+ output=citations.seekConversation(records[0],{turnId:options['--turn'][0],captureId:options['--capture']});
71
+ diagnostics=output.gaps;
72
+ }
73
+ }
74
+ if(json)console.log(JSON.stringify(output));
75
+ else {
76
+ if(command==='cite'&&output.selector||command==='seek'&&output.candidates)console.log(JSON.stringify(output));
77
+ for(const r of output.records??[])console.log(`${r.id??'(invalid)'}\t${r.path}`);
78
+ for(const d of diagnostics)console.error(`${d.severity}: ${d.code}`);
79
+ }
80
+ process.exitCode=command==='seek'&&output.candidates?.length===0?1:exitFor(diagnostics);
81
+ }
82
+ }catch(e) {
83
+ const d=diagnostic(e.code&&/^[A-Z][A-Z0-9_]+$/.test(e.code)?e.code:e.message==='INVALID_ARGUMENTS'?'INVALID_ARGUMENTS':'READ_INCOMPLETE');
84
+ if(json)console.log(JSON.stringify({api_version:1,records:[],diagnostics:[d]}));else console.error(`${d.severity}: ${d.code}`);
85
+ process.exitCode=exitFor([d]);
86
+ }
87
+ }
@@ -0,0 +1,358 @@
1
+ // Synchronous read door. Explicit roots only; no binding discovery, writes or media probes.
2
+ import {readFileSync,readdirSync,lstatSync,realpathSync} from 'node:fs';
3
+ import {resolve,relative,basename,dirname,join,isAbsolute,sep} from 'node:path';
4
+ import {createHash} from 'node:crypto';
5
+ import {paths} from './freedom-workspace.mjs';
6
+ import {parseConversationHeader,validateConversation} from './frontmatter/conversation.mjs';
7
+ import {parseConversationInstant,compareConversationInstants} from './freedom-conversation-time.mjs';
8
+ import {normalizeLegacy} from './freedom-conversation-legacy.mjs';
9
+ import {conversationIdCollisionKey as foldId} from './freedom-conversation-casefold.mjs';
10
+
11
+ const hash=bytes=>createHash('sha256').update(bytes).digest('hex');
12
+ const diagnostic=(code,path=null,field=null,severity='error')=>({code,severity,path,field,message:'Conversation record requires explicit resolution; no source content is included in this diagnostic.'});
13
+ const excludedPart=p=>/^[._]/.test(p)||['sources','derived','staging'].includes(p);
14
+ export function classifyConversationPath(p) {
15
+ if(typeof p!=='string'||p.includes('\\')||p.includes('\0'))return 'excluded';
16
+ const parts=p.split('/');
17
+ if(parts.some(p=>!p||excludedPart(p))||parts.at(-1)==='README.md'||parts.at(-1)==='annotations.md')return 'excluded';
18
+ if(parts.length===2&&parts[1]==='transcript.md')return 'canonical';
19
+ if(parts.length===1&&/\.md$/.test(p))return 'legacy';
20
+ return 'excluded';
21
+ }
22
+ const emptyRecord=(file,bytes=Buffer.alloc(0),layout='canonical')=>({file:resolve(file),relativePath:basename(file),id:null,layout,header:null,legacyFields:null,rawHeader:null,body:bytes,fileSha256:hash(bytes),bodySha256:hash(bytes),sections:[],turns:[],gaps:[],diagnostics:[]});
23
+ const attachGaps=r=>{r.diagnostics=r.diagnostics.map(d=>({...d,path:d.path??r.file}));r.gaps=r.diagnostics.filter(d=>d.severity==='warning'||d.code.endsWith('_UNRESOLVED'));return r;};
24
+
25
+ // Byte offsets, including original CRLF separators, remain independent of JS string lengths.
26
+ function physicalLines(bytes,startLine=1) {
27
+ const lines=[];let start=0,line=startLine;
28
+ while(start<bytes.length){const lf=bytes.indexOf(10,start),end=lf<0?bytes.length:lf+1;lines.push({start,end,line:line++,text:bytes.subarray(start,lf<0?end:lf).toString('utf8').replace(/\r$/,'')});start=end;}
29
+ return lines;
30
+ }
31
+ const interpretation=/^(?:granola notes|summary|key quotes|action items|notes|generated notes|generated interpretation)$/i;
32
+ const warning=/^(?:attribution warning|speaker attribution(?: warning)?|transcription warning|warning|warnings)$/i;
33
+ function sectionsFor(record,bodyStartLine) {
34
+ const lines=physicalLines(record.body,bodyStartLine),headings=[],warningStarts=[];let fence=null;
35
+ for(let i=0;i<lines.length;i++) {
36
+ const text=lines[i].text;
37
+ if(fence){if(new RegExp(`^ {0,3}${fence.char}{${fence.length},}\\s*$`).test(text))fence=null;continue;}
38
+ const opening=/^ {0,3}(`{3,}|~{3,})(.*)$/.exec(text);
39
+ if(opening&&!(opening[1][0]==='`'&&opening[2].includes('`'))){fence={char:opening[1][0],length:opening[1].length};continue;}
40
+ const m=/^ {0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/.exec(text);
41
+ if(m)headings.push({index:i,level:m[1].length,name:m[2]});
42
+ if(/^\s*>\s*\[!WARNING\]/i.test(text)||/^\s*(?:>\s*)?\*\*(?:speaker )?attribution warning\b/i.test(text))warningStarts.push(i);
43
+ }
44
+ const canonical=record.header.body_format==='turns-v1';
45
+ const kind=h=>canonical?(h.level===2&&h.name==='Transcript'?'transcript':'other'):/^(?:Transcript|Full transcript)$/i.test(h.name)?'transcript':warning.test(h.name)?'warning':interpretation.test(h.name)?'interpretation':'other';
46
+ // Canonical section ownership is H2-only; nested names never create another speech span.
47
+ const sectionHeadings=canonical?headings.filter(h=>h.level<=2):headings;
48
+ const sections=[];
49
+ const add=(name,kind,start,end)=>{if(start>=end)return;sections.push({name,kind,startLine:lines[start].line,endLine:lines[end-1].line,bytes:record.body.subarray(lines[start].start,lines[end-1].end)});};
50
+ // Select outer sections; nested headings remain part of the parent exact byte span.
51
+ let consumed=0;
52
+ for(let i=0;i<sectionHeadings.length;i++) {
53
+ const h=sectionHeadings[i];if(h.index<consumed)continue;
54
+ if(h.index>consumed)add('', 'other',consumed,h.index);
55
+ let end=lines.length;
56
+ for(let j=i+1;j<sectionHeadings.length;j++) {
57
+ const next=sectionHeadings[j];
58
+ const boundary=kind(next)!=='other'||canonical&&['Now','Log','Open questions'].includes(next.name);
59
+ if(next.level<=h.level&&(kind(h)!=='transcript'||boundary)){end=next.index;break;}
60
+ }
61
+ // A display title or notes container must not swallow a nested Transcript heading.
62
+ if(kind(h)!=='transcript')end=sectionHeadings[i+1]?.index??lines.length;
63
+ add(h.name,kind(h),h.index,end);consumed=end;
64
+ }
65
+ if(consumed<lines.length)add('', 'other',consumed,lines.length);
66
+ // Warnings are independent views even where they overlap a larger transcript section.
67
+ for(const h of headings.filter(h=>warning.test(h.name)))if(!sections.some(s=>s.startLine===lines[h.index].line&&s.kind==='warning')) {
68
+ const next=headings.find(x=>x.index>h.index&&x.level<=h.level);add(h.name,'warning',h.index,next?.index??lines.length);
69
+ }
70
+ for(const start of warningStarts){let end=start+1;while(end<lines.length&&lines[end].text.trim()&&(!lines[start].text.trimStart().startsWith('>')||lines[end].text.trimStart().startsWith('>')))end++;add('Attribution warning','warning',start,end);}
71
+ if(canonical) {
72
+ const required=['Now','Transcript','Log','Open questions'],actual=headings.filter(h=>h.level===2).map(h=>h.name);
73
+ if(JSON.stringify(actual)!==JSON.stringify(required)||headings.some(h=>h.level===2&&lines[h.index].text!==`## ${h.name}`))record.diagnostics.push(diagnostic('CANONICAL_SECTIONS_INVALID',record.file,'body'));
74
+ const titles=headings.filter(h=>h.level===1);
75
+ if(titles.length>1||titles.some(h=>h.name!==record.header.title))record.diagnostics.push(diagnostic('TITLE_MISMATCH',record.file,'title'));
76
+ }
77
+ const transcripts=sections.filter(s=>s.kind==='transcript');
78
+ if(!transcripts.length)record.diagnostics.push(diagnostic('TRANSCRIPT_SECTION_UNKNOWN',record.file,'body','warning'));
79
+ else if(!canonical&&transcripts.every(s=>!s.bytes.toString('utf8').replace(/^[^\n]*(?:\n|$)/,'').trim()))record.diagnostics.push(diagnostic('TRANSCRIPT_EMPTY',record.file,'body','warning'));
80
+ return sections.sort((a,b)=>a.startLine-b.startLine);
81
+ }
82
+
83
+ const exactKeys=(value,keys)=>value!==null&&typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).sort().join(',')===[...keys].sort().join(',');
84
+ const milliseconds=n=>typeof n==='number'&&Number.isFinite(n)&&n>=0;
85
+ const interval=(start,end)=>milliseconds(start)&&(end===null||milliseconds(end)&&end>=start);
86
+ function validTiming(t,captures) {
87
+ if(t===null)return true;
88
+ if(!t||typeof t!=='object')return false;
89
+ if(t.basis==='capture')return exactKeys(t,['basis','capture','start_ms','end_ms'])&&captures.has(t.capture)&&interval(t.start_ms,t.end_ms);
90
+ if(t.basis==='conversation')return exactKeys(t,['basis','start_ms','end_ms','mapping'])&&interval(t.start_ms,t.end_ms)&&Array.isArray(t.mapping)&&t.mapping.every(m=>
91
+ exactKeys(m,['capture','conversation_start_ms','conversation_end_ms','capture_start_ms','capture_end_ms'])&&captures.has(m.capture)&&
92
+ [m.conversation_start_ms,m.conversation_end_ms,m.capture_start_ms,m.capture_end_ms].every(milliseconds)&&m.conversation_end_ms>=m.conversation_start_ms&&
93
+ m.capture_end_ms-m.capture_start_ms===m.conversation_end_ms-m.conversation_start_ms);
94
+ if(t.basis==='wall-clock') {
95
+ const start=parseConversationInstant(t.start),end=parseConversationInstant(t.end);
96
+ return exactKeys(t,['basis','start','end','mapping'])&&!!start&&(t.end===null||!!end&&compareConversationInstants(end,start)>=0)&&Array.isArray(t.mapping)&&t.mapping.every(m=>
97
+ exactKeys(m,['capture','wall_start','capture_start_ms'])&&captures.has(m.capture)&&!!parseConversationInstant(m.wall_start)&&milliseconds(m.capture_start_ms));
98
+ }
99
+ return false;
100
+ }
101
+ function turnsFor(record) {
102
+ if(record.header.body_format!=='turns-v1'||record.diagnostics.some(d=>d.severity==='error'))return [];
103
+ const section=record.sections.find(s=>s.kind==='transcript');if(!section)return [];
104
+ const lines=physicalLines(section.bytes,section.startLine),starts=[],turns=[],ids=new Set();let fence=null;
105
+ const fail=(code,field='body')=>record.diagnostics.push(diagnostic(code,record.file,field));
106
+ for(let i=1;i<lines.length;i++) {
107
+ const text=lines[i].text;
108
+ if(fence){if(new RegExp(`^ {0,3}${fence.char}{${fence.length},}\\s*$`).test(text))fence=null;continue;}
109
+ const opening=/^ {0,3}(`{3,}|~{3,})(.*)$/.exec(text);
110
+ if(opening&&!(opening[1][0]==='`'&&opening[2].includes('`'))){fence={char:opening[1][0],length:opening[1].length};continue;}
111
+ const heading=/^### (t-\d{6,})$/.exec(text);
112
+ if(heading)starts.push({index:i,id:heading[1]});
113
+ else if(/^\s*#{1,6}\s+t-/.test(text))fail('TURN_HEADING_INVALID');
114
+ }
115
+ const preludeEnd=starts[0]?.index??lines.length;
116
+ if(lines.slice(1,preludeEnd).some(l=>l.text.trim()))fail('TURN_GRAMMAR_INVALID');
117
+ const captures=new Map(record.header.sources.map(c=>[c.id,c]));
118
+ for(let i=0;i<starts.length;i++) {
119
+ const {index,id}=starts[i],end=starts[i+1]?.index??lines.length;
120
+ if(ids.has(id))fail('TURN_ID_DUPLICATE');ids.add(id);
121
+ const comment=/^<!-- turn (\{.*\}) -->$/.exec(lines[index+1]?.text??'');
122
+ if(!comment){fail('TURN_METADATA_INVALID');continue;}
123
+ // Reuse the scoped codec's JSON syntax/duplicate/prototype checks; no second JSON parser.
124
+ const parsed=parseConversationHeader(Buffer.from(`---\nextensions: ${comment[1]}\n---\n`));
125
+ const meta=parsed.header?.extensions;
126
+ if(!exactKeys(meta,['speaker','sources','timing','source_gap'])){fail('TURN_METADATA_INVALID');continue;}
127
+ if(meta.speaker!==null&&!record.header.participants.some(p=>p.id===meta.speaker))fail('TURN_SPEAKER_UNKNOWN','speaker');
128
+ const gap=meta.source_gap===null||typeof meta.source_gap==='string'&&!!meta.source_gap.trim();
129
+ if(!gap||!Array.isArray(meta.sources)||!meta.sources.length&&meta.source_gap===null)fail('TURN_SOURCE_INVALID','sources');
130
+ const warnUnknown=field=>record.diagnostics.push(diagnostic('UNKNOWN_FACT',record.file,`turns[${i}].${field}`,'warning'));
131
+ if(Array.isArray(meta.sources))for(const [sourceIndex,ref] of meta.sources.entries()) {
132
+ if(!exactKeys(ref,['capture','locator','start_ms','end_ms'])){fail('TURN_SOURCE_INVALID','sources');continue;}
133
+ for(const key of ['locator','start_ms','end_ms'])if(ref[key]===null)warnUnknown(`sources[${sourceIndex}].${key}`);
134
+ const capture=captures.get(ref.capture);
135
+ if(!capture||!(ref.locator===null||Number.isInteger(ref.locator)&&ref.locator>=0&&ref.locator<capture.locators.length)||!(ref.start_ms===null&&ref.end_ms===null||interval(ref.start_ms,ref.end_ms)))fail('TURN_SOURCE_INVALID','sources');
136
+ }
137
+ if(!validTiming(meta.timing,captures))fail('TURN_TIMING_INVALID','timing');
138
+ else if(meta.timing!==null){const endField=meta.timing.basis==='wall-clock'?'end':'end_ms';if(meta.timing[endField]===null)warnUnknown(`timing.${endField}`);}
139
+ for(const [missing,code,field] of [[meta.speaker===null,'SPEAKER_UNRESOLVED','speaker'],[meta.timing===null,'TIMING_UNRESOLVED','timing'],[!!meta.source_gap,'TURN_SOURCE_UNRESOLVED','sources']])if(missing)record.diagnostics.push(diagnostic(code,record.file,field,'warning'));
140
+ const textStart=lines[index+1].end,textEnd=end<lines.length?lines[end].start:section.bytes.length;
141
+ turns.push({id,...meta,text:section.bytes.subarray(textStart,textEnd),startLine:lines[index].line,endLine:lines[end-1].line});
142
+ }
143
+ if(record.diagnostics.some(d=>d.severity==='error'))return [];
144
+ const empty=turns.map(turn=>!turn.text.toString('utf8').trim());
145
+ for(let i=0;i<turns.length;i++)if(empty[i])record.diagnostics.push(diagnostic('TURN_EMPTY',record.file,`turns[${i}].text`,'warning'));
146
+ if(empty.every(Boolean))record.diagnostics.push(diagnostic('TRANSCRIPT_EMPTY',record.file,'body','warning'));
147
+ return turns;
148
+ }
149
+
150
+ function canonicalIntent(bytes,file,layout) {
151
+ if(layout==='canonical'||layout!=='legacy'&&basename(file)==='transcript.md')return true;
152
+ // Inspect only the delimited header, never a quoted header inside transcript speech.
153
+ const text=bytes.toString('utf8'),match=/^(?:\uFEFF)?---\r?\n([\s\S]*?)(?:\r?\n---(?:\r?\n|$)|$)/.exec(text);
154
+ return !!match&&/^(?:schema_version\s*:|kind\s*:\s*["']?conversation(?:-redirect)?["']?\s*$)/m.test(match[1]);
155
+ }
156
+ export function parseConversation(bytes,{file,layout}) {
157
+ if(!Buffer.isBuffer(bytes)||typeof file!=='string'||!file)throw new TypeError('conversation requires bytes and a file');
158
+ if(layout!==undefined&&!['legacy','canonical'].includes(layout))throw new TypeError('layout must be legacy or canonical');
159
+ let record;
160
+ if(canonicalIntent(bytes,file,layout)) {
161
+ const parsed=parseConversationHeader(bytes);
162
+ record={...emptyRecord(file,bytes,layout??'canonical'),...parsed,bodySha256:hash(parsed.body)};
163
+ if(!parsed.header){if(!parsed.diagnostics.length)record.diagnostics.push(diagnostic('CANONICAL_HEADER_REQUIRED',record.file));return attachGaps(record);}
164
+ if(parsed.header.kind==='conversation-redirect') {
165
+ // Header codec owns syntax. Redirects have their own deliberately minimal schema.
166
+ record.diagnostics=[];
167
+ const h=parsed.header;
168
+ if(h.schema_version!==1||Object.keys(h).sort().join(',')!=='kind,schema_version,to'||typeof h.to!=='string'||!h.to||parsed.body.length!==0)record.diagnostics.push(diagnostic('INVALID_REDIRECT',record.file));
169
+ return attachGaps(record);
170
+ }
171
+ record.id=typeof parsed.header.id==='string'?parsed.header.id:null;
172
+ record.diagnostics=validateConversation(parsed.header,(layout==='canonical'||layout===undefined&&basename(file)==='transcript.md')?{folderId:basename(dirname(file))}:{});
173
+ if(record.diagnostics.some(d=>d.severity==='error'))return attachGaps(record);
174
+ } else record=normalizeLegacy(bytes,{file});
175
+ if(record.header) {
176
+ const prefix=bytes.subarray(0,bytes.length-record.body.length);
177
+ const startLine=1+prefix.reduce((n,b)=>n+(b===10?1:0),0);
178
+ record.sections=sectionsFor(record,startLine);
179
+ record.turns=turnsFor(record);
180
+ if(record.header.sources.length===0&&!record.diagnostics.some(d=>d.code==='SOURCE_UNKNOWN'))record.diagnostics.push(diagnostic('SOURCE_UNKNOWN',record.file,'sources','warning'));
181
+ if(record.header.participants.length===0)record.diagnostics.push(diagnostic('PARTICIPANTS_UNKNOWN',record.file,'participants','warning'));
182
+ }
183
+ return attachGaps(record);
184
+ }
185
+ const inside=(root,file)=>{const rel=relative(resolve(root),resolve(file));return rel===''||(!isAbsolute(rel)&&rel!=='..'&&!rel.startsWith('..'+sep));};
186
+ function safeRedirectTarget(root,to) {
187
+ if(isAbsolute(to)||to.includes('\\')||to.includes('\0')||to.split('/').some(p=>!p||p==='.'||p==='..'))return null;
188
+ const target=resolve(root,to);if(!inside(root,target))return null;
189
+ // Check existing ancestors too, so a missing leaf below an escaping symlink still refuses.
190
+ let ancestor=target;
191
+ while(true){try{if(!inside(realpathSync(root),realpathSync(ancestor)))return null;break;}catch(e){if(e.code!=='ENOENT'&&e.code!=='ENOTDIR')throw e;const parent=dirname(ancestor);if(parent===ancestor)throw e;ancestor=parent;}}
192
+ return target;
193
+ }
194
+ export function readConversation(file,{read=readFileSync,root,layout}={}) {
195
+ let current=resolve(file),hops=0;const seen=new Set();
196
+ while(true) {
197
+ if(seen.has(current)){const r=emptyRecord(current);r.diagnostics=[diagnostic('REDIRECT_LOOP',current)];return r;}seen.add(current);
198
+ let record;
199
+ try {
200
+ // Layout is evidence from the caller or the explicit mapped root, never a guessed binding.
201
+ let currentLayout=hops===0?layout:undefined;
202
+ if(currentLayout===undefined&&root) {
203
+ const classified=classifyConversationPath(relative(paths(root).transcripts,current).split(sep).join('/'));
204
+ if(classified!=='excluded')currentLayout=classified;
205
+ }
206
+ record=parseConversation(read(current),{file:current,layout:currentLayout});
207
+ }catch(e){record=emptyRecord(current);record.diagnostics=[diagnostic(e.code==='ENOENT'?'NOT_FOUND':'READ_INCOMPLETE',current)];return record;}
208
+ if(record.header?.kind!=='conversation-redirect') {
209
+ if(hops>0&&(record.layout!=='canonical'||basename(current)!=='transcript.md'))record.diagnostics.push(diagnostic('REDIRECT_TARGET_INVALID',current));
210
+ return record;
211
+ }
212
+ if(record.diagnostics.length)return record;
213
+ if(!root){record.diagnostics.push(diagnostic('REDIRECT_WORKSPACE_REQUIRED',current));return record;}
214
+ if(hops++>=8){record.diagnostics.push(diagnostic('REDIRECT_HOP_LIMIT',current));return record;}
215
+ let target;try{target=safeRedirectTarget(root,record.header.to);}catch{record.diagnostics.push(diagnostic('READ_INCOMPLETE',current));return record;}
216
+ if(!inside(root,current)||!target){record.diagnostics.push(diagnostic('REDIRECT_OUTSIDE_WORKSPACE',current));return record;}
217
+ current=target;
218
+ }
219
+ }
220
+ export function listConversationDirectory(transcriptsDir) {
221
+ if(typeof transcriptsDir!=='string'||!transcriptsDir)throw new TypeError('explicit transcript directory required');
222
+ const root=resolve(transcriptsDir),records=[],diagnostics=[];
223
+ function visit(dir,rel='') {
224
+ let entries;try{const stat=lstatSync(dir);if(stat.isSymbolicLink()){diagnostics.push(diagnostic('SYMLINK_EXCLUDED',dir));return;}entries=readdirSync(dir).sort();}catch{diagnostics.push(diagnostic('READ_INCOMPLETE',dir));return;}
225
+ for(const name of entries) {
226
+ const file=join(dir,name),p=rel?`${rel}/${name}`:name;let stat;
227
+ try{stat=lstatSync(file);}catch{diagnostics.push(diagnostic('READ_INCOMPLETE',file));continue;}
228
+ if(stat.isSymbolicLink()){diagnostics.push(diagnostic('SYMLINK_EXCLUDED',file));continue;}
229
+ if(p.split('/').some(excludedPart)||name==='annotations.md'||name==='README.md')continue;
230
+ if(stat.isDirectory()){visit(file,p);continue;}
231
+ const layout=classifyConversationPath(p);
232
+ if(stat.isFile()&&layout!=='excluded') {
233
+ let record;try{record=parseConversation(readFileSync(file),{file,layout});}catch{diagnostics.push(diagnostic('READ_INCOMPLETE',file));continue;}
234
+ if(record.header?.kind==='conversation-redirect'){diagnostics.push(...record.diagnostics);continue;}
235
+ record.relativePath=p;records.push(record);diagnostics.push(...record.diagnostics);
236
+ }else if(/\.md$/.test(name))diagnostics.push(diagnostic('UNCLASSIFIED_MARKDOWN',file));
237
+ }
238
+ }
239
+ visit(root);diagnostics.push(...indexConversations(records).diagnostics);return {records,diagnostics};
240
+ }
241
+ export function listConversations(root) {
242
+ if(typeof root!=='string'||!root)throw new TypeError('explicit workspace root required');
243
+ try{return listConversationDirectory(paths(root).transcripts);}catch{return {records:[],diagnostics:[diagnostic('READ_INCOMPLETE',resolve(root))]};}
244
+ }
245
+ const aliasesOf=record=>Array.isArray(record.header?.aliases)?record.header.aliases.filter(a=>a&&typeof a.value==='string'):[];
246
+ const addIndex=(map,key,record)=>{const values=map.get(key)??[];if(!values.includes(record))values.push(record);map.set(key,values);};
247
+ function conversationIndex(records) {
248
+ const byId=new Map(),byAlias=new Map(),folded=new Map(),diagnostics=[],collisions=new Map();
249
+ const collision=(values,code,field)=>{
250
+ if(values.length<2)return;
251
+ for(const record of values) {
252
+ diagnostics.push(diagnostic(code,record.file,field));
253
+ const peers=collisions.get(record)??new Set();
254
+ for(const peer of values)peers.add(peer);
255
+ collisions.set(record,peers);
256
+ }
257
+ };
258
+ for(const record of records) {
259
+ if(typeof record.id==='string'){addIndex(byId,record.id,record);addIndex(folded,foldId(record.id),record);}
260
+ for(const a of aliasesOf(record))if(typeof a.value==='string')addIndex(byAlias,a.kind==='source-id'?`${a.namespace}:${a.value}`:a.value,record);
261
+ }
262
+ for(const values of folded.values())collision(values,'ID_COLLISION','id');
263
+ for(const [key,values] of byAlias){const candidates=[...new Set([...values,...(byId.get(key)??[])])];collision(candidates,'AMBIGUOUS_ALIAS','aliases');}
264
+ const portable=new Map([...folded].map(([key,values])=>[key,[...values]]));
265
+ for(const record of records)for(const a of aliasesOf(record))if(a.kind==='record-id'&&typeof a.value==='string')addIndex(portable,foldId(a.value),record);
266
+ for(const values of portable.values())if(values.some(r=>aliasesOf(r).some(a=>a.kind==='record-id')))collision(values,'AMBIGUOUS_ALIAS','aliases');
267
+ return {byId,byAlias,diagnostics,collisions};
268
+ }
269
+ export function indexConversations(records) {
270
+ const {byId,byAlias,diagnostics}=conversationIndex(records);return {byId,byAlias,diagnostics};
271
+ }
272
+ export function resolveConversation(records,idOrAlias,{root}={}) {
273
+ const {byId,byAlias,diagnostics,collisions}=conversationIndex(records),candidates=new Set([...(byId.get(idOrAlias)??[]),...(byAlias.get(idOrAlias)??[])]);
274
+ // Bare vendor-id queries are compatibility lookups, never a preference between namespaces.
275
+ for(const r of records)if(aliasesOf(r).some(a=>a.kind==='source-id'&&a.value===idOrAlias))candidates.add(r);
276
+ const select=candidates=>{
277
+ // Follow the selected records' collision memberships, not the spelling of this query.
278
+ // Set iteration includes newly reached peers, retaining transitive conflicts too.
279
+ for(const candidate of candidates)for(const peer of collisions.get(candidate)??[])candidates.add(peer);
280
+ if(candidates.size>1) {
281
+ const files=new Set([...candidates].map(r=>r.file));
282
+ const blocking=diagnostics.filter(d=>files.has(d.path));
283
+ return {record:null,diagnostics:[...blocking,...[...candidates].map(r=>diagnostic('AMBIGUOUS_ALIAS',r.file,'aliases'))]};
284
+ }
285
+ const record=[...candidates][0];return {record,diagnostics:record.diagnostics};
286
+ };
287
+ if(candidates.size)return select(candidates);
288
+ if(root&&idOrAlias.endsWith('.md')) {
289
+ let target;try{target=safeRedirectTarget(root,idOrAlias);}catch{return {record:null,diagnostics:[diagnostic('READ_INCOMPLETE')]};}
290
+ if(!target)return {record:null,diagnostics:[diagnostic('REDIRECT_OUTSIDE_WORKSPACE')]};
291
+ const record=readConversation(target,{root});
292
+ const indexed=records.filter(r=>r.file===record.file);
293
+ if(indexed.length){const selected=select(new Set(indexed));if(!selected.record)return selected;}
294
+ return {record:record.header?.kind==='conversation'?record:null,diagnostics:record.diagnostics};
295
+ }
296
+ return {record:null,diagnostics:[diagnostic('NOT_FOUND')]};
297
+ }
298
+ export function conversationSourceRefs(record) {
299
+ if(record.header?.schema_version!==1||record.header.kind!=='conversation'||!Array.isArray(record.header.sources)||validateConversation(record.header).some(d=>d.severity==='error'))return [];
300
+ return record.header.sources.flatMap(c=>(Array.isArray(c.locators)&&c.locators.length?c.locators:[null]).flatMap(l=>(Array.isArray(c.roles)?c.roles:[]).map(role=>({capture:c.id,type:c.type,service:c.service,recordingId:c.recording_id,vendorId:c.vendor_id,role,path:l?.path??null,url:l?.url??null,serviceId:l?.service_id??null,availability:c.availability}))));
301
+ }
302
+ export const conversationSections=record=>record.sections;
303
+ export const conversationTurns=record=>record.turns;
304
+
305
+ const sharingDecision=(effectiveScope,reason,allowed=false)=>({allowed,effectiveScope,reason});
306
+ // Retained restrictions are authoritative after relocation too. Do not reinterpret old
307
+ // permissive-looking values, or let a new scope choice erase an unresolved old restriction.
308
+ function retainedSharingRestriction(record) {
309
+ const keys=['private','sensitivity','keep','scope','sharing'];
310
+ if(record.diagnostics?.some(d=>d.code==='RESTRICTION_UNRESOLVED')||record.header?.legacy?.unparsed_keys?.some(k=>keys.includes(k)))return 'RESTRICTION_UNRESOLVED';
311
+ let home=false;
312
+ for(const fields of [record.legacyFields,record.header?.legacy?.fields])if(fields)for(const key of keys) {
313
+ if(!Object.hasOwn(fields,key))continue;
314
+ const value=fields[key];
315
+ if(key==='private'&&(value===true||value==='true')||key==='sensitivity'&&['private','confidential','restricted'].includes(value))home=true;
316
+ else if(!(key==='private'&&(value===false||value==='false')))return 'RESTRICTION_UNRESOLVED';
317
+ }
318
+ return home?'SHARING_HOME':null;
319
+ }
320
+
321
+ /** Report the stored boundary only; neither audience nor this result authorizes delivery. */
322
+ export function effectiveSharing(record) {
323
+ if(!record||typeof record!=='object')return sharingDecision('home','INVALID_SCHEMA');
324
+ const restriction=retainedSharingRestriction(record);
325
+ if(restriction==='RESTRICTION_UNRESOLVED')return sharingDecision('home',restriction);
326
+ if(validateConversation(record.header).some(d=>d.severity==='error')||record.diagnostics?.some(d=>d.severity==='error'))return sharingDecision('home','INVALID_SCHEMA');
327
+ if(restriction)return sharingDecision('home',restriction);
328
+ const scope=record.header.keep.scope;
329
+ if(scope==='unknown')return sharingDecision('home','SHARING_UNKNOWN');
330
+ if(scope==='home')return sharingDecision('home','SHARING_HOME');
331
+ return sharingDecision(scope,'DELIVERY_TARGET_REQUIRED');
332
+ }
333
+
334
+ /** Pure boundary gate. Inject fresh group facts; outbound session authorization is separate. */
335
+ export function canProjectConversation(record,target,context={}) {
336
+ const boundary=effectiveSharing(record);
337
+ if(boundary.effectiveScope==='home')return boundary;
338
+ const h=record.header,scope=boundary.effectiveScope;
339
+ // Reuse the scoped schema for Evidence and group-path shape, without another parser.
340
+ const checkKeep=keep=>validateConversation({...h,keep}).some(d=>d.severity==='error');
341
+ if(context?.decision!==undefined&&(context.decision===null||checkKeep({scope:'home',group:null,decision:context.decision})))return sharingDecision('home','INVALID_CONTEXT_DECISION');
342
+ if(!target||!['public','group'].includes(target.kind))return sharingDecision(scope,'INVALID_PROJECTION_TARGET');
343
+ if(target.kind==='group'&&(typeof target.group!=='string'||checkKeep({scope:'home',group:target.group,decision:null})))return sharingDecision(scope,'INVALID_PROJECTION_TARGET');
344
+ if(scope==='family'&&(target.kind!=='group'||target.group!==h.keep.group))return sharingDecision(scope,'GROUP_MISMATCH');
345
+ if(target.kind==='group') {
346
+ let group;
347
+ try {group=typeof context?.resolveGroup==='function'?context.resolveGroup(target.group):null;}catch{return sharingDecision('home','GROUP_UNRESOLVED');}
348
+ if(!group||group.exists!==true||group.authorized!==true)return sharingDecision('home','GROUP_UNRESOLVED');
349
+ }
350
+ return sharingDecision(scope,'SHARING_ALLOWED',true);
351
+ }
352
+
353
+ export function conversationCount(root) {
354
+ const {records,diagnostics}=listConversations(root);
355
+ const blocking=diagnostics.filter(d=>d.severity==='error');
356
+ if(blocking.length)throw Object.assign(new Error('Conversation count is unavailable because discovery is incomplete or conflicting.'),{code:blocking[0].code,diagnostics:blocking});
357
+ return records.length;
358
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * "Was I run, or was I imported?" — answered once, correctly, so nobody hand-rolls it again.
3
+ *
4
+ * Node resolves symlinks when it loads a module, so `import.meta.url` is always the file's REAL
5
+ * path. `process.argv[1]` is whatever the caller typed. A script reached through a symlink sees
6
+ * two different strings for one file, and every hand-rolled comparison of them returns false.
7
+ *
8
+ * The consequence is the worst shape a failure can take: `main()` does not run, nothing is
9
+ * written, and the process exits 0. There is no error to read and no output to miss. It reads
10
+ * exactly like a successful run, and the only way anyone finds out is by noticing that the thing
11
+ * they asked for is not there.
12
+ *
13
+ * This is not an exotic path. A symlink is how Freedom reaches its own scripts: `.agents/skills`
14
+ * points at the operator's skills folder, `~/.agents/skills` and `~/.claude/skills` point back
15
+ * into the workspace, and a skill that lives in the repo it belongs to is symlinked into the
16
+ * global index. Roughly a third of skill paths on a mature machine cross one.
17
+ *
18
+ * Every near-miss below shipped in this repo at some point, and each one looks careful:
19
+ *
20
+ * import.meta.url === `file://${process.argv[1]}`
21
+ * Raw strings. Also wrong for a relative argv, and wrong for any path needing URL escaping.
22
+ *
23
+ * resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))
24
+ * `resolve` makes a path absolute and flattens `..`. It does not follow a symlink. This is
25
+ * the one that reads as the fixed version.
26
+ *
27
+ * import.meta.url === pathToFileURL(process.argv[1]).href
28
+ * Correct URL encoding, no symlink resolution. Same failure, better punctuation.
29
+ *
30
+ * import.meta.url.endsWith(process.argv[1].split("/").pop())
31
+ * Compares BASENAMES, so it is loose in the other direction: any file of the same name
32
+ * anywhere satisfies it.
33
+ *
34
+ * Earned 2026-09-09. Two concat calls against a private copy of a script reported nothing and
35
+ * produced nothing; the same commands at the real path worked first time. A sweep then found 36
36
+ * of these in the shipped template, including `freedom-session-name.mjs`, which the session
37
+ * opener reads to decide whether to offer a session name — and whose silence reads as "no".
38
+ */
39
+ import { realpathSync } from "node:fs";
40
+ import { fileURLToPath } from "node:url";
41
+
42
+ /**
43
+ * True when this module is the script node was asked to run.
44
+ *
45
+ * @param {string} moduleUrl Always pass `import.meta.url` from the calling module.
46
+ * @returns {boolean}
47
+ *
48
+ * @example
49
+ * import { isMain } from "../../lib/freedom-is-main.mjs";
50
+ * if (isMain(import.meta.url)) process.exit(main(process.argv.slice(2)));
51
+ */
52
+ export function isMain(moduleUrl) {
53
+ const entry = process.argv[1];
54
+ if (!entry || !moduleUrl) return false;
55
+ try {
56
+ return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(entry);
57
+ } catch {
58
+ // A path that cannot be resolved (deleted mid-run, or an argv[1] that is not a real file, as
59
+ // under `node --eval`) is not this module. Never throw from a guard: a crash here takes down
60
+ // an import that had nothing to do with running as a CLI.
61
+ return false;
62
+ }
63
+ }
64
+
65
+ export default isMain;
@@ -0,0 +1,15 @@
1
+ // Shared capture-lock implementation only; archive migration remains outside this package.
2
+ import {join} from 'node:path';
3
+ import {stateDir} from './freedom-workspace.mjs';
4
+ import {acquire as acquireLock,release as releaseLock} from './freedom-run-lock.mjs';
5
+ export const MIGRATE_LOCK='catch-up-freeda';
6
+ export function withRunLock(root, { pid, by }, fn) {
7
+ const lockPath = join(stateDir(MIGRATE_LOCK, root), ".lock");
8
+ const lock = acquireLock(lockPath, { pid, by });
9
+ if (!lock.acquired) {
10
+ const h = lock.status.record;
11
+ return { code: 1, error: `refused: the ${MIGRATE_LOCK} run lock is held${h ? ` by pid ${h.pid} (${h.by ?? "unknown"}, since ${h.since ?? "unknown"})` : ` (${lock.status.why ?? lock.status.state})`}. Wait for that run to finish.` };
12
+ }
13
+ try { return fn(); }
14
+ finally { if (!lock.reentrant) releaseLock(lockPath, { pid }); }
15
+ }
@@ -0,0 +1,8 @@
1
+ // Only existing caller-owned person references; no person discovery or operational processing.
2
+ import {statSync} from 'node:fs';
3
+ import {join,resolve as resolvePath,sep} from 'node:path';
4
+ import {paths} from './freedom-workspace.mjs';
5
+ export function peopleDir(root){return paths(root).people;}
6
+ const isFile=p=>{try{return statSync(p).isFile();}catch{return false;}};
7
+ export function safeSlug(dir,slug){if(typeof slug!=='string'||!slug||slug.startsWith('/')||/[\\\0]/.test(slug))return false;if(slug.split('/').some(seg=>seg===''||seg.startsWith('.')))return false;const root=resolvePath(dir);return resolvePath(root,slug).startsWith(root+sep);}
8
+ export function recordPath(dir,slug){if(!safeSlug(dir,slug))return null;for(const c of [join(dir,slug,'friend.md'),join(dir,`${slug}.md`),join(dir,'archive',slug,'friend.md'),join(dir,'archive',`${slug}.md`)])if(isFile(c))return c;return null;}