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