@sleepy_panda_srl/portulan 0.3.0 → 0.4.1
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 +18 -6
- package/cli/README.md +56 -4
- package/cli/advisory.mjs +167 -53
- package/cli/cache-risk.mjs +69 -0
- package/cli/comments.mjs +8 -5
- package/cli/compile.mjs +492 -146
- package/cli/context.mjs +204 -43
- package/cli/doctor.mjs +211 -33
- package/cli/drills.mjs +32 -7
- package/cli/finish.md +34 -0
- package/cli/finish.mjs +9 -67
- package/cli/form.mjs +35 -19
- 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 +123 -37
- package/cli/librarian.mjs +103 -19
- package/cli/persona-routing.mjs +209 -0
- package/cli/plugin-lint.mjs +77 -56
- package/cli/portulan.mjs +7 -2
- 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/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 +19 -12
- package/cli/upgrade.mjs +62 -44
- package/cli/vendor.mjs +210 -126
- package/core/operating/context.md +368 -60
- 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 +52 -10
- package/core/personas/implementer.md +3 -0
- package/core/personas/librarian.md +3 -0
- package/core/personas/reviewer.md +3 -0
- package/core/templates/handoff.md +6 -2
- 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.4.0.json +154 -0
- package/evals/releases/0.4.0.md +69 -0
- package/evals/releases/0.4.1.json +154 -0
- package/evals/releases/0.4.1.md +69 -0
- package/package.json +6 -2
- package/packs/rituals/checkpoints/README.md +35 -0
- package/packs/rituals/checkpoints/pack.json +3 -2
- package/packs/tools/local-llm/README.md +46 -6
- package/packs/tools/local-llm/runner/local-llm.mjs +127 -13
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +26 -2
- package/spec/README.md +37 -4
- 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 +262 -117
- package/spec/workspace.schema.json +67 -11
package/cli/telemetry.mjs
CHANGED
|
@@ -22,6 +22,10 @@ import { isInside } from "./inside.mjs";
|
|
|
22
22
|
|
|
23
23
|
/** Every network mode in cli/ or a pack (named from `<category>/<name>/`); a new one is unrailed until it has a row here. */
|
|
24
24
|
export const NETWORK_MODES = Object.freeze([
|
|
25
|
+
{ module: "cli/fixtures/on-path-host/capture.mjs", flag: "--run-host", what: "runs the manual real-host on-path demonstration" },
|
|
26
|
+
{ module: "cli/fixtures/nested-on-path-host/capture.mjs", flag: "--run-host", what: "runs the manual real-host nested instruction demonstration" },
|
|
27
|
+
{ module: "cli/context.mjs", flag: "--count-tokens", what: "sends measured project source to Anthropic's token counter" },
|
|
28
|
+
{ module: "cli/doctor.mjs", flag: "--count-tokens", aliases: [{ module: "cli/portulan.mjs", subcommand: "doctor" }, { module: "portulan", subcommand: "doctor" }, { module: "@sleepy_panda_srl/portulan", subcommand: "doctor" }], what: "requests a provider token estimate" },
|
|
25
29
|
{ module: "cli/review-meter.mjs", flag: "--fetch", what: "fetches review data from GitHub" },
|
|
26
30
|
{ module: "cli/telemetry.mjs", flag: "--export", what: "posts an OTLP payload to a collector" },
|
|
27
31
|
{ module: "cli/feedback.mjs", flag: "--approve", what: "files a GitHub issue with `gh issue create`" },
|
|
@@ -40,7 +44,7 @@ export const LOCAL_RECORD_MODES = Object.freeze([
|
|
|
40
44
|
{ module: "portulan", subcommand: "doctor", requiresStatic: "--static", what: "dispatches to the local usage-record reader" },
|
|
41
45
|
]);
|
|
42
46
|
|
|
43
|
-
export const modeSpelling = (m) => m.requiresStatic ? `${m.module}${m.subcommand ? ` ${m.subcommand}` : ""} without an immediate ${m.requiresStatic}` : (m.flag ? `${
|
|
47
|
+
export const modeSpelling = (m) => m.requiresStatic ? `${m.module}${m.subcommand ? ` ${m.subcommand}` : ""} without an immediate ${m.requiresStatic}` : (m.flag ? [m, ...(m.aliases ?? [])].map((s) => `${s.module}${s.subcommand ? ` ${s.subcommand}` : ""} ${m.flag}`).join(", ") : `${m.module} with anything but ${m.unless} alone${m.alsoUnless ? ` or ${m.alsoUnless.join(" ")} alone` : ""}`);
|
|
44
48
|
|
|
45
49
|
/** Whole-line comments only: a trailing `#` may sit inside a quote. */
|
|
46
50
|
export const stripShellComments = (source) =>
|
|
@@ -51,14 +55,14 @@ export const stripShellComments = (source) =>
|
|
|
51
55
|
|
|
52
56
|
const shellTokens = (body) => body.split(/\s+/).map((t) => t.replace(/^[("'`]+/, "").replace(/[)"'`;]+$/, "")).filter(Boolean);
|
|
53
57
|
|
|
54
|
-
const tokenNamesModule = (token, module) => token === module || token.endsWith(`/${module}`);
|
|
58
|
+
const tokenNamesModule = (token, module) => token === module || token.endsWith(`/${module}`) || (module.startsWith("@") && token.startsWith(`${module}@`));
|
|
55
59
|
|
|
56
60
|
const tokenIsFlag = (token, flag) => token === flag || token.startsWith(`${flag}=`);
|
|
61
|
+
const shellSource = (source) => stripShellComments(source).replace(/\\\r?\n/g, "");
|
|
57
62
|
|
|
58
63
|
/** An `unless` row passes only where every invocation is the module and its one safe flag, alone on its command. */
|
|
59
64
|
const onlySafeInvocations = (source, m) =>
|
|
60
|
-
|
|
61
|
-
.replace(/\\\n/g, " ")
|
|
65
|
+
shellSource(source)
|
|
62
66
|
.split(/\n|;|&&?|\|\|?|\$\(|<\(|>\(|`/)
|
|
63
67
|
.every((segment) => {
|
|
64
68
|
const tokens = shellTokens(segment);
|
|
@@ -66,8 +70,7 @@ const onlySafeInvocations = (source, m) =>
|
|
|
66
70
|
return tokens.every((t, i) => !tokenNamesModule(t, m.module) || ((tokens.length === i + 2 && tokens[i + 1] === m.unless) || (m.alsoUnless && JSON.stringify(tokens.slice(i + 1)) === JSON.stringify(m.alsoUnless))));
|
|
67
71
|
});
|
|
68
72
|
|
|
69
|
-
const onlyStaticInvocations = (source, mode) =>
|
|
70
|
-
.replace(/\\\n/g, " ")
|
|
73
|
+
const onlyStaticInvocations = (source, mode) => shellSource(source)
|
|
71
74
|
.split(/\n|;|&&?|\|\|?|\$\(|<\(|>\(|`/)
|
|
72
75
|
.every((segment) => {
|
|
73
76
|
const words = shellTokens(segment);
|
|
@@ -78,9 +81,9 @@ const onlyStaticInvocations = (source, mode) => stripShellComments(source)
|
|
|
78
81
|
});
|
|
79
82
|
|
|
80
83
|
export function auditRecipeSource(source) {
|
|
81
|
-
const tokens = shellTokens(
|
|
84
|
+
const tokens = shellTokens(shellSource(source));
|
|
82
85
|
const network = NETWORK_MODES.filter((m) =>
|
|
83
|
-
m.flag ? tokens.some((t) => tokenNamesModule(t,
|
|
86
|
+
m.flag ? [m, ...(m.aliases ?? [])].some((s) => tokens.some((t, i) => tokenNamesModule(t, s.module) && (!s.subcommand || tokens[i + 1] === s.subcommand))) && tokens.some((t) => tokenIsFlag(t, m.flag)) : !onlySafeInvocations(source, m),
|
|
84
87
|
);
|
|
85
88
|
const local = LOCAL_RECORD_MODES.filter((m) => !onlyStaticInvocations(source, m));
|
|
86
89
|
return [...network, ...local];
|
|
@@ -114,9 +117,10 @@ export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
|
|
|
114
117
|
const read = [];
|
|
115
118
|
for (const r of recipes) {
|
|
116
119
|
const words = String(r.run ?? "").split(/\s+/).filter(Boolean);
|
|
117
|
-
//
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
+
// Direct CLI commands are graded as shell text; referenced shell scripts are read too.
|
|
121
|
+
const bare = words[0] === "portulan";
|
|
122
|
+
const direct = bare || (words[0] === "node" && !words.some((t) => t.endsWith(".sh")));
|
|
123
|
+
const script = bare ? "cli/portulan.mjs" : direct ? words.find((t) => /\.[cm]?js$/.test(t)) : words.find((t) => t.endsWith(".sh"));
|
|
120
124
|
if (!script) {
|
|
121
125
|
return { ok: false, why: `recipe ${JSON.stringify(r.id)} has no readable script in its run line (${JSON.stringify(r.run ?? null)}); this audit cannot grade what it cannot read` };
|
|
122
126
|
}
|
|
@@ -131,7 +135,10 @@ export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
|
|
|
131
135
|
return { ok: false, why: `recipe ${JSON.stringify(r.id)} names ${script}, which could not be read — ${cause.message}` };
|
|
132
136
|
}
|
|
133
137
|
read.push(r.id);
|
|
134
|
-
|
|
138
|
+
const hits = [...auditRecipeSource(r.run), ...(direct ? [] : auditRecipeSource(source))];
|
|
139
|
+
for (const [i, hit] of hits.entries()) {
|
|
140
|
+
if (!hits.slice(0, i).some((previous) => previous.module === hit.module && previous.flag === hit.flag && previous.requiresStatic === hit.requiresStatic)) findings.push({ recipe: r.id, script, ...hit });
|
|
141
|
+
}
|
|
135
142
|
}
|
|
136
143
|
return { ok: true, examined: read, findings };
|
|
137
144
|
}
|
package/cli/upgrade.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// Exit 0 succeeded · 1 a step owed under `--check` or left by hand, a red workspace, an unresolved pointer · 2 could not run.
|
|
5
5
|
|
|
6
6
|
import fs from "node:fs";
|
|
7
|
+
import { assertNoSourceJournal } from "./instructions-journal.mjs";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
10
|
|
|
@@ -12,6 +13,7 @@ import { resolveGovernor } from "./discover.mjs";
|
|
|
12
13
|
import { gitIn } from "./form.mjs";
|
|
13
14
|
import { offerLines } from "./sessions.mjs";
|
|
14
15
|
import { walk } from "./vendor.mjs";
|
|
16
|
+
import { atomicWrite,guardedRemove,readOutput,sameContent } from "./guidance-files.mjs";
|
|
15
17
|
|
|
16
18
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
17
19
|
const BUNDLE = path.resolve(HERE, "..");
|
|
@@ -19,10 +21,10 @@ const MIGRATIONS = path.join(BUNDLE, "spec", "migrations");
|
|
|
19
21
|
|
|
20
22
|
let stagingSeq = 0;
|
|
21
23
|
|
|
22
|
-
/**
|
|
24
|
+
/** Unavailable, without a verdict about the workspace. */
|
|
23
25
|
export class UpgradeError extends Error {}
|
|
24
26
|
|
|
25
|
-
/**
|
|
27
|
+
/** This bundle's Workspace Definition version from the schema `$id`. */
|
|
26
28
|
export function bundleSpec({ schemaPath } = {}) {
|
|
27
29
|
const file = schemaPath ?? path.join(BUNDLE, "spec", "workspace.schema.json");
|
|
28
30
|
let text;
|
|
@@ -68,7 +70,7 @@ export async function loadSteps({ dir = MIGRATIONS } = {}) {
|
|
|
68
70
|
return steps;
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
/**
|
|
73
|
+
/** The step's workspace view; contract: `spec/migrations/README.md`. */
|
|
72
74
|
export function readWorkspace(dir) {
|
|
73
75
|
const root = path.resolve(dir);
|
|
74
76
|
const file = path.join(root, "workspace.json");
|
|
@@ -120,7 +122,7 @@ export function readWorkspace(dir) {
|
|
|
120
122
|
};
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
/** The
|
|
125
|
+
/** The step's declared repository view, or null without a tree. */
|
|
124
126
|
export function repositoryView(root, manifest) {
|
|
125
127
|
if (typeof manifest?.tree !== "string") return null;
|
|
126
128
|
const dir = path.resolve(root, manifest.tree);
|
|
@@ -223,7 +225,7 @@ export async function planFor(ws, ctx, steps) {
|
|
|
223
225
|
} catch (error) {
|
|
224
226
|
answer = { owed: null, because: `${step.id} threw while deciding whether it is owed — ${error.message}` };
|
|
225
227
|
}
|
|
226
|
-
// `hand
|
|
228
|
+
// `hand` requires a person; report it and continue.
|
|
227
229
|
entries.push({ step, owed: answer.owed, because: answer.because, hand: answer.owed === true && answer.hand === true });
|
|
228
230
|
if (answer.owed === null) unknown += 1;
|
|
229
231
|
else if (answer.owed === true) owed += 1;
|
|
@@ -231,14 +233,14 @@ export async function planFor(ws, ctx, steps) {
|
|
|
231
233
|
return { entries, owed, unknown };
|
|
232
234
|
}
|
|
233
235
|
|
|
234
|
-
/**
|
|
236
|
+
/** Resolve inside root, rejecting escapes. */
|
|
235
237
|
export function inside(root, rel) {
|
|
236
238
|
const resolved = path.resolve(root, rel);
|
|
237
239
|
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) return null;
|
|
238
240
|
return resolved;
|
|
239
241
|
}
|
|
240
242
|
|
|
241
|
-
/**
|
|
243
|
+
/** Refusal or null; root may itself follow macOS's `/var` alias. */
|
|
242
244
|
function linkOnPath(root, resolved) {
|
|
243
245
|
const rel = path.relative(root, resolved);
|
|
244
246
|
if (rel === "") return null;
|
|
@@ -257,7 +259,7 @@ function linkOnPath(root, resolved) {
|
|
|
257
259
|
return null;
|
|
258
260
|
}
|
|
259
261
|
|
|
260
|
-
/**
|
|
262
|
+
/** Remove created directories deepest first, stopping at a nonempty one. */
|
|
261
263
|
function unwindDirs(dirs) {
|
|
262
264
|
for (const made of [...dirs].reverse()) {
|
|
263
265
|
try {
|
|
@@ -275,6 +277,7 @@ export function applyEdits(dir, edits, options = {}) {
|
|
|
275
277
|
const treeRoot = typeof options.treeDir === "string" ? path.resolve(options.treeDir) : null;
|
|
276
278
|
const snapshots = [];
|
|
277
279
|
for (const edit of edits) {
|
|
280
|
+
try{options.beforeMutation?.(edit);}catch(error){return {ok:false,snapshots,reason:error.message};}
|
|
278
281
|
if (edit.root !== undefined && edit.root !== "workspace" && edit.root !== "tree") {
|
|
279
282
|
return { ok: false, snapshots, reason: `an edit names root \`${edit.root}\`, which is neither the workspace nor the tree` };
|
|
280
283
|
}
|
|
@@ -294,29 +297,35 @@ export function applyEdits(dir, edits, options = {}) {
|
|
|
294
297
|
if (linked !== null) return { ok: false, snapshots, reason: linked };
|
|
295
298
|
|
|
296
299
|
let previous = null;
|
|
297
|
-
let mode = null;
|
|
300
|
+
let mode = null, observedIdentity = null;
|
|
298
301
|
try {
|
|
299
|
-
|
|
300
|
-
|
|
302
|
+
const at=fs.lstatSync(file);
|
|
303
|
+
if(!at.isFile()) return {ok:false,snapshots,reason:`${file} is not a regular file`};
|
|
304
|
+
if(edit.nested && edit.next!==null && at.nlink!==1) return {ok:false,snapshots,reason:`${file} is a multiply linked guidance output`};
|
|
305
|
+
previous = readOutput(file,root);
|
|
306
|
+
const after=fs.lstatSync(file);
|
|
307
|
+
if(after.dev!==at.dev||after.ino!==at.ino||after.mode!==at.mode)return {ok:false,snapshots,reason:`${file} changed while its original was read`};
|
|
308
|
+
mode=at.mode;observedIdentity={dev:at.dev,ino:at.ino,mode:at.mode};
|
|
301
309
|
} catch (error) {
|
|
302
310
|
if (error.code !== "ENOENT") {
|
|
303
311
|
return { ok: false, snapshots, reason: `${file} could not be read before writing — ${error.code ?? error.message}` };
|
|
304
312
|
}
|
|
305
313
|
}
|
|
306
314
|
|
|
315
|
+
if (Object.hasOwn(edit,"before") && !sameContent(previous,edit.before)) return {ok:false,snapshots,reason:`${file} changed after guidance planning`};
|
|
316
|
+
if(edit.beforeIdentity){const at=fs.lstatSync(file,{throwIfNoEntry:false});if(!at||at.dev!==edit.beforeIdentity.dev||at.ino!==edit.beforeIdentity.ino||at.mode!==edit.beforeIdentity.mode)return {ok:false,snapshots,reason:`${file} changed identity after source planning`};}
|
|
307
317
|
if (edit.next === null) {
|
|
308
318
|
if (previous === null) continue;
|
|
309
319
|
if (!fs.lstatSync(file).isFile()) return { ok: false, snapshots, reason: `${file} is not a regular file — refusing to delete it` };
|
|
310
320
|
try {
|
|
311
|
-
|
|
321
|
+
guardedRemove(file,{before:previous,identity:edit.beforeIdentity??observedIdentity,allowLinks:Boolean(edit.claimBase),claimBase:edit.claimBase,onClaim:data=>options.journal?.(edit,"claim",data),onRemove:claim=>{snapshots.push({file:edit.file,root:edit.root,previous,mode,created:[],deleted:true,after:null,identity:null,cleanup:[claim]});options.journal?.(edit,"retired",claim);}});
|
|
312
322
|
} catch (error) {
|
|
313
323
|
return { ok: false, snapshots, reason: `${file} could not be deleted — ${error.code ?? error.message}` };
|
|
314
324
|
}
|
|
315
|
-
snapshots.push({ file: edit.file, root: edit.root, previous, mode, created: [], deleted: true });
|
|
316
325
|
continue;
|
|
317
326
|
}
|
|
318
327
|
|
|
319
|
-
//
|
|
328
|
+
// lstat distinguishes links and unreadable paths from absence.
|
|
320
329
|
const wanted = [];
|
|
321
330
|
for (let at = path.dirname(file); at.startsWith(root) && at !== root; at = path.dirname(at)) {
|
|
322
331
|
try {
|
|
@@ -330,7 +339,7 @@ export function applyEdits(dir, edits, options = {}) {
|
|
|
330
339
|
}
|
|
331
340
|
}
|
|
332
341
|
|
|
333
|
-
//
|
|
342
|
+
// Unwind this edit's created directories if publication fails.
|
|
334
343
|
const created = [];
|
|
335
344
|
try {
|
|
336
345
|
for (const dir of wanted) {
|
|
@@ -338,7 +347,7 @@ export function applyEdits(dir, edits, options = {}) {
|
|
|
338
347
|
fs.mkdirSync(dir);
|
|
339
348
|
created.push(dir);
|
|
340
349
|
} catch (error) {
|
|
341
|
-
//
|
|
350
|
+
// A concurrently created directory is not ours to remove.
|
|
342
351
|
if (error.code !== "EEXIST" || !fs.lstatSync(dir).isDirectory()) throw error;
|
|
343
352
|
}
|
|
344
353
|
}
|
|
@@ -347,24 +356,17 @@ export function applyEdits(dir, edits, options = {}) {
|
|
|
347
356
|
return { ok: false, snapshots, reason: `${path.dirname(file)} could not be created — ${error.code ?? error.message}` };
|
|
348
357
|
}
|
|
349
358
|
|
|
350
|
-
//
|
|
359
|
+
// Stage each edit beside its target for exclusive publication.
|
|
351
360
|
const staging = `${file}.portulan-upgrade.${process.pid}.${stagingSeq++}`;
|
|
361
|
+
let published;
|
|
352
362
|
try {
|
|
353
|
-
//
|
|
354
|
-
write(staging, edit.next, { flag: "wx" });
|
|
363
|
+
// Exclusive creation refuses existing files and symlinks.
|
|
355
364
|
const keep = mode ?? edit.mode ?? null;
|
|
356
|
-
|
|
357
|
-
fs.renameSync(staging, file);
|
|
365
|
+
published=atomicWrite(file,edit.next,{exclusive:edit.exclusive??previous===null,before:previous,identity:edit.beforeIdentity??observedIdentity??undefined,mode:keep,write,stagingPath:edit.nested?undefined:staging,onPrepare:data=>options.journal?.(edit,"stage",data),onClaim:data=>options.journal?.(edit,"claim",data),onCleanupClaim:data=>options.journal?.(edit,"undo-claim",data),onPublish:(id,cleanup)=>snapshots.push({file:edit.file,root:edit.root,previous,mode,created,after:edit.next,identity:{dev:id.dev,ino:id.ino,mode:id.mode},cleanup})});
|
|
358
366
|
} catch (error) {
|
|
359
|
-
try {
|
|
360
|
-
fs.rmSync(staging, { force: true });
|
|
361
|
-
} catch {
|
|
362
|
-
/* the staging file is the lesser problem; the write failure is what gets reported */
|
|
363
|
-
}
|
|
364
367
|
unwindDirs(created);
|
|
365
368
|
return { ok: false, snapshots, reason: `${file} could not be written — ${error.code ?? error.message}` };
|
|
366
369
|
}
|
|
367
|
-
snapshots.push({ file: edit.file, root: edit.root, previous, mode, created });
|
|
368
370
|
}
|
|
369
371
|
return { ok: true, snapshots };
|
|
370
372
|
}
|
|
@@ -374,9 +376,12 @@ export function restore(dir, snapshots, options = {}) {
|
|
|
374
376
|
const treeRoot = typeof options.treeDir === "string" ? path.resolve(options.treeDir) : null;
|
|
375
377
|
const restored = [];
|
|
376
378
|
const failed = [];
|
|
377
|
-
|
|
379
|
+
const restoredIdentities=new Map();
|
|
380
|
+
// Reverse order empties shared directories before their owner removes them.
|
|
378
381
|
for (const snapshot of [...snapshots].reverse()) {
|
|
379
|
-
|
|
382
|
+
if(options.stopOnFailure&&failed.length){failed.push(snapshot.file);continue;}
|
|
383
|
+
try{options.beforeMutation?.(snapshot);}catch{failed.push(snapshot.file);continue;}
|
|
384
|
+
// Exported restore must recheck containment of caller-supplied snapshots.
|
|
380
385
|
const root = snapshot.root === "tree" ? treeRoot : workspaceRoot;
|
|
381
386
|
const file = root === null ? null : inside(root, snapshot.file);
|
|
382
387
|
if (file === null || linkOnPath(root, file) !== null) {
|
|
@@ -384,15 +389,27 @@ export function restore(dir, snapshots, options = {}) {
|
|
|
384
389
|
continue;
|
|
385
390
|
}
|
|
386
391
|
try {
|
|
392
|
+
for(const item of snapshot.cleanup??[])if(typeof item.file!=="string"||inside(root,path.relative(root,item.file))!==item.file||linkOnPath(root,item.file)!==null)throw new UpgradeError("cleanup journal is outside its transaction root or aliases another path");
|
|
393
|
+
const at=fs.lstatSync(file,{throwIfNoEntry:false});
|
|
394
|
+
const aliases=(snapshot.cleanup??[]).filter(item=>{try{const linked=fs.lstatSync(item.file);return linked.isFile()&&linked.dev===at?.dev&&linked.ino===at?.ino&&sameContent(readOutput(item.file,root),item.before);}catch{return false;}});
|
|
395
|
+
const allowLinks=Boolean(at&&at.nlink>1&&at.nlink===aliases.length+1);
|
|
396
|
+
if(at && (!at.isFile() || at.isSymbolicLink() || (at.nlink!==1&&!allowLinks))) throw new UpgradeError(`${file} is not an ordinary singly linked restore target`);
|
|
397
|
+
const current=at ? readOutput(file,root) : null;
|
|
398
|
+
if(Object.hasOwn(snapshot,"after")) {
|
|
399
|
+
const expected=restoredIdentities.has(file) ? restoredIdentities.get(file) : snapshot.identity;
|
|
400
|
+
if(!sameContent(current,snapshot.after) || (expected && (!at || at.dev!==expected.dev || at.ino!==expected.ino || (expected.mode!==undefined&&at.mode!==expected.mode)))) throw new UpgradeError(`${file} changed since this transaction wrote it`);
|
|
401
|
+
}
|
|
402
|
+
let replacedIdentity=null;
|
|
387
403
|
if (snapshot.previous === null) {
|
|
388
|
-
|
|
389
|
-
unwindDirs(snapshot.created ?? []);
|
|
404
|
+
if(at)guardedRemove(file,{before:current,identity:{dev:at.dev,ino:at.ino,mode:at.mode},allowLinks,onClaim:data=>options.journal?.(snapshot,"undo-claim",data),onRemove:claim=>options.journal?.(snapshot,"retired",claim)});
|
|
390
405
|
} else {
|
|
391
|
-
|
|
392
|
-
if (at?.isSymbolicLink()) throw new UpgradeError(`${file} is a symlink — refusing to restore through it`);
|
|
393
|
-
fs.writeFileSync(file, snapshot.previous);
|
|
394
|
-
if (snapshot.mode !== null && snapshot.mode !== undefined) fs.chmodSync(file, snapshot.mode);
|
|
406
|
+
replacedIdentity=atomicWrite(file,snapshot.previous,{exclusive:!at,before:current,identity:at ? {dev:at.dev,ino:at.ino,mode:at.mode} : undefined,allowLinks,mode:snapshot.mode,onPrepare:data=>options.journal?.(snapshot,"undo-stage",data),onClaim:data=>options.journal?.(snapshot,"undo-claim",data),onCleanupClaim:data=>options.journal?.(snapshot,"claim",data)});
|
|
395
407
|
}
|
|
408
|
+
for(const item of snapshot.cleanup??[])if(fs.lstatSync(item.file,{throwIfNoEntry:false}))guardedRemove(item.file,{before:item.before,identity:item.identity,allowLinks:true,claimBase:path.basename(file)});
|
|
409
|
+
if(snapshot.previous===null)unwindDirs(snapshot.created??[]);
|
|
410
|
+
const replaced=fs.lstatSync(file,{throwIfNoEntry:false});
|
|
411
|
+
if(replacedIdentity?(!replaced||replaced.dev!==replacedIdentity.dev||replaced.ino!==replacedIdentity.ino||replaced.mode!==replacedIdentity.mode):Boolean(replaced))throw new UpgradeError(`${file} changed during rollback publication`);
|
|
412
|
+
restoredIdentities.set(file,replacedIdentity);
|
|
396
413
|
restored.push(snapshot.file);
|
|
397
414
|
} catch {
|
|
398
415
|
failed.push(snapshot.file);
|
|
@@ -497,7 +514,7 @@ export async function run(argv = [], options = {}) {
|
|
|
497
514
|
return 2;
|
|
498
515
|
}
|
|
499
516
|
|
|
500
|
-
//
|
|
517
|
+
// Resolve target and residence.
|
|
501
518
|
const target = await resolveTarget(positional[0], { env });
|
|
502
519
|
if (target.state === "could-not-look") {
|
|
503
520
|
warn(`upgrade: ${target.sentence}`);
|
|
@@ -524,6 +541,7 @@ export async function run(argv = [], options = {}) {
|
|
|
524
541
|
return read.code;
|
|
525
542
|
}
|
|
526
543
|
const ws = read.ws;
|
|
544
|
+
try{assertNoSourceJournal(ws.dir);if(ws.repository)assertNoSourceJournal(ws.repository.dir);}catch(error){warn(`upgrade: ${error.message}`);return 2;}
|
|
527
545
|
const relative = path.relative(process.cwd(), ws.dir);
|
|
528
546
|
const shown = relative && relative.length < ws.dir.length ? relative : ws.dir;
|
|
529
547
|
|
|
@@ -533,7 +551,7 @@ export async function run(argv = [], options = {}) {
|
|
|
533
551
|
for (const line of offerLines()) say(`upgrade: ${line}`);
|
|
534
552
|
};
|
|
535
553
|
|
|
536
|
-
//
|
|
554
|
+
// Classify version drift.
|
|
537
555
|
const declared = /^([0-9]+)\.([0-9]+)$/.exec(ws.manifest?.portulan?.spec ?? "");
|
|
538
556
|
if (!declared) {
|
|
539
557
|
warn(`upgrade: ${shown} declares no readable \`portulan.spec\` — refusing to guess which contract it was written against`);
|
|
@@ -551,7 +569,7 @@ export async function run(argv = [], options = {}) {
|
|
|
551
569
|
return 2;
|
|
552
570
|
}
|
|
553
571
|
|
|
554
|
-
//
|
|
572
|
+
// Pre-state gate requires a gradeable workspace.
|
|
555
573
|
if (!behindByMajor) {
|
|
556
574
|
let pre;
|
|
557
575
|
try {
|
|
@@ -572,7 +590,7 @@ export async function run(argv = [], options = {}) {
|
|
|
572
590
|
}
|
|
573
591
|
}
|
|
574
592
|
|
|
575
|
-
//
|
|
593
|
+
// Plan.
|
|
576
594
|
const ctx = { bundle: BUNDLE, spec, tree, today: options.today ?? new Date().toISOString().slice(0, 10) };
|
|
577
595
|
const plan = await planFor(ws, ctx, steps);
|
|
578
596
|
|
|
@@ -622,10 +640,10 @@ export async function run(argv = [], options = {}) {
|
|
|
622
640
|
return 0;
|
|
623
641
|
}
|
|
624
642
|
|
|
625
|
-
//
|
|
643
|
+
// Apply and reread after each step.
|
|
626
644
|
let snapshots = [];
|
|
627
645
|
let current = ws;
|
|
628
|
-
//
|
|
646
|
+
// Steps can declare a tree, but never move it.
|
|
629
647
|
let treeDir = ws.repository?.dir ?? null;
|
|
630
648
|
const undo = () => {
|
|
631
649
|
const result = restore(current.dir, snapshots, { treeDir });
|
|
@@ -640,7 +658,7 @@ export async function run(argv = [], options = {}) {
|
|
|
640
658
|
const byHand = new Map();
|
|
641
659
|
const apply = async (entry) => {
|
|
642
660
|
applied.add(entry.step.id);
|
|
643
|
-
//
|
|
661
|
+
// Foreign plan failures must reach rollback.
|
|
644
662
|
let planned;
|
|
645
663
|
try {
|
|
646
664
|
planned = await entry.step.plan(current, ctx);
|
|
@@ -677,7 +695,7 @@ export async function run(argv = [], options = {}) {
|
|
|
677
695
|
treeDir = current.repository?.dir ?? treeDir;
|
|
678
696
|
return null;
|
|
679
697
|
};
|
|
680
|
-
//
|
|
698
|
+
// Revisit earlier steps until a pass applies nothing.
|
|
681
699
|
for (let pass = 0; ; pass++) {
|
|
682
700
|
let appliedNow = 0;
|
|
683
701
|
for (const entry of plan.entries) {
|
|
@@ -712,7 +730,7 @@ export async function run(argv = [], options = {}) {
|
|
|
712
730
|
}
|
|
713
731
|
}
|
|
714
732
|
|
|
715
|
-
//
|
|
733
|
+
// Grade with the real validator.
|
|
716
734
|
let post;
|
|
717
735
|
try {
|
|
718
736
|
post = await inspect(current.dir);
|