@sleepy_panda_srl/portulan 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +6 -5
  2. package/cli/README.md +142 -358
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1322 -1607
  6. package/cli/context.mjs +883 -0
  7. package/cli/control-chars.mjs +12 -301
  8. package/cli/discover.mjs +17 -574
  9. package/cli/doctor.mjs +224 -1074
  10. package/cli/drills.mjs +68 -502
  11. package/cli/feedback.mjs +16 -237
  12. package/cli/finish.mjs +437 -0
  13. package/cli/form.mjs +644 -0
  14. package/cli/fuzz-shell.mjs +20 -305
  15. package/cli/gate.mjs +7 -199
  16. package/cli/goldens.mjs +26 -184
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +243 -566
  19. package/cli/inside.mjs +3 -28
  20. package/cli/instructions.mjs +829 -0
  21. package/cli/ledger.mjs +778 -0
  22. package/cli/librarian.mjs +135 -599
  23. package/cli/manifest.mjs +2 -39
  24. package/cli/mutants.mjs +9 -170
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +5 -74
  27. package/cli/pack-version.mjs +13 -255
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +5 -124
  30. package/cli/recipe-set.mjs +15 -245
  31. package/cli/release-eval.mjs +28 -348
  32. package/cli/review-meter.mjs +17 -258
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +10 -176
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +83 -514
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +27 -492
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +8 -86
  43. package/core/README.md +1 -1
  44. package/core/engine.md +3 -9
  45. package/core/operating/autonomy.md +1 -1
  46. package/core/operating/context.md +183 -0
  47. package/core/operating/evolution.md +3 -3
  48. package/core/operating/loop.md +13 -20
  49. package/core/operating/memory.md +12 -3
  50. package/core/operating/sessions.md +100 -0
  51. package/core/personas/librarian.md +2 -2
  52. package/core/skills/consolidate/SKILL.md +4 -2
  53. package/core/templates/handoff.md +28 -15
  54. package/core/templates/memory-entry.md +1 -0
  55. package/evals/README.md +255 -845
  56. package/evals/releases/0.2.0.json +146 -0
  57. package/evals/releases/0.2.0.md +67 -0
  58. package/evals/releases/README.md +3 -3
  59. package/package.json +4 -2
  60. package/packs/rituals/checkpoints/README.md +1 -1
  61. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  62. package/spec/README.md +112 -6
  63. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  64. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  65. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  66. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  67. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  68. package/spec/migrations/0006-boot-card.mjs +77 -0
  69. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  70. package/spec/migrations/0008-card-reading.mjs +55 -0
  71. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  72. package/spec/migrations/0010-comment-rail.mjs +57 -0
  73. package/spec/migrations/README.md +45 -10
  74. package/spec/pack.schema.json +1 -1
  75. package/spec/slots.md +333 -21
  76. package/spec/workspace.schema.json +145 -4
@@ -1,40 +1,9 @@
1
1
  /**
2
2
  * `rule-carriers` — the rail that keeps a reduced rule reduced.
3
3
  *
4
- * Proposal `.portulan/proposals/0027-a-reduced-rule-stays-reduced.md`. A rule an incident has reduced
5
- * to ONE carrier is registered with the spellings its other carriers used; those spellings may then
6
- * appear only in the carrier, or beside a citation of it.
4
+ * A registered rule's other spellings may appear only in its carrier, or beside a citation of it.
7
5
  *
8
- * ## What this is NOT
9
- *
10
- * It is not a solution to `0020`'s class, and describing it as one would be the capability claim
11
- * `.portulan/memory/a-stated-enforcer-must-be-the-real-one.md` forbids. `0020` proved no rail can find
12
- * *this patch's rule's other carriers*, because a rule has no token and the sibling set is exactly what
13
- * nobody enumerated. That holds. **This operates only after an incident has enumerated a set** — at
14
- * which point the rule does have a token, namely the spellings actually found — and its job is to stop
15
- * the reduction being undone. Measured need: on 2026-08-10 one branch removed a hand-maintained count
16
- * and roster while another re-armed them, and a handoff on `main` is titled
17
- * *the-correction-merged-and-the-next-pull-request-put-it-back*.
18
- *
19
- * A rule nobody registered is covered by nothing, and nothing says so. That is stated in the proposal
20
- * and repeated here because this file is where a reader checks whether the claim matches the code.
21
- *
22
- * ## Scope is a PREFIX, deliberately, and the record layer is out by construction
23
- *
24
- * `scope` entries are path prefixes, not globs. A glob language would be a second thing to get wrong,
25
- * and prefixes are what the domain actually needs: doctrine lives in directories. `exclude` entries are
26
- * prefixes too and win over `scope`.
27
- *
28
- * The record layer — handoffs, proposals, milestone files, the Session log, the changelog — is excluded
29
- * because it legitimately quotes retired sentences forever: `0004` keeps its own minting words under a
30
- * dated supersession note, and records here are forward-only. A rail over them would be red on arrival
31
- * and permanently, which is what `a-superlative-is-a-count-nobody-ran.md` refused a grep over.
32
- *
33
- * ## Exit codes — the three-code discipline every recipe here holds
34
- *
35
- * 0 no registered spelling appears outside its carrier without a citation
36
- * 1 at least one does
37
- * 2 could not run — the registry is unusable, or its own audit fails
6
+ * Exit 0 green · 1 a registered spelling outside its carrier, uncited · 2 could not run.
38
7
  */
39
8
 
40
9
  import fs from "node:fs";
@@ -46,11 +15,6 @@ export class RegistryError extends Error {}
46
15
 
47
16
  const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
48
17
 
49
- /**
50
- * Validate the registry BEFORE it is used, so an unusable rule is could-not-run rather than a rule
51
- * that quietly covers nothing. The same direction `recipe-set.mjs` takes: the emitter validates
52
- * before it emits, because "declared" is what a gate treats as "enforced".
53
- */
54
18
  export function parseRegistry(source, { where = "registry" } = {}) {
55
19
  let raw;
56
20
  try {
@@ -93,11 +57,6 @@ export function parseRegistry(source, { where = "registry" } = {}) {
93
57
  if (typeof entry !== "string" || entry.trim() === "") {
94
58
  throw new RegistryError(`${at} (\`${id}\`) has an empty entry in \`${key}\``);
95
59
  }
96
- // Untrimmed is refused rather than silently trimmed, matching `cli/compile.mjs`, which
97
- // does the same for a rule's action and a required context. It matters most for `scope`
98
- // and `exclude`, which are compared with `startsWith`: a trailing space makes a prefix
99
- // that can never match any path, so the entry silently covers nothing and the registry
100
- // still reports green. Refusing is predictable; repairing quietly is not.
101
60
  if (entry !== entry.trim()) {
102
61
  throw new RegistryError(
103
62
  `${at} (\`${id}\`) has an untrimmed entry in \`${key}\`: ${JSON.stringify(entry)} — ` +
@@ -110,10 +69,6 @@ export function parseRegistry(source, { where = "registry" } = {}) {
110
69
  if (!Array.isArray(exclude) || exclude.some((e) => typeof e !== "string" || e.trim() === "")) {
111
70
  throw new RegistryError(`${at} (\`${id}\`) has an unusable \`exclude\``);
112
71
  }
113
- // The rule-level `exclude` needs the untrimmed check too. It did not have one: the check was
114
- // added to `tells`, `cites`, `scope` and the top-level `exclude` and missed this fourth site
115
- // — one rule, four enforcement points, repaired at three. The suite caught it, which is the
116
- // only reason it is not shipping as the defect this whole change is about.
117
72
  for (const entry of exclude) {
118
73
  if (entry !== entry.trim()) {
119
74
  throw new RegistryError(
@@ -135,15 +90,7 @@ export function parseRegistry(source, { where = "registry" } = {}) {
135
90
  };
136
91
  });
137
92
 
138
- // The top-level `exclude` is validated exactly as a rule's is. It was not, and the gap was not
139
- // cosmetic — but the reason is NOT a crash, and this comment said it was until it was corrected.
140
- // `inDomain` calls `file.startsWith(p)`, and `String.prototype.startsWith` COERCES its argument:
141
- // 1, null, true, {} and ["x"] all return false quietly, so nothing throws for anything JSON can
142
- // carry. The real risk is worse than a stack trace because it is silent — `[]` and `""` coerce to
143
- // the empty string, EVERY path starts with the empty string, so one such entry would exclude the
144
- // whole tree and this rail would report green having examined nothing. A fail-open in an
145
- // allow-list. The suite pins that coercion; this comment stated the opposite for one round, which
146
- // is the class this file exists to catch, in the explanation of the fix for it.
93
+ // `startsWith` coerces its argument: `[]` or `""` would match every path and exclude the whole tree.
147
94
  if (raw.exclude !== undefined) {
148
95
  if (!Array.isArray(raw.exclude)) {
149
96
  throw new RegistryError(`${where} has an \`exclude\` that is not an array`);
@@ -164,11 +111,8 @@ export function parseRegistry(source, { where = "registry" } = {}) {
164
111
  return { rules, exclude: Array.isArray(raw.exclude) ? [...raw.exclude] : [] };
165
112
  }
166
113
 
167
- /** A file is in a rule's domain when a scope prefix matches and no exclude prefix does. */
168
114
  export function inDomain(file, rule, globalExclude = []) {
169
- // `rule.exclude` is normalised to an array by `parseRegistry`, but this function is exported and a
170
- // caller can hand it a rule object built by hand — which the suite does, and which threw. An
171
- // exported predicate that only works on one caller's normalisation is a trap for the next reader.
115
+ // Exported, so a hand-built rule may lack the arrays `parseRegistry` guarantees.
172
116
  const ruleExclude = Array.isArray(rule.exclude) ? rule.exclude : [];
173
117
  const scope = Array.isArray(rule.scope) ? rule.scope : [];
174
118
  const excluded = [...globalExclude, ...ruleExclude].some((p) => file === p || file.startsWith(p));
@@ -176,35 +120,7 @@ export function inDomain(file, rule, globalExclude = []) {
176
120
  return scope.some((p) => file === p || file.startsWith(p));
177
121
  }
178
122
 
179
- /**
180
- * Normalise before matching, and this is the part the first run taught rather than the design.
181
- *
182
- * Four of the first registry's seven tells matched NOTHING at the commit whose carriers they were
183
- * copied from, and the dead-tell audit caught all four. Two causes, both already written down in this
184
- * repository as traps and both walked into anyway:
185
- *
186
- * 1. **A markdown link's URL sits inside the phrase.** `.portulan/dod.md` reads
187
- * "run each recipe [`workspace.json`](workspace.json) declares" — the literal text carries the
188
- * whole link, so a tell spelled the way a human reads the sentence matches nothing. Links are
189
- * collapsed to their label first, which is the repair `#211` recorded and this file now applies.
190
- * 2. **Prose wraps.** `spec/slots.md` breaks "This repository's CI reads / `verify.recipes` from the
191
- * manifest" across a newline, so any tell spanning that break fails on a raw `includes`.
192
- *
193
- * 3. **Emphasis and code spans sit INSIDE the phrase.** The record documenting the sweep wrote
194
- * "every verify recipe the workspace **declares**" — the bold markers fall between the words, so
195
- * the tell went dead the moment the live carrier was fixed and only the record still quoted it.
196
- *
197
- * Three variants of ONE trap — markup between the words of a sentence — each found by running this
198
- * instrument rather than reading it, and the third found by forcing the audit red. So both haystack
199
- * and needle are flattened: links to labels, emphasis and code markers dropped, every whitespace run
200
- * to one space, lowercased. A tell is then written the way a human READS the sentence, which is the
201
- * only way anyone will ever write one.
202
- *
203
- * Liveness is measured over the whole tracked set INCLUDING the record layer, deliberately. After a
204
- * successful sweep a retired spelling survives only in the handoff and proposal that recorded it —
205
- * that is the steady state, not a defect — so records are what keep a correct tell alive. A tell that
206
- * matches nothing even there was never a real spelling.
207
- */
123
+ /** Text as a reader reads it, so a tell matches across links, emphasis, code spans and line wraps. */
208
124
  export function normalise(text) {
209
125
  return text
210
126
  .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
@@ -215,27 +131,13 @@ export function normalise(text) {
215
131
 
216
132
  const lower = normalise;
217
133
 
218
- /**
219
- * The scan. `read(file)` returns the file's text; a file that cannot be read is reported rather than
220
- * skipped, because a check class that disappears quietly is worse than one that says it could not run.
221
- *
222
- * `resolve` turns a listed path and a registered carrier into the same kind of thing before they are
223
- * compared, and it defaults to resolving against the process's own directory rather than to identity.
224
- * A default of identity would be the string comparison this parameter exists to remove, spelled as a
225
- * default — correct for the one caller that already resolves and silently wrong for every other.
226
- */
134
+ /** `resolve` defaults to `path.resolve`, never identity: `./cli/x.mjs` and `cli/x.mjs` name one carrier. */
227
135
  export function scan({ registry, files, read, resolve = (p) => path.resolve(p) }) {
228
136
  const findings = [];
229
137
  const unreadable = [];
230
- // Nested maps, NOT a joined string key. The first version joined the rule id and the tell with a
231
- // separator and split on it to report — which a tell CONTAINING that separator corrupts, and a JSON
232
- // registry can carry one as a legal escape that `control-chars` never sees, because it is an escape
233
- // in the file rather than a raw byte. The repair is not to validate the separator out; it is to stop
234
- // having one. `evolution.md` ranks removing what would otherwise need enforcing above enforcing it.
235
- const tellSeen = new Map(); // rule id -> Map(tell -> seen)
236
-
237
- // Resolved once per rule rather than once per file × rule, and kept beside the rule id so the
238
- // comparison below has an identity on both sides instead of two spellings.
138
+ // rule id → tell → seen, nested rather than a joined key, since a tell may hold any separator.
139
+ const tellSeen = new Map();
140
+
239
141
  const carrierId = new Map();
240
142
 
241
143
  for (const rule of registry.rules) {
@@ -257,14 +159,6 @@ export function scan({ registry, files, read, resolve = (p) => path.resolve(p) }
257
159
  const fileId = resolve(file);
258
160
 
259
161
  for (const rule of registry.rules) {
260
- // The carrier is where the rule LIVES; its own spellings are the point of it.
261
- //
262
- // Compared as RESOLVED paths, not as strings — the same repair `run()` already applies to
263
- // the registry's own exclusion one screen below, and for the same reason. `git ls-files -z`
264
- // emits `/` while a registry author writes whatever resolves on disk, so `./cli/x.mjs` names
265
- // the carrier perfectly well, passes the carrier audit, and then fails to equal the string
266
- // `cli/x.mjs`. The rule would flag its own carrier as a restatement of itself: the one file
267
- // allowed to spell the rule, reported for spelling it.
268
162
  const isCarrier = fileId === carrierId.get(rule.id);
269
163
 
270
164
  const hits = rule.tells.filter((t) => hay.includes(lower(t)));
@@ -290,28 +184,6 @@ export function scan({ registry, files, read, resolve = (p) => path.resolve(p) }
290
184
  return { findings, deadTells, unreadable };
291
185
  }
292
186
 
293
- /**
294
- * The carrier must exist **and be a file**. A rule pointing at a file that is gone is could-not-run,
295
- * never green — and so is one pointing at a directory.
296
- *
297
- * `existsSync` was the first spelling and it answers `true` for a directory. A directory carrier then
298
- * passed the audit, matched no file in the scan, and left the rule covering **nothing** while the
299
- * recipe printed green: the quiet-coverage-loss shape the three audits exist to prevent, arriving
300
- * through the audit itself.
301
- *
302
- * `state(carrier)` answers `file`, `absent`, `not-a-file`, or **`unreadable:<errno>`**, and they are
303
- * kept apart rather than collapsed to a boolean because the caller prints them. Telling a maintainer a
304
- * directory *"does not resolve"* sends them looking for a missing file that is sitting right there —
305
- * the same defect `control-chars`'s exemption audit was corrected for, where *dead* and *never read*
306
- * had to be split.
307
- *
308
- * **This function does not enumerate the states; it forwards whatever `state` returns.** Anything but
309
- * `file` is unusable and is carried through with its own label, so a caller may add a state without
310
- * touching this code — which is exactly how `unreadable:<errno>` arrived, and exactly how this
311
- * docstring came to describe three states while the caller returned four. A doc that lists a set it
312
- * does not enforce goes stale silently, so it now says both the set it knows and the rule it actually
313
- * applies. Copilot, #249 round 1, suppressed and promoted.
314
- */
315
187
  export function auditCarriers(registry, { state }) {
316
188
  const unusable = [];
317
189
  for (const rule of registry.rules) {
@@ -350,26 +222,13 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
350
222
  return 2;
351
223
  }
352
224
 
353
- // `statSync` rather than `lstatSync`: a symlink pointing at a real file IS a usable carrier, and
354
- // the scan reads through it exactly the same way. What is refused is a target that is not a file
355
- // once followed.
225
+ // `statSync`, not `lstatSync`: a symlink to a file is a usable carrier.
356
226
  const unusable = auditCarriers(registry, {
357
227
  state: (p) => {
358
228
  let st;
359
229
  try {
360
230
  st = fs.statSync(path.resolve(cwd, p));
361
231
  } catch (cause) {
362
- // ONLY `ENOENT` IS ABSENT. A bare `catch` here turned every errno into "does not
363
- // resolve" — so a carrier the process could not LOOK at (EACCES on a parent directory,
364
- // ELOOP on a symlink cycle, ENAMETOOLONG) was reported as one it had looked at and not
365
- // found. The exit code was right either way; the sentence was not, and an accurate
366
- // error sentence is this change's whole subject.
367
- //
368
- // The rule already existed on a sibling noun and was not swept to this one:
369
- // `./control-chars.mjs`'s `bytesOf` returns `null` for `ENOENT` alone and refuses every
370
- // other errno, citing `../.portulan/memory/a-checker-must-refuse-what-it-cannot-check.md`
371
- // — *could not look* reported as *looked and found nothing*. Same rule, same words, now
372
- // on this noun too. Copilot, #249 round 1.
373
232
  if (cause.code === "ENOENT") return "absent";
374
233
  return `unreadable:${cause.code ?? cause.message}`;
375
234
  }
@@ -402,15 +261,7 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
402
261
  return 2;
403
262
  }
404
263
 
405
- // THE REGISTRY IS NOT A CARRIER, and leaving it in the scanned set defeated the dead-tell audit
406
- // outright: every tell is spelled in the registry, so every tell found ITSELF and read as alive.
407
- // The audit reported green over a tell that matches nothing else in the tree — found by forcing
408
- // it red, not by reading it, and it passed the first demonstration only because the registry was
409
- // untracked in that scratch worktree.
410
- // Compared as RESOLVED ABSOLUTE paths, not as strings. `git ls-files -z` always emits `/`, while
411
- // `path.relative` emits the platform separator — so on Windows the two spellings never matched, the
412
- // registry was scanned after all, and the self-satisfied dead-tell audit came straight back. A
413
- // string comparison between a git path and a platform path is a defect wherever it appears.
264
+ // The registry spells every tell, so scanning it would keep dead tells alive; resolved, as git's `/` is not every platform's.
414
265
  const registryAbs = path.resolve(cwd, registryPath);
415
266
  const files = all.filter((f) => path.resolve(cwd, f) !== registryAbs);
416
267
 
@@ -454,21 +305,7 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
454
305
  return 0;
455
306
  }
456
307
 
457
- /**
458
- * The main-module guard, and it is `cli/portulan.mjs`'s `isMain()` rather than a third spelling of the
459
- * same question — one carrier, and the others reach it, which is the rule this file exists to enforce.
460
- *
461
- * Two ways to get this wrong, and this file has now had both:
462
- *
463
- * 1. Comparing `process.argv[1]` against `new URL(import.meta.url).pathname`. This working copy lives
464
- * under "Sleepy Panda SRL Projects", a URL pathname percent-encodes the spaces, the comparison failed,
465
- * and the tool **exited 0 having run nothing**. Comparing URLs on both sides removes that entirely.
466
- * 2. Comparing resolved paths when the script is reached through a **symlink** — an npm `bin`, most
467
- * obviously. `path.resolve` does not follow links, so the same silent skip returns. Hence the
468
- * `realpathSync` fallback, in a `try` because a missing path must answer *no* rather than throw.
469
- *
470
- * Both failures look identical from outside: a green that is the tool never starting.
471
- */
308
+ /** URLs on both sides, since `import.meta.url` percent-encodes; the realpath covers a symlinked `bin`. */
472
309
  function isMain() {
473
310
  const invoked = process.argv[1];
474
311
  if (!invoked) return false;
@@ -0,0 +1,82 @@
1
+ // The session switches' texts: the cache-lifetime offer `init` and `upgrade` print, and the one line `doctor` reports on them.
2
+
3
+ export const LIFETIME_OFFER = Object.freeze({
4
+ what:
5
+ 'five-minute cache writes: `"sessions": { "cache_lifetime": "5m" }` in the manifest, at Workspace Definition 2.11 or later, ' +
6
+ "which `portulan compile` writes into .claude/settings.json as `promptCacheTtl`; unset, Claude Code writes for an hour on a " +
7
+ "subscription within its usage limits and for five minutes on an API key, where this changes nothing",
8
+ reason:
9
+ "A five-minute cache write costs 1.25 times an uncached input token where an hour's costs 2. On Portulan's own tasks, run " +
10
+ "straight through, five-minute writes cut the cost of a boot by 22 to 30% and of an edit by about 18% against an hour's " +
11
+ "(measured 2026-09-24).",
12
+ tradeOff:
13
+ "A pause of over five minutes between two requests, while a person reads or a review or CI runs, writes the whole context " +
14
+ "again at 1.25 times where an hour's lifetime reads it back at a small fraction of that; in a long session one such pause " +
15
+ "can cost more than every write the shorter lifetime saved, so it suits sessions that work straight through.",
16
+ });
17
+
18
+ export const OFFER_ENDS = 'Declaring either lifetime ends this offer; on an API key "5m" is already the host\'s default.';
19
+
20
+ /** The offer's lines, unprefixed: each caller adds its own `init: ` or `upgrade: `. */
21
+ export function offerLines({ asking = false } = {}) {
22
+ const { what, reason, tradeOff } = LIFETIME_OFFER;
23
+ if (asking) return [`${what[0].toUpperCase()}${what.slice(1)}.`, ` ${reason}`, ` ${tradeOff}`];
24
+ return [`offered, and not written — ${what}`, ` ${reason}`, ` ${tradeOff}`, ` ${OFFER_ENDS}`];
25
+ }
26
+
27
+ const plain = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
28
+ const figure = (v) => typeof v === "number" && Number.isFinite(v);
29
+
30
+ function headlessWords(headless) {
31
+ const words = [];
32
+ if (typeof headless.cache_lifetime === "string") words.push(headless.cache_lifetime);
33
+ if (headless.git_instructions === false) words.push("without git instructions");
34
+ if (headless.git_instructions === true) words.push("with git instructions");
35
+ if (headless.exclude_dynamic_sections === true) words.push("with the per-machine sections in the first message");
36
+ if (headless.exclude_dynamic_sections === false) words.push("with the per-machine sections in the system prompt");
37
+ return words.length ? `headless runs ${words.join(", ")}` : "headless runs the host's defaults";
38
+ }
39
+
40
+ function multipliersWords(multipliers) {
41
+ if (!plain(multipliers)) return null;
42
+ const write = plain(multipliers.write) ? multipliers.write : {};
43
+ const words = [];
44
+ if (figure(multipliers.read)) words.push(`read ${multipliers.read}×`);
45
+ // Only as a pair: a lone figure would not say which lifetime it prices.
46
+ if (figure(write["5m"]) && figure(write["1h"])) words.push(`writes ${write["5m"]}×/${write["1h"]}×`);
47
+ return words.length ? `multipliers declared, ${words.join(" and ")}` : "multipliers declared";
48
+ }
49
+
50
+ function horizonWords(horizon) {
51
+ if (!plain(horizon)) return null;
52
+ const { requests } = horizon;
53
+ return figure(requests) ? `a horizon of ${requests} request${requests === 1 ? "" : "s"}` : "a horizon declared";
54
+ }
55
+
56
+ /** `doctor`'s line on the session switches: one line, since a session reads it again on every request; never throws. */
57
+ export function sessionsLine(manifest) {
58
+ const workspace = plain(manifest) ? manifest : {};
59
+ const sessions = plain(workspace.sessions) ? workspace.sessions : {};
60
+ const spend = plain(workspace.spend) ? workspace.spend : {};
61
+ const lifetime = typeof sessions.cache_lifetime === "string" ? sessions.cache_lifetime : null;
62
+
63
+ const parts = [
64
+ lifetime === null
65
+ ? "cache lifetime the host's default, an hour on a subscription within its usage limits and five minutes on an API key"
66
+ : `cache lifetime ${lifetime}, compiled as \`promptCacheTtl\``,
67
+ sessions.git_instructions === false
68
+ ? "git instructions off"
69
+ : sessions.git_instructions === true
70
+ ? "git instructions on"
71
+ : "git instructions the host's default",
72
+ ];
73
+ if (plain(sessions.headless)) parts.push(headlessWords(sessions.headless));
74
+ parts.push(multipliersWords(spend.multipliers) ?? "multipliers the general ones");
75
+ const horizon = horizonWords(spend.horizon);
76
+ if (horizon !== null) parts.push(horizon);
77
+ if (spend.restart === "block") parts.push("a turn's end held once at the restart threshold");
78
+ if (lifetime === null && workspace.kind === "repository") {
79
+ parts.push("`portulan upgrade` prints the five-minute lifetime's offer and its trade-off");
80
+ }
81
+ return parts.join("; ");
82
+ }