@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
@@ -0,0 +1,14 @@
1
+ // Stop recipe deadline policy.
2
+ export const DEFAULT_STOP_TIMEOUT = 90;
3
+ export const STOP_HOOK_MARGIN = 60;
4
+ export const MAX_STOP_TIMEOUT = Math.floor((2 ** 31 - 1) / 1000) - STOP_HOOK_MARGIN;
5
+
6
+ export function readStopPolicy(manifest) {
7
+ const seconds = manifest?.verify?.stopTimeout;
8
+ if (seconds === undefined) return { seconds: DEFAULT_STOP_TIMEOUT, declared: false };
9
+ const spec = manifest?.portulan?.spec;
10
+ const version = typeof spec === "string" ? /^(\d+)\.(\d+)$/.exec(spec) : null;
11
+ if (!version || Number(version[1]) !== 2 || Number(version[2]) < 14) throw new Error("verify.stopTimeout requires portulan.spec 2.14 or a later minor on major 2");
12
+ if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > MAX_STOP_TIMEOUT) throw new Error(`verify.stopTimeout must be an integer from 1 to ${MAX_STOP_TIMEOUT} seconds`);
13
+ return { seconds, declared: true };
14
+ }
package/cli/symbols.mjs CHANGED
@@ -8,6 +8,7 @@ import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import process from "node:process";
10
10
  import { pathToFileURL } from "node:url";
11
+ import { HTML_ENTITIES } from "./html-entities.mjs";
11
12
 
12
13
  const USAGE = "usage: node cli/symbols.mjs <file>... | <file.md>#<heading>... | --find <name>...";
13
14
 
@@ -764,7 +765,14 @@ function order(entries) {
764
765
  // 5. Markdown: headings, and the section under each
765
766
  // ===========================================================================================
766
767
 
767
- // Headings as CommonMark reads them, outside fences, HTML comments and frontmatter; a setext heading in a list item is missed.
768
+ // Top-level headings; list-container headings and full CommonMark block parsing are outside this reader.
769
+
770
+ function characterReference(reference) {
771
+ if (!reference.startsWith("#")) return Object.hasOwn(HTML_ENTITIES, reference) ? HTML_ENTITIES[reference] : `&${reference};`;
772
+ const hex = /^#x/i.test(reference);
773
+ const point = Number.parseInt(reference.slice(hex ? 2 : 1), hex ? 16 : 10);
774
+ return String.fromCodePoint(point === 0 || point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff) ? 0xfffd : point);
775
+ }
768
776
 
769
777
  /** A heading's text as it reads rendered: a code span's content kept, links and images their text, and emphasis, tags and escapes gone. */
770
778
  function plainText(title) {
@@ -772,9 +780,8 @@ function plainText(title) {
772
780
  text
773
781
  .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
774
782
  .replace(/!?\[([^\]]*)\]\[[^\]]*\]/g, "$1")
775
- .replace(/<[^>]*>/g, "")
776
- .replace(/\\([!-/:-@[-`{-~])/g, "$1")
777
- .replace(/(^|[^\p{L}\p{N}*_])[*_]+|[*_]+(?=[^\p{L}\p{N}*_]|$)/gu, "$1");
783
+ .replace(/\\([!-/:-@[-`{-~])|<([A-Za-z][A-Za-z0-9+.-]{1,31}:[^<>\x00-\x20]*|[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?)>|<[^>]*>|&(#\d{1,7}|#[xX][\da-fA-F]{1,6}|[A-Za-z][A-Za-z0-9]*);|(^|[^\p{L}\p{N}*_])[*_]+|[*_]+(?=[^\p{L}\p{N}*_]|$)/gu,
784
+ (match, escaped, autolink, reference, lead) => escaped ?? autolink ?? (reference ? characterReference(reference) : lead ?? ""));
778
785
  let out = "";
779
786
  let at = 0;
780
787
  for (const span of title.matchAll(/(`+)(.+?)\1(?!`)/g)) {
@@ -794,6 +801,15 @@ const loose = (text) => plainText(text).replace(/\s+/g, " ").trim().toLowerCase(
794
801
  // A line that opens a block other than a paragraph, so no `=` or `-` line under it makes it a heading.
795
802
  const NOT_PARAGRAPH = /^(?: {4}|\t| {0,3}(?:>|[-*+](?:[ \t]|$)|\d{1,9}[.)](?:[ \t]|$)|<[A-Za-z/!?]|\|))/;
796
803
  const RULE = /^ {0,3}(?:(?:-[ \t]*){3,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})$/;
804
+ const HEADING_OR_FENCE = /^(?:#{1,6}(?:[ \t]|$)|`{3,}|~{3,})/;
805
+
806
+ const startsListParagraph = (text) => text.trim() !== "" && !NOT_PARAGRAPH.test(text) && !RULE.test(text) && !HEADING_OR_FENCE.test(text);
807
+
808
+ function columns(text, start = 0) {
809
+ let col = start;
810
+ for (const char of text) col += char === "\t" ? 4 - col % 4 : 1;
811
+ return col;
812
+ }
797
813
 
798
814
  /** The outline of a Markdown file: `{lines, bytes, entries}`, an entry per heading, nested by level. */
799
815
  export function outlineMd(src) {
@@ -811,8 +827,10 @@ export function outlineMd(src) {
811
827
  }
812
828
  let fence = null;
813
829
  let comment = false;
814
- let para = null; // the line a paragraph opened at, at the margin, while it is open
830
+ let para = null; // the line the current paragraph opened at
815
831
  let above = "start"; // what the line above was: start, blank, heading, rule, para or other
832
+ const listIndents = [];
833
+ let listParagraph = false;
816
834
  for (; i < lines; i++) {
817
835
  const line = at(i);
818
836
  if (fence) {
@@ -829,6 +847,36 @@ export function outlineMd(src) {
829
847
  }
830
848
  continue;
831
849
  }
850
+ const indent = columns(/^[ \t]*/.exec(line)[0]);
851
+ const marker = !RULE.test(line) && /^([ \t]*)([-*+]|\d{1,9}[.)])([ \t]+|$)/.exec(line);
852
+ if (marker && (indent <= 3 || (listIndents.length && indent < listIndents.at(-1) + 4))) {
853
+ const end = indent + marker[2].length;
854
+ const padding = columns(marker[3], end) - end;
855
+ while (listIndents.length && listIndents.at(-1) > indent) listIndents.pop();
856
+ listIndents.push(end + (padding >= 1 && padding <= 4 ? padding : 1));
857
+ listParagraph = padding <= 4 && startsListParagraph(line.slice(marker[0].length));
858
+ para = null;
859
+ above = "other";
860
+ continue;
861
+ }
862
+ if (listIndents.length) {
863
+ if (line.trim() === "") {
864
+ listParagraph = false;
865
+ } else if (indent >= listIndents[0] || (listParagraph && !RULE.test(line) && !/^ {0,3}(?:#{1,6}(?:[ \t]|$)|>|`{3,}|~{3,}|<)/.test(line))) {
866
+ if (indent >= listIndents[0]) {
867
+ while (listIndents.at(-1) > indent) listIndents.pop();
868
+ listParagraph = (listParagraph || indent < listIndents.at(-1) + 4) && startsListParagraph(line.trimStart());
869
+ }
870
+ para = null;
871
+ above = "other";
872
+ continue;
873
+ } else {
874
+ listIndents.length = 0;
875
+ listParagraph = false;
876
+ para = null;
877
+ above = "blank";
878
+ }
879
+ }
832
880
  const open = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
833
881
  if (open && !(open[1][0] === "`" && open[2].includes("`"))) {
834
882
  fence = new RegExp(`^ {0,3}${open[1][0]}{${open[1].length},}[ \\t]*$`);
@@ -843,7 +891,7 @@ export function outlineMd(src) {
843
891
  }
844
892
  const atx = /^ {0,3}(#{1,6})(?:[ \t]+|$)(.*)$/.exec(line);
845
893
  if (atx) {
846
- found.push({ start: i + 1, level: atx[1].length, title: atx[2].replace(/(?:^|[ \t]+)#+[ \t]*$/, "").trim() });
894
+ found.push({ start: i + 1, level: atx[1].length, title: atx[2].replace(/(?:^|[ \t]+)#+[ \t]*$/, "").replace(/^[ \t]+|[ \t]+$/g, "") });
847
895
  para = null;
848
896
  above = "heading";
849
897
  continue;
@@ -866,8 +914,7 @@ export function outlineMd(src) {
866
914
  para = null;
867
915
  above = "other";
868
916
  } else if (above !== "para" && above !== "other") {
869
- // A paragraph a setext line may underline opens at the margin; one indented may sit in a list.
870
- para = /^\S/.test(line) ? i + 1 : null;
917
+ para = i + 1;
871
918
  above = para === null ? "other" : "para";
872
919
  }
873
920
  }
package/cli/telemetry.mjs CHANGED
@@ -17,16 +17,35 @@ import { recipeSet, resolverFor } from "./recipe-set.mjs";
17
17
  import { isInside } from "./inside.mjs";
18
18
 
19
19
  // ---------------------------------------------------------------------------------------------
20
- // The offline audit: no verify recipe may reach the network
20
+ // The offline audit: recipes reach neither the network nor local usage records
21
21
  // ---------------------------------------------------------------------------------------------
22
22
 
23
- /** Every mode in cli/ that can reach the network; a new one is unrailed until it has a row here. */
23
+ /** Every network mode in cli/ or a pack (named from `<category>/<name>/`); a new one is unrailed until it has a row here. */
24
24
  export const NETWORK_MODES = Object.freeze([
25
+ { module: "cli/fixtures/on-path-host/capture.mjs", flag: "--run-host", what: "runs the manual real-host on-path demonstration" },
26
+ { module: "cli/fixtures/nested-on-path-host/capture.mjs", flag: "--run-host", what: "runs the manual real-host nested instruction demonstration" },
27
+ { module: "cli/context.mjs", flag: "--count-tokens", what: "sends measured project source to Anthropic's token counter" },
28
+ { module: "cli/doctor.mjs", flag: "--count-tokens", aliases: [{ module: "cli/portulan.mjs", subcommand: "doctor" }, { module: "portulan", subcommand: "doctor" }, { module: "@sleepy_panda_srl/portulan", subcommand: "doctor" }], what: "requests a provider token estimate" },
25
29
  { module: "cli/review-meter.mjs", flag: "--fetch", what: "fetches review data from GitHub" },
26
30
  { module: "cli/telemetry.mjs", flag: "--export", what: "posts an OTLP payload to a collector" },
27
31
  { module: "cli/feedback.mjs", flag: "--approve", what: "files a GitHub issue with `gh issue create`" },
32
+ {
33
+ module: "tools/local-llm/runner/local-llm.mjs",
34
+ unless: "--verify-offline",
35
+ alsoUnless: ["eval", "--verify"],
36
+ what: "calls the model endpoint except offline rails and captured-record verification",
37
+ },
38
+ ]);
39
+
40
+ /** Local usage is machine-specific too; static mode must be explicit on each recipe invocation. */
41
+ export const LOCAL_RECORD_MODES = Object.freeze([
42
+ { module: "cli/doctor.mjs", requiresStatic: "--static", what: "reads local usage records by default" },
43
+ { module: "cli/portulan.mjs", subcommand: "doctor", requiresStatic: "--static", what: "dispatches to the local usage-record reader" },
44
+ { module: "portulan", subcommand: "doctor", requiresStatic: "--static", what: "dispatches to the local usage-record reader" },
28
45
  ]);
29
46
 
47
+ export const modeSpelling = (m) => m.requiresStatic ? `${m.module}${m.subcommand ? ` ${m.subcommand}` : ""} without an immediate ${m.requiresStatic}` : (m.flag ? [m, ...(m.aliases ?? [])].map((s) => `${s.module}${s.subcommand ? ` ${s.subcommand}` : ""} ${m.flag}`).join(", ") : `${m.module} with anything but ${m.unless} alone${m.alsoUnless ? ` or ${m.alsoUnless.join(" ")} alone` : ""}`);
48
+
30
49
  /** Whole-line comments only: a trailing `#` may sit inside a quote. */
31
50
  export const stripShellComments = (source) =>
32
51
  source
@@ -36,13 +55,38 @@ export const stripShellComments = (source) =>
36
55
 
37
56
  const shellTokens = (body) => body.split(/\s+/).map((t) => t.replace(/^[("'`]+/, "").replace(/[)"'`;]+$/, "")).filter(Boolean);
38
57
 
39
- const tokenNamesModule = (token, module) => token === module || token.endsWith(`/${module}`);
58
+ const tokenNamesModule = (token, module) => token === module || token.endsWith(`/${module}`) || (module.startsWith("@") && token.startsWith(`${module}@`));
40
59
 
41
60
  const tokenIsFlag = (token, flag) => token === flag || token.startsWith(`${flag}=`);
61
+ const shellSource = (source) => stripShellComments(source).replace(/\\\r?\n/g, "");
62
+
63
+ /** An `unless` row passes only where every invocation is the module and its one safe flag, alone on its command. */
64
+ const onlySafeInvocations = (source, m) =>
65
+ shellSource(source)
66
+ .split(/\n|;|&&?|\|\|?|\$\(|<\(|>\(|`/)
67
+ .every((segment) => {
68
+ const tokens = shellTokens(segment);
69
+ if (tokens[0] === "for" || ["[", "test"].includes(tokens[0]) || (tokens[0] === "if" && ["[", "test"].includes(tokens[1]))) return true;
70
+ return tokens.every((t, i) => !tokenNamesModule(t, m.module) || ((tokens.length === i + 2 && tokens[i + 1] === m.unless) || (m.alsoUnless && JSON.stringify(tokens.slice(i + 1)) === JSON.stringify(m.alsoUnless))));
71
+ });
72
+
73
+ const onlyStaticInvocations = (source, mode) => shellSource(source)
74
+ .split(/\n|;|&&?|\|\|?|\$\(|<\(|>\(|`/)
75
+ .every((segment) => {
76
+ const words = shellTokens(segment);
77
+ const first = words[0];
78
+ // A path in a file test or dependency list is data, not a CLI invocation.
79
+ if (["for", "printf", "echo", "[", "test"].includes(first) || (first === "if" && words.slice(1, 3).some((w) => ["[", "test"].includes(w)))) return true;
80
+ return words.every((word, i) => !tokenNamesModule(word, mode.module) || (mode.subcommand && words[i + 1] !== mode.subcommand) || words[i + (mode.subcommand ? 2 : 1)] === mode.requiresStatic);
81
+ });
42
82
 
43
83
  export function auditRecipeSource(source) {
44
- const tokens = shellTokens(stripShellComments(source));
45
- return NETWORK_MODES.filter((m) => tokens.some((t) => tokenNamesModule(t, m.module)) && tokens.some((t) => tokenIsFlag(t, m.flag)));
84
+ const tokens = shellTokens(shellSource(source));
85
+ const network = NETWORK_MODES.filter((m) =>
86
+ m.flag ? [m, ...(m.aliases ?? [])].some((s) => tokens.some((t, i) => tokenNamesModule(t, s.module) && (!s.subcommand || tokens[i + 1] === s.subcommand))) && tokens.some((t) => tokenIsFlag(t, m.flag)) : !onlySafeInvocations(source, m),
87
+ );
88
+ const local = LOCAL_RECORD_MODES.filter((m) => !onlyStaticInvocations(source, m));
89
+ return [...network, ...local];
46
90
  }
47
91
 
48
92
  export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
@@ -72,9 +116,11 @@ export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
72
116
  const findings = [];
73
117
  const read = [];
74
118
  for (const r of recipes) {
75
- const script = String(r.run ?? "")
76
- .split(/\s+/)
77
- .find((t) => t.endsWith(".sh"));
119
+ const words = String(r.run ?? "").split(/\s+/).filter(Boolean);
120
+ // Direct CLI commands are graded as shell text; referenced shell scripts are read too.
121
+ const bare = words[0] === "portulan";
122
+ const direct = bare || (words[0] === "node" && !words.some((t) => t.endsWith(".sh")));
123
+ const script = bare ? "cli/portulan.mjs" : direct ? words.find((t) => /\.[cm]?js$/.test(t)) : words.find((t) => t.endsWith(".sh"));
78
124
  if (!script) {
79
125
  return { ok: false, why: `recipe ${JSON.stringify(r.id)} has no readable script in its run line (${JSON.stringify(r.run ?? null)}); this audit cannot grade what it cannot read` };
80
126
  }
@@ -89,7 +135,10 @@ export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
89
135
  return { ok: false, why: `recipe ${JSON.stringify(r.id)} names ${script}, which could not be read — ${cause.message}` };
90
136
  }
91
137
  read.push(r.id);
92
- for (const hit of auditRecipeSource(source)) findings.push({ recipe: r.id, script, ...hit });
138
+ const hits = [...auditRecipeSource(r.run), ...(direct ? [] : auditRecipeSource(source))];
139
+ for (const [i, hit] of hits.entries()) {
140
+ if (!hits.slice(0, i).some((previous) => previous.module === hit.module && previous.flag === hit.flag && previous.requiresStatic === hit.requiresStatic)) findings.push({ recipe: r.id, script, ...hit });
141
+ }
93
142
  }
94
143
  return { ok: true, examined: read, findings };
95
144
  }
@@ -157,7 +206,7 @@ export const PRODUCERS = {
157
206
  return [
158
207
  { name: "portulan.review.pull_requests", unit: "{pull_request}", description: "Merged pull requests in the metered window.", value: m.pullRequests },
159
208
  { name: "portulan.review.submissions", unit: "{submission}", description: "Reviews the reviewer submitted across the window. Submission units, never fix-rounds.", value: m.submissions },
160
- { name: "portulan.review.submissions_per_pull_request", unit: "1", description: "The figure a-review-loop-needs-a-bound.md bounds the loop on.", value: m.submissionsPerPullRequest },
209
+ { name: "portulan.review.submissions_per_pull_request", unit: "1", description: "The figure the retired a-review-loop-needs-a-bound.md bounded the loop on.", value: m.submissionsPerPullRequest },
161
210
  { name: "portulan.review.submissions_no_inline", unit: "{submission}", description: "Submissions raising no inline comment.", value: m.noInline },
162
211
  { name: "portulan.review.no_inline_rate", unit: "1", description: "An UPPER BOUND on the found-nothing rate; a submission carrying only suppressed notes counts in it.", value: m.noInlineRate },
163
212
  { name: "portulan.review.pushes", unit: "{push}", description: "Distinct heads the reviewer saw — a floor on pushes, not a count of them.", value: m.pushes },
@@ -336,7 +385,7 @@ const USAGE = [
336
385
  " first: OTEL_EXPORTER_OTLP_METRICS_ENDPOINT (used as given) else",
337
386
  " OTEL_EXPORTER_OTLP_ENDPOINT (a base, +/v1/metrics); and",
338
387
  " OTEL_EXPORTER_OTLP_METRICS_HEADERS replacing OTEL_EXPORTER_OTLP_HEADERS",
339
- " --audit-recipes assert that no recipe the workspace YIELDS can reach a network mode;",
388
+ " --audit-recipes assert that no recipe the workspace YIELDS can reach a network mode or read local usage records;",
340
389
  " takes --workspace and --pack-root, and needs no --config",
341
390
  "",
342
391
  "exit 0 the thing asked for happened · 1 a verdict (golden drift, or export while opted out)",
@@ -403,16 +452,16 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
403
452
  return 2;
404
453
  }
405
454
  if (res.findings.length > 0) {
406
- io.error(`telemetry: ${res.findings.length} verify recipe(s) can reach the network:`);
407
- for (const f of res.findings) io.error(` - ${f.recipe} (${f.script}) invokes ${f.module} ${f.flag}, which ${f.what}`);
408
- io.error("A verify recipe may not make a network call: spec/slots.md and .portulan/verify/README.md");
455
+ io.error(`telemetry: ${res.findings.length} verify recipe(s) can reach the network or local usage records:`);
456
+ for (const f of res.findings) io.error(` - ${f.recipe} (${f.script}) invokes ${modeSpelling(f)}, which ${f.what}`);
457
+ io.error("A verify recipe may not make a network call or read local usage records: spec/slots.md and .portulan/verify/README.md");
409
458
  io.error("both prohibit it, because a rail that moves with the network goes red about the world");
410
459
  io.error("rather than about the tree.");
411
460
  return 1;
412
461
  }
413
- io.log(`telemetry: ${res.examined.length} yielded recipe(s) examined; none reaches a network mode`);
414
- io.log(` Modes railed: ${NETWORK_MODES.map((m) => `${m.module} ${m.flag}`).join(", ")}.`);
415
- io.log(" A network mode with no row here is UNRAILED, and nothing can audit that a row was added.");
462
+ io.log(`telemetry: ${res.examined.length} yielded recipe(s) examined; none reaches a network mode or reads local usage records`);
463
+ io.log(` Modes railed: ${[...NETWORK_MODES, ...LOCAL_RECORD_MODES].map(modeSpelling).join(", ")}.`);
464
+ io.log(" A network or local-record mode with no row here is UNRAILED, and nothing can audit that a row was added.");
416
465
  return 0;
417
466
  }
418
467
 
package/cli/upgrade.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  // Exit 0 succeeded · 1 a step owed under `--check` or left by hand, a red workspace, an unresolved pointer · 2 could not run.
5
5
 
6
6
  import fs from "node:fs";
7
+ import { assertNoSourceJournal } from "./instructions-journal.mjs";
7
8
  import path from "node:path";
8
9
  import { fileURLToPath, pathToFileURL } from "node:url";
9
10
 
@@ -12,6 +13,7 @@ import { resolveGovernor } from "./discover.mjs";
12
13
  import { gitIn } from "./form.mjs";
13
14
  import { offerLines } from "./sessions.mjs";
14
15
  import { walk } from "./vendor.mjs";
16
+ import { atomicWrite,guardedRemove,readOutput,sameContent } from "./guidance-files.mjs";
15
17
 
16
18
  const HERE = path.dirname(fileURLToPath(import.meta.url));
17
19
  const BUNDLE = path.resolve(HERE, "..");
@@ -19,10 +21,10 @@ const MIGRATIONS = path.join(BUNDLE, "spec", "migrations");
19
21
 
20
22
  let stagingSeq = 0;
21
23
 
22
- /** Everything that means `upgrade` could not run. Carries no verdict about a workspace. */
24
+ /** Unavailable, without a verdict about the workspace. */
23
25
  export class UpgradeError extends Error {}
24
26
 
25
- /** The Workspace Definition version this bundle implements, read from its schema's `$id`. */
27
+ /** This bundle's Workspace Definition version from the schema `$id`. */
26
28
  export function bundleSpec({ schemaPath } = {}) {
27
29
  const file = schemaPath ?? path.join(BUNDLE, "spec", "workspace.schema.json");
28
30
  let text;
@@ -68,7 +70,7 @@ export async function loadSteps({ dir = MIGRATIONS } = {}) {
68
70
  return steps;
69
71
  }
70
72
 
71
- /** Read a workspace into the view a step is given, as `spec/migrations/README.md` defines it. */
73
+ /** The step's workspace view; contract: `spec/migrations/README.md`. */
72
74
  export function readWorkspace(dir) {
73
75
  const root = path.resolve(dir);
74
76
  const file = path.join(root, "workspace.json");
@@ -120,7 +122,7 @@ export function readWorkspace(dir) {
120
122
  };
121
123
  }
122
124
 
123
- /** The repository the manifest's `tree` names, as a step reads it; `null` where it names no directory. */
125
+ /** The step's declared repository view, or null without a tree. */
124
126
  export function repositoryView(root, manifest) {
125
127
  if (typeof manifest?.tree !== "string") return null;
126
128
  const dir = path.resolve(root, manifest.tree);
@@ -223,7 +225,7 @@ export async function planFor(ws, ctx, steps) {
223
225
  } catch (error) {
224
226
  answer = { owed: null, because: `${step.id} threw while deciding whether it is owed — ${error.message}` };
225
227
  }
226
- // `hand`: owed, and placed by a person rather than by the step, so `--write` reports it and runs on.
228
+ // `hand` requires a person; report it and continue.
227
229
  entries.push({ step, owed: answer.owed, because: answer.because, hand: answer.owed === true && answer.hand === true });
228
230
  if (answer.owed === null) unknown += 1;
229
231
  else if (answer.owed === true) owed += 1;
@@ -231,14 +233,14 @@ export async function planFor(ws, ctx, steps) {
231
233
  return { entries, owed, unknown };
232
234
  }
233
235
 
234
- /** `rel` resolved inside `root`, or `null` if it escapes: `path.join` would pass an absolute `rel` as inside. */
236
+ /** Resolve inside root, rejecting escapes. */
235
237
  export function inside(root, rel) {
236
238
  const resolved = path.resolve(root, rel);
237
239
  if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) return null;
238
240
  return resolved;
239
241
  }
240
242
 
241
- /** A reason to refuse, or `null`; `root` itself is not checked, since macOS's `os.tmpdir()` runs through `/var`. */
243
+ /** Refusal or null; root may itself follow macOS's `/var` alias. */
242
244
  function linkOnPath(root, resolved) {
243
245
  const rel = path.relative(root, resolved);
244
246
  if (rel === "") return null;
@@ -257,7 +259,7 @@ function linkOnPath(root, resolved) {
257
259
  return null;
258
260
  }
259
261
 
260
- /** `dirs` as created, shallowest first; one that will not go is not empty, and neither are its parents. */
262
+ /** Remove created directories deepest first, stopping at a nonempty one. */
261
263
  function unwindDirs(dirs) {
262
264
  for (const made of [...dirs].reverse()) {
263
265
  try {
@@ -275,6 +277,7 @@ export function applyEdits(dir, edits, options = {}) {
275
277
  const treeRoot = typeof options.treeDir === "string" ? path.resolve(options.treeDir) : null;
276
278
  const snapshots = [];
277
279
  for (const edit of edits) {
280
+ try{options.beforeMutation?.(edit);}catch(error){return {ok:false,snapshots,reason:error.message};}
278
281
  if (edit.root !== undefined && edit.root !== "workspace" && edit.root !== "tree") {
279
282
  return { ok: false, snapshots, reason: `an edit names root \`${edit.root}\`, which is neither the workspace nor the tree` };
280
283
  }
@@ -294,29 +297,35 @@ export function applyEdits(dir, edits, options = {}) {
294
297
  if (linked !== null) return { ok: false, snapshots, reason: linked };
295
298
 
296
299
  let previous = null;
297
- let mode = null;
300
+ let mode = null, observedIdentity = null;
298
301
  try {
299
- previous = fs.readFileSync(file, "utf8");
300
- mode = fs.lstatSync(file).mode;
302
+ const at=fs.lstatSync(file);
303
+ if(!at.isFile()) return {ok:false,snapshots,reason:`${file} is not a regular file`};
304
+ if(edit.nested && edit.next!==null && at.nlink!==1) return {ok:false,snapshots,reason:`${file} is a multiply linked guidance output`};
305
+ previous = readOutput(file,root);
306
+ const after=fs.lstatSync(file);
307
+ if(after.dev!==at.dev||after.ino!==at.ino||after.mode!==at.mode)return {ok:false,snapshots,reason:`${file} changed while its original was read`};
308
+ mode=at.mode;observedIdentity={dev:at.dev,ino:at.ino,mode:at.mode};
301
309
  } catch (error) {
302
310
  if (error.code !== "ENOENT") {
303
311
  return { ok: false, snapshots, reason: `${file} could not be read before writing — ${error.code ?? error.message}` };
304
312
  }
305
313
  }
306
314
 
315
+ if (Object.hasOwn(edit,"before") && !sameContent(previous,edit.before)) return {ok:false,snapshots,reason:`${file} changed after guidance planning`};
316
+ if(edit.beforeIdentity){const at=fs.lstatSync(file,{throwIfNoEntry:false});if(!at||at.dev!==edit.beforeIdentity.dev||at.ino!==edit.beforeIdentity.ino||at.mode!==edit.beforeIdentity.mode)return {ok:false,snapshots,reason:`${file} changed identity after source planning`};}
307
317
  if (edit.next === null) {
308
318
  if (previous === null) continue;
309
319
  if (!fs.lstatSync(file).isFile()) return { ok: false, snapshots, reason: `${file} is not a regular file — refusing to delete it` };
310
320
  try {
311
- fs.rmSync(file);
321
+ guardedRemove(file,{before:previous,identity:edit.beforeIdentity??observedIdentity,allowLinks:Boolean(edit.claimBase),claimBase:edit.claimBase,onClaim:data=>options.journal?.(edit,"claim",data),onRemove:claim=>{snapshots.push({file:edit.file,root:edit.root,previous,mode,created:[],deleted:true,after:null,identity:null,cleanup:[claim]});options.journal?.(edit,"retired",claim);}});
312
322
  } catch (error) {
313
323
  return { ok: false, snapshots, reason: `${file} could not be deleted — ${error.code ?? error.message}` };
314
324
  }
315
- snapshots.push({ file: edit.file, root: edit.root, previous, mode, created: [], deleted: true });
316
325
  continue;
317
326
  }
318
327
 
319
- // `lstat`, not `existsSync`, which follows links and answers `false` for a path it could not read.
328
+ // lstat distinguishes links and unreadable paths from absence.
320
329
  const wanted = [];
321
330
  for (let at = path.dirname(file); at.startsWith(root) && at !== root; at = path.dirname(at)) {
322
331
  try {
@@ -330,7 +339,7 @@ export function applyEdits(dir, edits, options = {}) {
330
339
  }
331
340
  }
332
341
 
333
- // No snapshot owns these until the rename lands, so every failure before it unwinds them.
342
+ // Unwind this edit's created directories if publication fails.
334
343
  const created = [];
335
344
  try {
336
345
  for (const dir of wanted) {
@@ -338,7 +347,7 @@ export function applyEdits(dir, edits, options = {}) {
338
347
  fs.mkdirSync(dir);
339
348
  created.push(dir);
340
349
  } catch (error) {
341
- // Made by another since the probe: fine if a directory, and not this run's to remove.
350
+ // A concurrently created directory is not ours to remove.
342
351
  if (error.code !== "EEXIST" || !fs.lstatSync(dir).isDirectory()) throw error;
343
352
  }
344
353
  }
@@ -347,24 +356,17 @@ export function applyEdits(dir, edits, options = {}) {
347
356
  return { ok: false, snapshots, reason: `${path.dirname(file)} could not be created — ${error.code ?? error.message}` };
348
357
  }
349
358
 
350
- // Unique per process and edit, and beside its file so the rename stays atomic.
359
+ // Stage each edit beside its target for exclusive publication.
351
360
  const staging = `${file}.portulan-upgrade.${process.pid}.${stagingSeq++}`;
361
+ let published;
352
362
  try {
353
- // `wx`: anything already at the staging path, a planted symlink included, fails rather than being followed.
354
- write(staging, edit.next, { flag: "wx" });
363
+ // Exclusive creation refuses existing files and symlinks.
355
364
  const keep = mode ?? edit.mode ?? null;
356
- if (keep !== null) fs.chmodSync(staging, keep);
357
- fs.renameSync(staging, file);
365
+ published=atomicWrite(file,edit.next,{exclusive:edit.exclusive??previous===null,before:previous,identity:edit.beforeIdentity??observedIdentity??undefined,mode:keep,write,stagingPath:edit.nested?undefined:staging,onPrepare:data=>options.journal?.(edit,"stage",data),onClaim:data=>options.journal?.(edit,"claim",data),onCleanupClaim:data=>options.journal?.(edit,"undo-claim",data),onPublish:(id,cleanup)=>snapshots.push({file:edit.file,root:edit.root,previous,mode,created,after:edit.next,identity:{dev:id.dev,ino:id.ino,mode:id.mode},cleanup})});
358
366
  } catch (error) {
359
- try {
360
- fs.rmSync(staging, { force: true });
361
- } catch {
362
- /* the staging file is the lesser problem; the write failure is what gets reported */
363
- }
364
367
  unwindDirs(created);
365
368
  return { ok: false, snapshots, reason: `${file} could not be written — ${error.code ?? error.message}` };
366
369
  }
367
- snapshots.push({ file: edit.file, root: edit.root, previous, mode, created });
368
370
  }
369
371
  return { ok: true, snapshots };
370
372
  }
@@ -374,9 +376,12 @@ export function restore(dir, snapshots, options = {}) {
374
376
  const treeRoot = typeof options.treeDir === "string" ? path.resolve(options.treeDir) : null;
375
377
  const restored = [];
376
378
  const failed = [];
377
- // Last edit first: a directory two edits share is recorded on the first, and empties only after the second.
379
+ const restoredIdentities=new Map();
380
+ // Reverse order empties shared directories before their owner removes them.
378
381
  for (const snapshot of [...snapshots].reverse()) {
379
- // Contained again, as `restore` is exported and a snapshot may come from any caller.
382
+ if(options.stopOnFailure&&failed.length){failed.push(snapshot.file);continue;}
383
+ try{options.beforeMutation?.(snapshot);}catch{failed.push(snapshot.file);continue;}
384
+ // Exported restore must recheck containment of caller-supplied snapshots.
380
385
  const root = snapshot.root === "tree" ? treeRoot : workspaceRoot;
381
386
  const file = root === null ? null : inside(root, snapshot.file);
382
387
  if (file === null || linkOnPath(root, file) !== null) {
@@ -384,15 +389,27 @@ export function restore(dir, snapshots, options = {}) {
384
389
  continue;
385
390
  }
386
391
  try {
392
+ for(const item of snapshot.cleanup??[])if(typeof item.file!=="string"||inside(root,path.relative(root,item.file))!==item.file||linkOnPath(root,item.file)!==null)throw new UpgradeError("cleanup journal is outside its transaction root or aliases another path");
393
+ const at=fs.lstatSync(file,{throwIfNoEntry:false});
394
+ const aliases=(snapshot.cleanup??[]).filter(item=>{try{const linked=fs.lstatSync(item.file);return linked.isFile()&&linked.dev===at?.dev&&linked.ino===at?.ino&&sameContent(readOutput(item.file,root),item.before);}catch{return false;}});
395
+ const allowLinks=Boolean(at&&at.nlink>1&&at.nlink===aliases.length+1);
396
+ if(at && (!at.isFile() || at.isSymbolicLink() || (at.nlink!==1&&!allowLinks))) throw new UpgradeError(`${file} is not an ordinary singly linked restore target`);
397
+ const current=at ? readOutput(file,root) : null;
398
+ if(Object.hasOwn(snapshot,"after")) {
399
+ const expected=restoredIdentities.has(file) ? restoredIdentities.get(file) : snapshot.identity;
400
+ if(!sameContent(current,snapshot.after) || (expected && (!at || at.dev!==expected.dev || at.ino!==expected.ino || (expected.mode!==undefined&&at.mode!==expected.mode)))) throw new UpgradeError(`${file} changed since this transaction wrote it`);
401
+ }
402
+ let replacedIdentity=null;
387
403
  if (snapshot.previous === null) {
388
- fs.rmSync(file, { force: true });
389
- unwindDirs(snapshot.created ?? []);
404
+ if(at)guardedRemove(file,{before:current,identity:{dev:at.dev,ino:at.ino,mode:at.mode},allowLinks,onClaim:data=>options.journal?.(snapshot,"undo-claim",data),onRemove:claim=>options.journal?.(snapshot,"retired",claim)});
390
405
  } else {
391
- const at = fs.lstatSync(file, { throwIfNoEntry: false });
392
- if (at?.isSymbolicLink()) throw new UpgradeError(`${file} is a symlink — refusing to restore through it`);
393
- fs.writeFileSync(file, snapshot.previous);
394
- if (snapshot.mode !== null && snapshot.mode !== undefined) fs.chmodSync(file, snapshot.mode);
406
+ replacedIdentity=atomicWrite(file,snapshot.previous,{exclusive:!at,before:current,identity:at ? {dev:at.dev,ino:at.ino,mode:at.mode} : undefined,allowLinks,mode:snapshot.mode,onPrepare:data=>options.journal?.(snapshot,"undo-stage",data),onClaim:data=>options.journal?.(snapshot,"undo-claim",data),onCleanupClaim:data=>options.journal?.(snapshot,"claim",data)});
395
407
  }
408
+ for(const item of snapshot.cleanup??[])if(fs.lstatSync(item.file,{throwIfNoEntry:false}))guardedRemove(item.file,{before:item.before,identity:item.identity,allowLinks:true,claimBase:path.basename(file)});
409
+ if(snapshot.previous===null)unwindDirs(snapshot.created??[]);
410
+ const replaced=fs.lstatSync(file,{throwIfNoEntry:false});
411
+ if(replacedIdentity?(!replaced||replaced.dev!==replacedIdentity.dev||replaced.ino!==replacedIdentity.ino||replaced.mode!==replacedIdentity.mode):Boolean(replaced))throw new UpgradeError(`${file} changed during rollback publication`);
412
+ restoredIdentities.set(file,replacedIdentity);
396
413
  restored.push(snapshot.file);
397
414
  } catch {
398
415
  failed.push(snapshot.file);
@@ -497,7 +514,7 @@ export async function run(argv = [], options = {}) {
497
514
  return 2;
498
515
  }
499
516
 
500
- // ---- which workspace, and in which residence
517
+ // Resolve target and residence.
501
518
  const target = await resolveTarget(positional[0], { env });
502
519
  if (target.state === "could-not-look") {
503
520
  warn(`upgrade: ${target.sentence}`);
@@ -524,6 +541,7 @@ export async function run(argv = [], options = {}) {
524
541
  return read.code;
525
542
  }
526
543
  const ws = read.ws;
544
+ try{assertNoSourceJournal(ws.dir);if(ws.repository)assertNoSourceJournal(ws.repository.dir);}catch(error){warn(`upgrade: ${error.message}`);return 2;}
527
545
  const relative = path.relative(process.cwd(), ws.dir);
528
546
  const shown = relative && relative.length < ws.dir.length ? relative : ws.dir;
529
547
 
@@ -533,7 +551,7 @@ export async function run(argv = [], options = {}) {
533
551
  for (const line of offerLines()) say(`upgrade: ${line}`);
534
552
  };
535
553
 
536
- // ---- which direction is this workspace off in
554
+ // Classify version drift.
537
555
  const declared = /^([0-9]+)\.([0-9]+)$/.exec(ws.manifest?.portulan?.spec ?? "");
538
556
  if (!declared) {
539
557
  warn(`upgrade: ${shown} declares no readable \`portulan.spec\` — refusing to guess which contract it was written against`);
@@ -551,7 +569,7 @@ export async function run(argv = [], options = {}) {
551
569
  return 2;
552
570
  }
553
571
 
554
- // ---- the pre-state gate, which can only ever apply to a gradeable workspace
572
+ // Pre-state gate requires a gradeable workspace.
555
573
  if (!behindByMajor) {
556
574
  let pre;
557
575
  try {
@@ -572,7 +590,7 @@ export async function run(argv = [], options = {}) {
572
590
  }
573
591
  }
574
592
 
575
- // ---- the plan
593
+ // Plan.
576
594
  const ctx = { bundle: BUNDLE, spec, tree, today: options.today ?? new Date().toISOString().slice(0, 10) };
577
595
  const plan = await planFor(ws, ctx, steps);
578
596
 
@@ -622,10 +640,10 @@ export async function run(argv = [], options = {}) {
622
640
  return 0;
623
641
  }
624
642
 
625
- // ---- apply, one step at a time, re-reading between them
643
+ // Apply and reread after each step.
626
644
  let snapshots = [];
627
645
  let current = ws;
628
- // A step may declare the tree but none moves it, so every snapshot was made in the latest one.
646
+ // Steps can declare a tree, but never move it.
629
647
  let treeDir = ws.repository?.dir ?? null;
630
648
  const undo = () => {
631
649
  const result = restore(current.dir, snapshots, { treeDir });
@@ -640,7 +658,7 @@ export async function run(argv = [], options = {}) {
640
658
  const byHand = new Map();
641
659
  const apply = async (entry) => {
642
660
  applied.add(entry.step.id);
643
- // A step's `plan` is foreign code: a throw becomes a refusal, so the rollback still runs.
661
+ // Foreign plan failures must reach rollback.
644
662
  let planned;
645
663
  try {
646
664
  planned = await entry.step.plan(current, ctx);
@@ -677,7 +695,7 @@ export async function run(argv = [], options = {}) {
677
695
  treeDir = current.repository?.dir ?? treeDir;
678
696
  return null;
679
697
  };
680
- // A step can make an earlier one owed, so the chain is asked again until a pass applies nothing.
698
+ // Revisit earlier steps until a pass applies nothing.
681
699
  for (let pass = 0; ; pass++) {
682
700
  let appliedNow = 0;
683
701
  for (const entry of plan.entries) {
@@ -712,7 +730,7 @@ export async function run(argv = [], options = {}) {
712
730
  }
713
731
  }
714
732
 
715
- // ---- and grade what it produced, with the real validator
733
+ // Grade with the real validator.
716
734
  let post;
717
735
  try {
718
736
  post = await inspect(current.dir);