@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
@@ -11,10 +11,13 @@
11
11
 
12
12
  import { createHash } from "node:crypto";
13
13
  import fs from "node:fs";
14
+ import os from "node:os";
14
15
  import path from "node:path";
15
16
  import { pathToFileURL } from "node:url";
16
17
 
17
- import { BOOT_CARD_UNIT, CompileError, GUIDANCE_RULES_DIR, ON_READ_INDEX, claudeCodeGuidance, guidanceEdits, importPath, importSpans, parseUnit } from "./compile.mjs";
18
+ import { BOOT_CARD_UNIT, CompileError, GUIDANCE_RULES_DIR, ON_READ_INDEX, claudeCodeGuidance, guidanceEdits, importPath, importSpans, importTargetIsFile, parseUnit } from "./compile.mjs";
19
+ import { preflightNestedEdits } from "./guidance-files.mjs";
20
+ import { InstructionJournal, loadInstructionJournal, assertJournalScope } from "./instructions-journal.mjs";
18
21
  import { isInside } from "./inside.mjs";
19
22
  import { outlineMd } from "./symbols.mjs";
20
23
 
@@ -149,7 +152,7 @@ function importLines(text, from) {
149
152
  for (const { target, index } of importSpans(text)) {
150
153
  const bare = importPath(target);
151
154
  if (bare === null) continue;
152
- if (!bare.startsWith("~") && !fs.statSync(path.resolve(path.dirname(from), bare), { throwIfNoEntry: false })?.isFile()) continue;
155
+ if (!bare.startsWith("~") && !importTargetIsFile(path.resolve(path.dirname(from), bare))) continue;
153
156
  const line = starts.findLastIndex((start) => start <= index);
154
157
  out.set(line, [...(out.get(line) ?? []), `@${target}`]);
155
158
  }
@@ -164,7 +167,7 @@ function strayImports(body, unitFile) {
164
167
  for (const { target } of importSpans(body)) {
165
168
  const bare = importPath(target);
166
169
  if (bare === null || bare.startsWith("~") || path.isAbsolute(bare)) continue;
167
- if (fs.statSync(path.resolve(path.dirname(unitFile), bare), { throwIfNoEntry: false })?.isFile()) out.push(`@${target}`);
170
+ if (importTargetIsFile(path.resolve(path.dirname(unitFile), bare))) out.push(`@${target}`);
168
171
  }
169
172
  return out;
170
173
  }
@@ -481,7 +484,7 @@ export function planJoin({ tree, context, read }) {
481
484
  continue;
482
485
  }
483
486
  const bytes = Buffer.byteLength(body.join("\n"), "utf8") + (line < lines.length - 1 ? 1 : 0);
484
- back.set(source, { source, body, edited: unitDigest(unitText) !== digest, description: unit.description, bytes });
487
+ back.set(source, { source, text:unitText, body, edited: unitDigest(unitText) !== digest, description: unit.description, bytes });
485
488
  }
486
489
  if (refusals.length > refused) continue;
487
490
  const next = [];
@@ -563,18 +566,24 @@ export function linkRefusal(tree, rel) {
563
566
  export function instructionReader(tree, read) {
564
567
  return (rel) => {
565
568
  const at = path.join(tree, ...rel.split("/"));
566
- if (!INSTRUCTION_FILES.includes(rel) || !fs.lstatSync(at, { throwIfNoEntry: false })?.isSymbolicLink()) return read(rel);
567
- let to;
569
+ if (!INSTRUCTION_FILES.includes(rel)) return read(rel);
570
+ let to, entry;
568
571
  try {
572
+ entry = fs.lstatSync(at);
569
573
  to = fs.realpathSync(at);
570
574
  } catch (error) {
571
- if (error.code === "ENOENT" || error.code === "ENOTDIR") return null;
575
+ if (error.code === "ENOENT" || error.code === "ENOTDIR") return entry ? null : read(rel);
572
576
  throw new InstructionsError(`${rel} could not be followed — ${error.code ?? error.message}`);
573
577
  }
574
578
  if (!isInside(fs.realpathSync(tree), to)) return null;
575
579
  try {
576
- return fs.readFileSync(to, "utf8");
580
+ if (!fs.statSync(to).isFile()) throw new InstructionsError(`${rel} is not a regular instruction source`);
581
+ if (!entry.isSymbolicLink()) return read(rel);
582
+ const raw = fs.readFileSync(to), text = raw.toString("utf8");
583
+ if (!Buffer.from(text, "utf8").equals(raw)) throw new InstructionsError(`${rel} is not valid UTF-8`);
584
+ return text;
577
585
  } catch (error) {
586
+ if (error instanceof InstructionsError) throw error;
578
587
  throw new InstructionsError(`${rel} could not be read — ${error.code ?? error.message}`);
579
588
  }
580
589
  };
@@ -596,7 +605,7 @@ export function splitOffers(tree, { ratio, floor, over = false }) {
596
605
  const from = path.join(tree, ...rel.split("/"));
597
606
  let text;
598
607
  try {
599
- if (!isInside(fs.realpathSync(tree), fs.realpathSync(from)) || fs.lstatSync(from).isSymbolicLink()) continue;
608
+ if (!isInside(fs.realpathSync(tree), fs.realpathSync(from)) || fs.lstatSync(from).isSymbolicLink() || !fs.statSync(from).isFile()) continue;
600
609
  text = fs.readFileSync(from, "utf8");
601
610
  } catch {
602
611
  continue;
@@ -650,8 +659,12 @@ export function treeReader(tree) {
650
659
  if (!isInside(tree, at)) throw new InstructionsError(`${rel} resolves outside the repository`);
651
660
  let text;
652
661
  try {
653
- if (!isInside(real, fs.realpathSync(at))) throw new InstructionsError(`${rel} is a link out of the repository, which this does not read`);
654
- text = fs.readFileSync(at, "utf8");
662
+ const target = fs.realpathSync(at);
663
+ if (!isInside(real, target)) throw new InstructionsError(`${rel} is a link out of the repository, which this does not read`);
664
+ if (!fs.statSync(target).isFile()) throw new InstructionsError(`${rel} is not a regular instruction source`);
665
+ const raw = fs.readFileSync(target);
666
+ text = raw.toString("utf8");
667
+ if (!Buffer.from(text, "utf8").equals(raw)) throw new InstructionsError(`${rel} is not valid UTF-8`);
655
668
  } catch (error) {
656
669
  if (error instanceof InstructionsError) throw error;
657
670
  if (error.code === "ENOENT" || error.code === "ENOTDIR") return null;
@@ -707,6 +720,262 @@ function locate(workspaceDir) {
707
720
  return { manifest, tree, context, taken };
708
721
  }
709
722
 
723
+ function sourceEdits(plan,join,modeOf){
724
+ return join?[
725
+ ...plan.files.map(f=>({root:"tree",file:f.rel,before:f.before,next:f.after,mode:modeOf(f.rel)})),
726
+ ...plan.units.map(u=>({root:"tree",file:u.source,before:u.text,next:null,mode:modeOf(u.source)})),
727
+ ]:[
728
+ ...plan.units.map(u=>({root:"tree",file:u.source,before:null,next:u.text,mode:modeOf(u.from)})),
729
+ ...plan.files.map(f=>({root:"tree",file:f.rel,before:f.before,next:f.after,mode:modeOf(f.rel)})),
730
+ ];
731
+ }
732
+ function planningReader(tree){
733
+ const raw=treeReader(tree),reads=new Map();
734
+ const read=rel=>{const file=path.join(tree,...rel.split("/")),before=fs.lstatSync(file,{throwIfNoEntry:false}),text=raw(rel),after=fs.lstatSync(file,{throwIfNoEntry:false});if(Boolean(before)!==Boolean(after)||(before&&(before.dev!==after.dev||before.ino!==after.ino||before.mode!==after.mode)))throw new InstructionsError(`${rel} changed during planning`);const value={text,identity:after?{dev:after.dev,ino:after.ino,mode:after.mode}:null};const old=reads.get(rel);if(old&&JSON.stringify(old)!==JSON.stringify(value))throw new InstructionsError(`${rel} changed between planning reads`);reads.set(rel,value);return text;};
735
+ return {read,reads};
736
+ }
737
+ function verifySavedPlan(where,journal){
738
+ const o=journal.owner,reads=new Map(o.reads.map(row=>[row.file,row])),read=rel=>reads.get(rel)?.text??null;
739
+ const plan=o.operation==="join"?planJoin({tree:where.tree,context:where.context,read}):planSplit({tree:where.tree,context:where.context,taken:o.taken,read});
740
+ if(plan.refusals.length)throw new InstructionsError(`saved migration no longer proves its operation: ${plan.refusals.join("; ")}`);
741
+ const edits=sourceEdits(plan,o.operation==="join",rel=>reads.get(rel)?.identity?.mode);
742
+ const expected=edits.map(({file,before,next,mode})=>({file,before,next,mode})),saved=o.inputs.map(({file,before,next,mode})=>({file,before,next,mode}));
743
+ if(JSON.stringify(expected)!==JSON.stringify(saved))throw new InstructionsError("saved edits are not the proved split or join of their original sources; retained journal for review");
744
+ for(const input of o.inputs)if(input.before!==null&&(read(input.file)!==input.before||JSON.stringify(reads.get(input.file)?.identity)!==JSON.stringify(input.original)))throw new InstructionsError("saved source does not match its planning snapshot");
745
+ }
746
+ function preflightSources(where, workspaceDir, edits, planning) {
747
+ assertJournalScope(where.tree, workspaceDir);
748
+ for (const root of INSTRUCTION_FILES) {
749
+ const file = path.join(where.tree, ...root.split("/")),
750
+ at = fs.lstatSync(file, { throwIfNoEntry: false });
751
+ if (at && (!at.isFile() || at.isSymbolicLink() || at.nlink !== 1))
752
+ throw new InstructionsError(`${root} is not an ordinary singly linked root; nothing was written`);
753
+ planning.read(root);
754
+ }
755
+ for (const edit of edits) {
756
+ const file = path.join(where.tree, ...edit.file.split("/")),
757
+ at = fs.lstatSync(file, { throwIfNoEntry: false });
758
+ if (edit.before === null) {
759
+ if (at) throw new InstructionsError(`${edit.file} arrived after planning`);
760
+ continue;
761
+ }
762
+ const held = planning.reads.get(edit.file);
763
+ if (
764
+ !at?.isFile() ||
765
+ at.isSymbolicLink() ||
766
+ at.nlink !== 1 ||
767
+ !held?.identity ||
768
+ at.dev !== held.identity.dev ||
769
+ at.ino !== held.identity.ino ||
770
+ at.mode !== held.identity.mode ||
771
+ planning.read(edit.file) !== edit.before
772
+ )
773
+ throw new InstructionsError(`${edit.file} changed identity, mode or bytes after planning`);
774
+ }
775
+ }
776
+ function referenceKey(tree, file) {
777
+ const root = fs.realpathSync(tree);
778
+ let full = path.resolve(file);
779
+ const seen = new Set();
780
+ for (let depth = 0; depth < 32; depth++) {
781
+ let real;
782
+ try {
783
+ real = fs.realpathSync(full);
784
+ } catch {
785
+ try {
786
+ const at = fs.lstatSync(full, { throwIfNoEntry: false });
787
+ if (at?.isSymbolicLink()) {
788
+ if (seen.has(full)) return null;
789
+ seen.add(full);
790
+ full = path.resolve(fs.realpathSync(path.dirname(full)), fs.readlinkSync(full));
791
+ continue;
792
+ }
793
+ real = path.join(fs.realpathSync(path.dirname(full)), path.basename(full));
794
+ } catch {
795
+ return null;
796
+ }
797
+ }
798
+ return isInside(root, real) ? posix(path.relative(root, real)).normalize("NFC").toLowerCase() : null;
799
+ }
800
+ return null;
801
+ }
802
+ function referencesOfRoots(tree, read) {
803
+ const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }),
804
+ found = new Set();
805
+ const add = (file) => {
806
+ const key = referenceKey(tree, file);
807
+ if (key !== null) found.add(key);
808
+ };
809
+ for (const file of INSTRUCTION_FILES) {
810
+ const current = read(file);
811
+ if (current === null) continue;
812
+ const text = typeof current === "string" ? current : decoder.decode(current);
813
+ for (const marker of marksOf(text).moved) add(path.resolve(tree, ...marker.source.split("/")));
814
+ for (const span of importSpans(text)) {
815
+ const target = importPath(span.target);
816
+ if (target === null) continue;
817
+ add(
818
+ target.startsWith("~/")
819
+ ? path.resolve(os.homedir(), target.slice(2))
820
+ : path.resolve(tree, path.dirname(file), target),
821
+ );
822
+ }
823
+ }
824
+ return found;
825
+ }
826
+ function currentReferences(journal) {
827
+ return referencesOfRoots(journal.root, (file) => journal.source({ file })?.bytes ?? null);
828
+ }
829
+ function assertProjectedReferences(where, edits, planning) {
830
+ const roots = new Map(
831
+ edits.filter((edit) => INSTRUCTION_FILES.includes(edit.file)).map((edit) => [edit.file, edit.next]),
832
+ );
833
+ const references = referencesOfRoots(where.tree, (file) =>
834
+ roots.has(file) ? roots.get(file) : planning.read(file),
835
+ );
836
+ for (const edit of edits)
837
+ if (edit.next === null && references.has(edit.file.normalize("NFC").toLowerCase()))
838
+ throw new InstructionsError(`a resulting root still references ${edit.file}; its unit was preserved`);
839
+ }
840
+ async function writeSourceOperation({
841
+ journal,
842
+ where,
843
+ workspaceDir,
844
+ options,
845
+ say,
846
+ write,
847
+ applyEdits,
848
+ restore,
849
+ context,
850
+ declared,
851
+ ratio,
852
+ tokens,
853
+ alwaysBytes,
854
+ recovered = false,
855
+ }) {
856
+ let undoing = false;
857
+ const forwardGuard = (edit) => {
858
+ journal.guard();
859
+ const retiring =
860
+ journal.owner.operation === "join" &&
861
+ edit?.next === null &&
862
+ journal.owner.inputs.some((input) => input.file === edit.file);
863
+ journal.assertRootImages(retiring);
864
+ if (retiring && currentReferences(journal).has(edit.file.normalize("NFC").toLowerCase()))
865
+ throw new InstructionsError(`a live root still references ${edit.file}; its unit was preserved`);
866
+ };
867
+ const hook = (edit, phase, data) => {
868
+ journal.guard();
869
+ if (!undoing && phase !== "retired") forwardGuard(edit);
870
+ if (journal.owner.inputs.some((input) => input.file === edit.file)) journal.observe(edit, phase, data);
871
+ };
872
+ let applied = { ok: true, snapshots: [] };
873
+ const undo = (why) => {
874
+ let back;
875
+ undoing = true;
876
+ try {
877
+ back = restore(workspaceDir, applied.snapshots, {
878
+ treeDir: where.tree,
879
+ beforeMutation: (snapshot) => {
880
+ journal.guard();
881
+ if (
882
+ snapshot.previous === null &&
883
+ journal.owner.inputs.some((input) => input.file === snapshot.file) &&
884
+ currentReferences(journal).has(snapshot.file.normalize("NFC").toLowerCase())
885
+ )
886
+ throw new InstructionsError(
887
+ `a live root still references ${snapshot.file}; its unit was preserved`,
888
+ );
889
+ },
890
+ journal: hook,
891
+ stopOnFailure: true,
892
+ });
893
+ } catch (error) {
894
+ back = { ok: false };
895
+ why += `; ${error.message}`;
896
+ } finally {
897
+ undoing = false;
898
+ }
899
+ let complete = false;
900
+ try {
901
+ complete = back.ok && journal.originalsRestored();
902
+ if (complete) {
903
+ journal.cleanupSources();
904
+ journal.mark("aborted");
905
+ journal.clean();
906
+ }
907
+ } catch (error) {
908
+ complete = false;
909
+ why += `; ${error.message}`;
910
+ }
911
+ say(
912
+ `instructions: ${why}; ${complete ? "rolled back, nothing is changed" : `the rollback was INCOMPLETE — retained ${journal.dir}; resume the recorded migration after reviewing changed sources`}`,
913
+ );
914
+ return 2;
915
+ };
916
+ try {
917
+ const remaining = journal.remaining();
918
+ applied = applyEdits(workspaceDir, remaining, {
919
+ treeDir: where.tree,
920
+ write,
921
+ beforeMutation: forwardGuard,
922
+ journal: hook,
923
+ });
924
+ if (!applied.ok) return undo(`the write failed — ${applied.reason}`);
925
+ let compiled;
926
+ try {
927
+ compiled = guidanceEdits(workspaceDir).edits.map((edit) => ({ ...edit, root: "tree" }));
928
+ preflightNestedEdits(
929
+ where.tree,
930
+ compiled.filter((edit) => edit.nested),
931
+ );
932
+ } catch (error) {
933
+ return undo(`\`compile\` refused the guidance — ${error.message}`);
934
+ }
935
+ const indexed = applyEdits(workspaceDir, compiled, {
936
+ treeDir: where.tree,
937
+ write,
938
+ beforeMutation: forwardGuard,
939
+ });
940
+ applied.snapshots.push(...indexed.snapshots);
941
+ if (!indexed.ok) return undo(`the index could not be written — ${indexed.reason}`);
942
+ const checked = await import("./compile.mjs");
943
+ if (checked.compileGuidance(workspaceDir, { check: true }).drifted)
944
+ throw new InstructionsError("the completed source set did not regenerate clean guidance");
945
+ journal.cleanupSources();
946
+ journal.mark("committed");
947
+ journal.clean();
948
+ let now;
949
+ try {
950
+ now = alwaysBytes();
951
+ } catch (error) {
952
+ if (!(error instanceof context.ContextError)) throw error;
953
+ say(
954
+ `instructions: written, and the index compiled; the always tier could not be measured after it — ${error.message}`,
955
+ );
956
+ return 0;
957
+ }
958
+ const offer =
959
+ options.join || declared.budget !== null
960
+ ? ""
961
+ : `. A budget is yours to declare: 0036 offers the larger of ${grouped(context.OFFER_FLOOR_TOKENS)} tokens and that, ${grouped(Math.max(context.OFFER_FLOOR_TOKENS, context.tokensOf(now, ratio)))}, as \`context.always.budget.tokens\``;
962
+ say(
963
+ `instructions: ${recovered ? "recovered the interrupted migration; " : ""}written, and the index compiled: the always tier is ${grouped(now)} B (${tokens(now)})${offer}`,
964
+ );
965
+ return 0;
966
+ } catch (error) {
967
+ if (!journal.terminal && applied.snapshots.length) return undo(error.message);
968
+ say(`instructions: ${error.message}; recovery record retained at ${journal.dir}`);
969
+ return 2;
970
+ } finally {
971
+ try {
972
+ journal.release();
973
+ } catch (error) {
974
+ say(`instructions: recovery lease retained at ${journal.dir} — ${error.message}`);
975
+ }
976
+ }
977
+ }
978
+
710
979
  // `write` is the suite's, as `applyEdits` takes it: a write failing partway cannot be staged on a real disk.
711
980
  export async function run(argv, say = (line) => process.stdout.write(`${line}\n`), { cwd = process.cwd(), write } = {}) {
712
981
  let options;
@@ -720,7 +989,7 @@ export async function run(argv, say = (line) => process.stdout.write(`${line}\n`
720
989
  return 2;
721
990
  }
722
991
  const workspaceDir = path.resolve(cwd, options.workspace);
723
- const read = treeReader(where.tree);
992
+ const planning=planningReader(where.tree),read=planning.read;
724
993
  // Loaded here, not at the top: `./context.mjs` and `./upgrade.mjs` reach this module, so a static edge would close a cycle.
725
994
  const context = await import("./context.mjs");
726
995
  const { applyEdits, restore } = await import("./upgrade.mjs");
@@ -736,6 +1005,16 @@ export async function run(argv, say = (line) => process.stdout.write(`${line}\n`
736
1005
  const tokens = (bytes) => `~${grouped(context.tokensOf(bytes, ratio))} tokens`;
737
1006
  const alwaysBytes = () => context.alwaysTier(where.tree).entries.reduce((n, e) => n + e.bytes, 0);
738
1007
 
1008
+ try{
1009
+ const pending=loadInstructionJournal(where.tree,workspaceDir,where.context);
1010
+ if(pending){
1011
+ if(!options.write){say(`instructions: interrupted migration at ${pending.dir}; resume with --write (${pending.owner?.operation??"cleanup"})`);return 2;}
1012
+ if(pending.terminal){pending.clean();say("instructions: completed interrupted journal cleanup; source files were preserved");return 0;}
1013
+ pending.acquire();try{pending.recoverClaims();if(pending.owner.operation!==(options.join?"join":"split"))throw new InstructionsError(`pending ${pending.owner.operation} must be resumed with its original command`);verifySavedPlan(where,pending);pending.restoreRetiredUnits(currentReferences(pending));}catch(error){try{pending.release();}catch{}throw error;}
1014
+ return writeSourceOperation({journal:pending,where,workspaceDir,options,say,write,applyEdits,restore,context,declared,ratio,tokens,alwaysBytes,recovered:true});
1015
+ }
1016
+ }catch(error){say(`instructions: ${error.message}`);return 2;}
1017
+
739
1018
  let edits;
740
1019
  try {
741
1020
  if (options.join) {
@@ -746,34 +1025,59 @@ export async function run(argv, say = (line) => process.stdout.write(`${line}\n`
746
1025
  say("instructions: no section of CLAUDE.md or .claude/CLAUDE.md is moved, so there is nothing to join");
747
1026
  return 0;
748
1027
  }
1028
+ preflightNestedEdits(
1029
+ where.tree,
1030
+ guidanceEdits(workspaceDir).edits.filter((edit) => edit.nested),
1031
+ );
749
1032
  for (const f of joined.files) {
750
1033
  for (const unit of f.units) say(`instructions: ${joinLine(unit, f.rel)}`);
751
1034
  const n = f.units.length;
752
- say(`instructions: ${f.rel} takes back ${n} section${n === 1 ? "" : "s"}, and each of the ${grouped(f.clauses.clauses)} clauses lands once, none missing and none doubled`);
1035
+ say(
1036
+ `instructions: ${f.rel} takes back ${n} section${n === 1 ? "" : "s"}, and each of the ${grouped(f.clauses.clauses)} clauses lands once, none missing and none doubled`,
1037
+ );
753
1038
  }
754
- edits = [
755
- ...joined.files.map((f) => ({ root: "tree", file: f.rel, next: f.after })),
756
- ...joined.units.map((u) => ({ root: "tree", file: u.source, next: null })),
757
- ];
1039
+ edits = sourceEdits(joined, true, (rel) => planning.reads.get(rel)?.identity?.mode);
758
1040
  } else {
759
1041
  const split = planSplit({ tree: where.tree, context: where.context, taken: where.taken, read });
760
1042
  for (const r of split.refusals) say(`instructions: refused — ${r}`);
761
1043
  if (split.refusals.length) return 2;
762
1044
  if (split.marked === 0) {
763
- say(`instructions: no section of CLAUDE.md or .claude/CLAUDE.md is marked — a line \`${ON_READ_MARK}\` under a heading marks its section`);
1045
+ say(
1046
+ `instructions: no section of CLAUDE.md or .claude/CLAUDE.md is marked — a line \`${ON_READ_MARK}\` under a heading marks its section`,
1047
+ );
764
1048
  return 0;
765
1049
  }
766
1050
  for (const line of splitLines(split)) say(`instructions: ${line}`);
767
- const before = alwaysBytes();
768
- const after = before - split.files.reduce((n, f) => n + f.removed, 0) + split.index;
769
- say(`instructions: the always tier goes from ${grouped(before)} B (${tokens(before)}) to ${grouped(after)} B (${tokens(after)}) at ${ratio} bytes per token, markers counted, which the host drops`);
770
- edits = [
771
- ...split.files.map((f) => ({ root: "tree", file: f.rel, next: f.after })),
772
- ...split.units.map((u) => ({ root: "tree", file: u.source, next: u.text })),
773
- ];
1051
+ const guidance = guidanceEdits(workspaceDir);
1052
+ preflightNestedEdits(
1053
+ where.tree,
1054
+ guidance.edits.filter((edit) => edit.nested),
1055
+ );
1056
+ edits = sourceEdits(split, false, (rel) => planning.reads.get(rel)?.identity?.mode);
1057
+ }
1058
+ preflightSources(where, workspaceDir, edits, planning);
1059
+ if (options.join) assertProjectedReferences(where, edits, planning);
1060
+ const { previewMigration } = await import("./init-preview.mjs");
1061
+ let projected;
1062
+ try {
1063
+ projected = previewMigration(where.tree, workspaceDir, edits);
1064
+ } catch (error) {
1065
+ throw new InstructionsError(`prospective guidance refuses — ${error.message}`);
1066
+ }
1067
+ if (!options.join) {
1068
+ const before = projected.before[0],
1069
+ after = projected.after?.[0];
1070
+ if (after === undefined)
1071
+ say(
1072
+ `instructions: the always tier is currently ${grouped(before)} B (${tokens(before)}); its after forecast is unavailable because native imports cannot be projected exactly. --write remeasures the emitted result.`,
1073
+ );
1074
+ else
1075
+ say(
1076
+ `instructions: the always tier goes from ${grouped(before)} B (${tokens(before)}) to ${grouped(after)} B (${tokens(after)}) at ${ratio} bytes per token, markers counted, which the host drops`,
1077
+ );
774
1078
  }
775
1079
  } catch (error) {
776
- if (!(error instanceof InstructionsError || error instanceof context.ContextError)) throw error;
1080
+ if (!(error instanceof InstructionsError || error instanceof context.ContextError || error instanceof CompileError)) throw error;
777
1081
  say(`instructions: ${error.message}`);
778
1082
  return 2;
779
1083
  }
@@ -782,37 +1086,8 @@ export async function run(argv, say = (line) => process.stdout.write(`${line}\n`
782
1086
  return 0;
783
1087
  }
784
1088
 
785
- const applied = applyEdits(workspaceDir, edits, { treeDir: where.tree, write });
786
- const undo = (why) => {
787
- const back = restore(workspaceDir, applied.snapshots, { treeDir: where.tree });
788
- say(`instructions: ${why}; ${back.ok ? "rolled back, nothing is changed" : `the rollback was INCOMPLETE — not put back: ${back.failed.join(", ")}`}`);
789
- return 2;
790
- };
791
- if (!applied.ok) return undo(`the write failed — ${applied.reason}`);
792
- let compiled;
793
- try {
794
- compiled = guidanceEdits(workspaceDir).edits.map((edit) => ({ root: "tree", file: edit.file, next: edit.next }));
795
- } catch (error) {
796
- return undo(`\`compile\` refused the guidance — ${error.message}`);
797
- }
798
- const indexed = applyEdits(workspaceDir, compiled, { treeDir: where.tree, write });
799
- applied.snapshots.push(...indexed.snapshots);
800
- if (!indexed.ok) return undo(`the index could not be written — ${indexed.reason}`);
801
- let now;
802
- try {
803
- now = alwaysBytes();
804
- } catch (error) {
805
- if (!(error instanceof context.ContextError)) throw error;
806
- say(`instructions: written, and the index compiled; the always tier could not be measured after it — ${error.message}`);
807
- return 0;
808
- }
809
- const offer =
810
- options.join || declared.budget !== null
811
- ? ""
812
- : `. A budget is yours to declare: 0036 offers the larger of ${grouped(context.OFFER_FLOOR_TOKENS)} tokens and that, ` +
813
- `${grouped(Math.max(context.OFFER_FLOOR_TOKENS, context.tokensOf(now, ratio)))}, as \`context.always.budget.tokens\``;
814
- say(`instructions: written, and the index compiled: the always tier is ${grouped(now)} B (${tokens(now)})${offer}`);
815
- return 0;
1089
+ try{preflightSources(where,workspaceDir,edits,planning);const journal=InstructionJournal.create(where.tree,workspaceDir,where.context,options.join?"join":"split",edits,planning.reads,where.taken);return writeSourceOperation({journal,where,workspaceDir,options,say,write,applyEdits,restore,context,declared,ratio,tokens,alwaysBytes});}
1090
+ catch(error){say(`instructions: ${error.message}`);return 2;}
816
1091
  }
817
1092
 
818
1093
  if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {