@sleepy_panda_srl/portulan 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +15 -4
  2. package/cli/README.md +55 -3
  3. package/cli/advisory.mjs +167 -53
  4. package/cli/cache-risk.mjs +69 -0
  5. package/cli/comments.mjs +8 -5
  6. package/cli/compile.mjs +492 -146
  7. package/cli/context.mjs +204 -43
  8. package/cli/doctor.mjs +211 -33
  9. package/cli/drills.mjs +32 -7
  10. package/cli/finish.md +34 -0
  11. package/cli/finish.mjs +8 -67
  12. package/cli/form.mjs +34 -18
  13. package/cli/frontmatter.mjs +39 -0
  14. package/cli/gate.mjs +14 -12
  15. package/cli/guidance-files.mjs +335 -0
  16. package/cli/handoff-policy.mjs +76 -0
  17. package/cli/html-entities.mjs +2130 -0
  18. package/cli/import-files.mjs +15 -0
  19. package/cli/index.mjs +77 -24
  20. package/cli/init-preview.mjs +553 -0
  21. package/cli/init.mjs +261 -46
  22. package/cli/instructions-journal.mjs +1185 -0
  23. package/cli/instructions.mjs +332 -57
  24. package/cli/ledger.mjs +123 -37
  25. package/cli/librarian.mjs +103 -19
  26. package/cli/persona-routing.mjs +209 -0
  27. package/cli/plugin-lint.mjs +77 -56
  28. package/cli/recipe-output.mjs +91 -0
  29. package/cli/recipe-runner.md +68 -0
  30. package/cli/recipe-runner.mjs +131 -0
  31. package/cli/recipe-set.mjs +12 -3
  32. package/cli/ritual-contexts.mjs +103 -0
  33. package/cli/sessions.mjs +2 -1
  34. package/cli/spend-analysis.mjs +111 -0
  35. package/cli/spend-offer.md +53 -0
  36. package/cli/spend-offer.mjs +93 -0
  37. package/cli/stop-diagnostics.mjs +114 -0
  38. package/cli/stop-gate.mjs +133 -90
  39. package/cli/stop-policy.mjs +14 -0
  40. package/cli/symbols.mjs +55 -8
  41. package/cli/telemetry.mjs +19 -12
  42. package/cli/upgrade.mjs +62 -44
  43. package/cli/vendor.mjs +207 -125
  44. package/core/operating/context.md +368 -60
  45. package/core/operating/evolution.md +3 -2
  46. package/core/operating/loop.md +9 -4
  47. package/core/operating/sessions.md +6 -15
  48. package/core/personas/README.md +52 -10
  49. package/core/personas/implementer.md +3 -0
  50. package/core/personas/librarian.md +3 -0
  51. package/core/personas/reviewer.md +3 -0
  52. package/core/templates/handoff.md +6 -2
  53. package/evals/ab/personas/README.md +111 -0
  54. package/evals/ab/personas/acquisition.json +330 -0
  55. package/evals/ab/personas/bindings/implementer.txt +43 -0
  56. package/evals/ab/personas/bindings/librarian.txt +40 -0
  57. package/evals/ab/personas/bindings/reviewer.txt +41 -0
  58. package/evals/ab/personas/corpus.json +69 -0
  59. package/evals/ab/personas/grader.mjs +171 -0
  60. package/evals/ab/personas/implementer.json +774 -0
  61. package/evals/ab/personas/implementer.md +22 -0
  62. package/evals/ab/personas/librarian.json +758 -0
  63. package/evals/ab/personas/librarian.md +22 -0
  64. package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
  65. package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
  66. package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
  67. package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
  68. package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
  69. package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
  70. package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
  71. package/evals/ab/personas/reviewer.json +758 -0
  72. package/evals/ab/personas/reviewer.md +22 -0
  73. package/evals/releases/0.4.0.json +154 -0
  74. package/evals/releases/0.4.0.md +69 -0
  75. package/package.json +6 -2
  76. package/packs/rituals/checkpoints/README.md +35 -0
  77. package/packs/rituals/checkpoints/pack.json +3 -2
  78. package/packs/tools/local-llm/README.md +46 -6
  79. package/packs/tools/local-llm/runner/local-llm.mjs +127 -13
  80. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +26 -2
  81. package/spec/README.md +37 -4
  82. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
  83. package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
  84. package/spec/migrations/README.md +6 -0
  85. package/spec/pack.schema.json +12 -3
  86. package/spec/slots.md +262 -117
  87. package/spec/workspace.schema.json +67 -11
package/cli/finish.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
- import { spawn, spawnSync } from "node:child_process";
11
+ import { spawnSync } from "node:child_process";
12
12
  import { pathToFileURL } from "node:url";
13
13
 
14
14
  import { AUTO, discoverPackRoots, namedWithAuto } from "./discover.mjs";
@@ -16,19 +16,15 @@ import { packRoots, resolvePack } from "./compile.mjs";
16
16
  import { CHANGES_DIR, CHANGES_README } from "./form.mjs";
17
17
  import { CHANGE_NAME } from "./index.mjs";
18
18
  import { recipeSet, resolverFor } from "./recipe-set.mjs";
19
+ import { INTERRUPTIONS, runRecipe, tailKeeper } from "./recipe-runner.mjs";
20
+ export { runRecipe, tailKeeper };
19
21
 
20
22
  export const RECIPE_TIMEOUT_MS = 10 * 60 * 1000;
21
23
 
22
24
  export const TAIL_LINES = 25;
23
25
 
24
- const TAIL_BYTES = 64 * 1024;
25
-
26
- const DRAIN_MS = 1000;
27
-
28
26
  const GIT_TIMEOUT_MS = 2 * 60 * 1000;
29
27
 
30
- const CANNOT_RUN = new Set([2, 126, 127]);
31
-
32
28
  const USAGE = [
33
29
  "usage: node cli/finish.mjs -m <subject> [-m <paragraph>]... [options]",
34
30
  " node cli/finish.mjs -F <file | -> [options]",
@@ -124,6 +120,9 @@ const textOf = (thrown) => {
124
120
  };
125
121
 
126
122
  const resultOf = (id, r) => {
123
+ if (INTERRUPTIONS.includes(r?.interrupted)) {
124
+ return { id, outcome: "could not run", code: null, interrupted: r.interrupted, output: typeof r.output === "string" ? r.output : `interrupted by ${r.interrupted}` };
125
+ }
127
126
  if (r?.outcome === "green") return { id, outcome: "green" };
128
127
  if (r?.outcome === "red" || r?.outcome === "could not run") {
129
128
  return { id, outcome: r.outcome, code: Number.isInteger(r.code) ? r.code : null, output: typeof r.output === "string" ? r.output : "" };
@@ -206,65 +205,6 @@ export function recipesOf({ root, workspaceDir, named, forced }) {
206
205
  return set.ok ? { recipes: set.recipes } : { why: set.reason };
207
206
  }
208
207
 
209
- /** A stream's last `limit` bytes, kept as it arrives: what is held never passes `limit` and one chunk. */
210
- export function tailKeeper(limit = TAIL_BYTES) {
211
- const chunks = [];
212
- let held = 0;
213
- return {
214
- add(chunk) {
215
- chunks.push(chunk);
216
- held += chunk.length;
217
- while (held - chunks[0].length >= limit) held -= chunks.shift().length;
218
- },
219
- get held() {
220
- return held;
221
- },
222
- text: () => Buffer.concat(chunks).subarray(-limit).toString("utf8"),
223
- };
224
- }
225
-
226
- export function runRecipe(recipe, { root, env, timeout = RECIPE_TIMEOUT_MS }) {
227
- return new Promise((resolve) => {
228
- const kept = tailKeeper();
229
- let failure = null;
230
- let late = false;
231
- let limit;
232
- let drain;
233
- const settle = (status) => {
234
- clearTimeout(limit);
235
- clearTimeout(drain);
236
- const code = failure || late ? null : status;
237
- if (code === 0) return resolve({ id: recipe.id, outcome: "green" });
238
- const why = failure ? `\n${failure.message}` : late ? `\nkilled at its time limit of ${timeout / 1000} s` : "";
239
- const cannot = code === null || CANNOT_RUN.has(code);
240
- resolve({ id: recipe.id, outcome: cannot ? "could not run" : "red", code, output: `${kept.text()}${why}` });
241
- };
242
- // As CI and the Stop-gate run a recipe, its stderr joined to its stdout so its lines keep their order.
243
- const child = spawn("bash", ["-c", `exec 2>&1\n${recipe.run}`], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] });
244
- const stop = () => {
245
- child.stdout.destroy();
246
- child.stderr.destroy();
247
- };
248
- child.stdout.on("data", kept.add);
249
- child.stderr.on("data", kept.add);
250
- child.on("exit", () => {
251
- clearTimeout(limit);
252
- // A process the shell left running is not waited for: once the pipe closes, the child's next write kills it with SIGPIPE, or fails with EPIPE where it ignores that signal, and a child that never writes keeps running.
253
- drain = setTimeout(stop, DRAIN_MS);
254
- });
255
- child.on("error", (error) => {
256
- failure = error;
257
- settle(null);
258
- });
259
- child.on("close", settle);
260
- limit = setTimeout(() => {
261
- late = true;
262
- child.kill();
263
- stop();
264
- }, timeout);
265
- });
266
- }
267
-
268
208
  /** Resolves to `{ code, lines }`: the exit code and what to print, the first line saying what happened. */
269
209
  export async function finish(options, { cwd = process.cwd(), env = process.env, readStdin, runOne = runRecipe } = {}) {
270
210
  const stop = (code, ...lines) => ({ code, lines: [`finish: ${lines[0]}`, ...lines.slice(1)] });
@@ -374,10 +314,11 @@ export async function finish(options, { cwd = process.cwd(), env = process.env,
374
314
  } catch (error) {
375
315
  results.push({ id: recipe.id, outcome: "could not run", code: null, output: textOf(error) });
376
316
  }
317
+ if (results.at(-1).interrupted) break;
377
318
  }
378
319
  const failed = results.filter((r) => r.outcome !== "green");
379
320
  if (failed.length) {
380
- const code = failed.some((r) => r.outcome === "red") ? 1 : 2;
321
+ const code = failed.some((r) => r.interrupted) ? 2 : failed.some((r) => r.outcome === "red") ? 1 : 2;
381
322
  const named = failed.map((r) => r.id).join(", ");
382
323
  return stop(
383
324
  code,
package/cli/form.mjs CHANGED
@@ -10,6 +10,7 @@ import { BOOT_CARD_LINE, BOOT_CARD_UNIT, GUIDANCE_RULES_DIR, leadsOfText } from
10
10
  import { CHANGE_SECTIONS, readChanges, renderChanges } from "./index.mjs";
11
11
  import { instructionsState, shellWord, splitCommand } from "./instructions.mjs";
12
12
  import { recipeSet } from "./recipe-set.mjs";
13
+ import { isInside } from "./inside.mjs";
13
14
 
14
15
  /** Anything that means the form could not be read. Carries no verdict. */
15
16
  export class FormError extends Error {}
@@ -40,12 +41,23 @@ function present(file) {
40
41
  }
41
42
  }
42
43
 
43
- function readOrNull(file) {
44
+ function readOrNull(file, boundary) {
44
45
  try {
45
- return fs.readFileSync(file, "utf8");
46
+ fs.lstatSync(file);
46
47
  } catch (cause) {
47
48
  if (cause.code === "ENOENT" || cause.code === "ENOTDIR") return null;
48
- throw new FormError(`${file} could not be read — ${cause.code ?? cause.message}`);
49
+ throw new FormError(`${file} could not be inspected — ${cause.code ?? "read error"}`);
50
+ }
51
+ try {
52
+ const real = fs.realpathSync(file);
53
+ if (!isInside(fs.realpathSync(boundary), real)) throw new FormError(`${file} is outside the form's source boundary`);
54
+ if (!fs.statSync(real).isFile()) throw new FormError(`${file} is not a regular form source`);
55
+ const raw = fs.readFileSync(real), text = raw.toString("utf8");
56
+ if (!Buffer.from(text, "utf8").equals(raw)) throw new FormError(`${file} is not valid UTF-8`);
57
+ return text;
58
+ } catch (cause) {
59
+ if (cause instanceof FormError) throw cause;
60
+ throw new FormError(`${file} could not be read — ${cause.code ?? "read error"}`);
49
61
  }
50
62
  }
51
63
 
@@ -87,7 +99,9 @@ export function handoffsReadme() {
87
99
  A handoff carries work still open, so the next session starts from where things stand. Write one only
88
100
  when a session ends with work not committed and pushed: committed work carries its why in its commit
89
101
  message, and needs none. Name it \`YYYY-MM-DD-<slug>.md\`, keep only the parts that have something to say,
90
- and five lines is a valid handoff. Drafted from the engine's \`core/templates/handoff.md\`.
102
+ and five lines is a valid handoff. The H1 names the outcome; the filename already gives its date.
103
+ Link measured figures and durable rules in their owning records. Keep decisions and their why when
104
+ compressing to a declared token cap. Drafted from the engine's \`core/templates/handoff.md\`.
91
105
 
92
106
  **State.** Where things stand: what is committed, and what is in progress and uncommitted.
93
107
 
@@ -108,12 +122,14 @@ export function handoffIndexIgnore(rel, workspaceRel) {
108
122
  }
109
123
 
110
124
  export function withIgnoreLines(text, lines) {
111
- const have = new Set((text ?? "").split(/\r?\n/).map((l) => l.trim()));
112
- const patterns = lines.filter((l) => !l.startsWith("#"));
113
- if (patterns.every((l) => have.has(l))) return text ?? "";
114
- const base = text ?? "";
115
- const sep = base === "" ? "" : base.endsWith("\n\n") ? "" : base.endsWith("\n") ? "\n" : "\n\n";
116
- return `${base}${sep}${lines.join("\n")}\n`;
125
+ const bytes = Buffer.isBuffer(text), base = text ?? "";
126
+ const existing = bytes ? base.toString("latin1") : base;
127
+ const have = new Set(existing.split(/\r?\n/).map(l => bytes ? l.replace(/^[ \t\r]+|[ \t\r]+$/g, "") : l.trim()));
128
+ const patterns = lines.filter(l => !l.startsWith("#"));
129
+ if (patterns.every(l => have.has(bytes ? Buffer.from(l).toString("latin1") : l))) return base;
130
+ const sep = existing === "" || existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
131
+ const addition = `${sep}${lines.join("\n")}\n`;
132
+ return bytes ? Buffer.concat([base, Buffer.from(addition)]) : base + addition;
117
133
  }
118
134
 
119
135
  export const COMMENTS_RECIPE = "comments";
@@ -181,9 +197,9 @@ exit 2
181
197
 
182
198
  // ------------------------------------------------------------------------- git
183
199
 
184
- export function gitIn(root) {
200
+ export function gitIn(root, { env, timeout, noFsmonitor = false } = {}) {
185
201
  const run = (...args) => {
186
- const out = spawnSync("git", ["-C", root, ...args], { encoding: "utf8" });
202
+ const out = spawnSync("git", [...(noFsmonitor ? ["-c", "core.fsmonitor=false"] : []), "-C", root, ...args], { encoding: "utf8", timeout, killSignal: "SIGKILL", env });
187
203
  if (out.error) return { status: null, stdout: "", stderr: String(out.error.code ?? out.error.message) };
188
204
  return { status: out.status, stdout: out.stdout, stderr: out.stderr };
189
205
  };
@@ -296,7 +312,7 @@ export function markdownOnDisk(root, keep = null) {
296
312
  export function sessionLogsIn(root, files) {
297
313
  const found = [];
298
314
  for (const rel of files) {
299
- const text = readOrNull(path.join(root, ...rel.split("/")));
315
+ const text = readOrNull(path.join(root, ...rel.split("/")), root);
300
316
  if (text === null || !text.includes("Session log")) continue;
301
317
  const logs = sessionLogSections(text).filter((s) => s.entries);
302
318
  if (logs.length) found.push({ file: rel, line: logs[0].line });
@@ -567,7 +583,7 @@ export function formOf(workspaceDir, manifest) {
567
583
  const tree = typeof manifest?.tree === "string" ? path.resolve(wsDir, manifest.tree) : null;
568
584
  if (tree === null) return { tree, pieces };
569
585
 
570
- const changelog = readOrNull(path.join(tree, CHANGELOG));
586
+ const changelog = readOrNull(path.join(tree, CHANGELOG), tree);
571
587
  const fragments = present(path.join(tree, CHANGES_README));
572
588
  if (changelog !== null || fragments) add("changes", fragments ? "new" : "today", fragments ? `changelog fragments in ${CHANGES_DIR}/` : `no ${CHANGES_README}`);
573
589
  const entries = changelog === null ? null : unreleasedCount(changelog);
@@ -593,16 +609,16 @@ export function formOf(workspaceDir, manifest) {
593
609
  if (typeof context !== "string") {
594
610
  add("card", "today", "no boot card: `slots.context` is undeclared");
595
611
  } else {
596
- const source = readOrNull(path.resolve(wsDir, context, `${BOOT_CARD_UNIT}.md`));
612
+ const source = readOrNull(path.resolve(wsDir, context, `${BOOT_CARD_UNIT}.md`), wsDir);
597
613
  if (source === null) add("card", "new", "no boot card, by choice: `slots.context` holds no `boot` unit");
598
614
  else if (withReading(source) !== null) add("card", "today", "a boot card drafted before it carried the engine's rules on reading and the cache");
599
615
  else if (!carriesReading(source)) {
600
616
  pieces.push({ id: "card", state: "today", hand: true, text: `a boot card without the engine's rules on reading and the cache, whose head \`upgrade\` does not recognise: add a section holding the line \`${READING_LINE}\`` });
601
- } else if (readOrNull(path.join(tree, COMPILED_CARD)) !== null) add("card", "new", "a compiled boot card");
602
- else if ((readOrNull(path.join(tree, "AGENTS.md")) ?? "").split(/\r?\n/).includes(BOOT_CARD_LINE)) add("card", "new", "a boot card at the head of AGENTS.md, which this host reads");
617
+ } else if (readOrNull(path.join(tree, COMPILED_CARD), tree) !== null) add("card", "new", "a compiled boot card");
618
+ else if ((readOrNull(path.join(tree, "AGENTS.md"), tree) ?? "").split(/\r?\n/).includes(BOOT_CARD_LINE)) add("card", "new", "a boot card at the head of AGENTS.md, which this host reads");
603
619
  else add("card", "today", `a boot card not yet compiled to ${COMPILED_CARD}: run \`portulan compile\``);
604
620
  }
605
- const marked = instructionsState(tree, (rel) => readOrNull(path.join(tree, ...rel.split("/"))));
621
+ const marked = instructionsState(tree, (rel) => readOrNull(path.join(tree, ...rel.split("/")), tree));
606
622
  if (marked.files.length) {
607
623
  const files = marked.files.join(" and ");
608
624
  const count = (n) => `${n} section${n === 1 ? "" : "s"}`;
@@ -0,0 +1,39 @@
1
+ // Flat frontmatter for plugin components and host bindings.
2
+
3
+ const BLOCK_SCALAR = /^[|>][+-]?$/;
4
+
5
+ /** A flat-YAML reader: `fields` is null without a usable block, and `error` is set when that is a defect. */
6
+ export function parseFrontmatter(text) {
7
+ const lines = text.split(/\r?\n/);
8
+ if (lines[0]?.trim() !== "---") return { fields: null };
9
+
10
+ const close = lines.indexOf("---", 1);
11
+ if (close === -1) {
12
+ return { fields: null, error: "the frontmatter block is never closed" };
13
+ }
14
+
15
+ const fields = {};
16
+ const body = lines.slice(1, close);
17
+ for (let i = 0; i < body.length; i += 1) {
18
+ const match = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(body[i]);
19
+ if (!match) continue;
20
+ const key = match[1];
21
+ let value = match[2].trim();
22
+
23
+ if (BLOCK_SCALAR.test(value)) {
24
+ const collected = [];
25
+ while (i + 1 < body.length && (body[i + 1].trim() === "" || /^\s+/.test(body[i + 1]))) {
26
+ i += 1;
27
+ collected.push(body[i].trim());
28
+ }
29
+ value = collected.join(" ").trim();
30
+ } else if (
31
+ (value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
32
+ (value.startsWith("'") && value.endsWith("'") && value.length >= 2)
33
+ ) {
34
+ value = value.slice(1, -1);
35
+ }
36
+ fields[key] = value;
37
+ }
38
+ return { fields };
39
+ }
package/cli/gate.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
- import { fileURLToPath } from "node:url";
10
+ import { pathToFileURL } from "node:url";
11
11
 
12
12
  import { matchesRule, policyPath, packContributions, composeFragments, parse } from "./compile.mjs";
13
13
 
@@ -64,17 +64,19 @@ async function main() {
64
64
  const rule = decide(payload, yielded(policy));
65
65
  if (!rule) stepAside();
66
66
 
67
- const decision = rule.tier === "prohibited" ? "deny" : "ask";
68
- process.stdout.write(
69
- `${JSON.stringify({
70
- hookSpecificOutput: {
71
- hookEventName: "PreToolUse",
72
- permissionDecision: decision,
73
- permissionDecisionReason: `PORTULAN GATE \`${rule.id}\` (${rule.tier}) — ${rule.reason}`,
74
- },
75
- })}\n`,
76
- );
67
+ process.stdout.write(`${gateOutput(rule)}\n`);
77
68
  process.exit(0);
78
69
  }
79
70
 
80
- main().catch(stepAside);
71
+ export function gateOutput(rule) {
72
+ if (!rule) return null;
73
+ return JSON.stringify({
74
+ hookSpecificOutput: {
75
+ hookEventName: "PreToolUse",
76
+ permissionDecision: rule.tier === "prohibited" ? "deny" : "ask",
77
+ permissionDecisionReason: `PORTULAN GATE \`${rule.id}\` (${rule.tier}) — ${rule.reason}`,
78
+ },
79
+ });
80
+ }
81
+
82
+ if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href) main().catch(stepAside);