@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.
- package/README.md +15 -4
- package/cli/README.md +55 -3
- 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 +8 -67
- package/cli/form.mjs +34 -18
- 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/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 +207 -125
- 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/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,19 +16,15 @@ import { packRoots, resolvePack } from "./compile.mjs";
|
|
|
16
16
|
import { CHANGES_DIR, CHANGES_README } from "./form.mjs";
|
|
17
17
|
import { CHANGE_NAME } from "./index.mjs";
|
|
18
18
|
import { recipeSet, resolverFor } from "./recipe-set.mjs";
|
|
19
|
+
import { INTERRUPTIONS, runRecipe, tailKeeper } from "./recipe-runner.mjs";
|
|
20
|
+
export { runRecipe, tailKeeper };
|
|
19
21
|
|
|
20
22
|
export const RECIPE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
21
23
|
|
|
22
24
|
export const TAIL_LINES = 25;
|
|
23
25
|
|
|
24
|
-
const TAIL_BYTES = 64 * 1024;
|
|
25
|
-
|
|
26
|
-
const DRAIN_MS = 1000;
|
|
27
|
-
|
|
28
26
|
const GIT_TIMEOUT_MS = 2 * 60 * 1000;
|
|
29
27
|
|
|
30
|
-
const CANNOT_RUN = new Set([2, 126, 127]);
|
|
31
|
-
|
|
32
28
|
const USAGE = [
|
|
33
29
|
"usage: node cli/finish.mjs -m <subject> [-m <paragraph>]... [options]",
|
|
34
30
|
" node cli/finish.mjs -F <file | -> [options]",
|
|
@@ -124,6 +120,9 @@ const textOf = (thrown) => {
|
|
|
124
120
|
};
|
|
125
121
|
|
|
126
122
|
const resultOf = (id, r) => {
|
|
123
|
+
if (INTERRUPTIONS.includes(r?.interrupted)) {
|
|
124
|
+
return { id, outcome: "could not run", code: null, interrupted: r.interrupted, output: typeof r.output === "string" ? r.output : `interrupted by ${r.interrupted}` };
|
|
125
|
+
}
|
|
127
126
|
if (r?.outcome === "green") return { id, outcome: "green" };
|
|
128
127
|
if (r?.outcome === "red" || r?.outcome === "could not run") {
|
|
129
128
|
return { id, outcome: r.outcome, code: Number.isInteger(r.code) ? r.code : null, output: typeof r.output === "string" ? r.output : "" };
|
|
@@ -206,65 +205,6 @@ export function recipesOf({ root, workspaceDir, named, forced }) {
|
|
|
206
205
|
return set.ok ? { recipes: set.recipes } : { why: set.reason };
|
|
207
206
|
}
|
|
208
207
|
|
|
209
|
-
/** A stream's last `limit` bytes, kept as it arrives: what is held never passes `limit` and one chunk. */
|
|
210
|
-
export function tailKeeper(limit = TAIL_BYTES) {
|
|
211
|
-
const chunks = [];
|
|
212
|
-
let held = 0;
|
|
213
|
-
return {
|
|
214
|
-
add(chunk) {
|
|
215
|
-
chunks.push(chunk);
|
|
216
|
-
held += chunk.length;
|
|
217
|
-
while (held - chunks[0].length >= limit) held -= chunks.shift().length;
|
|
218
|
-
},
|
|
219
|
-
get held() {
|
|
220
|
-
return held;
|
|
221
|
-
},
|
|
222
|
-
text: () => Buffer.concat(chunks).subarray(-limit).toString("utf8"),
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
export function runRecipe(recipe, { root, env, timeout = RECIPE_TIMEOUT_MS }) {
|
|
227
|
-
return new Promise((resolve) => {
|
|
228
|
-
const kept = tailKeeper();
|
|
229
|
-
let failure = null;
|
|
230
|
-
let late = false;
|
|
231
|
-
let limit;
|
|
232
|
-
let drain;
|
|
233
|
-
const settle = (status) => {
|
|
234
|
-
clearTimeout(limit);
|
|
235
|
-
clearTimeout(drain);
|
|
236
|
-
const code = failure || late ? null : status;
|
|
237
|
-
if (code === 0) return resolve({ id: recipe.id, outcome: "green" });
|
|
238
|
-
const why = failure ? `\n${failure.message}` : late ? `\nkilled at its time limit of ${timeout / 1000} s` : "";
|
|
239
|
-
const cannot = code === null || CANNOT_RUN.has(code);
|
|
240
|
-
resolve({ id: recipe.id, outcome: cannot ? "could not run" : "red", code, output: `${kept.text()}${why}` });
|
|
241
|
-
};
|
|
242
|
-
// As CI and the Stop-gate run a recipe, its stderr joined to its stdout so its lines keep their order.
|
|
243
|
-
const child = spawn("bash", ["-c", `exec 2>&1\n${recipe.run}`], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
244
|
-
const stop = () => {
|
|
245
|
-
child.stdout.destroy();
|
|
246
|
-
child.stderr.destroy();
|
|
247
|
-
};
|
|
248
|
-
child.stdout.on("data", kept.add);
|
|
249
|
-
child.stderr.on("data", kept.add);
|
|
250
|
-
child.on("exit", () => {
|
|
251
|
-
clearTimeout(limit);
|
|
252
|
-
// A process the shell left running is not waited for: once the pipe closes, the child's next write kills it with SIGPIPE, or fails with EPIPE where it ignores that signal, and a child that never writes keeps running.
|
|
253
|
-
drain = setTimeout(stop, DRAIN_MS);
|
|
254
|
-
});
|
|
255
|
-
child.on("error", (error) => {
|
|
256
|
-
failure = error;
|
|
257
|
-
settle(null);
|
|
258
|
-
});
|
|
259
|
-
child.on("close", settle);
|
|
260
|
-
limit = setTimeout(() => {
|
|
261
|
-
late = true;
|
|
262
|
-
child.kill();
|
|
263
|
-
stop();
|
|
264
|
-
}, timeout);
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
|
|
268
208
|
/** Resolves to `{ code, lines }`: the exit code and what to print, the first line saying what happened. */
|
|
269
209
|
export async function finish(options, { cwd = process.cwd(), env = process.env, readStdin, runOne = runRecipe } = {}) {
|
|
270
210
|
const stop = (code, ...lines) => ({ code, lines: [`finish: ${lines[0]}`, ...lines.slice(1)] });
|
|
@@ -374,10 +314,11 @@ export async function finish(options, { cwd = process.cwd(), env = process.env,
|
|
|
374
314
|
} catch (error) {
|
|
375
315
|
results.push({ id: recipe.id, outcome: "could not run", code: null, output: textOf(error) });
|
|
376
316
|
}
|
|
317
|
+
if (results.at(-1).interrupted) break;
|
|
377
318
|
}
|
|
378
319
|
const failed = results.filter((r) => r.outcome !== "green");
|
|
379
320
|
if (failed.length) {
|
|
380
|
-
const code = failed.some((r) => r.outcome === "red") ? 1 : 2;
|
|
321
|
+
const code = failed.some((r) => r.interrupted) ? 2 : failed.some((r) => r.outcome === "red") ? 1 : 2;
|
|
381
322
|
const named = failed.map((r) => r.id).join(", ");
|
|
382
323
|
return stop(
|
|
383
324
|
code,
|
package/cli/form.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { BOOT_CARD_LINE, BOOT_CARD_UNIT, GUIDANCE_RULES_DIR, leadsOfText } from
|
|
|
10
10
|
import { CHANGE_SECTIONS, readChanges, renderChanges } from "./index.mjs";
|
|
11
11
|
import { instructionsState, shellWord, splitCommand } from "./instructions.mjs";
|
|
12
12
|
import { recipeSet } from "./recipe-set.mjs";
|
|
13
|
+
import { isInside } from "./inside.mjs";
|
|
13
14
|
|
|
14
15
|
/** Anything that means the form could not be read. Carries no verdict. */
|
|
15
16
|
export class FormError extends Error {}
|
|
@@ -40,12 +41,23 @@ function present(file) {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
function readOrNull(file) {
|
|
44
|
+
function readOrNull(file, boundary) {
|
|
44
45
|
try {
|
|
45
|
-
|
|
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 });
|
|
@@ -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);
|