@sleepy_panda_srl/portulan 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -6
- package/cli/README.md +56 -4
- package/cli/advisory.mjs +167 -53
- package/cli/cache-risk.mjs +69 -0
- package/cli/comments.mjs +8 -5
- package/cli/compile.mjs +492 -146
- package/cli/context.mjs +204 -43
- package/cli/doctor.mjs +211 -33
- package/cli/drills.mjs +32 -7
- package/cli/finish.md +34 -0
- package/cli/finish.mjs +9 -67
- package/cli/form.mjs +35 -19
- package/cli/frontmatter.mjs +39 -0
- package/cli/gate.mjs +14 -12
- package/cli/guidance-files.mjs +335 -0
- package/cli/handoff-policy.mjs +76 -0
- package/cli/html-entities.mjs +2130 -0
- package/cli/import-files.mjs +15 -0
- package/cli/index.mjs +77 -24
- package/cli/init-preview.mjs +553 -0
- package/cli/init.mjs +261 -46
- package/cli/instructions-journal.mjs +1185 -0
- package/cli/instructions.mjs +332 -57
- package/cli/ledger.mjs +123 -37
- package/cli/librarian.mjs +103 -19
- package/cli/persona-routing.mjs +209 -0
- package/cli/plugin-lint.mjs +77 -56
- package/cli/portulan.mjs +7 -2
- package/cli/recipe-output.mjs +91 -0
- package/cli/recipe-runner.md +68 -0
- package/cli/recipe-runner.mjs +131 -0
- package/cli/recipe-set.mjs +12 -3
- package/cli/ritual-contexts.mjs +103 -0
- package/cli/sessions.mjs +2 -1
- package/cli/spend-analysis.mjs +111 -0
- package/cli/spend-offer.md +53 -0
- package/cli/spend-offer.mjs +93 -0
- package/cli/stop-diagnostics.mjs +114 -0
- package/cli/stop-gate.mjs +133 -90
- package/cli/stop-policy.mjs +14 -0
- package/cli/symbols.mjs +55 -8
- package/cli/telemetry.mjs +19 -12
- package/cli/upgrade.mjs +62 -44
- package/cli/vendor.mjs +210 -126
- package/core/operating/context.md +368 -60
- package/core/operating/evolution.md +3 -2
- package/core/operating/loop.md +9 -4
- package/core/operating/sessions.md +6 -15
- package/core/personas/README.md +52 -10
- package/core/personas/implementer.md +3 -0
- package/core/personas/librarian.md +3 -0
- package/core/personas/reviewer.md +3 -0
- package/core/templates/handoff.md +6 -2
- package/evals/ab/personas/README.md +111 -0
- package/evals/ab/personas/acquisition.json +330 -0
- package/evals/ab/personas/bindings/implementer.txt +43 -0
- package/evals/ab/personas/bindings/librarian.txt +40 -0
- package/evals/ab/personas/bindings/reviewer.txt +41 -0
- package/evals/ab/personas/corpus.json +69 -0
- package/evals/ab/personas/grader.mjs +171 -0
- package/evals/ab/personas/implementer.json +774 -0
- package/evals/ab/personas/implementer.md +22 -0
- package/evals/ab/personas/librarian.json +758 -0
- package/evals/ab/personas/librarian.md +22 -0
- package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
- package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
- package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
- package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
- package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
- package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
- package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
- package/evals/ab/personas/reviewer.json +758 -0
- package/evals/ab/personas/reviewer.md +22 -0
- package/evals/releases/0.4.0.json +154 -0
- package/evals/releases/0.4.0.md +69 -0
- package/evals/releases/0.4.1.json +154 -0
- package/evals/releases/0.4.1.md +69 -0
- package/package.json +6 -2
- package/packs/rituals/checkpoints/README.md +35 -0
- package/packs/rituals/checkpoints/pack.json +3 -2
- package/packs/tools/local-llm/README.md +46 -6
- package/packs/tools/local-llm/runner/local-llm.mjs +127 -13
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +26 -2
- package/spec/README.md +37 -4
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
- package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
- package/spec/migrations/README.md +6 -0
- package/spec/pack.schema.json +12 -3
- package/spec/slots.md +262 -117
- 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
|
-
//
|
|
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
|
|
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
|
|
217
|
-
return
|
|
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
|
|
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
|
|
276
|
-
return
|
|
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 }`,
|
|
281
|
-
function handoffToday(stamp, tree
|
|
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(
|
|
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
|
|
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)${
|
|
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
|
|
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
|
-
|
|
351
|
-
const
|
|
352
|
-
if (
|
|
353
|
-
problems.push({
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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: "
|
|
377
|
-
text:
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
479
|
-
const result = verdict({ problems, counts: state.counts, total: state.total, chain });
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
if (
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
//
|
|
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(
|
|
776
|
-
|
|
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
|
|
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]*$/, "").
|
|
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
|
-
|
|
870
|
-
para = /^\S/.test(line) ? i + 1 : null;
|
|
917
|
+
para = i + 1;
|
|
871
918
|
above = para === null ? "other" : "para";
|
|
872
919
|
}
|
|
873
920
|
}
|