@sleepy_panda_srl/portulan 0.1.2 → 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.
- package/README.md +7 -6
- package/cli/README.md +142 -291
- package/cli/advisory.mjs +354 -0
- package/cli/comments.mjs +932 -0
- package/cli/compile.mjs +1443 -1210
- package/cli/context.mjs +883 -0
- package/cli/control-chars.mjs +12 -301
- package/cli/discover.mjs +17 -574
- package/cli/doctor.mjs +225 -1071
- package/cli/drills.mjs +1107 -0
- package/cli/feedback.mjs +16 -237
- package/cli/finish.mjs +437 -0
- package/cli/form.mjs +644 -0
- package/cli/fuzz-shell.mjs +595 -0
- package/cli/gate.mjs +7 -198
- package/cli/goldens.mjs +311 -0
- package/cli/index.mjs +305 -655
- package/cli/init.mjs +244 -564
- package/cli/inside.mjs +3 -28
- package/cli/instructions.mjs +829 -0
- package/cli/ledger.mjs +778 -0
- package/cli/librarian.mjs +135 -599
- package/cli/manifest.mjs +2 -39
- package/cli/mutants.mjs +764 -0
- package/cli/new.mjs +12 -243
- package/cli/pack-identity.mjs +6 -68
- package/cli/pack-version.mjs +13 -248
- package/cli/plugin-lint.mjs +42 -658
- package/cli/portulan.mjs +5 -124
- package/cli/recipe-set.mjs +16 -240
- package/cli/release-eval.mjs +748 -0
- package/cli/review-meter.mjs +500 -0
- package/cli/rule-carriers.mjs +12 -175
- package/cli/sessions.mjs +82 -0
- package/cli/skill-goldens.mjs +424 -0
- package/cli/skills-set.mjs +12 -414
- package/cli/stop-gate.mjs +126 -454
- package/cli/symbols.mjs +1132 -0
- package/cli/telemetry.mjs +582 -0
- package/cli/upgrade.mjs +171 -314
- package/cli/vendor.mjs +146 -476
- package/cli/version-carriers.mjs +9 -74
- package/core/README.md +1 -1
- package/core/engine.md +3 -9
- package/core/operating/autonomy.md +1 -1
- package/core/operating/context.md +183 -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/librarian.md +2 -2
- package/core/skills/consolidate/SKILL.md +4 -2
- package/core/templates/gate-policy.md +27 -8
- package/core/templates/handoff.md +28 -15
- package/core/templates/memory-entry.md +1 -0
- package/evals/README.md +320 -0
- package/evals/releases/0.1.3.json +134 -0
- package/evals/releases/0.1.3.md +64 -0
- package/evals/releases/0.2.0.json +146 -0
- package/evals/releases/0.2.0.md +67 -0
- package/evals/releases/README.md +32 -0
- package/package.json +9 -2
- package/packs/rituals/checkpoints/README.md +9 -2
- package/packs/rituals/checkpoints/pack.json +1 -1
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
- package/packs/tools/github/verify/actions-pinned.sh +10 -55
- package/spec/README.md +112 -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 +359 -23
- package/spec/workspace.schema.json +145 -4
package/cli/discover.mjs
CHANGED
|
@@ -1,189 +1,39 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Host plugin-cache discovery
|
|
3
|
-
// resolved to the workspace it names.
|
|
4
|
-
//
|
|
5
|
-
// Row 7 of `../docs/plan.md` owns this, by the amendment of 2026-08-03: *"reading a host's
|
|
6
|
-
// installed-plugin record to resolve an installed workspace or pack, resolving a pointer's
|
|
7
|
-
// `governed_by` to the workspace it names (a cache hit, or the honest not installed here
|
|
8
|
-
// sentence)"*. The same amendment fixes where the answer is produced and where it is merely
|
|
9
|
-
// reported: **the boot is a skill, and real resolution stays the CLI's.** So this file resolves and
|
|
10
|
-
// `../plugin/skills/portulan/SKILL.md` reports what it says.
|
|
11
|
-
//
|
|
12
|
-
// ## What this closes, and what it deliberately leaves open
|
|
13
|
-
//
|
|
14
|
-
// [#134](https://github.com/sleepy-panda-srl/portulan/issues/134)'s boot half: a workspace
|
|
15
|
-
// delivered by a private feed was invisible to `/portulan`, because the boot looked at exactly one
|
|
16
|
-
// path and nothing anywhere resolved `governed_by`. The pointer kind landed at Workspace Definition
|
|
17
|
-
// 2.7 with proposal `0017` and named the governing workspace; nothing dereferenced the name.
|
|
18
|
-
//
|
|
19
|
-
// **`--pack-root` IS wired to this file**, and this paragraph said the opposite until 2026-08-12. It
|
|
20
|
-
// read *"`--pack-root` is NOT wired to this file … nothing here adds a root at all"*, which was true
|
|
21
|
-
// when written and stopped being true at milestone 7 session 4, when
|
|
22
|
-
// [#123](https://github.com/sleepy-panda-srl/portulan/issues/123)'s half landed `resolutionRoots`
|
|
23
|
-
// here — a stale sentence that survived a session because the change that falsified it added a
|
|
24
|
-
// function below rather than editing the header above it. Corrected in the same stroke as the union,
|
|
25
|
-
// whose whole subject is a rule with more carriers than its author remembered.
|
|
26
|
-
//
|
|
27
|
-
// #117's property is the one to keep hold of while reading the rest: **a NAMED root replaces the
|
|
28
|
-
// `tree`-derived one**, so *"this pack resolved from the feed"* cannot be satisfied by a copy lying in
|
|
29
|
-
// the local tree. That half is untouched. The row states the direction discovery takes: it **adds a
|
|
30
|
-
// root only where none was named**, and never replaces one that was — and since 2026-08-12 the
|
|
31
|
-
// implementation takes that verb literally, which is what `resolutionRoots`' own docblock argues.
|
|
32
|
-
// **On 2026-08-13 it began taking the row's OTHER verb literally too** — *`--pack-root` and its
|
|
33
|
-
// siblings are optional where discovery finds a root* — so the unasked arm consults a wired thunk
|
|
34
|
-
// as well. `resolutionRoots`' docblock carries the ruling, the asymmetry between the two arms, and
|
|
35
|
-
// what now holds the boundary the old narrowing was standing in for.
|
|
36
|
-
//
|
|
37
|
-
// ## Four states, because three of them are not "no"
|
|
38
|
-
//
|
|
39
|
-
// A resolver with two answers is the fail-open this repository keeps minting rules about
|
|
40
|
-
// (`../.portulan/memory/verify-preconditions-fail-closed.md`): *could not look* must never read as
|
|
41
|
-
// *nothing wrong*, and *two candidates* must never read as *this one*.
|
|
42
|
-
//
|
|
43
|
-
// `resolved` exactly one installed plugin carries a workspace manifest whose `name` is the
|
|
44
|
-
// one the pointer asks for. The root is the directory that manifest sits in.
|
|
45
|
-
// `not-installed` nothing on this host carries it — including the case where the host has no
|
|
46
|
-
// installed-plugin record at all, which is a host with nothing installed.
|
|
47
|
-
// `ambiguous` two or more distinct roots answer to the name. REFUSED, both named. Picking one
|
|
48
|
-
// would be booting on a workspace the project did not ask for while looking
|
|
49
|
-
// exactly like success, which is the failure the boot skill's step 2 exists for.
|
|
50
|
-
// `could-not-look` the record is there and could not be read or parsed. Never reported as absence.
|
|
51
|
-
//
|
|
52
|
-
// ## What it matches on, and why that is the manifest rather than the plugin
|
|
53
|
-
//
|
|
54
|
-
// A pointer names a **workspace**, by the `name` the governing workspace's own manifest carries —
|
|
55
|
-
// `spec/workspace.schema.json` says so of `governed_by.workspace`, on the same slug definition, "so
|
|
56
|
-
// a pointer and its target cannot drift into different spellings of one identifier". A plugin name
|
|
57
|
-
// is a different identifier in a different namespace, and the two agreeing on this machine today is
|
|
58
|
-
// a coincidence of naming rather than a contract. So every candidate's `workspace.json` is read and
|
|
59
|
-
// its `name` compared. A cache hit is a hit on the manifest.
|
|
60
|
-
//
|
|
61
|
-
// ## Three limits, stated rather than left to be found
|
|
62
|
-
//
|
|
63
|
-
// 1. **Two candidate locations per plugin, named.** `<installPath>/workspace.json` and
|
|
64
|
-
// `<installPath>/.portulan/workspace.json` — the payload that IS a workspace, and the payload
|
|
65
|
-
// that is a repository carrying one. A recursive walk would find more and would be the matcher
|
|
66
|
-
// clever enough to be wrong quietly that `../.portulan/gate-map.md` refuses elsewhere; a limit a
|
|
67
|
-
// reader can measure is worth more here than reach.
|
|
68
|
-
// 2. **Nothing is fetched.** No network call, ever — not by this file and not by anything it is
|
|
69
|
-
// wired into. A workspace that is not installed is not installed, and the row puts feed
|
|
70
|
-
// authentication and discovery of the uninstalled explicitly out of scope.
|
|
71
|
-
// 3. **A resolved workspace is not a graded workspace.** This says where it is. Whether it is green
|
|
72
|
-
// is `doctor`'s answer about THAT directory, and running it is the caller's business.
|
|
73
|
-
//
|
|
74
|
-
// ## It is also runnable, and that is a seam rather than a convenience
|
|
2
|
+
// Host plugin-cache discovery: reads a host's installed-plugin record to resolve a pointer and find pack roots.
|
|
75
3
|
//
|
|
76
4
|
// node cli/discover.mjs [--json] <workspace-dir>
|
|
77
5
|
//
|
|
78
|
-
//
|
|
79
|
-
// prose — a sentence this very change rewrites, parsed by an agent, with no field to key on. So there
|
|
80
|
-
// is a surface, and `--json` is the machine-readable half of it.
|
|
81
|
-
//
|
|
82
|
-
// **This is not a ninth `portulan` subcommand.** `docs/vision.md` names eight and is human-owned;
|
|
83
|
-
// `plugin-lint` and `librarian` are the standing precedent for a tool that lives in `cli/`, runs as
|
|
84
|
-
// `node cli/<tool>.mjs`, and is deliberately absent from `./portulan.mjs`'s list. Whether this ever
|
|
85
|
-
// joins that list is the maintainer's call, exactly as theirs is.
|
|
86
|
-
//
|
|
87
|
-
// Exit codes are the three this repository uses everywhere: **0** resolved — **and also
|
|
88
|
-
// `resides-here`**, the answer for a manifest that is not a pointer at all · **1** a verdict that the
|
|
89
|
-
// workspace is not resolvable here (`not-installed`, `ambiguous`) · **2** could not run or could not
|
|
90
|
-
// look. The split matters at the one place it is read: *could not look* must never be spendable as
|
|
91
|
-
// *not installed*.
|
|
92
|
-
//
|
|
93
|
-
// **`state` is the field to key on, not the code.** Two states share exit 0 and no exit code can tell
|
|
94
|
-
// them apart — that is the cost of answering the question for both kinds of manifest rather than
|
|
95
|
-
// pushing the case analysis back onto the caller, and it is a cost rather than a defect. _(This
|
|
96
|
-
// paragraph said "**0** resolved" alone until #182 item 3: a sentence narrower than the code it
|
|
97
|
-
// described, committed in the same change that corrected that exact shape in eleven other carriers.
|
|
98
|
-
// Copilot, round 4 on #181.)_
|
|
99
|
-
//
|
|
100
|
-
// Zero dependencies, Node built-ins only, and every environment input injectable — the host config
|
|
101
|
-
// directory is read from `env`, so a test never depends on the machine it runs on.
|
|
6
|
+
// Exit 0 resolved or resides-here · 1 not-installed or ambiguous · 2 could not run or could not look.
|
|
102
7
|
|
|
103
8
|
import fs from "node:fs";
|
|
104
9
|
import os from "node:os";
|
|
105
10
|
import path from "node:path";
|
|
106
11
|
import { pathToFileURL } from "node:url";
|
|
107
12
|
|
|
108
|
-
/** The plugin-record filename and its directory, both the host's rather than ours. */
|
|
109
13
|
export const RECORD = path.join("plugins", "installed_plugins.json");
|
|
110
14
|
|
|
111
|
-
/**
|
|
112
|
-
* The record schema versions this reader claims to understand. Measured: `2` on Claude Code 2.1.226.
|
|
113
|
-
*
|
|
114
|
-
* Exported because it is a limit, and because refusing an unrecognised version is a **decision** rather
|
|
115
|
-
* than an oversight. The file belongs to the host, not to us: a later version may move `installPath`,
|
|
116
|
-
* rename it, or nest it, and a reader that parsed on regardless would report roots that are not roots —
|
|
117
|
-
* a confident wrong answer, which is the class this module's four states exist to prevent. So an
|
|
118
|
-
* unknown version is `unreadable`, which reaches a caller as **could-not-look** and never as
|
|
119
|
-
* *not installed*.
|
|
120
|
-
*
|
|
121
|
-
* The cost is stated rather than hidden: on the day the host bumps to `3`, discovery stops here until
|
|
122
|
-
* this set is widened by someone who has looked. That is the direction
|
|
123
|
-
* `../.portulan/memory/a-checker-must-refuse-what-it-cannot-check.md` requires, and the opposite
|
|
124
|
-
* failure — silently resolving against a shape nobody verified — is the one that cannot be noticed.
|
|
125
|
-
*
|
|
126
|
-
* _(Raised by the session building `--pack-root auto` on #183, which had the guard on its own reader and
|
|
127
|
-
* flagged its absence here before the two modules were reconciled. Verified against the live record
|
|
128
|
-
* rather than accepted: it carries `"version": 2`.)_
|
|
129
|
-
*/
|
|
15
|
+
/** Claude Code 2.1.226 writes record version `2`; a version not listed here reads as `unreadable`. */
|
|
130
16
|
export const RECORD_VERSIONS = new Set([2]);
|
|
131
17
|
|
|
132
|
-
/**
|
|
133
|
-
* Where a candidate plugin payload may carry a workspace manifest, in the order tried.
|
|
134
|
-
*
|
|
135
|
-
* Exported because it is a limit, and a limit nobody can enumerate is a limit nobody can check —
|
|
136
|
-
* the suite asserts this list rather than restating it.
|
|
137
|
-
*/
|
|
138
18
|
export const MANIFEST_AT = ["workspace.json", path.join(".portulan", "workspace.json")];
|
|
139
19
|
|
|
140
|
-
/**
|
|
141
|
-
* The host's configuration directory.
|
|
142
|
-
*
|
|
143
|
-
* `CLAUDE_CONFIG_DIR` overrides it — that is the host's documented escape hatch and it is the reason
|
|
144
|
-
* this takes an `env` at all rather than reading `process.env` at the point of use. An empty string
|
|
145
|
-
* is treated as unset: an exported-but-blank variable is a shell accident, and honouring it would
|
|
146
|
-
* point discovery at **the process's working directory**, since `path.resolve("")` is `cwd` and not
|
|
147
|
-
* `/` — a plausible-looking answer to a question nobody asked, and worse than an obvious one because
|
|
148
|
-
* it changes with where the tool was invoked from.
|
|
149
|
-
*
|
|
150
|
-
* _(This said "the filesystem root" until #182's round, which is wrong about `path.resolve` and was
|
|
151
|
-
* contradicted by this function's own test one file away — the same two-carriers-one-fact shape this
|
|
152
|
-
* change corrected in eleven other places, here in the docblock of the guard the fix cites as its
|
|
153
|
-
* precedent. Copilot, on the round reviewing the records.)_
|
|
154
|
-
*/
|
|
20
|
+
/** A blank `CLAUDE_CONFIG_DIR` is unset: `path.resolve("")` would answer the working directory. */
|
|
155
21
|
export function configDir({ env = process.env, home = os.homedir() } = {}) {
|
|
156
22
|
const named = env.CLAUDE_CONFIG_DIR;
|
|
157
23
|
if (typeof named === "string" && named.trim() !== "") return path.resolve(named);
|
|
158
24
|
return path.join(home, ".claude");
|
|
159
25
|
}
|
|
160
26
|
|
|
161
|
-
/** The installed-plugin record's path for a host. */
|
|
162
27
|
export function recordPath(options = {}) {
|
|
163
28
|
return path.join(configDir(options), RECORD);
|
|
164
29
|
}
|
|
165
30
|
|
|
166
|
-
/**
|
|
167
|
-
* Read the host's installed-plugin record.
|
|
168
|
-
*
|
|
169
|
-
* Returns `{ state, path, entries, detail }` where `state` is one of `read` · `absent` ·
|
|
170
|
-
* `unreadable`. The three are kept apart at the source because collapsing them is exactly how a
|
|
171
|
-
* resolver starts answering "not installed" to a question it never asked.
|
|
172
|
-
*
|
|
173
|
-
* `entries` is flat — the record maps `<plugin>@<marketplace>` to an ARRAY, one per install scope,
|
|
174
|
-
* and a caller reasoning about roots wants the installs rather than the grouping. Malformed
|
|
175
|
-
* individual entries are dropped rather than taking the whole read down: one plugin's bad record
|
|
176
|
-
* should not blind discovery to every other plugin on the host.
|
|
177
|
-
*/
|
|
178
31
|
export function readInstalls(options = {}) {
|
|
179
32
|
const file = recordPath(options);
|
|
180
33
|
let raw;
|
|
181
34
|
try {
|
|
182
35
|
raw = fs.readFileSync(file, "utf8");
|
|
183
36
|
} catch (cause) {
|
|
184
|
-
// ENOENT is a host with nothing installed — or no host at all, which is CI. Every other
|
|
185
|
-
// errno is a file that exists and would not open, which is a different fact and gets a
|
|
186
|
-
// different word.
|
|
187
37
|
if (cause.code === "ENOENT") return { state: "absent", path: file, entries: [], detail: null };
|
|
188
38
|
return { state: "unreadable", path: file, entries: [], detail: cause.code ?? cause.message };
|
|
189
39
|
}
|
|
@@ -193,18 +43,9 @@ export function readInstalls(options = {}) {
|
|
|
193
43
|
} catch (cause) {
|
|
194
44
|
return { state: "unreadable", path: file, entries: [], detail: `not JSON — ${cause.message}` };
|
|
195
45
|
}
|
|
196
|
-
// `Array.isArray` is checked on BOTH the record and its `plugins`, and the second was missing — the
|
|
197
|
-
// sibling of a guard sitting one clause away. `typeof [] === "object"`, so `{"plugins": []}` read as
|
|
198
|
-
// a healthy record with nothing installed, and a malformed file collapsed into `not-installed`:
|
|
199
|
-
// *could not look* spent as absence, which is the one thing this file's four states exist to prevent.
|
|
200
|
-
// Found by Copilot, round 2, in a function whose own docblock argues that the three read states are
|
|
201
|
-
// kept apart at the source.
|
|
202
46
|
if (record === null || typeof record !== "object" || Array.isArray(record) || typeof record.plugins !== "object" || record.plugins === null || Array.isArray(record.plugins)) {
|
|
203
47
|
return { state: "unreadable", path: file, entries: [], detail: "no `plugins` object — this is not an installed-plugin record" };
|
|
204
48
|
}
|
|
205
|
-
// The version is checked BEFORE the entries are read, because it is the claim that makes reading them
|
|
206
|
-
// meaningful: `installPath` means what it means in version 2, and this reader has only ever seen
|
|
207
|
-
// version 2. Refused rather than parsed hopefully — see RECORD_VERSIONS for the argument and its cost.
|
|
208
49
|
if (!RECORD_VERSIONS.has(record.version)) {
|
|
209
50
|
return {
|
|
210
51
|
state: "unreadable",
|
|
@@ -239,28 +80,6 @@ export function readInstalls(options = {}) {
|
|
|
239
80
|
return { state: "read", path: file, entries, detail: null };
|
|
240
81
|
}
|
|
241
82
|
|
|
242
|
-
/**
|
|
243
|
-
* Read a candidate manifest, returning `{ manifest, name, kind }` or `null`.
|
|
244
|
-
*
|
|
245
|
-
* Unreadable and unparseable are both `null` and both deliberate: a plugin payload carrying
|
|
246
|
-
* something that is not a workspace manifest at one of the two named paths is simply not a
|
|
247
|
-
* candidate, and a resolver that failed loudly on every unrelated `workspace.json` on the host would
|
|
248
|
-
* be unusable. What it must never do is COUNT such a file as a match, and returning `null` is that.
|
|
249
|
-
*
|
|
250
|
-
* **`name` alone is not enough to say a file is a workspace manifest, and requiring only `name` was a
|
|
251
|
-
* fail-open.** Found at the pre-commit checkpoint by building it rather than by reading this
|
|
252
|
-
* function: a plugin payload carrying an Nx-style `workspace.json` — `{"version": 2, "name": "…",
|
|
253
|
-
* "projects": {…}}`, no `portulan` key, no `kind` — resolved, exit 0, and the boot would have been
|
|
254
|
-
* pointed at it. `workspace.json` is a common filename in the wider ecosystem, so that is an ordinary
|
|
255
|
-
* file rather than a contrived one, and the docblock above already promised it was not a candidate.
|
|
256
|
-
*
|
|
257
|
-
* The gate is `portulan` **and** `name`, which is the Workspace Definition's required identity minus
|
|
258
|
-
* `kind` — `kind` is read here for the pointer refusal and is not required, because a manifest
|
|
259
|
-
* missing it is schema-invalid and that is `doctor`'s verdict about the directory rather than
|
|
260
|
-
* discovery's. **This is deliberately not a second schema validator**: it is the cheapest test that
|
|
261
|
-
* distinguishes *a Portulan workspace manifest* from *a file that happens to share the name*, and
|
|
262
|
-
* conformance stays the one tool that owns it.
|
|
263
|
-
*/
|
|
264
83
|
function readCandidate(root) {
|
|
265
84
|
for (const rel of MANIFEST_AT) {
|
|
266
85
|
const file = path.join(root, rel);
|
|
@@ -272,11 +91,7 @@ function readCandidate(root) {
|
|
|
272
91
|
}
|
|
273
92
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
|
274
93
|
if (typeof parsed.name !== "string") continue;
|
|
275
|
-
// `
|
|
276
|
-
// gate, so a non-Portulan file could still qualify by carrying one — the same fail-open one
|
|
277
|
-
// tightening later (Copilot, round 2). `spec` is what the Workspace Definition REQUIRES of the
|
|
278
|
-
// block, so this keys on the contract rather than on a key's presence; the pattern is left to
|
|
279
|
-
// `doctor`, which owns conformance.
|
|
94
|
+
// `workspace.json` is a common filename in other tools; `portulan.spec` is what marks a Portulan one.
|
|
280
95
|
if (parsed.portulan === null || typeof parsed.portulan !== "object" || Array.isArray(parsed.portulan)) continue;
|
|
281
96
|
if (typeof parsed.portulan.spec !== "string") continue;
|
|
282
97
|
return { manifest: file, dir: path.dirname(file), name: parsed.name, kind: typeof parsed.kind === "string" ? parsed.kind : null };
|
|
@@ -284,43 +99,8 @@ function readCandidate(root) {
|
|
|
284
99
|
return null;
|
|
285
100
|
}
|
|
286
101
|
|
|
287
|
-
/**
|
|
288
|
-
* Resolve a pointer's `governed_by` against a host's plugin cache.
|
|
289
|
-
*
|
|
290
|
-
* `governedBy` is the manifest's own object — `{ workspace, feed }`. `feed`, where present,
|
|
291
|
-
* CONSTRAINS: it names the marketplace the workspace ships through, and an install from a different
|
|
292
|
-
* marketplace is not the thing the pointer asked for however well its name matches. That near miss
|
|
293
|
-
* is reported rather than swallowed, because "the right name from the wrong feed" is a diagnosis a
|
|
294
|
-
* reader needs and an absence sentence would hide.
|
|
295
|
-
*
|
|
296
|
-
* Returns a verdict carrying its own `sentence` — one carrier, so every surface that reports this
|
|
297
|
-
* prints the same words rather than paraphrasing them into four slightly different claims.
|
|
298
|
-
*/
|
|
299
102
|
export function resolveGovernor(governedBy, options = {}) {
|
|
300
|
-
//
|
|
301
|
-
// operation** — and blank-testing is the whole of it: the value that survives is the RAW one, never
|
|
302
|
-
// a trimmed copy.
|
|
303
|
-
//
|
|
304
|
-
// The two halves are different classes and the difference is worth stating, because a fix that
|
|
305
|
-
// flattened them would be claiming to close more than it does. `governed_by.feed` is
|
|
306
|
-
// `type: string, minLength: 1` in the Workspace Definition and nothing more, so `" "` **validates**
|
|
307
|
-
// — it constrained every candidate to a marketplace of that name, matched nothing, and answered
|
|
308
|
-
// `not-installed` about a workspace that is installed, which is the real defect and the class the
|
|
309
|
-
// four verdicts exist to prevent. `governed_by.workspace` is a `slug`, so `" "` is
|
|
310
|
-
// **schema-invalid** and `doctor` refuses it before anything reaches here: that half is
|
|
311
|
-
// defence-in-depth on this file's own runnable seam rather than a hole in the schema.
|
|
312
|
-
//
|
|
313
|
-
// **Not trimmed, deliberately, and the reason is a measurement.** Trimming only the wanted side makes
|
|
314
|
-
// `" sleepy-panda "` in a pointer match `sleepy-panda` on disk while the same padding ON DISK still
|
|
315
|
-
// misses — a new asymmetry, and discovery quietly repairing a manifest the slug pattern refuses,
|
|
316
|
-
// which `readCandidate` above promises it is not doing. `configDir()` blank-tests and hands
|
|
317
|
-
// `path.resolve` the raw string for the same reason; normalising identifiers is conformance's job and
|
|
318
|
-
// conformance is `doctor`'s. Found at the pre-commit checkpoint, which built the padded case rather
|
|
319
|
-
// than reading the diff — the first draft here trimmed, and turned a `resolved` into a
|
|
320
|
-
// `not-installed`.
|
|
321
|
-
//
|
|
322
|
-
// Copilot, #181 rounds 3 and 4; filed as #182 item 1 at the bound and taken here on the maintainer's
|
|
323
|
-
// grant of an extension, recorded in this pull request's own conversation.
|
|
103
|
+
// Blank is unset, but padding is kept: normalising a slug is conformance, and conformance is `doctor`'s.
|
|
324
104
|
const named = (value) => (typeof value === "string" && value.trim() !== "" ? value : null);
|
|
325
105
|
const wanted = {
|
|
326
106
|
workspace: named(governedBy?.workspace),
|
|
@@ -341,9 +121,6 @@ export function resolveGovernor(governedBy, options = {}) {
|
|
|
341
121
|
});
|
|
342
122
|
|
|
343
123
|
if (wanted.workspace === null) {
|
|
344
|
-
// A pointer with no `governed_by.workspace` is refused by `doctor` and by the schema before
|
|
345
|
-
// anything reaches here. Guarded anyway: a resolver asked to find "undefined" must say what
|
|
346
|
-
// it was asked rather than search for it.
|
|
347
124
|
return verdict("could-not-look", {
|
|
348
125
|
sentence: "this pointer names no governing workspace, so there is nothing to resolve — `governed_by.workspace` is required of a pointer",
|
|
349
126
|
});
|
|
@@ -380,17 +157,13 @@ export function resolveGovernor(governedBy, options = {}) {
|
|
|
380
157
|
continue;
|
|
381
158
|
}
|
|
382
159
|
if (found.kind === "pointer") {
|
|
383
|
-
// A pointer resolving to a pointer is a chain, and the residence ruling permits none: a
|
|
384
|
-
// repository is governed by exactly one workspace. Refused as a near miss rather than
|
|
385
|
-
// silently skipped, because the reader needs to know the name WAS found.
|
|
386
160
|
nearMisses.push({ ...candidate, why: "pointer" });
|
|
387
161
|
continue;
|
|
388
162
|
}
|
|
389
163
|
matches.push(candidate);
|
|
390
164
|
}
|
|
391
165
|
|
|
392
|
-
//
|
|
393
|
-
// directory, and refusing that would be refusing the ordinary case.
|
|
166
|
+
// One workspace installed at two scopes is two entries and one root, not an ambiguity.
|
|
394
167
|
const roots = [...new Set(matches.map((m) => m.root))];
|
|
395
168
|
if (roots.length > 1) {
|
|
396
169
|
return verdict("ambiguous", {
|
|
@@ -431,11 +204,6 @@ export function resolveGovernor(governedBy, options = {}) {
|
|
|
431
204
|
.map((m) =>
|
|
432
205
|
m.why === "feed"
|
|
433
206
|
? `\`${m.key}\` carries that name and ships through ${
|
|
434
|
-
// `marketplace` is null wherever the record key carries no `@` — a shape
|
|
435
|
-
// `readInstalls` tolerates on purpose, so the sentence has to survive it.
|
|
436
|
-
// It printed "ships through `null`", which reads as a marketplace called
|
|
437
|
-
// null rather than as one nobody recorded. Copilot, on the round that
|
|
438
|
-
// reviewed the fix; the same class as the note beside it.
|
|
439
207
|
m.marketplace === null ? "no marketplace the record names" : `\`${m.marketplace}\``
|
|
440
208
|
}, which is not the feed \`${wanted.feed}\` this pointer names`
|
|
441
209
|
: `\`${m.key}\` carries that name and is itself a pointer, and a repository is governed by exactly one workspace`,
|
|
@@ -450,36 +218,14 @@ export function resolveGovernor(governedBy, options = {}) {
|
|
|
450
218
|
// The command — the seam the boot skill reads
|
|
451
219
|
// ===========================================================================================
|
|
452
220
|
|
|
453
|
-
/**
|
|
454
|
-
* Which exit code each verdict carries. Exported so the suite asserts the mapping rather than a literal.
|
|
455
|
-
*
|
|
456
|
-
* **This maps the RESOLVER's four states, and `run()` carries a fifth answer of its own.** A directory
|
|
457
|
-
* whose manifest is not a pointer is answered `resides-here` and exits **0** without consulting this
|
|
458
|
-
* table — so exit 0 does not identify a state, and `state` is the field a caller keys on. Said here
|
|
459
|
-
* rather than left implicit, because a table named `EXIT` with four entries reads as the whole contract
|
|
460
|
-
* (#182 item 3).
|
|
461
|
-
*/
|
|
221
|
+
/** The resolver's verdicts; `run()` also exits 0 on `resides-here`, so a caller keys on `state`. */
|
|
462
222
|
export const EXIT = { resolved: 0, "not-installed": 1, ambiguous: 1, "could-not-look": 2 };
|
|
463
223
|
|
|
464
|
-
/**
|
|
465
|
-
* Resolve the pointer at `<workspace-dir>/workspace.json` and print the answer.
|
|
466
|
-
*
|
|
467
|
-
* The argument is a **workspace directory**, the same thing `doctor` takes, so a caller holding one
|
|
468
|
-
* path can hand it to either tool. A directory carrying a governing manifest is not an error and not
|
|
469
|
-
* a resolution: it is answered as *this repository's workspace resides here*, exit 0, because the
|
|
470
|
-
* question "where is the workspace that governs this" has a true answer for both kinds and a tool
|
|
471
|
-
* that only understood one of them would push the case analysis back onto its caller.
|
|
472
|
-
*/
|
|
473
224
|
export function run(argv, options = {}) {
|
|
474
225
|
const say = options.say ?? ((line) => process.stdout.write(`${line}\n`));
|
|
475
226
|
const warn = options.warn ?? ((line) => process.stderr.write(`${line}\n`));
|
|
476
227
|
const json = argv.includes("--json");
|
|
477
228
|
const dirs = argv.filter((a) => !a.startsWith("-"));
|
|
478
|
-
// An unknown flag is REFUSED rather than dropped, and this is the boot's contract rather than
|
|
479
|
-
// tidiness: `--jsonn <ptr>` used to print prose and exit 0, so a typo — or a later rename of this
|
|
480
|
-
// flag — silently degraded the machine-readable answer into the half the skill is told never to
|
|
481
|
-
// read, with a success code on top. `./doctor.mjs` already guards leading-`-` arguments; found at
|
|
482
|
-
// the pre-commit checkpoint by typing the typo rather than by reading the filter.
|
|
483
229
|
const KNOWN = new Set(["--json", "--help", "-h"]);
|
|
484
230
|
const unknown = argv.filter((a) => a.startsWith("-") && !KNOWN.has(a));
|
|
485
231
|
if (unknown.length) {
|
|
@@ -510,9 +256,6 @@ export function run(argv, options = {}) {
|
|
|
510
256
|
return 2;
|
|
511
257
|
}
|
|
512
258
|
if (manifest?.kind !== "pointer") {
|
|
513
|
-
// Answered rather than refused, and the shape is the same object so a caller keys on `state`
|
|
514
|
-
// either way. `resides-here` is not one of the resolver's four: those are answers about a
|
|
515
|
-
// HOST, and this is an answer about the argument.
|
|
516
259
|
const verdict = {
|
|
517
260
|
state: "resides-here",
|
|
518
261
|
root: path.dirname(manifestPath),
|
|
@@ -533,46 +276,10 @@ export function run(argv, options = {}) {
|
|
|
533
276
|
// ===========================================================================================
|
|
534
277
|
// Pack-resolution roots
|
|
535
278
|
// ===========================================================================================
|
|
536
|
-
//
|
|
537
|
-
// The second half of row 7's discovery clause (#123). Everything above resolves a WORKSPACE by name,
|
|
538
|
-
// which is what a pointer needs; this resolves the roots a declared PACK is looked up under, which is
|
|
539
|
-
// what `--pack-root` needed a value for.
|
|
540
|
-
//
|
|
541
|
-
// It is built on `readInstalls` above rather than on a second reader — the two halves landed as
|
|
542
|
-
// separate pull requests (#181 and #183) and each shipped its own record reader, which is the
|
|
543
|
-
// three-tools-two-semantics defect `../cli/compile.mjs`'s `namedRootsOption` records. Reconciled on
|
|
544
|
-
// merge: the reader, the config directory and the record-version refusal are this file's, once.
|
|
545
279
|
|
|
546
|
-
/**
|
|
547
|
-
* The value of `--pack-root` that means *discover it*, rather than naming a directory.
|
|
548
|
-
*
|
|
549
|
-
* Matched against the **raw argument, before any path resolution**, so a directory genuinely called
|
|
550
|
-
* `auto` stays reachable as `./auto` or by an absolute path. A keyword in a value space that until now
|
|
551
|
-
* held only paths is a spelling whose meaning this change flips, so the escape is stated in the help
|
|
552
|
-
* text everywhere, and in the missing-value error of the three tools that resolve roots themselves
|
|
553
|
-
* (`compile`, `doctor`, `index`). `init` and `vendor` forward roots rather than resolving them and
|
|
554
|
-
* carry it in their usage text only — narrower than "every parse error", which is what this sentence
|
|
555
|
-
* said until a checkpoint checked it.
|
|
556
|
-
*/
|
|
280
|
+
/** Matched against the raw `--pack-root` value, so a directory named `auto` is reached as `./auto`. */
|
|
557
281
|
export const AUTO = "auto";
|
|
558
282
|
|
|
559
|
-
/**
|
|
560
|
-
* Is this directory a pack-resolution root — does it hold `<category>/<name>/pack.json`?
|
|
561
|
-
*
|
|
562
|
-
* Asking the RESOLVER's own question rather than looking for a directory named `packs` is what makes
|
|
563
|
-
* this work on both shapes a plugin lands in, and the first version of this function did not:
|
|
564
|
-
*
|
|
565
|
-
* - **Repository-shaped** — the plugin IS a repository checkout (`source: "./"`), so its packs sit
|
|
566
|
-
* under `<installPath>/packs`. The engine plugin is this shape.
|
|
567
|
-
* - **Flat** — the plugin ships a pack family directly, so the CATEGORIES are at the install root:
|
|
568
|
-
* `<installPath>/rituals/checkpoints/pack.json`. `portulan-checkpoints@portulan-internal` is this
|
|
569
|
-
* shape, and it is the pack this project's own workspace composes.
|
|
570
|
-
*
|
|
571
|
-
* Probing only the first meant discovery could not see either plugin the private feed actually ships,
|
|
572
|
-
* with a green suite throughout, because the fixtures encoded the same assumption as the code. Found
|
|
573
|
-
* by a pre-commit checkpoint that looked at a real install — `.portulan/memory/`'s standing lesson:
|
|
574
|
-
* a harness you write to check your own change inherits your blind spot.
|
|
575
|
-
*/
|
|
576
283
|
export function isPackRoot(dir) {
|
|
577
284
|
let categories;
|
|
578
285
|
try {
|
|
@@ -596,53 +303,8 @@ export function isPackRoot(dir) {
|
|
|
596
303
|
return false;
|
|
597
304
|
}
|
|
598
305
|
|
|
599
|
-
/**
|
|
600
|
-
* Pack-resolution roots discovered from the host's plugin cache.
|
|
601
|
-
*
|
|
602
|
-
* Returns `{ ok, roots, installs, why }`. `ok: false` is **could not look** — the record is there and
|
|
603
|
-
* could not be read — and is never the same answer as `ok: true` with no roots, which is *looked,
|
|
604
|
-
* found nothing installed that carries packs*.
|
|
605
|
-
*
|
|
606
|
-
* ## An ABSENT record is `ok: true`, and this line was the opposite until 2026-08-13
|
|
607
|
-
*
|
|
608
|
-
* `readInstalls` keeps three states apart at the source — `read`, `absent`, `unreadable` — and its
|
|
609
|
-
* docblock says collapsing them is how a resolver starts lying. **This function then collapsed two of
|
|
610
|
-
* them**, mapping `absent` to could-not-look from the day this function was written (2026-08-09) to
|
|
611
|
-
* 2026-08-13 — four days, not the "year of sessions" an earlier draft of this paragraph claimed, which
|
|
612
|
-
* was a superlative nobody counted in a file about answers nobody measured. The docblock above
|
|
613
|
-
* described the collapse as though it were the design.
|
|
614
|
-
*
|
|
615
|
-
* It is not a style question, and the measurement is what settles it. A host with **no record at all**
|
|
616
|
-
* is a host with nothing installed — which is every CI runner, and precisely what
|
|
617
|
-
* `readInstalls`'s own comment above says `absent` means. Reported as could-not-look it made
|
|
618
|
-
* `--pack-root auto` return the **empty set**, and an empty set makes `doctor` report every declared
|
|
619
|
-
* pack *unverifiable* and exit **0**. Measured on the workspace `init` drafts by default: with no
|
|
620
|
-
* flag, exit 1 and the cache pack correctly FAILS; with `--pack-root auto`, exit **0** and neither
|
|
621
|
-
* pack is looked at — including one that resolves perfectly well from the adopter's own tree. The flag
|
|
622
|
-
* did not merely fail to help, it **discarded a root it already had** and converted a correct red into
|
|
623
|
-
* a green.
|
|
624
|
-
*
|
|
625
|
-
* The target behaviour was already in the table: a host with a **valid record and nothing installed**
|
|
626
|
-
* has always done the right thing here. `absent` now joins it, and **this module already said so in
|
|
627
|
-
* two other places** — `readInstalls`'s ENOENT comment, and the pointer path below, which has always
|
|
628
|
-
* turned `absent` into a `not-installed` VERDICT ("nothing is installed for it to be among") rather
|
|
629
|
-
* than a could-not-look. `./plugin-lint.mjs` states the general rule: *"Absent counts as
|
|
630
|
-
* examined … that IS the finding."* `discoverPackRoots` was the outlier inside its own file.
|
|
631
|
-
*
|
|
632
|
-
* **The distinction that keeps this from contradicting the fail-closed rule:** a *dependency the check
|
|
633
|
-
* needs* being missing is could-not-run — that is `../.portulan/memory/verify-preconditions-fail-closed.md`'s
|
|
634
|
-
* subject, and why a missing tool still exits 2. A *world-state the check observes* being empty is an
|
|
635
|
-
* answer. The plugin record is the second kind: discovery's whole question is "what is installed
|
|
636
|
-
* here", and "nothing" answers it.
|
|
637
|
-
*
|
|
638
|
-
* Both shapes are offered per install, repository-shaped first; `resolvePack` is first-match-wins, and
|
|
639
|
-
* no plugin carries both.
|
|
640
|
-
*/
|
|
641
306
|
export function discoverPackRoots(options = {}) {
|
|
642
307
|
const read = readInstalls(options);
|
|
643
|
-
// Absent is an ANSWER: nothing is installed. Only a record that is there and will not be read is
|
|
644
|
-
// could-not-look, and that one keeps `ok: false` — asking for discovery and being unable to look
|
|
645
|
-
// must never be spendable as a green.
|
|
646
308
|
if (read.state === "absent") {
|
|
647
309
|
return {
|
|
648
310
|
ok: true,
|
|
@@ -662,6 +324,7 @@ export function discoverPackRoots(options = {}) {
|
|
|
662
324
|
const roots = [];
|
|
663
325
|
const contributing = [];
|
|
664
326
|
for (const install of read.entries) {
|
|
327
|
+
// A repository-shaped plugin keeps its packs under `packs/`; a flat pack family is a root itself.
|
|
665
328
|
for (const candidate of [path.join(install.installPath, "packs"), install.installPath]) {
|
|
666
329
|
if (!isPackRoot(candidate)) continue;
|
|
667
330
|
roots.push(candidate);
|
|
@@ -671,167 +334,25 @@ export function discoverPackRoots(options = {}) {
|
|
|
671
334
|
return { ok: true, roots, installs: contributing, why: null };
|
|
672
335
|
}
|
|
673
336
|
|
|
674
|
-
/**
|
|
675
|
-
* The one sentence every tool prints when a caller asks for both a named root and `auto`.
|
|
676
|
-
*
|
|
677
|
-
* A constant because **seven** commands reach this refusal — `compile`, `doctor`, `index`, `init`,
|
|
678
|
-
* `vendor`, `skills-set` and, since 2026-08-13, `recipe-set` — and seven spellings of it is how one of
|
|
679
|
-
* them ends up wording it as a warning, which is the silent drop it replaces wearing a different coat.
|
|
680
|
-
* _(It said "five" until `recipe-set` gained the flag; six was already wrong.)_
|
|
681
|
-
*/
|
|
682
337
|
export const NAMED_WITH_AUTO =
|
|
683
338
|
"name roots or ask for `auto`, never both: `--pack-root auto` cannot be combined with a named root";
|
|
684
339
|
|
|
685
|
-
/**
|
|
686
|
-
* The refusal's CONDITION, exported so an argument parser and the resolver share one, not two.
|
|
687
|
-
*
|
|
688
|
-
* Each command must refuse at parse time — that is where an exit 2 belongs, before a workspace is
|
|
689
|
-
* read — while `resolutionRoots` must refuse for API callers that never parsed anything. Two places
|
|
690
|
-
* have to ask, so the thing they ask is a function rather than a repeated `&&`.
|
|
691
|
-
*
|
|
692
|
-
* Returns the sentence to print, or `null` when the combination was not asked for.
|
|
693
|
-
*/
|
|
340
|
+
/** The refusal to print, or `null` unless both a named root and `auto` were asked for. */
|
|
694
341
|
export function namedWithAuto(named = [], forced = false) {
|
|
695
|
-
// Takes the array a parser has or the boolean a resolver has, because the two callers genuinely
|
|
696
|
-
// hold different shapes of the same fact and the alternative was worse: the first cut passed a
|
|
697
|
-
// one-element array containing a SENTINEL STRING so an explicitly-empty named set would trip this.
|
|
698
|
-
// That put a fake root in a predicate about roots, and made the refusal say "a named root" about a
|
|
699
|
-
// set that had none. Raised by Copilot, round 1 on #233.
|
|
700
342
|
const given = Array.isArray(named) ? named.length > 0 : Boolean(named);
|
|
701
343
|
return given && forced ? NAMED_WITH_AUTO : null;
|
|
702
344
|
}
|
|
703
345
|
|
|
704
|
-
/**
|
|
705
|
-
* Apply the precedence rule to the sources of a pack-resolution root.
|
|
706
|
-
*
|
|
707
|
-
* ## A named root wins outright; asked-for discovery ADDS
|
|
708
|
-
*
|
|
709
|
-
* Two ratified texts describe this and use the verb "add" for different objects, which reads as a
|
|
710
|
-
* contradiction until the objects are named:
|
|
711
|
-
*
|
|
712
|
-
* - **The row** (`docs/plan.md` row 7): *"an explicitly named root is never silently overridden —
|
|
713
|
-
* discovery adds a root only where none was named"*. The object is a **named** root; discovery
|
|
714
|
-
* loses to `--pack-root`, and that half has never moved.
|
|
715
|
-
* - **#123's closing constraint:** *"Discovery that silently adds roots would reintroduce exactly the
|
|
716
|
-
* substitution the pre-commit checkpoint caught."* The object is the **`tree`-derived** root, and
|
|
717
|
-
* the operative word in it is **silently**.
|
|
718
|
-
*
|
|
719
|
-
* **The rule is: named > (discovered ∪ derived), discovered first.** It was *named > discovered >
|
|
720
|
-
* derived, never union* until 2026-08-12, and what changed it was a measurement rather than a
|
|
721
|
-
* preference — see below. The order inside the union is load-bearing and not cosmetic:
|
|
722
|
-
* `resolvePack` is first-match-wins, so where both carry a pack the **discovered** copy wins, which
|
|
723
|
-
* keeps every result the old `auto` produced as a subset of this one and keeps the pin meaningful.
|
|
724
|
-
*
|
|
725
|
-
* ## What forced the change: the ordinary workspace could not go green
|
|
726
|
-
*
|
|
727
|
-
* A workspace composing a cache-installed pack **and** one of its own — which is what `init` produces
|
|
728
|
-
* by default the moment an adopter adds a pack, since it composes `rituals/checkpoints` — had **no
|
|
729
|
-
* green invocation** that did not require typing the host plugin-cache path by hand. Measured
|
|
730
|
-
* 2026-08-12 across all four arrangements: no flag reds on the cache pack, `auto` reds on the
|
|
731
|
-
* adopter's own, `auto` plus a named root reds on the cache pack because named replaces both, and
|
|
732
|
-
* only two named roots — one of them a cache path nobody should have to know — went green. That
|
|
733
|
-
* falsified this docblock's own claim that the narrowing survived *"where it matters — nobody has to
|
|
734
|
-
* know the cache path"*. For that shape, somebody had to.
|
|
735
|
-
*
|
|
736
|
-
* ## What the union costs, said plainly because the first framing of it did not
|
|
737
|
-
*
|
|
738
|
-
* The replaced rule had **two** grounds and only the first was surfaced when the change was proposed.
|
|
739
|
-
*
|
|
740
|
-
* 1. **The unasked path must not read the host.** An earlier draft gave a discovered root, unasked, to
|
|
741
|
-
* a workspace deriving none. A pre-commit checkpoint priced it: `examples/workspace.json` declares
|
|
742
|
-
* packs and no `tree`, and `.portulan/verify/doctor.sh` grades `examples`, so that branch made a
|
|
743
|
-
* **required recipe** read `~/.claude` on every run — red locally, green in CI. **Untouched here by
|
|
744
|
-
* construction:** the union lives inside the `forced` branch, so nothing changes when nobody asks.
|
|
745
|
-
* 2. **A structural provenance guarantee, now traded.** Under the old rule the resolution set held no
|
|
746
|
-
* tree-derived root under `auto`, so *"this pack resolved from the feed"* was a property of the
|
|
747
|
-
* set's composition — knowable from the invocation alone. Under the union it becomes a **statement
|
|
748
|
-
* per pack**, which is detection where there was prevention, and this repository has measured that
|
|
749
|
-
* nobody reads a passing validator's output. The trade was put to the maintainer and ruled: union,
|
|
750
|
-
* **but never silently** — which is #123's own word, and the reason `origins` below is a field
|
|
751
|
-
* rather than a sentence. A provenance a caller can only grep out of prose is checkable against
|
|
752
|
-
* sentences this same change wrote.
|
|
753
|
-
*
|
|
754
|
-
* **What may be claimed after the trade:** no path needs typing; every pack's resolution names the root
|
|
755
|
-
* it used and whether that root was discovered or derived; a local-tree resolution is visible rather
|
|
756
|
-
* than silent. **What may NOT:** that `auto` resolves only from a feed — it no longer does — or that a
|
|
757
|
-
* green under it certifies provenance. The green certifies resolution. What bounds a pack's *content* is
|
|
758
|
-
* unchanged and is the pin, never this function. The narrower property #117 demonstrated also stands
|
|
759
|
-
* verbatim: a **named** root still replaces the derived one, so a resolved-from-a-feed demonstration
|
|
760
|
-
* given a named root cannot be satisfied by a local copy.
|
|
761
|
-
*
|
|
762
|
-
* _(Each clause above read "under `--pack-root auto`" until 2026-08-13. The trade is the same trade and
|
|
763
|
-
* it now applies to the bare invocation as well, which is the disposal: a flag whose absence bought a
|
|
764
|
-
* provenance guarantee could not also be optional. What asking still buys is the **strict degrade** —
|
|
765
|
-
* see the asymmetry below — and nothing else.)_
|
|
766
|
-
*
|
|
767
|
-
* ## Asking for both is refused, not reconciled
|
|
768
|
-
*
|
|
769
|
-
* `--pack-root auto --pack-root ./packs` used to drop the `auto` without a word. A rule whose whole
|
|
770
|
-
* justification is *never silently* cannot ship beside a branch that silently discards an explicit
|
|
771
|
-
* request, so the combination is a **refusal** — `refusal` is set, and the roots are empty so a caller
|
|
772
|
-
* that forgets to check fails closed rather than resolving against half of what was asked for.
|
|
773
|
-
*
|
|
774
|
-
* ## The unasked arm consults discovery too, as of 2026-08-13 — the row's word, met
|
|
775
|
-
*
|
|
776
|
-
* The row's clause is *"optional where discovery finds a root"*, and until this date `--pack-root` was
|
|
777
|
-
* not optional: on the workspace `init` drafts by default plus one pack of the adopter's own, `doctor`
|
|
778
|
-
* exited **1** with no flag and **0** under `auto`. Measured, and it was worse than one red — the same
|
|
779
|
-
* arrangement made `recipe-set` and `skills-set --check` exit **2**, and left `compile` composing the
|
|
780
|
-
* bound checkpoint pack's two gate fragments into nothing. Three of five tools were unusable unasked.
|
|
781
|
-
*
|
|
782
|
-
* The maintainer ruled a **behaviour change rather than a row amendment**: the row's word stands and
|
|
783
|
-
* the implementation meets it. A fresh supervisor graded that ruling sound from `../docs/vision.md`,
|
|
784
|
-
* which defines `doctor` as a **per-host capability report** — answering about the host is what it is
|
|
785
|
-
* for — and sharpened the boundary to: **a verdict about the *repository* must not depend on the
|
|
786
|
-
* machine.**
|
|
787
|
-
*
|
|
788
|
-
* **What holds that boundary is the PIN, not this branch.** Six required invocations name their root
|
|
789
|
-
* (2026-08-13), a named root **replaces** every other source, and `../cli/pinned-roots.live.test.mjs`
|
|
790
|
-
* fails if any of them drops it or if a seventh joins them unpinned. So a required check's verdict
|
|
791
|
-
* cannot move with what is installed on the machine running it, whatever the unasked default is. This
|
|
792
|
-
* docblock said the **thunk** was what kept `compile --check` host-independent, and that sentence had
|
|
793
|
-
* already gone false one pull request earlier, when `.portulan/verify/compile.sh` gained
|
|
794
|
-
* `--pack-root packs`: it names a root. The guarantee moved carriers and the prose had not.
|
|
795
|
-
*
|
|
796
|
-
* ## The asymmetry between the two arms, which is the whole reason they are two
|
|
797
|
-
*
|
|
798
|
-
* **`forced` and unasked differ on exactly one question: what an unreadable record means.**
|
|
799
|
-
*
|
|
800
|
-
* - **Asked and could not look is could-not-run** — exit 2, ruled 2026-08-13. You asked; the question
|
|
801
|
-
* is unanswerable; that is what the third code is for.
|
|
802
|
-
* - **Unasked and could not look degrades to derived-only, with the diagnostic REPORTED** — never an
|
|
803
|
-
* empty set, never exit 2. Nobody asked, so the readability of the host's record cannot be a
|
|
804
|
-
* precondition for grading a repository. Silent, though, it must not be: the diagnostic rides in
|
|
805
|
-
* `why`, so a host with a corrupt record is visible rather than merely survived.
|
|
806
|
-
*
|
|
807
|
-
* **Reusing the `forced` branch as the unasked default is therefore the one implementation that must
|
|
808
|
-
* not happen.** It would import that branch's `couldNotRun` and every CI runner — where the record is
|
|
809
|
-
* absent, then unreadable the day a host bumps its schema — would exit 2 or, worse, be given an empty
|
|
810
|
-
* root set and go green by not looking. That is the fail-open this module spent a session closing.
|
|
811
|
-
*
|
|
812
|
-
* `discovery` may be a THUNK, and **its presence is the switch**: an API caller that wires none keeps
|
|
813
|
-
* the hermetic behaviour this function had before, on every arm. There is no second flag for it,
|
|
814
|
-
* because a per-caller opt-out would be a second carrier of the precedence rule — the
|
|
815
|
-
* three-tools-two-semantics defect `./compile.mjs`'s `namedRootsOption` records.
|
|
816
|
-
*/
|
|
346
|
+
/** A named root wins outright; otherwise discovered roots lead the derived ones, as `resolvePack` takes the first match. */
|
|
817
347
|
export function resolutionRoots({ named = [], namedGiven = null, derived = [], discovery = null, forced = false } = {}) {
|
|
818
348
|
let cached;
|
|
819
349
|
const resolveDiscovery = () => {
|
|
820
350
|
if (cached === undefined) cached = typeof discovery === "function" ? discovery() : discovery;
|
|
821
351
|
return cached;
|
|
822
352
|
};
|
|
823
|
-
//
|
|
824
|
-
// explicitly EMPTY named set means **search nowhere** for API callers and must not fall through to
|
|
825
|
-
// the derived root. Three tools disagreed about that once; keeping the distinction here rather
|
|
826
|
-
// than in each of them is what stops a fourth from inventing a fifth answer.
|
|
353
|
+
// An explicitly empty named set searches nowhere rather than falling through to the derived root.
|
|
827
354
|
const givenNamed = namedGiven ?? named.length > 0;
|
|
828
|
-
// Every branch returns the same shape, `origins` included: a caller joining a resolved pack to its
|
|
829
|
-
// root must not have to know which branch produced the plan.
|
|
830
355
|
const tag = (roots, origin) => roots.map((root) => ({ root, origin }));
|
|
831
|
-
// `refusal` and `couldNotRun` are separate fields because they are separate facts, and both map to
|
|
832
|
-
// exit 2. `refusal` says *your command line asked for two different resolution sets*; `couldNotRun`
|
|
833
|
-
// says *you asked me to look and I could not*. Collapsing them would send a reader with an
|
|
834
|
-
// unreadable plugin record to re-read their flags.
|
|
835
356
|
const plan = (roots, source, why, origins = null, refusal = null, couldNotRun = null) => ({
|
|
836
357
|
roots,
|
|
837
358
|
source,
|
|
@@ -840,20 +361,7 @@ export function resolutionRoots({ named = [], namedGiven = null, derived = [], d
|
|
|
840
361
|
refusal,
|
|
841
362
|
couldNotRun,
|
|
842
363
|
});
|
|
843
|
-
//
|
|
844
|
-
// `resolvePack` is first-match-wins, so where both roots carry a pack the discovered copy answers,
|
|
845
|
-
// which keeps every result the old `auto` produced a subset of this one. Written once because two
|
|
846
|
-
// orders keyed on whether a flag was typed would make the flag change the *meaning* of resolution
|
|
847
|
-
// rather than its inputs — and two matching literals are a convention, while one function is a
|
|
848
|
-
// guarantee. `lead` is the only difference between the arms: who asked.
|
|
849
|
-
//
|
|
850
|
-
// **What `source: "union"` means, because the arms reach this differently and a reader will assume
|
|
851
|
-
// they do not.** The `forced` arm calls this even when discovery found **nothing** — you asked, and
|
|
852
|
-
// *"I looked, and there is nothing installed"* is the answer to report. The unasked arm calls it only
|
|
853
|
-
// where discovery contributed a root; otherwise it returns `derived`, with discovery's own sentence in
|
|
854
|
-
// the `why`. So `union` reads as *discovery contributed, or was asked for* — never as *the set has two
|
|
855
|
-
// origins*. Recorded because a test fixture with an empty cache passed the asked half and bound
|
|
856
|
-
// nothing on the unasked one, which is how the asymmetry was noticed at all.
|
|
364
|
+
// `union` means discovery contributed a root or was asked for, not that the set has two origins.
|
|
857
365
|
const union = (found, lead) => {
|
|
858
366
|
const nothingFound = found.roots.length === 0 && found.why;
|
|
859
367
|
return plan(
|
|
@@ -875,9 +383,6 @@ export function resolutionRoots({ named = [], namedGiven = null, derived = [], d
|
|
|
875
383
|
);
|
|
876
384
|
}
|
|
877
385
|
if (givenNamed) {
|
|
878
|
-
// Deliberately does not consult discovery: the row's guarantee is that a named root is never
|
|
879
|
-
// silently overridden, and the cheapest way to keep that true is to have nothing to override
|
|
880
|
-
// it with on this branch.
|
|
881
386
|
return plan(
|
|
882
387
|
[...named],
|
|
883
388
|
"named",
|
|
@@ -886,93 +391,31 @@ export function resolutionRoots({ named = [], namedGiven = null, derived = [], d
|
|
|
886
391
|
}
|
|
887
392
|
if (forced) {
|
|
888
393
|
const found = resolveDiscovery();
|
|
889
|
-
// **Also could-not-run**, and it took Copilot's round 4 to see it: this branch's own `why`
|
|
890
|
-
// says *discovery was requested and did not run*, which is the definition of the third exit
|
|
891
|
-
// code, and it was returning a bare empty plan — the exact fail-open the branch below was just
|
|
892
|
-
// repaired for, one line above it. `resolutionRoots` is exported and `rootPlan` takes `forced`
|
|
893
|
-
// and `discovery` independently, so a caller can reach this with no thunk wired and, treating
|
|
894
|
-
// an empty root set as *nothing to check*, exit 0 over a discovery that never happened.
|
|
895
394
|
if (!found) {
|
|
896
395
|
const why = "discovery was requested and did not run — no discovery was wired into this call";
|
|
897
396
|
return plan([], "none", why, [], null, why);
|
|
898
397
|
}
|
|
899
|
-
//
|
|
900
|
-
// returned an empty plan, and an empty root set makes `doctor` report every declared pack
|
|
901
|
-
// *unverifiable* and exit **0**. So `--pack-root auto` against an unreadable record was a
|
|
902
|
-
// green over a host nobody could read, and it discarded the tree-derived root on the way —
|
|
903
|
-
// turning a correct red into a green while losing information it already had. A discovery that
|
|
904
|
-
// could not look is a could-not-run, which is what the third exit code exists for.
|
|
905
|
-
//
|
|
906
|
-
// An **absent** record no longer reaches here: `discoverPackRoots` answers `ok: true` with no
|
|
907
|
-
// roots for that, because a host with no record is a host with nothing installed. Only a
|
|
908
|
-
// record that exists and will not be read is this branch.
|
|
909
|
-
//
|
|
910
|
-
// **The strongest ground for exit 2 rather than a quiet degrade is `RECORD_VERSIONS`.** That
|
|
911
|
-
// constant promises discovery stops on a record schema this reader has not seen, "until this
|
|
912
|
-
// set is widened by someone who has looked". A host bumping its record version lands exactly
|
|
913
|
-
// here — so a degrade-and-carry-on would silently convert every `auto` user to derived-only on
|
|
914
|
-
// that day, fleet-wide, repealing a decision the constant records as deliberate. Exit 2 makes
|
|
915
|
-
// it loud, which is what the constant asked for.
|
|
916
|
-
//
|
|
917
|
-
// **That argument is about the ASKED arm alone, and saying so is the point.** Since the unasked
|
|
918
|
-
// arm began consulting discovery (2026-08-13) a record-version bump degrades the *bare* fleet to
|
|
919
|
-
// derived-only rather than stopping it — which is correct, because nobody asked — so "loud" has
|
|
920
|
-
// to be delivered there by the diagnostic rather than by an exit code. Where each tool surfaces
|
|
921
|
-
// it: `doctor` prints `plan.why` on its `resolution root` note and on every unresolved pack;
|
|
922
|
-
// `compile` prints the plan line whenever the source is a union and an `UNRESOLVED` line per
|
|
923
|
-
// pack; `recipe-set` and `skills-set` still exit **2** on a composed artefact they could not
|
|
924
|
-
// read, which is the row's own could-not-run rule and is untouched by the degrade.
|
|
398
|
+
// Asked for and could not look is could-not-run, never an empty set `doctor` would pass as unverifiable.
|
|
925
399
|
if (!found.ok) return plan([], "none", found.why, [], null, found.why);
|
|
926
|
-
// Where discovery found NOTHING, its own sentence rides through — see `union`. "0 root(s)" alone
|
|
927
|
-
// cannot tell a host with no record from a host with a valid record listing nothing installed,
|
|
928
|
-
// and those are different facts about the machine — the second is a host that has Portulan packs
|
|
929
|
-
// available and none of them relevant; the first is a host that has never installed one.
|
|
930
400
|
return union(found, "discovered in the host plugin cache");
|
|
931
401
|
}
|
|
932
|
-
// ── The unasked arm. Nobody named a root and nobody typed `auto`.
|
|
933
|
-
//
|
|
934
|
-
// A wired thunk is consulted here, and the DEGRADES are the whole of the difference from the arm
|
|
935
|
-
// above: an unreadable record loses the discovered half and keeps the derived one, rather than
|
|
936
|
-
// becoming a could-not-run. Guarded on `discovery` rather than on the memoised value, so a caller
|
|
937
|
-
// that wires nothing never reaches the thunk at all — that identity, not a flag, is what keeps an
|
|
938
|
-
// API caller hermetic.
|
|
939
402
|
const found = discovery === null || discovery === undefined ? null : resolveDiscovery();
|
|
940
403
|
if (found && found.ok && found.roots.length) return union(found, "discovered in the host plugin cache unasked");
|
|
941
|
-
// Everything below is derived-only, and the three ways of getting here need three sentences,
|
|
942
|
-
// because collapsing them is how this module started lying about `absent` in the first place.
|
|
943
|
-
// `couldNotRun` is null on every one of them: nobody asked.
|
|
944
|
-
// `found.why` is **optional**, and appending it unguarded put the literal `null` in a user-facing
|
|
945
|
-
// sentence. `discoverPackRoots` returns `why: null` on its ordinary success path — a record that
|
|
946
|
-
// READ fine and simply lists no plugin carrying packs — which is a real host rather than an edge:
|
|
947
|
-
// anyone with plugins installed and none of them a pack feed. The `union` helper one screen up
|
|
948
|
-
// guards exactly this (`found.roots.length === 0 && found.why`) and this arm did not: one operation,
|
|
949
|
-
// two sites, correct at one. Raised by Copilot, round 1 on #237.
|
|
950
404
|
const said = (lead) => (found.why ? `${lead} — ${found.why}` : lead);
|
|
951
405
|
const because = !found
|
|
952
406
|
? null
|
|
953
407
|
: found.ok
|
|
954
|
-
? //
|
|
955
|
-
// distinguishes a host with no record from one whose record lists nothing relevant.
|
|
408
|
+
? // `why` is optional: a record that lists no pack root carries none.
|
|
956
409
|
said("discovery was consulted and found no root")
|
|
957
|
-
: //
|
|
958
|
-
// make a corrupt plugin record indistinguishable from a clean host, which is the pair
|
|
959
|
-
// `discoverPackRoots` keeps apart one function above.
|
|
410
|
+
: // Unasked, an unreadable record degrades to the derived root, reported rather than exit 2.
|
|
960
411
|
said("discovery was consulted and could not look, so only the derived root is searched");
|
|
961
412
|
if (derived.length) {
|
|
962
|
-
// Same discipline as the arm below: the stem is ONE string and only the tail branches, so a
|
|
963
|
-
// discriminator matching on it cannot be broken by editing the other case.
|
|
964
413
|
return plan(
|
|
965
414
|
[...derived],
|
|
966
415
|
"derived",
|
|
967
416
|
`derived from the workspace manifest's \`tree\`${because ? `; ${because}` : " — pass `--pack-root auto` to search the host plugin cache as well"}`,
|
|
968
417
|
);
|
|
969
418
|
}
|
|
970
|
-
// The STEM is deliberately the same clause in both arms — *no root was named and none is derivable
|
|
971
|
-
// from the manifest* — and only the tail differs. Rewording it cost a test that was asserting the
|
|
972
|
-
// right property for the right reason: `doctor.test.mjs`'s *"a declared pack on a workspace with no
|
|
973
|
-
// tree is REPORTED, never failed"* matched on `none is derivable from the manifest`, and a
|
|
974
|
-
// paraphrase broke a discriminator this change had no business touching. Prose that carries a
|
|
975
|
-
// discriminator is an interface.
|
|
976
419
|
return plan(
|
|
977
420
|
[],
|
|
978
421
|
"none",
|