@sleepy_panda_srl/portulan 0.3.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.
Files changed (87) hide show
  1. package/README.md +15 -4
  2. package/cli/README.md +55 -3
  3. package/cli/advisory.mjs +167 -53
  4. package/cli/cache-risk.mjs +69 -0
  5. package/cli/comments.mjs +8 -5
  6. package/cli/compile.mjs +492 -146
  7. package/cli/context.mjs +204 -43
  8. package/cli/doctor.mjs +211 -33
  9. package/cli/drills.mjs +32 -7
  10. package/cli/finish.md +34 -0
  11. package/cli/finish.mjs +8 -67
  12. package/cli/form.mjs +34 -18
  13. package/cli/frontmatter.mjs +39 -0
  14. package/cli/gate.mjs +14 -12
  15. package/cli/guidance-files.mjs +335 -0
  16. package/cli/handoff-policy.mjs +76 -0
  17. package/cli/html-entities.mjs +2130 -0
  18. package/cli/import-files.mjs +15 -0
  19. package/cli/index.mjs +77 -24
  20. package/cli/init-preview.mjs +553 -0
  21. package/cli/init.mjs +261 -46
  22. package/cli/instructions-journal.mjs +1185 -0
  23. package/cli/instructions.mjs +332 -57
  24. package/cli/ledger.mjs +123 -37
  25. package/cli/librarian.mjs +103 -19
  26. package/cli/persona-routing.mjs +209 -0
  27. package/cli/plugin-lint.mjs +77 -56
  28. package/cli/recipe-output.mjs +91 -0
  29. package/cli/recipe-runner.md +68 -0
  30. package/cli/recipe-runner.mjs +131 -0
  31. package/cli/recipe-set.mjs +12 -3
  32. package/cli/ritual-contexts.mjs +103 -0
  33. package/cli/sessions.mjs +2 -1
  34. package/cli/spend-analysis.mjs +111 -0
  35. package/cli/spend-offer.md +53 -0
  36. package/cli/spend-offer.mjs +93 -0
  37. package/cli/stop-diagnostics.mjs +114 -0
  38. package/cli/stop-gate.mjs +133 -90
  39. package/cli/stop-policy.mjs +14 -0
  40. package/cli/symbols.mjs +55 -8
  41. package/cli/telemetry.mjs +19 -12
  42. package/cli/upgrade.mjs +62 -44
  43. package/cli/vendor.mjs +207 -125
  44. package/core/operating/context.md +368 -60
  45. package/core/operating/evolution.md +3 -2
  46. package/core/operating/loop.md +9 -4
  47. package/core/operating/sessions.md +6 -15
  48. package/core/personas/README.md +52 -10
  49. package/core/personas/implementer.md +3 -0
  50. package/core/personas/librarian.md +3 -0
  51. package/core/personas/reviewer.md +3 -0
  52. package/core/templates/handoff.md +6 -2
  53. package/evals/ab/personas/README.md +111 -0
  54. package/evals/ab/personas/acquisition.json +330 -0
  55. package/evals/ab/personas/bindings/implementer.txt +43 -0
  56. package/evals/ab/personas/bindings/librarian.txt +40 -0
  57. package/evals/ab/personas/bindings/reviewer.txt +41 -0
  58. package/evals/ab/personas/corpus.json +69 -0
  59. package/evals/ab/personas/grader.mjs +171 -0
  60. package/evals/ab/personas/implementer.json +774 -0
  61. package/evals/ab/personas/implementer.md +22 -0
  62. package/evals/ab/personas/librarian.json +758 -0
  63. package/evals/ab/personas/librarian.md +22 -0
  64. package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
  65. package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
  66. package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
  67. package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
  68. package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
  69. package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
  70. package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
  71. package/evals/ab/personas/reviewer.json +758 -0
  72. package/evals/ab/personas/reviewer.md +22 -0
  73. package/evals/releases/0.4.0.json +154 -0
  74. package/evals/releases/0.4.0.md +69 -0
  75. package/package.json +6 -2
  76. package/packs/rituals/checkpoints/README.md +35 -0
  77. package/packs/rituals/checkpoints/pack.json +3 -2
  78. package/packs/tools/local-llm/README.md +46 -6
  79. package/packs/tools/local-llm/runner/local-llm.mjs +127 -13
  80. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +26 -2
  81. package/spec/README.md +37 -4
  82. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
  83. package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
  84. package/spec/migrations/README.md +6 -0
  85. package/spec/pack.schema.json +12 -3
  86. package/spec/slots.md +262 -117
  87. 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 ? `${m.module} ${m.flag}` : `${m.module} with anything but ${m.unless} alone${m.alsoUnless ? ` or ${m.alsoUnless.join(" ")} alone` : ""}`);
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
- stripShellComments(source)
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) => stripShellComments(source)
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(stripShellComments(source));
84
+ const tokens = shellTokens(shellSource(source));
82
85
  const network = NETWORK_MODES.filter((m) =>
83
- m.flag ? tokens.some((t) => tokenNamesModule(t, m.module)) && tokens.some((t) => tokenIsFlag(t, m.flag)) : !onlySafeInvocations(source, m),
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
- // A run line that is itself a `node` invocation is the whole shell text the recipe runs, so it is graded as read.
118
- const direct = words[0] === "node" && !words.some((t) => t.endsWith(".sh"));
119
- const script = direct ? words.find((t) => /\.[cm]?js$/.test(t)) : words.find((t) => t.endsWith(".sh"));
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
- for (const hit of auditRecipeSource(direct ? r.run : source)) findings.push({ recipe: r.id, script, ...hit });
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
- /** Everything that means `upgrade` could not run. Carries no verdict about a workspace. */
24
+ /** Unavailable, without a verdict about the workspace. */
23
25
  export class UpgradeError extends Error {}
24
26
 
25
- /** The Workspace Definition version this bundle implements, read from its schema's `$id`. */
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
- /** Read a workspace into the view a step is given, as `spec/migrations/README.md` defines it. */
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 repository the manifest's `tree` names, as a step reads it; `null` where it names no directory. */
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`: owed, and placed by a person rather than by the step, so `--write` reports it and runs on.
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
- /** `rel` resolved inside `root`, or `null` if it escapes: `path.join` would pass an absolute `rel` as inside. */
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
- /** A reason to refuse, or `null`; `root` itself is not checked, since macOS's `os.tmpdir()` runs through `/var`. */
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
- /** `dirs` as created, shallowest first; one that will not go is not empty, and neither are its parents. */
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
- previous = fs.readFileSync(file, "utf8");
300
- mode = fs.lstatSync(file).mode;
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
- fs.rmSync(file);
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
- // `lstat`, not `existsSync`, which follows links and answers `false` for a path it could not read.
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
- // No snapshot owns these until the rename lands, so every failure before it unwinds them.
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
- // Made by another since the probe: fine if a directory, and not this run's to remove.
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
- // Unique per process and edit, and beside its file so the rename stays atomic.
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
- // `wx`: anything already at the staging path, a planted symlink included, fails rather than being followed.
354
- write(staging, edit.next, { flag: "wx" });
363
+ // Exclusive creation refuses existing files and symlinks.
355
364
  const keep = mode ?? edit.mode ?? null;
356
- if (keep !== null) fs.chmodSync(staging, keep);
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
- // Last edit first: a directory two edits share is recorded on the first, and empties only after the second.
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
- // Contained again, as `restore` is exported and a snapshot may come from any caller.
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
- fs.rmSync(file, { force: true });
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
- const at = fs.lstatSync(file, { throwIfNoEntry: false });
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
- // ---- which workspace, and in which residence
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
- // ---- which direction is this workspace off in
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
- // ---- the pre-state gate, which can only ever apply to a gradeable workspace
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
- // ---- the plan
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
- // ---- apply, one step at a time, re-reading between them
643
+ // Apply and reread after each step.
626
644
  let snapshots = [];
627
645
  let current = ws;
628
- // A step may declare the tree but none moves it, so every snapshot was made in the latest one.
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
- // A step's `plan` is foreign code: a throw becomes a refusal, so the rollback still runs.
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
- // A step can make an earlier one owed, so the chain is asked again until a pass applies nothing.
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
- // ---- and grade what it produced, with the real validator
733
+ // Grade with the real validator.
716
734
  let post;
717
735
  try {
718
736
  post = await inspect(current.dir);