@sleepy_panda_srl/portulan 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +15 -4
  2. package/cli/README.md +61 -6
  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 +781 -130
  7. package/cli/context.mjs +204 -43
  8. package/cli/doctor.mjs +248 -31
  9. package/cli/drills.mjs +56 -9
  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 +230 -40
  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 +1 -1
  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/review-meter.mjs +14 -9
  34. package/cli/ritual-contexts.mjs +103 -0
  35. package/cli/sessions.mjs +2 -1
  36. package/cli/spend-analysis.mjs +111 -0
  37. package/cli/spend-offer.md +53 -0
  38. package/cli/spend-offer.mjs +93 -0
  39. package/cli/stop-diagnostics.mjs +114 -0
  40. package/cli/stop-gate.mjs +133 -90
  41. package/cli/stop-policy.mjs +14 -0
  42. package/cli/symbols.mjs +55 -8
  43. package/cli/telemetry.mjs +66 -17
  44. package/cli/upgrade.mjs +62 -44
  45. package/cli/vendor.mjs +207 -125
  46. package/core/operating/context.md +401 -18
  47. package/core/operating/evolution.md +3 -2
  48. package/core/operating/loop.md +9 -4
  49. package/core/operating/sessions.md +6 -15
  50. package/core/personas/README.md +75 -0
  51. package/core/personas/implementer.md +3 -0
  52. package/core/personas/librarian.md +3 -0
  53. package/core/personas/reviewer.md +3 -0
  54. package/core/skills/clarify/SKILL.md +1 -1
  55. package/core/skills/consolidate/SKILL.md +1 -1
  56. package/core/templates/handoff.md +6 -2
  57. package/evals/README.md +23 -7
  58. package/evals/ab/personas/README.md +111 -0
  59. package/evals/ab/personas/acquisition.json +330 -0
  60. package/evals/ab/personas/bindings/implementer.txt +43 -0
  61. package/evals/ab/personas/bindings/librarian.txt +40 -0
  62. package/evals/ab/personas/bindings/reviewer.txt +41 -0
  63. package/evals/ab/personas/corpus.json +69 -0
  64. package/evals/ab/personas/grader.mjs +171 -0
  65. package/evals/ab/personas/implementer.json +774 -0
  66. package/evals/ab/personas/implementer.md +22 -0
  67. package/evals/ab/personas/librarian.json +758 -0
  68. package/evals/ab/personas/librarian.md +22 -0
  69. package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
  70. package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
  71. package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
  72. package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
  73. package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
  74. package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
  75. package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
  76. package/evals/ab/personas/reviewer.json +758 -0
  77. package/evals/ab/personas/reviewer.md +22 -0
  78. package/evals/releases/0.3.0.json +150 -0
  79. package/evals/releases/0.3.0.md +68 -0
  80. package/evals/releases/0.4.0.json +154 -0
  81. package/evals/releases/0.4.0.md +69 -0
  82. package/package.json +7 -2
  83. package/packs/.claude-plugin/plugin.json +2 -1
  84. package/packs/README.md +7 -4
  85. package/packs/rituals/checkpoints/README.md +35 -0
  86. package/packs/rituals/checkpoints/pack.json +3 -2
  87. package/packs/tools/README.md +8 -8
  88. package/packs/tools/local-llm/README.md +176 -0
  89. package/packs/tools/local-llm/pack.json +32 -0
  90. package/packs/tools/local-llm/personas/local-worker.md +44 -0
  91. package/packs/tools/local-llm/runner/local-llm.mjs +1779 -0
  92. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +88 -0
  93. package/packs/tools/local-llm/verify/README.md +18 -0
  94. package/spec/README.md +37 -3
  95. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
  96. package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
  97. package/spec/migrations/README.md +6 -0
  98. package/spec/pack.schema.json +12 -3
  99. package/spec/slots.md +272 -123
  100. 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";
@@ -692,9 +696,10 @@ export async function run(argv, options = {}) {
692
696
  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
697
  if (!sourceStat.isDirectory()) throw new VendorError(`\`${parsed.source}\` is not a directory — the source is a workspace directory, the same shape \`doctor\` takes`);
694
698
 
695
- let manifest;
699
+ let manifest,manifestBytes;
696
700
  try {
697
- manifest = JSON.parse(fs.readFileSync(path.join(source, "workspace.json"), "utf8"));
701
+ manifestBytes=fs.readFileSync(path.join(source,"workspace.json"));
702
+ manifest = JSON.parse(manifestBytes);
698
703
  } catch (cause) {
699
704
  throw new VendorError(
700
705
  cause.code === "ENOENT"
@@ -714,6 +719,8 @@ export async function run(argv, options = {}) {
714
719
  }
715
720
 
716
721
  const sourceResidence = residenceOf(manifest);
722
+ const checkSourceRecovery=()=>{try{assertNoSourceJournal(source);if(typeof manifest.tree==="string")assertNoSourceJournal(path.resolve(source,manifest.tree));}catch(error){throw new VendorError(error.message);}};
723
+ checkSourceRecovery();
717
724
  const dest = path.resolve(parsed.into);
718
725
 
719
726
  if (parsed.residence === "in-repo" && path.basename(dest) !== ".portulan") {
@@ -736,13 +743,11 @@ export async function run(argv, options = {}) {
736
743
  }
737
744
 
738
745
  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
- }
746
+ try {
747
+ guidance = guidanceUnits(source, ".");
748
+ } catch (error) {
749
+ if (error instanceof CompileError) throw new VendorError(error.message);
750
+ throw error;
746
751
  }
747
752
 
748
753
  // ---- the scope bound, refused in both its shapes
@@ -779,10 +784,28 @@ export async function run(argv, options = {}) {
779
784
  // ---- the destination
780
785
 
781
786
  const carve = parsed.switching ? carveOut(dest, manifest.name) : { allow: new Set(), pointer: null };
787
+ const preserved=new Map(),priorStamps=new Map();
788
+ const priorRoot=fs.existsSync(dest)?{real:fs.realpathSync(dest),stat:fs.statSync(dest)}:null;
789
+ 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);}
790
+ 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
791
  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);
792
+ const sourceDirs = directories(source).map(entry=>{const at=fs.lstatSync(path.join(source,entry.rel));return {...entry,identity:{dev:at.dev,ino:at.ino}};});
793
+ const journals=[...walked,...sourceDirs].filter(file=>file.rel.split("/").some(part=>part.toLowerCase()===SOURCE_JOURNAL));
794
+ 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`);
795
+ checkSourceRecovery();
796
+ const sourceReal=fs.realpathSync(source),sourceDirectory=fs.statSync(source),observed=new Map();
797
+ if(parsed.switching)for(const file of walked) {
798
+ const stamp=fileStamp(source,file.rel,sourceReal);
799
+ if(stamp===null)throw new VendorError(`${file.rel} could not be inventoried safely before switching`);
800
+ observed.set(file.rel,stamp);
801
+ }
802
+ 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");
803
+ let nestedOwned;
804
+ try { nestedOwned=ownedNestedFiles(source,"."); } catch(error) { if(error instanceof CompileError) throw new VendorError(error.message); throw error; }
805
+ const files = walked.filter((f) => !isGenerated(f.rel) && !nestedOwned.has(f.rel));
806
+ const unfinished=files.filter(f=>/\.portulan-(?:guidance(?:-claim)?|upgrade|retire)\./.test(path.basename(f.rel)));
807
+ 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`);
808
+ const generated = walked.filter((f) => isGenerated(f.rel) || nestedOwned.has(f.rel)).map((f) => f.rel);
786
809
  const rels = files.map((f) => f.rel);
787
810
  const hostFile = parsed.host === null ? null : path.join(path.dirname(dest), "AGENTS.md");
788
811
 
@@ -816,7 +839,8 @@ export async function run(argv, options = {}) {
816
839
 
817
840
  const retargeted = retarget(manifest, parsed.residence, parsed.kindOf);
818
841
  const oldResidence = source;
819
- const repoDir = parsed.residence === "in-repo" ? path.dirname(dest) : path.dirname(source);
842
+ const oldTree=typeof manifest.tree==="string" && manifest.tree.trim() ? path.resolve(source,manifest.tree) : source;
843
+ const repoDir = parsed.residence === "in-repo" ? path.dirname(dest) : oldTree;
820
844
 
821
845
  if (parsed.dryRun) {
822
846
  say(`vendor: ${parsed.switching ? "switch" : "vendor"} \`${manifest.name}\` — ${sourceResidence} → ${parsed.residence}`);
@@ -852,26 +876,34 @@ export async function run(argv, options = {}) {
852
876
  }
853
877
  }
854
878
  fs.mkdirSync(staging, { recursive: true });
855
- undo.push(() => fs.rmSync(staging, { recursive: true, force: true }));
879
+ const stagingRoot={real:fs.realpathSync(staging),stat:fs.statSync(staging)},stagedOwned=new Map(),stagedDirs=new Map();
880
+ undo.push(()=>cleanupPublications(staging,stagedOwned,new Map(),stagingRoot,true,stagedDirs));
881
+ const stageWrite=(rel,bytes,mode=null)=>{
882
+ 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);
883
+ stagedOwned.set(rel,[at.dev,at.ino,at.mode,createHash("sha256").update(Buffer.alloc(0)).digest("hex")]);
884
+ 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")]);}
885
+ 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;}
886
+ finally{fs.closeSync(fd);}
887
+ };
856
888
 
857
889
  for (const entry of sourceDirs) {
858
890
  if (isGenerated(entry.rel)) continue;
859
891
  const full = path.join(staging, entry.rel);
860
- fs.mkdirSync(full, { recursive: true });
861
- fs.chmodSync(full, entry.mode);
892
+ makeOwnedDirectories(staging,entry.rel,stagedDirs);
893
+ fs.chmodSync(full, entry.mode|0o700);
862
894
  }
863
895
  // Copied byte for byte: `# portulan:bundle-fallback` lines are not rewritten here; `portulan upgrade` re-derives them.
864
896
  for (const file of files) {
865
897
  if (file.rel === "workspace.json") continue;
866
898
  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);
899
+ stageWrite(file.rel,fs.readFileSync(path.join(source,file.rel)),file.mode);
870
900
  }
871
901
  if (parsed.switching && carve.pointer && !rels.includes("README.md")) {
872
- fs.writeFileSync(path.join(staging, "README.md"), arrivedReadme(manifest.name));
902
+ stageWrite("README.md",arrivedReadme(manifest.name));
873
903
  }
874
- fs.writeFileSync(path.join(staging, "workspace.json"), `${JSON.stringify(retargeted, null, 2)}\n`);
904
+ stageWrite("workspace.json",`${JSON.stringify(retargeted,null,2)}\n`);
905
+ const inputTimes=new Map([...stagedOwned.keys()].map(rel=>[rel,fs.lstatSync(path.join(staging,rel),{bigint:true}).ctimeNs]));
906
+ try{guidance=guidanceUnits(staging,".");}catch(error){if(error instanceof CompileError)throw new VendorError(error.message);throw error;}
875
907
  if (hostFile !== null) {
876
908
  // Not required: a missing kernel is said in the file rather than refusing the run.
877
909
  let kernel = null;
@@ -880,9 +912,13 @@ export async function run(argv, options = {}) {
880
912
  } catch {
881
913
  kernel = null;
882
914
  }
883
- fs.writeFileSync(path.join(staging, "..AGENTS.md.vendoring"), agentsMd(retargeted, parsed.host, kernel, guidance));
915
+ stageWrite("..AGENTS.md.vendoring",agentsMd(retargeted,parsed.host,kernel,guidance));
884
916
  }
917
+ for(const entry of [...sourceDirs].reverse())if(!isGenerated(entry.rel))fs.chmodSync(path.join(staging,entry.rel),entry.mode);
885
918
  fault("materialise:files");
919
+ const stagedNow=walk(staging);
920
+ 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");
921
+ 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
922
 
887
923
  // ---- doctor the new residence, BEFORE it is one
888
924
 
@@ -898,9 +934,12 @@ export async function run(argv, options = {}) {
898
934
  say(`vendor: RED at the new residence — nothing was moved, and \`${manifest.name}\` still resides ${sourceResidence === "in-repo" ? "in its repository" : "feed-side"}.`);
899
935
  for (const f of staged) say(`vendor: ${f.check} ${f.message}`);
900
936
  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);
937
+ const incomplete=await unwind(undo);if(incomplete.length)warn(`vendor: rollback incomplete; retained ${incomplete.join(", ")}`);
902
938
  return 1;
903
939
  }
940
+ if([...inputTimes].some(([rel,time])=>fs.lstatSync(path.join(staging,rel),{bigint:true}).ctimeNs!==time))throw new VendorError("staged source changed after guidance validation");
941
+
942
+ checkSourceRecovery();
904
943
 
905
944
  // ---- the one rename, and the sentence that precedes it
906
945
 
@@ -915,48 +954,42 @@ export async function run(argv, options = {}) {
915
954
  say("vendor: wrote — and the window closes. If it reports none, the window never opened: delete nothing.");
916
955
  }
917
956
 
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
- }
957
+ 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");
958
+ const destWasAbsent=priorRoot===null,destOwned=new Map(),hostOwned=new Map(),hostPrevious=new Map(),destDirs=new Map(),hostDirs=new Map();
959
+ const hostRoot=hostFile===null?null:path.dirname(hostFile),hostIdentity=hostRoot===null?null:{real:fs.realpathSync(hostRoot),stat:fs.statSync(hostRoot)};
960
+ let destRoot=priorRoot;
961
+ const oldBytes=new Map([...preserved].map(([rel,bytes])=>[rel,{bytes,mode:priorStamps.get(rel)[2]}]));
962
+ undo.push(()=>{
963
+ const failures=[];
964
+ if(hostFile!==null && hostOwned.size)failures.push(...cleanupPublications(hostRoot,hostOwned,hostPrevious,hostIdentity,false,hostDirs));
965
+ if(destRoot)failures.push(...cleanupPublications(dest,destOwned,oldBytes,destRoot,destWasAbsent,destDirs));
966
+ return failures;
936
967
  });
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);
968
+ const publishHost=()=>{
969
+ if(hostFile===null)return;
970
+ const stamp=stagedOwned.get("..AGENTS.md.vendoring"),bytes=readStamped(staging,"..AGENTS.md.vendoring",stamp);
971
+ atomicWrite(hostFile,bytes,{exclusive:true,mode:stamp[2],onPublish:id=>hostOwned.set(path.basename(hostFile),[id.dev,id.ino,stamp[2],stamp[3]])});
972
+ guardedRemove(path.join(staging,"..AGENTS.md.vendoring"),{beforeDigest:stamp[3],identity:{dev:stamp[0],ino:stamp[1],mode:stamp[2]}});
973
+ stagedOwned.delete("..AGENTS.md.vendoring");
974
+ };
975
+ if(destWasAbsent) {
976
+ fs.mkdirSync(path.dirname(dest),{recursive:true});publishHost();
977
+ destRoot={real:path.join(fs.realpathSync(path.dirname(dest)),path.basename(dest)),stat:stagingRoot.stat};
978
+ for(const [rel,stamp]of stagedOwned)destOwned.set(rel,stamp);
979
+ for(const [rel,stamp]of stagedDirs)destDirs.set(rel,stamp);
980
+ fs.renameSync(staging,dest);
942
981
  } 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"));
982
+ 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);}
983
+ const publish=rel=>{
984
+ const stamp=stagedOwned.get(rel),prior=priorStamps.get(rel),bytes=readStamped(staging,rel,stamp);
985
+ 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]])});
986
+ };
987
+ for(const rel of stagedOwned.keys())if(rel!=="workspace.json"&&rel!=="..AGENTS.md.vendoring")publish(rel);
988
+ publishHost();publish("workspace.json");
989
+ for(const entry of directories(staging).reverse())if(destDirs.has(entry.rel))fs.chmodSync(path.join(dest,entry.rel),entry.mode);
990
+ const retained=cleanupPublications(staging,stagedOwned,new Map(),stagingRoot,true,stagedDirs);
991
+ if(retained.length)throw new VendorError(`staging cleanup incomplete: ${retained.join(", ")}`);
958
992
  }
959
- fs.rmSync(staging, { recursive: true, force: true });
960
993
  fault("materialise:manifest");
961
994
 
962
995
  const records = [];
@@ -968,12 +1001,9 @@ export async function run(argv, options = {}) {
968
1001
  if (readmeBlocked.length) {
969
1002
  recordsLeft.push(`\`${CHANGES_README}\` ${readmeBlocked[0].why === "already exists" ? "is the tree's own" : readmeBlocked[0].why}`);
970
1003
  } 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" });
1004
+ makeOwnedDirectories(hostRoot,path.dirname(CHANGES_README),hostDirs);
1005
+ const bytes=changesReadme();
1006
+ atomicWrite(readme,bytes,{exclusive:true,onPublish:id=>hostOwned.set(CHANGES_README,[id.dev,id.ino,id.mode,createHash("sha256").update(bytes).digest("hex")])});
977
1007
  records.push(CHANGES_README);
978
1008
  }
979
1009
  const index = retargeted.handoffs?.index?.path;
@@ -982,21 +1012,20 @@ export async function run(argv, options = {}) {
982
1012
  if (typeof index === "string" && blocked.length) recordsLeft.push(`\`.gitignore\` ${blocked[0].why}`);
983
1013
  if (typeof index === "string" && blocked.length === 0) {
984
1014
  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
- }
1015
+ const prior=fileStamp(hostRoot,".gitignore",hostIdentity.real);
1016
+ let before=null;
1017
+ try {fs.lstatSync(ignore);if(!prior)throw new VendorError(".gitignore changed or is not an ordinary file");before=readStamped(hostRoot,".gitignore",prior);}
1018
+ catch(cause){if(cause.code!=="ENOENT")throw cause;}
991
1019
  const next = withIgnoreLines(before, handoffIndexIgnore(rel, path.relative(hostRoot, dest).split(path.sep).join("/")));
992
1020
  if (next !== (before ?? "")) {
993
- undo.push(() => (before === null ? fs.rmSync(ignore, { force: true }) : fs.writeFileSync(ignore, before)));
994
- fs.writeFileSync(ignore, next);
1021
+ if(prior)hostPrevious.set(".gitignore",{bytes:before,mode:prior[2]});
1022
+ 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
1023
  records.push(".gitignore");
996
1024
  }
997
1025
  }
998
1026
  }
999
1027
 
1028
+ checkSourceRecovery();
1000
1029
  if (!parsed.switching) {
1001
1030
  say(`vendor: wrote ${files.length + (hostFile ? 1 : 0)} file(s) — the \`${manifest.name}\` workspace at ${display(dest)}${hostFile ? `, and ${display(hostFile)}` : ""}.`);
1002
1031
  if (records.length) say(`vendor: and the records the new form keeps, beside it: ${records.map((r) => `\`${r}\``).join(", ")}.`);
@@ -1009,15 +1038,16 @@ export async function run(argv, options = {}) {
1009
1038
  // ---- retire the old residence's MANIFEST, which is the act that transfers governance
1010
1039
 
1011
1040
  // Before the material: retiring the files first would leave a governing manifest over a workspace with holes.
1041
+ checkSourceRecovery();
1012
1042
  const oldManifest = path.join(oldResidence, "workspace.json");
1043
+ 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
1044
  if (leave === "pointer") {
1014
1045
  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);
1046
+ const stamp=observed.get("workspace.json");
1047
+ 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
1048
  } else {
1020
- fs.rmSync(oldManifest, { force: true });
1049
+ const stamp=observed.get("workspace.json");
1050
+ guardedRemove(oldManifest,{beforeDigest:stamp[3],identity:{dev:stamp[0],ino:stamp[1],mode:stamp[2]},onRemove:()=>{pastTheFlip=true;}});
1021
1051
  }
1022
1052
  pastTheFlip = true;
1023
1053
  undo.length = 0;
@@ -1037,18 +1067,23 @@ export async function run(argv, options = {}) {
1037
1067
  say(`vendor: governance has moved to ${display(dest)}, and \`doctor\` is RED at ${newEnd.length ? "the new" : "the old"} end.`);
1038
1068
  for (const f of [...newEnd, ...oldEnd]) say(`vendor: ${f.check} ${f.message}`);
1039
1069
  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.`);
1070
+ say(`vendor: the transfer completed, but validation failed. Preserve both ends and re-run \`doctor\` after fixing what is named above.`);
1041
1071
  return 1;
1042
1072
  }
1043
1073
 
1044
1074
  // ---- retire the old residence's material — only what this run can account for
1045
1075
 
1046
- // Compile's output is deleted though never copied: it is reproducible, and left here it would enforce beside a pointer.
1076
+ // Retire only unchanged files from the initial inventory, including compiled outputs.
1047
1077
  const moved = new Set([...rels, ...generated]);
1048
1078
  const leftovers = [];
1079
+ const retiredGenerated=[],generatedSet=new Set(generated);
1080
+ const retain=rel=>{if(!leftovers.includes(rel))leftovers.push(rel);};
1081
+ let keptResidence=false,retiredCount=0;
1049
1082
  let remaining = [];
1050
1083
  let unscannable = null;
1084
+ 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
1085
  try {
1086
+ if(!sameResidence())throw new VendorError("the old residence changed identity during switching");
1052
1087
  remaining = walk(oldResidence);
1053
1088
  } catch (cause) {
1054
1089
  // A failed scan is not an empty residence: under `--leave nothing` it would delete files this run cannot account for.
@@ -1056,18 +1091,31 @@ export async function run(argv, options = {}) {
1056
1091
  }
1057
1092
  for (const file of remaining) {
1058
1093
  if (file.rel === "workspace.json") continue;
1059
- if (leave === "pointer" && file.rel === "README.md" && !moved.has("README.md")) continue;
1060
1094
  if (!moved.has(file.rel)) {
1061
- leftovers.push(file.rel);
1095
+ retain(file.rel);
1062
1096
  continue;
1063
1097
  }
1064
- fs.rmSync(path.join(oldResidence, file.rel), { force: true });
1098
+ if(!retireObserved(oldResidence,file.rel,observed.get(file.rel),sourceReal)) {retain(file.rel);continue;}
1099
+ retiredCount++;if(generatedSet.has(file.rel))retiredGenerated.push(file.rel);
1065
1100
  }
1066
- if (unscannable === null) pruneEmpty(oldResidence);
1101
+ if(unscannable===null) {
1102
+ try {for(const file of walk(oldResidence))if(file.rel!=="workspace.json")retain(file.rel);}
1103
+ catch(cause){unscannable=cause;}
1104
+ }
1105
+ if(unscannable===null && !sameResidence())unscannable=new VendorError("the old residence changed identity during retirement");
1106
+ if (unscannable === null) pruneObservedDirectories(oldResidence,sourceDirs);
1067
1107
  if (leave === "pointer") {
1068
- fs.writeFileSync(path.join(oldResidence, "README.md"), pointerReadme(manifest.name, parsed.feed, display(dest)));
1108
+ try {fs.writeFileSync(path.join(oldResidence,"README.md"),pointerReadme(manifest.name,parsed.feed,display(dest)),{flag:"wx"});}
1109
+ catch(cause){if(cause.code!=="EEXIST")throw cause;retain("README.md");}
1069
1110
  } else if (leftovers.length === 0 && unscannable === null) {
1070
- fs.rmSync(oldResidence, { recursive: true, force: true });
1111
+ try {fs.rmdirSync(oldResidence);}
1112
+ catch(cause) {
1113
+ if(cause.code!=="ENOENT") {
1114
+ keptResidence=true;
1115
+ try {for(const file of walk(oldResidence))retain(file.rel);}
1116
+ catch(error){unscannable=error;}
1117
+ }
1118
+ }
1071
1119
  }
1072
1120
  fault("retire:material");
1073
1121
 
@@ -1075,12 +1123,12 @@ export async function run(argv, options = {}) {
1075
1123
  say(`vendor: resides at ${display(dest)}, green.`);
1076
1124
  if (unscannable !== null) {
1077
1125
  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),");
1126
+ 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
1127
  say("vendor: and nothing here deletes files it could not account for. Clear it by hand when you can read it.");
1080
1128
  } else if (leave === "pointer") {
1081
1129
  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.`);
1130
+ } else if (leftovers.length || keptResidence) {
1131
+ say(`vendor: the original manifest at ${display(oldResidence)} is retired; remaining content was preserved for review.`);
1084
1132
  } else {
1085
1133
  say(`vendor: nothing left at ${display(oldResidence)}.`);
1086
1134
  }
@@ -1088,12 +1136,12 @@ export async function run(argv, options = {}) {
1088
1136
  say(`vendor: ${leftovers.length} file(s) at the old residence were not moved by this run and were left alone: ${leftovers.join(", ")}.`);
1089
1137
  say("vendor: nothing here deletes a file it cannot account for — they are yours to keep or remove.");
1090
1138
  }
1091
- if (generated.length) {
1092
- say(`vendor: ${generated.length} compiled artifact(s) did not travel and were retired with the old residence: ${generated.join(", ")}.`);
1139
+ if (retiredGenerated.length) {
1140
+ say(`vendor: ${retiredGenerated.length} compiled artifact(s) did not travel and were retired with the old residence: ${retiredGenerated.join(", ")}.`);
1093
1141
  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
1142
  }
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.`);
1143
+ if (sourceResidence === "in-repo" && !isInside(source,oldTree)) {
1144
+ 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
1145
  }
1098
1146
  if (!parsed.repoRoots.length && cards.length) {
1099
1147
  say(`vendor: no --repo-root was given, so the cross-repository half of "green at both ends" reported rather than checked.`);
@@ -1117,17 +1165,72 @@ export async function run(argv, options = {}) {
1117
1165
  }
1118
1166
  return 0;
1119
1167
  } catch (error) {
1120
- if (!pastTheFlip) await unwind(undo);
1168
+ if (!pastTheFlip) {const incomplete=await unwind(undo);if(incomplete.length)warn(`vendor: rollback incomplete; retained ${incomplete.join(", ")}`);}
1121
1169
  const message = error instanceof VendorError ? error.message : `could not run — ${error.code === "EIO" ? error.message : (error.stack ?? error)}`;
1122
1170
  warn(`vendor: ${message}`);
1123
1171
  if (pastTheFlip) {
1124
1172
  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.");
1173
+ warn("vendor: run `doctor` at both ends to check the current governance and retained files.");
1126
1174
  }
1127
1175
  return 2;
1128
1176
  }
1129
1177
  }
1130
1178
 
1179
+ function makeOwnedDirectories(root,rel,created) {
1180
+ if(rel==="."||rel==="")return;
1181
+ let current="";
1182
+ 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;}}
1183
+ }
1184
+ function cleanupPublications(root,published,previous,expectedRoot,removeRoot,created=new Map()) {
1185
+ if(!fs.existsSync(root))return [];
1186
+ 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];}
1187
+ const real=fs.realpathSync(root),failed=[],directoryModes=[];
1188
+ 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);}}
1189
+ const order=[...published.keys()].sort((a,b)=>a==="workspace.json"?-1:b==="workspace.json"?1:0);
1190
+ for(const rel of order) {
1191
+ const stamp=published.get(rel),full=path.join(root,rel),prior=previous.get(rel);
1192
+ try {
1193
+ if(!sameStamp(fileStamp(root,rel,real,stamp),stamp)){failed.push(full);continue;}
1194
+ const options={beforeDigest:stamp[3],identity:{dev:stamp[0],ino:stamp[1],mode:stamp[2]}};
1195
+ if(prior)atomicWrite(full,prior.bytes,{...options,mode:prior.mode});else guardedRemove(full,options);
1196
+ }catch{failed.push(full);}
1197
+ }
1198
+ 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);}}
1199
+ 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);}
1200
+ if(removeRoot){try{fs.rmdirSync(root);}catch(error){if(error.code!=="ENOENT")failed.push(root);}}
1201
+ return failed;
1202
+ }
1203
+
1204
+ function fileStamp(root,rel,realRoot,expected=null) {
1205
+ try {
1206
+ const file=path.join(root,rel),at=fs.lstatSync(file);
1207
+ 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;
1208
+ return [at.dev,at.ino,at.mode,createHash("sha256").update(fs.readFileSync(file)).digest("hex")];
1209
+ } catch {return null;}
1210
+ }
1211
+ function readStamped(root,rel,stamp) {
1212
+ const full=path.join(root,rel),fd=fs.openSync(full,fs.constants.O_RDONLY|(fs.constants.O_NOFOLLOW??0)|fs.constants.O_NONBLOCK);
1213
+ 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;}
1214
+ finally{fs.closeSync(fd);}
1215
+ }
1216
+ const sameStamp=(left,right)=>Boolean(left && right && left.every((value,index)=>value===right[index]));
1217
+ function retireObserved(root,rel,expected,realRoot) {
1218
+ if(!sameStamp(fileStamp(root,rel,realRoot,expected),expected))return false;
1219
+ const file=path.join(root,rel),claim=path.join(path.dirname(file),`.${path.basename(file)}.portulan-retire.${process.pid}.${randomUUID()}`);
1220
+ let made=false,moved=false,placeholder;
1221
+ try {
1222
+ const fd=fs.openSync(claim,"wx");made=true;const at=fs.fstatSync(fd);placeholder={dev:at.dev,ino:at.ino};fs.closeSync(fd);
1223
+ 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;}
1224
+ if(!sameStamp(fileStamp(root,path.relative(root,claim),realRoot,expected),expected)) {
1225
+ try {fs.linkSync(claim,file);fs.unlinkSync(claim);moved=false;made=false;}
1226
+ catch {throw new VendorError(`${rel} changed during retirement; its data is preserved at ${claim}. Review both paths before cleanup`);}
1227
+ return false;
1228
+ }
1229
+ fs.unlinkSync(claim);moved=false;made=false;return true;
1230
+ } catch(error){if(!moved&&["EACCES","EPERM","EROFS"].includes(error.code))return false;throw error;}
1231
+ 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`);}}
1232
+ }
1233
+
1131
1234
  async function verdict(dir, roots) {
1132
1235
  try {
1133
1236
  const { findings } = await inspect(dir, roots);
@@ -1138,34 +1241,13 @@ async function verdict(dir, roots) {
1138
1241
  }
1139
1242
 
1140
1243
  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
- }
1244
+ const failed=[];
1245
+ while(undo.length){try{const result=await undo.pop()();if(Array.isArray(result))failed.push(...result);}catch(error){failed.push(error.message);}}
1246
+ return failed;
1149
1247
  }
1150
1248
 
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));
1249
+ function pruneObservedDirectories(root,observed) {
1250
+ 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
1251
  }
1170
1252
 
1171
1253
  // `?? ""`: `process.argv[1]` is absent when a non-script imports this, and `pathToFileURL(undefined)` throws.