@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.
Files changed (90) hide show
  1. package/README.md +18 -6
  2. package/cli/README.md +56 -4
  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 +9 -67
  12. package/cli/form.mjs +35 -19
  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/portulan.mjs +7 -2
  29. package/cli/recipe-output.mjs +91 -0
  30. package/cli/recipe-runner.md +68 -0
  31. package/cli/recipe-runner.mjs +131 -0
  32. package/cli/recipe-set.mjs +12 -3
  33. package/cli/ritual-contexts.mjs +103 -0
  34. package/cli/sessions.mjs +2 -1
  35. package/cli/spend-analysis.mjs +111 -0
  36. package/cli/spend-offer.md +53 -0
  37. package/cli/spend-offer.mjs +93 -0
  38. package/cli/stop-diagnostics.mjs +114 -0
  39. package/cli/stop-gate.mjs +133 -90
  40. package/cli/stop-policy.mjs +14 -0
  41. package/cli/symbols.mjs +55 -8
  42. package/cli/telemetry.mjs +19 -12
  43. package/cli/upgrade.mjs +62 -44
  44. package/cli/vendor.mjs +210 -126
  45. package/core/operating/context.md +368 -60
  46. package/core/operating/evolution.md +3 -2
  47. package/core/operating/loop.md +9 -4
  48. package/core/operating/sessions.md +6 -15
  49. package/core/personas/README.md +52 -10
  50. package/core/personas/implementer.md +3 -0
  51. package/core/personas/librarian.md +3 -0
  52. package/core/personas/reviewer.md +3 -0
  53. package/core/templates/handoff.md +6 -2
  54. package/evals/ab/personas/README.md +111 -0
  55. package/evals/ab/personas/acquisition.json +330 -0
  56. package/evals/ab/personas/bindings/implementer.txt +43 -0
  57. package/evals/ab/personas/bindings/librarian.txt +40 -0
  58. package/evals/ab/personas/bindings/reviewer.txt +41 -0
  59. package/evals/ab/personas/corpus.json +69 -0
  60. package/evals/ab/personas/grader.mjs +171 -0
  61. package/evals/ab/personas/implementer.json +774 -0
  62. package/evals/ab/personas/implementer.md +22 -0
  63. package/evals/ab/personas/librarian.json +758 -0
  64. package/evals/ab/personas/librarian.md +22 -0
  65. package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
  66. package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
  67. package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
  68. package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
  69. package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
  70. package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
  71. package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
  72. package/evals/ab/personas/reviewer.json +758 -0
  73. package/evals/ab/personas/reviewer.md +22 -0
  74. package/evals/releases/0.4.0.json +154 -0
  75. package/evals/releases/0.4.0.md +69 -0
  76. package/evals/releases/0.4.1.json +154 -0
  77. package/evals/releases/0.4.1.md +69 -0
  78. package/package.json +6 -2
  79. package/packs/rituals/checkpoints/README.md +35 -0
  80. package/packs/rituals/checkpoints/pack.json +3 -2
  81. package/packs/tools/local-llm/README.md +46 -6
  82. package/packs/tools/local-llm/runner/local-llm.mjs +127 -13
  83. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +26 -2
  84. package/spec/README.md +37 -4
  85. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
  86. package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
  87. package/spec/migrations/README.md +6 -0
  88. package/spec/pack.schema.json +12 -3
  89. package/spec/slots.md +262 -117
  90. package/spec/workspace.schema.json +67 -11
package/cli/vendor.mjs CHANGED
@@ -5,10 +5,14 @@
5
5
  // Exit 0 done · 1 `doctor` red at an end · 2 could not run.
6
6
 
7
7
  import fs from "node:fs";
8
+ import {createHash,randomUUID} from "node:crypto";
9
+ import {isInside} from "./inside.mjs";
10
+ import {atomicWrite,guardedRemove} from "./guidance-files.mjs";
11
+ import {assertNoSourceJournal,SOURCE_JOURNAL} from "./instructions-journal.mjs";
8
12
  import path from "node:path";
9
13
  import { fileURLToPath, pathToFileURL } from "node:url";
10
14
 
11
- import { agentsMdGuidance, BOOT_CARD_UNIT, CompileError, compileGuidance, GENERATED_DIRS, guidanceUnits } from "./compile.mjs";
15
+ import { agentsMdGuidance, BOOT_CARD_UNIT, CompileError, compileGuidance, GENERATED_DIRS, guidanceUnits, ownedNestedFiles } from "./compile.mjs";
12
16
  import { changesReadme, CHANGES_README, handoffIndexIgnore, withIgnoreLines } from "./form.mjs";
13
17
  import { inspect } from "./doctor.mjs";
14
18
  import { AUTO, namedWithAuto } from "./discover.mjs";
@@ -536,7 +540,9 @@ export function agentsMd(manifest, host, kernel = null, guidance = null) {
536
540
  "- **A resolved pointer.** Nothing here fetches anything.",
537
541
  ...(cardText?.includes("<plugin root>")
538
542
  ? ["- **The Portulan package.** The card's `<plugin root>` is where it is installed, which this copy is not."]
539
- : []),
543
+ : cardText?.includes("portulan finish")
544
+ ? ["- **The Portulan package.** The card's `portulan finish` runs from it, and this copy does not carry it."]
545
+ : []),
540
546
  "",
541
547
  "Run `portulan doctor .portulan` to see where this stands.",
542
548
  "",
@@ -692,9 +698,10 @@ export async function run(argv, options = {}) {
692
698
  if (sourceStat.isSymbolicLink()) throw new VendorError(`\`${parsed.source}\` is a symlink, and this refuses to read a workspace through one — a manifest reached that way describes some other directory`);
693
699
  if (!sourceStat.isDirectory()) throw new VendorError(`\`${parsed.source}\` is not a directory — the source is a workspace directory, the same shape \`doctor\` takes`);
694
700
 
695
- let manifest;
701
+ let manifest,manifestBytes;
696
702
  try {
697
- manifest = JSON.parse(fs.readFileSync(path.join(source, "workspace.json"), "utf8"));
703
+ manifestBytes=fs.readFileSync(path.join(source,"workspace.json"));
704
+ manifest = JSON.parse(manifestBytes);
698
705
  } catch (cause) {
699
706
  throw new VendorError(
700
707
  cause.code === "ENOENT"
@@ -714,6 +721,8 @@ export async function run(argv, options = {}) {
714
721
  }
715
722
 
716
723
  const sourceResidence = residenceOf(manifest);
724
+ const checkSourceRecovery=()=>{try{assertNoSourceJournal(source);if(typeof manifest.tree==="string")assertNoSourceJournal(path.resolve(source,manifest.tree));}catch(error){throw new VendorError(error.message);}};
725
+ checkSourceRecovery();
717
726
  const dest = path.resolve(parsed.into);
718
727
 
719
728
  if (parsed.residence === "in-repo" && path.basename(dest) !== ".portulan") {
@@ -736,13 +745,11 @@ export async function run(argv, options = {}) {
736
745
  }
737
746
 
738
747
  let guidance = null;
739
- if (parsed.host !== null) {
740
- try {
741
- guidance = guidanceUnits(source, ".");
742
- } catch (error) {
743
- if (error instanceof CompileError) throw new VendorError(error.message);
744
- throw error;
745
- }
748
+ try {
749
+ guidance = guidanceUnits(source, ".");
750
+ } catch (error) {
751
+ if (error instanceof CompileError) throw new VendorError(error.message);
752
+ throw error;
746
753
  }
747
754
 
748
755
  // ---- the scope bound, refused in both its shapes
@@ -779,10 +786,28 @@ export async function run(argv, options = {}) {
779
786
  // ---- the destination
780
787
 
781
788
  const carve = parsed.switching ? carveOut(dest, manifest.name) : { allow: new Set(), pointer: null };
789
+ const preserved=new Map(),priorStamps=new Map();
790
+ const priorRoot=fs.existsSync(dest)?{real:fs.realpathSync(dest),stat:fs.statSync(dest)}:null;
791
+ for(const rel of carve.allow){const stamp=fileStamp(dest,rel,priorRoot.real);if(!stamp)throw new VendorError(`${rel} changed during destination snapshot, or is a symlink or nonregular file`);const bytes=readStamped(dest,rel,stamp);priorStamps.set(rel,stamp);preserved.set(rel,bytes);}
792
+ if(carve.pointer&&JSON.stringify(JSON.parse(preserved.get("workspace.json").toString("utf8")))!==JSON.stringify(carve.pointer))throw new VendorError("the destination pointer changed while its replacement was being authorized");
782
793
  const walked = walk(source);
783
- const sourceDirs = directories(source);
784
- const files = walked.filter((f) => !isGenerated(f.rel));
785
- const generated = walked.filter((f) => isGenerated(f.rel)).map((f) => f.rel);
794
+ const sourceDirs = directories(source).map(entry=>{const at=fs.lstatSync(path.join(source,entry.rel));return {...entry,identity:{dev:at.dev,ino:at.ino}};});
795
+ const journals=[...walked,...sourceDirs].filter(file=>file.rel.split("/").some(part=>part.toLowerCase()===SOURCE_JOURNAL));
796
+ if(journals.length)throw new VendorError(`reserved source journal was inventoried: ${journals.map(file=>file.rel).join(", ")}; resume or review the source migration before vendoring`);
797
+ checkSourceRecovery();
798
+ const sourceReal=fs.realpathSync(source),sourceDirectory=fs.statSync(source),observed=new Map();
799
+ if(parsed.switching)for(const file of walked) {
800
+ const stamp=fileStamp(source,file.rel,sourceReal);
801
+ if(stamp===null)throw new VendorError(`${file.rel} could not be inventoried safely before switching`);
802
+ observed.set(file.rel,stamp);
803
+ }
804
+ if(parsed.switching && observed.get("workspace.json")?.at(-1)!==createHash("sha256").update(manifestBytes).digest("hex"))throw new VendorError("the source manifest changed while its inventory was being prepared");
805
+ let nestedOwned;
806
+ try { nestedOwned=ownedNestedFiles(source,"."); } catch(error) { if(error instanceof CompileError) throw new VendorError(error.message); throw error; }
807
+ const files = walked.filter((f) => !isGenerated(f.rel) && !nestedOwned.has(f.rel));
808
+ const unfinished=files.filter(f=>/\.portulan-(?:guidance(?:-claim)?|upgrade|retire)\./.test(path.basename(f.rel)));
809
+ if(unfinished.length)throw new VendorError(`source recovery artifacts were retained: ${unfinished.map(f=>f.rel).join(", ")}; finish or review the source migration before vendoring`);
810
+ const generated = walked.filter((f) => isGenerated(f.rel) || nestedOwned.has(f.rel)).map((f) => f.rel);
786
811
  const rels = files.map((f) => f.rel);
787
812
  const hostFile = parsed.host === null ? null : path.join(path.dirname(dest), "AGENTS.md");
788
813
 
@@ -816,7 +841,8 @@ export async function run(argv, options = {}) {
816
841
 
817
842
  const retargeted = retarget(manifest, parsed.residence, parsed.kindOf);
818
843
  const oldResidence = source;
819
- const repoDir = parsed.residence === "in-repo" ? path.dirname(dest) : path.dirname(source);
844
+ const oldTree=typeof manifest.tree==="string" && manifest.tree.trim() ? path.resolve(source,manifest.tree) : source;
845
+ const repoDir = parsed.residence === "in-repo" ? path.dirname(dest) : oldTree;
820
846
 
821
847
  if (parsed.dryRun) {
822
848
  say(`vendor: ${parsed.switching ? "switch" : "vendor"} \`${manifest.name}\` — ${sourceResidence} → ${parsed.residence}`);
@@ -852,26 +878,34 @@ export async function run(argv, options = {}) {
852
878
  }
853
879
  }
854
880
  fs.mkdirSync(staging, { recursive: true });
855
- undo.push(() => fs.rmSync(staging, { recursive: true, force: true }));
881
+ const stagingRoot={real:fs.realpathSync(staging),stat:fs.statSync(staging)},stagedOwned=new Map(),stagedDirs=new Map();
882
+ undo.push(()=>cleanupPublications(staging,stagedOwned,new Map(),stagingRoot,true,stagedDirs));
883
+ const stageWrite=(rel,bytes,mode=null)=>{
884
+ const full=path.join(staging,rel);makeOwnedDirectories(staging,path.dirname(rel),stagedDirs);const fd=fs.openSync(full,"wx"),wanted=Buffer.from(bytes),at=fs.fstatSync(fd);
885
+ stagedOwned.set(rel,[at.dev,at.ino,at.mode,createHash("sha256").update(Buffer.alloc(0)).digest("hex")]);
886
+ try{fs.writeFileSync(fd,bytes);if(mode!==null)fs.fchmodSync(fd,mode);const done=fs.fstatSync(fd);stagedOwned.set(rel,[done.dev,done.ino,done.mode,createHash("sha256").update(wanted).digest("hex")]);}
887
+ catch(error){const partial=fs.fstatSync(fd);stagedOwned.set(rel,[partial.dev,partial.ino,partial.mode,createHash("sha256").update(wanted.subarray(0,partial.size)).digest("hex")]);throw error;}
888
+ finally{fs.closeSync(fd);}
889
+ };
856
890
 
857
891
  for (const entry of sourceDirs) {
858
892
  if (isGenerated(entry.rel)) continue;
859
893
  const full = path.join(staging, entry.rel);
860
- fs.mkdirSync(full, { recursive: true });
861
- fs.chmodSync(full, entry.mode);
894
+ makeOwnedDirectories(staging,entry.rel,stagedDirs);
895
+ fs.chmodSync(full, entry.mode|0o700);
862
896
  }
863
897
  // Copied byte for byte: `# portulan:bundle-fallback` lines are not rewritten here; `portulan upgrade` re-derives them.
864
898
  for (const file of files) {
865
899
  if (file.rel === "workspace.json") continue;
866
900
  const full = path.join(staging, file.rel);
867
- fs.mkdirSync(path.dirname(full), { recursive: true });
868
- fs.writeFileSync(full, fs.readFileSync(path.join(source, file.rel)));
869
- fs.chmodSync(full, file.mode);
901
+ stageWrite(file.rel,fs.readFileSync(path.join(source,file.rel)),file.mode);
870
902
  }
871
903
  if (parsed.switching && carve.pointer && !rels.includes("README.md")) {
872
- fs.writeFileSync(path.join(staging, "README.md"), arrivedReadme(manifest.name));
904
+ stageWrite("README.md",arrivedReadme(manifest.name));
873
905
  }
874
- fs.writeFileSync(path.join(staging, "workspace.json"), `${JSON.stringify(retargeted, null, 2)}\n`);
906
+ stageWrite("workspace.json",`${JSON.stringify(retargeted,null,2)}\n`);
907
+ const inputTimes=new Map([...stagedOwned.keys()].map(rel=>[rel,fs.lstatSync(path.join(staging,rel),{bigint:true}).ctimeNs]));
908
+ try{guidance=guidanceUnits(staging,".");}catch(error){if(error instanceof CompileError)throw new VendorError(error.message);throw error;}
875
909
  if (hostFile !== null) {
876
910
  // Not required: a missing kernel is said in the file rather than refusing the run.
877
911
  let kernel = null;
@@ -880,9 +914,13 @@ export async function run(argv, options = {}) {
880
914
  } catch {
881
915
  kernel = null;
882
916
  }
883
- fs.writeFileSync(path.join(staging, "..AGENTS.md.vendoring"), agentsMd(retargeted, parsed.host, kernel, guidance));
917
+ stageWrite("..AGENTS.md.vendoring",agentsMd(retargeted,parsed.host,kernel,guidance));
884
918
  }
919
+ for(const entry of [...sourceDirs].reverse())if(!isGenerated(entry.rel))fs.chmodSync(path.join(staging,entry.rel),entry.mode);
885
920
  fault("materialise:files");
921
+ const stagedNow=walk(staging);
922
+ if(stagedNow.length!==stagedOwned.size||stagedNow.some(file=>!sameStamp(fileStamp(staging,file.rel,stagingRoot.real,stagedOwned.get(file.rel)),stagedOwned.get(file.rel))))throw new VendorError("staged material changed before publication; retained files need review");
923
+ if([...inputTimes].some(([rel,time])=>fs.lstatSync(path.join(staging,rel),{bigint:true}).ctimeNs!==time))throw new VendorError("staged source changed while guidance was being validated");
886
924
 
887
925
  // ---- doctor the new residence, BEFORE it is one
888
926
 
@@ -898,9 +936,12 @@ export async function run(argv, options = {}) {
898
936
  say(`vendor: RED at the new residence — nothing was moved, and \`${manifest.name}\` still resides ${sourceResidence === "in-repo" ? "in its repository" : "feed-side"}.`);
899
937
  for (const f of staged) say(`vendor: ${f.check} ${f.message}`);
900
938
  say("vendor: the switch was refused before it began, which is the point of validating a copy before it is a residence.");
901
- await unwind(undo);
939
+ const incomplete=await unwind(undo);if(incomplete.length)warn(`vendor: rollback incomplete; retained ${incomplete.join(", ")}`);
902
940
  return 1;
903
941
  }
942
+ if([...inputTimes].some(([rel,time])=>fs.lstatSync(path.join(staging,rel),{bigint:true}).ctimeNs!==time))throw new VendorError("staged source changed after guidance validation");
943
+
944
+ checkSourceRecovery();
904
945
 
905
946
  // ---- the one rename, and the sentence that precedes it
906
947
 
@@ -915,48 +956,42 @@ export async function run(argv, options = {}) {
915
956
  say("vendor: wrote — and the window closes. If it reports none, the window never opened: delete nothing.");
916
957
  }
917
958
 
918
- const preserved = new Map();
919
- for (const rel of carve.allow) preserved.set(rel, fs.readFileSync(path.join(dest, rel)));
920
-
921
- // Registered before the first byte moves; it reads `written` by reference, so it undoes exactly what happened.
922
- const written = [];
923
- const destWasAbsent = carve.allow.size === 0 && !fs.existsSync(dest);
924
- undo.push(() => {
925
- // The manifest first: removing it closes the window, so cleanup that throws after it leaves no governor.
926
- fs.rmSync(path.join(dest, "workspace.json"), { force: true });
927
- if (hostFile !== null) fs.rmSync(hostFile, { force: true });
928
- if (destWasAbsent) {
929
- fs.rmSync(dest, { recursive: true, force: true });
930
- } else {
931
- for (const rel of written) if (!preserved.has(rel)) fs.rmSync(path.join(dest, rel), { force: true });
932
- for (const [rel, bytes] of preserved) fs.writeFileSync(path.join(dest, rel), bytes);
933
- // Emptied directories too: one left behind makes the retry's `carveOut` refuse the destination.
934
- pruneEmpty(dest);
935
- }
959
+ if(priorRoot && (fs.statSync(dest).dev!==priorRoot.stat.dev||fs.statSync(dest).ino!==priorRoot.stat.ino||[...priorStamps].some(([rel,stamp])=>!sameStamp(fileStamp(dest,rel,priorRoot.real,stamp),stamp))))throw new VendorError("the destination pointer changed during staging");
960
+ const destWasAbsent=priorRoot===null,destOwned=new Map(),hostOwned=new Map(),hostPrevious=new Map(),destDirs=new Map(),hostDirs=new Map();
961
+ const hostRoot=hostFile===null?null:path.dirname(hostFile),hostIdentity=hostRoot===null?null:{real:fs.realpathSync(hostRoot),stat:fs.statSync(hostRoot)};
962
+ let destRoot=priorRoot;
963
+ const oldBytes=new Map([...preserved].map(([rel,bytes])=>[rel,{bytes,mode:priorStamps.get(rel)[2]}]));
964
+ undo.push(()=>{
965
+ const failures=[];
966
+ if(hostFile!==null && hostOwned.size)failures.push(...cleanupPublications(hostRoot,hostOwned,hostPrevious,hostIdentity,false,hostDirs));
967
+ if(destRoot)failures.push(...cleanupPublications(dest,destOwned,oldBytes,destRoot,destWasAbsent,destDirs));
968
+ return failures;
936
969
  });
937
-
938
- if (destWasAbsent) {
939
- fs.mkdirSync(path.dirname(dest), { recursive: true });
940
- if (hostFile !== null) fs.renameSync(path.join(staging, "..AGENTS.md.vendoring"), hostFile);
941
- fs.renameSync(staging, dest);
970
+ const publishHost=()=>{
971
+ if(hostFile===null)return;
972
+ const stamp=stagedOwned.get("..AGENTS.md.vendoring"),bytes=readStamped(staging,"..AGENTS.md.vendoring",stamp);
973
+ atomicWrite(hostFile,bytes,{exclusive:true,mode:stamp[2],onPublish:id=>hostOwned.set(path.basename(hostFile),[id.dev,id.ino,stamp[2],stamp[3]])});
974
+ guardedRemove(path.join(staging,"..AGENTS.md.vendoring"),{beforeDigest:stamp[3],identity:{dev:stamp[0],ino:stamp[1],mode:stamp[2]}});
975
+ stagedOwned.delete("..AGENTS.md.vendoring");
976
+ };
977
+ if(destWasAbsent) {
978
+ fs.mkdirSync(path.dirname(dest),{recursive:true});publishHost();
979
+ destRoot={real:path.join(fs.realpathSync(path.dirname(dest)),path.basename(dest)),stat:stagingRoot.stat};
980
+ for(const [rel,stamp]of stagedOwned)destOwned.set(rel,stamp);
981
+ for(const [rel,stamp]of stagedDirs)destDirs.set(rel,stamp);
982
+ fs.renameSync(staging,dest);
942
983
  } else {
943
- // Files first, manifest last: until the manifest lands, a directory of files is not a residence.
944
- for (const entry of directories(staging)) {
945
- fs.mkdirSync(path.join(dest, entry.rel), { recursive: true });
946
- fs.chmodSync(path.join(dest, entry.rel), entry.mode);
947
- }
948
- for (const file of walk(staging)) {
949
- if (file.rel === "workspace.json" || file.rel === "..AGENTS.md.vendoring") continue;
950
- const full = path.join(dest, file.rel);
951
- fs.mkdirSync(path.dirname(full), { recursive: true });
952
- fs.writeFileSync(full, fs.readFileSync(path.join(staging, file.rel)));
953
- fs.chmodSync(full, file.mode);
954
- written.push(file.rel);
955
- }
956
- if (hostFile !== null) fs.renameSync(path.join(staging, "..AGENTS.md.vendoring"), hostFile);
957
- fs.renameSync(path.join(staging, "workspace.json"), path.join(dest, "workspace.json"));
984
+ for(const entry of directories(staging)){makeOwnedDirectories(dest,entry.rel,destDirs);if(destDirs.has(entry.rel))fs.chmodSync(path.join(dest,entry.rel),entry.mode|0o700);}
985
+ const publish=rel=>{
986
+ const stamp=stagedOwned.get(rel),prior=priorStamps.get(rel),bytes=readStamped(staging,rel,stamp);
987
+ atomicWrite(path.join(dest,rel),bytes,{exclusive:!prior,beforeDigest:prior?.[3],identity:prior?{dev:prior[0],ino:prior[1],mode:prior[2]}:undefined,mode:stamp[2],onPublish:id=>destOwned.set(rel,[id.dev,id.ino,stamp[2],stamp[3]])});
988
+ };
989
+ for(const rel of stagedOwned.keys())if(rel!=="workspace.json"&&rel!=="..AGENTS.md.vendoring")publish(rel);
990
+ publishHost();publish("workspace.json");
991
+ for(const entry of directories(staging).reverse())if(destDirs.has(entry.rel))fs.chmodSync(path.join(dest,entry.rel),entry.mode);
992
+ const retained=cleanupPublications(staging,stagedOwned,new Map(),stagingRoot,true,stagedDirs);
993
+ if(retained.length)throw new VendorError(`staging cleanup incomplete: ${retained.join(", ")}`);
958
994
  }
959
- fs.rmSync(staging, { recursive: true, force: true });
960
995
  fault("materialise:manifest");
961
996
 
962
997
  const records = [];
@@ -968,12 +1003,9 @@ export async function run(argv, options = {}) {
968
1003
  if (readmeBlocked.length) {
969
1004
  recordsLeft.push(`\`${CHANGES_README}\` ${readmeBlocked[0].why === "already exists" ? "is the tree's own" : readmeBlocked[0].why}`);
970
1005
  } else {
971
- const made = fs.mkdirSync(path.dirname(readme), { recursive: true });
972
- undo.push(() => {
973
- fs.rmSync(readme, { force: true });
974
- if (made !== undefined) fs.rmSync(made, { recursive: true, force: true });
975
- });
976
- fs.writeFileSync(readme, changesReadme(), { flag: "wx" });
1006
+ makeOwnedDirectories(hostRoot,path.dirname(CHANGES_README),hostDirs);
1007
+ const bytes=changesReadme();
1008
+ atomicWrite(readme,bytes,{exclusive:true,onPublish:id=>hostOwned.set(CHANGES_README,[id.dev,id.ino,id.mode,createHash("sha256").update(bytes).digest("hex")])});
977
1009
  records.push(CHANGES_README);
978
1010
  }
979
1011
  const index = retargeted.handoffs?.index?.path;
@@ -982,21 +1014,20 @@ export async function run(argv, options = {}) {
982
1014
  if (typeof index === "string" && blocked.length) recordsLeft.push(`\`.gitignore\` ${blocked[0].why}`);
983
1015
  if (typeof index === "string" && blocked.length === 0) {
984
1016
  const rel = path.relative(hostRoot, path.resolve(dest, index)).split(path.sep).join("/");
985
- let before = null;
986
- try {
987
- before = fs.readFileSync(ignore, "utf8");
988
- } catch (cause) {
989
- if (cause.code !== "ENOENT") throw cause;
990
- }
1017
+ const prior=fileStamp(hostRoot,".gitignore",hostIdentity.real);
1018
+ let before=null;
1019
+ try {fs.lstatSync(ignore);if(!prior)throw new VendorError(".gitignore changed or is not an ordinary file");before=readStamped(hostRoot,".gitignore",prior);}
1020
+ catch(cause){if(cause.code!=="ENOENT")throw cause;}
991
1021
  const next = withIgnoreLines(before, handoffIndexIgnore(rel, path.relative(hostRoot, dest).split(path.sep).join("/")));
992
1022
  if (next !== (before ?? "")) {
993
- undo.push(() => (before === null ? fs.rmSync(ignore, { force: true }) : fs.writeFileSync(ignore, before)));
994
- fs.writeFileSync(ignore, next);
1023
+ if(prior)hostPrevious.set(".gitignore",{bytes:before,mode:prior[2]});
1024
+ atomicWrite(ignore,next,{exclusive:!prior,beforeDigest:prior?.[3],identity:prior?{dev:prior[0],ino:prior[1],mode:prior[2]}:undefined,mode:prior?.[2],onPublish:id=>hostOwned.set(".gitignore",[id.dev,id.ino,id.mode,createHash("sha256").update(next).digest("hex")])});
995
1025
  records.push(".gitignore");
996
1026
  }
997
1027
  }
998
1028
  }
999
1029
 
1030
+ checkSourceRecovery();
1000
1031
  if (!parsed.switching) {
1001
1032
  say(`vendor: wrote ${files.length + (hostFile ? 1 : 0)} file(s) — the \`${manifest.name}\` workspace at ${display(dest)}${hostFile ? `, and ${display(hostFile)}` : ""}.`);
1002
1033
  if (records.length) say(`vendor: and the records the new form keeps, beside it: ${records.map((r) => `\`${r}\``).join(", ")}.`);
@@ -1009,15 +1040,16 @@ export async function run(argv, options = {}) {
1009
1040
  // ---- retire the old residence's MANIFEST, which is the act that transfers governance
1010
1041
 
1011
1042
  // Before the material: retiring the files first would leave a governing manifest over a workspace with holes.
1043
+ checkSourceRecovery();
1012
1044
  const oldManifest = path.join(oldResidence, "workspace.json");
1045
+ if(!sameStamp(fileStamp(source,"workspace.json",sourceReal,observed.get("workspace.json")),observed.get("workspace.json")))throw new VendorError("the source manifest changed during staging; the switch is refused");
1013
1046
  if (leave === "pointer") {
1014
1047
  const staged2 = `${oldManifest}.vendoring`;
1015
- // Registered before the write: a temp file left in the old residence would block a later run's cleanup.
1016
- undo.push(() => fs.rmSync(staged2, { force: true }));
1017
- fs.writeFileSync(staged2, `${JSON.stringify(pointerManifest(oldResidence, manifest.name, parsed.feed), null, 2)}\n`);
1018
- fs.renameSync(staged2, oldManifest);
1048
+ const stamp=observed.get("workspace.json");
1049
+ atomicWrite(oldManifest,`${JSON.stringify(pointerManifest(oldResidence,manifest.name,parsed.feed),null,2)}\n`,{stagingPath:staged2,beforeDigest:stamp[3],identity:{dev:stamp[0],ino:stamp[1],mode:stamp[2]},mode:stamp[2],onPublish:()=>{pastTheFlip=true;}});
1019
1050
  } else {
1020
- fs.rmSync(oldManifest, { force: true });
1051
+ const stamp=observed.get("workspace.json");
1052
+ guardedRemove(oldManifest,{beforeDigest:stamp[3],identity:{dev:stamp[0],ino:stamp[1],mode:stamp[2]},onRemove:()=>{pastTheFlip=true;}});
1021
1053
  }
1022
1054
  pastTheFlip = true;
1023
1055
  undo.length = 0;
@@ -1037,18 +1069,23 @@ export async function run(argv, options = {}) {
1037
1069
  say(`vendor: governance has moved to ${display(dest)}, and \`doctor\` is RED at ${newEnd.length ? "the new" : "the old"} end.`);
1038
1070
  for (const f of [...newEnd, ...oldEnd]) say(`vendor: ${f.check} ${f.message}`);
1039
1071
  say(`vendor: the old residence's files were NOT removed — green at both ends comes before retirement, and it was not green.`);
1040
- say(`vendor: exactly one workspace governs ${path.basename(repoDir)}. Fix what is named above, then re-run \`doctor\` at both ends.`);
1072
+ say(`vendor: the transfer completed, but validation failed. Preserve both ends and re-run \`doctor\` after fixing what is named above.`);
1041
1073
  return 1;
1042
1074
  }
1043
1075
 
1044
1076
  // ---- retire the old residence's material — only what this run can account for
1045
1077
 
1046
- // Compile's output is deleted though never copied: it is reproducible, and left here it would enforce beside a pointer.
1078
+ // Retire only unchanged files from the initial inventory, including compiled outputs.
1047
1079
  const moved = new Set([...rels, ...generated]);
1048
1080
  const leftovers = [];
1081
+ const retiredGenerated=[],generatedSet=new Set(generated);
1082
+ const retain=rel=>{if(!leftovers.includes(rel))leftovers.push(rel);};
1083
+ let keptResidence=false,retiredCount=0;
1049
1084
  let remaining = [];
1050
1085
  let unscannable = null;
1086
+ const sameResidence=()=>{try{const at=fs.statSync(source);return fs.realpathSync(source)===sourceReal && at.dev===sourceDirectory.dev && at.ino===sourceDirectory.ino;}catch{return false;}};
1051
1087
  try {
1088
+ if(!sameResidence())throw new VendorError("the old residence changed identity during switching");
1052
1089
  remaining = walk(oldResidence);
1053
1090
  } catch (cause) {
1054
1091
  // A failed scan is not an empty residence: under `--leave nothing` it would delete files this run cannot account for.
@@ -1056,18 +1093,31 @@ export async function run(argv, options = {}) {
1056
1093
  }
1057
1094
  for (const file of remaining) {
1058
1095
  if (file.rel === "workspace.json") continue;
1059
- if (leave === "pointer" && file.rel === "README.md" && !moved.has("README.md")) continue;
1060
1096
  if (!moved.has(file.rel)) {
1061
- leftovers.push(file.rel);
1097
+ retain(file.rel);
1062
1098
  continue;
1063
1099
  }
1064
- fs.rmSync(path.join(oldResidence, file.rel), { force: true });
1100
+ if(!retireObserved(oldResidence,file.rel,observed.get(file.rel),sourceReal)) {retain(file.rel);continue;}
1101
+ retiredCount++;if(generatedSet.has(file.rel))retiredGenerated.push(file.rel);
1065
1102
  }
1066
- if (unscannable === null) pruneEmpty(oldResidence);
1103
+ if(unscannable===null) {
1104
+ try {for(const file of walk(oldResidence))if(file.rel!=="workspace.json")retain(file.rel);}
1105
+ catch(cause){unscannable=cause;}
1106
+ }
1107
+ if(unscannable===null && !sameResidence())unscannable=new VendorError("the old residence changed identity during retirement");
1108
+ if (unscannable === null) pruneObservedDirectories(oldResidence,sourceDirs);
1067
1109
  if (leave === "pointer") {
1068
- fs.writeFileSync(path.join(oldResidence, "README.md"), pointerReadme(manifest.name, parsed.feed, display(dest)));
1110
+ try {fs.writeFileSync(path.join(oldResidence,"README.md"),pointerReadme(manifest.name,parsed.feed,display(dest)),{flag:"wx"});}
1111
+ catch(cause){if(cause.code!=="EEXIST")throw cause;retain("README.md");}
1069
1112
  } else if (leftovers.length === 0 && unscannable === null) {
1070
- fs.rmSync(oldResidence, { recursive: true, force: true });
1113
+ try {fs.rmdirSync(oldResidence);}
1114
+ catch(cause) {
1115
+ if(cause.code!=="ENOENT") {
1116
+ keptResidence=true;
1117
+ try {for(const file of walk(oldResidence))retain(file.rel);}
1118
+ catch(error){unscannable=error;}
1119
+ }
1120
+ }
1071
1121
  }
1072
1122
  fault("retire:material");
1073
1123
 
@@ -1075,12 +1125,12 @@ export async function run(argv, options = {}) {
1075
1125
  say(`vendor: resides at ${display(dest)}, green.`);
1076
1126
  if (unscannable !== null) {
1077
1127
  say(`vendor: the old residence at ${display(oldResidence)} could NOT be scanned — ${unscannable.message}`);
1078
- say("vendor: so nothing there was removed. It no longer governs (its manifest is gone or is a pointer),");
1128
+ say(retiredCount===0 ? "vendor: so nothing there was removed. Its original manifest is retired or is a pointer," : "vendor: cleanup stopped after some accounted-for files were retired; retained content needs review,");
1079
1129
  say("vendor: and nothing here deletes files it could not account for. Clear it by hand when you can read it.");
1080
1130
  } else if (leave === "pointer") {
1081
1131
  say(`vendor: a pointer at ${display(oldResidence)}, green.`);
1082
- } else if (leftovers.length) {
1083
- say(`vendor: no workspace at ${display(oldResidence)} — its manifest is retired and it governs nothing.`);
1132
+ } else if (leftovers.length || keptResidence) {
1133
+ say(`vendor: the original manifest at ${display(oldResidence)} is retired; remaining content was preserved for review.`);
1084
1134
  } else {
1085
1135
  say(`vendor: nothing left at ${display(oldResidence)}.`);
1086
1136
  }
@@ -1088,12 +1138,12 @@ export async function run(argv, options = {}) {
1088
1138
  say(`vendor: ${leftovers.length} file(s) at the old residence were not moved by this run and were left alone: ${leftovers.join(", ")}.`);
1089
1139
  say("vendor: nothing here deletes a file it cannot account for — they are yours to keep or remove.");
1090
1140
  }
1091
- if (generated.length) {
1092
- say(`vendor: ${generated.length} compiled artifact(s) did not travel and were retired with the old residence: ${generated.join(", ")}.`);
1141
+ if (retiredGenerated.length) {
1142
+ say(`vendor: ${retiredGenerated.length} compiled artifact(s) did not travel and were retired with the old residence: ${retiredGenerated.join(", ")}.`);
1093
1143
  say("vendor: enforcement is keyed to the residence — where a settings file lands differs between the two — so a copy would name paths for the residence it left.");
1094
1144
  }
1095
- if (sourceResidence === "in-repo") {
1096
- say(`vendor: \`${display(path.join(path.dirname(source), ".claude", "settings.json"))}\` and \`${display(path.join(path.dirname(source), ".claude", "rules", "portulan"))}\` — where they exist — were compiled from the workspace that has just moved, and nothing here writes outside ${display(source)}. They are yours to remove.`);
1145
+ if (sourceResidence === "in-repo" && !isInside(source,oldTree)) {
1146
+ say(`vendor: \`${display(path.join(oldTree,".claude","settings.json"))}\` and \`${display(path.join(oldTree,".claude","rules","portulan"))}\` — where they exist — lie outside the retired workspace. This switch left those paths and their recorded nested targets for you to review.`);
1097
1147
  }
1098
1148
  if (!parsed.repoRoots.length && cards.length) {
1099
1149
  say(`vendor: no --repo-root was given, so the cross-repository half of "green at both ends" reported rather than checked.`);
@@ -1117,17 +1167,72 @@ export async function run(argv, options = {}) {
1117
1167
  }
1118
1168
  return 0;
1119
1169
  } catch (error) {
1120
- if (!pastTheFlip) await unwind(undo);
1170
+ if (!pastTheFlip) {const incomplete=await unwind(undo);if(incomplete.length)warn(`vendor: rollback incomplete; retained ${incomplete.join(", ")}`);}
1121
1171
  const message = error instanceof VendorError ? error.message : `could not run — ${error.code === "EIO" ? error.message : (error.stack ?? error)}`;
1122
1172
  warn(`vendor: ${message}`);
1123
1173
  if (pastTheFlip) {
1124
1174
  warn("vendor: governance had already moved when this failed, so nothing was rolled back — undoing it would re-open the window in the other direction.");
1125
- warn("vendor: exactly one workspace governs. Run `doctor` at both ends to see where it stands.");
1175
+ warn("vendor: run `doctor` at both ends to check the current governance and retained files.");
1126
1176
  }
1127
1177
  return 2;
1128
1178
  }
1129
1179
  }
1130
1180
 
1181
+ function makeOwnedDirectories(root,rel,created) {
1182
+ if(rel==="."||rel==="")return;
1183
+ let current="";
1184
+ for(const part of rel.split(path.sep)){current=path.join(current,part);const full=path.join(root,current);try{fs.mkdirSync(full);const at=fs.lstatSync(full);created.set(current,{dev:at.dev,ino:at.ino});}catch(error){if(error.code!=="EEXIST"||!fs.lstatSync(full).isDirectory()||fs.lstatSync(full).isSymbolicLink())throw error;}}
1185
+ }
1186
+ function cleanupPublications(root,published,previous,expectedRoot,removeRoot,created=new Map()) {
1187
+ if(!fs.existsSync(root))return [];
1188
+ if(expectedRoot){const at=fs.statSync(root);if(at.dev!==expectedRoot.stat.dev||at.ino!==expectedRoot.stat.ino||fs.realpathSync(root)!==expectedRoot.real)return [root];}
1189
+ const real=fs.realpathSync(root),failed=[],directoryModes=[];
1190
+ for(const [rel,id]of created){const full=path.join(root,rel);try{const at=fs.lstatSync(full);if(at.isDirectory()&&!at.isSymbolicLink()&&at.dev===id.dev&&at.ino===id.ino){directoryModes.push({full,id,mode:at.mode});if((at.mode&0o700)!==0o700)fs.chmodSync(full,at.mode|0o700);}}catch(error){if(error.code!=="ENOENT")failed.push(full);}}
1191
+ const order=[...published.keys()].sort((a,b)=>a==="workspace.json"?-1:b==="workspace.json"?1:0);
1192
+ for(const rel of order) {
1193
+ const stamp=published.get(rel),full=path.join(root,rel),prior=previous.get(rel);
1194
+ try {
1195
+ if(!sameStamp(fileStamp(root,rel,real,stamp),stamp)){failed.push(full);continue;}
1196
+ const options={beforeDigest:stamp[3],identity:{dev:stamp[0],ino:stamp[1],mode:stamp[2]}};
1197
+ if(prior)atomicWrite(full,prior.bytes,{...options,mode:prior.mode});else guardedRemove(full,options);
1198
+ }catch{failed.push(full);}
1199
+ }
1200
+ for(const [rel,id]of [...created].reverse()){const full=path.join(root,rel);try{const at=fs.lstatSync(full);if(!at.isDirectory()||at.isSymbolicLink()||at.dev!==id.dev||at.ino!==id.ino){failed.push(full);continue;}fs.rmdirSync(full);}catch(error){if(error.code!=="ENOENT")failed.push(full);}}
1201
+ for(const {full,id,mode}of directoryModes)try{const at=fs.lstatSync(full);if(at.isDirectory()&&!at.isSymbolicLink()&&at.dev===id.dev&&at.ino===id.ino)fs.chmodSync(full,mode);}catch(error){if(error.code!=="ENOENT")failed.push(full);}
1202
+ if(removeRoot){try{fs.rmdirSync(root);}catch(error){if(error.code!=="ENOENT")failed.push(root);}}
1203
+ return failed;
1204
+ }
1205
+
1206
+ function fileStamp(root,rel,realRoot,expected=null) {
1207
+ try {
1208
+ const file=path.join(root,rel),at=fs.lstatSync(file);
1209
+ if(!at.isFile() || (expected && (at.dev!==expected[0] || at.ino!==expected[1] || at.mode!==expected[2])) || fs.realpathSync(file)!==path.join(realRoot,rel))return null;
1210
+ return [at.dev,at.ino,at.mode,createHash("sha256").update(fs.readFileSync(file)).digest("hex")];
1211
+ } catch {return null;}
1212
+ }
1213
+ function readStamped(root,rel,stamp) {
1214
+ const full=path.join(root,rel),fd=fs.openSync(full,fs.constants.O_RDONLY|(fs.constants.O_NOFOLLOW??0)|fs.constants.O_NONBLOCK);
1215
+ try {const at=fs.fstatSync(fd),bytes=fs.readFileSync(fd);if(!at.isFile()||!sameStamp([at.dev,at.ino,at.mode,createHash("sha256").update(bytes).digest("hex")],stamp))throw new VendorError(`${rel} changed before publication`);return bytes;}
1216
+ finally{fs.closeSync(fd);}
1217
+ }
1218
+ const sameStamp=(left,right)=>Boolean(left && right && left.every((value,index)=>value===right[index]));
1219
+ function retireObserved(root,rel,expected,realRoot) {
1220
+ if(!sameStamp(fileStamp(root,rel,realRoot,expected),expected))return false;
1221
+ const file=path.join(root,rel),claim=path.join(path.dirname(file),`.${path.basename(file)}.portulan-retire.${process.pid}.${randomUUID()}`);
1222
+ let made=false,moved=false,placeholder;
1223
+ try {
1224
+ const fd=fs.openSync(claim,"wx");made=true;const at=fs.fstatSync(fd);placeholder={dev:at.dev,ino:at.ino};fs.closeSync(fd);
1225
+ try{fs.renameSync(file,claim);moved=true;}catch(error){const held=fs.lstatSync(claim,{throwIfNoEntry:false});if(held&&(held.dev!==placeholder.dev||held.ino!==placeholder.ino)){moved=true;try{fs.linkSync(claim,file);fs.unlinkSync(claim);moved=false;made=false;}catch{throw new VendorError(`${rel} had an uncertain retirement; its data is preserved at ${claim}. Review both paths before cleanup`);}}throw error;}
1226
+ if(!sameStamp(fileStamp(root,path.relative(root,claim),realRoot,expected),expected)) {
1227
+ try {fs.linkSync(claim,file);fs.unlinkSync(claim);moved=false;made=false;}
1228
+ catch {throw new VendorError(`${rel} changed during retirement; its data is preserved at ${claim}. Review both paths before cleanup`);}
1229
+ return false;
1230
+ }
1231
+ fs.unlinkSync(claim);moved=false;made=false;return true;
1232
+ } catch(error){if(!moved&&["EACCES","EPERM","EROFS"].includes(error.code))return false;throw error;}
1233
+ finally {if(made&&!moved){const at=fs.lstatSync(claim,{throwIfNoEntry:false});if(at?.isFile()&&at.dev===placeholder.dev&&at.ino===placeholder.ino&&at.size===0)guardedRemove(claim,{before:Buffer.alloc(0),identity:placeholder});else if(at)throw new VendorError(`${rel} retirement staging changed; retained ${claim} for review`);}}
1234
+ }
1235
+
1131
1236
  async function verdict(dir, roots) {
1132
1237
  try {
1133
1238
  const { findings } = await inspect(dir, roots);
@@ -1138,34 +1243,13 @@ async function verdict(dir, roots) {
1138
1243
  }
1139
1244
 
1140
1245
  async function unwind(undo) {
1141
- while (undo.length) {
1142
- const step = undo.pop();
1143
- try {
1144
- step();
1145
- } catch {
1146
- /* best effort: the message about the original failure is worth more than this one */
1147
- }
1148
- }
1246
+ const failed=[];
1247
+ while(undo.length){try{const result=await undo.pop()();if(Array.isArray(result))failed.push(...result);}catch(error){failed.push(error.message);}}
1248
+ return failed;
1149
1249
  }
1150
1250
 
1151
- function pruneEmpty(root) {
1152
- const walkDown = (dir) => {
1153
- let entries;
1154
- try {
1155
- entries = fs.readdirSync(dir, { withFileTypes: true });
1156
- } catch {
1157
- return;
1158
- }
1159
- for (const entry of entries) if (entry.isDirectory()) walkDown(path.join(dir, entry.name));
1160
- if (dir !== root) {
1161
- try {
1162
- if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
1163
- } catch {
1164
- /* a directory that will not go is one somebody still has something in */
1165
- }
1166
- }
1167
- };
1168
- walkDown(path.resolve(root));
1251
+ function pruneObservedDirectories(root,observed) {
1252
+ for(const entry of [...observed].reverse())try{const full=path.join(root,entry.rel),at=fs.lstatSync(full);if(at.isDirectory()&&!at.isSymbolicLink()&&at.dev===entry.identity.dev&&at.ino===entry.identity.ino)fs.rmdirSync(full);}catch{}
1169
1253
  }
1170
1254
 
1171
1255
  // `?? ""`: `process.argv[1]` is absent when a non-script imports this, and `pathToFileURL(undefined)` throws.