@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.
Files changed (90) hide show
  1. package/README.md +18 -6
  2. package/cli/README.md +56 -4
  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 +492 -146
  7. package/cli/context.mjs +204 -43
  8. package/cli/doctor.mjs +211 -33
  9. package/cli/drills.mjs +32 -7
  10. package/cli/finish.md +34 -0
  11. package/cli/finish.mjs +9 -67
  12. package/cli/form.mjs +35 -19
  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 +123 -37
  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 +7 -2
  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/ritual-contexts.mjs +103 -0
  34. package/cli/sessions.mjs +2 -1
  35. package/cli/spend-analysis.mjs +111 -0
  36. package/cli/spend-offer.md +53 -0
  37. package/cli/spend-offer.mjs +93 -0
  38. package/cli/stop-diagnostics.mjs +114 -0
  39. package/cli/stop-gate.mjs +133 -90
  40. package/cli/stop-policy.mjs +14 -0
  41. package/cli/symbols.mjs +55 -8
  42. package/cli/telemetry.mjs +19 -12
  43. package/cli/upgrade.mjs +62 -44
  44. package/cli/vendor.mjs +210 -126
  45. package/core/operating/context.md +368 -60
  46. package/core/operating/evolution.md +3 -2
  47. package/core/operating/loop.md +9 -4
  48. package/core/operating/sessions.md +6 -15
  49. package/core/personas/README.md +52 -10
  50. package/core/personas/implementer.md +3 -0
  51. package/core/personas/librarian.md +3 -0
  52. package/core/personas/reviewer.md +3 -0
  53. package/core/templates/handoff.md +6 -2
  54. package/evals/ab/personas/README.md +111 -0
  55. package/evals/ab/personas/acquisition.json +330 -0
  56. package/evals/ab/personas/bindings/implementer.txt +43 -0
  57. package/evals/ab/personas/bindings/librarian.txt +40 -0
  58. package/evals/ab/personas/bindings/reviewer.txt +41 -0
  59. package/evals/ab/personas/corpus.json +69 -0
  60. package/evals/ab/personas/grader.mjs +171 -0
  61. package/evals/ab/personas/implementer.json +774 -0
  62. package/evals/ab/personas/implementer.md +22 -0
  63. package/evals/ab/personas/librarian.json +758 -0
  64. package/evals/ab/personas/librarian.md +22 -0
  65. package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
  66. package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
  67. package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
  68. package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
  69. package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
  70. package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
  71. package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
  72. package/evals/ab/personas/reviewer.json +758 -0
  73. package/evals/ab/personas/reviewer.md +22 -0
  74. package/evals/releases/0.4.0.json +154 -0
  75. package/evals/releases/0.4.0.md +69 -0
  76. package/evals/releases/0.4.1.json +154 -0
  77. package/evals/releases/0.4.1.md +69 -0
  78. package/package.json +6 -2
  79. package/packs/rituals/checkpoints/README.md +35 -0
  80. package/packs/rituals/checkpoints/pack.json +3 -2
  81. package/packs/tools/local-llm/README.md +46 -6
  82. package/packs/tools/local-llm/runner/local-llm.mjs +127 -13
  83. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +26 -2
  84. package/spec/README.md +37 -4
  85. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
  86. package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
  87. package/spec/migrations/README.md +6 -0
  88. package/spec/pack.schema.json +12 -3
  89. package/spec/slots.md +262 -117
  90. package/spec/workspace.schema.json +67 -11
package/cli/advisory.mjs CHANGED
@@ -1,31 +1,37 @@
1
1
  #!/usr/bin/env node
2
- // The restart advisory — one line, once, where the agent is, and the same figure for the human.
2
+ // Restart comparison with explicit horizon and resume expense; unknown recovery cost stays conditional.
3
3
  //
4
- // node cli/advisory.mjs tool [<figures>] a PostToolUse hook: the line, as additionalContext, once, mid-stretch
5
- // node cli/advisory.mjs prompt [<figures>] a UserPromptSubmit hook: the same line, at the next prompt, if not said yet
6
- // node cli/advisory.mjs status [<figures>] a status-line command: the figure and the request count, on every refresh
7
- // node cli/advisory.mjs stop [<figures>] a Stop hook, where a workspace declares it: the same line as a block, once
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
- const STATE_VERSION = 2;
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
- function advance(kept, transcriptPath, size) {
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
- Object.assign(kept, { offset: 0, tail: "", figures: sessionFigures() });
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 !== null && !read.malformed) foldFigures(kept.figures, 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
- if (advance(found.kept, found.transcriptPath, found.stat.size) && found.file !== null) keep(found.file, found.kept, warn);
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 assumed =
196
- m.source === "declared"
197
- ? "multipliers declared"
198
- : `multipliers undeclared, so the general read multiplier and the ${m.recorded ? `${m.lifetime === "1h" ? "one-hour" : "five-minute"} writes the host recorded` : "five-minute write the records did not state"}`;
199
- return (
200
- `Portulan restart advisory: after ${grouped(figure.requests)} requests, each re-reading it, this session's context, ${grouped(figure.context)} tokens, has reached its restart threshold of ` +
201
- `${grouped(figure.threshold)} = fresh context ${grouped(figure.fresh)} × (1 + write ${m.write}× / (${figure.horizon} more requests × read ${m.read}×)); ${assumed}. ` +
202
- `Continuing costs more in re-reads than restarting: ${FINISH[event]}write the handoff and end the session. Said once.`
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
- const multiplied = `${m.source === "declared" ? "multipliers declared" : "multipliers undeclared"}: read ${m.read}×, write ${m.write}×`;
209
- const requests = `${grouped(figure.requests)} request${figure.requests === 1 ? "" : "s"}`;
210
- return figure.context >= figure.threshold
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 once(event, payload, { dir = os.tmpdir(), warn = () => {}, declared = null, horizon = HORIZON } = {}) {
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
- // A host can give a block's retry a new session id, where the held-once record would hold it again.
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.context < figure.threshold) return null;
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 JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: adviceLine(figure, event) } });
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 JSON.stringify({ decision: "block", reason: adviceLine(figure, event) });
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 = 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
- const counts = usage !== null && typeof usage === "object" ? ["input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"].map((k) => usage[k]) : [];
275
- if (counts.length === 3 && counts.every((n) => Number.isSafeInteger(n) && n >= 0)) figure.context = counts.reduce((a, b) => a + b, 0);
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
- const priced = { dir, warn, declared: figures.declared, horizon: figures.horizon ?? HORIZON };
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();