@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
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 {
|
|
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,22 +16,19 @@ 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]",
|
|
31
|
+
" portulan finish <the same arguments>, where the package is installed",
|
|
35
32
|
"",
|
|
36
33
|
" -m <text> a paragraph of the commit message, the first its subject, as git takes -m",
|
|
37
34
|
" -F <file | -> the whole commit message, from a file or from standard input",
|
|
@@ -124,6 +121,9 @@ const textOf = (thrown) => {
|
|
|
124
121
|
};
|
|
125
122
|
|
|
126
123
|
const resultOf = (id, r) => {
|
|
124
|
+
if (INTERRUPTIONS.includes(r?.interrupted)) {
|
|
125
|
+
return { id, outcome: "could not run", code: null, interrupted: r.interrupted, output: typeof r.output === "string" ? r.output : `interrupted by ${r.interrupted}` };
|
|
126
|
+
}
|
|
127
127
|
if (r?.outcome === "green") return { id, outcome: "green" };
|
|
128
128
|
if (r?.outcome === "red" || r?.outcome === "could not run") {
|
|
129
129
|
return { id, outcome: r.outcome, code: Number.isInteger(r.code) ? r.code : null, output: typeof r.output === "string" ? r.output : "" };
|
|
@@ -206,65 +206,6 @@ export function recipesOf({ root, workspaceDir, named, forced }) {
|
|
|
206
206
|
return set.ok ? { recipes: set.recipes } : { why: set.reason };
|
|
207
207
|
}
|
|
208
208
|
|
|
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
209
|
/** Resolves to `{ code, lines }`: the exit code and what to print, the first line saying what happened. */
|
|
269
210
|
export async function finish(options, { cwd = process.cwd(), env = process.env, readStdin, runOne = runRecipe } = {}) {
|
|
270
211
|
const stop = (code, ...lines) => ({ code, lines: [`finish: ${lines[0]}`, ...lines.slice(1)] });
|
|
@@ -374,10 +315,11 @@ export async function finish(options, { cwd = process.cwd(), env = process.env,
|
|
|
374
315
|
} catch (error) {
|
|
375
316
|
results.push({ id: recipe.id, outcome: "could not run", code: null, output: textOf(error) });
|
|
376
317
|
}
|
|
318
|
+
if (results.at(-1).interrupted) break;
|
|
377
319
|
}
|
|
378
320
|
const failed = results.filter((r) => r.outcome !== "green");
|
|
379
321
|
if (failed.length) {
|
|
380
|
-
const code = failed.some((r) => r.outcome === "red") ? 1 : 2;
|
|
322
|
+
const code = failed.some((r) => r.interrupted) ? 2 : failed.some((r) => r.outcome === "red") ? 1 : 2;
|
|
381
323
|
const named = failed.map((r) => r.id).join(", ");
|
|
382
324
|
return stop(
|
|
383
325
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
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 });
|
|
@@ -527,7 +543,7 @@ export function draftCard(manifest, read, { workspace, inTree, repoCards = [] })
|
|
|
527
543
|
}
|
|
528
544
|
section(
|
|
529
545
|
"Records",
|
|
530
|
-
'Close a change with `git add <new files> &&
|
|
546
|
+
'Close a change with `git add <new files> && portulan finish -m "<subject>" -m "<why>"` after writing its changelog entry, a one-bullet file `changes/<slug>.<section>.md`: it commits and pushes only when every verify recipe passes. Where `portulan` is not on the path, run `npx @sleepy_panda_srl/portulan finish` instead, and do not search the disk for it.',
|
|
531
547
|
...(slots.handoffs ? [`A session ending with work not committed and pushed leaves a dated handoff in ${shown(slots.handoffs)}.`] : []),
|
|
532
548
|
);
|
|
533
549
|
section(
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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);
|