@sleepy_panda_srl/portulan 0.2.0 → 0.4.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.
- package/README.md +15 -4
- package/cli/README.md +61 -6
- package/cli/advisory.mjs +167 -53
- package/cli/cache-risk.mjs +69 -0
- package/cli/comments.mjs +8 -5
- package/cli/compile.mjs +781 -130
- package/cli/context.mjs +204 -43
- package/cli/doctor.mjs +248 -31
- package/cli/drills.mjs +56 -9
- package/cli/finish.md +34 -0
- package/cli/finish.mjs +8 -67
- package/cli/form.mjs +34 -18
- package/cli/frontmatter.mjs +39 -0
- package/cli/gate.mjs +14 -12
- package/cli/guidance-files.mjs +335 -0
- package/cli/handoff-policy.mjs +76 -0
- package/cli/html-entities.mjs +2130 -0
- package/cli/import-files.mjs +15 -0
- package/cli/index.mjs +77 -24
- package/cli/init-preview.mjs +553 -0
- package/cli/init.mjs +261 -46
- package/cli/instructions-journal.mjs +1185 -0
- package/cli/instructions.mjs +332 -57
- package/cli/ledger.mjs +230 -40
- package/cli/librarian.mjs +103 -19
- package/cli/persona-routing.mjs +209 -0
- package/cli/plugin-lint.mjs +77 -56
- package/cli/portulan.mjs +1 -1
- package/cli/recipe-output.mjs +91 -0
- package/cli/recipe-runner.md +68 -0
- package/cli/recipe-runner.mjs +131 -0
- package/cli/recipe-set.mjs +12 -3
- package/cli/review-meter.mjs +14 -9
- package/cli/ritual-contexts.mjs +103 -0
- package/cli/sessions.mjs +2 -1
- package/cli/spend-analysis.mjs +111 -0
- package/cli/spend-offer.md +53 -0
- package/cli/spend-offer.mjs +93 -0
- package/cli/stop-diagnostics.mjs +114 -0
- package/cli/stop-gate.mjs +133 -90
- package/cli/stop-policy.mjs +14 -0
- package/cli/symbols.mjs +55 -8
- package/cli/telemetry.mjs +66 -17
- package/cli/upgrade.mjs +62 -44
- package/cli/vendor.mjs +207 -125
- package/core/operating/context.md +401 -18
- package/core/operating/evolution.md +3 -2
- package/core/operating/loop.md +9 -4
- package/core/operating/sessions.md +6 -15
- package/core/personas/README.md +75 -0
- package/core/personas/implementer.md +3 -0
- package/core/personas/librarian.md +3 -0
- package/core/personas/reviewer.md +3 -0
- package/core/skills/clarify/SKILL.md +1 -1
- package/core/skills/consolidate/SKILL.md +1 -1
- package/core/templates/handoff.md +6 -2
- package/evals/README.md +23 -7
- package/evals/ab/personas/README.md +111 -0
- package/evals/ab/personas/acquisition.json +330 -0
- package/evals/ab/personas/bindings/implementer.txt +43 -0
- package/evals/ab/personas/bindings/librarian.txt +40 -0
- package/evals/ab/personas/bindings/reviewer.txt +41 -0
- package/evals/ab/personas/corpus.json +69 -0
- package/evals/ab/personas/grader.mjs +171 -0
- package/evals/ab/personas/implementer.json +774 -0
- package/evals/ab/personas/implementer.md +22 -0
- package/evals/ab/personas/librarian.json +758 -0
- package/evals/ab/personas/librarian.md +22 -0
- package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
- package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
- package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
- package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
- package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
- package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
- package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
- package/evals/ab/personas/reviewer.json +758 -0
- package/evals/ab/personas/reviewer.md +22 -0
- package/evals/releases/0.3.0.json +150 -0
- package/evals/releases/0.3.0.md +68 -0
- package/evals/releases/0.4.0.json +154 -0
- package/evals/releases/0.4.0.md +69 -0
- package/package.json +7 -2
- package/packs/.claude-plugin/plugin.json +2 -1
- package/packs/README.md +7 -4
- package/packs/rituals/checkpoints/README.md +35 -0
- package/packs/rituals/checkpoints/pack.json +3 -2
- package/packs/tools/README.md +8 -8
- package/packs/tools/local-llm/README.md +176 -0
- package/packs/tools/local-llm/pack.json +32 -0
- package/packs/tools/local-llm/personas/local-worker.md +44 -0
- package/packs/tools/local-llm/runner/local-llm.mjs +1779 -0
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +88 -0
- package/packs/tools/local-llm/verify/README.md +18 -0
- package/spec/README.md +37 -3
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
- package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
- package/spec/migrations/README.md +6 -0
- package/spec/pack.schema.json +12 -3
- package/spec/slots.md +272 -123
- package/spec/workspace.schema.json +67 -11
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// Nested instruction ownership and atomic guidance publication.
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { randomUUID,createHash } from "node:crypto";
|
|
6
|
+
import { isInside } from "./inside.mjs";
|
|
7
|
+
|
|
8
|
+
export class CompileError extends Error {
|
|
9
|
+
constructor(message) { super(message); this.name = "CompileError"; }
|
|
10
|
+
}
|
|
11
|
+
export function readOwnedBytes(file, scope) {
|
|
12
|
+
fs.lstatSync(file);
|
|
13
|
+
let owner, real;
|
|
14
|
+
try { owner = fs.realpathSync(scope); real = fs.realpathSync(file); }
|
|
15
|
+
catch (error) { throw new CompileError(`${file} could not be resolved — ${error.code ?? error.message}`); }
|
|
16
|
+
if (!isInside(owner, real)) throw new CompileError(`${file} resolves outside its owner ${scope}`);
|
|
17
|
+
if (!fs.statSync(real).isFile()) throw new CompileError(`${file} is not a regular source file`);
|
|
18
|
+
return fs.readFileSync(real);
|
|
19
|
+
}
|
|
20
|
+
export function readOwnedText(file, scope) {
|
|
21
|
+
try{return new TextDecoder("utf-8",{fatal:true,ignoreBOM:true}).decode(readOwnedBytes(file,scope));}
|
|
22
|
+
catch(error){if(error instanceof TypeError)throw new CompileError(`${file} is not valid UTF-8`);throw error;}
|
|
23
|
+
}
|
|
24
|
+
export const sameContent=(left,right)=>left===null||right===null ? left===right : Buffer.from(left).equals(Buffer.from(right));
|
|
25
|
+
export function readOutput(file,scope) {
|
|
26
|
+
const bytes=readOwnedBytes(file,scope);
|
|
27
|
+
try{return new TextDecoder("utf-8",{fatal:true,ignoreBOM:true}).decode(bytes);}catch{return bytes;}
|
|
28
|
+
}
|
|
29
|
+
export const NESTED_REGISTRY = ".claude/rules/portulan/.instructions";
|
|
30
|
+
const MARK = '<!-- portulan compiled instruction ';
|
|
31
|
+
const posix = value => value.split(path.sep).join("/");
|
|
32
|
+
const relative = value => typeof value === "string" && value !== "" && !/[\\\u0000-\u001f\u007f\u0085\u2028\u2029]/.test(value) && !path.posix.isAbsolute(value) && !/^[A-Za-z]:/.test(value) && value.split("/").every(part => part !== "" && part !== "." && part !== "..");
|
|
33
|
+
const within = (base, value) => base === "." || value.startsWith(`${base}/`);
|
|
34
|
+
const samePath = value => value.toLowerCase();
|
|
35
|
+
const entry = file => {
|
|
36
|
+
try { return fs.lstatSync(file); }
|
|
37
|
+
catch (error) { if (error.code === "ENOENT") return null; throw new CompileError(`${file} could not be inspected — ${error.code ?? error.message}`); }
|
|
38
|
+
};
|
|
39
|
+
export function nestedTarget(value, where) {
|
|
40
|
+
const parts = typeof value === "string" ? value.split("/") : [];
|
|
41
|
+
if (!relative(value) || value.length > 1024 || parts.length < 2 || parts.length > 33 || parts.at(-1) !== "CLAUDE.md" || !parts.slice(0,-1).every(part => part.length <= 128 && part.toLowerCase()!=="claude.md" && /^[A-Za-z0-9][A-Za-z0-9._ -]*$/.test(part) && !/[ .]$/.test(part) && !/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) {
|
|
42
|
+
throw new CompileError(`${where}: target must name a portable relative child-directory CLAUDE.md, without globs, escapes or control paths`);
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
function ownerAt(root, named) {
|
|
47
|
+
const base = path.resolve(root), workspace = path.resolve(base, named);
|
|
48
|
+
if (!isInside(base, workspace)) throw new CompileError("nested instruction workspace lies outside its project");
|
|
49
|
+
const exists = entry(workspace);
|
|
50
|
+
const owner = posix(exists ? path.relative(fs.realpathSync(base),fs.realpathSync(workspace)) : path.relative(base,workspace)) || ".";
|
|
51
|
+
if (owner !== "." && !relative(owner)) throw new CompileError("nested instruction workspace cannot be represented inside its project");
|
|
52
|
+
return owner;
|
|
53
|
+
}
|
|
54
|
+
function sourceId(value, owner) {
|
|
55
|
+
if (!relative(value) || !value.endsWith(".md") || !within(owner, value)) throw new CompileError("nested ownership source is not inside its workspace");
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
export function nestedMark(owner, source, target) {
|
|
59
|
+
const json = JSON.stringify({ workspace: owner, source, target }).replaceAll("<", "\\u003c").replaceAll(">", "\\u003e").replaceAll("&", "\\u0026");
|
|
60
|
+
return `${MARK}${json} -->\n`;
|
|
61
|
+
}
|
|
62
|
+
function marked(text) {
|
|
63
|
+
const bytes=Buffer.from(text),end=bytes.indexOf(10);
|
|
64
|
+
let first;try{first=new TextDecoder("utf-8",{fatal:true,ignoreBOM:true}).decode(end<0?bytes:bytes.subarray(0,end));}catch{return null;}
|
|
65
|
+
if (!first.startsWith(MARK) || !first.endsWith(" -->")) return null;
|
|
66
|
+
try {
|
|
67
|
+
const value = JSON.parse(first.slice(MARK.length,-4));
|
|
68
|
+
return value && Object.keys(value).sort().join(",") === "source,target,workspace" && typeof value.source === "string" && typeof value.workspace === "string" && typeof value.target === "string" ? value : null;
|
|
69
|
+
} catch { return null; }
|
|
70
|
+
}
|
|
71
|
+
function landing(root, rel, kind = "file") {
|
|
72
|
+
let current = path.resolve(root);
|
|
73
|
+
for (const [index, part] of rel.split("/").entries()) {
|
|
74
|
+
current = path.join(current, part); const value = entry(current);
|
|
75
|
+
if (!value) return null;
|
|
76
|
+
if(!fs.readdirSync(path.dirname(current)).includes(part)) throw new CompileError(`${rel} has a noncanonical case spelling; nested ownership refuses it`);
|
|
77
|
+
if (value.isSymbolicLink() || (index < rel.split("/").length-1 || kind==="directory" ? !value.isDirectory() : !value.isFile())) throw new CompileError(`${rel} has a link or nonregular output path; nested ownership refuses it`);
|
|
78
|
+
}
|
|
79
|
+
return fs.lstatSync(current);
|
|
80
|
+
}
|
|
81
|
+
function temporaryKind(name,base) {
|
|
82
|
+
for(const [kind,prefix] of [["prepared",`.${base}.portulan-guidance.`],["claim",`.${base}.portulan-guidance-claim.`]]) {
|
|
83
|
+
if(!name.startsWith(prefix))continue;
|
|
84
|
+
const match=/^\d+\.[a-f0-9-]{36}(?:\.([a-f0-9]{64}))?$/.exec(name.slice(prefix.length));
|
|
85
|
+
if(match && (kind==="claim" ? Boolean(match[1]) : !match[1]))return {kind,digest:match[1]};
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const digest=value=>createHash("sha256").update(value).digest("hex");
|
|
90
|
+
function temporaryLinks(root,rel,value) {
|
|
91
|
+
if(value.nlink===1)return [];
|
|
92
|
+
const file=path.join(root,rel),base=path.basename(file);
|
|
93
|
+
const links=fs.readdirSync(path.dirname(file)).filter(name=>{
|
|
94
|
+
const type=temporaryKind(name,base);if(!type)return false;
|
|
95
|
+
const full=path.join(path.dirname(file),name),candidate=fs.lstatSync(full);
|
|
96
|
+
return candidate.isFile()&&candidate.dev===value.dev&&candidate.ino===value.ino&&(type.kind!=="claim"||digest(fs.readFileSync(full))===type.digest);
|
|
97
|
+
});
|
|
98
|
+
if(value.nlink!==links.length+1)throw new CompileError(`${rel} is a multiply linked output; nested ownership cannot rewrite it`);
|
|
99
|
+
return links.map(name=>posix(path.join(path.dirname(rel),name)));
|
|
100
|
+
}
|
|
101
|
+
function serialize(owner, files) {
|
|
102
|
+
return `${JSON.stringify({ format: 1, workspace: owner, files: [...files].sort((a,b) => a.target < b.target ? -1 : a.target > b.target ? 1 : 0) },null,2)}\n`;
|
|
103
|
+
}
|
|
104
|
+
function consistentPrefixes(targets) {
|
|
105
|
+
const seen=new Map();
|
|
106
|
+
for(const target of targets) {
|
|
107
|
+
const parts=target.split("/").slice(0,-1);
|
|
108
|
+
for(let end=1;end<=parts.length;end++) {
|
|
109
|
+
const prefix=parts.slice(0,end).join("/"),key=samePath(prefix),previous=seen.get(key);
|
|
110
|
+
if(previous!==undefined && previous!==prefix)throw new CompileError(`nested targets have inconsistent case spelling for directory prefix ${prefix}`);
|
|
111
|
+
seen.set(key,prefix);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export function validateNestedTargets(targets) {
|
|
116
|
+
const seen=new Set();
|
|
117
|
+
for(const target of targets){nestedTarget(target,"nested declaration");const key=samePath(target);if(seen.has(key))throw new CompileError(`${target}: two units have the same nested target`);seen.add(key);}
|
|
118
|
+
consistentPrefixes(targets);
|
|
119
|
+
}
|
|
120
|
+
function registryRows(text, owner) {
|
|
121
|
+
let data; try { data=JSON.parse(text); } catch { throw new CompileError("nested ownership registry is malformed"); }
|
|
122
|
+
if(!data || Object.keys(data).sort().join(",")!=="files,format,workspace" || data.format!==1 || data.workspace!==owner || !Array.isArray(data.files) || data.files.length===0) throw new CompileError("nested ownership registry is foreign or malformed");
|
|
123
|
+
const seen=new Set();
|
|
124
|
+
for(const row of data.files) {
|
|
125
|
+
if(!row || Object.keys(row).sort().join(",")!=="pending,sources,target" || typeof row.pending!=="boolean" || !Array.isArray(row.sources) || row.sources.length<1 || row.sources.length>2 || (!row.pending && row.sources.length!==1) || new Set(row.sources).size!==row.sources.length) throw new CompileError("nested ownership registry has malformed records");
|
|
126
|
+
nestedTarget(row.target,"nested ownership registry");const key=samePath(row.target);
|
|
127
|
+
if(seen.has(key)) throw new CompileError("nested ownership registry has duplicate targets");seen.add(key);
|
|
128
|
+
for(const source of row.sources)sourceId(source,owner);
|
|
129
|
+
}
|
|
130
|
+
consistentPrefixes(data.files.map(row=>row.target));
|
|
131
|
+
return data.files;
|
|
132
|
+
}
|
|
133
|
+
function prepared(root, rel, approve) {
|
|
134
|
+
const parent=path.posix.dirname(rel),base=path.posix.basename(rel);
|
|
135
|
+
try {if(!landing(root,parent,"directory"))return [];}catch{return [];}
|
|
136
|
+
const result=[];
|
|
137
|
+
for(const name of fs.readdirSync(path.join(root,parent))) {
|
|
138
|
+
const type=temporaryKind(name,base);if(!type)continue;
|
|
139
|
+
const file=path.posix.join(parent,name),at=entry(path.join(root,file));
|
|
140
|
+
if(!at?.isFile() || at.isSymbolicLink() || at.nlink!==1)continue;
|
|
141
|
+
let text;try{text=readOutput(path.join(root,file),root);if(type.kind==="claim" && digest(Buffer.from(text))!==type.digest)continue;if(!approve(text))continue;}catch{continue;}
|
|
142
|
+
result.push({file,before:text,next:null,nested:true,claimBase:base,mode:at.mode,claim:type.kind==="claim",beforeIdentity:{dev:at.dev,ino:at.ino,mode:at.mode}});
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
export function readNestedState(root, workspace = ".portulan") {
|
|
147
|
+
const owner=ownerAt(root,workspace);
|
|
148
|
+
let present;try{present=fs.lstatSync(path.join(root,NESTED_REGISTRY));}catch(error){if(error.code!=="ENOENT" && error.code!=="ENOTDIR")throw error;}
|
|
149
|
+
const cleanup=prepared(root,NESTED_REGISTRY,text=>sameContent(serialize(owner,registryRows(text,owner)),text));
|
|
150
|
+
const value=present ? landing(root,NESTED_REGISTRY) : null;
|
|
151
|
+
let text=null,files=[],recovery=null;
|
|
152
|
+
if(value) {
|
|
153
|
+
text=readOwnedText(path.join(root,NESTED_REGISTRY),root);files=registryRows(text,owner);
|
|
154
|
+
cleanup.push(...temporaryLinks(root,NESTED_REGISTRY,value).map(file=>({file,before:text,next:null,nested:true,claimBase:path.posix.basename(NESTED_REGISTRY)})));
|
|
155
|
+
} else {
|
|
156
|
+
const candidates=cleanup.filter(edit=>edit.claim);
|
|
157
|
+
if(candidates.length) {
|
|
158
|
+
if(candidates.some(edit=>!sameContent(edit.before,candidates[0].before)))throw new CompileError("interrupted ownership claims disagree; review the retained registry files");
|
|
159
|
+
recovery=candidates[0].before;files=registryRows(recovery,owner);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const current=new Map();
|
|
163
|
+
for(const row of files) {
|
|
164
|
+
const at=landing(root,row.target);let body=null,source=null;
|
|
165
|
+
const authorized=text=>{const mark=marked(text);return mark && mark.workspace===owner && mark.target===row.target && row.sources.includes(mark.source);};
|
|
166
|
+
if(at) {
|
|
167
|
+
body=readOutput(path.join(root,row.target),root);
|
|
168
|
+
if(!authorized(body))throw new CompileError(`${row.target} has lost its compiler ownership mark; human guidance is preserved`);
|
|
169
|
+
source=marked(body).source;
|
|
170
|
+
for(const file of temporaryLinks(root,row.target,at))cleanup.push({file,before:body,next:null,nested:true,claimBase:path.posix.basename(row.target)});
|
|
171
|
+
}
|
|
172
|
+
const artifacts=prepared(root,row.target,authorized);
|
|
173
|
+
const modes=new Set(artifacts.map(edit=>edit.mode));
|
|
174
|
+
if(!at&&modes.size>1)throw new CompileError(`${row.target}: interrupted permission records disagree; review ${artifacts.map(edit=>edit.file).join(", ")}`);
|
|
175
|
+
cleanup.push(...artifacts);
|
|
176
|
+
current.set(row.target,{text:body,source,mode:at?.mode??artifacts[0]?.mode,evidence:artifacts.map(edit=>edit.file),evidenceSources:[...new Set(artifacts.map(edit=>marked(edit.before).source))]});
|
|
177
|
+
}
|
|
178
|
+
return {owner,text,files,cleanup,current,recovery};
|
|
179
|
+
}
|
|
180
|
+
function writable(root, rel) {
|
|
181
|
+
const full = path.join(root,rel);
|
|
182
|
+
let parent = path.dirname(full); while (!entry(parent)) parent = path.dirname(parent);
|
|
183
|
+
fs.accessSync(parent,fs.constants.W_OK | fs.constants.X_OK);
|
|
184
|
+
}
|
|
185
|
+
export function workspaceProtection(root, workspace, manifest) {
|
|
186
|
+
const owner=ownerAt(root,workspace), realRoot=fs.realpathSync(root), base=path.resolve(realRoot,owner);
|
|
187
|
+
if(manifest===undefined) {
|
|
188
|
+
try { manifest=JSON.parse(readOwnedText(path.join(base,"workspace.json"),base)); }
|
|
189
|
+
catch { throw new CompileError("nested ownership cannot inspect its workspace source boundaries"); }
|
|
190
|
+
}
|
|
191
|
+
const found=[];
|
|
192
|
+
for(const value of Object.values(manifest.slots ?? {}).filter(value=>typeof value === "string")) {
|
|
193
|
+
const full=path.resolve(base,value), names=[full];
|
|
194
|
+
try { names.push(fs.realpathSync(full)); } catch(error) { if(error.code!=="ENOENT" && error.code!=="ENOTDIR") throw error; }
|
|
195
|
+
for(const name of names) found.push({path:posix(path.relative(realRoot,name))||".",directory:value.endsWith("/")});
|
|
196
|
+
}
|
|
197
|
+
for(const name of ["compile",".claude"]) found.push({path:posix(path.relative(realRoot,path.join(base,name))),directory:true});
|
|
198
|
+
return found;
|
|
199
|
+
}
|
|
200
|
+
function reservedTarget(target,protectedPaths) {
|
|
201
|
+
return protectedPaths.some(p=>p.directory ? p.path==="." || samePath(target)===samePath(p.path) || samePath(target).startsWith(`${samePath(p.path)}/`) : samePath(target)===samePath(p.path));
|
|
202
|
+
}
|
|
203
|
+
export function nestedPlan(root, guidance, files, workspace = ".portulan") {
|
|
204
|
+
const state = readNestedState(root,workspace), desired = files.filter(file => file.nested);
|
|
205
|
+
const deferred=new Set(desired.flatMap(file=>{const observed=state.current.get(file.path);return observed?.text===null?observed.evidence:[];}));
|
|
206
|
+
const early=state.cleanup.filter(edit=>!deferred.has(edit.file)),late=state.cleanup.filter(edit=>deferred.has(edit.file));
|
|
207
|
+
const edits = state.recovery ? [{file:NESTED_REGISTRY,before:null,next:state.recovery,exclusive:true,nested:true,mode:state.cleanup[0]?.mode},...early] : [...early], stale = [], claimed = new Set([NESTED_REGISTRY,...state.cleanup.map(edit => edit.file)]);
|
|
208
|
+
validateNestedTargets(desired.map(file=>file.path));
|
|
209
|
+
if(desired.length || state.files.length)landing(root,NESTED_REGISTRY);
|
|
210
|
+
const protectedPaths = guidance?.protected ?? (state.files.length ? workspaceProtection(root,workspace) : []);
|
|
211
|
+
const reserved=target=>reservedTarget(target,protectedPaths);
|
|
212
|
+
for(const row of state.files) {
|
|
213
|
+
if(reserved(row.target)) throw new CompileError(`${row.target} is now a reserved curated source and cannot be retired`);
|
|
214
|
+
if(desired.some(file=>samePath(file.path)===samePath(row.target) && file.path!==row.target)) throw new CompileError(`${row.target} has an ambiguous case-only target change; retire it before selecting another spelling`);
|
|
215
|
+
}
|
|
216
|
+
for (const file of desired) {
|
|
217
|
+
if ((state.owner !== "." && within(state.owner,file.path)) || reserved(file.path)) throw new CompileError(`${file.path} collides with a reserved workspace/source path`);
|
|
218
|
+
sourceId(file.unit.ownedSource ?? file.unit.source,state.owner);
|
|
219
|
+
landing(root,file.path);
|
|
220
|
+
const observed = state.current.get(file.path);
|
|
221
|
+
if (!observed && entry(path.join(root,file.path))) throw new CompileError(`${file.path} exists without its compiler ownership registry; human guidance is preserved`);
|
|
222
|
+
claimed.add(file.path);
|
|
223
|
+
}
|
|
224
|
+
consistentPrefixes([...state.files.map(row=>row.target),...desired.map(file=>file.path)]);
|
|
225
|
+
const final = desired.map(file => ({ target: file.path, sources: [file.unit.ownedSource ?? file.unit.source], pending: false }));
|
|
226
|
+
const finalText = final.length ? serialize(state.owner,final) : null;
|
|
227
|
+
const control=finalText===null ? null : {path:NESTED_REGISTRY,text:finalText,unit:null,control:true};
|
|
228
|
+
if(state.text===finalText && state.cleanup.length===0 && desired.every(file=>state.current.get(file.path)?.text===file.text)) return {edits:[],claimed,stale:[],control};
|
|
229
|
+
const desiredByPath = new Map(desired.map(file => [file.path,file]));
|
|
230
|
+
const union = [];
|
|
231
|
+
for (const row of state.files) {
|
|
232
|
+
claimed.add(row.target); const now = state.current.get(row.target), wanted = desiredByPath.get(row.target);
|
|
233
|
+
const observedSources=now.text!==null?[now.source]:(wanted?now.evidenceSources:[]);
|
|
234
|
+
if(observedSources.length){const sources=[...new Set([...observedSources,...(wanted?[wanted.unit.ownedSource??wanted.unit.source]:[])])].sort();if(sources.length>2)throw new CompileError(`${row.target}: interrupted source transitions require review before another source can claim the target`);union.push({target:row.target,sources,pending:Boolean(wanted)});}
|
|
235
|
+
if (!wanted && now.text !== null) stale.push({ file: row.target, before: now.text, next: null, nested: true });
|
|
236
|
+
}
|
|
237
|
+
for (const file of desired) {
|
|
238
|
+
const source = file.unit.ownedSource ?? file.unit.source;
|
|
239
|
+
if (!union.some(row => row.target === file.path)) union.push({ target: file.path, sources: [source], pending: true });
|
|
240
|
+
}
|
|
241
|
+
let beforeRegistry = state.recovery ?? state.text;
|
|
242
|
+
if (union.length) {
|
|
243
|
+
const unionText = serialize(state.owner,union);
|
|
244
|
+
if (unionText !== beforeRegistry) { edits.push({ file:NESTED_REGISTRY,before:beforeRegistry,next:unionText,exclusive:beforeRegistry===null,nested:true }); beforeRegistry=unionText; }
|
|
245
|
+
}
|
|
246
|
+
for (const file of desired) {
|
|
247
|
+
const before = state.current.get(file.path)?.text ?? null;
|
|
248
|
+
if (before !== file.text) edits.push({ file:file.path,before,next:file.text,exclusive:before===null,nested:true,mode:state.current.get(file.path)?.mode });
|
|
249
|
+
}
|
|
250
|
+
edits.push(...late,...stale);
|
|
251
|
+
if (finalText !== beforeRegistry) edits.push({ file:NESTED_REGISTRY,before:beforeRegistry,next:finalText,nested:true });
|
|
252
|
+
return { edits, claimed, stale: [...state.cleanup,...stale,...(state.text!==null && !final.length ? [{ file:NESTED_REGISTRY,next:null,nested:true }] : [])], control };
|
|
253
|
+
}
|
|
254
|
+
function guidanceTemporary(file,base=path.basename(file),hash=null) {
|
|
255
|
+
if(!base || base==="." || base===".." || path.basename(base)!==base || base.includes("\\"))throw new CompileError("invalid temporary-file basename");
|
|
256
|
+
return path.join(path.dirname(file),`.${base}.portulan-guidance${hash?"-claim":""}.${process.pid}.${randomUUID()}${hash?"."+hash:""}`);
|
|
257
|
+
}
|
|
258
|
+
function rawIdentity(file,identity) {
|
|
259
|
+
const fd=fs.openSync(file,fs.constants.O_RDONLY|(fs.constants.O_NOFOLLOW??0)|fs.constants.O_NONBLOCK);
|
|
260
|
+
try {const at=fs.fstatSync(fd);if(!at.isFile()||!matchesIdentity(at,identity))throw new CompileError(`${file} changed identity before reading`);return fs.readFileSync(fd);}finally{fs.closeSync(fd);}
|
|
261
|
+
}
|
|
262
|
+
const matchesIdentity=(at,identity)=>!identity || Boolean(at && at.dev===identity.dev && at.ino===identity.ino && (identity.mode===undefined||at.mode===identity.mode) && (identity.nlink===undefined||at.nlink===identity.nlink));
|
|
263
|
+
function putClaimBack(claim,file) {
|
|
264
|
+
try{fs.linkSync(claim,file);fs.unlinkSync(claim);}
|
|
265
|
+
catch{throw new CompileError(`${file} changed during publication; conflicting data remains at ${claim}. Review both paths`);}
|
|
266
|
+
}
|
|
267
|
+
function claimExisting(file,{before,beforeDigest,identity,allowLinks=false,claimBase,onClaim}={}) {
|
|
268
|
+
const at=entry(file);
|
|
269
|
+
if(!at){if(identity||beforeDigest||(before!==undefined && before!==null))throw new CompileError(`${file} changed after guidance planning`);return null;}
|
|
270
|
+
if(!at.isFile() || at.isSymbolicLink() || (!allowLinks&&at.nlink!==1) || !matchesIdentity(at,identity))throw new CompileError(`${file} changed identity or is not an ordinary write target`);
|
|
271
|
+
const expectedIdentity={dev:at.dev,ino:at.ino,nlink:at.nlink,mode:at.mode};
|
|
272
|
+
if(before===null)throw new CompileError(`${file} arrived after guidance planning`);
|
|
273
|
+
const expected=before===undefined?rawIdentity(file,expectedIdentity):before;
|
|
274
|
+
if(beforeDigest && digest(Buffer.from(expected))!==beforeDigest)throw new CompileError(`${file} changed contents after publication`);
|
|
275
|
+
const claim=guidanceTemporary(file,claimBase,digest(Buffer.from(expected)));
|
|
276
|
+
const fd=fs.openSync(claim,"wx",at.mode&0o777),created=fs.fstatSync(fd),placeholder={dev:created.dev,ino:created.ino},expectedBytes=Buffer.from(expected);
|
|
277
|
+
let moved=false,cleared=false,prepared=expectedBytes,preparedMode=created.mode;
|
|
278
|
+
try {
|
|
279
|
+
try{fs.writeFileSync(fd,expected);fs.fchmodSync(fd,at.mode);}catch(error){prepared=expectedBytes.subarray(0,fs.fstatSync(fd).size);throw error;}finally{preparedMode=fs.fstatSync(fd).mode;fs.closeSync(fd);}
|
|
280
|
+
onClaim?.({file,claim,identity:{dev:at.dev,ino:at.ino,mode:at.mode},placeholder:{...placeholder,mode:preparedMode}});
|
|
281
|
+
try{fs.renameSync(file,claim);moved=true;}catch(error){const held=entry(claim);if(held&&!matchesIdentity(held,placeholder)){moved=true;putClaimBack(claim,file);moved=false;cleared=true;}throw error;}
|
|
282
|
+
const held=entry(claim);
|
|
283
|
+
if(!held?.isFile() || !matchesIdentity(held,expectedIdentity) || (!allowLinks&&held.nlink!==1) || !sameContent(rawIdentity(claim,expectedIdentity),expected)) {
|
|
284
|
+
putClaimBack(claim,file);moved=false;cleared=true;
|
|
285
|
+
throw new CompileError(`${file} changed after guidance planning; its current data was preserved`);
|
|
286
|
+
}
|
|
287
|
+
return {file:claim,before:expected,identity:{dev:at.dev,ino:at.ino,mode:at.mode}};
|
|
288
|
+
} finally {if(!moved&&!cleared&&matchesIdentity(entry(claim),placeholder)&&sameContent(rawIdentity(claim,placeholder),prepared))fs.unlinkSync(claim);}
|
|
289
|
+
}
|
|
290
|
+
export function guardedRemove(file,{before,beforeDigest,identity,allowLinks=false,claimBase,onRemove,onClaim}={}) {
|
|
291
|
+
const claim=claimExisting(file,{before,beforeDigest,identity,allowLinks,claimBase,onClaim});
|
|
292
|
+
if(claim!==null)try{onRemove?.(claim);fs.unlinkSync(claim.file);}catch(error){error.retainedGuidance=[claim];error.message+=`; retained guidance at ${claim.file}`;throw error;}
|
|
293
|
+
}
|
|
294
|
+
export function atomicWrite(file,text,{exclusive=false,before,beforeDigest,identity,allowLinks=false,mode,write=fs.writeFileSync,stagingPath,onPublish,onPrepare,onClaim,onCleanupClaim}={}) {
|
|
295
|
+
const staging=stagingPath??guidanceTemporary(file),cleanup=[];let made=false,stageIdentity,claim=null,published=false,writeFinished=false;
|
|
296
|
+
try {
|
|
297
|
+
const fd=fs.openSync(staging,"wx",(mode??0o666)&0o7777);made=true;const owned=fs.fstatSync(fd);stageIdentity={dev:owned.dev,ino:owned.ino};
|
|
298
|
+
try {if(write===fs.writeFileSync)write(fd,text);else write(staging,text,{flag:"r+"});writeFinished=true;if(mode!==undefined&&mode!==null)fs.fchmodSync(fd,mode);}finally{stageIdentity.mode=fs.fstatSync(fd).mode;fs.closeSync(fd);}
|
|
299
|
+
if(!matchesIdentity(entry(staging),stageIdentity))throw new CompileError(`${staging} changed during preparation`);
|
|
300
|
+
if(!sameContent(rawIdentity(staging,stageIdentity),text))throw new CompileError(`${staging} has incomplete or changed prepared bytes`);
|
|
301
|
+
onPrepare?.({file,staging,identity:stageIdentity});
|
|
302
|
+
if(!exclusive)claim=claimExisting(file,{before,beforeDigest,identity,allowLinks,onClaim});
|
|
303
|
+
cleanup.push({file:staging,before:text,identity:stageIdentity});if(claim!==null)cleanup.push(claim);
|
|
304
|
+
fs.linkSync(staging,file);published=true;onPublish?.(stageIdentity,cleanup);
|
|
305
|
+
if(claim!==null){fs.unlinkSync(claim.file);claim=null;}
|
|
306
|
+
return stageIdentity;
|
|
307
|
+
} catch(error) {
|
|
308
|
+
if(!published&&stageIdentity&&matchesIdentity(entry(file),stageIdentity)&&sameContent(rawIdentity(file,stageIdentity),text)){published=true;onPublish?.(stageIdentity,cleanup);}
|
|
309
|
+
if(claim!==null && !published) {putClaimBack(claim.file,file);claim=null;}
|
|
310
|
+
throw error;
|
|
311
|
+
} finally {
|
|
312
|
+
if(made && matchesIdentity(entry(staging),stageIdentity))try{guardedRemove(staging,{before:writeFinished?text:rawIdentity(staging,stageIdentity),identity:stageIdentity,allowLinks:true,claimBase:path.basename(file),onClaim:writeFinished?onCleanupClaim:undefined});}catch(error){cleanup.push(...(error.retainedGuidance??[]));throw error;}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
export function preflightNestedEdits(root, edits) {
|
|
316
|
+
for(const edit of edits)try{const at=landing(root,edit.file);if(edit.beforeIdentity&&!matchesIdentity(at,edit.beforeIdentity))throw new CompileError(`${edit.file} changed identity or mode before publication`);writable(root,edit.file);}catch(error){if(error instanceof CompileError)throw error;throw new CompileError(`${edit.file} cannot be published — ${error.code??error.message}`);}
|
|
317
|
+
}
|
|
318
|
+
export function applyNestedEdits(root, edits) {
|
|
319
|
+
preflightNestedEdits(root,edits);
|
|
320
|
+
for (const edit of edits) {
|
|
321
|
+
const file=path.join(root,edit.file), at=landing(root,edit.file), current=at ? readOutput(file,root) : null;
|
|
322
|
+
if (!sameContent(current,edit.before)) throw new CompileError(`${edit.file} changed after guidance planning`);
|
|
323
|
+
if (edit.next===null) { if(at)guardedRemove(file,{before:edit.before,identity:edit.beforeIdentity??{dev:at.dev,ino:at.ino,mode:at.mode},allowLinks:Boolean(edit.claimBase),claimBase:edit.claimBase});continue; }
|
|
324
|
+
fs.mkdirSync(path.dirname(file),{recursive:true});
|
|
325
|
+
atomicWrite(file,edit.next,{exclusive:edit.exclusive,before:edit.before,identity:edit.beforeIdentity??(at?{dev:at.dev,ino:at.ino,mode:at.mode}:undefined),mode:at ? at.mode : edit.mode});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
export function ownedNestedFiles(root, workspace = ".portulan") {
|
|
329
|
+
const state=readNestedState(root,workspace);
|
|
330
|
+
if(state.files.length) {
|
|
331
|
+
const protectedPaths=workspaceProtection(root,workspace);
|
|
332
|
+
for(const row of state.files)if(reservedTarget(row.target,protectedPaths))throw new CompileError(`${row.target} is now a reserved curated source and cannot be excluded from vendor material`);
|
|
333
|
+
}
|
|
334
|
+
return new Set([...state.current].filter(([,value])=>value.text!==null).map(([file])=>file).concat(state.cleanup.map(edit=>edit.file)));
|
|
335
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Handoff caps and windows.
|
|
2
|
+
import { declaredContext } from "./context.mjs";
|
|
3
|
+
|
|
4
|
+
export class HandoffError extends Error {}
|
|
5
|
+
|
|
6
|
+
const object = (value, where, keys) => {
|
|
7
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new HandoffError(`${where} must be an object`);
|
|
8
|
+
for (const key of Object.keys(value)) if (!keys.includes(key)) throw new HandoffError(`${where}.${key} is not a handoff setting`);
|
|
9
|
+
return value;
|
|
10
|
+
};
|
|
11
|
+
const positive = (value, where) => {
|
|
12
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new HandoffError(`${where} must be a positive integer`);
|
|
13
|
+
return value;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function realHandoffDay(value) {
|
|
17
|
+
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
|
18
|
+
const at = new Date(0);
|
|
19
|
+
const [y, m, d] = value.split("-").map(Number);
|
|
20
|
+
at.setUTCFullYear(y, m - 1, d);
|
|
21
|
+
return at.toISOString().slice(0, 10) === value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function handoffPolicy(workspace) {
|
|
25
|
+
let cap = null, cutoff = null, window = null;
|
|
26
|
+
if (workspace.handoffs !== undefined) {
|
|
27
|
+
const handoffs = object(workspace.handoffs, "handoffs", ["series", "index"]);
|
|
28
|
+
if (handoffs.series !== undefined) {
|
|
29
|
+
const series = object(handoffs.series, "handoffs.series", ["budget"]);
|
|
30
|
+
const budget = object(series.budget, "handoffs.series.budget", ["record_tokens", "cutoff"]);
|
|
31
|
+
cap = positive(budget.record_tokens, "handoffs.series.budget.record_tokens");
|
|
32
|
+
cutoff = budget.cutoff;
|
|
33
|
+
if (!realHandoffDay(cutoff)) throw new HandoffError("handoffs.series.budget.cutoff must be a real day written YYYY-MM-DD");
|
|
34
|
+
}
|
|
35
|
+
if (handoffs.index !== undefined) {
|
|
36
|
+
const index = object(handoffs.index, "handoffs.index", ["path", "budget"]);
|
|
37
|
+
if (typeof index.path !== "string" || !index.path) throw new HandoffError("handoffs.index.path must name the generated index");
|
|
38
|
+
if (index.budget !== undefined) window = positive(object(index.budget, "handoffs.index.budget", ["tokens"]).tokens, "handoffs.index.budget.tokens");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (cap === null && window === null) return { cap, cutoff, window, ratio: null };
|
|
42
|
+
if (!/^2\.(?:15|1[6-9]|[2-9]\d|\d{3,})$/.test(workspace.portulan?.spec ?? "")) throw new HandoffError("handoff budgets require Workspace Definition 2.15 or later");
|
|
43
|
+
if (!workspace.slots?.handoffs) throw new HandoffError("handoff budgets require slots.handoffs");
|
|
44
|
+
let ratio;
|
|
45
|
+
try { ratio = declaredContext(workspace).ratio; }
|
|
46
|
+
catch (error) { throw new HandoffError(error.message); }
|
|
47
|
+
if (ratio === null) throw new HandoffError("handoff budgets require an explicit context.ratio to count tokens by");
|
|
48
|
+
return { cap, cutoff, window, ratio };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const handoffTokens = (bytes, ratio) => Math.round(bytes / ratio);
|
|
52
|
+
|
|
53
|
+
export function handoffOverages(series, policy) {
|
|
54
|
+
const bound = [], historical = [];
|
|
55
|
+
if (policy.cap !== null) for (const record of series.records) {
|
|
56
|
+
const tokens = handoffTokens(record.bytes, policy.ratio);
|
|
57
|
+
if (tokens > policy.cap) (record.date > policy.cutoff ? bound : historical).push({ ...record, tokens });
|
|
58
|
+
}
|
|
59
|
+
return { bound, historical };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const HANDOFF_OFFER = "Handoffs: offer 3000 tokens per new handoff and 3000 for the index window, independently optional. Supply a measured ratio; its provenance is not proof of host calibration. Decline leaves both undeclared.";
|
|
63
|
+
|
|
64
|
+
export function handoffOffer(raw, { today = new Date().toISOString().slice(0, 10), newest = today } = {}) {
|
|
65
|
+
if (raw === undefined || raw === null || raw === "none") return null;
|
|
66
|
+
let value;
|
|
67
|
+
try { value = typeof raw === "string" ? JSON.parse(raw) : raw; }
|
|
68
|
+
catch { throw new HandoffError("handoff-budget must be JSON or `none`"); }
|
|
69
|
+
object(value, "handoff-budget", ["record_tokens", "index_tokens", "ratio"]);
|
|
70
|
+
if (value.record_tokens === undefined && value.index_tokens === undefined) throw new HandoffError("handoff-budget must accept record_tokens, index_tokens, or both");
|
|
71
|
+
const manifest = { portulan: { spec: "2.15" }, slots: { handoffs: "handoffs/" }, context: { ratio: value.ratio }, handoffs: { index: { path: "handoffs-index.md" } } };
|
|
72
|
+
if (value.record_tokens !== undefined) manifest.handoffs.series = { budget: { record_tokens: value.record_tokens, cutoff: newest > today ? newest : today } };
|
|
73
|
+
if (value.index_tokens !== undefined) manifest.handoffs.index.budget = { tokens: value.index_tokens };
|
|
74
|
+
handoffPolicy(manifest);
|
|
75
|
+
return { context: manifest.context, handoffs: manifest.handoffs };
|
|
76
|
+
}
|