@sleepy_panda_srl/portulan 0.1.3 → 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/README.md +7 -6
- package/cli/README.md +145 -358
- package/cli/advisory.mjs +354 -0
- package/cli/comments.mjs +932 -0
- package/cli/compile.mjs +1719 -1699
- package/cli/context.mjs +883 -0
- package/cli/control-chars.mjs +12 -301
- package/cli/discover.mjs +17 -574
- package/cli/doctor.mjs +269 -1080
- package/cli/drills.mjs +92 -504
- package/cli/feedback.mjs +16 -237
- package/cli/finish.mjs +437 -0
- package/cli/form.mjs +644 -0
- package/cli/fuzz-shell.mjs +20 -305
- package/cli/gate.mjs +7 -199
- package/cli/goldens.mjs +26 -184
- package/cli/index.mjs +305 -655
- package/cli/init.mjs +243 -566
- package/cli/inside.mjs +3 -28
- package/cli/instructions.mjs +829 -0
- package/cli/ledger.mjs +882 -0
- package/cli/librarian.mjs +135 -599
- package/cli/manifest.mjs +2 -39
- package/cli/mutants.mjs +9 -170
- package/cli/new.mjs +12 -243
- package/cli/pack-identity.mjs +5 -74
- package/cli/pack-version.mjs +13 -255
- package/cli/plugin-lint.mjs +42 -658
- package/cli/portulan.mjs +6 -125
- package/cli/recipe-set.mjs +15 -245
- package/cli/release-eval.mjs +28 -348
- package/cli/review-meter.mjs +29 -265
- package/cli/rule-carriers.mjs +12 -175
- package/cli/sessions.mjs +82 -0
- package/cli/skill-goldens.mjs +10 -176
- package/cli/skills-set.mjs +12 -414
- package/cli/stop-gate.mjs +83 -514
- package/cli/symbols.mjs +1132 -0
- package/cli/telemetry.mjs +82 -505
- package/cli/upgrade.mjs +171 -314
- package/cli/vendor.mjs +146 -476
- package/cli/version-carriers.mjs +8 -86
- package/core/README.md +1 -1
- package/core/engine.md +3 -9
- package/core/operating/autonomy.md +1 -1
- package/core/operating/context.md +258 -0
- package/core/operating/evolution.md +3 -3
- package/core/operating/loop.md +13 -20
- package/core/operating/memory.md +12 -3
- package/core/operating/sessions.md +100 -0
- package/core/personas/README.md +33 -0
- package/core/personas/librarian.md +2 -2
- package/core/skills/clarify/SKILL.md +1 -1
- package/core/skills/consolidate/SKILL.md +5 -3
- package/core/templates/handoff.md +28 -15
- package/core/templates/memory-entry.md +1 -0
- package/evals/README.md +271 -845
- package/evals/releases/0.2.0.json +146 -0
- package/evals/releases/0.2.0.md +67 -0
- package/evals/releases/0.3.0.json +150 -0
- package/evals/releases/0.3.0.md +68 -0
- package/evals/releases/README.md +3 -3
- package/package.json +5 -2
- package/packs/.claude-plugin/plugin.json +2 -1
- package/packs/README.md +7 -4
- package/packs/rituals/checkpoints/README.md +1 -1
- package/packs/tools/README.md +8 -8
- package/packs/tools/github/verify/actions-pinned.sh +10 -55
- package/packs/tools/local-llm/README.md +136 -0
- package/packs/tools/local-llm/pack.json +32 -0
- package/packs/tools/local-llm/personas/local-worker.md +44 -0
- package/packs/tools/local-llm/runner/local-llm.mjs +1665 -0
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +64 -0
- package/packs/tools/local-llm/verify/README.md +18 -0
- package/spec/README.md +113 -6
- package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
- package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
- package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
- package/spec/migrations/0004-changelog-fragments.mjs +53 -0
- package/spec/migrations/0005-session-log-retired.mjs +69 -0
- package/spec/migrations/0006-boot-card.mjs +77 -0
- package/spec/migrations/0007-guidance-compiled.mjs +31 -0
- package/spec/migrations/0008-card-reading.mjs +55 -0
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
- package/spec/migrations/0010-comment-rail.mjs +57 -0
- package/spec/migrations/README.md +45 -10
- package/spec/pack.schema.json +1 -1
- package/spec/slots.md +343 -27
- package/spec/workspace.schema.json +145 -4
package/cli/rule-carriers.mjs
CHANGED
|
@@ -1,40 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `rule-carriers` — the rail that keeps a reduced rule reduced.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
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
|
-
//
|
|
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;
|
package/cli/sessions.mjs
ADDED
|
@@ -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
|
+
}
|