@sleepy_panda_srl/portulan 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +15 -4
  2. package/cli/README.md +61 -6
  3. package/cli/advisory.mjs +167 -53
  4. package/cli/cache-risk.mjs +69 -0
  5. package/cli/comments.mjs +8 -5
  6. package/cli/compile.mjs +781 -130
  7. package/cli/context.mjs +204 -43
  8. package/cli/doctor.mjs +248 -31
  9. package/cli/drills.mjs +56 -9
  10. package/cli/finish.md +34 -0
  11. package/cli/finish.mjs +8 -67
  12. package/cli/form.mjs +34 -18
  13. package/cli/frontmatter.mjs +39 -0
  14. package/cli/gate.mjs +14 -12
  15. package/cli/guidance-files.mjs +335 -0
  16. package/cli/handoff-policy.mjs +76 -0
  17. package/cli/html-entities.mjs +2130 -0
  18. package/cli/import-files.mjs +15 -0
  19. package/cli/index.mjs +77 -24
  20. package/cli/init-preview.mjs +553 -0
  21. package/cli/init.mjs +261 -46
  22. package/cli/instructions-journal.mjs +1185 -0
  23. package/cli/instructions.mjs +332 -57
  24. package/cli/ledger.mjs +230 -40
  25. package/cli/librarian.mjs +103 -19
  26. package/cli/persona-routing.mjs +209 -0
  27. package/cli/plugin-lint.mjs +77 -56
  28. package/cli/portulan.mjs +1 -1
  29. package/cli/recipe-output.mjs +91 -0
  30. package/cli/recipe-runner.md +68 -0
  31. package/cli/recipe-runner.mjs +131 -0
  32. package/cli/recipe-set.mjs +12 -3
  33. package/cli/review-meter.mjs +14 -9
  34. package/cli/ritual-contexts.mjs +103 -0
  35. package/cli/sessions.mjs +2 -1
  36. package/cli/spend-analysis.mjs +111 -0
  37. package/cli/spend-offer.md +53 -0
  38. package/cli/spend-offer.mjs +93 -0
  39. package/cli/stop-diagnostics.mjs +114 -0
  40. package/cli/stop-gate.mjs +133 -90
  41. package/cli/stop-policy.mjs +14 -0
  42. package/cli/symbols.mjs +55 -8
  43. package/cli/telemetry.mjs +66 -17
  44. package/cli/upgrade.mjs +62 -44
  45. package/cli/vendor.mjs +207 -125
  46. package/core/operating/context.md +401 -18
  47. package/core/operating/evolution.md +3 -2
  48. package/core/operating/loop.md +9 -4
  49. package/core/operating/sessions.md +6 -15
  50. package/core/personas/README.md +75 -0
  51. package/core/personas/implementer.md +3 -0
  52. package/core/personas/librarian.md +3 -0
  53. package/core/personas/reviewer.md +3 -0
  54. package/core/skills/clarify/SKILL.md +1 -1
  55. package/core/skills/consolidate/SKILL.md +1 -1
  56. package/core/templates/handoff.md +6 -2
  57. package/evals/README.md +23 -7
  58. package/evals/ab/personas/README.md +111 -0
  59. package/evals/ab/personas/acquisition.json +330 -0
  60. package/evals/ab/personas/bindings/implementer.txt +43 -0
  61. package/evals/ab/personas/bindings/librarian.txt +40 -0
  62. package/evals/ab/personas/bindings/reviewer.txt +41 -0
  63. package/evals/ab/personas/corpus.json +69 -0
  64. package/evals/ab/personas/grader.mjs +171 -0
  65. package/evals/ab/personas/implementer.json +774 -0
  66. package/evals/ab/personas/implementer.md +22 -0
  67. package/evals/ab/personas/librarian.json +758 -0
  68. package/evals/ab/personas/librarian.md +22 -0
  69. package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
  70. package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
  71. package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
  72. package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
  73. package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
  74. package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
  75. package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
  76. package/evals/ab/personas/reviewer.json +758 -0
  77. package/evals/ab/personas/reviewer.md +22 -0
  78. package/evals/releases/0.3.0.json +150 -0
  79. package/evals/releases/0.3.0.md +68 -0
  80. package/evals/releases/0.4.0.json +154 -0
  81. package/evals/releases/0.4.0.md +69 -0
  82. package/package.json +7 -2
  83. package/packs/.claude-plugin/plugin.json +2 -1
  84. package/packs/README.md +7 -4
  85. package/packs/rituals/checkpoints/README.md +35 -0
  86. package/packs/rituals/checkpoints/pack.json +3 -2
  87. package/packs/tools/README.md +8 -8
  88. package/packs/tools/local-llm/README.md +176 -0
  89. package/packs/tools/local-llm/pack.json +32 -0
  90. package/packs/tools/local-llm/personas/local-worker.md +44 -0
  91. package/packs/tools/local-llm/runner/local-llm.mjs +1779 -0
  92. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +88 -0
  93. package/packs/tools/local-llm/verify/README.md +18 -0
  94. package/spec/README.md +37 -3
  95. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
  96. package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
  97. package/spec/migrations/README.md +6 -0
  98. package/spec/pack.schema.json +12 -3
  99. package/spec/slots.md +272 -123
  100. package/spec/workspace.schema.json +67 -11
package/cli/drills.mjs CHANGED
@@ -13,10 +13,11 @@ import { isInside } from "./inside.mjs";
13
13
  import { recipeSet, resolverFor } from "./recipe-set.mjs";
14
14
 
15
15
  const RAIL_TIMEOUT_MS = 10 * 60 * 1000;
16
+ const RAIL_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
16
17
 
17
18
  export const DRILL_SESSION_PREFIX = "portulan-drill";
18
19
 
19
- /** Hooks answer in their stdout JSON and exit 0 either way, so their drills judge by a tell read from stdout. */
20
+ /** Hook drills judge stdout JSON: exit 0 carries either verdict. */
20
21
  export const NON_RECIPE_RAILS = [
21
22
  {
22
23
  id: "stop-gate",
@@ -54,7 +55,7 @@ export const NOT_DRILLED = [
54
55
  why: "deliberately not a verify recipe — `../.portulan/identity.md` argues why: CI installs nothing, so declaring it would make a recipe that exits 2 on every run. It is checked at the supervised checkpoints and before a release, which is a rail on no calendar, and that cost is already stated in `../.portulan/verify/README.md`.",
55
56
  },
56
57
  {
57
- rail: "Dependabot, the `copilot auto-review` ruleset, and `pr-labels`",
58
+ rail: "Dependabot and `pr-labels`",
58
59
  why: "platform watchers whose triggering condition is an event this repository cannot manufacture on a schedule. `0007`'s own honest limit applies — not every watcher can be forced red safely — and each carries its own recorded observation instead.",
59
60
  },
60
61
  {
@@ -63,9 +64,20 @@ export const NOT_DRILLED = [
63
64
  },
64
65
  ];
65
66
 
66
- /** A rail that reads only the index misses a created file unless the drill sets `stage`. */
67
+ /** Index-only rails need `stage` for a created file. */
67
68
  export const DRILLS = [
68
- // ------------------------------------------------------------------------- the yielded recipes
69
+ {
70
+ rail: "cache-stability",
71
+ perturb: {
72
+ file: ".claude/rules/portulan/boot.md",
73
+ find: "# Portulan boot card",
74
+ replace: "# Portulan boot card\n\nCurrent date: 2099-12-31",
75
+ },
76
+ exit: 1,
77
+ tell: "differ from the committed golden",
78
+ why: "A volatile prefix change must require review of the committed baseline (#516), even when two renders agree.",
79
+ },
80
+ // The yielded recipes
69
81
  {
70
82
  rail: "docs",
71
83
  perturb: {
@@ -163,7 +175,7 @@ export const DRILLS = [
163
175
  },
164
176
  exit: 1,
165
177
  tell: "fixture(s) failed",
166
- why: "The jq programs the merge gates branch on are executed rather than described. A filter that silently changed shape would take the label gate down with it.",
178
+ why: "The jq programs the label gate branches on are executed rather than described. A filter that silently changed shape would take the label gate down with it.",
167
179
  },
168
180
  {
169
181
  rail: "index",
@@ -199,6 +211,17 @@ export const DRILLS = [
199
211
  tell: "does not reproduce",
200
212
  why: "The host writes one usage record per content block, so a reader that stops deduplicating by message id multiplies every figure by the blocks per request and still prints plausible numbers. The fixture's known totals are the only thing that tells those numbers from the true ones.",
201
213
  },
214
+ {
215
+ rail: "ledger",
216
+ perturb: {
217
+ file: "cli/ledger.mjs",
218
+ find: "if (!calls.has(result.call)) continue;",
219
+ replace: "if (result.call === null) continue;",
220
+ },
221
+ exit: 1,
222
+ tell: "✗ fixture: local is",
223
+ why: "A local worker's subagent reports its receipt back to the session that delegated it, so a reader that takes a receipt from any tool result counts the same local work twice, once from the Bash call that ran it and once from the report quoting it, and the hybrid line still reads like a figure. Only the hybrid fixture's known receipt count tells the two apart.",
224
+ },
202
225
  {
203
226
  rail: "control-chars",
204
227
  perturb: {
@@ -248,7 +271,7 @@ export const DRILLS = [
248
271
  rail: "pack-identity",
249
272
  perturb: {
250
273
  file: "README.md",
251
- find: "**Current release: `0.2.0`**",
274
+ find: "**Current release: `0.4.0`**",
252
275
  replace: "**Current release: `0.1.3`** <!-- the drill edited this and did not stage it -->",
253
276
  },
254
277
  stage: false,
@@ -332,7 +355,7 @@ export const DRILLS = [
332
355
  rail: "version-carriers",
333
356
  perturb: {
334
357
  file: "README.md",
335
- find: "**Current release: `0.2.0`**",
358
+ find: "**Current release: `0.4.0`**",
336
359
  replace: "**Current release: `0.1.3`**",
337
360
  },
338
361
  // Staged: this rail reads the index (`git show :<path>`), so an unstaged edit is invisible to it.
@@ -374,6 +397,17 @@ export const DRILLS = [
374
397
  tell: "invokes cli/review-meter.mjs --fetch",
375
398
  why: "A verify recipe that can reach the network goes red about the world rather than about the tree, and this repository prohibits it in three carriers while nothing checked it. The drill proves the checker sees a real invocation — spread across continued lines, the spelling a recipe actually uses — rather than only the literal string a test would hand it.",
376
399
  },
400
+ {
401
+ rail: "telemetry",
402
+ perturb: {
403
+ file: ".portulan/verify/doctor.sh",
404
+ find: "node cli/doctor.mjs --static --pack-root packs",
405
+ replace: "node cli/doctor.mjs --pack-root packs",
406
+ },
407
+ exit: 1,
408
+ tell: "invokes cli/doctor.mjs without an immediate --static",
409
+ why: "Doctor reads local usage records by default. Removing static mode from its recipe must fail the source audit before verification can depend on this machine's records. The drill runs telemetry, never the altered doctor recipe; filesystem-denial tests separately prove the static runtime path does not consult usage files.",
410
+ },
377
411
  {
378
412
  rail: "ab",
379
413
  perturb: {
@@ -458,8 +492,19 @@ export const DRILLS = [
458
492
  tell: "is a tag or branch, not a commit",
459
493
  why: "The composed pack's own rail, drilled from the adopting workspace exactly as a workspace-owned one is — which is the property milestone 7's composition amendment bought and nothing else here re-checks.",
460
494
  },
495
+ {
496
+ rail: "tools/local-llm:runner-rails",
497
+ perturb: {
498
+ file: "packs/tools/local-llm/runner/local-llm.mjs",
499
+ find: " if (!allowRemote && !LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) {",
500
+ replace: " if (!allowRemote && false) {",
501
+ },
502
+ exit: 1,
503
+ tell: "non-loopback endpoint was not refused by assertLoopback",
504
+ why: "Loopback is the rail that keeps a local worker's context on the machine, and the runner holds it in code rather than in any permission rule the compiler emits, so a guard that stopped refusing would leave every other rail green. The drill removes the refusal and requires the pack's own recipe to see it.",
505
+ },
461
506
 
462
- // ------------------------------------------------------- the coverage rail, which is itself a rail
507
+ // The coverage rail, which is itself a rail
463
508
  {
464
509
  rail: "drills",
465
510
  perturb: {
@@ -475,7 +520,7 @@ export const DRILLS = [
475
520
  why: "The allow-list is what stops a misspelled recipe id being silently reclassified as a rail of its own, leaving the real recipe undrilled while the coverage check reports green. A drill naming a rail nothing yields is the shortest path to that state.",
476
521
  },
477
522
 
478
- // ------------------------------------------------------------------------- the two non-recipe rails
523
+ // The two non-recipe rails
479
524
  {
480
525
  rail: "stop-gate",
481
526
  perturb: {
@@ -817,6 +862,7 @@ function runRail({ rail, worktree, stdin, workspaceRel }) {
817
862
  // After the spread, so a drill's own `cwd` cannot point the hook at another tree.
818
863
  input: `${JSON.stringify({ ...payload, cwd: worktree })}\n`,
819
864
  timeout: RAIL_TIMEOUT_MS,
865
+ maxBuffer: RAIL_MAX_BUFFER_BYTES,
820
866
  // Set, never inherited: the hooks read their tree and workspace from these two.
821
867
  env: { ...process.env, CLAUDE_PROJECT_DIR: worktree, PORTULAN_WORKSPACE: workspaceRel },
822
868
  });
@@ -828,6 +874,7 @@ function runRail({ rail, worktree, stdin, workspaceRel }) {
828
874
  cwd: worktree,
829
875
  encoding: "utf8",
830
876
  timeout: RAIL_TIMEOUT_MS,
877
+ maxBuffer: RAIL_MAX_BUFFER_BYTES,
831
878
  });
832
879
  if (result.error) throw new CouldNotRun(`rail \`${rail.id}\` could not run — ${result.error.message}`);
833
880
  return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
package/cli/finish.md ADDED
@@ -0,0 +1,34 @@
1
+ # Finish verification boundaries
2
+
3
+ Finish resolves the recipe set before staging tracked changes or creating its
4
+ provisional commit. An empty result from `treeRoots` asks for normal recipe-set
5
+ resolution; it does not mean verification succeeded without any recipes.
6
+
7
+ | Workspace and roots | Behavior |
8
+ | --- | --- |
9
+ | No composed packs | The tree's pack directories are not probed. Declared workspace recipes still run. |
10
+ | Tree roots contain every composed pack | Finish names those roots, avoiding an installed copy shadowing the tree. |
11
+ | Tree roots are absent, empty or incomplete | Normal recipe-set resolution and its shadow checks apply. An installed pack can supply the missing recipes; an unresolved pack refuses the operation before commit. |
12
+ | Explicit `--pack-root <directory>` | Only the named roots are used. Missing directories are refused, and missing packs do not fall back to an installed copy. |
13
+
14
+ The controls in `finish.test.mjs` cover these cases. For an absent tree directory,
15
+ the installed-pack fixture compares recipe-set's yielded identities against actual
16
+ execution markers. The unavailable-pack fixture checks that no commit hook ran and
17
+ that HEAD, the index, the worktree and the remote branch remain unchanged.
18
+
19
+ Bash parse failures are **could not run**, with exit 2 and the diagnostic included
20
+ in finish's report. A provisional commit made before the recipes ran is undone and
21
+ its changes stay staged; nothing is pushed. Both tested recipe forms parse after
22
+ the wrapper's `exec 2>&1` redirection. These controls do not demonstrate coverage
23
+ of Bash startup failures.
24
+
25
+ The manifest recipe format rejects newlines, carriage returns and tabs before
26
+ Bash runs. The CLI controls therefore use a single-line missing `fi` and a leading
27
+ `)`. Line numbers can differ between Bash versions. These are Bash's wrapper-script
28
+ diagnostics, not a general promise to translate recipe line numbers.
29
+ The historical two-line example in the review follow-up can be passed directly to
30
+ `runRecipe`, but is not accepted as a current manifest recipe.
31
+
32
+ This reference records concrete boundaries investigated for the historical review
33
+ follow-up. That review posted no findings identifying a different empty-root case;
34
+ these controls do not establish what its unspecified concern meant.
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);