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