@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.
Files changed (100) hide show
  1. package/README.md +15 -4
  2. package/cli/README.md +61 -6
  3. package/cli/advisory.mjs +167 -53
  4. package/cli/cache-risk.mjs +69 -0
  5. package/cli/comments.mjs +8 -5
  6. package/cli/compile.mjs +781 -130
  7. package/cli/context.mjs +204 -43
  8. package/cli/doctor.mjs +248 -31
  9. package/cli/drills.mjs +56 -9
  10. package/cli/finish.md +34 -0
  11. package/cli/finish.mjs +8 -67
  12. package/cli/form.mjs +34 -18
  13. package/cli/frontmatter.mjs +39 -0
  14. package/cli/gate.mjs +14 -12
  15. package/cli/guidance-files.mjs +335 -0
  16. package/cli/handoff-policy.mjs +76 -0
  17. package/cli/html-entities.mjs +2130 -0
  18. package/cli/import-files.mjs +15 -0
  19. package/cli/index.mjs +77 -24
  20. package/cli/init-preview.mjs +553 -0
  21. package/cli/init.mjs +261 -46
  22. package/cli/instructions-journal.mjs +1185 -0
  23. package/cli/instructions.mjs +332 -57
  24. package/cli/ledger.mjs +230 -40
  25. package/cli/librarian.mjs +103 -19
  26. package/cli/persona-routing.mjs +209 -0
  27. package/cli/plugin-lint.mjs +77 -56
  28. package/cli/portulan.mjs +1 -1
  29. package/cli/recipe-output.mjs +91 -0
  30. package/cli/recipe-runner.md +68 -0
  31. package/cli/recipe-runner.mjs +131 -0
  32. package/cli/recipe-set.mjs +12 -3
  33. package/cli/review-meter.mjs +14 -9
  34. package/cli/ritual-contexts.mjs +103 -0
  35. package/cli/sessions.mjs +2 -1
  36. package/cli/spend-analysis.mjs +111 -0
  37. package/cli/spend-offer.md +53 -0
  38. package/cli/spend-offer.mjs +93 -0
  39. package/cli/stop-diagnostics.mjs +114 -0
  40. package/cli/stop-gate.mjs +133 -90
  41. package/cli/stop-policy.mjs +14 -0
  42. package/cli/symbols.mjs +55 -8
  43. package/cli/telemetry.mjs +66 -17
  44. package/cli/upgrade.mjs +62 -44
  45. package/cli/vendor.mjs +207 -125
  46. package/core/operating/context.md +401 -18
  47. package/core/operating/evolution.md +3 -2
  48. package/core/operating/loop.md +9 -4
  49. package/core/operating/sessions.md +6 -15
  50. package/core/personas/README.md +75 -0
  51. package/core/personas/implementer.md +3 -0
  52. package/core/personas/librarian.md +3 -0
  53. package/core/personas/reviewer.md +3 -0
  54. package/core/skills/clarify/SKILL.md +1 -1
  55. package/core/skills/consolidate/SKILL.md +1 -1
  56. package/core/templates/handoff.md +6 -2
  57. package/evals/README.md +23 -7
  58. package/evals/ab/personas/README.md +111 -0
  59. package/evals/ab/personas/acquisition.json +330 -0
  60. package/evals/ab/personas/bindings/implementer.txt +43 -0
  61. package/evals/ab/personas/bindings/librarian.txt +40 -0
  62. package/evals/ab/personas/bindings/reviewer.txt +41 -0
  63. package/evals/ab/personas/corpus.json +69 -0
  64. package/evals/ab/personas/grader.mjs +171 -0
  65. package/evals/ab/personas/implementer.json +774 -0
  66. package/evals/ab/personas/implementer.md +22 -0
  67. package/evals/ab/personas/librarian.json +758 -0
  68. package/evals/ab/personas/librarian.md +22 -0
  69. package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
  70. package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
  71. package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
  72. package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
  73. package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
  74. package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
  75. package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
  76. package/evals/ab/personas/reviewer.json +758 -0
  77. package/evals/ab/personas/reviewer.md +22 -0
  78. package/evals/releases/0.3.0.json +150 -0
  79. package/evals/releases/0.3.0.md +68 -0
  80. package/evals/releases/0.4.0.json +154 -0
  81. package/evals/releases/0.4.0.md +69 -0
  82. package/package.json +7 -2
  83. package/packs/.claude-plugin/plugin.json +2 -1
  84. package/packs/README.md +7 -4
  85. package/packs/rituals/checkpoints/README.md +35 -0
  86. package/packs/rituals/checkpoints/pack.json +3 -2
  87. package/packs/tools/README.md +8 -8
  88. package/packs/tools/local-llm/README.md +176 -0
  89. package/packs/tools/local-llm/pack.json +32 -0
  90. package/packs/tools/local-llm/personas/local-worker.md +44 -0
  91. package/packs/tools/local-llm/runner/local-llm.mjs +1779 -0
  92. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +88 -0
  93. package/packs/tools/local-llm/verify/README.md +18 -0
  94. package/spec/README.md +37 -3
  95. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
  96. package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
  97. package/spec/migrations/README.md +6 -0
  98. package/spec/pack.schema.json +12 -3
  99. package/spec/slots.md +272 -123
  100. package/spec/workspace.schema.json +67 -11
@@ -0,0 +1,91 @@
1
+ // Agent output for any recipe: one green count, complete failure streams.
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { Readable } from "node:stream";
7
+ import { pipeline } from "node:stream/promises";
8
+ import { execFileSync } from "node:child_process";
9
+ import { runRecipe } from "./recipe-runner.mjs";
10
+
11
+ function spools(tmpdir) {
12
+ const dir = fs.mkdtempSync(path.join(tmpdir, "portulan-recipe-output-"));
13
+ const descriptors = [];
14
+ try {
15
+ for (const name of ["stdout", "stderr"]) {
16
+ const file = path.join(dir, name);
17
+ descriptors.push(fs.openSync(file, "wx+", 0o600));
18
+ fs.unlinkSync(file);
19
+ }
20
+ fs.rmdirSync(dir);
21
+ return descriptors;
22
+ } catch (error) {
23
+ for (const fd of descriptors) { try { fs.closeSync(fd); } catch {} }
24
+ fs.rmSync(dir, { recursive: true, force: true });
25
+ throw error;
26
+ }
27
+ }
28
+
29
+ async function replay(fd, destination) {
30
+ function* chunks() {
31
+ const buffer = Buffer.alloc(64 * 1024);
32
+ let offset = 0;
33
+ for (;;) {
34
+ const bytes = fs.readSync(fd, buffer, 0, buffer.length, offset);
35
+ if (bytes === 0) break;
36
+ offset += bytes;
37
+ yield Buffer.from(buffer.subarray(0, bytes));
38
+ }
39
+ }
40
+ await pipeline(Readable.from(chunks()), destination, { end: false });
41
+ }
42
+
43
+ export async function execute(command, { root = process.cwd(), env = process.env, tmpdir = os.tmpdir(), stdout = process.stdout, stderr = process.stderr, timeout = 600_000 } = {}) {
44
+ let descriptors = [];
45
+ try {
46
+ if (typeof command !== "string" || !command.trim()) throw new Error("one nonempty recipe command is required");
47
+ descriptors = spools(tmpdir);
48
+ const inherited = env.PORTULAN_RECIPE_MANAGED === "1";
49
+ const groupId = Number(env.PORTULAN_RECIPE_GROUP);
50
+ if (inherited && process.platform !== "win32") {
51
+ const actual = Number(execFileSync("ps", ["-o", "pgid=", "-p", String(process.pid)], { encoding: "utf8", timeout: 1000, stdio: ["ignore", "pipe", "pipe"] }).trim());
52
+ if (!Number.isInteger(groupId) || groupId <= 1 || actual !== groupId) throw new Error("inherited recipe group does not own this executor");
53
+ }
54
+ const result = await runRecipe({ id: "recipe", run: command }, {
55
+ root, env, timeout: inherited ? 0 : timeout, mergeOutput: false, inheritGroup: inherited, groupId,
56
+ onOutput: (name, chunk) => {
57
+ const fd = descriptors[name === "stdout" ? 0 : 1];
58
+ let offset = 0;
59
+ while (offset < chunk.length) {
60
+ let wrote;
61
+ try { wrote = fs.writeSync(fd, chunk, offset, chunk.length - offset); }
62
+ catch (error) { stderr.write(`recipe-output: could not run — ${error.message}\n`); throw error; }
63
+ if (wrote <= 0) throw new Error("output spool made no write progress");
64
+ offset += wrote;
65
+ }
66
+ },
67
+ });
68
+ if (result.outcome !== "green") {
69
+ await replay(descriptors[0], stdout);
70
+ await replay(descriptors[1], stderr);
71
+ if (result.diagnostics?.length) stderr.write(`recipe-output: could not run — ${result.diagnostics.join("; ")}\n`);
72
+ }
73
+ for (const fd of descriptors) fs.closeSync(fd);
74
+ descriptors = [];
75
+ if (result.outcome === "green") { await pipeline(Readable.from(["GREEN — 1 recipe passed\n"]), stdout, { end: false }); return 0; }
76
+ return result.code ?? 2;
77
+ } catch (error) {
78
+ stderr.write(`recipe-output: could not run — ${error.message}\n`);
79
+ return 2;
80
+ } finally {
81
+ for (const fd of descriptors) { try { fs.closeSync(fd); } catch {} }
82
+ }
83
+ }
84
+
85
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
86
+ const args = process.argv.slice(2);
87
+ if (args.length !== 2 || args[0] !== "--") {
88
+ process.stderr.write("usage: node cli/recipe-output.mjs -- <recipe-command>\n");
89
+ process.exitCode = 2;
90
+ } else process.exitCode = await execute(args[1]);
91
+ }
@@ -0,0 +1,68 @@
1
+ # Recipe execution and cleanup
2
+
3
+ `finish` and the Stop-gate share `recipe-runner.mjs`. Finish allows ten minutes.
4
+ Stop allows ninety seconds unless its trusted workspace declares
5
+ `verify.stopTimeout` in seconds at spec 2.14 or later. Recompile after declaring it:
6
+ the compiled Stop hook allows that deadline plus sixty seconds for other checks
7
+ and cleanup. An invalid declaration prevents execution. Output is drained as it arrives; only the last
8
+ 64 KiB and at most one incoming chunk are retained.
9
+
10
+ Stop reads both command and deadline from the hook-told workspace and runs in the
11
+ hook-told root. The payload's session worktree selects its record/work checks,
12
+ and cannot choose executable policy. Verification of that other worktree remains
13
+ the separate #311 trust question; a green here covers the told tree.
14
+
15
+ After three actual recipe refusals in a session, further Stops skip that recipe
16
+ and release explicitly unverified/RED. Repairing files then does not resume
17
+ automatic verification in that session: run it explicitly before claiming done,
18
+ or use a new session. A still-pending handoff at its cap, nine total refusals, or
19
+ the thirteenth consecutive hook-provoked Stop also suppresses recipe execution.
20
+ Skipped runs never charge a recipe failure or become green evidence. A handoff
21
+ that clears before the other bounds allows execution again; ordinary Stops reset
22
+ the chain, and a measured green before the recipe cap clears its streak.
23
+
24
+ The deadline tests inject a long selection and exercise a short live timeout;
25
+ they demonstrate configuration and cleanup, not a real five-minute recipe or a
26
+ live host's outer-timeout enforcement. Counter-file failure/concurrent-write
27
+ limits remain; this is not a cross-session scheduler or a cache of green trees.
28
+
29
+ On POSIX, Bash starts in a detached process group. A timeout sends TERM to the group;
30
+ SIGINT, SIGTERM and SIGHUP received by the active runner are forwarded to it. After
31
+ 250 ms the runner sends KILL to that group, even if Bash has already exited or closed
32
+ its pipes. Repeated interruptions do not restart that grace period. Cleanup allows
33
+ one further second for closure and reports failures as could-not-run. Finish retains
34
+ an observed interruption, stops its recipe loop, rolls back its provisional commit
35
+ and pushes nothing.
36
+
37
+ On Windows, the runner checks an absolute system `taskkill.exe` path before starting
38
+ Bash. Cleanup invokes `/PID <owned pid> /T /F` with a one-second command timeout;
39
+ this is forced tree termination rather than POSIX signal forwarding. Missing tools
40
+ and termination errors produce could-not-run diagnostics. Command construction and
41
+ error paths have unit controls here; native Windows execution has not been tested.
42
+
43
+ A normally exiting recipe keeps the existing behavior: inherited pipes are drained
44
+ for at most one second, and background processes can continue. Descendants that
45
+ intentionally escape the POSIX group are outside its cleanup. OS signal denial,
46
+ runner SIGKILL and failures outside active recipe execution cannot promise cleanup
47
+ or commit rollback. This runner does not impose a deadline on finish's other Git
48
+ operations.
49
+
50
+ `recipe-set --agent` wraps each command in `recipe-output.mjs`. That executor
51
+ spools stdout and stderr separately to private descriptors, unlinks their names
52
+ before starting the child, and replays all bytes on failure. Green is one counted
53
+ line. The runner's ordinary tail remains bounded; the executor's failure stream
54
+ is complete at its own boundary. Output capture, replay or setup failures return
55
+ could-not-run, never green.
56
+
57
+ The runner marks the environment of its owned child. A nested executor shares
58
+ that process group and delegates its deadline to the outer runner. On POSIX it
59
+ checks its actual group with `ps` before inheriting ownership, so the outer
60
+ timeout or interruption reaches every descendant even if the executor is killed.
61
+ An inner capture failure also terminates that owned group. Anonymous descriptors
62
+ leave no named temporary logs behind on a hard kill. Standalone executors own
63
+ their own recipe group and a ten-minute deadline. Native Windows execution of
64
+ this output mode remains untested, as does its forced-termination filesystem
65
+ behavior; inability to create anonymous spools refuses execution.
66
+
67
+ The platform mechanisms follow [Node's detached-process documentation](https://nodejs.org/api/child_process.html#optionsdetached)
68
+ and [Microsoft's taskkill reference](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/taskkill).
@@ -0,0 +1,131 @@
1
+ // Shared asynchronous recipe execution, bounded output and process-tree cleanup.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { spawn, execFileSync } from "node:child_process";
5
+
6
+ const CANNOT_RUN = new Set([2, 126, 127]);
7
+ export const INTERRUPTIONS = ["SIGINT", "SIGTERM", "SIGHUP"];
8
+
9
+ /** A stream's last `limit` bytes, retaining at most `limit` and one chunk. */
10
+ export function tailKeeper(limit = 64 * 1024) {
11
+ const chunks = [];
12
+ let held = 0;
13
+ return {
14
+ add(chunk) {
15
+ chunks.push(chunk);
16
+ held += chunk.length;
17
+ while (held - chunks[0].length >= limit) held -= chunks.shift().length;
18
+ },
19
+ get held() { return held; },
20
+ text: () => Buffer.concat(chunks).subarray(-limit).toString("utf8"),
21
+ };
22
+ }
23
+
24
+ export function windowsTaskkill(env, access = fs.accessSync) {
25
+ const root = env.SystemRoot || env.SYSTEMROOT || "C:\\Windows";
26
+ if (!path.win32.isAbsolute(root)) throw new Error("taskkill requires an absolute Windows system root");
27
+ const command = path.win32.join(root, "System32", "taskkill.exe");
28
+ access(command, fs.constants.F_OK);
29
+ return command;
30
+ }
31
+
32
+ /** Signal only a recipe-owned group, or force its Windows tree with a bounded system utility. */
33
+ export function terminateTree(pid, signal, { platform = process.platform, kill = process.kill.bind(process), exec = execFileSync, taskkill } = {}) {
34
+ if (!Number.isInteger(pid) || pid <= 0) throw new Error("recipe has no valid process id");
35
+ if (platform === "win32") {
36
+ if (!taskkill) throw new Error("taskkill was not preflighted");
37
+ exec(taskkill, ["/PID", String(pid), "/T", "/F"], { timeout: 1000, windowsHide: true, stdio: "pipe" });
38
+ } else {
39
+ try { kill(-pid, signal); } catch (error) { if (error.code !== "ESRCH") throw error; }
40
+ }
41
+ }
42
+
43
+ export function runRecipe(recipe, { root, env = process.env, timeout = 600_000, onOutput = null, mergeOutput = true, inheritGroup = false, groupId = null }) {
44
+ return new Promise((resolve) => {
45
+ const kept = tailKeeper();
46
+ const failures = [];
47
+ let child, taskkill, limit, grace, drain;
48
+ let settled = false, closed = false, stopping = false, escalated = false, late = false;
49
+ let status = null, interrupted = null;
50
+ const handlers = new Map();
51
+ const record = (error) => failures.push(String(error?.message ?? error));
52
+ const terminate = (signal) => terminateTree(inheritGroup && process.platform !== "win32" ? groupId : child.pid, signal, { taskkill });
53
+ const pipes = () => { child?.stdout?.destroy(); child?.stderr?.destroy(); };
54
+ const settle = () => {
55
+ if (settled) return;
56
+ settled = true;
57
+ for (const timer of [limit, grace, drain]) clearTimeout(timer);
58
+ for (const [signal, handler] of handlers) process.removeListener(signal, handler);
59
+ const code = failures.length || late || interrupted ? null : status;
60
+ if (code === 0) return resolve({ id: recipe.id, outcome: "green" });
61
+ const why = [
62
+ ...failures,
63
+ ...(interrupted ? [`interrupted by ${interrupted}`] : []),
64
+ ...(late ? [`killed at its time limit of ${timeout / 1000} s`] : []),
65
+ ];
66
+ resolve({ id: recipe.id, outcome: code === null || CANNOT_RUN.has(code) ? "could not run" : "red", code,
67
+ output: kept.text() + (why.length ? `\n${why.join("\n")}` : ""),
68
+ ...(onOutput ? { diagnostics: why } : {}),
69
+ ...(late ? { timedOut: true } : {}),
70
+ ...(interrupted ? { interrupted } : {}) });
71
+ };
72
+ const hardStop = () => {
73
+ try { terminate("SIGKILL"); }
74
+ catch (error) {
75
+ record(error);
76
+ try { child.kill("SIGKILL"); } catch (error) { record(error); }
77
+ }
78
+ escalated = true;
79
+ if (closed) return settle();
80
+ drain = setTimeout(() => {
81
+ record(new Error("recipe did not close after forced termination"));
82
+ pipes();
83
+ child.unref();
84
+ settle();
85
+ }, 1000);
86
+ };
87
+ const stop = (signal) => {
88
+ if (settled) return;
89
+ if (signal && interrupted === null) interrupted = signal;
90
+ if (stopping) return;
91
+ stopping = true;
92
+ clearTimeout(limit);
93
+ clearTimeout(drain);
94
+ if (process.platform === "win32") return hardStop();
95
+ try { terminate(signal || "SIGTERM"); } catch (error) { record(error); }
96
+ // The group may outlive its leader and pipes: neither event cancels escalation.
97
+ grace = setTimeout(hardStop, 250);
98
+ };
99
+ try {
100
+ for (const signal of INTERRUPTIONS) {
101
+ const handler = () => stop(signal);
102
+ handlers.set(signal, handler);
103
+ process.on(signal, handler);
104
+ }
105
+ if (process.platform === "win32") taskkill = windowsTaskkill(env);
106
+ if (inheritGroup && process.platform !== "win32" && (!Number.isInteger(groupId) || groupId <= 1)) throw new Error("inherited recipe group is invalid");
107
+ child = spawn("bash", ["-c", `${mergeOutput ? "exec 2>&1\n" : ""}${inheritGroup ? "" : "export PORTULAN_RECIPE_GROUP=$$\n"}${recipe.run}`], {
108
+ cwd: root, env: { ...env, PORTULAN_RECIPE_MANAGED: "1" }, detached: process.platform !== "win32" && !inheritGroup, windowsHide: true, stdio: ["ignore", "pipe", "pipe"],
109
+ });
110
+ } catch (error) { record(error); settle(); return; }
111
+ for (const [name, stream] of [["stdout", child.stdout], ["stderr", child.stderr]]) {
112
+ stream.on("data", (chunk) => {
113
+ kept.add(chunk);
114
+ try { onOutput?.(name, chunk); } catch (error) { record(error); stop(); }
115
+ });
116
+ stream.on("error", (error) => { record(error); stop(); });
117
+ }
118
+ child.on("error", (error) => { record(error); if (!child.pid) settle(); else stop(); });
119
+ child.on("exit", (code) => {
120
+ status = code;
121
+ clearTimeout(limit);
122
+ if (!stopping) drain = setTimeout(() => { pipes(); settle(); }, 1000);
123
+ });
124
+ child.on("close", (code) => {
125
+ status = code;
126
+ closed = true;
127
+ if (!stopping || escalated) settle();
128
+ });
129
+ if (!inheritGroup) limit = setTimeout(() => { late = true; stop(); }, timeout);
130
+ });
131
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
- import { pathToFileURL } from "node:url";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
6
 
7
7
  import { resolvePack, rootPlan, shadowedCopy } from "./compile.mjs";
8
8
  import { AUTO, discoverPackRoots, namedWithAuto } from "./discover.mjs";
@@ -23,6 +23,8 @@ const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
23
23
 
24
24
  /** The token a composed `run` uses to name its own pack's files. */
25
25
  const PACK_ROOT_TOKEN = "${PACK_ROOT}";
26
+ const quote = (word) => `'${word.replaceAll("'", "'\\''")}'`;
27
+ export const agentCommand = (command) => `${quote(process.execPath)} ${quote(path.join(path.dirname(fileURLToPath(import.meta.url)), "recipe-output.mjs"))} -- ${quote(command)}`;
26
28
 
27
29
  /** Its `:` is outside `$defs/slug`, so a composed id never equals a workspace id or `verify.default`. */
28
30
  export function composedId(packRef, id) {
@@ -115,6 +117,13 @@ export function recipeSet(manifest, options = {}) {
115
117
  if (recipes.length === 0) return refuse("the manifest yields no verify recipes — refusing to report green");
116
118
 
117
119
  const declaredDefault = manifest?.verify?.default;
120
+ if (options.agent) {
121
+ for (const recipe of recipes) {
122
+ recipe.run = agentCommand(recipe.run);
123
+ const bad = invalidRunnable(recipe.id, recipe.run, "agent executor", { requireSlug: false });
124
+ if (bad) return refuse(bad);
125
+ }
126
+ }
118
127
  return { ok: true, recipes, default: declaredDefault == null ? declaredDefault : String(declaredDefault) };
119
128
  }
120
129
 
@@ -228,7 +237,7 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
228
237
  stderr.write(`${err.message}\n`);
229
238
  return 2;
230
239
  }
231
- const set = recipeSet(manifest, { resolve });
240
+ const set = recipeSet(manifest, { resolve, agent: argv.includes("--agent") });
232
241
  if (!set.ok) {
233
242
  stderr.write(`${set.reason}\n`);
234
243
  return set.exitCode;
@@ -238,5 +247,5 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
238
247
  }
239
248
 
240
249
  if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
241
- process.exit(run(process.argv.slice(2)));
250
+ process.exitCode = run(process.argv.slice(2));
242
251
  }
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // The review-loop meter: the figures that bound this repository's review loop, derived from its reviews.
2
+ // The review-loop meter: the historical figures of this repository's Copilot review loop, derived from its reviews.
3
3
  //
4
4
  // Fix-rounds are not derivable from the API: whether a push answers a submission is a fact about its contents.
5
5
 
@@ -13,7 +13,7 @@ export const SNAPSHOT_VERSION = "1";
13
13
  // A prefix, not an equality: the reviewer is `copilot-pull-request-reviewer[bot]` on reviews and `Copilot` on comments.
14
14
  export const REVIEWER_PREFIX = "copilot";
15
15
 
16
- // The review-loop rule retires once submissions per pull request stay below this for a full milestone.
16
+ // The retirement threshold the review-loop rule stated: submissions per pull request below this for a full milestone.
17
17
  export const RETIRE_THRESHOLD = 2.0;
18
18
 
19
19
  const isReviewer = (login) =>
@@ -174,10 +174,14 @@ export function renderRegister(m) {
174
174
  lines.push("> it is regenerated and byte-compared, so a hand-edit survives exactly until the next run.");
175
175
  lines.push(">");
176
176
  lines.push("> **Every figure here is in SUBMISSION units** — every review the reviewer submits, one");
177
- lines.push("> per push, including on the branch as opened. It is not the fix-round unit that");
178
- lines.push("> `../../.portulan/memory/a-review-loop-needs-a-bound.md` rule 4 bounds, and no figure");
179
- lines.push("> here may be read as one. See `../../cli/review-meter.mjs` for why fix-rounds are not");
180
- lines.push("> derivable from the API at all.");
177
+ lines.push("> per push, including on the branch as opened. It is not the fix-round unit that rule 4");
178
+ lines.push("> of the retired `a-review-loop-needs-a-bound.md` bounded, and no figure here may be read");
179
+ lines.push("> as one. See `../../cli/review-meter.mjs` for why fix-rounds are not derivable from the");
180
+ lines.push("> API at all.");
181
+ lines.push(">");
182
+ lines.push("> **This register is historical.** It measures the Copilot review loop, which left this");
183
+ lines.push("> repository's review path on 2026-10-30, by the maintainer's decision; the record that");
184
+ lines.push("> bounded the loop retired with it.");
181
185
  lines.push("");
182
186
  lines.push(`- **Repository:** \`${m.repository}\``);
183
187
  lines.push(`- **Captured:** ${m.captured}`);
@@ -210,8 +214,9 @@ export function renderRegister(m) {
210
214
  lines.push("## Against the record's own retirement threshold");
211
215
  lines.push("");
212
216
  lines.push(
213
- `\`a-review-loop-needs-a-bound.md\` retires when submissions per pull request measures below ` +
214
- `**${RETIRE_THRESHOLD.toFixed(1)}** for a full milestone. This window measures ` +
217
+ `\`a-review-loop-needs-a-bound.md\` was to retire when submissions per pull request measured below ` +
218
+ `**${RETIRE_THRESHOLD.toFixed(1)}** for a full milestone, and retired on 2026-10-30 on its other ` +
219
+ `condition, Copilot review leaving the review path. This window measures ` +
215
220
  `**${round2(m.submissionsPerPullRequest)}**, which is ` +
216
221
  `${m.belowRetireThreshold === null ? "unmeasured" : m.belowRetireThreshold ? "**below**" : "**at or above**"} it.`,
217
222
  );
@@ -221,7 +226,7 @@ export function renderRegister(m) {
221
226
  "field the API carries, so this tool measures a window of merged pull requests and the " +
222
227
  "record's *for a full milestone* clause is not evaluated here. Reading this row as the " +
223
228
  "retirement condition met would be reading a different measure than the one the record " +
224
- "states.",
229
+ "stated.",
225
230
  );
226
231
  lines.push("");
227
232
  lines.push("## Per pull request");
@@ -0,0 +1,103 @@
1
+ // Own native settings without inventing a gate policy.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ export const CONTEXT_CAP_ENV = "CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS";
6
+ const object = v => v !== null && typeof v === "object" && !Array.isArray(v);
7
+ export function readContextCap(manifest, pack) {
8
+ const c = manifest?.contributes?.contexts;
9
+ if (c === undefined) return null;
10
+ const bad = why => { throw Error(`\`${pack}\` contributes.contexts ${why}`); };
11
+ if (manifest.category !== "rituals" || !pack.startsWith("rituals/")) bad("is reserved for ritual packs");
12
+ if (manifest?.portulan?.pack !== "1.1") bad("requires supported Pack Definition 1.1");
13
+ if (!object(c) || Object.keys(c).length !== 1 || !Object.hasOwn(c, "max_concurrent_subagents")) bad("requires only max_concurrent_subagents");
14
+ const n = c.max_concurrent_subagents;
15
+ if (!Number.isSafeInteger(n) || n < 1) bad("max_concurrent_subagents must be a positive safe integer");
16
+ return n;
17
+ }
18
+ export function contextCaps(contributions, unresolved = []) {
19
+ const sources = contributions.filter(c => c.contextCap != null).map(c => {
20
+ if (!Number.isSafeInteger(c.contextCap) || c.contextCap < 1) throw Error("invalid contributes.contexts count");
21
+ return { pack: c.pack, limit: c.contextCap, origin: c.origin ?? null, version: c.version ?? null };
22
+ }).sort((a, b) => a.pack.localeCompare(b.pack));
23
+ if (sources.length && unresolved.length) throw Error("ritual contexts cap cannot compile while composed packs remain unresolved; their contributions are unknown");
24
+ return { limit: sources.length ? sources.reduce((n, s) => Math.min(n, s.limit), Infinity) : null, sources, unknown: unresolved.length };
25
+ }
26
+ export function contextCapLines(cap) {
27
+ if (cap.limit === null) return cap.unknown ? ["No context cap found in resolved packs; unresolved pack contributions are unknown."] : [];
28
+ return [
29
+ `Claude Code: ${CONTEXT_CAP_ENV}=${cap.limit}, minimum of ${cap.sources.map(s => `${s.pack}=${s.limit}`).join(", ")}; compiled for ordinary fresh native Agent admission within one session.`,
30
+ "Parent excluded; project setting applies across rituals. Total children, requests, time and tokens are unbounded. Resumes and /subtask forks can exceed the admission limit; workflows/team agents have independent limits. Higher-precedence settings and native experimental/ultracode bypasses can defeat it. Measured on Claude Code 2.1.321; not a universal hard ceiling.",
31
+ "GitHub repository ruleset and AGENTS guidance: context cap not compiled by these adapters.",
32
+ ];
33
+ }
34
+ const keys = (v, required, optional = []) => object(v) && required.every(k => Object.hasOwn(v, k)) && Object.keys(v).every(k => [...required, ...optional].includes(k));
35
+ const canonical = v => Array.isArray(v) ? v.map(canonical) : object(v) ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])])) : v;
36
+ const fingerprint = v => {
37
+ const payload = structuredClone(v); delete payload.$portulan.digest;
38
+ return createHash("sha256").update(JSON.stringify(canonical(payload))).digest("hex");
39
+ };
40
+ const sourcesValid = sources => Array.isArray(sources) && sources.length > 0 && sources.every((s, i) =>
41
+ keys(s, ["pack", "limit", "origin", "version"]) && /^rituals\/[a-z0-9][a-z0-9-]*$/.test(s.pack) && Number.isSafeInteger(s.limit) && s.limit > 0 &&
42
+ [null, "tree", "outside-tree", "discovered"].includes(s.origin) && (s.version === null || typeof s.version === "string") &&
43
+ (i === 0 || sources[i - 1].pack.localeCompare(s.pack) < 0));
44
+ const capValid = (env, sources) => keys(env, [CONTEXT_CAP_ENV]) && sourcesValid(sources) && env[CONTEXT_CAP_ENV] === String(Math.min(...sources.map(s => s.limit)));
45
+ const commandValid = (v, action) => keys(v, ["type", "command"]) && v.type === "command" && typeof v.command === "string" && new RegExp(String.raw`^node "[^"\r\n]+[/\\]advisory\.mjs" ${action}(?: --(?:read|write-5m|write-1h|horizon|resume-tokens) [0-9.eE+-]+)*$`).test(v.command);
46
+ const hookValid = (v, action) => Array.isArray(v) && v.length === 1 && keys(v[0], ["hooks"]) && Array.isArray(v[0].hooks) && v[0].hooks.length === 1 && commandValid(v[0].hooks[0], action);
47
+ function adviceOwned(v) {
48
+ const m = v?.$portulan;
49
+ return keys(v, ["$portulan", "hooks", "statusLine"], ["includeGitInstructions", "promptCacheTtl", "env"]) &&
50
+ keys(m, ["generated", "kind", "digest", "source", "warning"], ["sessions", "spend", "packs", "contexts"]) &&
51
+ m.generated === "cli/compile.mjs" && m.kind === "advisory" && /^[a-f0-9]{64}$/.test(m.digest) && m.digest === fingerprint(v) &&
52
+ typeof m.source === "string" && !path.isAbsolute(m.source) && !m.source.split("/").includes("..") && /(?:^|\/)workspace\.json$/.test(m.source) && typeof m.warning === "string" &&
53
+ ["sessions", "spend"].every(k => !Object.hasOwn(m, k) || m[k] === m.source) &&
54
+ (!Object.hasOwn(m, "packs") || (Array.isArray(m.packs) && m.packs.length > 0 && m.packs.every((p, i) => keys(p, ["pack", "origin", "version"]) && typeof p.pack === "string" && ["tree", "outside-tree", "discovered"].includes(p.origin) && (p.version === null || typeof p.version === "string") && (i === 0 || m.packs[i - 1].pack.localeCompare(p.pack) < 0)))) &&
55
+ (!Object.hasOwn(v, "includeGitInstructions") || typeof v.includeGitInstructions === "boolean") && (!Object.hasOwn(v, "promptCacheTtl") || ["5m", "1h"].includes(v.promptCacheTtl)) &&
56
+ (Object.hasOwn(v, "env") ? capValid(v.env, m.contexts) : !Object.hasOwn(m, "contexts")) &&
57
+ keys(v.hooks, ["PostToolUse", "UserPromptSubmit"], ["Stop"]) && hookValid(v.hooks.PostToolUse, "tool") && hookValid(v.hooks.UserPromptSubmit, "prompt") &&
58
+ (!Object.hasOwn(v.hooks, "Stop") || hookValid(v.hooks.Stop, "stop")) && commandValid(v.statusLine, "status");
59
+ }
60
+ export function capOnlyPlan(root, cap, advice = null) {
61
+ const file = path.join(root, ".claude/settings.json");
62
+ const parent = path.dirname(file), desired = advice !== null || cap.limit !== null;
63
+ let parentStat;
64
+ try { parentStat = fs.lstatSync(parent); } catch (e) { if (e.code !== "ENOENT") throw e; }
65
+ if (parentStat && (!parentStat.isDirectory() || parentStat.isSymbolicLink())) {
66
+ if (!desired) return null;
67
+ throw Error("native settings parent is not an owned directory");
68
+ }
69
+ let current = null, old = null, links = 1;
70
+ try {
71
+ const stat = fs.lstatSync(file);
72
+ if (!stat.isFile() || stat.isSymbolicLink()) {
73
+ if (!desired) return null;
74
+ throw Error("native settings is not a regular owned file");
75
+ }
76
+ links = stat.nlink;
77
+ current = fs.readFileSync(file, "utf8");
78
+ try { old = JSON.parse(current); } catch { /* foreign */ }
79
+ } catch (e) { if (e.code !== "ENOENT") throw e; }
80
+ const generated = old?.$portulan?.generated === "cli/compile.mjs";
81
+ const marked = generated && old.$portulan.kind === "ritual-contexts";
82
+ const advisory = generated && old.$portulan.kind === "advisory";
83
+ if (advisory && advice === null) return null;
84
+ if (links !== 1 && (desired || marked)) throw Error("native settings is multiply linked; refusing replacement or removal");
85
+ if (cap.limit === null && !marked && !advisory && generated && Object.hasOwn(old?.env ?? {}, CONTEXT_CAP_ENV)) throw Error("previous policy-generated settings retain a stale context cap after policy removal; preserve and resolve those settings explicitly, or restore the policy and recompile");
86
+ const legacy = marked && keys(old, ["$portulan", "env"]) && keys(old.$portulan, ["generated", "kind", "sources"]) && capValid(old.env, old.$portulan.sources);
87
+ const owned = legacy || (advisory && adviceOwned(old));
88
+ if (current !== null && !owned && (desired || marked)) throw Error("native compile refuses settings with edited, unrelated or unowned content; preserve it and resolve ownership explicitly");
89
+ if (!desired && !owned) return null;
90
+ const value = advice !== null ? structuredClone(advice) : cap.limit === null ? null : {
91
+ $portulan: { generated: "cli/compile.mjs", kind: "ritual-contexts", sources: cap.sources },
92
+ env: { [CONTEXT_CAP_ENV]: String(cap.limit) },
93
+ };
94
+ if (advice !== null) { value.$portulan.kind = "advisory"; value.$portulan.digest = fingerprint(value); }
95
+ return { file, current, text: value === null ? null : JSON.stringify(value, null, 2) + "\n" };
96
+ }
97
+ export function emitCapOnly(plan, { check, say }) {
98
+ if (!plan || plan.current === plan.text) return 0;
99
+ if (check) { say("RED — native Claude settings drifted; recompile"); return 1; }
100
+ if (plan.text === null) { fs.rmSync(plan.file); say(`removed ${plan.file} — no ritual cap remains`); }
101
+ else { fs.mkdirSync(path.dirname(plan.file), { recursive: true }); fs.writeFileSync(plan.file, plan.text); say(`wrote ${plan.file}`); }
102
+ return 0;
103
+ }
package/cli/sessions.mjs CHANGED
@@ -74,7 +74,8 @@ export function sessionsLine(manifest) {
74
74
  parts.push(multipliersWords(spend.multipliers) ?? "multipliers the general ones");
75
75
  const horizon = horizonWords(spend.horizon);
76
76
  if (horizon !== null) parts.push(horizon);
77
- if (spend.restart === "block") parts.push("a turn's end held once at the restart threshold");
77
+ if (spend.resume?.tokens !== undefined) parts.push(`resume expense ${spend.resume.tokens} input-equivalent tokens`);
78
+ if (spend.restart === "block") parts.push("a turn's end held once only with explicit horizon, resume expense and positive estimated savings");
78
79
  if (lifetime === null && workspace.kind === "repository") {
79
80
  parts.push("`portulan upgrade` prints the five-minute lifetime's offer and its trade-off");
80
81
  }
@@ -0,0 +1,111 @@
1
+ // Class indices compare stated assumptions, never bills or route quality.
2
+ import { CLASSES, GENERAL_READ, WRITE_BY_LIFETIME, RECORDED_ROLES, SPEND_FIGURES, restartSaving } from "./ledger.mjs";
3
+
4
+ const classes = ["uncached", "written", "read", "output"];
5
+ const efforts = ["low", "medium", "high", "max"];
6
+ const families = ["opus", "sonnet", "haiku", "fable", "mythos"];
7
+ const causes = ["compaction", "model change", "effort change", "lifetime lapsed", "unexplained"];
8
+ const grouped = n => n.toLocaleString("en-US", { maximumFractionDigits: 0 });
9
+ const estimate = n => n.toLocaleString("en-US", { maximumSignificantDigits: 8 });
10
+ const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
11
+ const count = n => Number.isSafeInteger(n) && n >= 0;
12
+ const leaders = scores => {
13
+ const max = Math.max(...Object.values(scores));
14
+ return max <= 0 ? [] : classes.filter(k => Math.abs(scores[k] - max) <= max * Number.EPSILON * 8);
15
+ };
16
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
17
+
18
+ function scopeLines(label, contexts, rates) {
19
+ const total = Object.fromEntries(CLASSES.map(k => [k, 0]));
20
+ const roles = new Map(), inferred = Object.fromEntries(causes.map(k => [k, 0]));
21
+ let contributing = 0, requests = 0;
22
+ try {
23
+ for (const c of contexts) {
24
+ if (c.requests.length) contributing++;
25
+ for (const r of c.requests) {
26
+ if (r.usageMissing || r.usageUnsafe) return [`${label}: class analysis unassessed — usage counters are missing or invalid; totals are partial.`];
27
+ requests++;
28
+ for (const k of CLASSES) {
29
+ if (!count(r[k]) || !count(total[k] + r[k])) throw Error();
30
+ total[k] += r[k];
31
+ }
32
+ if (r.rebuild) inferred[causes.includes(r.rebuild.cause) ? r.rebuild.cause : "unexplained"]++;
33
+ const role = RECORDED_ROLES.includes(r.role) ? r.role : c.agent === null ? "main" : "unattributed subagent";
34
+ if (!roles.has(role)) roles.set(role, { output: 0, efforts: {}, models: {} });
35
+ const into = roles.get(role), effort = efforts.includes(r.effort) ? r.effort : "unknown";
36
+ const family = families.find(f => typeof r.model === "string" && new RegExp(`^(?:claude-)?${f}(?:-|$)`).test(r.model)) ?? "unknown";
37
+ into.output += r.output;
38
+ into.efforts[effort] = (into.efforts[effort] ?? 0) + r.output;
39
+ into.models[family] = (into.models[family] ?? 0) + r.output;
40
+ }
41
+ }
42
+ if (!requests) return [`${label}: no host requests in this scope; local receipts excluded.`];
43
+ const written = total.written1h + total.written5m + total.writtenUnknown;
44
+ if (!count(written)) throw Error();
45
+ const knownWrites = total.written1h * rates.write["1h"] + total.written5m * rates.write["5m"];
46
+ const low = { uncached: total.uncached, written: knownWrites + total.writtenUnknown * Math.min(...Object.values(rates.write)), read: total.read * rates.read, output: total.output * 5 };
47
+ const high = { ...low, written: knownWrites + total.writtenUnknown * Math.max(...Object.values(rates.write)) };
48
+ if (![...Object.values(low), ...Object.values(high)].every(Number.isFinite)) throw Error();
49
+ const first = leaders(low), last = leaders(high), certain = same(first, last);
50
+ const index = classes.map(k => `${k} ~${estimate(low[k])}${low[k] === high[k] ? "" : `..${estimate(high[k])}`}`).join(", ");
51
+ const verdict = certain ? first.length ? `dominant ${first.join(" + ")}${first.length > 1 ? " (tie)" : ""}` : "no nonzero dominant class"
52
+ : `uncertain dominance (lower-bound leaders ${first.join(" + ")}; upper-bound leaders ${last.join(" + ")})`;
53
+ const lines = [
54
+ `${label} tokens: ${grouped(requests)} requests; uncached ${grouped(total.uncached)}, written ${grouped(written)}, read ${grouped(total.read)}, output ${grouped(total.output)}.`,
55
+ `${label} class index: ${index}; ${verdict}.`,
56
+ ];
57
+ if (total.writtenUnknown) lines.push(`${label}: ${grouped(total.writtenUnknown)} write tokens have unstated TTL; index spans min/max of both write rates.`);
58
+ for (const key of new Set([...first, ...last])) {
59
+ const lever = {
60
+ read: "session length and tool output; compare a restart only with supported estimates",
61
+ written: `fresh contexts, TTL gaps and cache busts; ${grouped(contributing)} contributing contexts; inferred rebuilds: ${causes.map(k => `${k} ${inferred[k]}`).join(", ")}`,
62
+ output: "effort and model by recorded role; alternate-route savings unpriced without quality evidence",
63
+ uncached: "caching may not be engaged; inspect uncached tails and cache controls",
64
+ }[key];
65
+ lines.push(`${label} ${key} lever: ${lever}.`);
66
+ }
67
+ if (first.includes("output") || last.includes("output")) {
68
+ for (const [role, r] of [...roles].sort(([a], [b]) => compare(a, b))) {
69
+ const histogram = values => Object.entries(values).filter(([, n]) => n > 0).sort(([a], [b]) => compare(a, b)).map(([k, n]) => `${k} ${grouped(n)}`).join(", ") || "none";
70
+ lines.push(`${label} output role ${role}: ${grouped(r.output)} tokens; recorded effort ${histogram(r.efforts)}; model family ${histogram(r.models)}.`);
71
+ }
72
+ }
73
+ return lines;
74
+ } catch { return [`${label}: class analysis unassessed — token counts or estimated indices are unsafe or overflow.`]; }
75
+ }
76
+
77
+ function counterfactual(figure) {
78
+ if (figure?.unavailable === "branch-scope") return "restart counterfactual unpriced: latest session tail is outside branch-owned scope.";
79
+ if (figure?.unavailable === "usage") return "restart counterfactual unpriced: input usage counters are invalid or incomplete.";
80
+ const unavailable = "restart counterfactual unpriced: explicit horizon, resume expense and usable latest-branch figures required; model/effort and TTL alternatives are not priced here.";
81
+ if (!figure?.horizonDeclared || figure.resume == null) return unavailable;
82
+ const { fresh, context, horizon, resume } = figure, read = figure.multipliers?.read, write = figure.multipliers?.write;
83
+ if (![fresh, context, horizon, resume, read, write].every(Number.isFinite) || fresh <= 0 || context < 0 || horizon <= 0 || resume < 0 || read <= 0 || write <= 0) return unavailable;
84
+ try {
85
+ const continuing = horizon * read * context, restarting = horizon * read * fresh + write * fresh + resume;
86
+ const saving = restartSaving({ fresh, context, horizon, resume, read, write });
87
+ if (![continuing, restarting].every(Number.isFinite)) return unavailable;
88
+ return `restart counterfactual: continue reads ~${estimate(continuing)} vs restart write + reads + resume ~${estimate(restarting)} input-equivalents; estimated saving ~${estimate(saving)}${saving <= 0 ? " (no positive saving)" : ""}; fixed-context comparison using caller-supplied horizon/resume, not a forecast.`;
89
+ } catch { return unavailable; }
90
+ }
91
+
92
+ export function spendAnalysis(report) {
93
+ const contexts = report.collected.contexts;
94
+ if (!contexts.some(c => c.requests.length)) return [];
95
+ const rates = report.priced?.declared ?? { read: GENERAL_READ, write: WRITE_BY_LIFETIME };
96
+ if (!SPEND_FIGURES.read.holds(rates.read) || ["5m", "1h"].some(k => !SPEND_FIGURES.write.holds(rates.write?.[k]))) return ["class analysis unassessed — invalid rate assumptions."];
97
+ const sessions = new Map();
98
+ for (const c of contexts) for (const r of c.requests) {
99
+ if (Number.isFinite(r.at)) sessions.set(c.session, Math.max(sessions.get(c.session) ?? -Infinity, r.at));
100
+ }
101
+ const chosen = [...sessions].sort(([a, x], [b, y]) => y - x || compare(String(a), String(b))).slice(0, 5).map(([id]) => id);
102
+ const undated = new Set(contexts.map(c => c.session).filter(id => !sessions.has(id))).size;
103
+ const branch = contexts.map(c => ({ ...c, requests: c.requests.filter(r => r.branch === report.branch) }));
104
+ return [
105
+ `class index assumptions: uniform input rate across models; uncached 1×, read ${rates.read}×, writes 5m ${rates.write["5m"]}× / 1h ${rates.write["1h"]}× (${report.priced?.declared ? "declared" : "general, undeclared"}); output 5× assumed. Estimated normalized input-equivalents, not monetary dominance or a bill; local receipts excluded${report.collected.malformed ? "; partial usage data" : ""}.`,
106
+ ...scopeLines("branch", branch, rates),
107
+ `recent selection: latest ${chosen.length} of at most 5 timed owned parent sessions across branches/worktrees; latest main/child timestamp, session-ID tie break; all owned requests in selected sessions included; ${undated} wholly undated sessions excluded.`,
108
+ ...scopeLines("recent", contexts.filter(c => chosen.includes(c.session)), rates),
109
+ counterfactual(report.restart),
110
+ ];
111
+ }