@sleepy_panda_srl/portulan 0.2.0 → 0.3.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/cli/drills.mjs CHANGED
@@ -54,7 +54,7 @@ export const NOT_DRILLED = [
54
54
  why: "deliberately not a verify recipe — `../.portulan/identity.md` argues why: CI installs nothing, so declaring it would make a recipe that exits 2 on every run. It is checked at the supervised checkpoints and before a release, which is a rail on no calendar, and that cost is already stated in `../.portulan/verify/README.md`.",
55
55
  },
56
56
  {
57
- rail: "Dependabot, the `copilot auto-review` ruleset, and `pr-labels`",
57
+ rail: "Dependabot and `pr-labels`",
58
58
  why: "platform watchers whose triggering condition is an event this repository cannot manufacture on a schedule. `0007`'s own honest limit applies — not every watcher can be forced red safely — and each carries its own recorded observation instead.",
59
59
  },
60
60
  {
@@ -163,7 +163,7 @@ export const DRILLS = [
163
163
  },
164
164
  exit: 1,
165
165
  tell: "fixture(s) failed",
166
- why: "The jq programs the merge gates branch on are executed rather than described. A filter that silently changed shape would take the label gate down with it.",
166
+ why: "The jq programs the label gate branches on are executed rather than described. A filter that silently changed shape would take the label gate down with it.",
167
167
  },
168
168
  {
169
169
  rail: "index",
@@ -199,6 +199,17 @@ export const DRILLS = [
199
199
  tell: "does not reproduce",
200
200
  why: "The host writes one usage record per content block, so a reader that stops deduplicating by message id multiplies every figure by the blocks per request and still prints plausible numbers. The fixture's known totals are the only thing that tells those numbers from the true ones.",
201
201
  },
202
+ {
203
+ rail: "ledger",
204
+ perturb: {
205
+ file: "cli/ledger.mjs",
206
+ find: "if (!calls.has(result.call)) continue;",
207
+ replace: "if (result.call === null) continue;",
208
+ },
209
+ exit: 1,
210
+ tell: "✗ fixture: local is",
211
+ why: "A local worker's subagent reports its receipt back to the session that delegated it, so a reader that takes a receipt from any tool result counts the same local work twice, once from the Bash call that ran it and once from the report quoting it, and the hybrid line still reads like a figure. Only the hybrid fixture's known receipt count tells the two apart.",
212
+ },
202
213
  {
203
214
  rail: "control-chars",
204
215
  perturb: {
@@ -248,7 +259,7 @@ export const DRILLS = [
248
259
  rail: "pack-identity",
249
260
  perturb: {
250
261
  file: "README.md",
251
- find: "**Current release: `0.2.0`**",
262
+ find: "**Current release: `0.3.0`**",
252
263
  replace: "**Current release: `0.1.3`** <!-- the drill edited this and did not stage it -->",
253
264
  },
254
265
  stage: false,
@@ -332,7 +343,7 @@ export const DRILLS = [
332
343
  rail: "version-carriers",
333
344
  perturb: {
334
345
  file: "README.md",
335
- find: "**Current release: `0.2.0`**",
346
+ find: "**Current release: `0.3.0`**",
336
347
  replace: "**Current release: `0.1.3`**",
337
348
  },
338
349
  // Staged: this rail reads the index (`git show :<path>`), so an unstaged edit is invisible to it.
@@ -458,6 +469,17 @@ export const DRILLS = [
458
469
  tell: "is a tag or branch, not a commit",
459
470
  why: "The composed pack's own rail, drilled from the adopting workspace exactly as a workspace-owned one is — which is the property milestone 7's composition amendment bought and nothing else here re-checks.",
460
471
  },
472
+ {
473
+ rail: "tools/local-llm:runner-rails",
474
+ perturb: {
475
+ file: "packs/tools/local-llm/runner/local-llm.mjs",
476
+ find: " if (!allowRemote && !LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) {",
477
+ replace: " if (!allowRemote && false) {",
478
+ },
479
+ exit: 1,
480
+ tell: "non-loopback endpoint was not refused by assertLoopback",
481
+ why: "Loopback is the rail that keeps a local worker's context on the machine, and the runner holds it in code rather than in any permission rule the compiler emits, so a guard that stopped refusing would leave every other rail green. The drill removes the refusal and requires the pack's own recipe to see it.",
482
+ },
461
483
 
462
484
  // ------------------------------------------------------- the coverage rail, which is itself a rail
463
485
  {
package/cli/ledger.mjs CHANGED
@@ -120,10 +120,60 @@ const writtenOf = (r) => r.written1h + r.written5m + r.writtenUnknown;
120
120
 
121
121
  const chainOf = (r) => (r.sidechain ? `agent:${r.agent ?? ""}` : "session");
122
122
 
123
+ /** The local runner's receipt format, a whole line or nothing: a format, so any line that matches it is read as one. */
124
+ const RECEIPT =
125
+ /^OK mode=([a-z]+) model=(\S+)(?: target=.+?)? attempts=([1-9]\d*) in=(?:(0|[1-9]\d*)t|\?) out=(?:(0|[1-9]\d*)t|\?) time=(?:(0|[1-9]\d*)ms|\?) rate=(?:(?:0|[1-9]\d*)(?:\.\d)?tok\/s|\?) spend=\$0\.00$/;
126
+
127
+ /** A figure the runner printed as `?` is null, never 0. */
128
+ export function receiptOf(line) {
129
+ const m = RECEIPT.exec(line);
130
+ if (m === null) return null;
131
+ const [attempts, input, output, time] = [m[3], m[4], m[5], m[6]].map((v) => (v === undefined ? null : Number(v)));
132
+ if (![attempts, input, output, time].every((v) => v === null || Number.isSafeInteger(v))) return null;
133
+ return { mode: m[1], model: m[2], attempts, in: input, out: output, time };
134
+ }
135
+
136
+ /** The ids of the Bash calls a record makes: each block's type, name and id, and nothing it says. */
137
+ function bashCallsOf(record) {
138
+ const content = record.message?.content;
139
+ if (!Array.isArray(content)) return [];
140
+ return content.filter((b) => b !== null && typeof b === "object" && b.type === "tool_use" && b.name === "Bash" && text(b.id) !== null).map((b) => b.id);
141
+ }
142
+
143
+ /** A tool result's output as the host writes it: a string, or text blocks and nothing else. */
144
+ function outputOf(content) {
145
+ if (typeof content === "string") return content;
146
+ if (!Array.isArray(content) || !content.length || !content.every((b) => b?.type === "text" && typeof b.text === "string")) return null;
147
+ return content.map((b) => b.text).join("\n");
148
+ }
149
+
150
+ /** The receipts in a user record's tool results, each still to be matched to the Bash call it answers. */
151
+ function resultsOf(record) {
152
+ if (record === null || typeof record !== "object" || record.type !== "user" || !Array.isArray(record.message?.content)) return [];
153
+ const at = Date.parse(record.timestamp);
154
+ const found = [];
155
+ for (const block of record.message.content) {
156
+ if (block === null || typeof block !== "object" || block.type !== "tool_result" || text(block.tool_use_id) === null) continue;
157
+ const receipts = (outputOf(block.content) ?? "").split("\n").map(receiptOf).filter((r) => r !== null);
158
+ if (!receipts.length) continue;
159
+ found.push({
160
+ call: block.tool_use_id,
161
+ receipts,
162
+ branch: text(record.gitBranch),
163
+ cwd: text(record.cwd),
164
+ at: Number.isFinite(at) ? at : null,
165
+ sidechain: record.isSidechain === true,
166
+ agent: text(record.agentId),
167
+ });
168
+ }
169
+ return found;
170
+ }
171
+
123
172
  export function readLine(line) {
124
173
  const usageLike = line.includes('"usage":');
125
174
  const boundaryLike = line.includes('"compact_boundary"');
126
- if (!usageLike && !boundaryLike) return null;
175
+ const resultLike = line.includes('"tool_result"') && line.includes("spend=$0.00");
176
+ if (!usageLike && !boundaryLike && !resultLike) return null;
127
177
  let record;
128
178
  try {
129
179
  record = JSON.parse(line);
@@ -134,7 +184,12 @@ export function readLine(line) {
134
184
  return { boundary: true, sidechain: record.isSidechain === true, agent: text(record.agentId) };
135
185
  }
136
186
  const request = requestOf(record);
137
- return request === null ? null : { request };
187
+ if (request !== null) {
188
+ const calls = bashCallsOf(record);
189
+ return calls.length ? { request, calls } : { request };
190
+ }
191
+ const results = resultLike ? resultsOf(record) : [];
192
+ return results.length ? { results } : null;
138
193
  }
139
194
 
140
195
  /** Claude Code 2.1.280, undocumented: one record per content block, so one request per message id. */
@@ -144,6 +199,8 @@ export function readTranscript(file) {
144
199
  const requests = [];
145
200
  const tally = { records: 0, duplicates: 0, malformed: 0, synthetic: 0, compactions: 0 };
146
201
  const figures = sessionFigures();
202
+ const calls = new Set();
203
+ const receipts = [];
147
204
  // Compacted contexts awaiting their next request, by chain, so a subagent's compaction is never the session's.
148
205
  const awaiting = new Set();
149
206
  for (const line of source.split("\n")) {
@@ -154,6 +211,15 @@ export function readTranscript(file) {
154
211
  tally.malformed += 1;
155
212
  continue;
156
213
  }
214
+ if (read.results) {
215
+ // Only a Bash result is read for the format: an agent's report quoting a receipt is no second one.
216
+ for (const result of read.results) {
217
+ if (!calls.has(result.call)) continue;
218
+ const { call, receipts: lines, ...where } = result;
219
+ lines.forEach((receipt, i) => receipts.push({ key: `${call}#${i}`, ...receipt, ...where }));
220
+ }
221
+ continue;
222
+ }
157
223
  foldFigures(figures, read);
158
224
  if (read.boundary) {
159
225
  tally.compactions += 1;
@@ -161,6 +227,7 @@ export function readTranscript(file) {
161
227
  continue;
162
228
  }
163
229
  const request = read.request;
230
+ for (const id of read.calls ?? []) calls.add(id);
164
231
  tally.records += 1;
165
232
  if (request.synthetic) {
166
233
  tally.synthetic += 1;
@@ -177,7 +244,7 @@ export function readTranscript(file) {
177
244
  request.compacted = awaiting.delete(chainOf(request));
178
245
  requests.push(request);
179
246
  }
180
- return { requests, figures, ...tally };
247
+ return { requests, receipts, figures, ...tally };
181
248
  }
182
249
 
183
250
  /** The lifetime a request's writes used, or null where it wrote nothing it named. */
@@ -375,6 +442,8 @@ export function collect({ projects, roots }) {
375
442
  const contexts = [];
376
443
  const tally = { files: 0, records: 0, duplicates: 0, malformed: 0, synthetic: 0 };
377
444
  const seen = new Set();
445
+ const receipts = [];
446
+ const seenReceipts = new Set();
378
447
  for (const entry of transcriptFiles(projects, roots)) {
379
448
  let transcript;
380
449
  try {
@@ -400,8 +469,13 @@ export function collect({ projects, roots }) {
400
469
  tally.duplicates += inside.length - kept.length;
401
470
  if (kept.length) contexts.push({ file: entry.file, session: entry.session, agent, requests: kept, transcript: agent === null ? transcript : null });
402
471
  }
472
+ for (const r of transcript.receipts) {
473
+ if (!insideAny(r.cwd, roots) || seenReceipts.has(r.key)) continue;
474
+ seenReceipts.add(r.key);
475
+ receipts.push({ ...r, session: entry.session, agent: entry.agent ?? (r.sidechain ? `inline:${r.agent ?? "unnamed"}` : null) });
476
+ }
403
477
  }
404
- return { contexts, ...tally };
478
+ return { contexts, receipts, ...tally };
405
479
  }
406
480
 
407
481
  const zero = () => ({ requests: 0, uncached: 0, written1h: 0, written5m: 0, writtenUnknown: 0, read: 0, output: 0 });
@@ -451,6 +525,20 @@ export function tally(contexts, branch) {
451
525
  };
452
526
  }
453
527
 
528
+ /** What the lines in the receipt format on `branch` say, apart from every host figure: each null where none reported it. */
529
+ export function tallyLocal(receipts, branch) {
530
+ const own = receipts.filter((r) => r.branch === branch);
531
+ const figure = (k) => (own.some((r) => r[k] !== null) ? own.reduce((sum, r) => sum + (r[k] ?? 0), 0) : null);
532
+ return {
533
+ receipts: own.length,
534
+ sessions: new Set(own.map((r) => r.session)).size,
535
+ in: figure("in"),
536
+ out: figure("out"),
537
+ time: figure("time"),
538
+ unreported: Object.fromEntries(["in", "out", "time"].map((k) => [k, own.filter((r) => r[k] === null).length])),
539
+ };
540
+ }
541
+
454
542
  function sessionTotals(contexts) {
455
543
  const by = new Map();
456
544
  for (const c of contexts) {
@@ -574,7 +662,8 @@ export function ledger({ projects, config, roots, branch, lines = null, spend =
574
662
  const latest = latestSession(collected.contexts, branch);
575
663
  const priced = { declared: spend.multipliers, horizon: spend.horizon ?? HORIZON };
576
664
  const restart = latest === null ? null : { session: latest.session, ...thresholdFor(latest.transcript, priced) };
577
- return { projects, roots, branch, collected, figures, host, restart, lines, priced };
665
+ const local = tallyLocal(collected.receipts, branch);
666
+ return { projects, roots, branch, collected, figures, local, host, restart, lines, priced };
578
667
  }
579
668
 
580
669
  const signed = (n) => (n > 0 ? `+${grouped(n)}` : n < 0 ? `−${grouped(-n)}` : "0");
@@ -590,9 +679,21 @@ function unjudged({ declared, horizon } = { declared: null, horizon: HORIZON })
590
679
  );
591
680
  }
592
681
 
682
+ function localLine({ local: l, branch }) {
683
+ if (l.receipts === 0) return ` local worker: no line in the runner's receipt format on ${branch} is recorded here`;
684
+ const figure = (k, unit) => {
685
+ if (l[k] === null) return `${k} unreported`;
686
+ return `${k} ${grouped(l[k])} ${unit}${l.unreported[k] ? ` (${grouped(l.unreported[k])} receipt(s) did not report it)` : ""}`;
687
+ };
688
+ return (
689
+ ` local worker: ${grouped(l.receipts)} line(s) in the runner's receipt format, from Bash results in ${grouped(l.sessions)} session(s), at $0.00, none of it in the host's tokens or the restart threshold: ` +
690
+ `${figure("in", "tokens")}, ${figure("out", "tokens")}, ${figure("time", "ms")}`
691
+ );
692
+ }
693
+
593
694
  export function print(report, say) {
594
695
  const { collected, figures: f, host, restart, lines, priced } = report;
595
- say(`ledger: branch ${report.branch} — what this change spent, from Claude Code's own usage records (numbers only: nothing a context said is read)`);
696
+ say(`ledger: branch ${report.branch} — what this change spent, from Claude Code's own usage records (numbers only: nothing a context said is read but a line in the runner's receipt format in a Bash result)`);
596
697
  say(` worktrees: ${report.roots.join(", ")}`);
597
698
  say(
598
699
  ` read: ${grouped(collected.files)} transcript(s) under ${report.projects}, ${grouped(collected.records)} usage record(s): ` +
@@ -601,6 +702,7 @@ export function print(report, say) {
601
702
  );
602
703
  if (f.main.requests + f.subagents.requests === 0) {
603
704
  say(` no request on ${report.branch} is recorded here`);
705
+ say(localLine(report));
604
706
  say(unjudged(priced));
605
707
  return;
606
708
  }
@@ -613,6 +715,7 @@ export function print(report, say) {
613
715
  row("written, unstated", "writtenUnknown");
614
716
  row("read", "read");
615
717
  row("output", "output");
718
+ say(localLine(report));
616
719
  say(` contexts opened: ${grouped(f.sessions + f.subagentContexts)} (${grouped(f.sessions)} session(s), ${grouped(f.subagentContexts)} subagent(s)); compactions: ${grouped(f.compactions)}`);
617
720
  say(` largest context: ${grouped(f.largest)} tokens`);
618
721
  if (f.hitRate === null) say(" hit rate: none — no input was recorded, so none could be read from cache");
@@ -665,6 +768,7 @@ export function pinned(report) {
665
768
  rebuilt: f.rebuilt,
666
769
  causes: f.causes,
667
770
  host: report.host.map((h) => ({ session: h.session, difference: h.difference })),
771
+ local: report.local,
668
772
  threshold: report.restart?.threshold ?? null,
669
773
  };
670
774
  }
package/cli/portulan.mjs CHANGED
@@ -23,7 +23,7 @@ export const SUBCOMMANDS = [
23
23
  {
24
24
  name: "doctor",
25
25
  module: "doctor.mjs",
26
- summary: "validate a workspace against the Workspace Definition",
26
+ summary: "validate a workspace and report branch-owned host tokens beside local receipts; --static skips usage records",
27
27
  },
28
28
  {
29
29
  name: "compile",
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // The review-loop meter: the figures that bound this repository's review loop, derived from its reviews.
2
+ // The review-loop meter: the historical figures of this repository's Copilot review loop, derived from its reviews.
3
3
  //
4
4
  // Fix-rounds are not derivable from the API: whether a push answers a submission is a fact about its contents.
5
5
 
@@ -13,7 +13,7 @@ export const SNAPSHOT_VERSION = "1";
13
13
  // A prefix, not an equality: the reviewer is `copilot-pull-request-reviewer[bot]` on reviews and `Copilot` on comments.
14
14
  export const REVIEWER_PREFIX = "copilot";
15
15
 
16
- // The review-loop rule retires once submissions per pull request stay below this for a full milestone.
16
+ // The retirement threshold the review-loop rule stated: submissions per pull request below this for a full milestone.
17
17
  export const RETIRE_THRESHOLD = 2.0;
18
18
 
19
19
  const isReviewer = (login) =>
@@ -174,10 +174,14 @@ export function renderRegister(m) {
174
174
  lines.push("> it is regenerated and byte-compared, so a hand-edit survives exactly until the next run.");
175
175
  lines.push(">");
176
176
  lines.push("> **Every figure here is in SUBMISSION units** — every review the reviewer submits, one");
177
- lines.push("> per push, including on the branch as opened. It is not the fix-round unit that");
178
- lines.push("> `../../.portulan/memory/a-review-loop-needs-a-bound.md` rule 4 bounds, and no figure");
179
- lines.push("> here may be read as one. See `../../cli/review-meter.mjs` for why fix-rounds are not");
180
- lines.push("> derivable from the API at all.");
177
+ lines.push("> per push, including on the branch as opened. It is not the fix-round unit that rule 4");
178
+ lines.push("> of the retired `a-review-loop-needs-a-bound.md` bounded, and no figure here may be read");
179
+ lines.push("> as one. See `../../cli/review-meter.mjs` for why fix-rounds are not derivable from the");
180
+ lines.push("> API at all.");
181
+ lines.push(">");
182
+ lines.push("> **This register is historical.** It measures the Copilot review loop, which left this");
183
+ lines.push("> repository's review path on 2026-10-30, by the maintainer's decision; the record that");
184
+ lines.push("> bounded the loop retired with it.");
181
185
  lines.push("");
182
186
  lines.push(`- **Repository:** \`${m.repository}\``);
183
187
  lines.push(`- **Captured:** ${m.captured}`);
@@ -210,8 +214,9 @@ export function renderRegister(m) {
210
214
  lines.push("## Against the record's own retirement threshold");
211
215
  lines.push("");
212
216
  lines.push(
213
- `\`a-review-loop-needs-a-bound.md\` retires when submissions per pull request measures below ` +
214
- `**${RETIRE_THRESHOLD.toFixed(1)}** for a full milestone. This window measures ` +
217
+ `\`a-review-loop-needs-a-bound.md\` was to retire when submissions per pull request measured below ` +
218
+ `**${RETIRE_THRESHOLD.toFixed(1)}** for a full milestone, and retired on 2026-10-30 on its other ` +
219
+ `condition, Copilot review leaving the review path. This window measures ` +
215
220
  `**${round2(m.submissionsPerPullRequest)}**, which is ` +
216
221
  `${m.belowRetireThreshold === null ? "unmeasured" : m.belowRetireThreshold ? "**below**" : "**at or above**"} it.`,
217
222
  );
@@ -221,7 +226,7 @@ export function renderRegister(m) {
221
226
  "field the API carries, so this tool measures a window of merged pull requests and the " +
222
227
  "record's *for a full milestone* clause is not evaluated here. Reading this row as the " +
223
228
  "retirement condition met would be reading a different measure than the one the record " +
224
- "states.",
229
+ "stated.",
225
230
  );
226
231
  lines.push("");
227
232
  lines.push("## Per pull request");
package/cli/telemetry.mjs CHANGED
@@ -17,16 +17,31 @@ import { recipeSet, resolverFor } from "./recipe-set.mjs";
17
17
  import { isInside } from "./inside.mjs";
18
18
 
19
19
  // ---------------------------------------------------------------------------------------------
20
- // The offline audit: no verify recipe may reach the network
20
+ // The offline audit: recipes reach neither the network nor local usage records
21
21
  // ---------------------------------------------------------------------------------------------
22
22
 
23
- /** Every mode in cli/ that can reach the network; a new one is unrailed until it has a row here. */
23
+ /** Every network mode in cli/ or a pack (named from `<category>/<name>/`); a new one is unrailed until it has a row here. */
24
24
  export const NETWORK_MODES = Object.freeze([
25
25
  { module: "cli/review-meter.mjs", flag: "--fetch", what: "fetches review data from GitHub" },
26
26
  { module: "cli/telemetry.mjs", flag: "--export", what: "posts an OTLP payload to a collector" },
27
27
  { module: "cli/feedback.mjs", flag: "--approve", what: "files a GitHub issue with `gh issue create`" },
28
+ {
29
+ module: "tools/local-llm/runner/local-llm.mjs",
30
+ unless: "--verify-offline",
31
+ alsoUnless: ["eval", "--verify"],
32
+ what: "calls the model endpoint except offline rails and captured-record verification",
33
+ },
34
+ ]);
35
+
36
+ /** Local usage is machine-specific too; static mode must be explicit on each recipe invocation. */
37
+ export const LOCAL_RECORD_MODES = Object.freeze([
38
+ { module: "cli/doctor.mjs", requiresStatic: "--static", what: "reads local usage records by default" },
39
+ { module: "cli/portulan.mjs", subcommand: "doctor", requiresStatic: "--static", what: "dispatches to the local usage-record reader" },
40
+ { module: "portulan", subcommand: "doctor", requiresStatic: "--static", what: "dispatches to the local usage-record reader" },
28
41
  ]);
29
42
 
43
+ export const modeSpelling = (m) => m.requiresStatic ? `${m.module}${m.subcommand ? ` ${m.subcommand}` : ""} without an immediate ${m.requiresStatic}` : (m.flag ? `${m.module} ${m.flag}` : `${m.module} with anything but ${m.unless} alone${m.alsoUnless ? ` or ${m.alsoUnless.join(" ")} alone` : ""}`);
44
+
30
45
  /** Whole-line comments only: a trailing `#` may sit inside a quote. */
31
46
  export const stripShellComments = (source) =>
32
47
  source
@@ -40,9 +55,35 @@ const tokenNamesModule = (token, module) => token === module || token.endsWith(`
40
55
 
41
56
  const tokenIsFlag = (token, flag) => token === flag || token.startsWith(`${flag}=`);
42
57
 
58
+ /** An `unless` row passes only where every invocation is the module and its one safe flag, alone on its command. */
59
+ const onlySafeInvocations = (source, m) =>
60
+ stripShellComments(source)
61
+ .replace(/\\\n/g, " ")
62
+ .split(/\n|;|&&?|\|\|?|\$\(|<\(|>\(|`/)
63
+ .every((segment) => {
64
+ const tokens = shellTokens(segment);
65
+ if (tokens[0] === "for" || ["[", "test"].includes(tokens[0]) || (tokens[0] === "if" && ["[", "test"].includes(tokens[1]))) return true;
66
+ return tokens.every((t, i) => !tokenNamesModule(t, m.module) || ((tokens.length === i + 2 && tokens[i + 1] === m.unless) || (m.alsoUnless && JSON.stringify(tokens.slice(i + 1)) === JSON.stringify(m.alsoUnless))));
67
+ });
68
+
69
+ const onlyStaticInvocations = (source, mode) => stripShellComments(source)
70
+ .replace(/\\\n/g, " ")
71
+ .split(/\n|;|&&?|\|\|?|\$\(|<\(|>\(|`/)
72
+ .every((segment) => {
73
+ const words = shellTokens(segment);
74
+ const first = words[0];
75
+ // A path in a file test or dependency list is data, not a CLI invocation.
76
+ if (["for", "printf", "echo", "[", "test"].includes(first) || (first === "if" && words.slice(1, 3).some((w) => ["[", "test"].includes(w)))) return true;
77
+ return words.every((word, i) => !tokenNamesModule(word, mode.module) || (mode.subcommand && words[i + 1] !== mode.subcommand) || words[i + (mode.subcommand ? 2 : 1)] === mode.requiresStatic);
78
+ });
79
+
43
80
  export function auditRecipeSource(source) {
44
81
  const tokens = shellTokens(stripShellComments(source));
45
- return NETWORK_MODES.filter((m) => tokens.some((t) => tokenNamesModule(t, m.module)) && tokens.some((t) => tokenIsFlag(t, m.flag)));
82
+ const network = NETWORK_MODES.filter((m) =>
83
+ m.flag ? tokens.some((t) => tokenNamesModule(t, m.module)) && tokens.some((t) => tokenIsFlag(t, m.flag)) : !onlySafeInvocations(source, m),
84
+ );
85
+ const local = LOCAL_RECORD_MODES.filter((m) => !onlyStaticInvocations(source, m));
86
+ return [...network, ...local];
46
87
  }
47
88
 
48
89
  export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
@@ -72,9 +113,10 @@ export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
72
113
  const findings = [];
73
114
  const read = [];
74
115
  for (const r of recipes) {
75
- const script = String(r.run ?? "")
76
- .split(/\s+/)
77
- .find((t) => t.endsWith(".sh"));
116
+ const words = String(r.run ?? "").split(/\s+/).filter(Boolean);
117
+ // A run line that is itself a `node` invocation is the whole shell text the recipe runs, so it is graded as read.
118
+ const direct = words[0] === "node" && !words.some((t) => t.endsWith(".sh"));
119
+ const script = direct ? words.find((t) => /\.[cm]?js$/.test(t)) : words.find((t) => t.endsWith(".sh"));
78
120
  if (!script) {
79
121
  return { ok: false, why: `recipe ${JSON.stringify(r.id)} has no readable script in its run line (${JSON.stringify(r.run ?? null)}); this audit cannot grade what it cannot read` };
80
122
  }
@@ -89,7 +131,7 @@ export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
89
131
  return { ok: false, why: `recipe ${JSON.stringify(r.id)} names ${script}, which could not be read — ${cause.message}` };
90
132
  }
91
133
  read.push(r.id);
92
- for (const hit of auditRecipeSource(source)) findings.push({ recipe: r.id, script, ...hit });
134
+ for (const hit of auditRecipeSource(direct ? r.run : source)) findings.push({ recipe: r.id, script, ...hit });
93
135
  }
94
136
  return { ok: true, examined: read, findings };
95
137
  }
@@ -157,7 +199,7 @@ export const PRODUCERS = {
157
199
  return [
158
200
  { name: "portulan.review.pull_requests", unit: "{pull_request}", description: "Merged pull requests in the metered window.", value: m.pullRequests },
159
201
  { name: "portulan.review.submissions", unit: "{submission}", description: "Reviews the reviewer submitted across the window. Submission units, never fix-rounds.", value: m.submissions },
160
- { name: "portulan.review.submissions_per_pull_request", unit: "1", description: "The figure a-review-loop-needs-a-bound.md bounds the loop on.", value: m.submissionsPerPullRequest },
202
+ { name: "portulan.review.submissions_per_pull_request", unit: "1", description: "The figure the retired a-review-loop-needs-a-bound.md bounded the loop on.", value: m.submissionsPerPullRequest },
161
203
  { name: "portulan.review.submissions_no_inline", unit: "{submission}", description: "Submissions raising no inline comment.", value: m.noInline },
162
204
  { name: "portulan.review.no_inline_rate", unit: "1", description: "An UPPER BOUND on the found-nothing rate; a submission carrying only suppressed notes counts in it.", value: m.noInlineRate },
163
205
  { name: "portulan.review.pushes", unit: "{push}", description: "Distinct heads the reviewer saw — a floor on pushes, not a count of them.", value: m.pushes },
@@ -336,7 +378,7 @@ const USAGE = [
336
378
  " first: OTEL_EXPORTER_OTLP_METRICS_ENDPOINT (used as given) else",
337
379
  " OTEL_EXPORTER_OTLP_ENDPOINT (a base, +/v1/metrics); and",
338
380
  " OTEL_EXPORTER_OTLP_METRICS_HEADERS replacing OTEL_EXPORTER_OTLP_HEADERS",
339
- " --audit-recipes assert that no recipe the workspace YIELDS can reach a network mode;",
381
+ " --audit-recipes assert that no recipe the workspace YIELDS can reach a network mode or read local usage records;",
340
382
  " takes --workspace and --pack-root, and needs no --config",
341
383
  "",
342
384
  "exit 0 the thing asked for happened · 1 a verdict (golden drift, or export while opted out)",
@@ -403,16 +445,16 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
403
445
  return 2;
404
446
  }
405
447
  if (res.findings.length > 0) {
406
- io.error(`telemetry: ${res.findings.length} verify recipe(s) can reach the network:`);
407
- for (const f of res.findings) io.error(` - ${f.recipe} (${f.script}) invokes ${f.module} ${f.flag}, which ${f.what}`);
408
- io.error("A verify recipe may not make a network call: spec/slots.md and .portulan/verify/README.md");
448
+ io.error(`telemetry: ${res.findings.length} verify recipe(s) can reach the network or local usage records:`);
449
+ for (const f of res.findings) io.error(` - ${f.recipe} (${f.script}) invokes ${modeSpelling(f)}, which ${f.what}`);
450
+ io.error("A verify recipe may not make a network call or read local usage records: spec/slots.md and .portulan/verify/README.md");
409
451
  io.error("both prohibit it, because a rail that moves with the network goes red about the world");
410
452
  io.error("rather than about the tree.");
411
453
  return 1;
412
454
  }
413
- io.log(`telemetry: ${res.examined.length} yielded recipe(s) examined; none reaches a network mode`);
414
- io.log(` Modes railed: ${NETWORK_MODES.map((m) => `${m.module} ${m.flag}`).join(", ")}.`);
415
- io.log(" A network mode with no row here is UNRAILED, and nothing can audit that a row was added.");
455
+ io.log(`telemetry: ${res.examined.length} yielded recipe(s) examined; none reaches a network mode or reads local usage records`);
456
+ io.log(` Modes railed: ${[...NETWORK_MODES, ...LOCAL_RECORD_MODES].map(modeSpelling).join(", ")}.`);
457
+ io.log(" A network or local-record mode with no row here is UNRAILED, and nothing can audit that a row was added.");
416
458
  return 0;
417
459
  }
418
460
 
@@ -148,6 +148,70 @@ each section's anchor and size, the same way. It prints from the file as it is,
148
148
  go stale. Nothing checks that a session outlined first; what the rule saves is measured by rerunning a
149
149
  task, never by a recipe, which may not read the host's usage records.
150
150
 
151
+ ## A local model runs without the host floor
152
+
153
+ A model served on the machine itself costs nothing per token, so `0038`'s dollar arithmetic gives it no
154
+ restart threshold. What it does cost is time to prefill, memory for each context it holds, and recall that
155
+ still sags in a long context. And every turn it takes is a turn the parent session does not send again on
156
+ each request after. So a local model is called as a context firewall, and four rules hold it:
157
+
158
+ 1. **A local worker runs without the host floor (`omitClaudeMd`) and under a hard input-byte ceiling.** It
159
+ is given one bounded input: a symbol span from [`cli/symbols.mjs`](../../cli/symbols.mjs), a file under
160
+ 300 lines, a diff, or a log to compress, and never the parent session's instruction files. A prompt over
161
+ the ceiling is refused before a socket opens. _Why: a host floor of 44k to 85k tokens is prefilled into
162
+ a local model's cache on every cold context, where `0039` measured prompts of about 400 to 470 tokens
163
+ prefilling in 0.85 to 1.88 seconds._
164
+ 2. **Thinking effort is set explicitly at invocation**: low or off for a mechanical transform, bounded for
165
+ prose or review, never left to the chat template's default. _Why: one template `0039` read sets
166
+ reasoning effort to `xhigh` unless thinking is switched off, and under a 1,500-token cap that model
167
+ returned no answer on any of three tasks, every token spent thinking; at low effort it passed both
168
+ tasks it was rerun on._
169
+ 3. **A local edit is kept only on its verify command's exit `0`.** The runner applies the edit and runs the
170
+ command. On exit `0` one receipt line is all that enters the parent context (`0038` rule 5). On exit `1`
171
+ after its retries, or at once on any other exit, which is no verdict, it restores the file's original
172
+ bytes, and it does the same on SIGINT, SIGTERM and SIGHUP. **This is a rollback, not an atomic write**:
173
+ the verify needs the edited file in place, so any other signal that ends the runner, SIGKILL and SIGQUIT
174
+ among them, or a power loss between the edit and its verdict leaves the edit, and a transform with no
175
+ receipt is unverified. _Why not atomic: a scratch worktree would judge the last commit plus the edit
176
+ rather than the working copy plus the edit, would lack the untracked files a verify reads, and moving
177
+ the edit back after exit `0` would not be atomic either (`0039` ruling 4)._
178
+ 4. **Loopback is a rail, and routing is earned.** A local runner refuses an endpoint outside loopback
179
+ (`127.0.0.1`, `localhost`, `[::1]`) unless leaving it is proposed and approved. A screen grades an
180
+ installed model once per task against Portulan's own predicates, from
181
+ [`cli/comments.mjs`](../../cli/comments.mjs), [`cli/doctor.mjs`](../../cli/doctor.mjs) and
182
+ [`cli/skill-goldens.mjs`](../../cli/skill-goldens.mjs), and a model that fails it is not routed. Passing
183
+ it earns nothing. A role is routed to a local model by an A/B record under `0038` rule 4: the local model
184
+ as routed against the host's model on the same tasks, `k` runs per cell, captured once and railed to its
185
+ register in `evals/ab/`. _Why: an endpoint off the machine sends workspace context away under a local
186
+ tool's name, and the screen takes one sample per task with no baseline arm, while a cheaper model for a
187
+ role is earned by an A/B run, never assumed (`0039` ruling 5)._
188
+
189
+ **What the runner holds today.** The [`tools/local-llm`](../../packs/tools/local-llm/README.md) pack's
190
+ runner holds rules 1 to 3 and rule 4's loopback rail for the calls it makes. It sends the model its own
191
+ short instructions and the input it was given, with the end of a red verify's output on a retry, and
192
+ nothing else. It refuses a prompt over its ceiling before a socket opens, names the effort on every chat
193
+ request, low unless set, requires a verify command for an edit and rolls back as rule 3 says, and refuses
194
+ a host off loopback and a redirect away from it. Which inputs it is given is the caller's: only their
195
+ bytes are checked. `--allow-remote` lifts the loopback rail on the caller's word, and the pack's
196
+ `local-llm-off-loopback` gate has no matcher, so approval to leave loopback is the human's. Its `eval`
197
+ subcommand is the screen. Its `runner-rails` recipe holds the loopback and ceiling refusals and the
198
+ rollback on a red or no-verdict verify offline, with no network call; the rollback on a signal is held by
199
+ the runner's tests. This repository composes the pack and registers its skill; the ledger reports local
200
+ receipts beside the host's tokens. `doctor` includes that branch-owned usage report by default,
201
+ with unavailable data reported as unmeasured; `--static` omits usage-record discovery for verification.
202
+ The [captured A/B](../../evals/ab/local-worker.md) supports code
203
+ at low effort (L5/H5), while shared prose is disabled because codify scored L0/H3, despite handoff's
204
+ L5/H5. Operational defaults and explicit roles require the measured model at low effort; explicit
205
+ model-only calls remain candidates. The record and register are regraded and byte-compared in CI,
206
+ with shipped routing rederived from their turns. The `local-worker` persona declares
207
+ `omitClaudeMd: true` and `effort: low`, and `compile` writes both into a workspace's binding against a
208
+ committed golden.
209
+
210
+ _(Provenance: proposal
211
+ [`0039`](../../.portulan/proposals/0039-a-local-model-runs-without-the-host-floor-and-under-a-thinking-cap.md)'s
212
+ measurement of two local models against three of this repository's tasks on 2026-10-27, and its rulings 4
213
+ and 5 of 2026-10-30.)_
214
+
151
215
  ## What is machinery today, and what is not
152
216
 
153
217
  As of Workspace Definition 2.9 a workspace can declare the budget and the ratio, and both are checked
@@ -169,10 +233,21 @@ a section is never moved for its size alone. Claude Code 2.1.281 drops a comment
169
233
  before it loads an instruction file or rule (read in its program text on 2026-09-24), so the mark and the
170
234
  marker left behind cost nothing. This repository's own card holds its boot under the 7,500-token line of
171
235
  2026-09-23, railed at today's figure plus 2% by the same recipe. A pointer's repository is not measured,
172
- because a pointer declares no tree. **Still to land**: the exact mode that
173
- calibrates a ratio, the degradation report that says which tiers a host can express, the Stop-gate's
174
- named demotion, persona frontmatter and the librarian pass, one change at a time, in `0036`'s order of work
175
- and under row 12 of [`../../docs/plan.md`](../../docs/plan.md).
236
+ because a pointer declares no tree. **A persona can declare what its context costs**: `model`, `effort`,
237
+ `omitClaudeMd` and `cacheLifetime`, refused when malformed and never defaulted, which `compile` writes into
238
+ the hand-written binding of a persona a composed pack ships and byte-compares under `--check`, printing
239
+ each field a host cannot express, with the persona and the host, as its degradation report for personas.
240
+ Claude Code 2.1.317 takes all four on an agent, and loads `agents/` only from a plugin root (both read
241
+ in its program text on 2026-11-03), so in a tree that is not a plugin the compiled block is loaded by
242
+ nothing, and the degradation report says so; in one that is, only while the plugin is installed and
243
+ enabled, which `compile` cannot see. That stops short of the row: `compile` writes no
244
+ `.claude/agents/`, where a repository that is not a plugin loads its agents, and no binding where none
245
+ exists; a core persona's binding in this plugin's `agents/` is not compiled; and no host has been shown
246
+ honouring a compiled field. **Still to land**: the
247
+ exact mode that calibrates a ratio, the degradation report's half that says which tiers a host can
248
+ express, the Stop-gate's named demotion and the librarian pass, one change at a time, in `0036`'s order
249
+ of work and under row 12 of
250
+ [`../../docs/plan.md`](../../docs/plan.md).
176
251
  Until each lands, the rule it would enforce is held by the human gate, and saying so is the point: a rule
177
252
  nothing checks, presented as one that something does, is the failure this page exists to prevent.
178
253
 
@@ -21,6 +21,39 @@ context firewalls; Cognition — read-parallel / write-isolated.)_
21
21
  - **Read / write posture** — most personas read in parallel; writes are isolated to one place so two
22
22
  agents never clobber the same file.
23
23
 
24
+ ## What a persona may declare about its context
25
+
26
+ Four optional frontmatter fields say what a fresh context for the role should cost, per
27
+ [`../operating/context.md`](../operating/context.md): `model`, one model name or alias as a single
28
+ token; `effort`, one of `low`, `medium`, `high`, `xhigh` or `max`; `omitClaudeMd`, `true` or `false`
29
+ unquoted, whether the role runs without the project's instruction files; and `cacheLifetime`, `5m` or
30
+ `1h`. The effort levels and the name `omitClaudeMd` are adopted from Claude Code's vocabulary, the first
31
+ host to express them. `effort` sets the host subagent's reasoning effort, and is not the thinking cap the
32
+ `tools/local-llm` runner's `--effort` sends with each request it makes. `doctor` fails a
33
+ malformed value and `compile` refuses one, and neither supplies a value the persona did not write. For a
34
+ persona a composed pack ships, `compile` writes the declared fields into the frontmatter of the role's
35
+ hand-written host binding, `agents/<name>.md` in the workspace's tree, and leaves every other line of
36
+ that file as its author wrote it; `--check` byte-compares the result, and a line inside the compiled
37
+ block that `compile` did not write is refused rather than removed. One it cannot catch: a line added inside
38
+ the block in exactly the spelling and order `compile` writes reads as compiled, and is dropped once the
39
+ persona no longer declares that field, since the two cannot be told apart. And once a persona declares any
40
+ field, `compile` owns every top-level key it could write, so a hand-written `experimental:` is refused
41
+ even for a key other than `cacheTtl`. A field a host cannot express is
42
+ named, with the persona and the host, in the degradation report `compile` prints. Claude Code takes all
43
+ four on an agent (`cacheLifetime` as `experimental.cacheTtl`), and a vendored `AGENTS.md` takes none.
44
+ **Claude Code loads `agents/` only from a plugin root**, and a repository that is not a plugin loads its
45
+ agents from `.claude/agents/`, which `compile` does not write. So outside a plugin the compiled block is
46
+ loaded by nothing, and the degradation report says so for each persona it applies to. Where the tree is
47
+ a plugin root the report is silent, which is not proof of loading: a plugin's agents load only while it
48
+ is installed and enabled, and `compile` cannot see that. _(Provenance:
49
+ proposal `0036`'s order of work, item 7, and proposal `0039`'s first two rules. The host's keys, values
50
+ and agent locations were read in Claude Code 2.1.317's program text on 2026-11-03.)_
51
+
52
+ A binding has one persona owner across composed packs, including an unbound persona or one declaring
53
+ no fields; duplicate destinations are refused before any artifact is written. The binding must be a
54
+ regular file whose real path stays in the real declared tree, including its parent directories.
55
+ `omitClaudeMd` accepts only unquoted `true` or `false`, in both declarations and compiled blocks.
56
+
24
57
  ## Status
25
58
 
26
59
  **Milestone 3 — contract, exemplars, and one host binding.** This README fixes the persona contract; the
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: clarify
3
- description: Run a bounded clarification pass before planning a full-lane task whose intent is ambiguous or whose acceptance criteria are not yet testable. Use when the ask is underspecified, the constraints conflict, or the criteria can't be written as observable checks — surface the smallest set of decision-questions, get human answers, and fold them into EARS-shaped acceptance criteria before any code is planned.
3
+ description: Run a bounded clarification pass before planning a full-lane task. Use when the ask is underspecified, the constraints conflict, or the criteria can't be written as observable checks — surface the smallest set of decision-questions, get human answers, and fold them into EARS-shaped acceptance criteria before any code is planned.
4
4
  ---
5
5
 
6
6
  # Skill — Clarify