@sleepy_panda_srl/portulan 0.3.0 → 0.4.1

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 (90) hide show
  1. package/README.md +18 -6
  2. package/cli/README.md +56 -4
  3. package/cli/advisory.mjs +167 -53
  4. package/cli/cache-risk.mjs +69 -0
  5. package/cli/comments.mjs +8 -5
  6. package/cli/compile.mjs +492 -146
  7. package/cli/context.mjs +204 -43
  8. package/cli/doctor.mjs +211 -33
  9. package/cli/drills.mjs +32 -7
  10. package/cli/finish.md +34 -0
  11. package/cli/finish.mjs +9 -67
  12. package/cli/form.mjs +35 -19
  13. package/cli/frontmatter.mjs +39 -0
  14. package/cli/gate.mjs +14 -12
  15. package/cli/guidance-files.mjs +335 -0
  16. package/cli/handoff-policy.mjs +76 -0
  17. package/cli/html-entities.mjs +2130 -0
  18. package/cli/import-files.mjs +15 -0
  19. package/cli/index.mjs +77 -24
  20. package/cli/init-preview.mjs +553 -0
  21. package/cli/init.mjs +261 -46
  22. package/cli/instructions-journal.mjs +1185 -0
  23. package/cli/instructions.mjs +332 -57
  24. package/cli/ledger.mjs +123 -37
  25. package/cli/librarian.mjs +103 -19
  26. package/cli/persona-routing.mjs +209 -0
  27. package/cli/plugin-lint.mjs +77 -56
  28. package/cli/portulan.mjs +7 -2
  29. package/cli/recipe-output.mjs +91 -0
  30. package/cli/recipe-runner.md +68 -0
  31. package/cli/recipe-runner.mjs +131 -0
  32. package/cli/recipe-set.mjs +12 -3
  33. package/cli/ritual-contexts.mjs +103 -0
  34. package/cli/sessions.mjs +2 -1
  35. package/cli/spend-analysis.mjs +111 -0
  36. package/cli/spend-offer.md +53 -0
  37. package/cli/spend-offer.mjs +93 -0
  38. package/cli/stop-diagnostics.mjs +114 -0
  39. package/cli/stop-gate.mjs +133 -90
  40. package/cli/stop-policy.mjs +14 -0
  41. package/cli/symbols.mjs +55 -8
  42. package/cli/telemetry.mjs +19 -12
  43. package/cli/upgrade.mjs +62 -44
  44. package/cli/vendor.mjs +210 -126
  45. package/core/operating/context.md +368 -60
  46. package/core/operating/evolution.md +3 -2
  47. package/core/operating/loop.md +9 -4
  48. package/core/operating/sessions.md +6 -15
  49. package/core/personas/README.md +52 -10
  50. package/core/personas/implementer.md +3 -0
  51. package/core/personas/librarian.md +3 -0
  52. package/core/personas/reviewer.md +3 -0
  53. package/core/templates/handoff.md +6 -2
  54. package/evals/ab/personas/README.md +111 -0
  55. package/evals/ab/personas/acquisition.json +330 -0
  56. package/evals/ab/personas/bindings/implementer.txt +43 -0
  57. package/evals/ab/personas/bindings/librarian.txt +40 -0
  58. package/evals/ab/personas/bindings/reviewer.txt +41 -0
  59. package/evals/ab/personas/corpus.json +69 -0
  60. package/evals/ab/personas/grader.mjs +171 -0
  61. package/evals/ab/personas/implementer.json +774 -0
  62. package/evals/ab/personas/implementer.md +22 -0
  63. package/evals/ab/personas/librarian.json +758 -0
  64. package/evals/ab/personas/librarian.md +22 -0
  65. package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
  66. package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
  67. package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
  68. package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
  69. package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
  70. package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
  71. package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
  72. package/evals/ab/personas/reviewer.json +758 -0
  73. package/evals/ab/personas/reviewer.md +22 -0
  74. package/evals/releases/0.4.0.json +154 -0
  75. package/evals/releases/0.4.0.md +69 -0
  76. package/evals/releases/0.4.1.json +154 -0
  77. package/evals/releases/0.4.1.md +69 -0
  78. package/package.json +6 -2
  79. package/packs/rituals/checkpoints/README.md +35 -0
  80. package/packs/rituals/checkpoints/pack.json +3 -2
  81. package/packs/tools/local-llm/README.md +46 -6
  82. package/packs/tools/local-llm/runner/local-llm.mjs +127 -13
  83. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +26 -2
  84. package/spec/README.md +37 -4
  85. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
  86. package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
  87. package/spec/migrations/README.md +6 -0
  88. package/spec/pack.schema.json +12 -3
  89. package/spec/slots.md +262 -117
  90. package/spec/workspace.schema.json +67 -11
@@ -0,0 +1,553 @@
1
+ // Project guidance preview, shared by init and instruction migration.
2
+ import fs from "node:fs";
3
+ import { isAbsentImportError } from "./import-files.mjs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { preflightNestedEdits, readOwnedText } from "./guidance-files.mjs";
7
+ import { isInside } from "./inside.mjs";
8
+ import { alwaysTier } from "./context.mjs";
9
+ import {
10
+ compileGuidance,
11
+ guidanceUnits,
12
+ planGuidance,
13
+ GENERATED_DIRS,
14
+ NESTED_REGISTRY,
15
+ readNestedState,
16
+ parseUnit,
17
+ expandedBody,
18
+ importSpans,
19
+ importPath,
20
+ importTargetIsFile,
21
+ IMPORT_DEPTH,
22
+ LEADS_LINE,
23
+ GATES_LINE,
24
+ } from "./compile.mjs";
25
+
26
+ export function outsideTempParents(target) {
27
+ const owner = fs.realpathSync(target),
28
+ found = [];
29
+ for (const base of [os.tmpdir(), path.dirname(owner)]) {
30
+ try {
31
+ const real = fs.realpathSync(base);
32
+ if (!isInside(owner, real) && fs.statSync(real).isDirectory() && !found.includes(real)) found.push(real);
33
+ } catch {}
34
+ }
35
+ return found;
36
+ }
37
+
38
+ export function previewAlways(
39
+ target,
40
+ files,
41
+ { workspaceRel = ".portulan", hosts = ["claude-code", "agents-md"], checkWrites = true, migration = false } = {},
42
+ ) {
43
+ const root = path.resolve(target),
44
+ owner = fs.realpathSync(root);
45
+ const manifestRel = path.join(workspaceRel, "workspace.json");
46
+ const manifest = JSON.parse(
47
+ files.get(manifestRel)?.contents ?? fs.readFileSync(path.join(root, manifestRel), "utf8"),
48
+ );
49
+ const contextRel = path.join(workspaceRel, manifest.slots.context);
50
+ const removed = new Set([...files].filter(([, value]) => value.contents === null).map(([rel]) => rel));
51
+ for (const rel of [...removed]) {
52
+ try {
53
+ const real = fs.realpathSync(path.join(root, rel));
54
+ if (isInside(owner, real)) removed.add(path.relative(owner, real));
55
+ } catch {}
56
+ }
57
+ let unmeasured = false;
58
+ const before = hosts.map((host) => {
59
+ try {
60
+ return alwaysTier(root, { host });
61
+ } catch (error) {
62
+ throw new Error(
63
+ `${host === "agents-md" ? "AGENTS.md" : "Claude Code"} could not be measured: ${error.message}`,
64
+ );
65
+ }
66
+ });
67
+ for (const rel of [".claude", ".claude/rules", ".claude/rules/portulan", ".claude/skills"]) {
68
+ let stat;
69
+ try {
70
+ stat = fs.lstatSync(path.join(root, rel));
71
+ } catch (error) {
72
+ if (error.code === "ENOENT" || error.code === "ENOTDIR") continue;
73
+ throw error;
74
+ }
75
+ if (!stat.isDirectory()) throw new Error(`${rel} is not an ordinary compiler output directory`);
76
+ }
77
+ let box, view;
78
+ const candidates = outsideTempParents(target).sort(
79
+ (left, right) =>
80
+ Number(fs.statSync(right).dev === fs.statSync(owner).dev) -
81
+ Number(fs.statSync(left).dev === fs.statSync(owner).dev),
82
+ );
83
+ for (const base of candidates) {
84
+ try {
85
+ box = fs.mkdtempSync(path.join(base, ".portulan-init-preview-"));
86
+ view = path.join(box, path.basename(root));
87
+ fs.mkdirSync(view);
88
+ break;
89
+ } catch {}
90
+ }
91
+ if (!view) throw new Error("no writable scratch location outside the target");
92
+ const sourceLink = (file) => path.resolve(fs.realpathSync(path.dirname(file)), fs.readlinkSync(file));
93
+ const prospective = (file, seen = new Set()) => {
94
+ if (seen.has(file)) throw new Error("an import alias cannot be resolved");
95
+ seen.add(file);
96
+ let at = file,
97
+ suffix = [];
98
+ for (;;) {
99
+ let value;
100
+ try {
101
+ value = fs.lstatSync(at);
102
+ } catch (error) {
103
+ if (!isAbsentImportError(error)) throw error;
104
+ }
105
+ if (value?.isSymbolicLink()) return prospective(path.resolve(sourceLink(at), ...suffix), seen);
106
+ if (value) return path.resolve(fs.realpathSync(at), ...suffix);
107
+ const parent = path.dirname(at);
108
+ if (parent === at) throw new Error("an import cannot be resolved");
109
+ suffix.unshift(path.basename(at));
110
+ at = parent;
111
+ }
112
+ };
113
+ const writable = (rel, unlink = false) => {
114
+ if (!checkWrites) return;
115
+ const file = path.join(root, rel);
116
+ let value;
117
+ try {
118
+ value = fs.lstatSync(file);
119
+ } catch (error) {
120
+ if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error;
121
+ }
122
+ if (value && !unlink) {
123
+ if (!value.isFile() || value.nlink > 1)
124
+ throw new Error(`${rel} is not an ordinary singly linked write target`);
125
+ fs.accessSync(file, fs.constants.W_OK);
126
+ } else {
127
+ let dir = path.dirname(file);
128
+ while (!fs.existsSync(dir)) dir = path.dirname(dir);
129
+ fs.accessSync(dir, fs.constants.W_OK | fs.constants.X_OK);
130
+ }
131
+ };
132
+ const remains = (file) => {
133
+ const real = prospective(file);
134
+ return !(isInside(owner, real) && removed.has(path.relative(owner, real))) && importTargetIsFile(file);
135
+ };
136
+ const materialized = new Set(),
137
+ originalEntries = new Map();
138
+ const originalEntry = (file) => {
139
+ const parent = fs.statSync(path.dirname(file), { bigint: true }),
140
+ entry = fs.lstatSync(file, { bigint: true });
141
+ let name = "";
142
+ if (entry.nlink > 1n) {
143
+ name = path.basename(file);
144
+ try {
145
+ const names = fs.readdirSync(path.dirname(file));
146
+ if (!names.includes(name)) {
147
+ const folded = name.normalize("NFC").toLowerCase();
148
+ const actual = names.find((candidate) => {
149
+ if (candidate.normalize("NFC").toLowerCase() !== folded) return false;
150
+ const at = fs.lstatSync(path.join(path.dirname(file), candidate), { bigint: true });
151
+ return at.dev === entry.dev && at.ino === entry.ino;
152
+ });
153
+ if (actual !== undefined) name = actual;
154
+ }
155
+ } catch (error) {
156
+ if (error.code !== "EACCES" && error.code !== "EPERM") throw error;
157
+ }
158
+ }
159
+ return `${parent.dev}:${parent.ino}:${entry.dev}:${entry.ino}:${name}`;
160
+ };
161
+
162
+ const relative = (file) => {
163
+ if (isInside(root, file)) return path.relative(root, file);
164
+ if (isInside(owner, file)) return path.relative(owner, file);
165
+ throw new Error("a required input lies outside the project");
166
+ };
167
+ const stat = (file) => {
168
+ try {
169
+ return fs.lstatSync(file);
170
+ } catch (error) {
171
+ if (error.code === "ENOENT" || error.code === "ENOTDIR") return null;
172
+ throw error;
173
+ }
174
+ };
175
+ for (const [rel, file] of files)
176
+ if (file.contents === null && stat(path.join(root, rel)))
177
+ originalEntries.set(originalEntry(path.join(root, rel)), rel);
178
+ const directory = (file) => {
179
+ try {
180
+ return fs.statSync(file).isDirectory();
181
+ } catch (error) {
182
+ if (error.code === "ENOENT" || error.code === "ENOTDIR") return false;
183
+ throw error;
184
+ }
185
+ };
186
+ const link = (destination, next, kind) => {
187
+ const parent = fs.realpathSync(path.dirname(destination)),
188
+ target = path.resolve(next),
189
+ existing = stat(destination);
190
+ if (existing) {
191
+ if (!existing.isSymbolicLink() || path.resolve(parent, fs.readlinkSync(destination)) !== target)
192
+ throw new Error("conflicting projected link");
193
+ return;
194
+ }
195
+ fs.symlinkSync(path.relative(parent, target) || ".", destination, kind);
196
+ };
197
+ const parents = (rel, optional = false) => {
198
+ const parts = rel.split(path.sep);
199
+ for (let count = 1; count < parts.length; count++) {
200
+ const current = path.join(root, ...parts.slice(0, count)),
201
+ value = stat(current);
202
+ if (!value) break;
203
+ if (migration && value.isSymbolicLink()) {
204
+ const real = prospective(current);
205
+ if (!isInside(owner, real)) {
206
+ if (!optional) throw new Error(`${rel} has an outside parent alias`);
207
+ if (!isInside(owner, prospective(path.resolve(root, rel)))) return false;
208
+ fs.mkdirSync(path.join(view, path.relative(root, current)), { recursive: true });
209
+ continue;
210
+ }
211
+ const destination = path.join(view, path.relative(root, current)),
212
+ next = path.join(view, path.relative(owner, real));
213
+ if (!directory(real) && !directory(next)) {
214
+ if (optional) return false;
215
+ throw new Error(`${rel} has a missing or non-directory aliased parent`);
216
+ }
217
+ fs.mkdirSync(next, { recursive: true });
218
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
219
+ link(destination, next, "dir");
220
+ continue;
221
+ }
222
+ if (!value.isDirectory()) {
223
+ if (optional) return false;
224
+ throw new Error(`${path.relative(root, current)} has an unmodeled ancestor type or alias`);
225
+ }
226
+ }
227
+ return true;
228
+ };
229
+ const put = (rel, contents) => {
230
+ const file = path.join(view, rel);
231
+ fs.mkdirSync(path.dirname(file), { recursive: true });
232
+ fs.writeFileSync(file, contents);
233
+ materialized.add(rel);
234
+ };
235
+ const copy = (rel, required = false) => {
236
+ if (materialized.has(rel)) return Boolean(stat(path.join(view, rel)));
237
+ if (removed.has(rel)) return false;
238
+ const file = path.resolve(root, rel);
239
+ if (!isInside(root, file)) throw new Error("a required input leaves the project");
240
+ if (!parents(rel, migration && !required)) return false;
241
+ const value = stat(file);
242
+ if (!value) return false;
243
+ const identity = originalEntry(file),
244
+ observed = originalEntries.get(identity);
245
+ if (observed !== undefined) return Boolean(stat(path.join(view, observed)));
246
+ const resolved = prospective(file);
247
+ const canonical = isInside(owner, resolved) ? path.relative(owner, resolved) : null;
248
+ if (!value.isSymbolicLink() && canonical !== null && removed.has(canonical)) return false;
249
+ const destination = path.join(view, rel);
250
+ if (!value.isSymbolicLink() && canonical !== null && materialized.has(canonical))
251
+ return Boolean(stat(destination));
252
+ if (value.isSymbolicLink()) {
253
+ let real;
254
+ try {
255
+ real = fs.realpathSync(file);
256
+ } catch (error) {
257
+ if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error;
258
+ }
259
+ let next;
260
+ if (real) {
261
+ if (!isInside(owner, real)) {
262
+ if (required) throw new Error(`${rel} resolves outside its owner`);
263
+ return false;
264
+ }
265
+ if (fs.statSync(real).isDirectory()) {
266
+ if (migration && !required) return false;
267
+ throw new Error(`${rel} has an unmodeled directory alias`);
268
+ }
269
+ next = path.relative(owner, real);
270
+ } else {
271
+ const spelled = sourceLink(file);
272
+ next = relative(spelled);
273
+ if (!parents(next, migration && !required)) return false;
274
+ }
275
+ copy(next, required);
276
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
277
+ link(destination, path.join(view, next), "file");
278
+ materialized.add(rel);
279
+ originalEntries.set(identity, rel);
280
+ return true;
281
+ }
282
+ const real = fs.realpathSync(file);
283
+ if (!isInside(owner, real)) throw new Error(`${rel} resolves outside its owner`);
284
+ if (value.isDirectory()) fs.mkdirSync(destination, { recursive: true });
285
+ else if (value.isFile()) put(rel, fs.readFileSync(real));
286
+ else {
287
+ if (required) throw new Error(`${rel} is not a regular input`);
288
+ return false;
289
+ }
290
+ materialized.add(rel);
291
+ originalEntries.set(identity, rel);
292
+ return true;
293
+ };
294
+ const tree = (rel, select, recursive) => {
295
+ const value = stat(path.join(root, rel));
296
+ if (!value || !(value.isDirectory() || value.isSymbolicLink())) return;
297
+ if (!copy(rel)) return;
298
+ const dir = path.join(root, rel);
299
+ if (!fs.statSync(dir).isDirectory()) return;
300
+ for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
301
+ const child = path.join(rel, item.name);
302
+ if (item.isDirectory() && recursive) tree(child, select, true);
303
+ else if (select(item.name))
304
+ copy(child, child.startsWith(`.claude${path.sep}rules${path.sep}portulan${path.sep}`));
305
+ }
306
+ };
307
+ const absolute = (bare) => {
308
+ const real = prospective(bare);
309
+ if (isInside(owner, real)) {
310
+ if (migration) {
311
+ unmeasured = true;
312
+ return;
313
+ }
314
+ throw new Error("absolute project imports cannot be projected");
315
+ }
316
+ };
317
+ const projectImport = (original) => {
318
+ try {
319
+ const real = prospective(original);
320
+ if (!isInside(owner, real)) return null;
321
+ const rel = path.relative(owner, real), file = path.join(view, rel);
322
+ if (!stat(file)) copy(rel);
323
+ return importTargetIsFile(file) ? file : null;
324
+ } catch (error) {
325
+ if (isAbsentImportError(error)) return null;
326
+ throw error;
327
+ }
328
+ };
329
+ const follow = (seeds, strict = false) => {
330
+ const queue = seeds.map((seed) => ({ ...seed, depth: 0 })),
331
+ seen = new Set();
332
+ while (queue.length) {
333
+ const { rel, depth, text: supplied, origin } = queue.shift(),
334
+ file = path.join(view, rel);
335
+ if (!stat(file)) copy(rel, strict);
336
+ let real;
337
+ try {
338
+ real = fs.realpathSync(file);
339
+ } catch (error) {
340
+ if (error.code === "ENOENT" || error.code === "ENOTDIR") continue;
341
+ throw error;
342
+ }
343
+ if (!isInside(view, real) || !fs.statSync(real).isFile() || seen.has(real)) continue;
344
+ seen.add(real);
345
+ const text = supplied ?? fs.readFileSync(real, "utf8");
346
+ for (const { target: spelled, line } of importSpans(text)) {
347
+ const bare = importPath(spelled);
348
+ if (bare === null || bare.startsWith("~")) continue;
349
+ if (path.isAbsolute(bare)) {
350
+ absolute(bare);
351
+ continue;
352
+ }
353
+ if (strict && depth === 0 && line.trim() !== `@${spelled}`)
354
+ throw new Error(`${rel} has an import the compiler cannot express`);
355
+ if (!strict) {
356
+ const original = path.resolve(path.dirname(origin ?? path.join(root, rel)), bare);
357
+ const next = projectImport(original);
358
+ if (next && depth + 1 < IMPORT_DEPTH)
359
+ queue.push({ rel: path.relative(view, next), depth: depth + 1, origin: original });
360
+ continue;
361
+ }
362
+ const next = path.resolve(path.dirname(file), bare);
363
+ if (!isInside(view, next)) {
364
+ const original = path.resolve(root, path.dirname(rel), bare);
365
+ if (strict || isInside(owner, prospective(original)))
366
+ throw new Error(`${rel} has an unmodeled project re-entry`);
367
+ continue;
368
+ }
369
+ if (depth + 1 >= IMPORT_DEPTH) continue;
370
+ const nextRel = path.relative(view, next);
371
+ if (!stat(next)) copy(nextRel, strict);
372
+ queue.push({ rel: nextRel, depth: depth + 1 });
373
+ }
374
+ }
375
+ };
376
+ try {
377
+ for (const rel of ["CLAUDE.md", ".claude/CLAUDE.md", ...(hosts.includes("agents-md") ? ["AGENTS.md"] : [])])
378
+ copy(rel);
379
+ const nestedState = readNestedState(root, workspaceRel);
380
+ copy(NESTED_REGISTRY, true);
381
+ for (const row of nestedState.files) {
382
+ parents(row.target);
383
+ copy(row.target, true);
384
+ }
385
+ for (const edit of nestedState.cleanup) {
386
+ parents(edit.file);
387
+ copy(edit.file, true);
388
+ }
389
+ tree(
390
+ ".claude/rules",
391
+ (name) => name.endsWith(".md") || name === ".compiled" || name === path.basename(NESTED_REGISTRY),
392
+ true,
393
+ );
394
+ tree(".claude/commands", (name) => name.endsWith(".md"), true);
395
+ tree(".claude/agents", (name) => name.endsWith(".md"), false);
396
+ if (copy(".claude/skills") && fs.statSync(path.join(root, ".claude/skills")).isDirectory()) {
397
+ for (const item of fs.readdirSync(path.join(root, ".claude/skills"), { withFileTypes: true })) {
398
+ if (item.isDirectory() || item.isSymbolicLink())
399
+ copy(path.join(".claude/skills", item.name, "SKILL.md"));
400
+ }
401
+ }
402
+ for (const entry of before.flatMap((domain) => domain.entries)) {
403
+ if (isInside(root, entry.file) || isInside(owner, entry.file)) copy(relative(entry.file));
404
+ else projectImport(entry.file);
405
+ }
406
+ for (const name of GENERATED_DIRS) {
407
+ const rel = path.join(workspaceRel, name),
408
+ value = stat(path.join(root, rel));
409
+ if (value && !value.isDirectory()) throw new Error(`${rel} has an unmodeled compiler boundary`);
410
+ }
411
+ const sourceDir = path.join(root, contextRel);
412
+ if (stat(sourceDir)) {
413
+ copy(contextRel, true);
414
+ const scope = fs.realpathSync(path.join(root, workspaceRel));
415
+ for (const item of fs.readdirSync(sourceDir, { withFileTypes: true })) {
416
+ if (!item.name.endsWith(".md") || item.isDirectory()) continue;
417
+ if (!(item.isFile() || item.isSymbolicLink()))
418
+ throw new Error(`${item.name} is not a regular guidance source`);
419
+ const rel = path.join(contextRel, item.name),
420
+ real = fs.realpathSync(path.join(root, rel));
421
+ if (!isInside(scope, real)) throw new Error(`${rel} resolves outside its workspace`);
422
+ copy(rel, true);
423
+ }
424
+ }
425
+ for (const [rel, file] of files) {
426
+ if (!(file.ifAbsent && stat(path.join(root, rel)))) {
427
+ writable(rel);
428
+ if (file.contents === null) {
429
+ fs.rmSync(path.join(view, rel), { force: true });
430
+ materialized.add(rel);
431
+ } else put(rel, file.contents);
432
+ }
433
+ }
434
+ const context = path.join(view, contextRel);
435
+ for (const item of fs.readdirSync(context, { withFileTypes: true })) {
436
+ if (!item.name.endsWith(".md") || item.isDirectory()) continue;
437
+ if (!(item.isFile() || item.isSymbolicLink()))
438
+ throw new Error(`${item.name} is not a regular guidance source`);
439
+ const rel = path.join(contextRel, item.name),
440
+ file = path.join(view, rel);
441
+ const unit = parseUnit(path.basename(item.name, ".md"), readOwnedText(file, view), rel);
442
+ if (unit.target) {
443
+ parents(unit.target);
444
+ copy(unit.target, true);
445
+ }
446
+ if (unit.tier === "always") {
447
+ for (const line of unit.body.split("\n")) {
448
+ const leads = LEADS_LINE.exec(line),
449
+ gates = GATES_LINE.exec(line),
450
+ found = leads ?? gates;
451
+ if (!found) continue;
452
+ const bare = leads ? found[1].split("#")[0] : found[1];
453
+ if (path.isAbsolute(bare) && (!migration || !isInside(root, bare)))
454
+ throw new Error(`${rel} has an absolute directive dependency`);
455
+ const next = path.isAbsolute(bare)
456
+ ? path.resolve(view, path.relative(root, bare))
457
+ : path.resolve(path.dirname(file), bare);
458
+ if (!isInside(view, next)) throw new Error(`${rel} names a dependency outside the project`);
459
+ if (!stat(next)) copy(path.relative(view, next), true);
460
+ }
461
+ const text = expandedBody(unit, path.dirname(file), view, manifest.packs ?? [], { originRoot: root });
462
+ follow([{ rel, text }], true);
463
+ } else {
464
+ if (unit.tier === "on-invoke") {
465
+ const output = path.join(".claude/skills", unit.name, "SKILL.md");
466
+ parents(output);
467
+ const existing = stat(path.join(root, output));
468
+ if (existing && !existing.isFile())
469
+ throw new Error(`${output} is not an ordinary compiler output file`);
470
+ }
471
+ for (const { target: spelled } of importSpans(unit.body)) {
472
+ const bare = importPath(spelled);
473
+ if (bare === null || bare.startsWith("~") || path.isAbsolute(bare)) continue;
474
+ const next = path.resolve(path.dirname(file), bare);
475
+ if (!isInside(view, next)) {
476
+ if (!migration) throw new Error(`${rel} has an unmodeled import probe`);
477
+ if (remains(path.resolve(root, path.dirname(rel), bare)))
478
+ throw new Error(`${rel} has an import target that the compiler refuses`);
479
+ continue;
480
+ }
481
+ const original = path.join(root, path.relative(view, next));
482
+ const resolved = prospective(original);
483
+ const projected = isInside(owner, resolved)
484
+ ? path.join(view, path.relative(owner, resolved))
485
+ : null;
486
+ if ([next, projected].filter(Boolean).some(importTargetIsFile) || remains(original)) {
487
+ throw new Error(`${rel} has an import target that the compiler refuses`);
488
+ }
489
+ }
490
+ }
491
+ }
492
+ const plan = planGuidance(guidanceUnits(view, workspaceRel, { originRoot: root }), view, workspaceRel);
493
+ for (const file of plan.owedFiles) {
494
+ if (migration && !file.nested && !file.control) {
495
+ const original = path.join(root, file.path),
496
+ entry = stat(original);
497
+ if (entry?.isFile() && entry.nlink !== 1 && !fs.readFileSync(original).equals(Buffer.from(file.text)))
498
+ throw new Error(`${file.path} is a multiply linked guidance output`);
499
+ }
500
+ parents(file.path);
501
+ if (!file.nested && !file.control) writable(file.path);
502
+ }
503
+ for (const file of plan.stray.filter((file) => file.removable && !file.nested)) {
504
+ if (migration) {
505
+ const entry = stat(path.join(root, file.path));
506
+ if (entry?.isFile() && entry.nlink !== 1)
507
+ throw new Error(`${file.path} is a multiply linked guidance output`);
508
+ }
509
+ writable(file.path, true);
510
+ }
511
+ if (checkWrites) preflightNestedEdits(root, plan.nested.edits);
512
+ compileGuidance(path.join(view, workspaceRel), { originRoot: root });
513
+ if (compileGuidance(path.join(view, workspaceRel), { check: true, originRoot: root }).drifted)
514
+ throw new Error("the prospective source set did not regenerate clean guidance");
515
+ const nativeOrigins = new Map();
516
+ const materializeImport = ({ source, bare, depth }) => {
517
+ if (path.isAbsolute(bare)) {
518
+ absolute(bare);
519
+ return null;
520
+ }
521
+ const from = depth === 0 ? path.join(root, path.relative(view, source))
522
+ : nativeOrigins.get(source) ?? path.join(root, path.relative(view, source));
523
+ const original = path.resolve(path.dirname(from), bare), file = projectImport(original);
524
+ if (file && !nativeOrigins.has(file)) nativeOrigins.set(file, original);
525
+ return file;
526
+ };
527
+ return {
528
+ before: before.map((domain) => domain.entries.reduce((sum, entry) => sum + entry.bytes, 0)),
529
+ after: (() => {
530
+ const values = hosts.map((host) =>
531
+ alwaysTier(view, { host, materializeImport }).entries.reduce((sum, entry) => sum + entry.bytes, 0),
532
+ );
533
+ return unmeasured ? null : values;
534
+ })(),
535
+ };
536
+ } catch (error) {
537
+ throw new Error(error.message.replaceAll(view, root));
538
+ } finally {
539
+ fs.rmSync(box, { recursive: true, force: true });
540
+ }
541
+ }
542
+
543
+ export function previewMigration(tree, workspaceDir, edits) {
544
+ const workspaceRel = path.relative(tree, workspaceDir) || ".";
545
+ const files = new Map([
546
+ [
547
+ path.join(workspaceRel, "workspace.json"),
548
+ { contents: fs.readFileSync(path.join(workspaceDir, "workspace.json"), "utf8") },
549
+ ],
550
+ ...edits.map((edit) => [edit.file, { contents: edit.next }]),
551
+ ]);
552
+ return previewAlways(tree, files, { workspaceRel, hosts: ["claude-code"], checkWrites: false, migration: true });
553
+ }