@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.
- package/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +87 -0
- package/cli/README.md +337 -0
- package/cli/compile.mjs +2209 -0
- package/cli/control-chars.mjs +570 -0
- package/cli/discover.mjs +985 -0
- package/cli/doctor.mjs +2803 -0
- package/cli/feedback.mjs +970 -0
- package/cli/gate.mjs +271 -0
- package/cli/index.mjs +1390 -0
- package/cli/init.mjs +1729 -0
- package/cli/librarian.mjs +1384 -0
- package/cli/manifest.mjs +59 -0
- package/cli/new.mjs +803 -0
- package/cli/pack-version.mjs +543 -0
- package/cli/plugin-lint.mjs +1566 -0
- package/cli/portulan.mjs +266 -0
- package/cli/recipe-set.mjs +425 -0
- package/cli/rule-carriers.mjs +489 -0
- package/cli/skills-set.mjs +715 -0
- package/cli/stop-gate.mjs +434 -0
- package/cli/upgrade.mjs +897 -0
- package/cli/vendor.mjs +1504 -0
- package/core/README.md +46 -0
- package/core/engine.md +47 -0
- package/core/operating/autonomy.md +100 -0
- package/core/operating/evolution.md +84 -0
- package/core/operating/loop.md +83 -0
- package/core/operating/memory.md +161 -0
- package/core/operating/safety.md +42 -0
- package/core/operating/verification.md +76 -0
- package/core/personas/README.md +40 -0
- package/core/personas/implementer.md +51 -0
- package/core/personas/librarian.md +73 -0
- package/core/personas/reviewer.md +52 -0
- package/core/skills/README.md +52 -0
- package/core/skills/clarify/SKILL.md +42 -0
- package/core/skills/codify/SKILL.md +65 -0
- package/core/skills/consolidate/SKILL.md +121 -0
- package/core/templates/gate-policy.md +76 -0
- package/core/templates/handoff.md +29 -0
- package/core/templates/memory-entry.md +44 -0
- package/core/templates/pack.md +94 -0
- package/core/templates/persona.md +69 -0
- package/core/templates/proposal.md +27 -0
- package/core/templates/repo-card.md +28 -0
- package/core/templates/skill.md +57 -0
- package/core/templates/task.md +26 -0
- package/core/templates/workspace.md +81 -0
- package/package.json +41 -0
- package/packs/.claude-plugin/plugin.json +22 -0
- package/packs/README.md +61 -0
- package/packs/rituals/README.md +13 -0
- package/packs/rituals/checkpoints/README.md +105 -0
- package/packs/rituals/checkpoints/pack.json +36 -0
- package/packs/rituals/checkpoints/personas/supervisor.md +64 -0
- package/packs/rituals/checkpoints/skills/milestone-close/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/session-open/SKILL.md +57 -0
- package/packs/stacks/README.md +8 -0
- package/packs/tools/README.md +17 -0
- package/packs/tools/github/README.md +65 -0
- package/packs/tools/github/pack.json +29 -0
- package/packs/tools/github/verify/README.md +51 -0
- package/packs/tools/github/verify/actions-pinned.sh +132 -0
- package/spec/README.md +399 -0
- package/spec/migrations/0001-repository-declares-its-tree.mjs +100 -0
- package/spec/migrations/0002-bundle-fallback-path.mjs +179 -0
- package/spec/migrations/README.md +95 -0
- package/spec/pack.schema.json +201 -0
- package/spec/slots.md +796 -0
- 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
|
+
}
|