@sleepy_panda_srl/portulan 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -6
- package/cli/README.md +56 -4
- package/cli/advisory.mjs +167 -53
- package/cli/cache-risk.mjs +69 -0
- package/cli/comments.mjs +8 -5
- package/cli/compile.mjs +492 -146
- package/cli/context.mjs +204 -43
- package/cli/doctor.mjs +211 -33
- package/cli/drills.mjs +32 -7
- package/cli/finish.md +34 -0
- package/cli/finish.mjs +9 -67
- package/cli/form.mjs +35 -19
- package/cli/frontmatter.mjs +39 -0
- package/cli/gate.mjs +14 -12
- package/cli/guidance-files.mjs +335 -0
- package/cli/handoff-policy.mjs +76 -0
- package/cli/html-entities.mjs +2130 -0
- package/cli/import-files.mjs +15 -0
- package/cli/index.mjs +77 -24
- package/cli/init-preview.mjs +553 -0
- package/cli/init.mjs +261 -46
- package/cli/instructions-journal.mjs +1185 -0
- package/cli/instructions.mjs +332 -57
- package/cli/ledger.mjs +123 -37
- package/cli/librarian.mjs +103 -19
- package/cli/persona-routing.mjs +209 -0
- package/cli/plugin-lint.mjs +77 -56
- package/cli/portulan.mjs +7 -2
- package/cli/recipe-output.mjs +91 -0
- package/cli/recipe-runner.md +68 -0
- package/cli/recipe-runner.mjs +131 -0
- package/cli/recipe-set.mjs +12 -3
- package/cli/ritual-contexts.mjs +103 -0
- package/cli/sessions.mjs +2 -1
- package/cli/spend-analysis.mjs +111 -0
- package/cli/spend-offer.md +53 -0
- package/cli/spend-offer.mjs +93 -0
- package/cli/stop-diagnostics.mjs +114 -0
- package/cli/stop-gate.mjs +133 -90
- package/cli/stop-policy.mjs +14 -0
- package/cli/symbols.mjs +55 -8
- package/cli/telemetry.mjs +19 -12
- package/cli/upgrade.mjs +62 -44
- package/cli/vendor.mjs +210 -126
- package/core/operating/context.md +368 -60
- package/core/operating/evolution.md +3 -2
- package/core/operating/loop.md +9 -4
- package/core/operating/sessions.md +6 -15
- package/core/personas/README.md +52 -10
- package/core/personas/implementer.md +3 -0
- package/core/personas/librarian.md +3 -0
- package/core/personas/reviewer.md +3 -0
- package/core/templates/handoff.md +6 -2
- package/evals/ab/personas/README.md +111 -0
- package/evals/ab/personas/acquisition.json +330 -0
- package/evals/ab/personas/bindings/implementer.txt +43 -0
- package/evals/ab/personas/bindings/librarian.txt +40 -0
- package/evals/ab/personas/bindings/reviewer.txt +41 -0
- package/evals/ab/personas/corpus.json +69 -0
- package/evals/ab/personas/grader.mjs +171 -0
- package/evals/ab/personas/implementer.json +774 -0
- package/evals/ab/personas/implementer.md +22 -0
- package/evals/ab/personas/librarian.json +758 -0
- package/evals/ab/personas/librarian.md +22 -0
- package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
- package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
- package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
- package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
- package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
- package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
- package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
- package/evals/ab/personas/reviewer.json +758 -0
- package/evals/ab/personas/reviewer.md +22 -0
- package/evals/releases/0.4.0.json +154 -0
- package/evals/releases/0.4.0.md +69 -0
- package/evals/releases/0.4.1.json +154 -0
- package/evals/releases/0.4.1.md +69 -0
- package/package.json +6 -2
- package/packs/rituals/checkpoints/README.md +35 -0
- package/packs/rituals/checkpoints/pack.json +3 -2
- package/packs/tools/local-llm/README.md +46 -6
- package/packs/tools/local-llm/runner/local-llm.mjs +127 -13
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +26 -2
- package/spec/README.md +37 -4
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
- package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
- package/spec/migrations/README.md +6 -0
- package/spec/pack.schema.json +12 -3
- package/spec/slots.md +262 -117
- package/spec/workspace.schema.json +67 -11
package/cli/advisory.mjs
CHANGED
|
@@ -1,31 +1,37 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
2
|
+
// Restart comparison with explicit horizon and resume expense; unknown recovery cost stays conditional.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// <figures> --read <m> --write-5m <m> --write-1h <m>, all three or none, and --horizon <n>: a workspace's
|
|
10
|
-
// `spend`, as `./compile.mjs` writes it; undeclared, the general multipliers and horizon.
|
|
11
|
-
//
|
|
12
|
-
// Exit 0 on every path: a 2 would erase the person's prompt at `UserPromptSubmit` and hold every turn's end at
|
|
13
|
-
// `Stop`. What it cannot read or use, it passes over and names on stderr.
|
|
14
|
-
//
|
|
15
|
-
// The line rides a tool result or a prompt: a non-blocking `Stop` hook's output never reaches the model, and a
|
|
16
|
-
// headless run's one prompt comes before any request. The transcript is written asynchronously, so the line may
|
|
17
|
-
// come one request late, never early.
|
|
4
|
+
// node cli/advisory.mjs tool|prompt|status|stop [--read m --write-5m m --write-1h m] [--horizon n] [--resume-tokens cost]
|
|
5
|
+
// Undeclared prices/horizon use general figures; unknown resume cost permits conditional reports only.
|
|
6
|
+
// Tool/prompt output reaches the agent; a declared Stop holds only with explicit profitable estimates.
|
|
7
|
+
// Retain the first crossing in a delayed batch; Stop uses the latest estimate. Hook failures exit 0.
|
|
8
|
+
// Unusable figures are named on stderr.
|
|
18
9
|
|
|
19
10
|
import fs from "node:fs";
|
|
20
11
|
import os from "node:os";
|
|
21
12
|
import path from "node:path";
|
|
22
13
|
import { pathToFileURL } from "node:url";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
23
15
|
|
|
24
|
-
import { HORIZON, LedgerError, SPEND_FIGURES, figureOf, foldFigures, overflowingWrite, readLine, sessionFigures } from "./ledger.mjs";
|
|
16
|
+
import { HORIZON, INPUT_USAGE_MASK, inputUnusable, usageOf, contextOf, LedgerError, SPEND_FIGURES, figureOf, foldFigures, foldCanonical, overflowingWrite, readLine, readTranscript, restartSaving, sessionFigures } from "./ledger.mjs";
|
|
25
17
|
|
|
26
18
|
const grouped = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
27
|
-
|
|
28
|
-
|
|
19
|
+
const savingWords = (n) => grouped(Number(n.toPrecision(6)));
|
|
20
|
+
|
|
21
|
+
const STATE_VERSION = 8;
|
|
22
|
+
const SEEN_BYTES = 1024;
|
|
23
|
+
const emptySeen = () => Buffer.alloc(SEEN_BYTES).toString("hex");
|
|
24
|
+
// Membership selects exact replay; collisions never establish identity.
|
|
25
|
+
function membership(bits, id, remember = false) {
|
|
26
|
+
const hash = createHash("sha256").update(id).digest();
|
|
27
|
+
let hit = true;
|
|
28
|
+
for (const at of [0, 4, 8]) {
|
|
29
|
+
const bit = hash.readUInt32LE(at) % (SEEN_BYTES * 8), mask = 1 << (bit & 7);
|
|
30
|
+
hit = Boolean(bits[bit >> 3] & mask) && hit;
|
|
31
|
+
if (remember) bits[bit >> 3] |= mask;
|
|
32
|
+
}
|
|
33
|
+
return hit;
|
|
34
|
+
}
|
|
29
35
|
|
|
30
36
|
const CHUNK = 1 << 16;
|
|
31
37
|
|
|
@@ -97,18 +103,64 @@ function restore(file, transcriptPath, stat) {
|
|
|
97
103
|
}
|
|
98
104
|
if (kept === null || typeof kept !== "object" || kept.v !== STATE_VERSION || kept.transcript !== transcriptPath) return null;
|
|
99
105
|
if (kept.ino !== stat.ino || !isCount(kept.offset) || kept.offset > stat.size || typeof kept.tail !== "string" || !wellFormed(kept.figures)) return null;
|
|
106
|
+
if (typeof kept.comparison !== "string" || typeof kept.seen !== "string" || kept.seen.length !== SEEN_BYTES * 2 || !/^[0-9a-f]+$/.test(kept.seen)) return null;
|
|
107
|
+
if (typeof kept.freshUnsafe !== "boolean" || typeof kept.latestUnsafe !== "boolean" || ![kept.freshId, kept.latestId, kept.crossingId].every(id => id === null || typeof id === "string")) return null;
|
|
108
|
+
if (![kept.freshInput, kept.latestInput, kept.crossingInput].every(v => v === null || (Array.isArray(v) && v.length === 5 && v.every(isCount)))) return null;
|
|
109
|
+
if (!Array.isArray(kept.recentInputs) || kept.recentInputs.length > 16 || !kept.recentInputs.every(v => Array.isArray(v) && v.length === 3 && typeof v[0] === "string" && Array.isArray(v[1]) && v[1].length === 5 && v[1].every(isCount) && (isCount(v[2]) && (v[2] & INPUT_USAGE_MASK) === v[2]))) return null;
|
|
110
|
+
if (kept.crossing !== null && (!wellFormed(kept.crossing) || kept.crossing.pending || kept.crossing.compactions !== kept.figures.compactions || kept.crossing.fresh !== kept.figures.fresh || kept.crossing.requests > kept.figures.requests || kept.crossing.recent.length !== 0)) return null;
|
|
111
|
+
const seen = Buffer.from(kept.seen, "hex");
|
|
112
|
+
if ([...kept.figures.recent, kept.freshId, kept.latestId, kept.crossingId].some(id => id !== null && !membership(seen, id))) return null;
|
|
100
113
|
return kept;
|
|
101
114
|
}
|
|
102
115
|
|
|
103
|
-
|
|
116
|
+
const comparisonKey = ({ declared, horizon, resume }) => JSON.stringify([declared?.read ?? null, declared?.write?.["5m"] ?? null, declared?.write?.["1h"] ?? null, horizon, resume]);
|
|
117
|
+
const inputOf = r => [r.uncached, r.written1h, r.written5m, r.writtenUnknown, r.read];
|
|
118
|
+
const validity = () => ({ freshUnsafe: false, latestUnsafe: false, freshId: null, latestId: null, crossingId: null, freshInput: null, latestInput: null, crossingInput: null });
|
|
119
|
+
const reset = (kept) => Object.assign(kept, { offset: 0, tail: "", seen: emptySeen(), figures: sessionFigures(), crossing: null, recentInputs: [], ...validity() });
|
|
120
|
+
function rememberInput(state, r) {
|
|
121
|
+
if (r.id === null) return;
|
|
122
|
+
state.recentInputs = [...state.recentInputs.filter(v => v[0] !== r.id), [r.id, inputOf(r), r.usageMissing & INPUT_USAGE_MASK]].filter(v => state.figures.recent.includes(v[0])).slice(-16);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function replayValidity(kept, transcriptPath, spend) {
|
|
126
|
+
const transcript = readTranscript(transcriptPath, { end: kept.offset });
|
|
127
|
+
const state = { figures: sessionFigures(), crossing: null, recentInputs: [], ...validity() }, seen = Buffer.alloc(SEEN_BYTES);
|
|
128
|
+
for (const r of transcript.requests) {
|
|
129
|
+
if (r.id !== null) membership(seen, r.id, true);
|
|
130
|
+
if (r.sidechain) continue;
|
|
131
|
+
if (r.compacted) Object.assign(state, { crossing: null, ...validity() });
|
|
132
|
+
const fresh = state.figures.fresh === null || state.figures.pending || r.compacted;
|
|
133
|
+
foldCanonical(state.figures, r);
|
|
134
|
+
rememberInput(state, r);
|
|
135
|
+
const unsafe = inputUnusable(r);
|
|
136
|
+
if (fresh) { state.freshUnsafe = unsafe; state.freshId = r.id; state.freshInput = inputOf(r); }
|
|
137
|
+
state.latestUnsafe = unsafe; state.latestId = r.id; state.latestInput = inputOf(r);
|
|
138
|
+
if (state.freshUnsafe && !isCount(state.figures.fresh)) state.figures.fresh = 0;
|
|
139
|
+
if (state.latestUnsafe && !isCount(state.figures.last)) state.figures.last = 0;
|
|
140
|
+
if (state.freshUnsafe || state.latestUnsafe || state.crossing !== null) continue;
|
|
141
|
+
try {
|
|
142
|
+
const figure = figureOf(state.figures, spend);
|
|
143
|
+
if (figure !== null && figure.saving >= 0) { state.crossing = { ...state.figures, recent: [] }; state.crossingId = r.id; state.crossingInput = inputOf(r); }
|
|
144
|
+
} catch (error) { if (!(error instanceof LedgerError)) throw error; }
|
|
145
|
+
}
|
|
146
|
+
state.figures.compactions = transcript.figures.compactions;
|
|
147
|
+
state.figures.pending = transcript.figures.pending;
|
|
148
|
+
if (state.figures.pending) Object.assign(state, { crossing: null, ...validity() });
|
|
149
|
+
else if (state.crossing) state.crossing.compactions = state.figures.compactions;
|
|
150
|
+
Object.assign(kept, state, { seen: seen.toString("hex") });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function advance(kept, transcriptPath, size, spend) {
|
|
104
154
|
if (size === kept.offset) return false;
|
|
105
155
|
const fd = fs.openSync(transcriptPath, "r");
|
|
106
156
|
try {
|
|
107
157
|
if (kept.offset > 0 && tailBefore(fd, kept.offset) !== kept.tail) {
|
|
108
|
-
|
|
158
|
+
reset(kept);
|
|
109
159
|
}
|
|
160
|
+
const seen = Buffer.from(kept.seen, "hex");
|
|
110
161
|
const start = kept.offset;
|
|
111
162
|
let position = kept.offset;
|
|
163
|
+
let replay = false;
|
|
112
164
|
// Joined once the newline lands: joining at every chunk would copy a long line once per chunk it spans.
|
|
113
165
|
let waiting = [];
|
|
114
166
|
while (position < size) {
|
|
@@ -125,12 +177,48 @@ function advance(kept, transcriptPath, size) {
|
|
|
125
177
|
const complete = waiting.length === 0 ? bytes.subarray(0, end) : Buffer.concat([...waiting, bytes.subarray(0, end)]);
|
|
126
178
|
for (const line of complete.toString("utf8").split("\n")) {
|
|
127
179
|
const read = readLine(line);
|
|
128
|
-
if (read
|
|
180
|
+
if (read === null || read.malformed) continue;
|
|
181
|
+
const { requests, compactions } = kept.figures;
|
|
182
|
+
const fresh = kept.figures.fresh === null || kept.figures.pending;
|
|
183
|
+
const r = read.request;
|
|
184
|
+
if (r && !r.synthetic) {
|
|
185
|
+
const repeated = r.id !== null && membership(seen, r.id, true);
|
|
186
|
+
if (repeated && !kept.figures.recent.includes(r.id)) replay = true;
|
|
187
|
+
if (!r.sidechain || repeated) {
|
|
188
|
+
const input = inputOf(r);
|
|
189
|
+
const tracked = [[kept.freshId, kept.freshInput], [kept.latestId, kept.latestInput], [kept.crossingId, kept.crossingInput]];
|
|
190
|
+
const recent = kept.recentInputs.find(v => v[0] === r.id);
|
|
191
|
+
if (inputUnusable(r) || (recent && (JSON.stringify(recent[1]) !== JSON.stringify(input) || recent[2] !== (r.usageMissing & INPUT_USAGE_MASK))) || (r.id !== null && tracked.some(([id, before]) => id === r.id && (JSON.stringify(before) !== JSON.stringify(input) || kept.freshUnsafe || kept.latestUnsafe)))) replay = true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
foldFigures(kept.figures, read);
|
|
195
|
+
if (compactions !== kept.figures.compactions) Object.assign(kept, { crossing: null, ...validity() });
|
|
196
|
+
if (r && !r.synthetic && !r.sidechain) {
|
|
197
|
+
rememberInput(kept, r);
|
|
198
|
+
const unsafe = inputUnusable(r);
|
|
199
|
+
if (requests !== kept.figures.requests) {
|
|
200
|
+
if (fresh) { kept.freshUnsafe = unsafe; kept.freshId = r.id; kept.freshInput = inputOf(r); }
|
|
201
|
+
kept.latestUnsafe = unsafe; kept.latestId = r.id; kept.latestInput = inputOf(r);
|
|
202
|
+
} else if (r.id !== null && (r.usageUnsafe & 1)) {
|
|
203
|
+
if (r.id === kept.freshId) kept.freshUnsafe = true;
|
|
204
|
+
if (r.id === kept.latestId) kept.latestUnsafe = true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (kept.freshUnsafe || kept.latestUnsafe) continue;
|
|
208
|
+
if (requests === kept.figures.requests || kept.crossing !== null) continue;
|
|
209
|
+
try {
|
|
210
|
+
const figure = figureOf(kept.figures, spend);
|
|
211
|
+
if (figure !== null && figure.saving >= 0) { kept.crossing = { ...kept.figures, recent: [] }; kept.crossingId = r.id; kept.crossingInput = inputOf(r); }
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (!(error instanceof LedgerError)) throw error;
|
|
214
|
+
}
|
|
129
215
|
}
|
|
130
216
|
waiting = end + 1 < got ? [bytes.subarray(end + 1)] : [];
|
|
131
217
|
kept.offset = position - (got - end - 1);
|
|
132
218
|
}
|
|
133
219
|
if (kept.offset !== start) kept.tail = tailBefore(fd, kept.offset);
|
|
220
|
+
kept.seen = seen.toString("hex");
|
|
221
|
+
if (replay) replayValidity(kept, transcriptPath, spend);
|
|
134
222
|
return kept.offset !== start;
|
|
135
223
|
} finally {
|
|
136
224
|
fs.closeSync(fd);
|
|
@@ -165,19 +253,25 @@ function locate(payload, dir) {
|
|
|
165
253
|
if (!stat.isFile()) return { why: "the transcript could not be read — it is not a file" };
|
|
166
254
|
const sessionId = sessionOf(payload);
|
|
167
255
|
const file = sessionId === null ? null : stateFile(sessionId, dir);
|
|
168
|
-
const kept = (file === null ? null : restore(file, transcriptPath, stat)) ?? { v: STATE_VERSION, transcript: transcriptPath, ino: stat.ino, offset: 0, tail: "", figures: sessionFigures() };
|
|
256
|
+
const kept = (file === null ? null : restore(file, transcriptPath, stat)) ?? { v: STATE_VERSION, transcript: transcriptPath, ino: stat.ino, offset: 0, tail: "", seen: emptySeen(), figures: sessionFigures(), comparison: "", crossing: null, recentInputs: [], ...validity() };
|
|
169
257
|
return { transcriptPath, sessionId, stat, file, kept };
|
|
170
258
|
}
|
|
171
259
|
|
|
172
260
|
function refresh(found, warn, spend) {
|
|
173
261
|
try {
|
|
174
|
-
|
|
262
|
+
const key = comparisonKey(spend);
|
|
263
|
+
const changed = found.kept.comparison !== key;
|
|
264
|
+
if (changed) { reset(found.kept); found.kept.comparison = key; }
|
|
265
|
+
if ((advance(found.kept, found.transcriptPath, found.stat.size, spend) || changed) && found.file !== null) keep(found.file, found.kept, warn);
|
|
175
266
|
} catch (error) {
|
|
176
267
|
return { why: `the transcript could not be read — ${error.code ?? error.message}` };
|
|
177
268
|
}
|
|
178
269
|
let figure;
|
|
270
|
+
if (found.kept.freshUnsafe) return { why: "fresh context usage has invalid or missing counters; comparison unavailable (compaction or completed missing fields required)" };
|
|
271
|
+
if (found.kept.latestUnsafe) return { why: "latest context usage has invalid or missing counters; comparison unavailable (valid next request or completed missing fields required)" };
|
|
179
272
|
try {
|
|
180
273
|
figure = figureOf(found.kept.figures, spend);
|
|
274
|
+
if (figure !== null) figure.crossing = found.kept.crossing === null ? null : figureOf(found.kept.crossing, spend);
|
|
181
275
|
} catch (error) {
|
|
182
276
|
if (!(error instanceof LedgerError)) throw error;
|
|
183
277
|
return { why: error.message };
|
|
@@ -188,35 +282,40 @@ function refresh(found, warn, spend) {
|
|
|
188
282
|
: { why: "no request is recorded yet", after: "the first recorded request" };
|
|
189
283
|
}
|
|
190
284
|
|
|
191
|
-
const FINISH = { PostToolUse: "finish the current step, then ", UserPromptSubmit: "finish what this prompt asks, then ", Stop: "" };
|
|
192
|
-
|
|
193
285
|
export function adviceLine(figure, event = "UserPromptSubmit") {
|
|
194
286
|
const m = figure.multipliers;
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
287
|
+
const horizon = `${figure.horizon} remaining (${figure.horizonDeclared ? "declared" : "default"})`;
|
|
288
|
+
const resume = figure.resume == null ? "resume cost unknown; threshold is a lower bound" : `resume ${grouped(figure.resume)} input equivalents`;
|
|
289
|
+
const comparison = figure.saving < 0 ? "Latest estimate does not favor restarting."
|
|
290
|
+
: figure.resume != null && figure.horizonDeclared
|
|
291
|
+
? `Estimated savings ${savingWords(figure.saving)} input equivalents; consider a handoff and restart if these estimates still fit the task.`
|
|
292
|
+
: "Restart only if remaining work and recovery cost make it cheaper; finish bounded work when that costs less.";
|
|
293
|
+
const first = figure.crossing;
|
|
294
|
+
const history = first && first.requests !== figure.requests
|
|
295
|
+
? ` First crossing: request ${grouped(first.requests)}, context ${grouped(first.context)} / threshold ${grouped(first.threshold)} (fresh ${grouped(first.fresh)}, read ${first.multipliers.read}×, write ${first.multipliers.write}×, ${first.multipliers.recorded ? "recorded" : "assumed"} ${first.multipliers.lifetime}).`
|
|
296
|
+
: "";
|
|
297
|
+
return `Portulan restart advisory: after ${grouped(figure.requests)} requests, context ${grouped(figure.context)} tokens, threshold ${grouped(figure.threshold)}; ${horizon}, read ${m.read}×, write ${m.write}× (${m.source}); ${resume}.${history} ${comparison} Said once.`;
|
|
204
298
|
}
|
|
205
299
|
|
|
206
300
|
export function statusLine(figure) {
|
|
207
301
|
const m = figure.multipliers;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
? `${requests} · context ${compact(figure.context)} has reached its ${compact(figure.threshold)} restart threshold: write the handoff and restart · ${multiplied}`
|
|
212
|
-
: `${requests} · context ${compact(figure.context)} of a ${compact(figure.threshold)} restart threshold · ${multiplied}`;
|
|
302
|
+
return `${grouped(figure.requests)} requests · context ${compact(figure.context)} / restart ${compact(figure.threshold)} · ${figure.horizon} remaining (${figure.horizonDeclared ? "declared" : "default"}) · ` +
|
|
303
|
+
`${figure.resume == null ? "resume cost unknown: lower bound" : `resume ${grouped(figure.resume)} input-equivalent tokens; estimated saving ${savingWords(figure.saving)}`} · ` +
|
|
304
|
+
`multipliers ${m.source}: read ${m.read}×, write ${m.write}×`;
|
|
213
305
|
}
|
|
214
306
|
|
|
215
|
-
function
|
|
307
|
+
export function advisoryOutput(figure, event) {
|
|
308
|
+
const line = adviceLine(figure, event);
|
|
309
|
+
return event === "Stop"
|
|
310
|
+
? JSON.stringify({ decision: "block", reason: line })
|
|
311
|
+
: JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: line } });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function once(event, payload, { dir = os.tmpdir(), warn = () => {}, declared = null, horizon = null, resume = null } = {}) {
|
|
216
315
|
// Claude Code 2.1.281: a subagent's tool call carries its own `agent_id` and the main session's transcript,
|
|
217
316
|
// and a subagent told to end its session would end nothing.
|
|
218
317
|
if (typeof payload?.agent_id === "string" && payload.agent_id !== "") return null;
|
|
219
|
-
//
|
|
318
|
+
// Without the host's retry marker, rotated session ids can repeat the hold.
|
|
220
319
|
if (event === "Stop" && payload?.stop_hook_active === true) return null;
|
|
221
320
|
const sessionId = sessionOf(payload);
|
|
222
321
|
if (sessionId === null) {
|
|
@@ -231,12 +330,13 @@ function once(event, payload, { dir = os.tmpdir(), warn = () => {}, declared = n
|
|
|
231
330
|
const record = event === "Stop" ? heldFile : toldFile;
|
|
232
331
|
// Nothing written since the figures were kept, so nothing compacted: the record at the kept count answers without a read.
|
|
233
332
|
if (found.stat.size === found.kept.offset && fs.existsSync(record(sessionId, found.kept.figures.compactions, dir))) return null;
|
|
234
|
-
const figure = refresh(found, warn, { declared, horizon });
|
|
333
|
+
const figure = refresh(found, warn, { declared, horizon, resume });
|
|
235
334
|
if (figure.why !== undefined) {
|
|
236
335
|
warn(figure.why);
|
|
237
336
|
return null;
|
|
238
337
|
}
|
|
239
|
-
if (figure.
|
|
338
|
+
if (figure.crossing === null) return null;
|
|
339
|
+
if (event === "Stop" && (!figure.horizonDeclared || figure.resume === null || !(figure.saving > 0))) return null;
|
|
240
340
|
const mark = (file) => fs.writeFileSync(file, `${figure.context} ${figure.threshold}\n`, { flag: "wx", mode: 0o600 });
|
|
241
341
|
try {
|
|
242
342
|
mark(record(sessionId, figure.compactions, dir));
|
|
@@ -250,13 +350,13 @@ function once(event, payload, { dir = os.tmpdir(), warn = () => {}, declared = n
|
|
|
250
350
|
}
|
|
251
351
|
return null;
|
|
252
352
|
}
|
|
253
|
-
if (event !== "Stop") return
|
|
353
|
+
if (event !== "Stop") return advisoryOutput(figure, event);
|
|
254
354
|
try {
|
|
255
355
|
mark(toldFile(sessionId, figure.compactions, dir));
|
|
256
356
|
} catch (error) {
|
|
257
357
|
if (error.code !== "EEXIST") warn(`the told-once record could not be written — ${error.code ?? error.message}; the line may come once more after the block`);
|
|
258
358
|
}
|
|
259
|
-
return
|
|
359
|
+
return advisoryOutput(figure, event);
|
|
260
360
|
}
|
|
261
361
|
|
|
262
362
|
export const onTool = (payload, options) => once("PostToolUse", payload, options);
|
|
@@ -266,13 +366,24 @@ export const onPrompt = (payload, options) => once("UserPromptSubmit", payload,
|
|
|
266
366
|
export const onStop = (payload, options) => once("Stop", payload, options);
|
|
267
367
|
|
|
268
368
|
/** The status line, its context the host's own last-call counts where sent: current where the transcript may lag a request. */
|
|
269
|
-
export function onStatus(payload, { dir = os.tmpdir(), warn = () => {}, declared = null, horizon =
|
|
369
|
+
export function onStatus(payload, { dir = os.tmpdir(), warn = () => {}, declared = null, horizon = null, resume = null } = {}) {
|
|
270
370
|
const found = locate(payload, dir);
|
|
271
|
-
const figure = found.why === undefined ? refresh(found, warn, { declared, horizon }) : found;
|
|
371
|
+
const figure = found.why === undefined ? refresh(found, warn, { declared, horizon, resume }) : found;
|
|
272
372
|
if (figure.why !== undefined) return figure.after !== undefined ? `restart threshold: after ${figure.after}` : `restart threshold: not known, because ${figure.why}`;
|
|
273
373
|
const usage = payload?.context_window?.current_usage;
|
|
274
|
-
|
|
275
|
-
|
|
374
|
+
if (usage !== undefined && usage !== null) {
|
|
375
|
+
if (typeof usage !== "object" || Array.isArray(usage)) return "restart threshold: not known, because the current input has missing or invalid counters";
|
|
376
|
+
const current = usageOf(usage), context = contextOf(current);
|
|
377
|
+
if (!Number.isSafeInteger(context)) return "restart threshold: not known, because the current input sum is unsafe";
|
|
378
|
+
if (inputUnusable(current)) return "restart threshold: not known, because the current input has missing or invalid counters";
|
|
379
|
+
try {
|
|
380
|
+
figure.saving = restartSaving({ fresh: figure.fresh, context, horizon: figure.horizon, read: figure.multipliers.read, write: figure.multipliers.write, resume: figure.resume ?? 0 });
|
|
381
|
+
} catch (error) {
|
|
382
|
+
if (error instanceof LedgerError) return "restart threshold: not known, because the estimated saving overflows";
|
|
383
|
+
throw error;
|
|
384
|
+
}
|
|
385
|
+
figure.context = context;
|
|
386
|
+
}
|
|
276
387
|
return statusLine(figure);
|
|
277
388
|
}
|
|
278
389
|
|
|
@@ -284,7 +395,7 @@ function readPayload() {
|
|
|
284
395
|
}
|
|
285
396
|
}
|
|
286
397
|
|
|
287
|
-
const SPEND_FLAGS = { "--read": "read", "--write-5m": "write", "--write-1h": "write", "--horizon": "requests" };
|
|
398
|
+
const SPEND_FLAGS = { "--read": "read", "--write-5m": "write", "--write-1h": "write", "--horizon": "requests", "--resume-tokens": "resume" };
|
|
288
399
|
|
|
289
400
|
const MULTIPLIER_FLAGS = ["--read", "--write-5m", "--write-1h"];
|
|
290
401
|
|
|
@@ -294,7 +405,7 @@ const DECIMAL = /^[0-9]+(\.[0-9]+)?(e[+-]?[0-9]+)?$/;
|
|
|
294
405
|
/** The figures after the mode, `{ declared, horizon, fault }`, each null where absent; a partial multiplier set is undeclared whole. */
|
|
295
406
|
export function spendFlags(args) {
|
|
296
407
|
const values = new Map();
|
|
297
|
-
const faults = { multipliers: [], horizon: [], other: [] };
|
|
408
|
+
const faults = { multipliers: [], horizon: [], resume: [], other: [] };
|
|
298
409
|
const shown = (s) => (typeof s === "string" && /^[-+.a-zA-Z0-9]+$/.test(s) ? s : JSON.stringify(s));
|
|
299
410
|
for (let i = 0; i < args.length; i += 2) {
|
|
300
411
|
const [flag, raw] = [args[i], args[i + 1]];
|
|
@@ -302,7 +413,7 @@ export function spendFlags(args) {
|
|
|
302
413
|
faults.other.push(`${shown(flag)} is no flag it takes`);
|
|
303
414
|
continue;
|
|
304
415
|
}
|
|
305
|
-
const part = flag === "--horizon" ? faults.horizon : faults.multipliers;
|
|
416
|
+
const part = flag === "--horizon" ? faults.horizon : flag === "--resume-tokens" ? faults.resume : faults.multipliers;
|
|
306
417
|
const range = SPEND_FIGURES[SPEND_FLAGS[flag]];
|
|
307
418
|
const value = typeof raw === "string" && DECIMAL.test(raw) ? Number(raw) : Number.NaN;
|
|
308
419
|
if (values.has(flag)) part.push(`${flag} is given twice`);
|
|
@@ -319,10 +430,12 @@ export function spendFlags(args) {
|
|
|
319
430
|
const said = [];
|
|
320
431
|
if (faults.multipliers.length) said.push(`${faults.multipliers.join(", ")}, so the multipliers are undeclared`);
|
|
321
432
|
if (faults.horizon.length) said.push(`${faults.horizon.join(", ")}, so the horizon is undeclared, and ${HORIZON} requests`);
|
|
433
|
+
if (faults.resume.length) said.push(`${faults.resume.join(", ")}, so resume cost is unknown`);
|
|
322
434
|
if (faults.other.length) said.push(`${faults.other.join(", ")}, so ${faults.other.length === 1 ? "it is" : "each is"} passed over with the value after it`);
|
|
323
435
|
return {
|
|
324
436
|
declared: missing.length === 0 && !faults.multipliers.length ? { read: values.get("--read"), write: { "5m": values.get("--write-5m"), "1h": values.get("--write-1h") } } : null,
|
|
325
437
|
horizon: values.has("--horizon") && !faults.horizon.length ? values.get("--horizon") : null,
|
|
438
|
+
resume: values.has("--resume-tokens") && !faults.resume.length ? values.get("--resume-tokens") : null,
|
|
326
439
|
fault: said.length ? said.join("; ") : null,
|
|
327
440
|
};
|
|
328
441
|
}
|
|
@@ -334,7 +447,8 @@ export function main(argv = process.argv.slice(2), { stdout = process.stdout, st
|
|
|
334
447
|
try {
|
|
335
448
|
const figures = spendFlags(argv.slice(1));
|
|
336
449
|
if (figures.fault !== null) warn(`the figures after ${mode} are not all usable — ${figures.fault}`);
|
|
337
|
-
|
|
450
|
+
if (mode === "stop" && figures.fault !== null) return 0;
|
|
451
|
+
const priced = { dir, warn, declared: figures.declared, horizon: figures.horizon, resume: figures.resume };
|
|
338
452
|
if (mode === "prompt" || mode === "tool" || mode === "stop") {
|
|
339
453
|
const out = { tool: onTool, prompt: onPrompt, stop: onStop }[mode](input, priced);
|
|
340
454
|
if (out !== null) stdout.write(`${out}\n`);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Potential cache risks from project files; host activation is not inferred.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { readOwnedText } from "./compile.mjs";
|
|
5
|
+
|
|
6
|
+
const object = v => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
7
|
+
const strings = v => Array.isArray(v) && v.every(s => typeof s === "string");
|
|
8
|
+
const scope = "Project files only; full MCP schema, trust, live activation, user/managed/CLI overrides, broader model/MCP policies and untested selectors (including experimental-beta disabling) are unassessed.";
|
|
9
|
+
|
|
10
|
+
export function cacheRiskLines(root) {
|
|
11
|
+
const lines = [`Assessed selectors: project model/ANTHROPIC_MODEL (opusplan and conditional Haiku), ENABLE_TOOL_SEARCH=false and ToolSearch denial; ${scope}`];
|
|
12
|
+
const read = label => {
|
|
13
|
+
const file = path.join(root, label);
|
|
14
|
+
try { fs.lstatSync(file); }
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error.code === "ENOENT") return {};
|
|
17
|
+
lines.push(`${label}: unassessed — project source is unreadable.`); return {};
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const value = JSON.parse(readOwnedText(file, root));
|
|
21
|
+
if (!object(value)) throw Error();
|
|
22
|
+
return value;
|
|
23
|
+
} catch { lines.push(`${label}: unassessed — project source is unreadable, malformed, nonregular or outside its tree.`); return {}; }
|
|
24
|
+
};
|
|
25
|
+
const settings = label => {
|
|
26
|
+
const value = read(label);
|
|
27
|
+
if ((value.model !== undefined && typeof value.model !== "string") ||
|
|
28
|
+
(value.env !== undefined && (!object(value.env) || ["ANTHROPIC_MODEL", "ENABLE_TOOL_SEARCH"].some(k => value.env[k] !== undefined && typeof value.env[k] !== "string"))) ||
|
|
29
|
+
(value.permissions !== undefined && (!object(value.permissions) || (value.permissions.deny !== undefined && !strings(value.permissions.deny)))) ||
|
|
30
|
+
["enabledMcpjsonServers", "disabledMcpjsonServers"].some(k => value[k] !== undefined && !strings(value[k])) ||
|
|
31
|
+
(value.enableAllProjectMcpServers !== undefined && typeof value.enableAllProjectMcpServers !== "boolean")) {
|
|
32
|
+
lines.push(`${label}: unassessed — relevant project settings have invalid shapes.`); return {};
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
};
|
|
36
|
+
const shared = settings(".claude/settings.json"), local = settings(".claude/settings.local.json");
|
|
37
|
+
const env = { ...shared.env, ...local.env };
|
|
38
|
+
const model = env.ANTHROPIC_MODEL || (local.model ?? shared.model);
|
|
39
|
+
const source = env.ANTHROPIC_MODEL ? "project ANTHROPIC_MODEL" : "project model";
|
|
40
|
+
if (typeof model === "string" && /^opusplan(?:\[1m\])?$/i.test(model)) {
|
|
41
|
+
lines.push(`plan-mode model switching configured via ${source}: Opus planning and Sonnet execution can rebuild the model-specific cache at the boundary.`);
|
|
42
|
+
} else if (typeof model === "string" && /^(?:haiku(?:\[1m\])?|claude-haiku-[a-z0-9.-]+)$/i.test(model)) {
|
|
43
|
+
lines.push(`plan-mode model upgrade possible via ${source} for a Haiku selection: a permitted Sonnet upgrade can rebuild the model-specific cache. Provider and allowlist behavior is unresolved.`);
|
|
44
|
+
} else if (model === "") lines.push("project model selection is empty: resolved default and plan-mode behavior are unassessed.");
|
|
45
|
+
const mcp = read(".mcp.json");
|
|
46
|
+
const candidates = [];
|
|
47
|
+
if (mcp.mcpServers !== undefined && !object(mcp.mcpServers)) lines.push(".mcp.json: unassessed — project MCP definitions have an invalid shape.");
|
|
48
|
+
else for (const [name, server] of Object.entries(mcp.mcpServers ?? {})) {
|
|
49
|
+
const stdio = object(server) && (server.type === undefined || server.type === "stdio") && typeof server.command === "string" && server.command.trim() !== "";
|
|
50
|
+
let remote = false;
|
|
51
|
+
if (object(server) && ["http", "sse", "ws"].includes(server.type) && typeof server.url === "string") {
|
|
52
|
+
try { remote = (server.type === "ws" ? ["ws:", "wss:"] : ["http:", "https:"]).includes(new URL(server.url).protocol); } catch { /* Invalid endpoint is never printed. */ }
|
|
53
|
+
}
|
|
54
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name) || (!stdio && !remote) || server.disabled !== undefined ||
|
|
55
|
+
(server.args !== undefined && !strings(server.args)) || (server.env !== undefined && (!object(server.env) || !Object.values(server.env).every(v => typeof v === "string")))) {
|
|
56
|
+
lines.push(".mcp.json: unassessed — a project MCP definition is unsupported or invalid.");
|
|
57
|
+
} else candidates.push(name);
|
|
58
|
+
}
|
|
59
|
+
const rejected = new Set([...(shared.disabledMcpjsonServers ?? []), ...(local.disabledMcpjsonServers ?? [])]);
|
|
60
|
+
const approved = new Set([...(shared.enabledMcpjsonServers ?? []), ...(local.enabledMcpjsonServers ?? [])]);
|
|
61
|
+
const all = local.enableAllProjectMcpServers ?? shared.enableAllProjectMcpServers ?? false;
|
|
62
|
+
const eligible = candidates.filter(name => !rejected.has(name));
|
|
63
|
+
const denial = [...(shared.permissions?.deny ?? []), ...(local.permissions?.deny ?? [])].includes("ToolSearch");
|
|
64
|
+
if (eligible.length && (env.ENABLE_TOOL_SEARCH === "false" || denial)) {
|
|
65
|
+
const known = eligible.filter(name => all || approved.has(name)).length;
|
|
66
|
+
lines.push(`tool search disabled by ${denial ? "project ToolSearch denial" : "project ENABLE_TOOL_SEARCH=false"}: ${eligible.length} eligible project MCP candidate(s), ${known} with project-file approval; upfront tool definitions can enlarge or change the cache prefix.`);
|
|
67
|
+
}
|
|
68
|
+
return lines;
|
|
69
|
+
}
|
package/cli/comments.mjs
CHANGED
|
@@ -764,12 +764,15 @@ export function historyOf(comments) {
|
|
|
764
764
|
return comments.filter((comment) => kinds.has(comment)).map((comment) => ({ ...comment, kinds: [...kinds.get(comment)] }));
|
|
765
765
|
}
|
|
766
766
|
|
|
767
|
-
function trackedFiles(root) {
|
|
767
|
+
function trackedFiles(root, timeout, env, noFsmonitor) {
|
|
768
768
|
try {
|
|
769
|
-
const out = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
|
|
769
|
+
const out = execFileSync("git", [...(noFsmonitor ? ["-c", "core.fsmonitor=false"] : []), "ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
|
|
770
770
|
cwd: root,
|
|
771
771
|
encoding: "utf8",
|
|
772
772
|
maxBuffer: 256 * 1024 * 1024,
|
|
773
|
+
timeout,
|
|
774
|
+
env,
|
|
775
|
+
killSignal: "SIGKILL",
|
|
773
776
|
stdio: ["ignore", "pipe", "pipe"],
|
|
774
777
|
});
|
|
775
778
|
return [...new Set(out.split("\0").filter(Boolean))].sort();
|
|
@@ -799,8 +802,8 @@ function readSource(root, file, linked) {
|
|
|
799
802
|
}
|
|
800
803
|
|
|
801
804
|
/** Every file the tree holds in a language this reads, with its comments and the ones recording history. */
|
|
802
|
-
export function scan(root, { exclude = [] } = {}) {
|
|
803
|
-
const files = trackedFiles(root);
|
|
805
|
+
export function scan(root, { exclude = [], timeout, env, noFsmonitor = false } = {}) {
|
|
806
|
+
const files = trackedFiles(root, timeout, env, noFsmonitor);
|
|
804
807
|
const unused = exclude.filter((prefix) => !files.some((file) => file.startsWith(prefix)));
|
|
805
808
|
if (unused.length) throw new CommentsError(`--exclude ${unused.join(", ")} names no file in the tree: remove it`);
|
|
806
809
|
const read = [];
|
|
@@ -822,7 +825,7 @@ export function scan(root, { exclude = [] } = {}) {
|
|
|
822
825
|
const sum = (items, pick) => items.reduce((total, item) => total + pick(item), 0);
|
|
823
826
|
const figure = (n) => n.toLocaleString("en-US");
|
|
824
827
|
|
|
825
|
-
export const historyCount = (root) => sum(scan(root), (entry) => entry.history.length);
|
|
828
|
+
export const historyCount = (root, options) => sum(scan(root, options), (entry) => entry.history.length);
|
|
826
829
|
|
|
827
830
|
function directoryTable(read) {
|
|
828
831
|
const directories = new Map();
|