@sleepy_panda_srl/portulan 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +6 -5
  2. package/cli/README.md +142 -358
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1322 -1607
  6. package/cli/context.mjs +883 -0
  7. package/cli/control-chars.mjs +12 -301
  8. package/cli/discover.mjs +17 -574
  9. package/cli/doctor.mjs +224 -1074
  10. package/cli/drills.mjs +68 -502
  11. package/cli/feedback.mjs +16 -237
  12. package/cli/finish.mjs +437 -0
  13. package/cli/form.mjs +644 -0
  14. package/cli/fuzz-shell.mjs +20 -305
  15. package/cli/gate.mjs +7 -199
  16. package/cli/goldens.mjs +26 -184
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +243 -566
  19. package/cli/inside.mjs +3 -28
  20. package/cli/instructions.mjs +829 -0
  21. package/cli/ledger.mjs +778 -0
  22. package/cli/librarian.mjs +135 -599
  23. package/cli/manifest.mjs +2 -39
  24. package/cli/mutants.mjs +9 -170
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +5 -74
  27. package/cli/pack-version.mjs +13 -255
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +5 -124
  30. package/cli/recipe-set.mjs +15 -245
  31. package/cli/release-eval.mjs +28 -348
  32. package/cli/review-meter.mjs +17 -258
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +10 -176
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +83 -514
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +27 -492
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +8 -86
  43. package/core/README.md +1 -1
  44. package/core/engine.md +3 -9
  45. package/core/operating/autonomy.md +1 -1
  46. package/core/operating/context.md +183 -0
  47. package/core/operating/evolution.md +3 -3
  48. package/core/operating/loop.md +13 -20
  49. package/core/operating/memory.md +12 -3
  50. package/core/operating/sessions.md +100 -0
  51. package/core/personas/librarian.md +2 -2
  52. package/core/skills/consolidate/SKILL.md +4 -2
  53. package/core/templates/handoff.md +28 -15
  54. package/core/templates/memory-entry.md +1 -0
  55. package/evals/README.md +255 -845
  56. package/evals/releases/0.2.0.json +146 -0
  57. package/evals/releases/0.2.0.md +67 -0
  58. package/evals/releases/README.md +3 -3
  59. package/package.json +4 -2
  60. package/packs/rituals/checkpoints/README.md +1 -1
  61. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  62. package/spec/README.md +112 -6
  63. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  64. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  65. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  66. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  67. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  68. package/spec/migrations/0006-boot-card.mjs +77 -0
  69. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  70. package/spec/migrations/0008-card-reading.mjs +55 -0
  71. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  72. package/spec/migrations/0010-comment-rail.mjs +57 -0
  73. package/spec/migrations/README.md +45 -10
  74. package/spec/pack.schema.json +1 -1
  75. package/spec/slots.md +333 -21
  76. package/spec/workspace.schema.json +145 -4
package/cli/upgrade.mjs CHANGED
@@ -1,48 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // `upgrade` — migrate a workspace, in either residence.
3
3
  //
4
- // The eighth of the eight subcommands `docs/vision.md` names, and the last to be built. Row 7 of
5
- // `docs/plan.md`: *"`upgrade` migrates a workspace in **either** residence."*
6
- //
7
- // ## What a migration is, and where it lives
8
- //
9
- // Not here. `spec/migrations/` is the contract — a directory of steps, each a module — because a
10
- // migration is part of the **Workspace Definition** rather than part of a tool, and `spec/` ships in
11
- // `package.json`'s `files` so it reaches an `npx` user — the package was published 2026-08-18 at
12
- // `0.1.0`, closing #242, so that user now exists. See `../.portulan/identity.md`. This file is the runner: it decides which
13
- // steps a workspace owes, applies them, and grades the result with the real validator.
14
- //
15
- // **Two kinds of step**, by the maintainer's ruling of 2026-08-12: a `version` step migrates a
16
- // Workspace Definition MAJOR, and a `repair` fixes something a rewriter owes a workspace it touched.
17
- // Without the second kind this tool would be machinery with no subject — the train's only MAJOR
18
- // migration is `1.0 → 2.0` and **nothing in this repository declares 1.0**.
19
- //
20
- // ## The three states a workspace can be in relative to this bundle, and why two of them are refusals
21
- //
22
- // `doctor` refuses a manifest whose MAJOR differs from the schema's — in **either direction** — and
23
- // refuses a MINOR ahead of it. The direction matters here in a way it does not there:
24
- //
25
- // - **behind by a MAJOR** — the case this tool exists for. `doctor` cannot grade it, so the pre-state
26
- // gate below is skipped and the result is graded post-state only.
27
- // - **ahead** (MAJOR or MINOR) — **exit 2**. This bundle is older than the workspace; nothing here
28
- // knows the contract it was written against, and the remedy is to upgrade the CLI. Branching on the
29
- // *fact* of `doctor`'s refusal rather than on the direction sent such a workspace into the plan,
30
- // where every step answered *not owed* and the run exited **0 — nothing owed**: a green rendered by
31
- // a tool that could not read the workspace. Caught at the session-open checkpoint.
32
- // - **level, or behind by a MINOR** — gradeable, and graded before anything is planned.
33
- //
34
- // The same hole has a second mouth: a workspace behind by a MAJOR that **no step reaches** would also
35
- // plan to nothing and exit 0. That is refused too, further down, for the same reason.
36
- //
37
- // ## Exit codes
38
- //
39
- // `0` succeeded — nothing owed, or applied and green · `1` a verdict — steps owed under `--check`, a
40
- // red workspace before or after, **and a pointer that does not resolve** (`not-installed` or
41
- // `ambiguous`, which is `discover.mjs`'s own mapping rather than a second opinion about it) · `2`
42
- // could not run, including a step that **could not tell**, either direction this bundle cannot help
43
- // with, and a `--write` aimed at an installed workspace.
44
- //
45
- // Zero dependencies, ESM, no build step — the session-0 ruling.
4
+ // Exit 0 succeeded · 1 a step owed under `--check` or left by hand, a red workspace, an unresolved pointer · 2 could not run.
46
5
 
47
6
  import fs from "node:fs";
48
7
  import path from "node:path";
@@ -50,35 +9,22 @@ import { fileURLToPath, pathToFileURL } from "node:url";
50
9
 
51
10
  import { inspect, schemaVersion } from "./doctor.mjs";
52
11
  import { resolveGovernor } from "./discover.mjs";
53
- // The guarded walk, not a fourth implementation of one. `vendor`'s `walk` already refuses a symlink
54
- // anywhere under a workspace — rule 2 of the three a tool writing into somebody's tree owes — and
55
- // three `collisions()` implementing one rule is already an open complaint against this repository
56
- // (#169). Its errors are re-worded below so a reader is never told `vendor` failed.
12
+ import { gitIn } from "./form.mjs";
13
+ import { offerLines } from "./sessions.mjs";
57
14
  import { walk } from "./vendor.mjs";
58
15
 
59
16
  const HERE = path.dirname(fileURLToPath(import.meta.url));
60
17
  const BUNDLE = path.resolve(HERE, "..");
61
18
  const MIGRATIONS = path.join(BUNDLE, "spec", "migrations");
62
19
 
63
- /** Distinguishes one run's staging files from another's, alongside the pid. */
64
20
  let stagingSeq = 0;
65
21
 
66
22
  /** Everything that means `upgrade` could not run. Carries no verdict about a workspace. */
67
23
  export class UpgradeError extends Error {}
68
24
 
69
- /**
70
- * The Workspace Definition version **this bundle implements**, derived from the schema's `$id`.
71
- *
72
- * Derived rather than written down, and through `doctor`'s own exported reader, so this file is not a
73
- * second carrier of the current version. A literal here would be wrong the hour the schema moves —
74
- * which is the defect class this repository has repaired more often than any other.
75
- */
25
+ /** The Workspace Definition version this bundle implements, read from its schema's `$id`. */
76
26
  export function bundleSpec({ schemaPath } = {}) {
77
27
  const file = schemaPath ?? path.join(BUNDLE, "spec", "workspace.schema.json");
78
- // The THIRD site of the read-versus-parse rule, found by sweeping after Copilot named the second.
79
- // This one reads a file the bundle ships rather than one a user wrote, so a malformed schema is a
80
- // broken install rather than somebody's typo — which changes who the sentence is for, not whether
81
- // it should be accurate. Two carriers here were already one too many.
82
28
  let text;
83
29
  try {
84
30
  text = fs.readFileSync(file, "utf8");
@@ -94,13 +40,6 @@ export function bundleSpec({ schemaPath } = {}) {
94
40
  return schemaVersion(schema);
95
41
  }
96
42
 
97
- /**
98
- * The chain, in id order, read from the directory rather than from a list.
99
- *
100
- * There is no registry to keep in sync with the tree: a step is a file, and the filename is the
101
- * order. A hand-maintained list beside a directory is the second carrier this repository keeps
102
- * repairing out of its own design.
103
- */
104
43
  export async function loadSteps({ dir = MIGRATIONS } = {}) {
105
44
  let names;
106
45
  try {
@@ -122,8 +61,6 @@ export async function loadSteps({ dir = MIGRATIONS } = {}) {
122
61
  throw new UpgradeError(`${file} exports no \`step\` object — every module in ${dir} is a migration step`);
123
62
  }
124
63
  if (step.id !== path.basename(name, ".mjs")) {
125
- // The id IS the order, and the order is the filename. A step whose id disagrees with its
126
- // file would sort by one and report as the other.
127
64
  throw new UpgradeError(`${file} declares id \`${step.id}\`, which is not its filename — the id is the chain's order`);
128
65
  }
129
66
  steps.push(step);
@@ -131,13 +68,7 @@ export async function loadSteps({ dir = MIGRATIONS } = {}) {
131
68
  return steps;
132
69
  }
133
70
 
134
- /**
135
- * Read a workspace directory into the view `spec/migrations/README.md` promises a step.
136
- *
137
- * Read and parse stay two failures with two repairs: a `SyntaxError` carries no `.code`, and
138
- * reporting it under *could not be read* sends somebody with a malformed manifest to look at
139
- * permissions.
140
- */
71
+ /** Read a workspace into the view a step is given, as `spec/migrations/README.md` defines it. */
141
72
  export function readWorkspace(dir) {
142
73
  const root = path.resolve(dir);
143
74
  const file = path.join(root, "workspace.json");
@@ -146,8 +77,6 @@ export function readWorkspace(dir) {
146
77
  try {
147
78
  text = fs.readFileSync(file, "utf8");
148
79
  } catch (error) {
149
- // Only `ENOENT` means absent. An `EACCES` is a question that could not be answered, and
150
- // answering it *nothing there* is the fail-open.
151
80
  return {
152
81
  ok: false,
153
82
  code: 2,
@@ -169,17 +98,6 @@ export function readWorkspace(dir) {
169
98
  try {
170
99
  files = walk(root).map((entry) => entry.rel);
171
100
  } catch (error) {
172
- // **Framed, never rewritten.** `walk` is `vendor`'s and its refusals speak in `vendor`'s voice
173
- // — *"this refuses to copy through one"* — which is the wrong verb for a tool that is
174
- // migrating. The first attempt at this substituted the wording, and it was **inert**: the
175
- // pattern said `refusing to copy through` and the message says `refuses`. A fix that could not
176
- // fire, under a comment asserting it did — this repository's dominant defect class, committed
177
- // here in the change that cites it. Copilot's promoted note, round 2 on #231.
178
- //
179
- // Rewriting was the wrong shape anyway: matching another module's sentence makes this file a
180
- // second carrier of `vendor`'s wording, free to rot the next time that sentence is edited. So
181
- // the prefix says who declined and why, and the borrowed sentence is left exactly as its owner
182
- // wrote it.
183
101
  return { ok: false, code: 2, reason: `${root} could not be read for migration — ${error.message}` };
184
102
  }
185
103
 
@@ -190,12 +108,8 @@ export function readWorkspace(dir) {
190
108
  dir: root,
191
109
  manifest,
192
110
  manifestText: text,
111
+ repository: repositoryView(root, manifest),
193
112
  list: () => files,
194
- // The READ sibling of the write guard. `list()` only ever yields paths `walk` enumerated
195
- // inside the workspace, so a step iterating it is safe — but `read` takes whatever a step
196
- // hands it, and the step contract does not constrain that. A tool that refused to WRITE
197
- // outside the workspace while reading anything on the disk would be guarding one half of
198
- // the same rule. Found by sweeping after Copilot caught the `restore` sibling.
199
113
  read: (rel) => {
200
114
  const at = inside(root, rel);
201
115
  if (at === null) throw new UpgradeError(`\`${rel}\` resolves outside ${root} — refusing to read there`);
@@ -206,22 +120,56 @@ export function readWorkspace(dir) {
206
120
  };
207
121
  }
208
122
 
209
- /**
210
- * Which workspace does this run act on?
211
- *
212
- * A governing kind is its own answer. A `pointer` is resolved through `cli/discover.mjs`, and the
213
- * resolver's **own `sentence`** travels back with the verdict — one carrier, so every surface that
214
- * reports a resolution prints the same words rather than four paraphrases of them.
215
- */
123
+ /** The repository the manifest's `tree` names, as a step reads it; `null` where it names no directory. */
124
+ export function repositoryView(root, manifest) {
125
+ if (typeof manifest?.tree !== "string") return null;
126
+ const dir = path.resolve(root, manifest.tree);
127
+ try {
128
+ if (!fs.statSync(dir).isDirectory()) return null;
129
+ } catch {
130
+ return null;
131
+ }
132
+ const cache = new Map();
133
+ let git;
134
+ return {
135
+ dir,
136
+ read: (rel) => {
137
+ const at = inside(dir, rel);
138
+ if (at === null) throw new UpgradeError(`\`${rel}\` resolves outside ${dir} — refusing to read there`);
139
+ const linked = linkOnPath(dir, at);
140
+ if (linked !== null) throw new UpgradeError(linked.replace("write through", "read through"));
141
+ if (!cache.has(rel)) {
142
+ try {
143
+ cache.set(rel, fs.readFileSync(at, "utf8"));
144
+ } catch (error) {
145
+ if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw new UpgradeError(`${at} could not be read — ${error.code ?? error.message}`);
146
+ cache.set(rel, null);
147
+ }
148
+ }
149
+ return cache.get(rel);
150
+ },
151
+ names: (rel) => {
152
+ const at = inside(dir, rel);
153
+ if (at === null) throw new UpgradeError(`\`${rel}\` resolves outside ${dir} — refusing to list there`);
154
+ const linked = linkOnPath(dir, at);
155
+ if (linked !== null) throw new UpgradeError(linked.replace("write through", "list through"));
156
+ try {
157
+ return fs.readdirSync(at);
158
+ } catch (error) {
159
+ if (error.code === "ENOENT" || error.code === "ENOTDIR") return [];
160
+ throw new UpgradeError(`${at} could not be listed — ${error.code ?? error.message}`);
161
+ }
162
+ },
163
+ get git() {
164
+ if (git === undefined) git = gitIn(dir);
165
+ return git;
166
+ },
167
+ };
168
+ }
169
+
216
170
  export async function resolveTarget(dir, options = {}) {
217
171
  const root = path.resolve(dir);
218
172
  const file = path.join(root, "workspace.json");
219
- // **Read and parse are two failures with two repairs, and this had them as one.** `readWorkspace`
220
- // twelve lines up already keeps them apart — a `SyntaxError` carries no `.code`, so a malformed
221
- // manifest fell through to the *could not be read* sentence and sent the reader to look at
222
- // permissions and paths instead of at their JSON. One rule, two sites, enforced at one: `0020`,
223
- // and the second site is inside the file whose own contract states the distinction.
224
- // Copilot's suppressed note, round 7 on #231.
225
173
  const fail = (sentence) => ({ state: "could-not-look", dir: null, resolution: null, sentence });
226
174
 
227
175
  let text;
@@ -242,12 +190,6 @@ export async function resolveTarget(dir, options = {}) {
242
190
  return fail(`${file} does not parse as JSON — ${error.message}`);
243
191
  }
244
192
  if (manifest?.kind !== "pointer") {
245
- // **Not `discover`'s wording, deliberately, and the difference is the argument.** `discover`
246
- // answers *this repository's workspace resides here* because it is asked about a repository's
247
- // own `.portulan` — that is its question. This tool takes ANY workspace directory: `examples/`,
248
- // a feed-side portfolio, a path a user typed. Borrowing the sentence would have made a claim
249
- // about ownership from the mere absence of `kind: pointer`, which is a different fact from the
250
- // one that was checked. So it says what it actually established. Copilot, round 1 on #231.
251
193
  const kind = typeof manifest?.kind === "string" ? `\`${manifest.kind}\`` : "a governing";
252
194
  return {
253
195
  state: "resides-here",
@@ -265,16 +207,6 @@ export async function resolveTarget(dir, options = {}) {
265
207
  };
266
208
  }
267
209
 
268
- /**
269
- * Ask every step whether it is owed. Three answers, and the third is not a shorter plan.
270
- *
271
- * **A step that THROWS is `could not tell`, not a crash.** The three-valued answer exists precisely so
272
- * that "this step could not work out whether it applies" has somewhere to go other than a green — and
273
- * an unhandled exception routed around it entirely, taking the whole tool down instead of producing
274
- * the designed exit 2. A step is a module from `spec/migrations/`, so a bug in one, or an unexpected
275
- * read error inside it, must degrade to the answer the contract already has a word for.
276
- * Copilot's promoted note, round 6 on #231.
277
- */
278
210
  export async function planFor(ws, ctx, steps) {
279
211
  const entries = [];
280
212
  let owed = 0;
@@ -283,69 +215,30 @@ export async function planFor(ws, ctx, steps) {
283
215
  let answer;
284
216
  try {
285
217
  answer = await step.owed(ws, ctx);
286
- // **The VALUE, not the presence of the key.** A first cut accepted any object carrying an
287
- // `owed` property, which let `{ owed: "yes" }` or `{ owed: undefined }` through: neither is
288
- // `true` and neither is `null`, so the arithmetic below counted them as **not owed** — a
289
- // false green produced by the very guard added to prevent one. Checking that a field exists
290
- // is not checking that its value is one this contract defines.
291
- // Copilot, round 8 on #231.
292
218
  if (answer === null || typeof answer !== "object" || ![true, false, null].includes(answer.owed)) {
293
219
  answer = { owed: null, because: `${step.id} returned no usable verdict from \`owed\` (${JSON.stringify(answer?.owed)})` };
294
220
  } else if (typeof answer.because !== "string" || answer.because === "") {
295
- // The reason is what a reader is given when the run refuses. A missing one is not
296
- // grounds to discard a good verdict, so the verdict stands and the sentence is named
297
- // as absent rather than printed as `undefined`.
298
- answer = { owed: answer.owed, because: `${step.id} gave no reason` };
221
+ answer = { owed: answer.owed, hand: answer.hand, because: `${step.id} gave no reason` };
299
222
  }
300
223
  } catch (error) {
301
224
  answer = { owed: null, because: `${step.id} threw while deciding whether it is owed — ${error.message}` };
302
225
  }
303
- entries.push({ step, owed: answer.owed, because: answer.because });
226
+ // `hand`: owed, and placed by a person rather than by the step, so `--write` reports it and runs on.
227
+ entries.push({ step, owed: answer.owed, because: answer.because, hand: answer.owed === true && answer.hand === true });
304
228
  if (answer.owed === null) unknown += 1;
305
229
  else if (answer.owed === true) owed += 1;
306
230
  }
307
231
  return { entries, owed, unknown };
308
232
  }
309
233
 
310
- /**
311
- * Resolve a step-supplied relative path inside `root`, or `null` if it escapes.
312
- *
313
- * **One carrier, and it has two callers because it has to.** `applyEdits` writes and `restore`
314
- * writes back, and a guard on the first alone leaves the second reachable with the same value — one
315
- * rule enforced at one of its two sites, which is `0020` exactly. That is not hypothetical here:
316
- * `applyEdits` got the guard first and `restore` did not, and Copilot found the sibling before this
317
- * session's own sweep did.
318
- *
319
- * `resolve`, never `join`: `path.join(root, "/etc/passwd")` CONCATENATES to `<root>/etc/passwd`,
320
- * which hides an absolute path from the check instead of exposing it. And resolution rather than
321
- * pattern-matching, because every interesting escape parses fine and only fails once resolved.
322
- */
234
+ /** `rel` resolved inside `root`, or `null` if it escapes: `path.join` would pass an absolute `rel` as inside. */
323
235
  export function inside(root, rel) {
324
236
  const resolved = path.resolve(root, rel);
325
237
  if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) return null;
326
238
  return resolved;
327
239
  }
328
240
 
329
- /**
330
- * The other half of containment: the path on DISK, not the path as a string.
331
- *
332
- * `inside()` is lexical. A symlink at any **parent component** makes the write escape while the
333
- * string still resolves inside the root — `<root>/link/a.md` passes every character-level test and
334
- * lands wherever `link` points. `{ flag: "wx" }` guards only the final component. This is the rule
335
- * `cli/vendor.mjs` states as rule 2 and enforces by walking the chain, and `cli/skills-set.mjs`
336
- * spells the same walk; this file had inherited the rule and implemented only its lexical half.
337
- * Copilot, round 11 on #231.
338
- *
339
- * Components that do not exist are fine — they are about to be created, and nothing can be followed
340
- * through a path that is not there — so the walk stops at the first `ENOENT`. Anything else is a
341
- * question that could not be answered, and is refused rather than assumed absent: rule 3.
342
- *
343
- * The root itself is not checked, deliberately, and that is `vendor`'s boundary: **above the named
344
- * path, resolve — the caller named it, and on macOS `os.tmpdir()` runs through `/var`. At it and
345
- * below it, refuse.**
346
- *
347
- * Returns a reason to refuse, or `null` when the chain is clean.
348
- */
241
+ /** A reason to refuse, or `null`; `root` itself is not checked, since macOS's `os.tmpdir()` runs through `/var`. */
349
242
  function linkOnPath(root, resolved) {
350
243
  const rel = path.relative(root, resolved);
351
244
  if (rel === "") return null;
@@ -364,22 +257,7 @@ function linkOnPath(root, resolved) {
364
257
  return null;
365
258
  }
366
259
 
367
- /**
368
- * Remove directories this run created, deepest first, stopping at the first that will not go.
369
- *
370
- * **One carrier, two callers.** `applyEdits` needs it on every failure path between creating a
371
- * directory and recording the snapshot that would have owned it, and `restore` needs it when undoing
372
- * a file that did not exist. Two copies of this loop would be one rule at two sites — and the second
373
- * site is precisely where the first copy was missing.
374
- *
375
- * Stops rather than forces: a directory that will not `rmdir` is one that is not empty, which means
376
- * something else is in it and it was not ours to remove after all.
377
- *
378
- * **`ENOENT` is not that case, and stopping on it was a bug.** A directory already gone — removed out
379
- * of band, or by a `rm -rf` of a subtree — satisfies the goal rather than blocking it, and treating it
380
- * as an obstacle abandoned every parent above it while they may well have been empty. Absence means
381
- * *done*, not *stop*. Copilot's promoted note, round 6 on #231.
382
- */
260
+ /** `dirs` as created, shallowest first; one that will not go is not empty, and neither are its parents. */
383
261
  function unwindDirs(dirs) {
384
262
  for (const made of [...dirs].reverse()) {
385
263
  try {
@@ -391,37 +269,19 @@ function unwindDirs(dirs) {
391
269
  }
392
270
  }
393
271
 
394
- /**
395
- * Write the edits, keeping the previous contents so a red verdict can be undone.
396
- *
397
- * Each file lands by write-temp-then-rename, so no file is ever half-written. **The mode is carried
398
- * across**: `rename(2)` replaces the inode, and `verify/index.sh` is executable — a repair that left
399
- * the rail unexecutable would have broken the thing it exists to fix.
400
- *
401
- * **A parent directory is created where an edit names one that does not exist**, and the directories
402
- * created are recorded so a rollback can remove them again. Neither step shipped today writes a new
403
- * file, so nothing exercised this in production — which is exactly why it was worth fixing rather than
404
- * leaving: a later step adding a nested file would have failed `ENOENT` on a perfectly valid edit.
405
- * Copilot's promoted note, round 2 on #231.
406
- */
407
272
  export function applyEdits(dir, edits, options = {}) {
408
- // `write` is an injection point for the suite, the way `init` injects its reader and `skills-set`
409
- // injects its manifest. No production caller passes it. It exists because the window this guards —
410
- // `mkdir` succeeds, the write then fails — is a disk-full or permissions race that cannot be
411
- // staged honestly on a real filesystem, and a failure path with no test is how the orphan it
412
- // guards against got here in the first place.
413
273
  const write = options.write ?? fs.writeFileSync;
414
- const root = path.resolve(dir);
274
+ const workspaceRoot = path.resolve(dir);
275
+ const treeRoot = typeof options.treeDir === "string" ? path.resolve(options.treeDir) : null;
415
276
  const snapshots = [];
416
277
  for (const edit of edits) {
417
- // **A path built from somebody else's text is contained before it is opened.** A step's
418
- // `edit.file` is a value this tool did not author — an absolute path, or one climbing out
419
- // with `..`, would have this writing outside the workspace it was pointed at. Nothing in the
420
- // step contract prevents either, and a schema could not: the value is computed, not declared.
421
- //
422
- // This repository has already paid for the class once, at a persona's free-text `name`, where
423
- // `../../poison` had `doctor` read, grade and GREEN a file outside the tree. `cli/compile.mjs`
424
- // guards the same way. Copilot, round 4 on #231.
278
+ if (edit.root !== undefined && edit.root !== "workspace" && edit.root !== "tree") {
279
+ return { ok: false, snapshots, reason: `an edit names root \`${edit.root}\`, which is neither the workspace nor the tree` };
280
+ }
281
+ if (edit.root === "tree" && treeRoot === null) {
282
+ return { ok: false, snapshots, reason: `an edit to \`${edit.file}\` names the tree, and this workspace declares none` };
283
+ }
284
+ const root = edit.root === "tree" ? treeRoot : workspaceRoot;
425
285
  const file = inside(root, edit.file);
426
286
  if (file === null) {
427
287
  return {
@@ -444,9 +304,19 @@ export function applyEdits(dir, edits, options = {}) {
444
304
  }
445
305
  }
446
306
 
447
- // Shallowest first, so `unwindDirs` can take them in reverse. `lstat`, never `existsSync`:
448
- // the latter follows links and answers *false* for an unreadable path, which would turn "I
449
- // could not look" into "it is not there" — rule 3, at a third site.
307
+ if (edit.next === null) {
308
+ if (previous === null) continue;
309
+ if (!fs.lstatSync(file).isFile()) return { ok: false, snapshots, reason: `${file} is not a regular file — refusing to delete it` };
310
+ try {
311
+ fs.rmSync(file);
312
+ } catch (error) {
313
+ return { ok: false, snapshots, reason: `${file} could not be deleted — ${error.code ?? error.message}` };
314
+ }
315
+ snapshots.push({ file: edit.file, root: edit.root, previous, mode, created: [], deleted: true });
316
+ continue;
317
+ }
318
+
319
+ // `lstat`, not `existsSync`, which follows links and answers `false` for a path it could not read.
450
320
  const wanted = [];
451
321
  for (let at = path.dirname(file); at.startsWith(root) && at !== root; at = path.dirname(at)) {
452
322
  try {
@@ -460,10 +330,7 @@ export function applyEdits(dir, edits, options = {}) {
460
330
  }
461
331
  }
462
332
 
463
- // **What was actually made**, not what was wanted. The snapshot is pushed only after a
464
- // successful rename, so every failure between here and there has to clean up its own
465
- // directories or they are orphaned — created, unrecorded, and invisible to `restore`. That
466
- // is the leak Copilot's promoted note found in the fix that added them, one round earlier.
333
+ // No snapshot owns these until the rename lands, so every failure before it unwinds them.
467
334
  const created = [];
468
335
  try {
469
336
  for (const dir of wanted) {
@@ -471,11 +338,7 @@ export function applyEdits(dir, edits, options = {}) {
471
338
  fs.mkdirSync(dir);
472
339
  created.push(dir);
473
340
  } catch (error) {
474
- // A directory created between the `lstat` probe above and this line satisfies the
475
- // goal rather than defeating it — but only if what appeared is a DIRECTORY. An
476
- // `EEXIST` from a file of that name is a real failure, and swallowing both would
477
- // be the fail-open. Not recorded in `created`: this run did not make it, so this
478
- // run does not get to remove it. Copilot's promoted note, round 6.
341
+ // Made by another since the probe: fine if a directory, and not this run's to remove.
479
342
  if (error.code !== "EEXIST" || !fs.lstatSync(dir).isDirectory()) throw error;
480
343
  }
481
344
  }
@@ -484,21 +347,13 @@ export function applyEdits(dir, edits, options = {}) {
484
347
  return { ok: false, snapshots, reason: `${path.dirname(file)} could not be created — ${error.code ?? error.message}` };
485
348
  }
486
349
 
487
- // **Unique per edit, not a fixed suffix.** Two `upgrade --write` runs over one workspace —
488
- // concurrent CI invocations, or a human beside a pipeline — would otherwise race on the same
489
- // staging path and lose or corrupt an edit. Still in the same directory, so the `rename` stays
490
- // atomic. Copilot, round 6 on #231.
350
+ // Unique per process and edit, and beside its file so the rename stays atomic.
491
351
  const staging = `${file}.portulan-upgrade.${process.pid}.${stagingSeq++}`;
492
352
  try {
493
- // **Exclusive create.** The staging path is a path `walk` never saw — it does not exist
494
- // when the workspace is read — so the containment guard above says nothing about it. With
495
- // default flags a symlink planted there would be **followed**, writing outside the
496
- // workspace and defeating that guard entirely; a leftover file would be clobbered. `wx`
497
- // fails with `EEXIST` on anything already at the path, symlink included, which is the
498
- // fail-closed direction and rule 2 of the three a tool writing into somebody's tree owes.
499
- // Copilot, round 10 on #231.
353
+ // `wx`: anything already at the staging path, a planted symlink included, fails rather than being followed.
500
354
  write(staging, edit.next, { flag: "wx" });
501
- if (mode !== null) fs.chmodSync(staging, mode);
355
+ const keep = mode ?? edit.mode ?? null;
356
+ if (keep !== null) fs.chmodSync(staging, keep);
502
357
  fs.renameSync(staging, file);
503
358
  } catch (error) {
504
359
  try {
@@ -509,35 +364,21 @@ export function applyEdits(dir, edits, options = {}) {
509
364
  unwindDirs(created);
510
365
  return { ok: false, snapshots, reason: `${file} could not be written — ${error.code ?? error.message}` };
511
366
  }
512
- snapshots.push({ file: edit.file, previous, mode, created });
367
+ snapshots.push({ file: edit.file, root: edit.root, previous, mode, created });
513
368
  }
514
369
  return { ok: true, snapshots };
515
370
  }
516
371
 
517
- /**
518
- * Put back what `applyEdits` replaced.
519
- *
520
- * **It reports what it did not restore rather than claiming the tree is clean.** "The workspace is
521
- * exactly as it was" is a promise a mid-restore `EACCES` breaks, and a rollback that lies about
522
- * having succeeded is worse than one that failed loudly.
523
- */
524
- export function restore(dir, snapshots) {
525
- const root = path.resolve(dir);
372
+ export function restore(dir, snapshots, options = {}) {
373
+ const workspaceRoot = path.resolve(dir);
374
+ const treeRoot = typeof options.treeDir === "string" ? path.resolve(options.treeDir) : null;
526
375
  const restored = [];
527
376
  const failed = [];
528
- // **Reverse order — an unwind runs against the stack that made it.** Two edits landing in the same
529
- // newly-created directory record it as `created` on the FIRST of them only; forwards, that
530
- // snapshot's `rmdir` fails because the second file is still there, and the directory is left
531
- // behind. Backwards, the second file goes first and the directory is empty when its turn comes.
532
- // Found by sweeping this file for the sibling of the note that added the directories, rather than
533
- // by a round that raised it.
377
+ // Last edit first: a directory two edits share is recorded on the first, and empties only after the second.
534
378
  for (const snapshot of [...snapshots].reverse()) {
535
- // The SIBLING of the guard in `applyEdits`, and it is here because it was missing.
536
- // `applyEdits` got containment first and this did not — one rule at one of its two sites,
537
- // which is `0020`, in the change whose own commit message was about sweeping for siblings.
538
- // Copilot found it; the sweep did not. A snapshot normally comes from `applyEdits` and is
539
- // therefore already contained, but this function is exported and takes them from a caller.
540
- const file = inside(root, snapshot.file);
379
+ // Contained again, as `restore` is exported and a snapshot may come from any caller.
380
+ const root = snapshot.root === "tree" ? treeRoot : workspaceRoot;
381
+ const file = root === null ? null : inside(root, snapshot.file);
541
382
  if (file === null || linkOnPath(root, file) !== null) {
542
383
  failed.push(snapshot.file);
543
384
  continue;
@@ -545,14 +386,8 @@ export function restore(dir, snapshots) {
545
386
  try {
546
387
  if (snapshot.previous === null) {
547
388
  fs.rmSync(file, { force: true });
548
- // And the directories this run had to create for it. Leaving them behind would make
549
- // "the workspace is exactly as it was" false in a second way — quieter than a stray
550
- // file, and still not true.
551
389
  unwindDirs(snapshot.created ?? []);
552
390
  } else {
553
- // The sibling: a rollback writes back to a path that existed when the run began, but
554
- // "existed then" is not "is a regular file now". Refusing to write through a link
555
- // here costs nothing and keeps the rule whole at both write sites.
556
391
  const at = fs.lstatSync(file, { throwIfNoEntry: false });
557
392
  if (at?.isSymbolicLink()) throw new UpgradeError(`${file} is a symlink — refusing to restore through it`);
558
393
  fs.writeFileSync(file, snapshot.previous);
@@ -585,13 +420,10 @@ export function usage() {
585
420
  " --write apply the chain, then grade the result with doctor",
586
421
  " --tree the value a 1.0 `repository` workspace needs and this will not guess",
587
422
  "",
588
- // Precisely which half is refused. The older wording — \"never inside the install\" — read as
589
- // *this tool does not touch an installed workspace*, and a bare or `--check` run DOES read one
590
- // to work out what it owes. Only writing is refused. Copilot's promoted note, round 3.
591
423
  "A pointer is resolved through the host's installed-plugin record; the installed workspace is",
592
424
  "read and reported on, and `--write` is refused there — migrate it at its own directory.",
593
425
  "",
594
- "Exit codes: 0 succeeded · 1 a red verdict · 2 could not run.",
426
+ "Exit codes: 0 succeeded · 1 a red verdict, or a step owed by hand under --check or --write · 2 could not run.",
595
427
  ].join("\n");
596
428
  }
597
429
 
@@ -610,13 +442,6 @@ export async function run(argv = [], options = {}) {
610
442
  for (let i = 0; i < argv.length; i += 1) {
611
443
  const arg = argv[i];
612
444
  if (arg === "--tree") {
613
- // **Three refusals, matching the three parsers that already own this rule** —
614
- // `cli/init.mjs`, `cli/new.mjs` and `cli/vendor.mjs` each refuse a missing value, a value
615
- // that reads as another flag, and an empty one. This took only the first, so
616
- // `--tree --write <dir>` swallowed `--write` as the tree value and **silently dropped the
617
- // write**: a report where the user asked for a migration. A fourth parser inheriting half
618
- // a rule is the shape this pull request has now hit three times.
619
- // Copilot, round 12 on #231.
620
445
  const value = argv[i + 1];
621
446
  if (value === undefined) {
622
447
  warn("upgrade: `--tree` needs a value — it is the one thing this tool will not guess");
@@ -641,8 +466,6 @@ export async function run(argv = [], options = {}) {
641
466
  positional.push(arg);
642
467
  }
643
468
 
644
- // Refused rather than dropped. A silently-ignored `--wrote` is a user who believes they asked for
645
- // something and got a report instead — `discover`'s rule, for `discover`'s reason.
646
469
  const unknown = flags.filter((flag) => !KNOWN_FLAGS.has(flag));
647
470
  if (unknown.length) {
648
471
  warn(`upgrade: unknown flag \`${unknown[0]}\` — run \`portulan upgrade --help\``);
@@ -668,10 +491,6 @@ export async function run(argv = [], options = {}) {
668
491
  let steps;
669
492
  try {
670
493
  spec = bundleSpec();
671
- // Injected only by the suite, alongside `write` and `env`. The apply loop's guards against a
672
- // step returning an unusable plan cannot be reached from the two steps this bundle ships —
673
- // both are well-behaved — and a failure path with no test is how the defect those guards
674
- // exist for arrived in the first place.
675
494
  steps = options.steps ?? (await loadSteps());
676
495
  } catch (error) {
677
496
  warn(`upgrade: ${error.message}`);
@@ -691,11 +510,6 @@ export async function run(argv = [], options = {}) {
691
510
  if (target.state === "resolved") {
692
511
  say(`upgrade: ${target.sentence}`);
693
512
  if (write) {
694
- // A cache install is a **materialisation** whose identity is a version claim, not one of
695
- // proposal 0017's two residences. Writing here would leave a directory that no longer
696
- // matches the version the host's record names for it, and a reinstall would silently
697
- // revert it. The workspace itself is migrated at its own directory — the only act that
698
- // re-pins a feed.
699
513
  warn(
700
514
  `upgrade: \`${target.dir}\` is an installed workspace, pinned at the version the host's record names for it. ` +
701
515
  "Refusing to migrate a copy: run this against the workspace's own directory and republish, then reinstall here",
@@ -710,12 +524,15 @@ export async function run(argv = [], options = {}) {
710
524
  return read.code;
711
525
  }
712
526
  const ws = read.ws;
713
- // The SHORTER of the two spellings. A relative path is what a reader wants for a workspace inside
714
- // the tree they are standing in, and turns into `../../../../…` for a target that is not — a
715
- // temp directory, or an install in the plugin cache. Neither is wrong; one is unreadable.
716
527
  const relative = path.relative(process.cwd(), ws.dir);
717
528
  const shown = relative && relative.length < ws.dir.length ? relative : ws.dir;
718
529
 
530
+ const offerCacheLifetime = (manifest) => {
531
+ if (check || target.state === "resolved") return;
532
+ if (manifest?.kind !== "repository" || manifest?.sessions?.cache_lifetime !== undefined) return;
533
+ for (const line of offerLines()) say(`upgrade: ${line}`);
534
+ };
535
+
719
536
  // ---- which direction is this workspace off in
720
537
  const declared = /^([0-9]+)\.([0-9]+)$/.exec(ws.manifest?.portulan?.spec ?? "");
721
538
  if (!declared) {
@@ -756,7 +573,7 @@ export async function run(argv = [], options = {}) {
756
573
  }
757
574
 
758
575
  // ---- the plan
759
- const ctx = { bundle: BUNDLE, spec, tree };
576
+ const ctx = { bundle: BUNDLE, spec, tree, today: options.today ?? new Date().toISOString().slice(0, 10) };
760
577
  const plan = await planFor(ws, ctx, steps);
761
578
 
762
579
  if (plan.unknown > 0) {
@@ -767,8 +584,6 @@ export async function run(argv = [], options = {}) {
767
584
  return 2;
768
585
  }
769
586
 
770
- // The second mouth of the direction hole: a workspace behind by a MAJOR that no step reaches
771
- // would plan to nothing and exit 0, which is the same false green from the other end.
772
587
  if (behindByMajor && plan.owed === 0) {
773
588
  warn(
774
589
  `upgrade: ${shown} declares ${major}.${minor} and no migration in this chain reaches it — refusing to ` +
@@ -779,37 +594,41 @@ export async function run(argv = [], options = {}) {
779
594
 
780
595
  if (plan.owed === 0) {
781
596
  say(`upgrade: ${shown} owes nothing — it is current at ${major}.${minor}`);
597
+ offerCacheLifetime(ws.manifest);
782
598
  return 0;
783
599
  }
784
600
 
785
601
  for (const entry of plan.entries.filter((e) => e.owed === true)) {
786
- say(`upgrade: ${entry.step.id} (${entry.step.kind}) — ${entry.step.title}`);
602
+ say(`upgrade: ${entry.step.id} (${entry.step.kind}${entry.hand ? ", by hand" : ""}) — ${entry.step.title}`);
787
603
  say(`upgrade: ${entry.because}`);
788
604
  }
789
605
 
790
- // WHICH invocation to suggest depends on where the target came from. Telling the operator of a
791
- // resolved install to "run with --write" names the one command this tool then refuses with exit
792
- // 2 — advice and refusal disagreeing about the same act, which is the sibling of the refusal
793
- // itself. Caught at the pre-commit checkpoint, reproduced end to end on a real install.
606
+ const byHandOwed = plan.entries.filter((entry) => entry.hand).length;
607
+ const byHandNote = byHandOwed > 0 ? `, ${byHandOwed} of them by hand` : "";
794
608
  const advice =
795
609
  target.state === "resolved"
796
610
  ? "run this against the workspace's own directory and republish — an installed copy is not migrated in place"
797
- : "run with --write to apply them";
611
+ : byHandOwed === plan.owed
612
+ ? "add what each names by hand, then upgrade again"
613
+ : `run with --write to apply ${byHandOwed > 0 ? "the rest" : "them"}`;
798
614
 
799
615
  if (check) {
800
- warn(`upgrade: ${shown} owes ${plan.owed} step(s) — ${advice}`);
616
+ warn(`upgrade: ${shown} owes ${plan.owed} step(s)${byHandNote} — ${advice}`);
801
617
  return 1;
802
618
  }
803
619
  if (!write) {
804
- say(`upgrade: ${plan.owed} step(s) owed. Nothing was written — ${advice}`);
620
+ say(`upgrade: ${plan.owed} step(s) owed${byHandNote}. Nothing was written — ${advice}`);
621
+ offerCacheLifetime(ws.manifest);
805
622
  return 0;
806
623
  }
807
624
 
808
625
  // ---- apply, one step at a time, re-reading between them
809
626
  let snapshots = [];
810
627
  let current = ws;
628
+ // A step may declare the tree but none moves it, so every snapshot was made in the latest one.
629
+ let treeDir = ws.repository?.dir ?? null;
811
630
  const undo = () => {
812
- const result = restore(current.dir, snapshots);
631
+ const result = restore(current.dir, snapshots, { treeDir });
813
632
  if (!result.ok) {
814
633
  warn(`upgrade: the rollback was INCOMPLETE — put back ${result.restored.join(", ") || "nothing"}; NOT put back ${result.failed.join(", ")}`);
815
634
  return false;
@@ -817,19 +636,14 @@ export async function run(argv = [], options = {}) {
817
636
  return true;
818
637
  };
819
638
 
820
- for (const entry of plan.entries.filter((e) => e.owed === true)) {
821
- // **A step that throws mid-chain must not take the rollback with it.** `plan()` is a module's
822
- // code, and an exception here — after earlier steps have already written — would abort the
823
- // process with `undo()` never called, leaving a half-migrated workspace and no record of it.
824
- // Converted into the refusal the loop already knows how to unwind from. Copilot, round 6.
639
+ const applied = new Set();
640
+ const byHand = new Map();
641
+ const apply = async (entry) => {
642
+ applied.add(entry.step.id);
643
+ // A step's `plan` is foreign code: a throw becomes a refusal, so the rollback still runs.
825
644
  let planned;
826
645
  try {
827
646
  planned = await entry.step.plan(current, ctx);
828
- // Same lesson as `planFor` above, at the second site: presence of `ok` is not a usable
829
- // plan. `{ ok: true }` with no `edits` array made `applyEdits` throw on `undefined` —
830
- // **bypassing the rollback entirely** and leaving a partially migrated workspace, which is
831
- // the exact outcome the try/catch around this call exists to prevent. `{ ok: false }` with
832
- // no `reason` printed `undefined` at the user. Copilot, round 8 on #231.
833
647
  if (planned === null || typeof planned !== "object" || typeof planned.ok !== "boolean") {
834
648
  planned = { ok: false, reason: `${entry.step.id} returned no plan` };
835
649
  } else if (planned.ok && !Array.isArray(planned.edits)) {
@@ -846,11 +660,11 @@ export async function run(argv = [], options = {}) {
846
660
  if (snapshots.length) warn("upgrade: nothing was left behind — the steps that had already run were rolled back");
847
661
  return 2;
848
662
  }
849
- const applied = applyEdits(current.dir, planned.edits);
850
- snapshots = [...snapshots, ...applied.snapshots];
851
- if (!applied.ok) {
663
+ const edited = applyEdits(current.dir, planned.edits, { treeDir });
664
+ snapshots = [...snapshots, ...edited.snapshots];
665
+ if (!edited.ok) {
852
666
  if (!undo()) return 2;
853
- warn(`upgrade: ${entry.step.id} — ${applied.reason}`);
667
+ warn(`upgrade: ${entry.step.id} — ${edited.reason}`);
854
668
  return 2;
855
669
  }
856
670
  const again = readWorkspace(current.dir);
@@ -860,6 +674,42 @@ export async function run(argv = [], options = {}) {
860
674
  return 2;
861
675
  }
862
676
  current = again.ws;
677
+ treeDir = current.repository?.dir ?? treeDir;
678
+ return null;
679
+ };
680
+ // A step can make an earlier one owed, so the chain is asked again until a pass applies nothing.
681
+ for (let pass = 0; ; pass++) {
682
+ let appliedNow = 0;
683
+ for (const entry of plan.entries) {
684
+ let asked = entry;
685
+ if (applied.size > 0 || entry.owed !== true) {
686
+ [asked] = (await planFor(current, ctx, [entry.step])).entries;
687
+ if (asked.owed === null) {
688
+ if (!undo()) return 2;
689
+ warn(`upgrade: ${entry.step.id} could not tell, after the steps before it — ${asked.because}. Rolled back`);
690
+ return 2;
691
+ }
692
+ }
693
+ byHand.delete(entry.step.id);
694
+ if (asked.owed !== true) continue;
695
+ if (asked.hand) {
696
+ byHand.set(entry.step.id, asked.because);
697
+ continue;
698
+ }
699
+ if (pass > 0 || entry.owed !== true) {
700
+ say(`upgrade: ${entry.step.id} (${entry.step.kind}) — ${entry.step.title}`);
701
+ say(`upgrade: ${asked.because}`);
702
+ }
703
+ const refused = await apply(entry);
704
+ if (refused !== null) return refused;
705
+ appliedNow += 1;
706
+ }
707
+ if (appliedNow === 0) break;
708
+ if (pass === plan.entries.length) {
709
+ if (!undo()) return 2;
710
+ warn(`upgrade: the steps did not settle — pass ${pass + 1} still applied ${appliedNow}, and a chain of ${plan.entries.length} settles in fewer. Rolled back`);
711
+ return 2;
712
+ }
863
713
  }
864
714
 
865
715
  // ---- and grade what it produced, with the real validator
@@ -886,9 +736,16 @@ export async function run(argv = [], options = {}) {
886
736
  return 1;
887
737
  }
888
738
 
889
- const written = [...new Set(snapshots.map((s) => s.file))];
890
- say(`upgrade: applied ${plan.owed} step(s) to ${shown} — ${written.join(", ")}. doctor is green`);
891
- return 0;
739
+ const named = (s) => {
740
+ const at = s.root === "tree" ? path.relative(current.dir, path.resolve(treeDir, s.file)).split(path.sep).join("/") : s.file;
741
+ return s.deleted ? `${at} (deleted)` : at;
742
+ };
743
+ const written = [...new Set(snapshots.map(named))];
744
+ if (applied.size > 0) say(`upgrade: applied ${applied.size} step(s) to ${shown} — ${written.join(", ")}. doctor is green`);
745
+ const left = plan.entries.filter((entry) => byHand.has(entry.step.id));
746
+ for (const entry of left) warn(`upgrade: ${entry.step.id} is owed and not placed — ${byHand.get(entry.step.id)}`);
747
+ offerCacheLifetime(current.manifest);
748
+ return left.length > 0 ? 1 : 0;
892
749
  }
893
750
 
894
751
  if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {