@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
@@ -0,0 +1,114 @@
1
+ // Bounded streaming failure excerpts for the Stop recipe relay.
2
+ import { StringDecoder } from "node:string_decoder";
3
+
4
+ export const STOP_DIAGNOSTIC_BYTES = 4096;
5
+ export const STOP_RECIPE_BYTES = 6144;
6
+ const LINE_BYTES = 1024;
7
+ const SUCCESS = /^(?:PASS(?:\b|[-_])|GREEN\b|ok\b|[✔✓▶﹣]|TAP version \d+\b|\d+\.\.\d+(?:\s|$))|^#\s*Subtest:|^(?:#|ℹ)\s*(?:(?:pass|tests|suites|duration_ms|skipped|todo)\b|(?:fail|cancelled)\s+0\b)/i;
8
+ const FAILURE = /^(?:#\s*)?(?:FAIL(?:ED|URE|URES)?(?:\b|[-_])|RED\b|ERROR\b|ERR_[A-Z_]+\b|(?:Assertion|Type|Syntax|Range|Reference|Eval)?Error\b|not ok\b|[✗✖×])|^(?:#|ℹ)\s*(?:fail|cancelled)\s+[1-9]\d*\b|(?:^|\s)(?:error:|command not found\b|permission denied\b|No such file or directory\b)|^(?:verify|tests):.*(?:failed|failing|could not|cannot|refused|missing)/i;
9
+
10
+ export function clipDiagnostic(value, limit) {
11
+ const text = String(value);
12
+ if (Buffer.byteLength(text) <= limit) return text;
13
+ const notice = " [clipped]";
14
+ const decoder = new StringDecoder("utf8");
15
+ return decoder.write(Buffer.from(text).subarray(0, limit - Buffer.byteLength(notice))) + notice;
16
+ }
17
+
18
+ function lines() {
19
+ const kept = [];
20
+ let bytes = 0, omitted = 0;
21
+ return {
22
+ add(line) {
23
+ const size = Buffer.byteLength(line) + 1;
24
+ if (bytes + size <= 2800) { kept.push(line); bytes += size; }
25
+ else omitted++;
26
+ },
27
+ get present() { return kept.length > 0; },
28
+ text: () => kept.join("\n") + (omitted ? `\n[${omitted} diagnostic lines omitted]` : ""),
29
+ };
30
+ }
31
+
32
+ export function stopDiagnostics() {
33
+ const failures = lines(), fallback = lines();
34
+ const streams = new Map();
35
+ let observed = false;
36
+ function flush(state) {
37
+ const decoder = new StringDecoder("utf8");
38
+ const text = decoder.write(Buffer.concat(state.chunks)) + (state.clipped ? " [line clipped]" : decoder.end());
39
+ const line = text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
40
+ const trimmed = line.trim();
41
+ state.chunks = []; state.bytes = 0; state.clipped = false;
42
+ if (!trimmed) return;
43
+ const indent = line.length - line.trimStart().length;
44
+ if (state.todoIndent !== null) {
45
+ if (indent === state.todoIndent + 2 && trimmed === state.todoEnd) { state.todoIndent = null; return; }
46
+ if (indent > state.todoIndent) return;
47
+ state.todoIndent = null;
48
+ }
49
+ const tapExpected = /^(?:ok|not ok)\s+\d+\b.*(?<!\\)#\s*(?:TODO|SKIP)\b/i.test(trimmed);
50
+ const specAnnotated = /^[⚠✖✗].*\(\d+(?:\.\d+)?ms\)\s+#\s*\S/.test(trimmed);
51
+ const specExpected = /^⚠.*#\s*TODO\b/i.test(trimmed)
52
+ || (specAnnotated && (trimmed.startsWith("⚠") || !/\(\d+(?:\.\d+)?ms\)$/.test(trimmed)));
53
+ if (tapExpected || specExpected) {
54
+ state.todoIndent = indent; state.todoEnd = tapExpected ? "..." : "}"; state.context = 0; state.location = null;
55
+ return;
56
+ }
57
+ if (/^(?:---|\.\.\.)$|^duration_ms:\s*\d+(?:\.\d+)?$|^type:\s*['"](?:test|suite)['"]$/.test(trimmed)) return;
58
+ if (trimmed === "✖ failing tests:") { state.context = 0; state.location = null; return; }
59
+ if (/^test at .+:\d+(?::\d+)?$/.test(trimmed)) { state.location = line; return; }
60
+ if (SUCCESS.test(trimmed)) { state.context = 0; state.location = null; return; }
61
+ if (FAILURE.test(trimmed)) {
62
+ if (state.location) failures.add(state.location);
63
+ state.location = null;
64
+ failures.add(line); state.context = 8;
65
+ }
66
+ else if (state.context && /^\s|^#\s/.test(line)) { failures.add(line); state.context--; }
67
+ else {
68
+ if (state.location) fallback.add(state.location);
69
+ state.location = null;
70
+ fallback.add(line); state.context = 0;
71
+ }
72
+ }
73
+ function add(name, chunk) {
74
+ observed = true;
75
+ name = name === "stderr" ? "stderr" : "stdout";
76
+ if (!streams.has(name)) streams.set(name, { chunks: [], bytes: 0, clipped: false, context: 0, todoIndent: null, location: null });
77
+ const state = streams.get(name);
78
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
79
+ let start = 0;
80
+ while (start < buffer.length) {
81
+ const newline = buffer.indexOf(10, start);
82
+ const end = newline < 0 ? buffer.length : newline;
83
+ const size = Math.min(LINE_BYTES - state.bytes, end - start);
84
+ if (size) { state.chunks.push(Buffer.from(buffer.subarray(start, start + size))); state.bytes += size; }
85
+ if (end - start > size) state.clipped = true;
86
+ if (newline < 0) break;
87
+ flush(state);
88
+ start = newline + 1;
89
+ }
90
+ }
91
+ return {
92
+ add,
93
+ finish(result) {
94
+ const raw = Array.isArray(result.diagnostics) ? result.diagnostics : [];
95
+ if (!observed && result.output) {
96
+ const suffix = raw.length ? `\n${raw.join("\n")}` : "";
97
+ const output = suffix && result.output.endsWith(suffix) ? result.output.slice(0, -suffix.length) : result.output;
98
+ if (output) add("stdout", output);
99
+ }
100
+ for (const state of streams.values()) if (state.bytes || state.clipped) flush(state);
101
+ for (const state of streams.values()) if (state.location) fallback.add(state.location);
102
+ const structured = [
103
+ ...(result.interrupted ? [`interrupted by ${clipDiagnostic(result.interrupted, 64)}`] : []),
104
+ ...(result.timedOut ? ["recipe time limit reached"] : []),
105
+ ];
106
+ const reasons = [...structured, ...raw.filter(reason => !structured.includes(reason))];
107
+ const runner = reasons.length
108
+ ? `executor: ${reasons.slice(0, 3).map(reason => clipDiagnostic(reason, 192)).join("; ")}${reasons.length > 3 ? ` [${reasons.length - 3} executor reasons omitted]` : ""}\n` : "";
109
+ const body = failures.present ? failures.text() : fallback.present
110
+ ? `unrecognized output (no failure marker identified):\n${fallback.text()}` : "no failure diagnostic identified";
111
+ return clipDiagnostic(`${runner}${body}\nRun verification explicitly for complete output.`, STOP_DIAGNOSTIC_BYTES);
112
+ },
113
+ };
114
+ }
package/cli/stop-gate.mjs CHANGED
@@ -1,7 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // The Stop-gate runner — "done" is not a thing an agent may simply declare.
3
- //
4
- // A crashed hook fails open (Claude Code 2.1.220), so every failure path here returns a verdict instead of throwing.
2
+ // Stop-gate: bounded recipe and work checks. Hook failures need a verdict, since a crashed hook fails open.
5
3
 
6
4
  import fs from "node:fs";
7
5
  import os from "node:os";
@@ -11,6 +9,9 @@ import { fileURLToPath, pathToFileURL } from "node:url";
11
9
 
12
10
  import { isInside } from "./inside.mjs";
13
11
  import { recipeSet } from "./recipe-set.mjs";
12
+ import { runRecipe } from "./recipe-runner.mjs";
13
+ import { readStopPolicy } from "./stop-policy.mjs";
14
+ import { stopDiagnostics, clipDiagnostic, STOP_RECIPE_BYTES } from "./stop-diagnostics.mjs";
14
15
 
15
16
  const PROJECT = process.env.CLAUDE_PROJECT_DIR || process.cwd();
16
17
  const WORKSPACE = path.resolve(PROJECT, process.env.PORTULAN_WORKSPACE || ".portulan");
@@ -87,9 +88,6 @@ export const MAX_TOTAL_BLOCKS = 9;
87
88
  // restarting both caps. Set above MAX_TOTAL_BLOCKS, so a session whose id holds meets the other bounds first.
88
89
  export const MAX_CHAIN_BLOCKS = 12;
89
90
 
90
- // Well inside the host's 600 s hook timeout, so a hung recipe meets a verdict here first.
91
- const RECIPE_TIMEOUT_MS = 90_000;
92
-
93
91
  function counterFile(sessionId, dir, root = REPO) {
94
92
  const digest = (v) => {
95
93
  let h = 0;
@@ -189,11 +187,6 @@ function allow() {
189
187
  process.exit(0);
190
188
  }
191
189
 
192
- function block(reason) {
193
- process.stdout.write(`${JSON.stringify({ decision: "block", reason })}\n`);
194
- process.exit(0);
195
- }
196
-
197
190
  function defaultRecipe() {
198
191
  try {
199
192
  const manifest = JSON.parse(fs.readFileSync(path.join(WORKSPACE, "workspace.json"), "utf8"));
@@ -201,9 +194,28 @@ function defaultRecipe() {
201
194
  const set = recipeSet(manifest, { packs: [] });
202
195
  if (!set.ok) return null;
203
196
  const recipe = set.recipes.find((r) => r.id === set.default);
204
- return recipe ? { id: recipe.id, run: recipe.run } : null;
205
- } catch {
206
- return null;
197
+ return recipe ? { id: recipe.id, run: recipe.run, timeout: readStopPolicy(manifest).seconds } : null;
198
+ } catch (error) {
199
+ return { error: error.message };
200
+ }
201
+ }
202
+
203
+ function handoffPolicy(tree) {
204
+ const file = path.join(tree.workspace, "workspace.json");
205
+ try {
206
+ const manifest = JSON.parse(fs.readFileSync(file, "utf8"));
207
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) throw new Error("the manifest must be an object");
208
+ const slots = manifest.slots;
209
+ if (slots === undefined) return { directory: null };
210
+ if (!slots || typeof slots !== "object" || Array.isArray(slots)) throw new Error("slots must be an object");
211
+ if (!Object.hasOwn(slots, "handoffs")) return { directory: null };
212
+ if (typeof slots.handoffs !== "string" || !/^[^#?:/][^#?:]*\/$/.test(slots.handoffs)) {
213
+ throw new Error("slots.handoffs must be a directory path ending in /, without a fragment, query or colon");
214
+ }
215
+ return { directory: path.resolve(tree.workspace, slots.handoffs) };
216
+ } catch (error) {
217
+ const why = error instanceof SyntaxError ? "the manifest is not valid JSON" : error.code ?? error.message;
218
+ return { directory: null, error: `could not read the record policy from ${file}: ${why}. Fix the manifest before ending this work.` };
207
219
  }
208
220
  }
209
221
 
@@ -213,8 +225,8 @@ function didWork(root = REPO) {
213
225
  try {
214
226
  if (git(["status", "--porcelain"]).trim() !== "") return true;
215
227
  } catch {
216
- process.stderr.write("portulan stop-gate: cannot read git status — the handoff check is not running.\n");
217
- return false;
228
+ process.stderr.write("portulan stop-gate: cannot read git status — the work check is not running.\n");
229
+ return null;
218
230
  }
219
231
 
220
232
  try {
@@ -249,7 +261,7 @@ function didWork(root = REPO) {
249
261
  const degraded = (why, check) => {
250
262
  process.stderr.write(
251
263
  `portulan stop-gate: could not compare by patch-id (${why}), so commits on no remote are ` +
252
- "being read as work. If this branch was rebase-merged the handoff demand may be spurious — " +
264
+ "being read as work. If this branch was rebase-merged the work demand may be spurious — " +
253
265
  `${check} and say so rather than working around the gate.\n`,
254
266
  );
255
267
  return true;
@@ -272,25 +284,27 @@ function didWork(root = REPO) {
272
284
  }
273
285
  return cherry.split("\n").some((line) => line.startsWith("+"));
274
286
  } catch {
275
- process.stderr.write("portulan stop-gate: cannot determine whether this session did work — the handoff check is not running.\n");
276
- return false;
287
+ process.stderr.write("portulan stop-gate: cannot determine whether this session did work — the work check is not running.\n");
288
+ return null;
277
289
  }
278
290
  }
279
291
 
280
- /** Today's handoffs as `{ own, recorded }`, `recorded` being clean and held by a remote at that path. */
281
- function handoffToday(stamp, tree = { root: REPO, workspace: WORKSPACE }) {
292
+ /** Today's handoffs as `{ own, recorded }`, with remote content checked since two UTC days before their date. */
293
+ function handoffToday(stamp, tree, handoffsDir) {
282
294
  let dated;
283
295
  try {
284
- dated = fs.readdirSync(path.join(tree.workspace, "handoffs")).filter((f) => f.startsWith(stamp) && f.endsWith(".md"));
285
- } catch {
296
+ dated = fs.readdirSync(handoffsDir).filter((f) => f.startsWith(stamp) && f.endsWith(".md"));
297
+ } catch (error) {
298
+ if (error.code !== "ENOENT") process.stderr.write(`portulan stop-gate: could not read the declared handoff series (${error.code ?? "read failed"}); no handoff was found.\n`);
286
299
  return { own: [], recorded: [] };
287
300
  }
288
- const handoffsDir = path.join(tree.workspace, "handoffs");
289
301
  const dir = path.relative(tree.root, handoffsDir).split(path.sep).join("/");
290
302
  if (dir === "" || !isInside(tree.root, handoffsDir)) return { own: dated, recorded: [] };
291
303
  const git = (args) => execFileSync("git", args, { cwd: tree.root, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"] });
292
304
  const own = [];
293
305
  const recorded = [];
306
+ const cutoff = new Date(`${stamp}T00:00:00Z`);
307
+ cutoff.setUTCDate(cutoff.getUTCDate() - 2);
294
308
  for (const name of dated) {
295
309
  const file = `:(literal)${dir}/${name}`;
296
310
  let unrecorded;
@@ -300,7 +314,7 @@ function handoffToday(stamp, tree = { root: REPO, workspace: WORKSPACE }) {
300
314
  // By content, not commit: a rebase or squash merge pushes a handoff in a commit of its own.
301
315
  // `--root`, or `log.showRoot=false` skips a root commit.
302
316
  const blob = git(["rev-parse", `HEAD:./${dir}/${name}`]).trim();
303
- unrecorded = git(["log", "-1", "--root", "--format=%h", "--remotes", `--find-object=${blob}`, "--", file]).trim() === "";
317
+ unrecorded = git(["log", "-1", "--root", "--format=%h", "--remotes", `--since=${cutoff.toISOString()}`, `--find-object=${blob}`, "--", file]).trim() === "";
304
318
  }
305
319
  } catch {
306
320
  process.stderr.write(`portulan stop-gate: could not tell whether ${name} is committed and pushed, so it counts as this tree's own.\n`);
@@ -325,14 +339,14 @@ function treeIdentity(root = REPO) {
325
339
  }
326
340
 
327
341
  // Refs on disk only: a fetch would put a round trip, or an offline host's timeout, inside every Stop.
328
- function handoffInHistory(stamp, tree = { root: REPO, workspace: WORKSPACE }, held = []) {
342
+ function handoffInHistory(stamp, tree, handoffsDir, held = []) {
329
343
  try {
330
- const handoffsDir = path.join(tree.workspace, "handoffs");
331
344
  const dir = path.relative(tree.root, handoffsDir).split(path.sep).join("/");
332
345
  if (dir === "" || !isInside(tree.root, handoffsDir)) return null;
346
+ const prefix = dir.replace(/[\\*?[\]]/g, "\\$&");
333
347
  const git = (args) => execFileSync("git", args, { cwd: tree.root, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"] });
334
348
  // `:(glob)`, since git reads a bare `*` literally under `GIT_NOGLOB_PATHSPECS=1` (git 2.50.1).
335
- const commit = git(["log", "-1", "--format=%H", "--all", "--not", "HEAD", "--", `:(glob)${dir}/${stamp}*`,
349
+ const commit = git(["log", "-1", "--format=%H", "--all", "--not", "HEAD", "--", `:(glob)${prefix}/${stamp}*`,
336
350
  ...held.map((name) => `:(exclude,literal)${dir}/${name}`)]).trim();
337
351
  if (!commit) return null;
338
352
  const refs = git(["branch", "--all", "--contains", commit, "--format=%(refname:short)"])
@@ -343,47 +357,45 @@ function handoffInHistory(stamp, tree = { root: REPO, workspace: WORKSPACE }, he
343
357
  }
344
358
  }
345
359
 
346
- function collectProblems(tree = { root: REPO, workspace: WORKSPACE, origin: "told", note: null }) {
360
+ export function workNotice({ stamp, directory, identity, answeredElsewhere = "", recorded = [], found = "" }) {
361
+ if (directory === null) return `${identity} holds work that is not committed and pushed. Commit and push it, its why in the commit message.`;
362
+ const notCounted = recorded.length
363
+ ? `${recorded.map((f) => `\`${f}\``).join(", ")} ${recorded.length === 1 ? "is" : "are"} committed and pushed already, ` +
364
+ `so ${recorded.length === 1 ? "it records" : "they record"} work already recorded. `
365
+ : "";
366
+ return `no handoff dated ${stamp}${recorded.length ? " of this tree's own" : ""} in ${directory}, read from ` +
367
+ `${identity}${answeredElsewhere}. ${notCounted}` +
368
+ "This tree holds work that is not committed and pushed. Commit and push it, its why in the commit " +
369
+ "message, or end with a dated handoff naming what is open: where things stand, the open questions " +
370
+ `and the next action. Five lines is enough; absent is not.${found}`;
371
+ }
372
+
373
+ function workProblems(tree) {
347
374
  const problems = [];
348
- let recipeGreen = false;
349
375
 
350
- // Only the told workspace's recipe, run in the told root: one read from a tree the payload names would run what stdin chose.
351
- const recipe = defaultRecipe();
352
- if (!recipe) {
353
- problems.push({
354
- reason: "recipe",
355
- text:
356
- "could not read the workspace's default verify recipe from .portulan/workspace.json, so nothing " +
357
- "verified this work. That is 'could not run', which blocks exactly as red does.",
358
- });
359
- } else {
360
- try {
361
- execFileSync("bash", ["-c", recipe.run], {
362
- cwd: REPO,
363
- encoding: "utf8",
364
- timeout: RECIPE_TIMEOUT_MS,
365
- stdio: ["ignore", "pipe", "pipe"],
366
- });
367
- recipeGreen = true;
368
- } catch (error) {
369
- const code = error.status;
370
- const output = `${error.stdout ?? ""}${error.stderr ?? ""}`.trim().split("\n").slice(-25).join("\n");
371
- const CANNOT_RUN = new Set([2, 126, 127]);
372
- const outcome = CANNOT_RUN.has(code) || code === undefined || code === null
373
- ? `could not run (exit ${code ?? "no status"}) — the gate could not judge`
374
- : `RED (exit ${code})`;
376
+ const policy = handoffPolicy(tree);
377
+ const handoffEnabled = policy.directory !== null;
378
+ if (policy.error) {
379
+ problems.push({ reason: "handoff", text: policy.error });
380
+ return { problems, handoffEnabled, handoffCleared: false };
381
+ }
382
+ if (!handoffEnabled) {
383
+ const work = didWork(tree.root);
384
+ if (work === true) {
375
385
  problems.push({
376
- reason: "recipe",
377
- text: `verify recipe \`${recipe.id}\` in ${REPO} — ${outcome}\n${output}`,
386
+ reason: "handoff",
387
+ text: workNotice({ identity: treeIdentity(tree.root), directory: null }),
378
388
  });
379
389
  }
390
+ return { problems, handoffEnabled, handoffCleared: work === false };
380
391
  }
381
392
 
382
393
  const stamp = today();
383
- const handoffs = handoffToday(stamp, tree);
394
+ const handoffs = handoffToday(stamp, tree, policy.directory);
384
395
  const handoffPresent = handoffs.own.length > 0;
385
396
  // `handoffPresent` first: `didWork()` runs git several times and may warn about a handoff that is not owed.
386
- if (!handoffPresent && didWork(tree.root)) {
397
+ const work = handoffPresent ? null : didWork(tree.root);
398
+ if (work === true) {
387
399
  const real = (dir) => {
388
400
  try {
389
401
  return fs.realpathSync(dir);
@@ -395,39 +407,63 @@ function collectProblems(tree = { root: REPO, workspace: WORKSPACE, origin: "tol
395
407
  ? ` — the tree this session worked in, not the one this hook was told (${REPO})`
396
408
  : "";
397
409
  const { recorded } = handoffs;
398
- const elsewhere = handoffInHistory(stamp, tree, recorded);
410
+ const elsewhere = handoffInHistory(stamp, tree, policy.directory, recorded);
399
411
  const found = elsewhere
400
412
  ? ` One dated ${stamp} does exist elsewhere in this repository's refs, at ` +
401
413
  `${elsewhere.commit}${elsewhere.ref ? ` on \`${elsewhere.ref}\`` : ""} — so this working tree may not be ` +
402
414
  "the tree that did the work. Check before writing a second one."
403
415
  : "";
404
- const notCounted = recorded.length
405
- ? `${recorded.map((f) => `\`${f}\``).join(", ")} ${recorded.length === 1 ? "is" : "are"} committed and pushed already, ` +
406
- `so ${recorded.length === 1 ? "it records" : "they record"} work already recorded. `
407
- : "";
408
416
  problems.push({
409
417
  reason: "handoff",
410
- text:
411
- `no handoff dated ${stamp}${recorded.length ? " of this tree's own" : ""} in ${path.join(tree.workspace, "handoffs")}, read from ` +
412
- `${treeIdentity(tree.root)}${answeredElsewhere}. ${notCounted}` +
413
- "This tree holds work that is not committed and pushed. Commit and push it, its why in the commit " +
414
- "message, or end with a dated handoff naming what is open: where things stand, the open questions " +
415
- `and the next action. Five lines is enough; absent is not.${found}`,
418
+ text: workNotice({ stamp, directory: policy.directory, identity: treeIdentity(tree.root), answeredElsewhere, recorded, found }),
416
419
  });
417
420
  }
418
421
 
419
- return { problems, recipeGreen };
422
+ return { problems, handoffEnabled, handoffCleared: handoffPresent || work === false };
423
+ }
424
+
425
+ export async function runStopRecipe(recipe, execute = runRecipe) {
426
+ const problem = text => ({ reason: "recipe", text: clipDiagnostic(text, STOP_RECIPE_BYTES) });
427
+ if (!recipe || recipe.error) return {
428
+ reason: "recipe",
429
+ text: clipDiagnostic(`could not read the workspace's default verify recipe from ${clipDiagnostic(path.join(WORKSPACE, "workspace.json"), 512)}: ${clipDiagnostic(recipe?.error ?? "no runnable default", 4096)}. Nothing verified this work.`, STOP_RECIPE_BYTES),
430
+ };
431
+ const label = clipDiagnostic(`verify recipe \`${clipDiagnostic(recipe.id, 512)}\` in ${clipDiagnostic(REPO, 512)}`, 1100);
432
+ try {
433
+ const diagnostics = stopDiagnostics();
434
+ const result = await execute(recipe, { root: REPO, env: process.env, timeout: recipe.timeout * 1000, mergeOutput: false, onOutput: diagnostics.add });
435
+ if (result.outcome === "green") return null;
436
+ const outcome = result.timedOut
437
+ ? `could not run: timed out after ${clipDiagnostic(recipe.timeout, 64)} s — the gate could not judge`
438
+ : result.outcome === "red" ? `RED (exit ${clipDiagnostic(result.code, 64)})` : `could not run (exit ${clipDiagnostic(result.code ?? "no status", 64)}) — the gate could not judge`;
439
+ return problem(`${label} — ${outcome}\n${diagnostics.finish(result)}`);
440
+ } catch (error) {
441
+ return problem(`${label} could not run — ${clipDiagnostic(error?.message ?? error, 4096)}; the gate could not judge. Run verification explicitly.`);
442
+ }
443
+ }
444
+
445
+ async function collectProblems(tree, state, chain) {
446
+ const work = workProblems(tree);
447
+ const recipeCapped = (state.counts.recipe ?? 0) >= MAX_BLOCKS;
448
+ const handoffCapped = work.problems.some(p => p.reason === "handoff") && (state.counts.handoff ?? 0) >= MAX_BLOCKS;
449
+ const skip = recipeCapped || handoffCapped || state.total >= MAX_TOTAL_BLOCKS || chain > MAX_CHAIN_BLOCKS;
450
+ const recipeProblem = skip ? {
451
+ reason: "recipe", charge: false, capped: recipeCapped,
452
+ text: "verify recipe not rerun: this Stop is released at a refusal bound. No result was measured on this Stop; existing obligations remain unresolved. Run verification explicitly before claiming done.",
453
+ } : await runStopRecipe(defaultRecipe());
454
+ return { ...work, problems: [...(recipeProblem ? [recipeProblem] : []), ...work.problems], recipeGreen: !skip && recipeProblem === null };
420
455
  }
421
456
 
422
- export function verdict({ problems, counts = {}, total = 0, chain = 0, max = MAX_BLOCKS, maxTotal = MAX_TOTAL_BLOCKS, maxChain = MAX_CHAIN_BLOCKS }) {
457
+ export function verdict({ problems, counts = {}, total = 0, chain = 0, max = MAX_BLOCKS, maxTotal = MAX_TOTAL_BLOCKS, maxChain = MAX_CHAIN_BLOCKS, handoffEnabled = true }) {
423
458
  if (problems.length === 0) return { action: "allow" };
424
459
 
425
460
  const text = problems.map((p) => p.text).join("\n\n");
426
- const spent = problems.map((p) => p.reason).filter((reason) => (counts[reason] ?? 0) > max);
461
+ const spent = problems.filter((p) => p.capped || (counts[p.reason] ?? 0) > max).map((p) => p.reason);
462
+ const label = (reason) => !handoffEnabled && reason === "handoff" ? "work" : reason;
427
463
 
428
464
  if (spent.length || total > maxTotal || chain > maxChain) {
429
465
  const bound = spent.length
430
- ? `the cap of ${max} consecutive refusals for \`${spent.join("`, `")}\` was reached`
466
+ ? `the cap of ${max} consecutive refusals for \`${spent.map(label).join("`, `")}\` was reached`
431
467
  : total > maxTotal
432
468
  ? `the absolute ceiling of ${maxTotal} refusals was reached (no single reason had reached its cap of ${max})`
433
469
  : `${chain} consecutive hook-provoked stops were seen in this tree, past the chain bound of ${maxChain} — ` +
@@ -437,13 +473,13 @@ export function verdict({ problems, counts = {}, total = 0, chain = 0, max = MAX
437
473
  action: "release",
438
474
  message:
439
475
  `PORTULAN STOP-GATE — ${bound}. This session is ending **RED**, not done.\n` +
440
- `Nothing below was fixed, and the session ending does not fix it. Say so in the handoff and in any\n` +
476
+ `Nothing below was fixed, and the session ending does not fix it. Say so in ${handoffEnabled ? "the handoff and in any" : "any"}\n` +
441
477
  `report of this work; a task that ends at the cap is an unfinished task with a stop attached.\n\n` +
442
478
  `${text}\n`,
443
479
  };
444
480
  }
445
481
 
446
- const tally = problems.map((p) => `${p.reason} ${counts[p.reason] ?? 0}/${max}`).join(", ");
482
+ const tally = problems.map((p) => `${label(p.reason)} ${counts[p.reason] ?? 0}/${max}`).join(", ");
447
483
  return {
448
484
  action: "block",
449
485
  message:
@@ -453,7 +489,14 @@ export function verdict({ problems, counts = {}, total = 0, chain = 0, max = MAX
453
489
  };
454
490
  }
455
491
 
456
- function main() {
492
+ export function stopOutput(result) {
493
+ return {
494
+ stdout: result.action === "block" ? JSON.stringify({ decision: "block", reason: result.message }) : "",
495
+ stderr: result.action === "release" ? result.message : "",
496
+ };
497
+ }
498
+
499
+ async function main() {
457
500
  let payload = {};
458
501
  try {
459
502
  payload = JSON.parse(fs.readFileSync(0, "utf8"));
@@ -466,26 +509,26 @@ function main() {
466
509
  const tree = resolveSessionTree(payload.cwd);
467
510
  if (tree.note) process.stderr.write(`portulan stop-gate: ${tree.note}; answering about ${REPO} instead.\n`);
468
511
 
469
- const { problems, recipeGreen } = collectProblems(tree);
512
+ const prior = readCount(counterFile(sessionId, os.tmpdir()));
513
+ const chain = bumpChain(payload.stop_hook_active === true, os.tmpdir(), tree.root);
514
+ const { problems, recipeGreen, handoffEnabled, handoffCleared } = await collectProblems(tree, prior, chain);
470
515
 
471
516
  // The session's counts stay on the told root, so a `cwd` that moves mid-session cannot restart them.
472
517
  if (recipeGreen) clearReason(sessionId, "recipe");
473
- if (!problems.some((p) => p.reason === "handoff")) clearReason(sessionId, "handoff");
518
+ if (handoffCleared) clearReason(sessionId, "handoff");
474
519
 
475
520
  const state = problems.length === 0
476
521
  ? { counts: {}, total: 0 }
477
- : bumpCount(sessionId, [...new Set(problems.map((p) => p.reason))]);
478
- const chain = bumpChain(problems.length > 0 && payload.stop_hook_active === true, os.tmpdir(), tree.root);
479
- const result = verdict({ problems, counts: state.counts, total: state.total, chain });
480
-
481
- if (result.action === "allow") allow();
482
- if (result.action === "release") {
483
- process.stderr.write(result.message);
484
- allow();
485
- }
486
- block(result.message);
522
+ : bumpCount(sessionId, [...new Set(problems.filter(p => p.charge !== false).map((p) => p.reason))]);
523
+ if (problems.length === 0) bumpChain(false, os.tmpdir(), tree.root);
524
+ const result = verdict({ problems, counts: state.counts, total: state.total, chain, handoffEnabled });
525
+
526
+ const output = stopOutput(result);
527
+ if (output.stderr) process.stderr.write(output.stderr);
528
+ if (output.stdout) process.stdout.write(`${output.stdout}\n`);
529
+ allow();
487
530
  }
488
531
 
489
532
  if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
490
- main();
533
+ await main();
491
534
  }
@@ -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
  }