@sleepy_panda_srl/portulan 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +5 -0
  3. package/README.md +87 -0
  4. package/cli/README.md +337 -0
  5. package/cli/compile.mjs +2209 -0
  6. package/cli/control-chars.mjs +570 -0
  7. package/cli/discover.mjs +985 -0
  8. package/cli/doctor.mjs +2803 -0
  9. package/cli/feedback.mjs +970 -0
  10. package/cli/gate.mjs +271 -0
  11. package/cli/index.mjs +1390 -0
  12. package/cli/init.mjs +1729 -0
  13. package/cli/librarian.mjs +1384 -0
  14. package/cli/manifest.mjs +59 -0
  15. package/cli/new.mjs +803 -0
  16. package/cli/pack-version.mjs +543 -0
  17. package/cli/plugin-lint.mjs +1566 -0
  18. package/cli/portulan.mjs +266 -0
  19. package/cli/recipe-set.mjs +425 -0
  20. package/cli/rule-carriers.mjs +489 -0
  21. package/cli/skills-set.mjs +715 -0
  22. package/cli/stop-gate.mjs +434 -0
  23. package/cli/upgrade.mjs +897 -0
  24. package/cli/vendor.mjs +1504 -0
  25. package/core/README.md +46 -0
  26. package/core/engine.md +47 -0
  27. package/core/operating/autonomy.md +100 -0
  28. package/core/operating/evolution.md +84 -0
  29. package/core/operating/loop.md +83 -0
  30. package/core/operating/memory.md +161 -0
  31. package/core/operating/safety.md +42 -0
  32. package/core/operating/verification.md +76 -0
  33. package/core/personas/README.md +40 -0
  34. package/core/personas/implementer.md +51 -0
  35. package/core/personas/librarian.md +73 -0
  36. package/core/personas/reviewer.md +52 -0
  37. package/core/skills/README.md +52 -0
  38. package/core/skills/clarify/SKILL.md +42 -0
  39. package/core/skills/codify/SKILL.md +65 -0
  40. package/core/skills/consolidate/SKILL.md +121 -0
  41. package/core/templates/gate-policy.md +76 -0
  42. package/core/templates/handoff.md +29 -0
  43. package/core/templates/memory-entry.md +44 -0
  44. package/core/templates/pack.md +94 -0
  45. package/core/templates/persona.md +69 -0
  46. package/core/templates/proposal.md +27 -0
  47. package/core/templates/repo-card.md +28 -0
  48. package/core/templates/skill.md +57 -0
  49. package/core/templates/task.md +26 -0
  50. package/core/templates/workspace.md +81 -0
  51. package/package.json +41 -0
  52. package/packs/.claude-plugin/plugin.json +22 -0
  53. package/packs/README.md +61 -0
  54. package/packs/rituals/README.md +13 -0
  55. package/packs/rituals/checkpoints/README.md +105 -0
  56. package/packs/rituals/checkpoints/pack.json +36 -0
  57. package/packs/rituals/checkpoints/personas/supervisor.md +64 -0
  58. package/packs/rituals/checkpoints/skills/milestone-close/SKILL.md +75 -0
  59. package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +75 -0
  60. package/packs/rituals/checkpoints/skills/session-open/SKILL.md +57 -0
  61. package/packs/stacks/README.md +8 -0
  62. package/packs/tools/README.md +17 -0
  63. package/packs/tools/github/README.md +65 -0
  64. package/packs/tools/github/pack.json +29 -0
  65. package/packs/tools/github/verify/README.md +51 -0
  66. package/packs/tools/github/verify/actions-pinned.sh +132 -0
  67. package/spec/README.md +399 -0
  68. package/spec/migrations/0001-repository-declares-its-tree.mjs +100 -0
  69. package/spec/migrations/0002-bundle-fallback-path.mjs +179 -0
  70. package/spec/migrations/README.md +95 -0
  71. package/spec/pack.schema.json +201 -0
  72. package/spec/slots.md +796 -0
  73. package/spec/workspace.schema.json +438 -0
package/cli/vendor.mjs ADDED
@@ -0,0 +1,1504 @@
1
+ #!/usr/bin/env node
2
+ // `vendor` — materialise a workspace where it is needed, in either direction.
3
+ //
4
+ // `docs/vision.md`: *"vendor (materialise a workspace where it is needed: a self-contained AGENTS.md +
5
+ // .portulan/ into any host, and the reverse — out of a repository and into a feed-side workspace that
6
+ // names it)"*. The maintainer widened that gloss on 2026-08-03, which is what settled the verb row 7 had
7
+ // deliberately left unassigned — so this tool carries **the residence switch in both directions**.
8
+ //
9
+ // ## Two jobs, which are one operation with a direction
10
+ //
11
+ // **Into a host.** A self-contained `AGENTS.md` + `.portulan/` for a host that is not Claude Code — the
12
+ // vendored-standards delivery tier. The source keeps governing; this is a rendering, not a move.
13
+ //
14
+ // **The residence switch.** Feed-side ↔ in-repo, under the contract
15
+ // `../.portulan/proposals/0017-one-repository-one-governing-workspace.md` sets and row 7 states as law:
16
+ // the workspace is materialised in the new residence, a pointer or nothing is left in the old, and
17
+ // `doctor` is green at both ends before the old residence is retired.
18
+ //
19
+ // ## The invariant, and the window that cannot be closed
20
+ //
21
+ // **One repository is governed by exactly one workspace.** `doctor` keys governance on exactly two
22
+ // coordinates: a non-pointer `workspace.json` in the repository, and a feed-side workspace whose
23
+ // `repos/` slot carries a card naming it. A switch has to move both, they live in two directories, and
24
+ // no POSIX primitive changes both at once — so *some* intermediate state is unavoidable. Writing the new
25
+ // manifest first gives a moment with **two** governors; the reverse gives a moment with **zero**.
26
+ //
27
+ // 0017's switch contract numbers materialise **before** pointer-or-nothing and argues why: a window in
28
+ // which a repository is governed by nothing looks identical to a repository that never adopted Portulan.
29
+ // This tool obeys that ordering, and the second reason is the one worth stating here — the two-governor
30
+ // state is exactly what `doctor`'s residence refusals **detect and refuse out loud** (given
31
+ // `--repo-root`, without which the cross-repository check reports that it did not run), while zero
32
+ // governors is silent. Loud beats silent when neither can be avoided.
33
+ //
34
+ // What is guaranteed, precisely:
35
+ //
36
+ // - **Every handled failure leaves exactly one governor.** A red `doctor`, a caught I/O error, a refusal
37
+ // — all of them roll back to the state before the flip, or, past the flip, stop forward with the new
38
+ // residence governing. Never zero, never two.
39
+ // - **The window is one `rename(2)` wide.** Everything materialises into a staging directory that is not
40
+ // a residence anywhere anybody looks, and is validated there; only a rename puts it in place.
41
+ // - **Rollback is possible only before the flip.** Past it, governance has moved and undoing it would
42
+ // re-open the window in the other direction. So past the flip this tool goes forward and reports.
43
+ // - **An unhandled crash inside that one rename leaves two governors**, which `doctor --repo-root`
44
+ // refuses by name. The recovery sentence is printed *before* the window opens, not after. Closing this
45
+ // properly needs the mechanism 0017 defers under *Retire when* — a host that resolves a pointer and
46
+ // has nowhere to put a second workspace — and that is a contract change, not an implementer's.
47
+ //
48
+ // ## The three rules a tool that writes into somebody's tree carries
49
+ //
50
+ // `cli/init.mjs` and `cli/new.mjs` paid for these and a third writer inherits them — missing a sibling
51
+ // is issue #91's class and it has bitten every session of this milestone:
52
+ //
53
+ // 1. **Refuse an existing file rather than overwriting it** — any file, never only a manifest.
54
+ // 2. **Refuse a symlink at or below the named destination** rather than resolving through it. Above the
55
+ // named path, resolve: the user named that path, and on macOS `os.tmpdir()` runs through `/var`.
56
+ // Guarded on the READ side too — copying through a link materialises a file from outside the
57
+ // workspace and records it as part of one.
58
+ // 3. **Only `ENOENT` means absent.** An `EACCES` is a question that could not be answered, and answering
59
+ // it *nothing there* is the fail-open: "nothing looked" reported as "nothing wrong".
60
+ //
61
+ // ## Exit codes
62
+ //
63
+ // `0` it did it · `1` a red verdict — `doctor` was not green at an end · `2` it could not run. Unlike
64
+ // `init` and `new`, this tool **does** have a 1: it runs the real validator and reports its verdict, and
65
+ // collapsing "the workspace I wrote is invalid" into "I could not run" would leave a caller unable to
66
+ // tell a bad workspace from a missing flag.
67
+ //
68
+ // Zero dependencies, ESM, no build step — the session-0 ruling.
69
+
70
+ import fs from "node:fs";
71
+ import path from "node:path";
72
+ import { fileURLToPath, pathToFileURL } from "node:url";
73
+
74
+ import { inspect } from "./doctor.mjs";
75
+ // The discovery keyword, imported so no parse site can spell it differently (#123). _(This said
76
+ // "the five parse sites"; there are seven, and `discover.mjs`'s `NAMED_WITH_AUTO` had already been
77
+ // corrected from five to seven while this copy kept the old figure. The number is dropped rather than
78
+ // re-typed, because a count nothing derives goes stale again — which that constant's own docblock
79
+ // records happening twice.)_
80
+ import { AUTO, namedWithAuto } from "./discover.mjs";
81
+ import { recipeSet } from "./recipe-set.mjs";
82
+
83
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
84
+
85
+ /** Everything that means `vendor` could not run. Carries no verdict about a workspace. */
86
+ export class VendorError extends Error {}
87
+
88
+ /** The two residences. Named, never inferred from a path — `init`'s rule, and for `init`'s reason. */
89
+ export const RESIDENCES = new Set(["in-repo", "feed-side"]);
90
+
91
+ /** What a switch may leave behind, in 0017's own words: "a pointer, or nothing". */
92
+ export const LEAVE = new Set(["pointer", "nothing"]);
93
+
94
+ /** The Workspace Definition this tool writes against. A pointer needs 2.7 — the version that added it. */
95
+ const SPEC = "2.7";
96
+
97
+ // ONE definition of a slug, read from the contract that publishes it rather than written out again —
98
+ // the same read `cli/init.mjs` does, for the same reason: a second copy here is free to drift from
99
+ // `spec/workspace.schema.json`, and this tool's job at the boundary is to refuse what that refuses.
100
+ let SCHEMA_ERROR = null;
101
+ const SLUG = (() => {
102
+ try {
103
+ const file = path.join(HERE, "..", "spec", "workspace.schema.json");
104
+ return new RegExp(JSON.parse(fs.readFileSync(file, "utf8")).$defs.slug.pattern);
105
+ } catch (error) {
106
+ SCHEMA_ERROR = error;
107
+ return null;
108
+ }
109
+ })();
110
+
111
+ /** The governing kinds, in the order a reader would meet them. `pointer` is not one — it governs nothing. */
112
+ const GOVERNING_KINDS = ["repository", "demo", "portfolio"];
113
+
114
+ /**
115
+ * Top-level entries inside a workspace directory that `cli/compile.mjs` OWNS and this never carries.
116
+ *
117
+ * They are compiled enforcement, and **enforcement is keyed to the residence** — measured this session
118
+ * when `compile` was taught the feed-side shape: an in-repo workspace's Claude settings land at the
119
+ * repository root, a feed-side workspace's land inside the workspace, because that is what ships. So a
120
+ * copy of them at a new residence is a settings file naming paths for the residence it left, sitting
121
+ * where nothing reads it and nothing sweeps it — a document claiming enforcement that nothing carries,
122
+ * emitted into somebody else's tree, which is this milestone's own most expensive recurring defect.
123
+ *
124
+ * Excluded and **named**, never dropped quietly, and `compile` is the one tool that may delete them
125
+ * because it says so itself: a generated file is reproducible by definition.
126
+ */
127
+ const GENERATED = new Set(["compile", ".claude"]);
128
+
129
+ const isGenerated = (rel) => GENERATED.has(rel.split("/")[0]);
130
+
131
+ // ------------------------------------------------------------------------- the command line
132
+
133
+ const VALUED = new Set(["--into", "--residence", "--leave", "--host", "--kind", "--feed", "--pack-root", "--repo-root"]);
134
+
135
+ /**
136
+ * Splits argv into flags and the single source workspace directory.
137
+ *
138
+ * A value beginning with **any** leading `-` is a missing value, not a value — `init`'s round 9, where
139
+ * `--residence -h` was consumed as the residence and the user was then blamed for a token they typed as
140
+ * a flag. An empty string is refused for every flag, because none of them has a meaningful empty value
141
+ * and an empty one reached a manifest once already, failing `minLength: 1` on a run that reported
142
+ * success.
143
+ */
144
+ export function parseArgs(argv) {
145
+ const out = {
146
+ help: false,
147
+ source: null,
148
+ into: null,
149
+ residence: null,
150
+ switching: false,
151
+ leave: null,
152
+ host: null,
153
+ kindOf: null,
154
+ feed: null,
155
+ dryRun: false,
156
+ packRoots: [],
157
+ discoverPacks: false,
158
+ repoRoots: [],
159
+ given: new Set(),
160
+ };
161
+ const positional = [];
162
+
163
+ for (let i = 0; i < argv.length; i += 1) {
164
+ const arg = argv[i];
165
+ if (arg === "--help" || arg === "-h") {
166
+ out.help = true;
167
+ continue;
168
+ }
169
+ if (arg === "--switch") {
170
+ out.switching = true;
171
+ out.given.add("--switch");
172
+ continue;
173
+ }
174
+ if (arg === "--dry-run") {
175
+ out.dryRun = true;
176
+ out.given.add("--dry-run");
177
+ continue;
178
+ }
179
+ if (!arg.startsWith("-")) {
180
+ positional.push(arg);
181
+ continue;
182
+ }
183
+ if (!VALUED.has(arg)) {
184
+ // #155: both spellings, because the tool is genuinely reachable two ways and a usage line
185
+ // naming only one of them is wrong for whoever arrived the other way.
186
+ throw new VendorError(
187
+ `unknown option \`${arg}\` — run \`portulan vendor --help\` for the ones this understands, ` +
188
+ `or \`node cli/vendor.mjs --help\` from a checkout`,
189
+ );
190
+ }
191
+ const value = argv[i + 1];
192
+ if (value === undefined || value.startsWith("-")) {
193
+ throw new VendorError(`\`${arg}\` needs a value and the next argument is \`${value ?? "(nothing)"}\` — refusing to read a flag as one`);
194
+ }
195
+ if (value.trim() === "") {
196
+ throw new VendorError(`\`${arg}\` was given an empty value, and no option here has a meaningful empty value`);
197
+ }
198
+ // `auto` matched raw so `./auto` still names a directory. This tool only forwards roots to
199
+ // `doctor`, so it forwards the request too.
200
+ if (arg === "--pack-root") {
201
+ if (value === AUTO) out.discoverPacks = true;
202
+ else out.packRoots.push(value);
203
+ }
204
+ else if (arg === "--repo-root") out.repoRoots.push(value);
205
+ else out[{ "--into": "into", "--residence": "residence", "--leave": "leave", "--host": "host", "--kind": "kindOf", "--feed": "feed" }[arg]] = value;
206
+ out.given.add(arg);
207
+ i += 1;
208
+ }
209
+
210
+ if (positional.length > 1) {
211
+ throw new VendorError(
212
+ `${positional.length} source directories given (${positional.join(", ")}) — \`vendor\` materialises one workspace, ` +
213
+ `and picking one of two would be choosing which one gets moved`,
214
+ );
215
+ }
216
+ // Forwarded, so refused here too — and in the SHARED sentence rather than a spelling of this
217
+ // tool's own. `doctor` would refuse the same pair a moment later, but by then this tool has begun
218
+ // reporting about a workspace, and a refusal that arrives wearing `doctor`'s name reads as a
219
+ // verdict about the copy rather than as an answer about the command line.
220
+ const bothAsked = namedWithAuto(out.packRoots, out.discoverPacks === true);
221
+ if (bothAsked) throw new VendorError(bothAsked);
222
+ out.source = positional[0] ?? null;
223
+ return out;
224
+ }
225
+
226
+ // ------------------------------------------------------------------------- the residence, and the two keys
227
+
228
+ /**
229
+ * Which residence a manifest describes.
230
+ *
231
+ * Keyed on **`tree`**, which proposal 0017 names as the one thing keyed to location — *"every feature
232
+ * keys to a workspace SLOT, never to a residence… The one thing that IS keyed to location is `tree`"*.
233
+ * Deliberately not keyed on `kind`: a manifest whose `kind` and `tree` disagree is a defect `doctor`
234
+ * reports where it runs, and a second verdict about it from here would be a second carrier of that rule.
235
+ */
236
+ export function residenceOf(manifest) {
237
+ return manifest?.tree === undefined ? "feed-side" : "in-repo";
238
+ }
239
+
240
+ /**
241
+ * The same workspace, retargeted at the other residence — **exactly two keys change**.
242
+ *
243
+ * 0017's parity argument made executable: *"one artifact in two residences, differing in reach and
244
+ * delivery, never in content-kind"*. If this function ever has to touch a third key, that claim is
245
+ * wrong and it is a finding rather than a patch. Key ORDER is preserved for everything else so the
246
+ * materialised manifest diffs against its source as the two-line change it actually is.
247
+ */
248
+ export function retarget(manifest, residence, kindOf = null, tree = "../") {
249
+ if (kindOf !== null && !GOVERNING_KINDS.includes(kindOf)) {
250
+ throw new VendorError(
251
+ `\`--kind ${kindOf}\` is not a kind a workspace can be materialised as — the three are ${GOVERNING_KINDS.join(", ")}. ` +
252
+ `\`pointer\` is what a switch LEAVES BEHIND, never what it materialises: a pointer governs nothing, so ` +
253
+ `materialising one would be a switch that moved a workspace to nowhere`,
254
+ );
255
+ }
256
+ const out = {};
257
+ for (const [key, value] of Object.entries(manifest)) {
258
+ if (key === "tree") continue;
259
+ out[key] = value;
260
+ }
261
+ if (residence === "in-repo") {
262
+ out.kind = kindOf ?? "repository";
263
+ out.tree = tree;
264
+ } else {
265
+ // A `demo` workspace is already a feed-side shape — it declares no `tree` — so rewriting it to
266
+ // `portfolio` would change what the workspace IS in order to move it, which is not a move.
267
+ out.kind = kindOf ?? (manifest.kind === "demo" ? "demo" : "portfolio");
268
+ }
269
+ return out;
270
+ }
271
+
272
+ /**
273
+ * Slots whose value resolves **outside** the workspace directory.
274
+ *
275
+ * A slot pointing outside resolves against the workspace's neighbours, and a workspace materialised
276
+ * somewhere else has different neighbours — so the copy's slot dangles and `doctor` reds it on a path
277
+ * that does not resolve. Customer zero is exactly this shape (`"constitution": "../docs/vision.md"`),
278
+ * which is why this repository's own workspace is not the subject of the parity demonstration.
279
+ * Refused ahead of writing rather than produced and then graded.
280
+ */
281
+ export function escapingSlots(manifest, dir) {
282
+ const root = path.resolve(dir);
283
+ const out = [];
284
+ for (const [slot, value] of Object.entries(manifest?.slots ?? {})) {
285
+ if (typeof value !== "string") continue;
286
+ const resolved = path.resolve(root, value);
287
+ if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) out.push({ slot, value, resolved });
288
+ }
289
+ return out;
290
+ }
291
+
292
+ // ------------------------------------------------------------------------- reading the source
293
+
294
+ /**
295
+ * Every ordinary file under a workspace directory, with its mode — and a refusal for anything else.
296
+ *
297
+ * `lstatSync`, never `statSync`/`existsSync`: both follow links. A symlink anywhere under the source is
298
+ * refused rather than resolved, because copying through one materialises a file from **outside** the
299
+ * workspace and records it as part of the workspace. That is the write-path escape `init` shipped,
300
+ * arriving by the read path — the sibling nobody guarded (#91).
301
+ */
302
+ export function walk(dir) {
303
+ return scan(dir).files;
304
+ }
305
+
306
+ /**
307
+ * Every DIRECTORY under a workspace directory, including the empty ones.
308
+ *
309
+ * A copy driven only by `walk()` silently drops an empty directory, because a directory reaches the
310
+ * destination as a side effect of a file landing inside it. That is not a cosmetic loss: the Workspace
311
+ * Definition lets a declared slot be a directory, `doctor` requires the directory to exist, and an empty
312
+ * `memory/` or `proposals/` is the ordinary state of a workspace that has not earned a record yet. So a
313
+ * workspace GREEN at its source became a copy `doctor` refuses — measured, not reasoned about.
314
+ *
315
+ * The staging validation is what turned that into a refusal rather than a corruption: the switch was
316
+ * declined and nothing moved. It is still a defect — a valid workspace could not be moved at all, and
317
+ * the only way an adopter could act on the refusal was to put a file into every empty directory they
318
+ * own. Copilot's suppressed notes, round 8 on #164.
319
+ */
320
+ export function directories(dir) {
321
+ return scan(dir).dirs;
322
+ }
323
+
324
+ /** One descent, two answers, so the guards below cannot come to differ between them. */
325
+ function scan(dir) {
326
+ const root = path.resolve(dir);
327
+ const out = [];
328
+ const dirs = [];
329
+ const seen = (rel) => (rel === "" ? root : path.join(root, rel));
330
+
331
+ const descend = (rel, depth) => {
332
+ if (depth > 64) throw new VendorError(`\`${rel}\` is more than 64 directories deep — refusing to walk further rather than looping`);
333
+ let entries;
334
+ try {
335
+ entries = fs.readdirSync(seen(rel));
336
+ } catch (cause) {
337
+ throw new VendorError(`${seen(rel)} could not be read — ${cause.code ?? cause.message}. Only a missing path means "nothing there"`);
338
+ }
339
+ for (const entry of entries.sort()) {
340
+ const childRel = rel === "" ? entry : `${rel}/${entry}`;
341
+ let stat;
342
+ try {
343
+ stat = fs.lstatSync(seen(childRel));
344
+ } catch (cause) {
345
+ // Only ENOENT means absent, and here even that is a surprise: the entry was just listed.
346
+ if (cause.code === "ENOENT") continue;
347
+ throw new VendorError(`${seen(childRel)} could not be examined — ${cause.code ?? cause.message}. An unanswerable question is not an absence`);
348
+ }
349
+ if (stat.isSymbolicLink()) {
350
+ throw new VendorError(
351
+ `\`${childRel}\` is a symlink, and this refuses to copy through one. A resolved link materialises a file from ` +
352
+ `outside the workspace and records it as part of the workspace — the escape that cost \`init\` nine files ` +
353
+ `written into an unrelated directory, arriving here by the read path. Replace the link with a real file`,
354
+ );
355
+ }
356
+ if (stat.isDirectory()) {
357
+ dirs.push({ rel: childRel, mode: stat.mode & 0o777 });
358
+ descend(childRel, depth + 1);
359
+ continue;
360
+ }
361
+ if (!stat.isFile()) {
362
+ throw new VendorError(`\`${childRel}\` is neither a file nor a directory, and this copies neither by guessing at it`);
363
+ }
364
+ out.push({ rel: childRel, mode: stat.mode & 0o777 });
365
+ }
366
+ };
367
+
368
+ descend("", 0);
369
+ return { files: out, dirs };
370
+ }
371
+
372
+ // ------------------------------------------------------------------------- writing into somebody's tree
373
+
374
+ /**
375
+ * The path steps from `root` down to `target`, inclusive of both.
376
+ *
377
+ * **The boundary is the named destination, and it was found by running rather than reading** — the same
378
+ * finding `cli/new.mjs` records. A check that walked to the filesystem root refused every scratch
379
+ * directory in the suites, because on macOS `os.tmpdir()` resolves under `/var` and `/var` is a symlink.
380
+ * A symlink among the ancestors of the path the user named is a fact about their filesystem and they
381
+ * named it; a symlink **at or below** it is a segment this tool derives, and a link there is precisely
382
+ * how a write leaves the tree it was meant to stay inside.
383
+ */
384
+ function chain(target, root) {
385
+ const steps = [];
386
+ let at = path.resolve(target);
387
+ const stop = path.resolve(root);
388
+ for (let i = 0; i < 64; i += 1) {
389
+ steps.push(at);
390
+ if (at === stop) break;
391
+ const up = path.dirname(at);
392
+ if (up === at) break;
393
+ at = up;
394
+ }
395
+ return steps.reverse();
396
+ }
397
+
398
+ /**
399
+ * Which destination paths cannot be written — because something is already there, because a link is on
400
+ * the chain, or because the question could not be answered at all.
401
+ *
402
+ * An existing **directory** is not a collision: this writes files into directories a user may
403
+ * legitimately have created. `allow` carries the narrow carve-out a switch needs — see `carveOut`.
404
+ *
405
+ * `lstat` is injectable because the third rule is the one that cannot otherwise be forced: `chmod`
406
+ * refusals are ignored by root, and CI often runs as root, so a suite that reached for `chmod` would
407
+ * assert the rule on a developer's laptop and silently skip it where it matters.
408
+ */
409
+ export function collisions(destDir, rels, { lstat = fs.lstatSync, allow = new Set() } = {}) {
410
+ const found = [];
411
+ for (const rel of rels) {
412
+ const target = path.join(destDir, rel);
413
+ for (const step of chain(target, destDir)) {
414
+ let stat;
415
+ try {
416
+ stat = lstat(step);
417
+ } catch (cause) {
418
+ // Only ENOENT means absent — and nothing below an absent directory can exist either, so
419
+ // the walk stops clear. Anything else means this path's state is UNKNOWN, and an unknown
420
+ // is not a clear: declaring it one would let the write loop start on the strength of a
421
+ // question nobody could answer.
422
+ if (cause.code === "ENOENT") break;
423
+ found.push({ rel, path: step, why: `${step} could not be examined (${cause.code ?? cause.message})` });
424
+ break;
425
+ }
426
+ if (stat.isSymbolicLink()) {
427
+ found.push({ rel, path: step, why: `${step} is a symlink, and writing through it would leave the tree` });
428
+ break;
429
+ }
430
+ if (step === target) {
431
+ // A DIRECTORY at the exact path a file must be written is a collision, and this said it
432
+ // was not. The exemption for existing directories is right for the *intermediate*
433
+ // segments — a user may legitimately have created them — and wrong at the leaf, where
434
+ // `walk()` only ever yields files: preflight passed, and the write phase then threw
435
+ // `EISDIR` mid-copy, so the promise that every refusal stands ahead of the first byte
436
+ // was broken by the check that exists to keep it. Copilot's suppressed note, round 4.
437
+ if (stat.isDirectory()) {
438
+ found.push({ rel, path: step, why: `${step} is a directory, and a file has to be written there` });
439
+ break;
440
+ }
441
+ // Not a regular file — a FIFO, a socket, a device node. `walk()` already refuses these
442
+ // in the SOURCE ("copies neither by guessing at it"); the destination's ALLOWED leaves
443
+ // were the half that did not, so the carve-out could permit a FIFO named `README.md`
444
+ // and the later read would block rather than fail. Checked before `allow` is consulted,
445
+ // for the same reason the symlink test is: an exemption is about replacing a file, and
446
+ // this is not one. Copilot's suppressed notes, round 13 on #164.
447
+ if (!stat.isFile()) {
448
+ found.push({ rel, path: step, why: `${step} is neither a file nor a directory, and this reads and writes neither by guessing at it` });
449
+ break;
450
+ }
451
+ if (allow.has(rel)) break;
452
+ found.push({ rel, path: step, why: "already exists" });
453
+ break;
454
+ }
455
+ if (step !== target && !stat.isDirectory()) {
456
+ found.push({ rel, path: step, why: `${step} is in the way and is not a directory` });
457
+ break;
458
+ }
459
+ }
460
+ }
461
+ return found;
462
+ }
463
+
464
+ /**
465
+ * What a switch's destination is allowed to already contain, and nothing else.
466
+ *
467
+ * The feed-side → in-repo direction lands **on top of the pointer that is the old residence's in-repo
468
+ * half** — `init --residence pointer` writes exactly `workspace.json` and `README.md` there — so rule 1
469
+ * as written would refuse the switch's own ordinary case. The carve-out is deliberately narrow: the
470
+ * manifest must be a **pointer**, and it must name the workspace being moved in. A pointer naming
471
+ * somebody else is a foreign residence and refuses; a third file is somebody's and refuses.
472
+ */
473
+ function carveOut(destDir, incomingName) {
474
+ const manifestPath = path.join(destDir, "workspace.json");
475
+
476
+ // **`lstat` BEFORE any read, and this is the whole of it.** `readdirSync` and `readFileSync` follow
477
+ // symlinks, so without this the carve-out read a manifest *outside* the named tree and could then
478
+ // refuse — or permit — on the strength of a workspace that is not in this repository at all. The
479
+ // symlink-aware collision check below would still stop the write, which is why nothing escaped; but
480
+ // a containment guarantee that depends on which check happens to run first is not a guarantee, and
481
+ // a refusal drawn from a foreign manifest misdescribes what it found.
482
+ //
483
+ // `cli/init.mjs` shipped exactly this and its `residenceAt` records the fix in the same words. I
484
+ // copied the tool and not the lesson. Copilot, round 5 on #164 — issue #91's class again, and the
485
+ // fourth time this milestone.
486
+ for (const step of [destDir, manifestPath]) {
487
+ let stat;
488
+ try {
489
+ stat = fs.lstatSync(step);
490
+ } catch (cause) {
491
+ // Only ENOENT means absent, and an absent destination has nothing to carve out.
492
+ if (cause.code === "ENOENT") return { allow: new Set(), pointer: null };
493
+ throw new VendorError(`${step} could not be examined — ${cause.code ?? cause.message}. Only a missing path means "nothing there"`);
494
+ }
495
+ if (stat.isSymbolicLink()) {
496
+ throw new VendorError(
497
+ `${step} is a symlink, and this refuses to read a manifest through one as firmly as it refuses to write ` +
498
+ `through one. A manifest reached that way describes some other directory, so any verdict about this ` +
499
+ `destination drawn from it would be about somewhere else. Replace the link with a real directory`,
500
+ );
501
+ }
502
+ }
503
+
504
+ let entries;
505
+ try {
506
+ entries = fs.readdirSync(destDir);
507
+ } catch (cause) {
508
+ if (cause.code === "ENOENT") return { allow: new Set(), pointer: null };
509
+ throw new VendorError(`${destDir} could not be read — ${cause.code ?? cause.message}. Only a missing path means "nothing there"`);
510
+ }
511
+ if (entries.length === 0) return { allow: new Set(), pointer: null };
512
+
513
+ let manifest = null;
514
+ try {
515
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
516
+ } catch (cause) {
517
+ if (cause.code !== "ENOENT") {
518
+ throw new VendorError(
519
+ `${manifestPath} could not be read as a manifest — ${cause.code ?? cause.message}. Refusing to write over a manifest ` +
520
+ `it cannot understand: a corrupt policy layer is the case where overwriting costs the most and this tool knows the least`,
521
+ );
522
+ }
523
+ }
524
+ if (manifest === null || manifest.kind !== "pointer") {
525
+ return { allow: new Set(), pointer: null };
526
+ }
527
+ if (manifest.governed_by?.workspace !== incomingName) {
528
+ // `?? "(nobody)"` was `=== undefined` spelled another way: it caught an ABSENT governor and
529
+ // read `""`, `null`, `7` and `{}` as names, printing `` `` and `` `[object Object]` `` into a
530
+ // refusal claiming a foreign residence. Same class as #141 one file over, found at that fix's
531
+ // pre-commit checkpoint. The refusal itself stands — vendor is about to overwrite, and
532
+ // fail-closed on a manifest it cannot read is the right direction — but it now says which of
533
+ // the two it met — the WHOLE sentence, not only its opening clause. The first cut branched
534
+ // the prefix and left "That is a foreign residence … aimed somewhere else" standing for both,
535
+ // so the message still asserted a foreign residence about a pointer aimed nowhere: the same
536
+ // half-done repair this change is about, in the fix for it. (Copilot, the round after.)
537
+ //
538
+ // BOTH arms stringify. The first cut escaped only the unusable one and left the declared arm
539
+ // raw inside backticks — the same sibling shape, in the fix for that shape: this value comes
540
+ // from a manifest nobody validated, so a newline in it breaks the refusal across lines and
541
+ // padding hides inside the backticks. (Copilot, on the round reviewing it.)
542
+ const declared = typeof manifest.governed_by?.workspace === "string" && manifest.governed_by.workspace.trim() !== "";
543
+ throw new VendorError(
544
+ declared
545
+ ? `${destDir} carries a pointer naming ${JSON.stringify(manifest.governed_by.workspace)} as its governor, and the ` +
546
+ `workspace being moved in is \`${incomingName}\`. That is a foreign residence, not this switch's other half — ` +
547
+ `a repository is governed by exactly one workspace, and materialising over a pointer aimed somewhere else would ` +
548
+ `take governance from a workspace that never agreed to give it up`
549
+ : `${destDir} carries a pointer whose governor is unusable (${JSON.stringify(manifest.governed_by?.workspace)}), and the ` +
550
+ `workspace being moved in is \`${incomingName}\`. That is a malformed pointer rather than a foreign residence, ` +
551
+ `and materialising over it would overwrite a manifest nobody can read as consent — fix or remove the pointer, ` +
552
+ `and run this again`,
553
+ );
554
+ }
555
+ const extra = entries.filter((e) => e !== "workspace.json" && e !== "README.md").sort();
556
+ if (extra.length) {
557
+ throw new VendorError(
558
+ `${destDir} holds ${extra.map((e) => `\`${e}\``).join(", ")} beside the pointer. A switch may land on a pointer — that ` +
559
+ `is the old residence's own half — and on nothing else: those files are somebody's, and nothing here overwrites a ` +
560
+ `file you wrote. Move them aside and run this again`,
561
+ );
562
+ }
563
+ return { allow: new Set(entries), pointer: manifest };
564
+ }
565
+
566
+ // ------------------------------------------------------------------------- what a pointer says
567
+
568
+ /** A path as a reader would recognise it — relative to cwd when that is shorter, absolute otherwise. */
569
+ function display(target) {
570
+ const rel = path.relative(process.cwd(), target);
571
+ return rel && !rel.startsWith("..") && rel.length < target.length ? rel : target;
572
+ }
573
+
574
+ /**
575
+ * The pointer's own `name`.
576
+ *
577
+ * A residence directory is usually `.portulan`, whose basename would name every pointer in the world
578
+ * `portulan`. So a dot-led residence is named after the **repository** that holds it, and a feed slot
579
+ * after itself. Refused rather than defaulted when nothing survives slugification — a pointer called
580
+ * something nobody chose is worse than being asked.
581
+ */
582
+ function pointerName(residenceDir) {
583
+ const base = path.basename(residenceDir);
584
+ const source = base.startsWith(".") ? path.basename(path.dirname(residenceDir)) : base;
585
+ const slug = String(source).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
586
+ if (slug === "" || !SLUG.test(slug)) {
587
+ throw new VendorError(
588
+ `\`${source}\` yields no usable name for the pointer left at ${display(residenceDir)} — a pointer is a manifest and its ` +
589
+ `\`name\` has to be a slug. Rename the directory, or switch with \`--leave nothing\``,
590
+ );
591
+ }
592
+ return slug;
593
+ }
594
+
595
+ function pointerManifest(residenceDir, governor, feed) {
596
+ return {
597
+ portulan: { spec: SPEC },
598
+ name: pointerName(residenceDir),
599
+ // Residence-AGNOSTIC, because this function leaves pointers at both ends. It said "This
600
+ // repository is governed by…", which is true of the pointer left in a repository and false of
601
+ // the one left in a retired feed slot — a directory that is not a repository residence at all.
602
+ // A manifest carrying a sentence about somewhere it is not is the same defect one layer down
603
+ // from the prose this session spent a sweep on. Copilot's suppressed notes, round 5 on #164.
604
+ summary: `Governed by the \`${governor}\` workspace, which resides elsewhere. This directory holds a pointer and no policy layer of its own.`,
605
+ kind: "pointer",
606
+ governed_by: feed ? { workspace: governor, feed } : { workspace: governor },
607
+ };
608
+ }
609
+
610
+ function pointerReadme(governor, feed, movedTo) {
611
+ return `# This workspace lives elsewhere now
612
+
613
+ A **pointer**, not a workspace. It records one fact: the \`${governor}\` workspace governs here${feed ? `, and ships through the \`${feed}\` feed` : ""}.
614
+
615
+ \`portulan vendor --switch\` moved it to \`${movedTo}\` and left this behind. A repository is governed by
616
+ **exactly one** workspace — its own full workspace, or a pointer to the workspace that names it, never
617
+ both — so what is left here carries no slots, no verify recipes and no gate policy.
618
+
619
+ **Nothing here resolves the pointer for you.** Reading it and fetching it are different things, and only
620
+ the first is built: \`doctor\` reports the governor this names, and finding that workspace on this
621
+ machine is a separate question.
622
+
623
+ To move it back, run the switch in the other direction rather than deleting this and writing a workspace
624
+ by hand — the window between those two is a repository governed by nothing, which looks exactly like one
625
+ that never adopted Portulan.
626
+ `;
627
+ }
628
+
629
+ /** What lands where a pointer used to be, once the workspace itself has arrived. */
630
+ function arrivedReadme(name) {
631
+ return `# ${name} — resident here
632
+
633
+ This repository carried a **pointer** until \`portulan vendor --switch\` materialised the \`${name}\`
634
+ workspace into it. The pointer's own README said this repository's workspace lived elsewhere; it does
635
+ not, and a false sentence left behind in somebody's tree is worse than no sentence at all.
636
+
637
+ Every file beside this one came from the workspace as it stood at the other residence — byte for byte,
638
+ except \`workspace.json\`, whose \`kind\` and \`tree\` are the two keys a residence actually changes.
639
+
640
+ **Compiled host enforcement did not come with it.** \`compile\` writes host settings from this
641
+ workspace's gate policy, and no copy of files produces them. Run \`portulan compile\` here.
642
+ `;
643
+ }
644
+
645
+ // ------------------------------------------------------------------------- the vendored standards file
646
+
647
+ /**
648
+ * `AGENTS.md` — the standards plane for a host that is not Claude Code.
649
+ *
650
+ * **It names only what the manifest actually declares.** A vendored standards file listing slots the
651
+ * workspace does not carry is `.portulan/dod.md` condition 4's defect — a document describing a
652
+ * capability the tree does not have — emitted into somebody else's repository, which is the worst shape
653
+ * available for it. So every line below is generated from a key that is present.
654
+ *
655
+ * **Core's kernel is inlined; the workspace's own files are named; packs are neither.**
656
+ * `../core/engine.md` says the CLI *"composes it with the pack and workspace layers into a vendored
657
+ * `AGENTS.md` for any host"*, and two thirds of that is what happens: the kernel is embedded because it
658
+ * ships in this package and a host with no plugin has no other way to it, the workspace's slots are
659
+ * pointed at because they travel in the `.portulan/` beside this file, and a pack resolves from a feed
660
+ * at a pinned version — which vendoring does not do. That third is named in the artifact rather than
661
+ * quietly implied by the word "self-contained".
662
+ */
663
+ export function agentsMd(manifest, host, kernel = null) {
664
+ const lines = [
665
+ `# AGENTS.md — ${manifest.name}`,
666
+ "",
667
+ `> ${manifest.summary ?? "A Portulan workspace, vendored as standards."}`,
668
+ "",
669
+ `Vendored by \`portulan vendor --host ${host}\` from the \`${manifest.name}\` workspace. This file and the`,
670
+ "`.portulan/` directory beside it are the whole of it: no plugin, no marketplace, no second repository in",
671
+ "the trust path. An agent on this host reads the files named below and works this team's way.",
672
+ "",
673
+ "## Read these, in this order",
674
+ "",
675
+ ];
676
+ const GLOSS = {
677
+ identity: "who this team is, the stack, and the glossary",
678
+ principles: "the rules that are this team's rather than everyone's",
679
+ gates: "which concrete actions are Auto, Propose, Gated or Prohibited",
680
+ dod: "what *done* means here, beyond a green verify",
681
+ constitution: "the team's ground truth, which outranks everything else here",
682
+ };
683
+ const slots = Object.entries(manifest.slots ?? {});
684
+ if (slots.length === 0) {
685
+ lines.push("_This workspace declares no slots. There is nothing for an agent to read here yet._", "");
686
+ } else {
687
+ for (const [slot, value] of slots) {
688
+ lines.push(`- **\`.portulan/${value}\`** — ${GLOSS[slot] ?? `the \`${slot}\` slot`}.`);
689
+ }
690
+ lines.push("");
691
+ }
692
+
693
+ lines.push("## Verify — what *done* is checked against", "");
694
+ // Workspace-owned recipes only, and the exclusion is a ruling rather than an oversight.
695
+ //
696
+ // This artifact's own header promises **no plugin, no marketplace, no second repository in the
697
+ // trust path**, so a vendored host cannot resolve a pack — which means a composed recipe listed
698
+ // here would be a command the reader cannot run, in a table whose whole job is to say what *done*
699
+ // is checked against. Of the three available answers — list it as could-not-run, materialise the
700
+ // pack's files into the vendored tree, or exclude it and say so — the third is the only one that
701
+ // keeps the header's promise: the first ships a table row nobody can execute, and the second puts
702
+ // a pack's code into the artifact that advertises having none.
703
+ //
704
+ // So the table is the workspace's own set, the note below says that in the artifact rather than
705
+ // only here, and the *Packs this workspace composes* section that follows is where a reader is
706
+ // told what is missing and why. `recipeSet` is still the carrier — the exclusion is expressed by
707
+ // asking it for the workspace subset, never by re-enumerating `verify.recipes` in this file.
708
+ const composed = (manifest.packs ?? []).length > 0;
709
+ const set = recipeSet(manifest, { packs: [] });
710
+ // A manifest the carrier REFUSES is not a manifest that declares nothing, and collapsing the two
711
+ // would put "_No recipes are declared._" into a vendored artifact whose workspace actually declares
712
+ // an unreadable one — a false statement shipped to whoever receives the vendored copy, which is
713
+ // dod condition 4's class. The refusal's own reason is carried through instead. Copilot round 1.
714
+ const recipes = set.ok ? set.recipes : [];
715
+ if (!set.ok) {
716
+ lines.push(
717
+ "> **COULD NOT READ THIS WORKSPACE'S VERIFY RECIPES.** " + set.reason,
718
+ ">",
719
+ "> This is not the same as declaring none, and it is printed rather than swallowed: a vendored",
720
+ "> workspace whose verify table is silently empty would say *done is checked against nothing*",
721
+ "> when what is true is *nobody could read what done is checked against*.",
722
+ "",
723
+ );
724
+ } else if (recipes.length === 0) {
725
+ lines.push("_No recipes are declared._", "");
726
+ } else {
727
+ lines.push("| Recipe | Command |", "|---|---|");
728
+ for (const recipe of recipes) {
729
+ lines.push(`| \`${recipe.id}\`${recipe.id === manifest.verify?.default ? " (default)" : ""} | \`${recipe.run}\` |`);
730
+ }
731
+ lines.push(
732
+ "",
733
+ "Exit `0` green · `1` red · `2` **could not run**. The third is load-bearing: a recipe that could not",
734
+ "execute must never look like one that ran and passed.",
735
+ "",
736
+ );
737
+ if (composed) {
738
+ lines.push(
739
+ "**This table is the workspace's own recipes and deliberately excludes composed ones.** A pack may",
740
+ "contribute verify recipes to the workspace it is composed into, and those recipes run from the pack's",
741
+ "own files — which are not in this tree, because a vendored workspace has no pack in its trust path.",
742
+ "Listing them here would print commands you cannot run. **The packs that contribute them are named in",
743
+ "the section below**; the recipes themselves are not, because naming them would mean resolving the packs,",
744
+ "which is the thing this artifact does not do.",
745
+ "",
746
+ );
747
+ }
748
+ }
749
+
750
+ if (manifest.packs?.length) {
751
+ lines.push(
752
+ "## Packs this workspace composes",
753
+ "",
754
+ ...manifest.packs.map((p) => `- \`${p}\``),
755
+ "",
756
+ "**Their files are not here.** A pack resolves from a feed at a pinned version, and vendoring copies the",
757
+ "workspace rather than resolving its packs — so anything above is a name this host cannot open yet.",
758
+ "",
759
+ );
760
+ }
761
+
762
+ lines.push(
763
+ "## What this copy does NOT carry, said here rather than discovered",
764
+ "",
765
+ "- **Compiled host enforcement.** `portulan compile` turns the gate policy into a host's own settings and",
766
+ " hooks; copying files produces none of it. Until it is run, every tier above is a rule nothing checks.",
767
+ "- **A resolved pointer.** Nothing here fetches anything.",
768
+ "",
769
+ "Run `portulan doctor .portulan` to see where this stands.",
770
+ "",
771
+ );
772
+
773
+ if (kernel) {
774
+ lines.push(
775
+ "---",
776
+ "",
777
+ "# The engine kernel, inlined",
778
+ "",
779
+ "_Copied verbatim from `core/engine.md` in the Portulan package. A host without the plugin has no other",
780
+ "route to it, and standards a host cannot read are not standards. Everything above is this team's; what",
781
+ "follows is universal and identical in every workspace._",
782
+ "",
783
+ kernel.trim(),
784
+ "",
785
+ );
786
+ } else {
787
+ lines.push(
788
+ "---",
789
+ "",
790
+ "_The engine kernel could not be read from this installation, so it is **not** inlined below. Everything",
791
+ "above is still this team's layer; what is missing is the universal one. Read `core/engine.md` from the",
792
+ "Portulan package — this file names the gap rather than closing it silently._",
793
+ "",
794
+ );
795
+ }
796
+ return lines.join("\n");
797
+ }
798
+
799
+ // ------------------------------------------------------------------------- the entry point
800
+
801
+ export function usage() {
802
+ return [
803
+ "portulan vendor — materialise a workspace where it is needed, in either direction",
804
+ "",
805
+ " portulan vendor <workspace-dir> --into <dir> --residence <in-repo|feed-side> [options]",
806
+ " node cli/vendor.mjs <workspace-dir> --into <dir> --residence <r> [options] (from a checkout)",
807
+ "",
808
+ " --into <dir> REQUIRED. The destination is the WORKSPACE directory itself —",
809
+ " `<repo>/.portulan` in-repo, `<feed>/<name>` feed-side — the same",
810
+ " shape `doctor <workspace-dir>` takes.",
811
+ " --residence <r> REQUIRED: `in-repo` or `feed-side`. What the DESTINATION is. Never",
812
+ " inferred from the path — the residence is the one question a tool",
813
+ " may not answer for you, and the wrong guess is dual management.",
814
+ "",
815
+ " One of these two says what you are doing. There is no default:",
816
+ " --host <id> Vendor for a host that is not Claude Code: a self-contained",
817
+ " AGENTS.md beside the workspace. The source keeps governing.",
818
+ " Goes with `--residence in-repo` — the pair a host reads sits at",
819
+ " the root of a tree, and a feed-side workspace ships as a plugin.",
820
+ " --switch Change residence. The workspace is materialised at the new one, a",
821
+ " pointer or nothing is left at the old, and `doctor` is green at both",
822
+ " ends before the old residence is retired.",
823
+ "",
824
+ " --leave <pointer|nothing> What a switch leaves at the old residence. Default `pointer`.",
825
+ " --feed <name> The feed the governing workspace ships through, recorded in the",
826
+ " pointer. Only with `--switch --residence feed-side`.",
827
+ " --kind <k> The materialised workspace's kind: repository, demo or portfolio.",
828
+ " Defaults from the residence.",
829
+ " --pack-root <dir> Where packs are looked up, so `doctor` can resolve composed ones.",
830
+ " Repeatable. `auto` discovers it from the host's plugin cache;",
831
+ " `./auto` still names a directory called `auto`.",
832
+ " --repo-root <dir> Where the repositories this workspace names are checked out, so the",
833
+ " cross-repository refusal has somewhere to look. Repeatable.",
834
+ " --dry-run Print the plan and write nothing.",
835
+ "",
836
+ "Exit codes: 0 it did it · 1 `doctor` was not green at an end · 2 it could not run.",
837
+ ].join("\n");
838
+ }
839
+
840
+ /**
841
+ * The whole operation. Every refusal is ahead of the first byte; every write is ordered so that a
842
+ * handled failure at any point leaves exactly one governing workspace.
843
+ *
844
+ * `options.faultAt` throws after a named step. It is a **fault-injection seam and it is deliberate**:
845
+ * the property this tool exists to protect is what a *failure partway* leaves behind, and an ordering
846
+ * nothing can interrupt is an ordering nobody has checked. Reading the code establishes the writes are
847
+ * in the intended order; only forcing the stop establishes what sits on disk between two of them.
848
+ */
849
+ export async function run(argv, options = {}) {
850
+ const say = options.say ?? ((line = "") => process.stdout.write(`${line}\n`));
851
+ const warn = options.warn ?? ((line) => process.stderr.write(`${line}\n`));
852
+ const fault = (step) => {
853
+ if (options.faultAt === step) {
854
+ const error = new Error(`injected I/O failure after \`${step}\``);
855
+ error.code = "EIO";
856
+ throw error;
857
+ }
858
+ };
859
+
860
+ /** Undo steps, newest first. Registered only while a rollback is still the right answer. */
861
+ const undo = [];
862
+ let pastTheFlip = false;
863
+
864
+ try {
865
+ if (SLUG === null) {
866
+ throw new VendorError(`could not read spec/workspace.schema.json, which defines what a valid name is — ${SCHEMA_ERROR?.message}. Refusing to write a manifest it cannot check`);
867
+ }
868
+
869
+ const parsed = parseArgs(argv);
870
+ if (parsed.help) {
871
+ say(usage());
872
+ return 0;
873
+ }
874
+ if (!parsed.source) {
875
+ warn("vendor: no source workspace given — `vendor <workspace-dir> --into <dir> --residence <r>` materialises a workspace that exists.");
876
+ warn(usage());
877
+ return 2;
878
+ }
879
+
880
+ // ---- the flags, checked against each other before anything is read
881
+
882
+ if (!parsed.into) throw new VendorError("no destination given — pass `--into <dir>`, the directory the workspace is materialised into");
883
+ if (!parsed.residence) {
884
+ throw new VendorError(
885
+ "no residence given — pass `--residence in-repo` or `--residence feed-side`. It says what the DESTINATION is, and " +
886
+ "it is never inferred from the path: `init` holds the same rule for the same reason, which is that a repository " +
887
+ "is governed by exactly one workspace and the wrong guess is the dual management proposal 0017 refuses",
888
+ );
889
+ }
890
+ if (!RESIDENCES.has(parsed.residence)) throw new VendorError(`\`${parsed.residence}\` is not a residence — the two are \`in-repo\` and \`feed-side\``);
891
+ if (!parsed.switching && parsed.host === null) {
892
+ throw new VendorError(
893
+ "neither `--switch` nor `--host` was given, and this will not guess which you meant. `--host <id>` vendors a " +
894
+ "self-contained copy for a host and leaves the source governing; `--switch` changes which residence governs and " +
895
+ "retires the other. A plain copy of a governing workspace is how one repository ends up with two governors",
896
+ );
897
+ }
898
+ if (parsed.host !== null && parsed.residence !== "in-repo") {
899
+ // `agentsMd()` writes `.portulan/<slot>` paths and tells the reader to run
900
+ // `portulan doctor .portulan`, which is true by construction for an in-repo destination —
901
+ // that branch already refuses any basename but `.portulan`, on the boot-path rule. Vendored
902
+ // feed-side, the workspace directory is whatever the caller named, and the standards file
903
+ // would point at paths that do not exist: a document describing a tree it is not in,
904
+ // emitted into somebody else's repository, which is `dod.md` condition 4 and the shape this
905
+ // change has already swept fourteen carriers of.
906
+ //
907
+ // Refused rather than parameterised, and the reason is not effort. Vendoring for a host is
908
+ // the AAIF pair — `AGENTS.md` beside `.portulan/`, at the root an agent reads from — and a
909
+ // feed-side workspace ships as a plugin instead. Naming a residence that has no host to
910
+ // stand in is a question, not a configuration. Copilot's suppressed notes, round 11 on #164.
911
+ throw new VendorError(
912
+ "`--host` vendors the pair a host reads — `AGENTS.md` beside a `.portulan/` at the root of a tree — so it goes " +
913
+ "with `--residence in-repo`. A feed-side workspace is delivered as a plugin rather than read out of a directory, " +
914
+ "and the standards file this writes names `.portulan/` paths that would not exist there. Vendor it in-repo, or " +
915
+ "`--switch` it feed-side and install it",
916
+ );
917
+ }
918
+ if (parsed.switching && parsed.host !== null) {
919
+ throw new VendorError(
920
+ "`--host` does nothing with `--switch` — a switch materialises the workspace as a residence, not as vendored " +
921
+ "standards for a foreign host. Refused rather than ignored: an option accepted and then dropped is one you will " +
922
+ "believe had an effect. Run the switch, then vendor for the host from the new residence",
923
+ );
924
+ }
925
+ if (parsed.leave !== null && !parsed.switching) throw new VendorError("`--leave` only means something with `--switch` — there is no old residence to leave anything at");
926
+ if (parsed.leave !== null && !LEAVE.has(parsed.leave)) throw new VendorError(`\`--leave ${parsed.leave}\` is not one of \`pointer\` or \`nothing\` — 0017's own two`);
927
+ if (parsed.feed !== null && !(parsed.switching && parsed.residence === "feed-side")) {
928
+ throw new VendorError("`--feed` records the feed a governing workspace ships through, in the pointer left behind. It only means something with `--switch --residence feed-side`");
929
+ }
930
+ const leave = parsed.leave ?? "pointer";
931
+
932
+ // ---- the source
933
+
934
+ const source = path.resolve(parsed.source);
935
+ let sourceStat;
936
+ try {
937
+ sourceStat = fs.lstatSync(source);
938
+ } catch (cause) {
939
+ throw new VendorError(`\`${parsed.source}\` could not be examined — ${cause.code ?? cause.message}. \`vendor\` materialises a workspace that exists; it does not create one`);
940
+ }
941
+ if (sourceStat.isSymbolicLink()) throw new VendorError(`\`${parsed.source}\` is a symlink, and this refuses to read a workspace through one — a manifest reached that way describes some other directory`);
942
+ if (!sourceStat.isDirectory()) throw new VendorError(`\`${parsed.source}\` is not a directory — the source is a workspace directory, the same shape \`doctor\` takes`);
943
+
944
+ let manifest;
945
+ try {
946
+ manifest = JSON.parse(fs.readFileSync(path.join(source, "workspace.json"), "utf8"));
947
+ } catch (cause) {
948
+ throw new VendorError(
949
+ cause.code === "ENOENT"
950
+ ? `${display(source)} carries no \`workspace.json\`, so there is no workspace here to materialise. Point at a workspace directory — or run \`init\` if this repository has none`
951
+ : `${display(path.join(source, "workspace.json"))} could not be read — ${cause.code ?? cause.message}`,
952
+ );
953
+ }
954
+ if (manifest?.kind === "pointer") {
955
+ throw new VendorError(
956
+ `${display(source)} holds a **pointer**, not a workspace — it names \`${manifest.governed_by?.workspace ?? "(nobody)"}\` as its ` +
957
+ `governor and carries no policy layer of its own. Materialising a pointer would produce a copy of a reference. ` +
958
+ `Point at the governing workspace itself`,
959
+ );
960
+ }
961
+ if (typeof manifest?.name !== "string" || !SLUG.test(manifest.name)) {
962
+ throw new VendorError(`${display(source)}'s manifest declares no usable \`name\` (${JSON.stringify(manifest?.name)}) — a pointer has to name its governor exactly, so an unusable name here has nowhere to land`);
963
+ }
964
+
965
+ const sourceResidence = residenceOf(manifest);
966
+ const dest = path.resolve(parsed.into);
967
+
968
+ // The boot skill searches exactly `${CLAUDE_PROJECT_DIR}/.portulan/workspace.json` and is told
969
+ // not to search outward (0017, "Parity, and where it is keyed"). An in-repo residence anywhere
970
+ // else is a workspace nothing boots, and `tree: "../"` would be wrong for it besides.
971
+ if (parsed.residence === "in-repo" && path.basename(dest) !== ".portulan") {
972
+ throw new VendorError(
973
+ `an in-repo residence is \`<repository>/.portulan\`, and \`--into ${parsed.into}\` ends in \`${path.basename(dest)}\`. ` +
974
+ `The boot searches exactly one path and is told not to search outward, so a workspace anywhere else in the ` +
975
+ `repository is one nothing boots — and \`tree: "../"\` would be a claim about the tree that is not true`,
976
+ );
977
+ }
978
+
979
+ const escaping = escapingSlots(manifest, source);
980
+ if (escaping.length) {
981
+ throw new VendorError(
982
+ `${escaping.map((e) => `\`${e.slot}\` is \`${e.value}\``).join(", ")} — ${escaping.length > 1 ? "those slots resolve" : "that slot resolves"} ` +
983
+ `outside ${display(source)}, and a workspace materialised elsewhere has different neighbours. The copy's slot would ` +
984
+ `dangle and \`doctor\` would red it on a path that does not resolve. Move what the slot names inside the workspace ` +
985
+ `first — this repository's own workspace is exactly this shape, which is why it is not the one the parity ` +
986
+ `demonstration moves`,
987
+ );
988
+ }
989
+
990
+ // ---- the scope bound, refused in both its shapes
991
+
992
+ const cards = [];
993
+ if (manifest.slots?.repos) {
994
+ try {
995
+ for (const entry of fs.readdirSync(path.resolve(source, manifest.slots.repos))) {
996
+ if (entry.endsWith(".md") && entry !== "README.md") cards.push(entry.slice(0, -3));
997
+ }
998
+ } catch {
999
+ /* a missing repos directory is already a `doctor` failure, and not this tool's verdict to render */
1000
+ }
1001
+ }
1002
+ cards.sort();
1003
+ if (parsed.switching && cards.length > 1) {
1004
+ throw new VendorError(
1005
+ `\`${manifest.name}\` names ${cards.length} repositories (${cards.join(", ")}), and this switches a workspace between ` +
1006
+ `residences rather than moving one repository out of a portfolio. Both shapes are refused, not half-done: moving ` +
1007
+ `the whole workspace in-repo cannot answer which of the ${cards.length} repositories hosts it and would leave the ` +
1008
+ `others' pointers aimed at a retired residence; extracting one would edit another workspace's curated cards and ` +
1009
+ `\`products\` entries, and nothing here edits a manifest it did not write. Split the workspace by hand first, or ` +
1010
+ `vendor a copy with \`--host\``,
1011
+ );
1012
+ }
1013
+ if (parsed.switching && sourceResidence === parsed.residence) {
1014
+ throw new VendorError(
1015
+ `\`${manifest.name}\` already resides ${sourceResidence === "in-repo" ? "in a repository" : "feed-side"} and \`--residence ` +
1016
+ `${parsed.residence}\` names the same one, so this is not a change of residence. \`--switch\` retires an old residence; ` +
1017
+ `there is no old residence here. Drop \`--switch\` and pass \`--host\` if you meant a copy`,
1018
+ );
1019
+ }
1020
+
1021
+ // ---- the destination
1022
+
1023
+ const carve = parsed.switching ? carveOut(dest, manifest.name) : { allow: new Set(), pointer: null };
1024
+ const walked = walk(source);
1025
+ const sourceDirs = directories(source);
1026
+ const files = walked.filter((f) => !isGenerated(f.rel));
1027
+ const generated = walked.filter((f) => isGenerated(f.rel)).map((f) => f.rel);
1028
+ const rels = files.map((f) => f.rel);
1029
+ const hostFile = parsed.host === null ? null : path.join(path.dirname(dest), "AGENTS.md");
1030
+
1031
+ // **Every path this run will write, not merely every path it will copy.** When a switch lands on
1032
+ // a pointer whose source carries no `README.md`, one is SYNTHESISED — and the preflight was
1033
+ // built from the source's file list, so that leaf was never lstat'd at all. A symlink there was
1034
+ // followed by the write, which is the containment rule refused by the one path the check could
1035
+ // not see. The carve-out still permits replacing an ordinary pointer README; what it may not do
1036
+ // is exempt a link, and `collisions` tests for a symlink before it consults `allow`.
1037
+ // Copilot, round 9 on #164.
1038
+ const synthesised = parsed.switching && carve.pointer && !rels.includes("README.md") ? ["README.md"] : [];
1039
+ // Round 4 established that a directory where a file must be written is refused up front. That
1040
+ // was the DESTINATION; this is the source, and it reaches the same collision from the other end:
1041
+ // a source directory named `README.md` yields no `README.md` in the file list, so the switch
1042
+ // decides to synthesise one, `directories()` faithfully creates the directory in staging, and the
1043
+ // write throws `EISDIR` — recovered, since the undo is registered first, but reported as an
1044
+ // unanticipated failure rather than as the plain fact it is. Copilot's suppressed notes, round 12.
1045
+ if (synthesised.length && sourceDirs.some((d) => d.rel === "README.md")) {
1046
+ throw new VendorError(
1047
+ `${display(source)} holds a DIRECTORY named \`README.md\`, and this switch needs to write a file there — the ` +
1048
+ `destination's pointer README is being replaced and the source carries no README to replace it with. ` +
1049
+ `Rename that directory, or put a \`README.md\` file in the workspace so there is one to carry`,
1050
+ );
1051
+ }
1052
+ const clash = collisions(dest, [...rels, ...synthesised], { allow: carve.allow });
1053
+ if (hostFile !== null) clash.push(...collisions(path.dirname(dest), ["AGENTS.md"]));
1054
+ if (clash.length) {
1055
+ // Grouped by CAUSE, not listed per path: one symlinked directory blocks every file, and
1056
+ // naming it twenty times buries the single fact the reader needs under nineteen copies of
1057
+ // it. A refusal nobody finishes reading is a refusal that failed to explain.
1058
+ const byCause = new Map();
1059
+ for (const item of clash) {
1060
+ const key = item.why === "already exists" ? "already exists" : item.why;
1061
+ if (!byCause.has(key)) byCause.set(key, []);
1062
+ byCause.get(key).push(item.rel);
1063
+ }
1064
+ const summary = [...byCause]
1065
+ .map(([why, list]) => (list.length > 2 ? `${why} — blocking ${list.length} files, including \`${list[0]}\`` : `${why} — ${list.map((r) => `\`${r}\``).join(", ")}`))
1066
+ .join("; ");
1067
+ throw new VendorError(
1068
+ `refusing to write into ${display(dest)}: ${summary}. Nothing here overwrites a file you wrote, and nothing follows a ` +
1069
+ `link out of the tree. Move or remove what is in the way, or materialise into a clean directory`,
1070
+ );
1071
+ }
1072
+
1073
+ // ---- the plan
1074
+
1075
+ const retargeted = retarget(manifest, parsed.residence, parsed.kindOf);
1076
+ const oldResidence = source;
1077
+ const repoDir = parsed.residence === "in-repo" ? path.dirname(dest) : path.dirname(source);
1078
+
1079
+ if (parsed.dryRun) {
1080
+ say(`vendor: ${parsed.switching ? "switch" : "vendor"} \`${manifest.name}\` — ${sourceResidence} → ${parsed.residence}`);
1081
+ say(`vendor: from ${display(source)}`);
1082
+ say(`vendor: into ${display(dest)}`);
1083
+ for (const file of files) say(`vendor: ${file.rel}`);
1084
+ if (hostFile) say(`vendor: and ${display(hostFile)}`);
1085
+ if (generated.length) say(`vendor: NOT carried (compiled enforcement, keyed to the residence): ${generated.join(", ")}`);
1086
+ if (parsed.switching) {
1087
+ say(`vendor: then ${leave === "pointer" ? `a pointer naming \`${manifest.name}\`` : "nothing"} at ${display(oldResidence)}, once doctor is green at both ends`);
1088
+ }
1089
+ say("vendor: --dry-run, so nothing was written.");
1090
+ return 0;
1091
+ }
1092
+
1093
+ // ---- materialise, into a STAGING directory that is a residence nowhere
1094
+ //
1095
+ // 0017 defines a `.portulan/` with files and no manifest as **not a residence**, which is what
1096
+ // makes a partial copy safe. Staging goes further: the copy is validated somewhere nothing looks
1097
+ // for a workspace at all, so a destination that would not have been green never becomes a second
1098
+ // governor for even one rename. Only a rename puts it in place.
1099
+
1100
+ const staging = path.join(path.dirname(dest), `.${path.basename(dest).replace(/^\.+/, "")}.vendoring`);
1101
+ // `lstatSync`, not `existsSync` — which answers **false** on `EACCES` and so reported "clear" for
1102
+ // a path whose state nobody could read, letting the run proceed into `mkdirSync` and fail later
1103
+ // with an error about the wrong thing. This file states the only-`ENOENT` rule three times in its
1104
+ // own header and broke it here, in the one call that was written as a convenience rather than as
1105
+ // a check. Copilot's suppressed notes, round 10 on #164 — the fifth appearance of a guard that
1106
+ // was carried everywhere except one place.
1107
+ try {
1108
+ fs.lstatSync(staging);
1109
+ throw new VendorError(
1110
+ `${display(staging)} is already there — a previous run of this tool died before it could clear its staging directory. ` +
1111
+ `Nothing was written. Check what is in it, then remove it and run this again`,
1112
+ );
1113
+ } catch (cause) {
1114
+ if (cause instanceof VendorError) throw cause;
1115
+ if (cause.code !== "ENOENT") {
1116
+ throw new VendorError(
1117
+ `${display(staging)} could not be examined — ${cause.code ?? cause.message}. Only a missing path means "nothing there", ` +
1118
+ `and this needs somewhere it knows is empty to stage a copy in`,
1119
+ );
1120
+ }
1121
+ }
1122
+ fs.mkdirSync(staging, { recursive: true });
1123
+ undo.push(() => fs.rmSync(staging, { recursive: true, force: true }));
1124
+
1125
+ // DIRECTORIES first, and all of them — including the ones with nothing in them. A copy driven
1126
+ // only by the file list creates a directory as a side effect of a file landing in it, so an
1127
+ // empty declared slot never arrives, and `doctor` refuses the copy for a path that does not
1128
+ // exist. `memory/` and `proposals/` are empty in every workspace that has not earned a record
1129
+ // yet, which is most of them on the day they are switched.
1130
+ for (const entry of sourceDirs) {
1131
+ if (isGenerated(entry.rel)) continue;
1132
+ const full = path.join(staging, entry.rel);
1133
+ fs.mkdirSync(full, { recursive: true });
1134
+ fs.chmodSync(full, entry.mode);
1135
+ }
1136
+ // **A byte-for-byte copy carries a path that was only ever true on one machine.** Since
1137
+ // milestone 7 session 7 `init` drafts `verify/index.sh` with a third CLI location baked in —
1138
+ // the bundle it ran from, an absolute path git cannot carry — and the two lines holding it are
1139
+ // marked `# portulan:bundle-fallback` so a rewriter can find them rather than having to notice
1140
+ // a comment. This loop does **not** rewrite them, and a switched workspace whose bundle path
1141
+ // no longer resolves exits **2 — could not run**, which is the fail-closed direction.
1142
+ //
1143
+ // **The remedy exists elsewhere, and this loop deliberately does not call it.** Milestone 7
1144
+ // session 9 built `spec/migrations/0002-bundle-fallback-path.mjs`, run by `portulan upgrade`,
1145
+ // which re-derives every marked line for the bundle it is running from.
1146
+ //
1147
+ // **Ruled by the maintainer on 2026-08-12** — *vendor copies; upgrade repairs; vendor's
1148
+ // comment names the remedy. One tool, one job* — and closed as
1149
+ // https://github.com/sleepy-panda-srl/portulan/issues/230. So this is a decision rather
1150
+ // than a deferral, and the argument is worth keeping where the code is: a repair fixes a
1151
+ // value that *is not true where the workspace now is*, and after a same-machine switch — the
1152
+ // only kind this performs — **the baked path is still true**. This loop cannot observe the
1153
+ // event that invalidates it, and the correct post-travel value is derivable only by the
1154
+ // bundle running on the destination machine, which is what `upgrade` is. So copying and
1155
+ // repairing are not the same operation, and this is not a sibling site under `0020`.
1156
+ //
1157
+ // _(This comment claimed the issue was filed before it existed, until the pre-commit
1158
+ // checkpoint went looking for it.)_
1159
+ for (const file of files) {
1160
+ if (file.rel === "workspace.json") continue;
1161
+ const full = path.join(staging, file.rel);
1162
+ fs.mkdirSync(path.dirname(full), { recursive: true });
1163
+ fs.writeFileSync(full, fs.readFileSync(path.join(source, file.rel)));
1164
+ fs.chmodSync(full, file.mode);
1165
+ }
1166
+ // The README the pointer left behind said this repository's workspace lived elsewhere. It does
1167
+ // not any more, and a false sentence left in somebody's tree is this milestone's own recurring
1168
+ // defect — the one that shipped a claim about a rail into every adopter's drafted README.
1169
+ if (parsed.switching && carve.pointer && !rels.includes("README.md")) {
1170
+ fs.writeFileSync(path.join(staging, "README.md"), arrivedReadme(manifest.name));
1171
+ }
1172
+ fs.writeFileSync(path.join(staging, "workspace.json"), `${JSON.stringify(retargeted, null, 2)}\n`);
1173
+ if (hostFile !== null) {
1174
+ // Read rather than required: an installation missing its own kernel is a broken install and
1175
+ // that is a different problem, but refusing to vendor over it would help nobody. The
1176
+ // artifact says which of the two it is, so a reader never has to guess whether the absence
1177
+ // is deliberate.
1178
+ let kernel = null;
1179
+ try {
1180
+ kernel = fs.readFileSync(path.join(HERE, "..", "core", "engine.md"), "utf8");
1181
+ } catch {
1182
+ kernel = null;
1183
+ }
1184
+ fs.writeFileSync(path.join(staging, "..AGENTS.md.vendoring"), agentsMd(retargeted, parsed.host, kernel));
1185
+ }
1186
+ fault("materialise:files");
1187
+
1188
+ // ---- doctor the new residence, BEFORE it is one
1189
+ //
1190
+ // Standalone: the cross-repository refusal cannot pass here by construction, because at this
1191
+ // moment the old residence still governs — that pair IS the two-governor red. The both-ends
1192
+ // check with `--repo-root` runs after the flip, which is the first moment it can be satisfied.
1193
+
1194
+ // **`env` is threaded into every `verdict` call, and it stopped being optional hygiene on
1195
+ // 2026-08-13.** `verdict` calls `doctor`'s `inspect`, which builds its own resolution plan and
1196
+ // wires its own discovery thunk — so `vendor` acquired the unasked-discovery behaviour without a
1197
+ // line of it being edited here, and with `env` absent every run read the machine it happened to
1198
+ // be on. That is the hazard `vendor.test.mjs`'s own `green()` helper already names for the
1199
+ // pointer half: *the suite would behave one way on his laptop and another in CI.* Passed as part
1200
+ // of the doctor-options bundle so the two `verdict` sites below cannot disagree about it —
1201
+ // `oldResidence` was called with a bare `{}` and is exactly the sibling a fix at one site leaves
1202
+ // standing.
1203
+ const roots = {
1204
+ ...(options.env ? { env: options.env } : {}),
1205
+ ...(parsed.packRoots.length ? { packRoots: parsed.packRoots.map((r) => path.resolve(r)) } : {}),
1206
+ ...(parsed.discoverPacks ? { discoverPacks: true } : {}),
1207
+ };
1208
+ const staged = await verdict(staging, roots);
1209
+ if (staged.length) {
1210
+ say(`vendor: RED at the new residence — nothing was moved, and \`${manifest.name}\` still resides ${sourceResidence === "in-repo" ? "in its repository" : "feed-side"}.`);
1211
+ for (const f of staged) say(`vendor: ${f.check} ${f.message}`);
1212
+ say("vendor: the switch was refused before it began, which is the point of validating a copy before it is a residence.");
1213
+ await unwind(undo);
1214
+ return 1;
1215
+ }
1216
+
1217
+ // ---- the one rename, and the sentence that precedes it
1218
+ //
1219
+ // Printed BEFORE the window opens rather than after, because after is exactly when this process
1220
+ // may not be here to print anything.
1221
+
1222
+ if (parsed.switching) {
1223
+ // WHICH end to point `doctor` at is not a matter of taste, and this line had it INVERTED.
1224
+ // The cross-repository refusal runs from the **naming** workspace outward — 0017 says
1225
+ // visibility is one-way — so the only end that can see two governors is the FEED-SIDE one,
1226
+ // the workspace carrying the card that names the repository. Which end that is flips with
1227
+ // the direction: materialising feed-side, it is `dest`; materialising in-repo, the feed-side
1228
+ // workspace is the `source` this switch is moving out of. Pointing at the in-repo end sends
1229
+ // a reader to a run that finds only itself and reports nothing.
1230
+ //
1231
+ // Copilot's suppressed note found this and prescribed "always `dest`", which is right for one
1232
+ // direction and wrong for the other for the same reason the original was wrong.
1233
+ const visibleFrom = parsed.residence === "feed-side" ? dest : source;
1234
+ say(`vendor: materialising \`${manifest.name}\` at ${display(dest)}. For the next moment two workspaces govern ${path.basename(repoDir)};`);
1235
+ say(`vendor: if this run dies here, \`portulan doctor ${display(visibleFrom)} --repo-root ${display(path.dirname(repoDir))}\` says whether it did.`);
1236
+ // **Conditional, and the condition is load-bearing.** This said "removing <dest>/workspace.json
1237
+ // reverts it", full stop — an instruction that is only correct once the new manifest has
1238
+ // actually landed. Before that moment the file at that path is either absent or, switching
1239
+ // feed-side → in-repo, still the POINTER: deleting it there destroys the one record saying who
1240
+ // governs and leaves governance unreportable from the repository, which is the silent state
1241
+ // 0017 calls out by name. A recovery instruction is read exactly once, by someone with no time
1242
+ // to check it. Copilot's suppressed note, round 3 on #164.
1243
+ say(`vendor: ONLY if it reports two governors, remove ${display(path.join(dest, "workspace.json"))} — the manifest this run`);
1244
+ say("vendor: wrote — and the window closes. If it reports none, the window never opened: delete nothing.");
1245
+ }
1246
+
1247
+ const preserved = new Map();
1248
+ for (const rel of carve.allow) preserved.set(rel, fs.readFileSync(path.join(dest, rel)));
1249
+
1250
+ // **The undo is registered BEFORE the first byte moves, never after the last one.** Registering
1251
+ // it afterwards leaves every failure *between* the writes uncovered — and the gap was real: with
1252
+ // `--host`, `AGENTS.md` was renamed into place first and a failure in the very next rename rolled
1253
+ // back the staging directory while leaving the file behind, so a run that reported could-not-run
1254
+ // had still vendored half of something. Copilot, round 1 on #164. The closures below read
1255
+ // `written` by reference, so they cover exactly as much as has actually happened when they run.
1256
+ const written = [];
1257
+ const landed = carve.allow.size === 0 && !fs.existsSync(dest);
1258
+ undo.push(() => {
1259
+ // The manifest FIRST, always: it is what closes the window. Everything after it is cleanup,
1260
+ // and cleanup that throws must not leave a governor standing.
1261
+ fs.rmSync(path.join(dest, "workspace.json"), { force: true });
1262
+ if (hostFile !== null) fs.rmSync(hostFile, { force: true });
1263
+ if (landed) {
1264
+ // The destination did not exist before this run, so removing it restores the world.
1265
+ fs.rmSync(dest, { recursive: true, force: true });
1266
+ } else {
1267
+ for (const rel of written) if (!preserved.has(rel)) fs.rmSync(path.join(dest, rel), { force: true });
1268
+ for (const [rel, bytes] of preserved) fs.writeFileSync(path.join(dest, rel), bytes);
1269
+ // The DIRECTORIES the partial write created, too. Removing the files and leaving
1270
+ // `verify/` behind restores the destination's contents and not its shape — and the next
1271
+ // run's `carveOut` refuses it for holding something beside the pointer, so a rollback
1272
+ // that reported success wedges the retry it exists to make possible. That is `init`'s
1273
+ // partial write with the failure moved one layer out, and it is what "rolled back"
1274
+ // has to mean: entry for entry, not merely byte for byte. Copilot, round 6 on #164.
1275
+ pruneEmpty(dest);
1276
+ }
1277
+ });
1278
+
1279
+ if (landed) {
1280
+ // The destination does not exist: one atomic rename puts the whole workspace there, and
1281
+ // there is no half-populated state at all.
1282
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
1283
+ if (hostFile !== null) fs.renameSync(path.join(staging, "..AGENTS.md.vendoring"), hostFile);
1284
+ fs.renameSync(staging, dest);
1285
+ } else {
1286
+ // The destination exists — the switch's own feed-side → in-repo case, landing on the
1287
+ // pointer that is the old residence's in-repo half. Files first, manifest LAST: until the
1288
+ // manifest lands this is a directory of files, which 0017 says is not a residence.
1289
+ // Directories first here too, for the reason above and one more: the second leg of a
1290
+ // round trip lands on an existing destination, so this is the path a workspace takes home.
1291
+ for (const entry of directories(staging)) {
1292
+ fs.mkdirSync(path.join(dest, entry.rel), { recursive: true });
1293
+ fs.chmodSync(path.join(dest, entry.rel), entry.mode);
1294
+ }
1295
+ for (const file of walk(staging)) {
1296
+ if (file.rel === "workspace.json" || file.rel === "..AGENTS.md.vendoring") continue;
1297
+ const full = path.join(dest, file.rel);
1298
+ fs.mkdirSync(path.dirname(full), { recursive: true });
1299
+ fs.writeFileSync(full, fs.readFileSync(path.join(staging, file.rel)));
1300
+ fs.chmodSync(full, file.mode);
1301
+ written.push(file.rel);
1302
+ }
1303
+ if (hostFile !== null) fs.renameSync(path.join(staging, "..AGENTS.md.vendoring"), hostFile);
1304
+ fs.renameSync(path.join(staging, "workspace.json"), path.join(dest, "workspace.json"));
1305
+ }
1306
+ fs.rmSync(staging, { recursive: true, force: true });
1307
+ fault("materialise:manifest");
1308
+
1309
+ if (!parsed.switching) {
1310
+ say(`vendor: wrote ${files.length + (hostFile ? 1 : 0)} file(s) — the \`${manifest.name}\` workspace at ${display(dest)}${hostFile ? `, and ${display(hostFile)}` : ""}.`);
1311
+ say(`vendor: the source at ${display(source)} is untouched and still governs. This is a rendering, not a move.`);
1312
+ say(`vendor: nothing compiled — run \`portulan compile\` there to turn the gate policy into host enforcement.`);
1313
+ return 0;
1314
+ }
1315
+
1316
+ // ---- retire the old residence's MANIFEST, which is the act that transfers governance
1317
+ //
1318
+ // First, and by rename where there is something to rename: everything after this is cleanup,
1319
+ // and cleanup that fails must not be able to mint a second governor. The reverse order — files
1320
+ // first — would leave a governing manifest over a workspace with holes in it.
1321
+
1322
+ const oldManifest = path.join(oldResidence, "workspace.json");
1323
+ if (leave === "pointer") {
1324
+ const staged2 = `${oldManifest}.vendoring`;
1325
+ // Registered BEFORE the write, for the same reason the destination's undo is — and this is
1326
+ // the SIBLING of the leak round 1 fixed one function over, missed in the fix for it, which
1327
+ // is issue #91's class exactly. A failure in the rename below leaves this temp file in a
1328
+ // residence a later run will walk, where it reads as an unaccounted leftover and blocks the
1329
+ // very cleanup it came from. Copilot's suppressed notes, round 2 on #164.
1330
+ undo.push(() => fs.rmSync(staged2, { force: true }));
1331
+ fs.writeFileSync(staged2, `${JSON.stringify(pointerManifest(oldResidence, manifest.name, parsed.feed), null, 2)}\n`);
1332
+ fs.renameSync(staged2, oldManifest);
1333
+ } else {
1334
+ fs.rmSync(oldManifest, { force: true });
1335
+ }
1336
+ pastTheFlip = true;
1337
+ undo.length = 0;
1338
+ fault("retire:manifest");
1339
+
1340
+ // ---- green at BOTH ends, which is the first moment the contract's check can be satisfied
1341
+
1342
+ const bothRoots = { ...roots, ...(parsed.repoRoots.length ? { repoRoots: parsed.repoRoots.map((r) => path.resolve(r)) } : {}) };
1343
+ const newEnd = await verdict(dest, bothRoots);
1344
+ // `env` and nothing else: the old residence is graded as a POINTER, so its pack roots are not
1345
+ // the question — but its `governed_by` is dereferenced against the host's record, and the
1346
+ // injection seam has to reach here too. It was a bare `{}`.
1347
+ const oldEnd =
1348
+ leave === "pointer"
1349
+ ? await verdict(oldResidence, { ...(options.env ? { env: options.env } : {}) })
1350
+ : fs.existsSync(oldManifest)
1351
+ ? [{ check: "residence", message: `a manifest still stands at ${display(oldManifest)}` }]
1352
+ : [];
1353
+ if (newEnd.length || oldEnd.length) {
1354
+ // Past the flip. Rolling back would re-open the window in the other direction, so this goes
1355
+ // forward and reports: exactly one workspace governs, and it is the new one. The old
1356
+ // residence's material is NOT retired, which is the contract's own ordering doing its job.
1357
+ say(`vendor: governance has moved to ${display(dest)}, and \`doctor\` is RED at ${newEnd.length ? "the new" : "the old"} end.`);
1358
+ for (const f of [...newEnd, ...oldEnd]) say(`vendor: ${f.check} ${f.message}`);
1359
+ say(`vendor: the old residence's files were NOT removed — green at both ends comes before retirement, and it was not green.`);
1360
+ say(`vendor: exactly one workspace governs ${path.basename(repoDir)}. Fix what is named above, then re-run \`doctor\` at both ends.`);
1361
+ return 1;
1362
+ }
1363
+
1364
+ // ---- retire the old residence's material — only what this run can account for
1365
+
1366
+ // `compile`'s own artifacts are retired with the residence that produced them. They are the one
1367
+ // class this deletes without having copied it, and the licence is `compile`'s own sentence:
1368
+ // deleting a generated file the compiler wrote is reproducible by definition. Leaving them
1369
+ // would be leaving compiled enforcement beside a pointer.
1370
+ const moved = new Set([...rels, ...generated]);
1371
+ const leftovers = [];
1372
+ let remaining = [];
1373
+ let unscannable = null;
1374
+ try {
1375
+ remaining = walk(oldResidence);
1376
+ } catch (cause) {
1377
+ // **A scan that failed is not an empty old residence.** Swallowing it made `leftovers` empty,
1378
+ // which under `--leave nothing` reached `rmSync(oldResidence, {recursive: true})` and deleted
1379
+ // files this run could not account for — the exact opposite of the sentence two lines up, in
1380
+ // the branch where being wrong is unrecoverable. Copilot, round 1 on #164, and it is this
1381
+ // repository's own fail-open shape: a question that could not be answered, answered *nothing
1382
+ // there*. Governance has already moved, so stopping here is safe; deleting was never safe.
1383
+ unscannable = cause;
1384
+ }
1385
+ for (const file of remaining) {
1386
+ if (file.rel === "workspace.json") continue;
1387
+ if (leave === "pointer" && file.rel === "README.md" && !moved.has("README.md")) continue;
1388
+ if (!moved.has(file.rel)) {
1389
+ leftovers.push(file.rel);
1390
+ continue;
1391
+ }
1392
+ fs.rmSync(path.join(oldResidence, file.rel), { force: true });
1393
+ }
1394
+ if (unscannable === null) pruneEmpty(oldResidence);
1395
+ if (leave === "pointer") {
1396
+ fs.writeFileSync(path.join(oldResidence, "README.md"), pointerReadme(manifest.name, parsed.feed, display(dest)));
1397
+ } else if (leftovers.length === 0 && unscannable === null) {
1398
+ fs.rmSync(oldResidence, { recursive: true, force: true });
1399
+ }
1400
+ fault("retire:material");
1401
+
1402
+ say(`vendor: switched \`${manifest.name}\` — ${sourceResidence} → ${parsed.residence}.`);
1403
+ say(`vendor: resides at ${display(dest)}, green.`);
1404
+ if (unscannable !== null) {
1405
+ // The switch itself is complete — governance moved when the manifest did — and only the
1406
+ // cleanup is unfinished. Said as its own line rather than folded into the success sentence,
1407
+ // because "switched" and "and the old residence is tidy" are two different claims.
1408
+ say(`vendor: the old residence at ${display(oldResidence)} could NOT be scanned — ${unscannable.message}`);
1409
+ say("vendor: so nothing there was removed. It no longer governs (its manifest is gone or is a pointer),");
1410
+ say("vendor: and nothing here deletes files it could not account for. Clear it by hand when you can read it.");
1411
+ } else if (leave === "pointer") {
1412
+ say(`vendor: a pointer at ${display(oldResidence)}, green.`);
1413
+ } else if (leftovers.length) {
1414
+ // NOT "nothing left", which is what this said while the next line listed what was left.
1415
+ // `--leave nothing` is an instruction about the *residence*, and it was honoured — the
1416
+ // manifest is gone and nothing governs from there. It is not a promise about the directory,
1417
+ // because this never deletes a file it cannot account for. Two claims, and only one of them
1418
+ // is true here. Copilot's suppressed notes on #164.
1419
+ say(`vendor: no workspace at ${display(oldResidence)} — its manifest is retired and it governs nothing.`);
1420
+ } else {
1421
+ say(`vendor: nothing left at ${display(oldResidence)}.`);
1422
+ }
1423
+ if (leftovers.length) {
1424
+ say(`vendor: ${leftovers.length} file(s) at the old residence were not moved by this run and were left alone: ${leftovers.join(", ")}.`);
1425
+ say("vendor: nothing here deletes a file it cannot account for — they are yours to keep or remove.");
1426
+ }
1427
+ if (generated.length) {
1428
+ say(`vendor: ${generated.length} compiled artifact(s) did not travel and were retired with the old residence: ${generated.join(", ")}.`);
1429
+ say("vendor: enforcement is keyed to the residence — where a settings file lands differs between the two — so a copy would name paths for the residence it left.");
1430
+ }
1431
+ if (sourceResidence === "in-repo") {
1432
+ // Outside the workspace directory, so outside what this tool writes. Named rather than
1433
+ // reached for: a tool that starts deleting beyond the directory it was given is the tool
1434
+ // that eventually deletes the wrong thing.
1435
+ say(`vendor: \`${display(path.join(path.dirname(source), ".claude", "settings.json"))}\` — if it exists — was compiled from the policy that has just moved, and nothing here writes outside ${display(source)}. It is yours to remove.`);
1436
+ }
1437
+ if (!parsed.repoRoots.length && cards.length) {
1438
+ // A check that vanishes without a word is the fail-open this repository has recorded more
1439
+ // than any other. `doctor` says this where it runs; this says it where the switch happened.
1440
+ say(`vendor: no --repo-root was given, so the cross-repository half of "green at both ends" reported rather than checked.`);
1441
+ }
1442
+ say(`vendor: nothing compiled — run \`portulan compile\` against the new residence.`);
1443
+ return 0;
1444
+ } catch (error) {
1445
+ if (!pastTheFlip) await unwind(undo);
1446
+ const message = error instanceof VendorError ? error.message : `could not run — ${error.code === "EIO" ? error.message : (error.stack ?? error)}`;
1447
+ warn(`vendor: ${message}`);
1448
+ if (pastTheFlip) {
1449
+ warn("vendor: governance had already moved when this failed, so nothing was rolled back — undoing it would re-open the window in the other direction.");
1450
+ warn("vendor: exactly one workspace governs. Run `doctor` at both ends to see where it stands.");
1451
+ }
1452
+ return 2;
1453
+ }
1454
+ }
1455
+
1456
+ /** The failing findings the real validator returns — never a second opinion about what valid means. */
1457
+ async function verdict(dir, roots) {
1458
+ try {
1459
+ const { findings } = await inspect(dir, roots);
1460
+ return findings.filter((f) => f.severity === "fail");
1461
+ } catch (error) {
1462
+ return [{ check: "doctor", message: `could not judge ${display(dir)} — ${error.message}` }];
1463
+ }
1464
+ }
1465
+
1466
+ /** Runs the undo steps newest-first, and never lets a failed undo mask the failure that caused it. */
1467
+ async function unwind(undo) {
1468
+ while (undo.length) {
1469
+ const step = undo.pop();
1470
+ try {
1471
+ step();
1472
+ } catch {
1473
+ /* best effort: the message about the original failure is worth more than this one */
1474
+ }
1475
+ }
1476
+ }
1477
+
1478
+ /** Removes directories this run emptied, bottom-up, and never one that still holds something. */
1479
+ function pruneEmpty(root) {
1480
+ const walkDown = (dir) => {
1481
+ let entries;
1482
+ try {
1483
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1484
+ } catch {
1485
+ return;
1486
+ }
1487
+ for (const entry of entries) if (entry.isDirectory()) walkDown(path.join(dir, entry.name));
1488
+ if (dir !== root) {
1489
+ try {
1490
+ if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
1491
+ } catch {
1492
+ /* a directory that will not go is one somebody still has something in */
1493
+ }
1494
+ }
1495
+ };
1496
+ walkDown(path.resolve(root));
1497
+ }
1498
+
1499
+ // The `?? ""` is not decoration and every sibling tool here carries it: `process.argv[1]` is absent when
1500
+ // this module is imported by something that is not a script, and `pathToFileURL(undefined)` throws at
1501
+ // module load.
1502
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
1503
+ process.exitCode = await run(process.argv.slice(2));
1504
+ }