@sleepy_panda_srl/portulan 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +6 -5
  2. package/cli/README.md +142 -358
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1322 -1607
  6. package/cli/context.mjs +883 -0
  7. package/cli/control-chars.mjs +12 -301
  8. package/cli/discover.mjs +17 -574
  9. package/cli/doctor.mjs +224 -1074
  10. package/cli/drills.mjs +68 -502
  11. package/cli/feedback.mjs +16 -237
  12. package/cli/finish.mjs +437 -0
  13. package/cli/form.mjs +644 -0
  14. package/cli/fuzz-shell.mjs +20 -305
  15. package/cli/gate.mjs +7 -199
  16. package/cli/goldens.mjs +26 -184
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +243 -566
  19. package/cli/inside.mjs +3 -28
  20. package/cli/instructions.mjs +829 -0
  21. package/cli/ledger.mjs +778 -0
  22. package/cli/librarian.mjs +135 -599
  23. package/cli/manifest.mjs +2 -39
  24. package/cli/mutants.mjs +9 -170
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +5 -74
  27. package/cli/pack-version.mjs +13 -255
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +5 -124
  30. package/cli/recipe-set.mjs +15 -245
  31. package/cli/release-eval.mjs +28 -348
  32. package/cli/review-meter.mjs +17 -258
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +10 -176
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +83 -514
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +27 -492
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +8 -86
  43. package/core/README.md +1 -1
  44. package/core/engine.md +3 -9
  45. package/core/operating/autonomy.md +1 -1
  46. package/core/operating/context.md +183 -0
  47. package/core/operating/evolution.md +3 -3
  48. package/core/operating/loop.md +13 -20
  49. package/core/operating/memory.md +12 -3
  50. package/core/operating/sessions.md +100 -0
  51. package/core/personas/librarian.md +2 -2
  52. package/core/skills/consolidate/SKILL.md +4 -2
  53. package/core/templates/handoff.md +28 -15
  54. package/core/templates/memory-entry.md +1 -0
  55. package/evals/README.md +255 -845
  56. package/evals/releases/0.2.0.json +146 -0
  57. package/evals/releases/0.2.0.md +67 -0
  58. package/evals/releases/README.md +3 -3
  59. package/package.json +4 -2
  60. package/packs/rituals/checkpoints/README.md +1 -1
  61. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  62. package/spec/README.md +112 -6
  63. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  64. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  65. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  66. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  67. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  68. package/spec/migrations/0006-boot-card.mjs +77 -0
  69. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  70. package/spec/migrations/0008-card-reading.mjs +55 -0
  71. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  72. package/spec/migrations/0010-comment-rail.mjs +57 -0
  73. package/spec/migrations/README.md +45 -10
  74. package/spec/pack.schema.json +1 -1
  75. package/spec/slots.md +333 -21
  76. package/spec/workspace.schema.json +145 -4
package/cli/vendor.mjs CHANGED
@@ -1,82 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  // `vendor` — materialise a workspace where it is needed, in either direction.
3
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.
4
+ // Refuse an existing file, refuse any link at or below the destination or in the source, and treat only ENOENT as absent.
5
+ // Exit 0 done · 1 `doctor` red at an end · 2 could not run.
69
6
 
70
7
  import fs from "node:fs";
71
8
  import path from "node:path";
72
9
  import { fileURLToPath, pathToFileURL } from "node:url";
73
10
 
11
+ import { agentsMdGuidance, BOOT_CARD_UNIT, CompileError, compileGuidance, GENERATED_DIRS, guidanceUnits } from "./compile.mjs";
12
+ import { changesReadme, CHANGES_README, handoffIndexIgnore, withIgnoreLines } from "./form.mjs";
74
13
  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
14
  import { AUTO, namedWithAuto } from "./discover.mjs";
81
15
  import { recipeSet } from "./recipe-set.mjs";
82
16
 
@@ -85,18 +19,13 @@ const HERE = path.dirname(fileURLToPath(import.meta.url));
85
19
  /** Everything that means `vendor` could not run. Carries no verdict about a workspace. */
86
20
  export class VendorError extends Error {}
87
21
 
88
- /** The two residences. Named, never inferred from a path — `init`'s rule, and for `init`'s reason. */
89
22
  export const RESIDENCES = new Set(["in-repo", "feed-side"]);
90
23
 
91
- /** What a switch may leave behind, in 0017's own words: "a pointer, or nothing". */
92
24
  export const LEAVE = new Set(["pointer", "nothing"]);
93
25
 
94
- /** The Workspace Definition this tool writes against. A pointer needs 2.7 — the version that added it. */
26
+ /** The Workspace Definition version written; 2.7 is the first with pointers. */
95
27
  const SPEC = "2.7";
96
28
 
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
29
  let SCHEMA_ERROR = null;
101
30
  const SLUG = (() => {
102
31
  try {
@@ -108,23 +37,9 @@ const SLUG = (() => {
108
37
  }
109
38
  })();
110
39
 
111
- /** The governing kinds, in the order a reader would meet them. `pointer` is not one — it governs nothing. */
112
40
  const GOVERNING_KINDS = ["repository", "demo", "portfolio"];
113
41
 
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"]);
42
+ const GENERATED = new Set(GENERATED_DIRS);
128
43
 
129
44
  const isGenerated = (rel) => GENERATED.has(rel.split("/")[0]);
130
45
 
@@ -132,15 +47,6 @@ const isGenerated = (rel) => GENERATED.has(rel.split("/")[0]);
132
47
 
133
48
  const VALUED = new Set(["--into", "--residence", "--leave", "--host", "--kind", "--feed", "--pack-root", "--repo-root"]);
134
49
 
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
50
  export function parseArgs(argv) {
145
51
  const out = {
146
52
  help: false,
@@ -181,8 +87,6 @@ export function parseArgs(argv) {
181
87
  continue;
182
88
  }
183
89
  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
90
  throw new VendorError(
187
91
  `unknown option \`${arg}\` — run \`portulan vendor --help\` for the ones this understands, ` +
188
92
  `or \`node cli/vendor.mjs --help\` from a checkout`,
@@ -195,8 +99,7 @@ export function parseArgs(argv) {
195
99
  if (value.trim() === "") {
196
100
  throw new VendorError(`\`${arg}\` was given an empty value, and no option here has a meaningful empty value`);
197
101
  }
198
- // `auto` matched raw so `./auto` still names a directory. This tool only forwards roots to
199
- // `doctor`, so it forwards the request too.
102
+ // Matched raw, so `./auto` still names a directory.
200
103
  if (arg === "--pack-root") {
201
104
  if (value === AUTO) out.discoverPacks = true;
202
105
  else out.packRoots.push(value);
@@ -213,10 +116,7 @@ export function parseArgs(argv) {
213
116
  `and picking one of two would be choosing which one gets moved`,
214
117
  );
215
118
  }
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.
119
+ // Refused here, not left to `doctor`, so it reads as a command-line error rather than a verdict on the copy.
220
120
  const bothAsked = namedWithAuto(out.packRoots, out.discoverPacks === true);
221
121
  if (bothAsked) throw new VendorError(bothAsked);
222
122
  out.source = positional[0] ?? null;
@@ -225,26 +125,12 @@ export function parseArgs(argv) {
225
125
 
226
126
  // ------------------------------------------------------------------------- the residence, and the two keys
227
127
 
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
- */
128
+ /** Keyed on `tree` alone, never on `kind`: a manifest where the two disagree is `doctor`'s to report. */
236
129
  export function residenceOf(manifest) {
237
130
  return manifest?.tree === undefined ? "feed-side" : "in-repo";
238
131
  }
239
132
 
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
- */
133
+ /** Changes exactly two keys, `kind` and `tree`, and keeps every other key in its order. */
248
134
  export function retarget(manifest, residence, kindOf = null, tree = "../") {
249
135
  if (kindOf !== null && !GOVERNING_KINDS.includes(kindOf)) {
250
136
  throw new VendorError(
@@ -262,22 +148,12 @@ export function retarget(manifest, residence, kindOf = null, tree = "../") {
262
148
  out.kind = kindOf ?? "repository";
263
149
  out.tree = tree;
264
150
  } 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.
151
+ // A `demo` already declares no `tree`, so it moves feed-side as a demo.
267
152
  out.kind = kindOf ?? (manifest.kind === "demo" ? "demo" : "portfolio");
268
153
  }
269
154
  return out;
270
155
  }
271
156
 
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
157
  export function escapingSlots(manifest, dir) {
282
158
  const root = path.resolve(dir);
283
159
  const out = [];
@@ -291,37 +167,16 @@ export function escapingSlots(manifest, dir) {
291
167
 
292
168
  // ------------------------------------------------------------------------- reading the source
293
169
 
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
- */
170
+ /** Every ordinary file under `dir`, with its mode; throws a `VendorError` on a link, a special file, or anything unreadable. */
302
171
  export function walk(dir) {
303
172
  return scan(dir).files;
304
173
  }
305
174
 
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
- */
175
+ /** Every directory under `dir`, empty ones included: a declared slot may be an empty directory. */
320
176
  export function directories(dir) {
321
177
  return scan(dir).dirs;
322
178
  }
323
179
 
324
- /** One descent, two answers, so the guards below cannot come to differ between them. */
325
180
  function scan(dir) {
326
181
  const root = path.resolve(dir);
327
182
  const out = [];
@@ -342,7 +197,6 @@ function scan(dir) {
342
197
  try {
343
198
  stat = fs.lstatSync(seen(childRel));
344
199
  } catch (cause) {
345
- // Only ENOENT means absent, and here even that is a surprise: the entry was just listed.
346
200
  if (cause.code === "ENOENT") continue;
347
201
  throw new VendorError(`${seen(childRel)} could not be examined — ${cause.code ?? cause.message}. An unanswerable question is not an absence`);
348
202
  }
@@ -371,16 +225,7 @@ function scan(dir) {
371
225
 
372
226
  // ------------------------------------------------------------------------- writing into somebody's tree
373
227
 
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
- */
228
+ /** The steps from `root` down to `target`, both included: a link above `root`, such as macOS's `/var`, is the user's own. */
384
229
  function chain(target, root) {
385
230
  const steps = [];
386
231
  let at = path.resolve(target);
@@ -395,17 +240,7 @@ function chain(target, root) {
395
240
  return steps.reverse();
396
241
  }
397
242
 
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
- */
243
+ /** Destination paths that cannot be written: already there, behind a link, or in a state that could not be read. */
409
244
  export function collisions(destDir, rels, { lstat = fs.lstatSync, allow = new Set() } = {}) {
410
245
  const found = [];
411
246
  for (const rel of rels) {
@@ -415,10 +250,7 @@ export function collisions(destDir, rels, { lstat = fs.lstatSync, allow = new Se
415
250
  try {
416
251
  stat = lstat(step);
417
252
  } 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.
253
+ // Nothing can exist below an absent path, so the rest of the chain is clear.
422
254
  if (cause.code === "ENOENT") break;
423
255
  found.push({ rel, path: step, why: `${step} could not be examined (${cause.code ?? cause.message})` });
424
256
  break;
@@ -428,22 +260,11 @@ export function collisions(destDir, rels, { lstat = fs.lstatSync, allow = new Se
428
260
  break;
429
261
  }
430
262
  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
263
  if (stat.isDirectory()) {
438
264
  found.push({ rel, path: step, why: `${step} is a directory, and a file has to be written there` });
439
265
  break;
440
266
  }
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.
267
+ // Before `allow`: the carve-out exempts replacing a file, never a FIFO, a socket or a device.
447
268
  if (!stat.isFile()) {
448
269
  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
270
  break;
@@ -461,34 +282,16 @@ export function collisions(destDir, rels, { lstat = fs.lstatSync, allow = new Se
461
282
  return found;
462
283
  }
463
284
 
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
- */
285
+ /** What a switch's destination may already hold: a pointer naming the incoming workspace, and its README. */
473
286
  function carveOut(destDir, incomingName) {
474
287
  const manifestPath = path.join(destDir, "workspace.json");
475
288
 
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.
289
+ // `lstat` before any read: `readdirSync` and `readFileSync` follow links out of the named tree.
486
290
  for (const step of [destDir, manifestPath]) {
487
291
  let stat;
488
292
  try {
489
293
  stat = fs.lstatSync(step);
490
294
  } catch (cause) {
491
- // Only ENOENT means absent, and an absent destination has nothing to carve out.
492
295
  if (cause.code === "ENOENT") return { allow: new Set(), pointer: null };
493
296
  throw new VendorError(`${step} could not be examined — ${cause.code ?? cause.message}. Only a missing path means "nothing there"`);
494
297
  }
@@ -525,20 +328,7 @@ function carveOut(destDir, incomingName) {
525
328
  return { allow: new Set(), pointer: null };
526
329
  }
527
330
  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.)
331
+ // Quoted with `JSON.stringify` in both arms: the value comes from a manifest nobody validated.
542
332
  const declared = typeof manifest.governed_by?.workspace === "string" && manifest.governed_by.workspace.trim() !== "";
543
333
  throw new VendorError(
544
334
  declared
@@ -565,20 +355,11 @@ function carveOut(destDir, incomingName) {
565
355
 
566
356
  // ------------------------------------------------------------------------- what a pointer says
567
357
 
568
- /** A path as a reader would recognise it — relative to cwd when that is shorter, absolute otherwise. */
569
358
  function display(target) {
570
359
  const rel = path.relative(process.cwd(), target);
571
360
  return rel && !rel.startsWith("..") && rel.length < target.length ? rel : target;
572
361
  }
573
362
 
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
363
  function pointerName(residenceDir) {
583
364
  const base = path.basename(residenceDir);
584
365
  const source = base.startsWith(".") ? path.basename(path.dirname(residenceDir)) : base;
@@ -596,11 +377,7 @@ function pointerManifest(residenceDir, governor, feed) {
596
377
  return {
597
378
  portulan: { spec: SPEC },
598
379
  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.
380
+ // Residence-agnostic: a pointer is left in a repository or in a retired feed slot.
604
381
  summary: `Governed by the \`${governor}\` workspace, which resides elsewhere. This directory holds a pointer and no policy layer of its own.`,
605
382
  kind: "pointer",
606
383
  governed_by: feed ? { workspace: governor, feed } : { workspace: governor },
@@ -626,7 +403,6 @@ that never adopted Portulan.
626
403
  `;
627
404
  }
628
405
 
629
- /** What lands where a pointer used to be, once the workspace itself has arrived. */
630
406
  function arrivedReadme(name) {
631
407
  return `# ${name} — resident here
632
408
 
@@ -644,23 +420,9 @@ workspace's gate policy, and no copy of files produces them. Run \`portulan comp
644
420
 
645
421
  // ------------------------------------------------------------------------- the vendored standards file
646
422
 
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) {
423
+ /** `AGENTS.md` for a host that is not Claude Code, naming only what the manifest declares. */
424
+ export function agentsMd(manifest, host, kernel = null, guidance = null) {
425
+ const card = guidance?.units.find((u) => u.name === BOOT_CARD_UNIT) ?? null;
664
426
  const lines = [
665
427
  `# AGENTS.md — ${manifest.name}`,
666
428
  "",
@@ -668,18 +430,34 @@ export function agentsMd(manifest, host, kernel = null) {
668
430
  "",
669
431
  `Vendored by \`portulan vendor --host ${host}\` from the \`${manifest.name}\` workspace. This file and the`,
670
432
  "`.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",
433
+ ...(card
434
+ ? [
435
+ "the trust path. The card below is this team's boot, and the engine's kernel ends this file: an agent on",
436
+ "this host reads both, and opens a file the card names when that file's subject is the task.",
437
+ ]
438
+ : ["the trust path. An agent on this host reads the files named below and works this team's way."]),
674
439
  "",
675
440
  ];
441
+ let cardText = null;
442
+ if (card) {
443
+ [cardText] = agentsMdGuidance({ ...guidance, units: [card] }, `.portulan/${manifest.slots.context}`).inline;
444
+ lines.push(cardText.trimEnd(), "", "## The workspace's files, opened when the card or the task sends you to one", "");
445
+ } else {
446
+ lines.push("## Read these, in this order", "");
447
+ }
676
448
  const GLOSS = {
677
449
  identity: "who this team is, the stack, and the glossary",
678
450
  principles: "the rules that are this team's rather than everyone's",
679
451
  gates: "which concrete actions are Auto, Propose, Gated or Prohibited",
680
452
  dod: "what *done* means here, beyond a green verify",
681
453
  constitution: "the team's ground truth, which outranks everything else here",
454
+ context: "this team's guidance, one unit per file in its load tier; *Guidance* below carries the always tier and names the rest",
682
455
  };
456
+ if (card) {
457
+ GLOSS.context = guidance.units.length > 1
458
+ ? "this team's guidance, one unit per file in its load tier: the card above is its boot, and *Guidance* below carries the rest of the always tier and names the others"
459
+ : "this team's guidance: the card above is its boot";
460
+ }
683
461
  const slots = Object.entries(manifest.slots ?? {});
684
462
  if (slots.length === 0) {
685
463
  lines.push("_This workspace declares no slots. There is nothing for an agent to read here yet._", "");
@@ -690,27 +468,18 @@ export function agentsMd(manifest, host, kernel = null) {
690
468
  lines.push("");
691
469
  }
692
470
 
471
+ const rest = guidance ? guidance.units.filter((u) => u !== card) : [];
472
+ if (rest.length) {
473
+ const { inline, pointers } = agentsMdGuidance({ ...guidance, units: rest }, `.portulan/${manifest.slots.context}`);
474
+ lines.push("## Guidance", "");
475
+ for (const body of inline) lines.push(body.trimEnd(), "");
476
+ if (pointers.length) lines.push("Open each of these when it applies; each is one file:", "", ...pointers, "");
477
+ }
478
+
693
479
  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.
480
+ // The workspace's own recipes only: a vendored host cannot resolve a pack, so a pack's recipe could not run there.
708
481
  const composed = (manifest.packs ?? []).length > 0;
709
482
  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
483
  const recipes = set.ok ? set.recipes : [];
715
484
  if (!set.ok) {
716
485
  lines.push(
@@ -765,6 +534,9 @@ export function agentsMd(manifest, host, kernel = null) {
765
534
  "- **Compiled host enforcement.** `portulan compile` turns the gate policy into a host's own settings and",
766
535
  " hooks; copying files produces none of it. Until it is run, every tier above is a rule nothing checks.",
767
536
  "- **A resolved pointer.** Nothing here fetches anything.",
537
+ ...(cardText?.includes("<plugin root>")
538
+ ? ["- **The Portulan package.** The card's `<plugin root>` is where it is installed, which this copy is not."]
539
+ : []),
768
540
  "",
769
541
  "Run `portulan doctor .portulan` to see where this stands.",
770
542
  "",
@@ -837,15 +609,7 @@ export function usage() {
837
609
  ].join("\n");
838
610
  }
839
611
 
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
- */
612
+ /** Refuses before the first byte, and orders every write so a handled failure leaves exactly one governing workspace. */
849
613
  export async function run(argv, options = {}) {
850
614
  const say = options.say ?? ((line = "") => process.stdout.write(`${line}\n`));
851
615
  const warn = options.warn ?? ((line) => process.stderr.write(`${line}\n`));
@@ -857,7 +621,6 @@ export async function run(argv, options = {}) {
857
621
  }
858
622
  };
859
623
 
860
- /** Undo steps, newest first. Registered only while a rollback is still the right answer. */
861
624
  const undo = [];
862
625
  let pastTheFlip = false;
863
626
 
@@ -896,18 +659,6 @@ export async function run(argv, options = {}) {
896
659
  );
897
660
  }
898
661
  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
662
  throw new VendorError(
912
663
  "`--host` vendors the pair a host reads — `AGENTS.md` beside a `.portulan/` at the root of a tree — so it goes " +
913
664
  "with `--residence in-repo`. A feed-side workspace is delivered as a plugin rather than read out of a directory, " +
@@ -965,9 +716,6 @@ export async function run(argv, options = {}) {
965
716
  const sourceResidence = residenceOf(manifest);
966
717
  const dest = path.resolve(parsed.into);
967
718
 
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
719
  if (parsed.residence === "in-repo" && path.basename(dest) !== ".portulan") {
972
720
  throw new VendorError(
973
721
  `an in-repo residence is \`<repository>/.portulan\`, and \`--into ${parsed.into}\` ends in \`${path.basename(dest)}\`. ` +
@@ -987,6 +735,16 @@ export async function run(argv, options = {}) {
987
735
  );
988
736
  }
989
737
 
738
+ let guidance = null;
739
+ if (parsed.host !== null) {
740
+ try {
741
+ guidance = guidanceUnits(source, ".");
742
+ } catch (error) {
743
+ if (error instanceof CompileError) throw new VendorError(error.message);
744
+ throw error;
745
+ }
746
+ }
747
+
990
748
  // ---- the scope bound, refused in both its shapes
991
749
 
992
750
  const cards = [];
@@ -1028,20 +786,7 @@ export async function run(argv, options = {}) {
1028
786
  const rels = files.map((f) => f.rel);
1029
787
  const hostFile = parsed.host === null ? null : path.join(path.dirname(dest), "AGENTS.md");
1030
788
 
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
789
  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
790
  if (synthesised.length && sourceDirs.some((d) => d.rel === "README.md")) {
1046
791
  throw new VendorError(
1047
792
  `${display(source)} holds a DIRECTORY named \`README.md\`, and this switch needs to write a file there — the ` +
@@ -1052,9 +797,6 @@ export async function run(argv, options = {}) {
1052
797
  const clash = collisions(dest, [...rels, ...synthesised], { allow: carve.allow });
1053
798
  if (hostFile !== null) clash.push(...collisions(path.dirname(dest), ["AGENTS.md"]));
1054
799
  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
800
  const byCause = new Map();
1059
801
  for (const item of clash) {
1060
802
  const key = item.why === "already exists" ? "already exists" : item.why;
@@ -1091,19 +833,9 @@ export async function run(argv, options = {}) {
1091
833
  }
1092
834
 
1093
835
  // ---- 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
836
 
1100
837
  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.
838
+ // `lstatSync`, not `existsSync`, which answers false on EACCES.
1107
839
  try {
1108
840
  fs.lstatSync(staging);
1109
841
  throw new VendorError(
@@ -1122,40 +854,13 @@ export async function run(argv, options = {}) {
1122
854
  fs.mkdirSync(staging, { recursive: true });
1123
855
  undo.push(() => fs.rmSync(staging, { recursive: true, force: true }));
1124
856
 
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
857
  for (const entry of sourceDirs) {
1131
858
  if (isGenerated(entry.rel)) continue;
1132
859
  const full = path.join(staging, entry.rel);
1133
860
  fs.mkdirSync(full, { recursive: true });
1134
861
  fs.chmodSync(full, entry.mode);
1135
862
  }
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.)_
863
+ // Copied byte for byte: `# portulan:bundle-fallback` lines are not rewritten here; `portulan upgrade` re-derives them.
1159
864
  for (const file of files) {
1160
865
  if (file.rel === "workspace.json") continue;
1161
866
  const full = path.join(staging, file.rel);
@@ -1163,43 +868,26 @@ export async function run(argv, options = {}) {
1163
868
  fs.writeFileSync(full, fs.readFileSync(path.join(source, file.rel)));
1164
869
  fs.chmodSync(full, file.mode);
1165
870
  }
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
871
  if (parsed.switching && carve.pointer && !rels.includes("README.md")) {
1170
872
  fs.writeFileSync(path.join(staging, "README.md"), arrivedReadme(manifest.name));
1171
873
  }
1172
874
  fs.writeFileSync(path.join(staging, "workspace.json"), `${JSON.stringify(retargeted, null, 2)}\n`);
1173
875
  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.
876
+ // Not required: a missing kernel is said in the file rather than refusing the run.
1178
877
  let kernel = null;
1179
878
  try {
1180
879
  kernel = fs.readFileSync(path.join(HERE, "..", "core", "engine.md"), "utf8");
1181
880
  } catch {
1182
881
  kernel = null;
1183
882
  }
1184
- fs.writeFileSync(path.join(staging, "..AGENTS.md.vendoring"), agentsMd(retargeted, parsed.host, kernel));
883
+ fs.writeFileSync(path.join(staging, "..AGENTS.md.vendoring"), agentsMd(retargeted, parsed.host, kernel, guidance));
1185
884
  }
1186
885
  fault("materialise:files");
1187
886
 
1188
887
  // ---- 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.
888
+
889
+ // No `--repo-root` yet: the old residence still governs, so the cross-repository check cannot pass before the flip.
890
+ // Every `verdict` gets `env`: `doctor` discovers packs through it, and without it would read whatever machine it runs on.
1203
891
  const roots = {
1204
892
  ...(options.env ? { env: options.env } : {}),
1205
893
  ...(parsed.packRoots.length ? { packRoots: parsed.packRoots.map((r) => path.resolve(r)) } : {}),
@@ -1215,31 +903,14 @@ export async function run(argv, options = {}) {
1215
903
  }
1216
904
 
1217
905
  // ---- 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
906
 
907
+ // Printed before the window opens: after it, this process may not be alive to print.
1222
908
  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.
909
+ // Only the feed-side end sees two governors: visibility runs one way, out from the naming workspace.
1233
910
  const visibleFrom = parsed.residence === "feed-side" ? dest : source;
1234
911
  say(`vendor: materialising \`${manifest.name}\` at ${display(dest)}. For the next moment two workspaces govern ${path.basename(repoDir)};`);
1235
912
  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.
913
+ // Conditional: before the new manifest lands, that path may hold the pointer, the only record of who governs.
1243
914
  say(`vendor: ONLY if it reports two governors, remove ${display(path.join(dest, "workspace.json"))} — the manifest this run`);
1244
915
  say("vendor: wrote — and the window closes. If it reports none, the window never opened: delete nothing.");
1245
916
  }
@@ -1247,47 +918,29 @@ export async function run(argv, options = {}) {
1247
918
  const preserved = new Map();
1248
919
  for (const rel of carve.allow) preserved.set(rel, fs.readFileSync(path.join(dest, rel)));
1249
920
 
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.
921
+ // Registered before the first byte moves; it reads `written` by reference, so it undoes exactly what happened.
1256
922
  const written = [];
1257
- const landed = carve.allow.size === 0 && !fs.existsSync(dest);
923
+ const destWasAbsent = carve.allow.size === 0 && !fs.existsSync(dest);
1258
924
  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.
925
+ // The manifest first: removing it closes the window, so cleanup that throws after it leaves no governor.
1261
926
  fs.rmSync(path.join(dest, "workspace.json"), { force: true });
1262
927
  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.
928
+ if (destWasAbsent) {
1265
929
  fs.rmSync(dest, { recursive: true, force: true });
1266
930
  } else {
1267
931
  for (const rel of written) if (!preserved.has(rel)) fs.rmSync(path.join(dest, rel), { force: true });
1268
932
  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.
933
+ // Emptied directories too: one left behind makes the retry's `carveOut` refuse the destination.
1275
934
  pruneEmpty(dest);
1276
935
  }
1277
936
  });
1278
937
 
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.
938
+ if (destWasAbsent) {
1282
939
  fs.mkdirSync(path.dirname(dest), { recursive: true });
1283
940
  if (hostFile !== null) fs.renameSync(path.join(staging, "..AGENTS.md.vendoring"), hostFile);
1284
941
  fs.renameSync(staging, dest);
1285
942
  } 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.
943
+ // Files first, manifest last: until the manifest lands, a directory of files is not a residence.
1291
944
  for (const entry of directories(staging)) {
1292
945
  fs.mkdirSync(path.join(dest, entry.rel), { recursive: true });
1293
946
  fs.chmodSync(path.join(dest, entry.rel), entry.mode);
@@ -1306,27 +959,60 @@ export async function run(argv, options = {}) {
1306
959
  fs.rmSync(staging, { recursive: true, force: true });
1307
960
  fault("materialise:manifest");
1308
961
 
962
+ const records = [];
963
+ const recordsLeft = [];
964
+ if (hostFile !== null) {
965
+ const hostRoot = path.dirname(dest);
966
+ const readme = path.join(hostRoot, ...CHANGES_README.split("/"));
967
+ const readmeBlocked = collisions(hostRoot, [CHANGES_README]);
968
+ if (readmeBlocked.length) {
969
+ recordsLeft.push(`\`${CHANGES_README}\` ${readmeBlocked[0].why === "already exists" ? "is the tree's own" : readmeBlocked[0].why}`);
970
+ } else {
971
+ const made = fs.mkdirSync(path.dirname(readme), { recursive: true });
972
+ undo.push(() => {
973
+ fs.rmSync(readme, { force: true });
974
+ if (made !== undefined) fs.rmSync(made, { recursive: true, force: true });
975
+ });
976
+ fs.writeFileSync(readme, changesReadme(), { flag: "wx" });
977
+ records.push(CHANGES_README);
978
+ }
979
+ const index = retargeted.handoffs?.index?.path;
980
+ const ignore = path.join(hostRoot, ".gitignore");
981
+ const blocked = collisions(hostRoot, [".gitignore"]).filter((c) => c.why !== "already exists");
982
+ if (typeof index === "string" && blocked.length) recordsLeft.push(`\`.gitignore\` ${blocked[0].why}`);
983
+ if (typeof index === "string" && blocked.length === 0) {
984
+ const rel = path.relative(hostRoot, path.resolve(dest, index)).split(path.sep).join("/");
985
+ let before = null;
986
+ try {
987
+ before = fs.readFileSync(ignore, "utf8");
988
+ } catch (cause) {
989
+ if (cause.code !== "ENOENT") throw cause;
990
+ }
991
+ const next = withIgnoreLines(before, handoffIndexIgnore(rel, path.relative(hostRoot, dest).split(path.sep).join("/")));
992
+ if (next !== (before ?? "")) {
993
+ undo.push(() => (before === null ? fs.rmSync(ignore, { force: true }) : fs.writeFileSync(ignore, before)));
994
+ fs.writeFileSync(ignore, next);
995
+ records.push(".gitignore");
996
+ }
997
+ }
998
+ }
999
+
1309
1000
  if (!parsed.switching) {
1310
1001
  say(`vendor: wrote ${files.length + (hostFile ? 1 : 0)} file(s) — the \`${manifest.name}\` workspace at ${display(dest)}${hostFile ? `, and ${display(hostFile)}` : ""}.`);
1002
+ if (records.length) say(`vendor: and the records the new form keeps, beside it: ${records.map((r) => `\`${r}\``).join(", ")}.`);
1003
+ if (recordsLeft.length) say(`vendor: left as they are: ${recordsLeft.join("; ")}.`);
1311
1004
  say(`vendor: the source at ${display(source)} is untouched and still governs. This is a rendering, not a move.`);
1312
1005
  say(`vendor: nothing compiled — run \`portulan compile\` there to turn the gate policy into host enforcement.`);
1313
1006
  return 0;
1314
1007
  }
1315
1008
 
1316
1009
  // ---- 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
1010
 
1011
+ // Before the material: retiring the files first would leave a governing manifest over a workspace with holes.
1322
1012
  const oldManifest = path.join(oldResidence, "workspace.json");
1323
1013
  if (leave === "pointer") {
1324
1014
  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.
1015
+ // Registered before the write: a temp file left in the old residence would block a later run's cleanup.
1330
1016
  undo.push(() => fs.rmSync(staged2, { force: true }));
1331
1017
  fs.writeFileSync(staged2, `${JSON.stringify(pointerManifest(oldResidence, manifest.name, parsed.feed), null, 2)}\n`);
1332
1018
  fs.renameSync(staged2, oldManifest);
@@ -1341,9 +1027,6 @@ export async function run(argv, options = {}) {
1341
1027
 
1342
1028
  const bothRoots = { ...roots, ...(parsed.repoRoots.length ? { repoRoots: parsed.repoRoots.map((r) => path.resolve(r)) } : {}) };
1343
1029
  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
1030
  const oldEnd =
1348
1031
  leave === "pointer"
1349
1032
  ? await verdict(oldResidence, { ...(options.env ? { env: options.env } : {}) })
@@ -1351,9 +1034,6 @@ export async function run(argv, options = {}) {
1351
1034
  ? [{ check: "residence", message: `a manifest still stands at ${display(oldManifest)}` }]
1352
1035
  : [];
1353
1036
  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
1037
  say(`vendor: governance has moved to ${display(dest)}, and \`doctor\` is RED at ${newEnd.length ? "the new" : "the old"} end.`);
1358
1038
  for (const f of [...newEnd, ...oldEnd]) say(`vendor: ${f.check} ${f.message}`);
1359
1039
  say(`vendor: the old residence's files were NOT removed — green at both ends comes before retirement, and it was not green.`);
@@ -1363,10 +1043,7 @@ export async function run(argv, options = {}) {
1363
1043
 
1364
1044
  // ---- retire the old residence's material — only what this run can account for
1365
1045
 
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.
1046
+ // Compile's output is deleted though never copied: it is reproducible, and left here it would enforce beside a pointer.
1370
1047
  const moved = new Set([...rels, ...generated]);
1371
1048
  const leftovers = [];
1372
1049
  let remaining = [];
@@ -1374,12 +1051,7 @@ export async function run(argv, options = {}) {
1374
1051
  try {
1375
1052
  remaining = walk(oldResidence);
1376
1053
  } 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.
1054
+ // A failed scan is not an empty residence: under `--leave nothing` it would delete files this run cannot account for.
1383
1055
  unscannable = cause;
1384
1056
  }
1385
1057
  for (const file of remaining) {
@@ -1402,20 +1074,12 @@ export async function run(argv, options = {}) {
1402
1074
  say(`vendor: switched \`${manifest.name}\` — ${sourceResidence} → ${parsed.residence}.`);
1403
1075
  say(`vendor: resides at ${display(dest)}, green.`);
1404
1076
  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
1077
  say(`vendor: the old residence at ${display(oldResidence)} could NOT be scanned — ${unscannable.message}`);
1409
1078
  say("vendor: so nothing there was removed. It no longer governs (its manifest is gone or is a pointer),");
1410
1079
  say("vendor: and nothing here deletes files it could not account for. Clear it by hand when you can read it.");
1411
1080
  } else if (leave === "pointer") {
1412
1081
  say(`vendor: a pointer at ${display(oldResidence)}, green.`);
1413
1082
  } 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
1083
  say(`vendor: no workspace at ${display(oldResidence)} — its manifest is retired and it governs nothing.`);
1420
1084
  } else {
1421
1085
  say(`vendor: nothing left at ${display(oldResidence)}.`);
@@ -1429,17 +1093,28 @@ export async function run(argv, options = {}) {
1429
1093
  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
1094
  }
1431
1095
  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.`);
1096
+ say(`vendor: \`${display(path.join(path.dirname(source), ".claude", "settings.json"))}\` and \`${display(path.join(path.dirname(source), ".claude", "rules", "portulan"))}\` — where they exist — were compiled from the workspace that has just moved, and nothing here writes outside ${display(source)}. They are yours to remove.`);
1436
1097
  }
1437
1098
  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
1099
  say(`vendor: no --repo-root was given, so the cross-repository half of "green at both ends" reported rather than checked.`);
1441
1100
  }
1442
- say(`vendor: nothing compiled — run \`portulan compile\` against the new residence.`);
1101
+ // Past the flip, so a compile refusal is reported and the switch still completes.
1102
+ let guidanceCompiled = false;
1103
+ if (parsed.residence === "in-repo") {
1104
+ try {
1105
+ guidanceCompiled = compileGuidance(dest).declared;
1106
+ } catch (error) {
1107
+ say(`vendor: the guidance was NOT compiled — ${error.message}. Until \`portulan compile\` runs clean there, a session boots through the slots.`);
1108
+ }
1109
+ }
1110
+ if (guidanceCompiled) {
1111
+ say(`vendor: compiled the guidance into ${display(path.join(repoDir, ".claude", "rules", "portulan"))}, which Claude Code loads there; host settings are not — run \`portulan compile\` against the new residence for them.`);
1112
+ } else {
1113
+ say(`vendor: nothing compiled — run \`portulan compile\` against the new residence.`);
1114
+ }
1115
+ if (parsed.residence === "in-repo") {
1116
+ say(`vendor: the repository's own records are not moved by a switch — \`portulan upgrade --write ${display(dest)}\` moves them to the new form, which doctor's \`form\` line reports.`);
1117
+ }
1443
1118
  return 0;
1444
1119
  } catch (error) {
1445
1120
  if (!pastTheFlip) await unwind(undo);
@@ -1453,7 +1128,6 @@ export async function run(argv, options = {}) {
1453
1128
  }
1454
1129
  }
1455
1130
 
1456
- /** The failing findings the real validator returns — never a second opinion about what valid means. */
1457
1131
  async function verdict(dir, roots) {
1458
1132
  try {
1459
1133
  const { findings } = await inspect(dir, roots);
@@ -1463,7 +1137,6 @@ async function verdict(dir, roots) {
1463
1137
  }
1464
1138
  }
1465
1139
 
1466
- /** Runs the undo steps newest-first, and never lets a failed undo mask the failure that caused it. */
1467
1140
  async function unwind(undo) {
1468
1141
  while (undo.length) {
1469
1142
  const step = undo.pop();
@@ -1475,7 +1148,6 @@ async function unwind(undo) {
1475
1148
  }
1476
1149
  }
1477
1150
 
1478
- /** Removes directories this run emptied, bottom-up, and never one that still holds something. */
1479
1151
  function pruneEmpty(root) {
1480
1152
  const walkDown = (dir) => {
1481
1153
  let entries;
@@ -1496,9 +1168,7 @@ function pruneEmpty(root) {
1496
1168
  walkDown(path.resolve(root));
1497
1169
  }
1498
1170
 
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.
1171
+ // `?? ""`: `process.argv[1]` is absent when a non-script imports this, and `pathToFileURL(undefined)` throws.
1502
1172
  if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
1503
1173
  process.exitCode = await run(process.argv.slice(2));
1504
1174
  }