@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
|
@@ -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
|
+
}
|
package/cli/recipe-set.mjs
CHANGED
|
@@ -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.
|
|
250
|
+
process.exitCode = run(process.argv.slice(2));
|
|
242
251
|
}
|
package/cli/review-meter.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// The review-loop meter: the figures
|
|
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
|
|
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(">
|
|
179
|
-
lines.push(">
|
|
180
|
-
lines.push(">
|
|
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\`
|
|
214
|
-
`**${RETIRE_THRESHOLD.toFixed(1)}** for a full milestone
|
|
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
|
-
"
|
|
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.
|
|
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
|
+
}
|