@sleepy_panda_srl/portulan 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +5 -0
  3. package/README.md +87 -0
  4. package/cli/README.md +337 -0
  5. package/cli/compile.mjs +2209 -0
  6. package/cli/control-chars.mjs +570 -0
  7. package/cli/discover.mjs +985 -0
  8. package/cli/doctor.mjs +2803 -0
  9. package/cli/feedback.mjs +970 -0
  10. package/cli/gate.mjs +271 -0
  11. package/cli/index.mjs +1390 -0
  12. package/cli/init.mjs +1729 -0
  13. package/cli/librarian.mjs +1384 -0
  14. package/cli/manifest.mjs +59 -0
  15. package/cli/new.mjs +803 -0
  16. package/cli/pack-version.mjs +543 -0
  17. package/cli/plugin-lint.mjs +1566 -0
  18. package/cli/portulan.mjs +266 -0
  19. package/cli/recipe-set.mjs +425 -0
  20. package/cli/rule-carriers.mjs +489 -0
  21. package/cli/skills-set.mjs +715 -0
  22. package/cli/stop-gate.mjs +434 -0
  23. package/cli/upgrade.mjs +897 -0
  24. package/cli/vendor.mjs +1504 -0
  25. package/core/README.md +46 -0
  26. package/core/engine.md +47 -0
  27. package/core/operating/autonomy.md +100 -0
  28. package/core/operating/evolution.md +84 -0
  29. package/core/operating/loop.md +83 -0
  30. package/core/operating/memory.md +161 -0
  31. package/core/operating/safety.md +42 -0
  32. package/core/operating/verification.md +76 -0
  33. package/core/personas/README.md +40 -0
  34. package/core/personas/implementer.md +51 -0
  35. package/core/personas/librarian.md +73 -0
  36. package/core/personas/reviewer.md +52 -0
  37. package/core/skills/README.md +52 -0
  38. package/core/skills/clarify/SKILL.md +42 -0
  39. package/core/skills/codify/SKILL.md +65 -0
  40. package/core/skills/consolidate/SKILL.md +121 -0
  41. package/core/templates/gate-policy.md +76 -0
  42. package/core/templates/handoff.md +29 -0
  43. package/core/templates/memory-entry.md +44 -0
  44. package/core/templates/pack.md +94 -0
  45. package/core/templates/persona.md +69 -0
  46. package/core/templates/proposal.md +27 -0
  47. package/core/templates/repo-card.md +28 -0
  48. package/core/templates/skill.md +57 -0
  49. package/core/templates/task.md +26 -0
  50. package/core/templates/workspace.md +81 -0
  51. package/package.json +41 -0
  52. package/packs/.claude-plugin/plugin.json +22 -0
  53. package/packs/README.md +61 -0
  54. package/packs/rituals/README.md +13 -0
  55. package/packs/rituals/checkpoints/README.md +105 -0
  56. package/packs/rituals/checkpoints/pack.json +36 -0
  57. package/packs/rituals/checkpoints/personas/supervisor.md +64 -0
  58. package/packs/rituals/checkpoints/skills/milestone-close/SKILL.md +75 -0
  59. package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +75 -0
  60. package/packs/rituals/checkpoints/skills/session-open/SKILL.md +57 -0
  61. package/packs/stacks/README.md +8 -0
  62. package/packs/tools/README.md +17 -0
  63. package/packs/tools/github/README.md +65 -0
  64. package/packs/tools/github/pack.json +29 -0
  65. package/packs/tools/github/verify/README.md +51 -0
  66. package/packs/tools/github/verify/actions-pinned.sh +132 -0
  67. package/spec/README.md +399 -0
  68. package/spec/migrations/0001-repository-declares-its-tree.mjs +100 -0
  69. package/spec/migrations/0002-bundle-fallback-path.mjs +179 -0
  70. package/spec/migrations/README.md +95 -0
  71. package/spec/pack.schema.json +201 -0
  72. package/spec/slots.md +796 -0
  73. package/spec/workspace.schema.json +438 -0
package/cli/init.mjs ADDED
@@ -0,0 +1,1729 @@
1
+ #!/usr/bin/env node
2
+ // `init` — the subcommand that drafts a workspace for a repository that has none.
3
+ //
4
+ // `docs/plan.md` row 7: *"`init` asks where this repository's workspace resides — in the repository,
5
+ // or in a workspace that names it — and writes a full workspace or a pointer accordingly."* That verb
6
+ // is the design. This tool has **no default residence**: the one thing the row says it asks is the one
7
+ // thing it may not decide, and the wrong guess is the dual-management shape
8
+ // `../.portulan/proposals/0017-one-repository-one-governing-workspace.md` exists to refuse.
9
+ //
10
+ // ## What it writes, and what it refuses to write
11
+ //
12
+ // It drafts. `docs/vision.md` carries *"No auto-generated curated context. `init` drafts; humans
13
+ // accept"* as a binding non-goal, so every file here is a starting point with the questions left
14
+ // visible in it — never a filled-in workspace that reads as finished. The two hard refusals:
15
+ //
16
+ // **It never overwrites a residence.** A repository already carrying a workspace or a pointer is
17
+ // governed, and replacing that is not onboarding — it is the switch, which `cli/vendor.mjs` carries as
18
+ // of 2026-08-03, when the maintainer widened `vendor`'s gloss and settled the verb he had deliberately
19
+ // left unassigned on 2026-07-31. So `init` stops, says which residence it found, and names the tool
20
+ // whose job the switch is.
21
+ //
22
+ // **It refuses rather than emitting a manifest a validator would misread.** A governor or a name that
23
+ // is not a slug is caught here, at the boundary, because `doctor`'s cross-repository check still
24
+ // mis-reports an empty governor as a *conflicting* one
25
+ // ([#141](https://github.com/sleepy-panda-srl/portulan/issues/141)). That bug is `doctor`'s to fix;
26
+ // what this tool owes is that it can never be the thing that produced the input.
27
+ //
28
+ // ## Two capabilities this draft deliberately does not claim
29
+ //
30
+ // **The session-end gate is drafted as a binding, not as a wire — and the reason has CHANGED, so the
31
+ // paragraph is rewritten rather than left standing.** It used to be that `cli/compile.mjs` emitted a
32
+ // `Stop` hook naming `.portulan/compile/stop.mjs`, which was customer zero's own file and shipped in no
33
+ // published artifact, so a drafted workspace's compiled hook would have pointed at a file the adopter
34
+ // does not have — a gate existing only as a sentence, since a missing hook **fails open** (measured,
35
+ // CLI 2.1.220). That was the state the maintainer's 2026-07-31 ruling waited on: decide the runner's
36
+ // residence when there is a concrete mechanism to point at.
37
+ //
38
+ // **The mechanism now exists.** Both runners live in `cli/`, ship in the published package, and
39
+ // `compile` emits a path that resolves from a checkout and from a project-local install. What `init`
40
+ // still does not do is *run* `compile` over what it drafts — so a drafted workspace has the binding and
41
+ // no compiled hooks until its human runs `compile` themselves, which is the honest remaining gap and is
42
+ // what `cli/init.test.mjs` asserts.
43
+ //
44
+ // **The drafted README said the opposite for one pull request.** It told every adopter the runner "is not
45
+ // shipped in any artifact you have received" and that "nothing here compiles to a working Stop hook" —
46
+ // true before the runners moved, false the moment they did, and it survived a fourteen-carrier sweep
47
+ // because it describes the mechanism without naming the files the sweep was grepping for. The worst
48
+ // shape available: a false claim emitted into somebody else's tree, telling them a rail they now have is
49
+ // one they do not.
50
+ //
51
+ // **The interview asks where somebody is there to answer — milestone 7 session 7.** `docs/vision.md`
52
+ // glosses `init` as *interview + codebase scan*, and until this session the second half shipped and the
53
+ // first did not: the substrate — every question modelled as an answer, with a validator each — was
54
+ // built first precisely because a prompt loop cannot be run by CI, by a test, or by a headless host.
55
+ // It now drives a loop, under two conditions that keep both halves honest: the interview runs only
56
+ // where **stdin and stdout are both TTYs**, so every non-interactive invocation is byte-for-byte what
57
+ // it was before; and it decides nothing the flags path could not decide, so the two are one tool with
58
+ // two front doors rather than two tools. `--no-interview` is the escape for a terminal that wants the
59
+ // refusals. _(This paragraph read "there is no interactive interview yet" and left whether flags
60
+ // satisfy the gloss to the maintainer at milestone 7's close. The question is retired rather than
61
+ // answered: the loop exists, so nobody has to rule on whether its absence was acceptable.)_
62
+ //
63
+ // ## Exit codes
64
+ //
65
+ // `0` it wrote · `2` it wrote nothing. There is deliberately **no 1**: this tool renders no verdict
66
+ // about anybody's workspace, so it has no red to report. `compile` documents the same asymmetry from
67
+ // the other side — writing never returns 1, because a run that rewrites an artifact has nothing to
68
+ // disagree with.
69
+ //
70
+ // _The second code read "it could not run" until the interview arrived, and the interview introduced a
71
+ // way to write nothing that is not a failure at all: a human declining at the confirmation, or ending
72
+ // the input. Widening the sentence is the honest repair — `0` must keep meaning *it wrote*, since
73
+ // callers chain on it, and a decline is nobody's verdict. So the code is the same and the gloss now
74
+ // covers every way of reaching it._
75
+
76
+ import fs from "node:fs";
77
+ import path from "node:path";
78
+ import { fileURLToPath, pathToFileURL } from "node:url";
79
+
80
+ // Host plugin-cache discovery (#123). `init` composes a checkpoints pack by default — row 7 clause (a)
81
+ // — so it is the tool that most needed a root it did not have to be told.
82
+ import { AUTO, discoverPackRoots, namedWithAuto } from "./discover.mjs";
83
+
84
+ // The handoff index is GENERATED, and this is the generator — the same one `index --check` compares
85
+ // against. Imported rather than approximated: a draft that wrote its own version of this file would be
86
+ // a second renderer of one artifact, and the drafted rail would go red on the adopter's first run
87
+ // against a difference this tool invented. `cli/index.mjs` imports nothing from here, so the direction
88
+ // is one-way.
89
+ import { renderHandoffIndex } from "./index.mjs";
90
+
91
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
92
+
93
+ /** Every refusal in this file. Carrying a class rather than a string lets `run` map all of them to 2. */
94
+ export class InitError extends Error {}
95
+
96
+ // ONE definition of a slug, read from the contract that publishes it rather than written out again.
97
+ //
98
+ // A second copy here would be free to drift from `spec/workspace.schema.json`, and this tool's whole
99
+ // job at the boundary is to refuse exactly what that schema refuses. The read works in both layouts —
100
+ // from a checkout `spec/` is one level up from `cli/`, and the published package keeps that shape
101
+ // because `files` ships both under the package root.
102
+ //
103
+ // Unreadable is not fatal at import time, because a module that throws while loading gives the entry
104
+ // point nothing better than a stack trace. It becomes a could-not-run inside `run`, which is where a
105
+ // user can be told what is missing.
106
+ let SCHEMA_ERROR = null;
107
+ export const SLUG = (() => {
108
+ try {
109
+ const file = path.join(HERE, "..", "spec", "workspace.schema.json");
110
+ return new RegExp(JSON.parse(fs.readFileSync(file, "utf8")).$defs.slug.pattern);
111
+ } catch (error) {
112
+ SCHEMA_ERROR = error;
113
+ return null;
114
+ }
115
+ })();
116
+
117
+ /** The Workspace Definition this tool writes against. A pointer needs 2.7 — the version that added it. */
118
+ const SPEC = "2.7";
119
+
120
+ /** The gate-policy spec `cli/compile.mjs` reads. Checked by its own suite, not guessed at here. */
121
+ const GATE_POLICY_SPEC = "2.2";
122
+
123
+ const RESIDENCES = new Set(["in-repo", "pointer"]);
124
+
125
+ /** The pack composed by default when the cycle is scaffolded. The workspace names it; core names none. */
126
+ const DEFAULT_CHECKPOINTS = "rituals/checkpoints";
127
+
128
+ // Every key an answers file may carry, and the flag each corresponds to. Declared as data because the
129
+ // file reader and the flag parser must agree about what an answer IS — two lists is how a `residnce`
130
+ // gets silently dropped and the tool then asks for something the adopter believes they supplied.
131
+ const ANSWER_KEYS = new Set(["residence", "name", "summary", "governed-by", "feed", "checkpoints", "cycle", "pack-root"]);
132
+
133
+ /**
134
+ * A directory name, or any string, reduced to the schema's slug shape.
135
+ *
136
+ * Returns `null` rather than a fallback when nothing survives: a workspace called `workspace` because
137
+ * the directory was `---` is a name nobody chose, and `init` asks instead.
138
+ */
139
+ export function slugify(text) {
140
+ const slug = String(text ?? "")
141
+ .toLowerCase()
142
+ .replace(/[^a-z0-9]+/g, "-")
143
+ .replace(/^-+|-+$/g, "");
144
+ return slug === "" ? null : slug;
145
+ }
146
+
147
+ // ------------------------------------------------------------------------- the command line
148
+
149
+ /**
150
+ * Splits argv into flags and the single target directory.
151
+ *
152
+ * Throws rather than returning a half-parsed shape: a flag whose value is missing must not silently
153
+ * consume the next flag, which is the parsing bug that turns `--name --residence in-repo` into a
154
+ * workspace named `--residence`.
155
+ */
156
+ export function parseArgs(argv) {
157
+ const flags = {};
158
+ const targets = [];
159
+ let help = false;
160
+
161
+ const VALUED = new Set(["--residence", "--name", "--summary", "--governed-by", "--feed", "--checkpoints", "--answers", "--pack-root"]);
162
+
163
+ for (let i = 0; i < argv.length; i++) {
164
+ const arg = argv[i];
165
+ if (arg === "--help" || arg === "-h") {
166
+ help = true;
167
+ } else if (arg === "--no-cycle") {
168
+ flags.cycle = false;
169
+ } else if (arg === "--no-interview") {
170
+ // Not an answer, so it is deliberately outside ANSWER_KEYS and cannot arrive in an answers
171
+ // file: it governs how the answers are collected, and a file of answers has already
172
+ // collected them.
173
+ flags.noInterview = true;
174
+ } else if (VALUED.has(arg)) {
175
+ const value = argv[i + 1];
176
+ // ANY leading `-` is a missing value, not `--` only. `cli/doctor.mjs` already guards this
177
+ // way and `init` was the outlier: `init --residence -h <dir>` consumed `-h` as the
178
+ // residence and then complained that `-h` is not one, which blames the user for a token
179
+ // they typed as a flag. A help request is the likeliest thing to land here, and it was
180
+ // the likeliest thing to be eaten. Found by review on the pull request.
181
+ //
182
+ // A value that genuinely begins with `-` is not lost: `--answers` carries it, where the
183
+ // shape is a JSON string and nothing has to guess where a flag ends.
184
+ if (value === undefined || value.startsWith("-")) {
185
+ throw new InitError(
186
+ `\`${arg}\` needs a value and the next argument is \`${value ?? "(nothing)"}\` — refusing to read a ` +
187
+ `flag as one. If the value really does start with \`-\`, pass it through \`--answers\`, where it ` +
188
+ `is a JSON string and nothing has to guess.`,
189
+ );
190
+ }
191
+ // An EMPTY value is given-but-invalid, and it is refused here for every flag rather than
192
+ // per-field. `--summary ""` used to pass straight through `??` into the manifest, where
193
+ // the schema's `minLength: 1` made `doctor` red on a workspace `init` had just reported
194
+ // writing successfully — the one outcome this tool exists to make impossible. No flag
195
+ // here has a meaningful empty value, so one rule covers all of them.
196
+ if (value.trim() === "") {
197
+ throw new InitError(
198
+ `\`${arg}\` was given an empty value. Every answer this tool writes into a manifest has to be ` +
199
+ `non-empty — an empty one validates nowhere — so it is refused rather than treated as unasked.`,
200
+ );
201
+ }
202
+ const key = arg.slice(2);
203
+ if (key === "pack-root") (flags["pack-root"] ??= []).push(value);
204
+ else flags[key] = value;
205
+ i++;
206
+ } else if (arg.startsWith("-")) {
207
+ // #155: this said "run `init --help`", which is not a command anybody can run. The tool is
208
+ // genuinely reachable two ways and the honest string differs per invocation, so both are
209
+ // named rather than one being derived — deriving it would mean the entry point telling the
210
+ // subcommand what name it was invoked under, and that entry point deliberately passes argv
211
+ // and nothing else, so the tool behaves identically either way. A better sentence is not
212
+ // worth trading that property for. The same fix is applied to `usage()` below.
213
+ throw new InitError(
214
+ `unknown option \`${arg}\` — run \`portulan init --help\` for the ones this understands, ` +
215
+ `or \`node cli/init.mjs --help\` from a checkout`,
216
+ );
217
+ } else {
218
+ targets.push(arg);
219
+ }
220
+ }
221
+
222
+ if (targets.length > 1) {
223
+ throw new InitError(
224
+ `${targets.length} target directories given (${targets.join(", ")}) — \`init\` drafts one workspace for one ` +
225
+ `repository, and picking one of two would be choosing which repository gets governed`,
226
+ );
227
+ }
228
+
229
+ return { help, flags, target: targets[0] ?? null };
230
+ }
231
+
232
+ /**
233
+ * Merges an `--answers` file under the flags, and refuses anything it does not recognise.
234
+ *
235
+ * The nearer answer wins: a flag typed on the command line overrides the same key in the file, which
236
+ * is the resolution cascade's own direction — more specific beats more general.
237
+ */
238
+ export function resolveAnswers(flags) {
239
+ let fromFile = {};
240
+ if (flags.answers) {
241
+ let raw;
242
+ try {
243
+ raw = fs.readFileSync(flags.answers, "utf8");
244
+ } catch (error) {
245
+ throw new InitError(`could not read the answers file \`${flags.answers}\` — ${error.message}`);
246
+ }
247
+ try {
248
+ fromFile = JSON.parse(raw);
249
+ } catch (error) {
250
+ throw new InitError(`the answers file \`${flags.answers}\` is not valid JSON — ${error.message}`);
251
+ }
252
+ if (!fromFile || typeof fromFile !== "object" || Array.isArray(fromFile)) {
253
+ throw new InitError(`the answers file \`${flags.answers}\` must hold a JSON object of answers`);
254
+ }
255
+ // Refused rather than ignored, on the schema's own `additionalProperties: false` reasoning: the
256
+ // common case is a typo, and a silently-dropped answer leaves the tool asking for something the
257
+ // adopter believes they gave.
258
+ const unknown = Object.keys(fromFile).filter((key) => !ANSWER_KEYS.has(key));
259
+ if (unknown.length) {
260
+ throw new InitError(
261
+ `the answers file carries ${unknown.map((k) => `\`${k}\``).join(", ")}, which is not an answer this tool asks for ` +
262
+ `(it asks: ${[...ANSWER_KEYS].join(", ")}). A misspelt key is an answer nobody receives.`,
263
+ );
264
+ }
265
+ // The VALUES are checked too, not only the keys. Lecturing about a misspelt key while
266
+ // accepting `"cycle": "false"` — a truthy string that composes the pack the adopter was
267
+ // trying to switch off — checks the half that is easy to check and lets the half that
268
+ // changes behaviour through.
269
+ for (const [key, value] of Object.entries(fromFile)) {
270
+ const expected = key === "cycle" ? "boolean" : "string";
271
+ const actual = Array.isArray(value) ? "array" : typeof value;
272
+ const arrayOk = key === "pack-root" && Array.isArray(value) && value.every((v) => typeof v === "string" && v.trim() !== "");
273
+ if (arrayOk) continue;
274
+ if (actual !== expected) {
275
+ throw new InitError(
276
+ `the answers file gives \`${key}\` as ${actual === "array" ? "an array" : `a ${actual}`} (${JSON.stringify(value)}), ` +
277
+ `and this tool reads it as ${expected === "boolean" ? "a boolean" : "a string"}. \`"cycle": "false"\` is a ` +
278
+ `non-empty string, which is true — the answer would do the opposite of what it reads as.`,
279
+ );
280
+ }
281
+ if (expected === "string" && value.trim() === "") {
282
+ throw new InitError(`the answers file gives \`${key}\` as an empty string, which validates nowhere — refused rather than read as unasked`);
283
+ }
284
+ }
285
+ }
286
+
287
+ const merged = { ...fromFile };
288
+ for (const [key, value] of Object.entries(flags)) {
289
+ // Neither is an answer. `--answers` names where answers come from and `--no-interview` says
290
+ // how they are collected, and letting either into `given` would put a flag nobody answered
291
+ // into the set the misplaced-option refusal reads.
292
+ if (key === "answers" || key === "noInterview") continue;
293
+ merged[key] = value;
294
+ }
295
+
296
+ // WHICH keys were actually supplied, kept apart from what they resolved to. `cycle` and
297
+ // `checkpoints` have defaults, so their values cannot tell a caller who typed them from a caller
298
+ // who did not — and the residence check below must refuse only what somebody asked for. A
299
+ // default that triggered a refusal would be the tool objecting to its own choice.
300
+ const given = new Set(Object.keys(merged));
301
+
302
+ return {
303
+ given,
304
+ residence: merged.residence ?? null,
305
+ name: merged.name ?? null,
306
+ summary: merged.summary ?? null,
307
+ governedBy: merged["governed-by"] ?? null,
308
+ feed: merged.feed ?? null,
309
+ checkpoints: merged.checkpoints ?? DEFAULT_CHECKPOINTS,
310
+ cycle: merged.cycle !== false,
311
+ // NORMALISED to an array, because the two sources disagree about shape. The flag is
312
+ // repeatable and accumulates into one; an answers file may reasonably give a single string,
313
+ // which the value check above accepts as a string like every other key. Everything
314
+ // downstream — `.length`, `.map`, `packResolves`'s `.some` — is array-shaped, so a string
315
+ // reaching here turned a perfectly valid answers file into `roots.some is not a function`:
316
+ // a real answer, refused with a message about somebody else's bug. Found by review on the
317
+ // pull request. Normalising here rather than at each use keeps one shape after this line.
318
+ packRoots: [merged["pack-root"] ?? []].flat(),
319
+ };
320
+ }
321
+
322
+ /** Everything that must be true before a single byte is written. Each refusal names what to type. */
323
+ export function validateAnswers(answers) {
324
+ // The named+auto pair, refused for EVERY path rather than inside the one branch that resolves a
325
+ // pack. It lived in the `residence === "in-repo" && cycle && packRoots.length` arm until Copilot's
326
+ // round 3, so `--no-cycle` with both flags was accepted and one of them silently ignored —
327
+ // breaking the *refused in all five tools* claim that change made, in the fifth tool. _(Five was the
328
+ // set on #233; it is seven since `recipe-set` and this file joined. Kept as the round's own words
329
+ // rather than re-typed, and dated so it does not read as a present count.)_
330
+ //
331
+ // Third time this session that a correct refusal was placed where something could skip it: below a
332
+ // workspace read in `compile`, below a manifest read in `skills-set`, and inside a conditional
333
+ // here. The rule the three share is that a judgement about the COMMAND LINE belongs where the
334
+ // command line is assembled, not where its subject is used.
335
+ const bothAsked = namedWithAuto(
336
+ (answers.packRoots ?? []).filter((r) => r !== AUTO),
337
+ (answers.packRoots ?? []).includes(AUTO),
338
+ );
339
+ if (bothAsked) throw new InitError(bothAsked);
340
+ if (!answers.residence) {
341
+ throw new InitError(
342
+ "no residence given — `init` asks where this repository's workspace lives and will not choose for you. " +
343
+ "Pass `--residence in-repo` to write a full workspace into this repository, or `--residence pointer` " +
344
+ "with `--governed-by <workspace>` to record that another workspace governs it. A repository is " +
345
+ "governed by exactly one workspace, so this answer is the one that cannot be defaulted.",
346
+ );
347
+ }
348
+ if (!RESIDENCES.has(answers.residence)) {
349
+ throw new InitError(`\`${answers.residence}\` is not a residence — the two are \`in-repo\` and \`pointer\``);
350
+ }
351
+ if (answers.name !== null && !SLUG.test(answers.name)) {
352
+ throw new InitError(
353
+ `the workspace name \`${answers.name}\` is not a slug — lowercase letters, digits and single hyphens ` +
354
+ `(${SLUG.source}). A workspace ships as a plugin through a feed, so its name is an identifier rather than a title.`,
355
+ );
356
+ }
357
+ // ACCEPTED-BUT-IGNORED is refused, because this file's header claims it refuses what it cannot
358
+ // act on and that claim has to be true. `--feed` and `--governed-by` mean nothing to a full
359
+ // workspace; `--pack-root`, `--checkpoints` and `--no-cycle` mean nothing to a pointer, which
360
+ // composes nothing. Silently dropping them lets a caller — or an answers file nobody re-reads —
361
+ // believe an option had an effect it never had, which is the same defect as `--summary ""`
362
+ // reaching a manifest: an answer accepted and then not honoured. Found by review on the pull
363
+ // request. Keyed on what was GIVEN, never on the resolved value, so a default never trips it.
364
+ const misplaced = {
365
+ "in-repo": ["feed", "governed-by"],
366
+ pointer: ["pack-root", "checkpoints", "cycle"],
367
+ }[answers.residence].filter((key) => answers.given.has(key));
368
+ if (misplaced.length) {
369
+ const spelling = (key) => (key === "cycle" ? "`--no-cycle`" : `\`--${key}\``);
370
+ throw new InitError(
371
+ `${misplaced.map(spelling).join(" and ")} ${misplaced.length > 1 ? "do" : "does"} nothing with ` +
372
+ `\`--residence ${answers.residence}\` — ${
373
+ answers.residence === "pointer"
374
+ ? "a pointer holds no policy of its own, so it composes no packs"
375
+ : "a workspace that lives here has no governor and no feed to be delivered from"
376
+ }. Refused rather than ignored: an option accepted and then dropped is one you will believe had an effect.`,
377
+ );
378
+ }
379
+
380
+ if (answers.residence === "pointer") {
381
+ // Absent and invalid are different answers and take different refusals: telling an adopter
382
+ // who typed a malformed governor to pass the flag they just passed sends them to the wrong
383
+ // place. The EMPTY spelling never reaches here — the command line refuses it earlier, where
384
+ // the clearer sentence lives — so this branch is the omission and the one below is the
385
+ // malformation. Both matter because an empty or non-string governor is exactly the value
386
+ // `doctor` still mis-reports as a *conflicting* one (#141).
387
+ if (answers.governedBy === null) {
388
+ throw new InitError(
389
+ "a pointer must name the workspace that governs this repository — pass `--governed-by <workspace>`. " +
390
+ "A pointer that names nothing governs nothing, and is indistinguishable from a repository that " +
391
+ "never adopted Portulan.",
392
+ );
393
+ }
394
+ if (!SLUG.test(answers.governedBy)) {
395
+ throw new InitError(
396
+ `the governing workspace \`${answers.governedBy}\` is not a slug — lowercase letters, digits and single ` +
397
+ `hyphens (${SLUG.source}). It must be spelled exactly as that workspace's own \`name\`, or the pointer ` +
398
+ `and its target have drifted into two identifiers.`,
399
+ );
400
+ }
401
+ }
402
+ if (answers.residence === "in-repo" && answers.cycle) {
403
+ const parts = String(answers.checkpoints).split("/");
404
+ if (parts.length !== 2 || !parts.every((part) => SLUG.test(part))) {
405
+ throw new InitError(
406
+ `\`${answers.checkpoints}\` is not a pack id — a pack is \`<category>/<name>\`, each a slug, as ` +
407
+ `\`spec/pack.schema.json\` defines it. Composing an id nothing can resolve would put a red in the ` +
408
+ `adopter's first run for a reason they did not cause.`,
409
+ );
410
+ }
411
+ }
412
+ }
413
+
414
+ // ------------------------------------------------------------------------- the codebase scan
415
+
416
+ /**
417
+ * Reads what the repository actually says about itself. **Observations only.**
418
+ *
419
+ * The failure an onboarding tool is most likely to commit is the confident default: emitting
420
+ * `make test` because most repositories have one. `doctor` lints repo-card build and test claims
421
+ * against the tree, so an invented claim is a red the adopter did not cause — and, worse, a workspace
422
+ * that lies about them on the day it was created. Every field here is either read out of a file that
423
+ * exists or left `null`, and `null` is written into the draft as *not determined* rather than dropped.
424
+ */
425
+ export function scan(dir) {
426
+ const observed = { stack: [], build: null, test: null, run: null, name: null, vcs: null, evidence: [] };
427
+ const has = (rel) => fs.existsSync(path.join(dir, rel));
428
+ const read = (rel) => {
429
+ try {
430
+ return fs.readFileSync(path.join(dir, rel), "utf8");
431
+ } catch {
432
+ return null;
433
+ }
434
+ };
435
+
436
+ if (has(".git")) {
437
+ observed.vcs = "git";
438
+ observed.evidence.push(".git/");
439
+ }
440
+
441
+ if (has("package.json")) {
442
+ observed.stack.push("node");
443
+ observed.evidence.push("package.json");
444
+ let manifest = null;
445
+ try {
446
+ manifest = JSON.parse(read("package.json"));
447
+ } catch {
448
+ // A package.json that does not parse is evidence of node and evidence of nothing else. It
449
+ // is not this tool's business to fix it, and reading scripts out of a guess would be the
450
+ // invented claim this whole function exists to avoid.
451
+ }
452
+ if (manifest && typeof manifest === "object") {
453
+ if (typeof manifest.name === "string") observed.name = manifest.name;
454
+ const scripts = manifest.scripts ?? {};
455
+ if (typeof scripts.test === "string") observed.test = scripts.test;
456
+ if (typeof scripts.build === "string") observed.build = scripts.build;
457
+ if (typeof scripts.start === "string") observed.run = scripts.start;
458
+ }
459
+ }
460
+
461
+ const makefile = read("Makefile") ?? read("makefile");
462
+ if (makefile !== null) {
463
+ observed.stack.push("make");
464
+ observed.evidence.push("Makefile");
465
+ // A target that exists is an observation; `make test` on a Makefile with no `test:` target is
466
+ // a guess, and would fail for the adopter the first time they ran it.
467
+ for (const [target, key] of [["test", "test"], ["build", "build"], ["run", "run"]]) {
468
+ if (observed[key] === null && new RegExp(`^${target}\\s*:`, "m").test(makefile)) observed[key] = `make ${target}`;
469
+ }
470
+ }
471
+
472
+ for (const [file, stack] of [["pyproject.toml", "python"], ["Cargo.toml", "rust"], ["go.mod", "go"]]) {
473
+ if (has(file)) {
474
+ observed.stack.push(stack);
475
+ observed.evidence.push(file);
476
+ }
477
+ }
478
+
479
+ return observed;
480
+ }
481
+
482
+ // ------------------------------------------------------------------------- the draft
483
+
484
+ /**
485
+ * Decides the whole file set and returns it. **Writes nothing.**
486
+ *
487
+ * The split from writing is what keeps every refusal ahead of the first byte on disk: a tool that
488
+ * decides while writing has no state left in which it can still refuse. It also makes the shape
489
+ * testable without a filesystem, which is why almost every assertion in the suite is cheap.
490
+ *
491
+ * @returns {Map<string, {contents: string, mode?: number}>} keyed by path relative to the target
492
+ */
493
+ export function draft(answers, observed) {
494
+ return answers.residence === "pointer" ? draftPointer(answers) : draftWorkspace(answers, observed);
495
+ }
496
+
497
+ function json(value) {
498
+ return `${JSON.stringify(value, null, 2)}\n`;
499
+ }
500
+
501
+ function draftPointer(answers) {
502
+ const files = new Map();
503
+
504
+ // Exactly the five keys `doctor` permits a pointer, and no others. Anything more is not a
505
+ // cosmetic defect here — it is the dual-management refusal, and the adopter's first run is red.
506
+ const manifest = {
507
+ portulan: { spec: SPEC },
508
+ name: answers.name ?? "workspace",
509
+ summary: answers.summary ?? `This repository is governed by the \`${answers.governedBy}\` workspace.`,
510
+ kind: "pointer",
511
+ governed_by: answers.feed ? { workspace: answers.governedBy, feed: answers.feed } : { workspace: answers.governedBy },
512
+ };
513
+ files.set(".portulan/workspace.json", { contents: json(manifest) });
514
+
515
+ files.set(".portulan/README.md", {
516
+ contents: `# This repository's workspace lives elsewhere
517
+
518
+ This directory holds a **pointer**, not a workspace. It records one fact: the \`${answers.governedBy}\`
519
+ workspace governs this repository${answers.feed ? `, and ships through the \`${answers.feed}\` feed` : ""}.
520
+
521
+ A repository is governed by **exactly one** workspace — it carries its own full workspace, or a pointer
522
+ to the workspace that names it, never both. Two policy layers for one repository is not redundancy; it
523
+ is an agent booting on the wrong gates and looking exactly like success.
524
+
525
+ ## What you get here, and what you do not
526
+
527
+ Every Portulan feature keys to a workspace **slot**, never to a residence, so nothing is lost by
528
+ governing this repository from elsewhere. One step is real and is named rather than claimed away:
529
+ **the governing workspace has to be installed before anything boots against it**, and no tool here
530
+ fetches it. What *is* built is the step after that — a host's installed-plugin record is read from
531
+ disk and this pointer's \`governed_by\` is resolved against it, so once the workspace is installed
532
+ the boot loads it and reports it exactly as it would an in-repo one. Run
533
+ \`node <portulan>/cli/discover.mjs --json .portulan\` to see which of the four answers this machine
534
+ gives: resolved, not installed here, ambiguous, or could not look.
535
+
536
+ ## Moving back
537
+
538
+ If this repository should carry its own workspace instead, that is a **switch**, not a re-run of
539
+ \`init\`: the workspace is materialised in the new residence, a pointer or nothing is left in the old
540
+ one, and validation is green at both ends *before* the old residence is retired.
541
+
542
+ From this repository's root, with the \`${answers.governedBy}\` workspace checked out somewhere you can
543
+ name — nothing here fetches it:
544
+
545
+ \`\`\`
546
+ portulan vendor <path to the ${answers.governedBy} workspace> --into .portulan --residence in-repo --switch
547
+ \`\`\`
548
+
549
+ Run that rather than deleting this file and writing a workspace by hand — the window between those two
550
+ acts is a repository governed by nothing, which looks identical to a repository that never adopted
551
+ Portulan.
552
+
553
+ ## Curate this
554
+
555
+ \`init\` drafted this file. The \`summary\` in \`workspace.json\` is a placeholder; make it say what
556
+ this repository is, in the one line an agent reads before loading anything else.
557
+ `,
558
+ });
559
+
560
+ return files;
561
+ }
562
+
563
+ function draftWorkspace(answers, observed) {
564
+ const files = new Map();
565
+ const name = answers.name ?? "workspace";
566
+
567
+ const manifest = {
568
+ portulan: { spec: SPEC },
569
+ name,
570
+ summary: answers.summary ?? `The ${name} workspace — drafted by \`init\`, and not yet curated.`,
571
+ kind: "repository",
572
+ // A `repository` workspace must declare `tree`: it is the policy layer of a repository that is
573
+ // present, so it has an answer, and without one every repo-card and gate-map claim silently
574
+ // degrades from checked to unverifiable. The constraint is `doctor`'s rather than the schema's,
575
+ // which makes it exactly the kind a generator forgets.
576
+ tree: "../",
577
+ gates: "gates.json",
578
+ slots: {
579
+ identity: "identity.md",
580
+ principles: "principles.md",
581
+ gates: "gate-map.md",
582
+ dod: "dod.md",
583
+ handoffs: "handoffs/",
584
+ },
585
+ verify: {
586
+ default: "workspace",
587
+ recipes: [
588
+ {
589
+ id: "workspace",
590
+ run: "./.portulan/verify/workspace.sh",
591
+ requires: ["bash"],
592
+ doc: "verify/README.md",
593
+ },
594
+ // The records rail — row 7 clause (a)'s third record convention, beside the handoffs
595
+ // directory and the session-end binding. A generated index nothing compares is a file
596
+ // that is current until the first person forgets, which is the reminder this project
597
+ // trades for a rail wherever it can. It is NOT the default: the default is what the
598
+ // Stop-gate runs at every session end, and that slot belongs to the recipe saying
599
+ // whether the repository works.
600
+ {
601
+ id: "index",
602
+ run: "./.portulan/verify/index.sh",
603
+ requires: ["bash", "node"],
604
+ doc: "verify/README.md",
605
+ },
606
+ ],
607
+ },
608
+ // Sited OUTSIDE the series it indexes: an index living in `handoffs/` would be counted as a
609
+ // handoff by everything that walks the directory.
610
+ handoffs: { index: { path: "handoffs-index.md" } },
611
+ };
612
+ if (answers.cycle) manifest.packs = [answers.checkpoints];
613
+
614
+ files.set(".portulan/workspace.json", { contents: json(manifest) });
615
+ files.set(".portulan/gates.json", { contents: json(draftPolicy()) });
616
+ files.set(".portulan/README.md", { contents: draftReadme(answers, observed, name) });
617
+ files.set(".portulan/identity.md", { contents: draftIdentity(observed, name) });
618
+ files.set(".portulan/principles.md", { contents: draftPrinciples(name) });
619
+ files.set(".portulan/gate-map.md", { contents: draftGateMap() });
620
+ files.set(".portulan/dod.md", { contents: draftDod() });
621
+ files.set(".portulan/verify/README.md", { contents: draftVerifyReadme() });
622
+ files.set(".portulan/verify/workspace.sh", { contents: draftRecipe(), mode: 0o755 });
623
+ files.set(".portulan/verify/index.sh", { contents: draftIndexRecipe(), mode: 0o755 });
624
+ // Written by the generator, not by this file, and written NOW rather than left for the adopter's
625
+ // first `index` run — a rail whose subject does not exist yet is a red on day one for a file
626
+ // nobody was asked to write. An empty series renders an index with a zero count, which is the
627
+ // honest state of a workspace drafted five seconds ago.
628
+ files.set(`.portulan/${manifest.handoffs.index.path}`, {
629
+ contents: renderHandoffIndex(manifest, { records: [], bytes: 0 }),
630
+ });
631
+ // A Map of files cannot express an empty directory and git does not track one, so the slot's
632
+ // directory is created by a file that says why it is there.
633
+ files.set(".portulan/handoffs/.gitkeep", {
634
+ contents: "# The handoff series. One dated record per session; filenames lead with YYYY-MM-DD.\n",
635
+ });
636
+
637
+ return files;
638
+ }
639
+
640
+ function draftPolicy() {
641
+ return {
642
+ portulan: { spec: GATE_POLICY_SPEC },
643
+ why: "gate-map.md",
644
+ // A floor is drafted with no required checks, deliberately. `doctor` FAILS a floor requiring a
645
+ // status check no workflow job in the tree reports — so a draft naming the checks this
646
+ // repository has not written yet would put a red in the adopter's first run and teach them the
647
+ // tool guesses. The branch is the answer nearly every repository shares; the checks are theirs.
648
+ floor: {
649
+ branch: "main",
650
+ checks: [],
651
+ reviews: 0,
652
+ resolve_conversations: true,
653
+ },
654
+ rules: [
655
+ // The two rules below are the ones the FLOOR backend can actually compile — a policy
656
+ // whose floor no rule reaches is refused outright, so a starter policy that declared a
657
+ // floor and gated only `gh pr merge` would put a hard red in the adopter's very first
658
+ // run. Found by running the real validator against a real draft rather than by reading
659
+ // the compiler. They are also the right two to start with: both destroy a ref rather
660
+ // than adding one, which is the property that puts them above the line.
661
+ {
662
+ id: "force-push-without-a-lease",
663
+ tier: "gated",
664
+ action: { shell: "git push --force" },
665
+ reason: "A bare force-push to a shared remote can silently discard commits that arrived since you last fetched, and that is the one part of pushing which is not recoverable in a working copy. Use `--force-with-lease`, which refuses the push if the remote moved.",
666
+ },
667
+ {
668
+ id: "delete-a-remote-branch",
669
+ tier: "gated",
670
+ action: { shell: "git push --delete" },
671
+ reason: "Deleting a branch on a shared remote destroys a ref rather than adding one. It is the single push spelling whose damage outlives the working copy. SHARED is the operative word. Two conditions, both required: you created the branch, AND deleting the ref destroys no work that exists nowhere else. A branch somebody else pushed stays gated however the second comes out — they may be relying on it. Test the second against the REMOTE ref and fail closed: `git fetch origin <branch>:refs/remotes/origin/<branch> && git cherry origin/<default-branch> origin/<branch>` must exit 0 and show zero `+` lines, because an unknown ref exits 128 and prints nothing, which reads as zero. The base is the branch the work was destined for — your default branch, whatever it is named — and NOT any branch you happen to pick: a wrong base answers no-unique-work about a question nobody asked. `git branch --merged` lies wherever you rebase-merge. Any `+` line, any error, or any doubt about either condition: gated.",
672
+ },
673
+ {
674
+ id: "merge-a-pull-request",
675
+ tier: "gated",
676
+ action: { shell: "gh pr merge" },
677
+ reason: "An agent never decides for itself that a change is ready to land. A commit enters the repository's record here, and that decision is a human's.",
678
+ },
679
+ {
680
+ id: "edit-the-workspace",
681
+ tier: "propose",
682
+ action: { write: ".portulan/" },
683
+ reason: "This workspace is the layer that decides gates, lanes and the bar for done. Changing it by pull request is what keeps an agent from widening its own permissions in the same change it uses them.",
684
+ },
685
+ {
686
+ id: "edit-on-a-working-branch",
687
+ tier: "auto",
688
+ action: { write: "./" },
689
+ reason: "Creating and editing files on a working branch is unattended. What makes it safe is the gate on landing, not the edit.",
690
+ },
691
+ ],
692
+ };
693
+ }
694
+
695
+ function draftReadme(answers, observed, name) {
696
+ const bound = answers.cycle
697
+ ? `This workspace composes the \`${answers.checkpoints}\` pack, which carries the three checkpoint
698
+ skills — session-open, pre-commit, milestone-close — and the supervisor persona that staffs them.`
699
+ : `This workspace composes no packs: \`init\` was run with \`--no-cycle\`. The checkpoint ritual is
700
+ the one most teams want first; add it to \`packs\` in \`workspace.json\` when you do.`;
701
+
702
+ return `# ${name} — a drafted workspace
703
+
704
+ \`init\` wrote every file in this directory. **None of it is finished**, and that is deliberate: agents
705
+ may draft this layer and may never own it. Read each file, delete what does not describe your team, and
706
+ replace the placeholders. A workspace you have not curated is a workspace that makes an agent work
707
+ *generically* — which is the one thing it exists to prevent.
708
+
709
+ ## Where to start
710
+
711
+ 1. **\`identity.md\`** — who you are, your stack, your glossary. It is the first thing an agent reads
712
+ after the kernel, and the scan below could only observe so much.
713
+ 2. **\`principles.md\`** — the handful of rules that are yours rather than everyone's. Placeholders
714
+ until you write them.
715
+ 3. **\`verify/workspace.sh\`** — **this exits 2 (could not run) until you edit it.** It cannot report
716
+ green, because nobody has told it what green means for this repository. That is the honest state, not
717
+ a bug: "nothing looked" must never read as "nothing wrong."
718
+ 4. **\`gate-map.md\` and \`gates.json\`** — ${draftPolicy().rules.length} starter rules and a platform
719
+ floor with no required checks. Add your checks once your CI reports them; a floor requiring a check
720
+ no job reports blocks every pull request.
721
+
722
+ ## The cycle this drafts
723
+
724
+ ${bound}
725
+
726
+ **What is bound and what is not, stated plainly** — because a workspace that overstates its own rails
727
+ is worse than one with fewer of them.
728
+
729
+ - **The pack is named; resolving it is a separate step.** Naming a pack and finding it on this machine
730
+ are different things. **Until the root resolves, validation is RED**, not merely unverified:
731
+ \`doctor\` fails a declared pack it cannot resolve rather than passing over it. Three ways out —
732
+ \`doctor --pack-root auto .portulan\`, which finds it in this host's installed plugins;
733
+ \`doctor --pack-root <dir> .portulan\` if you would rather name the location; or \`init --no-cycle\`
734
+ to compose the pack later, once you know where it lives.
735
+ - **The records conventions are drafted, and the freshness rail is one of them** — a \`handoffs/\`
736
+ directory in the \`handoffs\` slot, a generated \`handoffs-index.md\` **written by the same generator
737
+ that will check it**, and \`verify/index.sh\` declared beside \`workspace\` to hold the two in
738
+ agreement. The index starts at zero handoffs, which is what this repository has, so the rail is green
739
+ today rather than red about a file nobody wrote. Edit that index by hand and the rail goes red; add a
740
+ handoff without regenerating and it goes red the same way. It is **not** the default recipe: the
741
+ default is what runs at every session end, and that slot belongs to the one that says whether this
742
+ repository works.
743
+ - **The rail needs the CLI, and may not find it here.** It looks at \`$PORTULAN_CLI\`, then
744
+ \`portulan\` on your \`PATH\`, then the bundle this workspace was drafted from — an absolute path on
745
+ the machine that ran \`init\`, which git cannot carry to anybody else. Where none answers it exits
746
+ **2 — could not run**, never 0. On CI that is the state to expect until the CLI is installed there,
747
+ and it is said here rather than left to be met as an amber pipeline.
748
+ - **The session-end gate is wired by \`compile\`, and this draft has not run it.** The runner that
749
+ enforces "a handoff dated today exists" before a session ends **does** ship in the package you have —
750
+ it is \`cli/stop-gate.mjs\` — and \`portulan compile\` emits a \`Stop\` hook naming it. What this
751
+ draft does is bind the ritual and the records conventions; running \`compile\` is the step that turns
752
+ them into enforcement, and it is yours to run because compiling writes host settings and that is not
753
+ a thing a scaffold should do to your machine unasked. **Until you run it**, treat the session-end
754
+ handoff as a practice your team holds rather than a rail — a rule nothing checks is worth exactly
755
+ what you can remember about it.
756
+
757
+ ## What the scan observed
758
+
759
+ ${observed.evidence.length ? observed.evidence.map((e) => `- \`${e}\``).join("\n") : "- Nothing it recognised. Every claim in `identity.md` is yours to write."}
760
+
761
+ Only what is listed above was read. Anything \`identity.md\` marks *not determined* was left blank
762
+ rather than guessed at — an invented build command is worse than an absent one, because the workspace
763
+ then disagrees with the repository on the day it was created.
764
+ `;
765
+ }
766
+
767
+ function draftIdentity(observed, name) {
768
+ const unknown = "_Not determined by the scan — fill this in._";
769
+ const row = (label, value) => `| ${label} | ${value ? `\`${value}\`` : unknown} |`;
770
+
771
+ return `# Identity — ${name}
772
+
773
+ > Who this team is, what it builds with, and what its words mean. The first thing an agent reads after
774
+ > the kernel, so keep it short and keep it true. \`init\` drafted this from what it could observe; every
775
+ > line marked *not determined* was left blank rather than guessed at.
776
+
777
+ ## The team
778
+
779
+ ${unknown} — how many people, what they own, how they work.
780
+
781
+ ## The stack
782
+
783
+ ${observed.stack.length ? observed.stack.map((s) => `- \`${s}\``).join("\n") : unknown}
784
+
785
+ ## Commands
786
+
787
+ | What | Command |
788
+ |---|---|
789
+ ${row("Build", observed.build)}
790
+ ${row("Test", observed.test)}
791
+ ${row("Run", observed.run)}
792
+
793
+ ${
794
+ observed.build || observed.test || observed.run
795
+ ? "These were read out of files in this repository, not inferred. Correct them if they are stale."
796
+ : "The scan found no command written down anywhere it looked. Write them here; anything else would be a guess."
797
+ }
798
+
799
+ ## Glossary
800
+
801
+ ${unknown} — the words your team uses that a newcomer would misread. This section earns its place the
802
+ first time an agent gets one wrong.
803
+
804
+ ## Write the limit, not the aspiration
805
+
806
+ Where something is not true yet, say so here rather than describing the intended state. A document that
807
+ claims a capability the tree does not have is the defect this section exists to prevent.
808
+ `;
809
+ }
810
+
811
+ function draftPrinciples(name) {
812
+ return `# Principles — ${name}
813
+
814
+ > The rules that are **yours** rather than everyone's. Core already carries the universal ones; this
815
+ > file is what makes an agent work *this* team's way. \`init\` cannot draft these — they are the part
816
+ > no scan can observe and no generator should invent.
817
+
818
+ Three questions worth answering here, in your own words:
819
+
820
+ 1. **What do you refuse to trade away?** Every team has one — a latency budget, a review depth, a
821
+ dependency policy, an accessibility floor. Name it, and say what it costs, because a principle with
822
+ no cost is a preference.
823
+ 2. **What has gone wrong more than once?** The rule that would have caught it belongs here, with a link
824
+ to the incident. A rule with no provenance cannot be retired later, because nobody can tell whether
825
+ the thing it guards against can still happen.
826
+ 3. **Where does this team differ from the obvious default?** That difference is the whole reason this
827
+ file exists rather than a link to someone's style guide.
828
+
829
+ _Delete this list once you have replaced it. A workspace whose principles slot still holds the prompt
830
+ is a workspace nobody has curated._
831
+ `;
832
+ }
833
+
834
+ function draftGateMap() {
835
+ return `# Gate map
836
+
837
+ > The **policy** half of the engine's autonomy model. Core defines the tiers — Auto, Propose, Gated,
838
+ > Prohibited — as universal mechanism; this file binds *your* concrete actions to them, because which
839
+ > action is dangerous is a property of a team, not of an engine.
840
+ >
841
+ > **\`gates.json\` is the policy; this file is the rationale.** Where they disagree, the JSON wins — it
842
+ > is the one that compiles.
843
+
844
+ ## The tiers, bound
845
+
846
+ ### Auto — the agent acts unattended
847
+
848
+ Recoverable, reversible, and it puts nothing in front of anyone.
849
+
850
+ - \`edit-on-a-working-branch\` — create and edit files on a working branch.
851
+
852
+ ### Propose — the agent drafts; a human decides
853
+
854
+ - \`edit-the-workspace\` — anything under \`.portulan/\`, including this file and the policy beside it.
855
+
856
+ ### Gated — the agent asks first, every time
857
+
858
+ - \`force-push-without-a-lease\` — a bare \`--force\` can discard commits that arrived since you fetched.
859
+ \`--force-with-lease\` is the spelling that refuses when the remote moved.
860
+ - \`delete-a-remote-branch\` — destroys a ref rather than adding one.
861
+ - \`merge-a-pull-request\` — a change enters the record here, and that decision is a human's.
862
+
863
+ ### Prohibited — no role acts here
864
+
865
+ Nothing yet. Add what must never happen regardless of who asks.
866
+
867
+ ## The platform floor
868
+
869
+ Branch protection on \`main\`, with conversation resolution required and **no required status checks
870
+ declared yet**. Add your checks to \`floor.checks\` in \`gates.json\` once your CI reports them — a floor
871
+ requiring a check no job reports blocks every pull request, which is why \`init\` declared none.
872
+
873
+ The two Gated ref rules above are what this floor compiles from: they become \`non_fast_forward\` and
874
+ \`deletion\` on \`refs/heads/main\`. Note the coarseness in the honest direction — \`non_fast_forward\`
875
+ blocks **every** force-push, including \`--force-with-lease\`, which the policy above classifies Auto. A
876
+ ref rule gates a ref and cannot read a command's flags.
877
+
878
+ Until \`floor.checks\` is declared, \`edit-the-workspace\` and every other Propose rule compiles to
879
+ nothing in this backend: requiring a pull request while requiring nothing green of it reads as a floor
880
+ and is not one.
881
+
882
+ The floor is the layer indifferent to how a command was spelled. Everything above depends on a host
883
+ honouring a permission rule; this does not.
884
+
885
+ ## The triage threshold
886
+
887
+ ${"_Not set._"} Where does small work stop needing ceremony? Answer it here, in a sentence a person can
888
+ apply without asking. Left unset, every rule above applies to a one-line fix, and a ritual that cannot
889
+ scale down gets switched off wholesale rather than tuned.
890
+ `;
891
+ }
892
+
893
+ function draftDod() {
894
+ return `# Definition of done
895
+
896
+ > Core supplies the floor: green verify, and never report done on what you could not explain. A
897
+ > workspace may **extend** that floor and may never lower it. These are the additional conditions for
898
+ > work here — each one you add should carry the reason it exists, or nobody can tell later whether it
899
+ > still earns its place.
900
+
901
+ A change is done when **all** of the following hold.
902
+
903
+ 1. **The verify recipe ran green in this working copy.** Not "should pass" — run it and read the
904
+ output. _Why: this is the only condition whose evidence is a machine's rather than a person's._
905
+ 2. **You could walk a reviewer through every line.** _Why: core's bar, and the one most often skipped
906
+ when a diff is mostly prose — prose reviews as "fine" far more easily than code does._
907
+
908
+ _Add yours below. Two are enough to start; a list nobody can hold in their head is a list that gets
909
+ skimmed. Each condition should be checkable by someone who was not in the room._
910
+ `;
911
+ }
912
+
913
+ function draftVerifyReadme() {
914
+ return `# verify/
915
+
916
+ The executable half of *done*. \`workspace.json\` declares the recipes and names the default.
917
+
918
+ **Nothing runs them for you yet, and that is worth knowing before you trust a green.** In this
919
+ repository there is no Stop-gate wired — see \`../README.md\` — and \`init\` writes no CI workflow, so
920
+ these recipes run when a person runs them. The design is that the default recipe is what a session-end
921
+ gate and a pull-request check both call; both of those are things you wire, and until you do, a recipe
922
+ is a command in a file.
923
+
924
+ | Recipe | What it checks |
925
+ |---|---|
926
+ | \`workspace\` | **Nothing yet — it exits 2.** Replace it with the command that tells you this repository is healthy. This one is the **default**: it is what runs at a session end. |
927
+ | \`index\` | The generated \`handoffs-index.md\` against the series it indexes, byte for byte. Finished as drafted — it checks a real thing today. |
928
+
929
+ ## The three exit codes, and why the middle one is not enough
930
+
931
+ - **0 — green.** Everything this recipe checks passed.
932
+ - **1 — red.** Something it checks failed. A verdict about the repository.
933
+ - **2 — could not run.** A precondition was missing: a tool absent, a file unreadable, nothing to
934
+ check. **This is not a pass.** A recipe that returns 0 because it found nothing to look at reports
935
+ "nothing wrong" when the truth is "nothing looked", and every gate downstream believes it.
936
+
937
+ \`workspace\` exits 2 for exactly that reason. It is not broken; it is honest about not yet knowing
938
+ what green means here.
939
+
940
+ **\`index\` has an expected 2 of its own, and it is worth knowing before you meet it.** The recipe needs
941
+ the Portulan CLI, and looks for it in three places: \`$PORTULAN_CLI\`, then \`portulan\` on \`PATH\`,
942
+ then the bundle this workspace was drafted from — the last being an absolute path on the machine that
943
+ ran \`init\`, which git cannot carry to a clone or to a CI runner. Where none of the three answers, and
944
+ where \`node\` itself is absent, the recipe exits **2** and names what it looked for. That is the state
945
+ to expect on CI until the CLI is installed there; it is not a red, and it is not a pass either.
946
+
947
+ **Regenerating is yours, and the rail deliberately does not do it.** A check that repaired what it
948
+ found would report green on a repository nobody had corrected, and the index is only worth having
949
+ because it is derived — run the index tool yourself and commit the result.
950
+
951
+ **One thing the three locations cannot establish, said rather than left implicit.** The second of them
952
+ is *whatever \`portulan\` is on your \`PATH\`* — and nothing here can tell that it is this tool rather
953
+ than another program of the same name. Where that matters to you, set \`$PORTULAN_CLI\` to a checkout
954
+ you chose: an explicit path is the one location that answers a question about identity rather than about
955
+ availability, and a location that holds no \`index.mjs\` exits **2** rather than being read as a drifted
956
+ index.
957
+ `;
958
+ }
959
+
960
+ function draftRecipe() {
961
+ return `#!/usr/bin/env bash
962
+ # Workspace verify recipe — REPLACE THIS.
963
+ #
964
+ # This is a draft. It runs nothing and exits 2 (could not run), because nobody has yet told it what
965
+ # "green" means for this repository. That is deliberate and it is not a placeholder to be deleted: a
966
+ # stub exiting 0 would report "nothing wrong" when the truth is "nothing looked", and the Stop-gate,
967
+ # CI, and every human reading a green check would believe it.
968
+ #
969
+ # Replace the body with the command that actually verifies this repository — the test suite, the
970
+ # linter, the build, whatever your team already trusts — and keep the three codes:
971
+ #
972
+ # exit 0 green: everything this checks passed
973
+ # exit 1 red: something it checks failed
974
+ # exit 2 could not run: a precondition was missing. Never a pass.
975
+
976
+ set -uo pipefail
977
+
978
+ printf 'verify: this workspace has not declared what green means yet.\\n' >&2
979
+ printf 'verify: edit .portulan/verify/workspace.sh — see .portulan/verify/README.md\\n' >&2
980
+ exit 2
981
+ `;
982
+ }
983
+
984
+ /**
985
+ * The records rail, drafted with its own honest first state.
986
+ *
987
+ * Unlike `workspace.sh` this one is **finished** — it checks a real thing on the day it is written,
988
+ * because the index it holds current is written beside it. What it cannot promise is that the tool is
989
+ * reachable: this package is not published, so on an adopter's CI none of the three locations may
990
+ * answer and the recipe's expected first state there is **exit 2**. That is the honest code and it is
991
+ * said in `verify/README.md` as well, rather than discovered when a pipeline goes amber.
992
+ *
993
+ * The third location is the bundle this workspace was drafted from, and it is written in as a
994
+ * MACHINE-LOCAL convenience for the person who ran `init` — an absolute path on one machine, which
995
+ * git cannot carry anywhere (`.portulan/memory/a-generated-file-must-not-point-at-what-git-cannot-carry.md`).
996
+ * The script says so where a reader meets it, so nobody reads it as a portable location.
997
+ *
998
+ * **Both lines carrying it are marked `# portulan:bundle-fallback`, and the marker is the point.** A
999
+ * workspace is not fixed where it was drafted: `vendor --switch` copies these files byte for byte into
1000
+ * another residence — so this path travels to machines it was never true on, and it does so silently,
1001
+ * because a stale absolute path exits 2 rather than failing loudly. A comment asking the next
1002
+ * implementer to notice is the reminder this project trades away wherever it can; a fixed token is
1003
+ * something a rewriter can **find**.
1004
+ *
1005
+ * **The rewriter arrived at milestone 7 session 9**, and this sentence read "`upgrade` *will* rewrite
1006
+ * them" until it did: `spec/migrations/0002-bundle-fallback-path.mjs`, run by `portulan upgrade`,
1007
+ * re-derives every marked line for the bundle it is running from — wherever the line is, since the
1008
+ * marker rather than the filename is the contract. `cli/vendor.mjs` and that step both cite the token
1009
+ * and `init.test.mjs` asserts they do, so the marker cannot be dropped by an edit to this recipe that
1010
+ * forgets why it was there.
1011
+ */
1012
+ function draftIndexRecipe() {
1013
+ const bundle = path.resolve(HERE, "..");
1014
+ return `#!/usr/bin/env bash
1015
+ # Records rail — the generated index is compared against the series it indexes.
1016
+ #
1017
+ # \`handoffs-index.md\` is GENERATED. This recipe regenerates it in memory and compares byte for byte,
1018
+ # so an index edited by hand, or one left behind when a handoff was added, is a RED rather than a file
1019
+ # that quietly stopped being true. Nothing here writes: run the index tool yourself to repair it.
1020
+ #
1021
+ # exit 0 green: the index matches the series
1022
+ # exit 1 red: it does not — regenerate it
1023
+ # exit 2 could not run: the Portulan CLI is not reachable from here. NEVER a pass.
1024
+
1025
+ set -uo pipefail
1026
+ cd "\$(dirname "\$0")/../.." || exit 2
1027
+
1028
+ # Three locations, in order. An explicit path wins, then an installed CLI, then the bundle this
1029
+ # workspace was drafted from — which is an absolute path on the machine that ran \`init\` and is NOT
1030
+ # portable: a clone of this repository elsewhere will not find it, and should set PORTULAN_CLI or
1031
+ # install the CLI instead.
1032
+ # Two of the three locations are node scripts, so node is their precondition and not their business.
1033
+ # An absent interpreter must exit 2 like any other missing tool: unchecked, \`node …\` dies **127**,
1034
+ # which is the shell's code for "not found" and reads to every reader downstream as a recipe that ran
1035
+ # and rendered a verdict. A recipe's own contract is the three codes, and it owes them even when what
1036
+ # is missing is the thing that would have run it.
1037
+ need_node() {
1038
+ command -v node >/dev/null 2>&1 && return 0
1039
+ printf 'verify: node is needed to run the index tool and is not on PATH, so the index was NOT checked.\\n' >&2
1040
+ exit 2
1041
+ }
1042
+
1043
+ if [ -n "\${PORTULAN_CLI:-}" ]; then
1044
+ # An explicit location that does not hold the tool is COULD NOT RUN, not red. Without this test
1045
+ # \`node .../index.mjs\` dies 1, and 1 is this recipe's code for "the index drifted — regenerate
1046
+ # it": a pointer typed wrongly would send somebody to repair a file that was never wrong.
1047
+ if [ ! -f "\${PORTULAN_CLI}/index.mjs" ]; then
1048
+ printf 'verify: PORTULAN_CLI is set to %s, which holds no index.mjs — the index was NOT checked.\\n' "\${PORTULAN_CLI}" >&2
1049
+ exit 2
1050
+ fi
1051
+ need_node
1052
+ set -- node "\${PORTULAN_CLI}/index.mjs"
1053
+ elif command -v portulan >/dev/null 2>&1; then
1054
+ set -- portulan index
1055
+ elif [ -f ${JSON.stringify(`${bundle}/cli/index.mjs`)} ]; then # portulan:bundle-fallback
1056
+ need_node
1057
+ set -- node ${JSON.stringify(`${bundle}/cli/index.mjs`)} # portulan:bundle-fallback
1058
+ else
1059
+ printf 'verify: the Portulan CLI is not reachable, so the index was NOT checked.\\n' >&2
1060
+ printf 'verify: looked at \$PORTULAN_CLI/index.mjs, portulan on PATH, and the bundle this\\n' >&2
1061
+ printf 'verify: workspace was drafted from. Set PORTULAN_CLI to a checkout, or install the CLI.\\n' >&2
1062
+ exit 2
1063
+ fi
1064
+
1065
+ "\$@" --check .portulan
1066
+ code=\$?
1067
+
1068
+ # 126 and 127 are the shell's two ways of saying it never ran the program — "found but not
1069
+ # executable" and "not found" — and both can arrive here even though \`command -v portulan\` answered:
1070
+ # a Node-based entry point whose interpreter is gone dies on exec, before any of this tool's own
1071
+ # codes exist. Left unmapped, either reads downstream as this recipe having RUN and rendered a
1072
+ # verdict about the index, which is the laundering \`need_node\` prevents on the branches it guards.
1073
+ # The index tool itself only ever exits 0, 1 or 2, so neither code is ambiguous here.
1074
+ #
1075
+ # Raised by Copilot, round 1 on #227, naming 127 alone; measured while pinning it, a bad interpreter
1076
+ # is **126** on this platform and the fix would have missed the case that prompted it.
1077
+ if [ "\$code" -eq 126 ] || [ "\$code" -eq 127 ]; then
1078
+ printf 'verify: the index tool could not be executed (%s) — the index was NOT checked.\\n' "\$code" >&2
1079
+ exit 2
1080
+ fi
1081
+ exit "\$code"
1082
+ `;
1083
+ }
1084
+
1085
+ // ------------------------------------------------------------------------- writing
1086
+
1087
+ /**
1088
+ * The residence already present in a target, if any.
1089
+ *
1090
+ * An unreadable manifest returns `unreadable` rather than `none`, and the distinction is the whole
1091
+ * point: reading a parse failure as "no workspace here" would make a corrupt manifest the one case
1092
+ * where this tool overwrites — the case where it knows least and where overwriting costs most.
1093
+ */
1094
+ export function residenceAt(target) {
1095
+ // The path to the manifest is walked with `lstatSync` BEFORE anything is read, and that order is
1096
+ // the whole of it. This function used `existsSync`/`readFileSync`, which follow symlinks — so a
1097
+ // repository with a `.portulan` symlink pointing at somebody else's workspace made `init` read a
1098
+ // manifest OUTSIDE the target and announce *"this repository already carries a `repository`
1099
+ // workspace"*, naming a workspace that is not in this repository at all. Two failures in one
1100
+ // sentence: an out-of-repo read, and a refusal that misdescribed what it found.
1101
+ //
1102
+ // It also ran ahead of the symlink-aware collision check, so the containment guarantee that check
1103
+ // exists to give was reachable only when this function happened not to fire first. A guarantee
1104
+ // that depends on which check runs first is not a guarantee. Found by review on the pull request,
1105
+ // as the follow-up to the escape it is the other half of.
1106
+ let here = target;
1107
+ for (const segment of [".portulan", "workspace.json"]) {
1108
+ here = path.join(here, segment);
1109
+ let stat;
1110
+ try {
1111
+ stat = fs.lstatSync(here);
1112
+ } catch (error) {
1113
+ // ONLY `ENOENT` means "nothing here". Every other error — EACCES above all — means the
1114
+ // question could not be answered, and answering "no residence" to a question that could
1115
+ // not be answered is the fail-open that `a-checker-must-refuse-what-it-cannot-check.md` governs:
1116
+ // "nothing looked" reported as "nothing wrong". A permission error here would have let
1117
+ // `init` proceed to write into a directory it could not even stat.
1118
+ if (error.code === "ENOENT") return { state: "none" };
1119
+ // `kind` is carried because the two ways of being unreadable need DIFFERENT sentences.
1120
+ // Telling somebody with a permissions problem to "repair the JSON" is a refusal that
1121
+ // misdescribes what it found — the defect the read-side symlink fix was about, arriving
1122
+ // one round later in the message rather than in the check.
1123
+ return { state: "unreadable", kind: "io", why: error.message };
1124
+ }
1125
+ if (stat.isSymbolicLink()) return { state: "symlink", where: path.relative(target, here) };
1126
+ }
1127
+ let parsed;
1128
+ try {
1129
+ parsed = JSON.parse(fs.readFileSync(here, "utf8"));
1130
+ } catch (error) {
1131
+ return { state: "unreadable", kind: "parse", why: error.message };
1132
+ }
1133
+ return { state: "present", kind: parsed?.kind ?? "unknown", name: parsed?.name ?? null };
1134
+ }
1135
+
1136
+ /**
1137
+ * The FIRST root that carries the pack, or null.
1138
+ *
1139
+ * It returned a boolean until 2026-08-13, and the root is needed now rather than merely convenient: the
1140
+ * unasked path resolves from **either** the host's plugin cache or the workspace's own `<target>/packs`,
1141
+ * and the closing advice was written claiming the cache for both. A sentence naming the wrong residence
1142
+ * is worse than one naming none — it sends an adopter to uninstall a plugin that had nothing to do with
1143
+ * it. Found by trying to write the test for the degrade, which is the half reading never catches.
1144
+ *
1145
+ * First-match-wins, matching `resolvePack`, so the order `expandRoots` returns is the order that decides.
1146
+ */
1147
+ function packResolvedAt(roots, packId) {
1148
+ return roots.find((root) => fs.existsSync(path.join(root, packId, "pack.json"))) ?? null;
1149
+ }
1150
+
1151
+ /**
1152
+ * The roots `init` checks a composed pack against, resolving `auto` the way the other four tools do.
1153
+ *
1154
+ * `auto` is kept VERBATIM in the answers file rather than expanded into the path it resolved to today:
1155
+ * a persisted answer naming a machine-specific cache path is exactly what an answers file exists to
1156
+ * avoid, and `auto` replays correctly on another machine while a path does not.
1157
+ *
1158
+ * **Two unions, and only one of them is law — do not collapse them.**
1159
+ *
1160
+ * - **Named ∪ discovered is still REFUSED.** The first version of this appended discovered roots
1161
+ * beside named ones, which made `init` the odd tool out with union semantics: the divergence
1162
+ * `../cli/compile.mjs`'s `namedRootsOption` records, re-committed inside the change that cites it.
1163
+ * Since 2026-08-12 asking for both is a refusal rather than a silent drop, which is stricter than
1164
+ * what that incident produced and in the same direction. _(This said "the only tool of **five**",
1165
+ * a figure that was seven by the time `recipe-set` and `init` joined the refusal — see
1166
+ * `NAMED_WITH_AUTO`, which derives nothing and has now been wrong twice. The count is dropped rather
1167
+ * than corrected, because nothing in the sentence needs it.)_
1168
+ * - **Discovered ∪ derived IS law**, as of the same date, in `discover.mjs`'s `resolutionRoots`. A
1169
+ * later reader must not "fix" this arm by citing the incident above: that incident is about the
1170
+ * first pair, and this arm implements the second.
1171
+ *
1172
+ * The derived root here is the one the workspace **being drafted** would have — `<target>/packs` —
1173
+ * because this check exists to predict the `doctor` run that follows the draft. Mirroring the shared
1174
+ * rule is what stops `init` refusing a composition that `doctor` then resolves happily: a false red
1175
+ * against the very tool it is predicting. _(That read "`doctor --pack-root auto`" until 2026-08-13,
1176
+ * when the bare invocation started resolving the same way — which is precisely why this arm had to
1177
+ * gain its own unasked branch below rather than staying a named-roots check.)_
1178
+ *
1179
+ * ## The UNASKED arm, added 2026-08-13 with the disposal, and its one hard rule
1180
+ *
1181
+ * `doctor` consults discovery unasked as of that date, so this check must too or it stops predicting
1182
+ * the run it exists to predict — and the prediction is the whole reason it is here. But `init` **WRITES
1183
+ * FILES**, and that makes one arm of the disposal non-negotiable here:
1184
+ *
1185
+ * **On the unasked path, a pack that does not resolve is ADVICE and never a refusal.** A refusal would
1186
+ * mean `init` drafts a workspace on a host where the pack is installed and refuses to draft one where
1187
+ * it is not — host-dependence deciding whether files exist, which is worse than any verdict moving.
1188
+ * `--pack-root <dir>` and `--pack-root auto` keep their refusals: a caller who said where to look and
1189
+ * was wrong is owed one. Nobody who said nothing is.
1190
+ *
1191
+ * **And the DRAFT ITSELF is byte-identical on every host.** Discovery reaches the advice and the
1192
+ * resolvability answer, never `draft()`. That is `../docs/vision.md`'s *no auto-generated curated
1193
+ * context* at the one tool that could break it, and `init.test.mjs` hashes the drafted files on a host
1194
+ * with a record and on one without to keep it that way.
1195
+ *
1196
+ * Returns `{ roots, why, refusal, asked }`. `why` is non-null only when discovery **could not look**,
1197
+ * which a caller must be able to tell from *looked and found nothing*; `asked` is what lets the caller
1198
+ * choose between a refusal and a sentence for the same answer.
1199
+ */
1200
+ function expandRoots(roots, target, env) {
1201
+ const named = roots.filter((root) => root !== AUTO);
1202
+ const forced = roots.includes(AUTO);
1203
+ const derived = path.join(target, "packs");
1204
+ const refusal = namedWithAuto(named, forced);
1205
+ if (refusal) return { roots: [], why: null, refusal, asked: true };
1206
+ if (named.length) return { roots: named, why: null, refusal: null, asked: true };
1207
+ // `env` is the injection seam — see `index.mjs`'s `readScopes`.
1208
+ const found = discoverPackRoots({ env });
1209
+ // Could not look. Asked, that is a refusal at the call site; unasked, the derived root carries on
1210
+ // alone and the sentence says why the other half is missing — the same degrade
1211
+ // `../cli/discover.mjs`'s `resolutionRoots` makes, for the same reason.
1212
+ if (!found.ok) return { roots: forced ? [] : [derived], why: found.why, refusal: null, asked: forced };
1213
+ return { roots: [...found.roots, derived], why: null, refusal: null, asked: forced };
1214
+ }
1215
+
1216
+ /**
1217
+ * Every path in the draft that cannot be written without destroying or being blocked by something.
1218
+ *
1219
+ * The residence check above keys on the MANIFEST, which is right for "is this repository governed?"
1220
+ * and wrong for "is it safe to write here?". A `.portulan/` holding a hand-written `gate-map.md` and
1221
+ * no manifest is not a residence — and the first version of this tool silently replaced that file.
1222
+ * The two questions are separate and both have to be asked.
1223
+ *
1224
+ * Blocked ancestors are the same check from the other side: a `verify` that is a *file* makes
1225
+ * `verify/README.md` unwritable, and finding that out halfway through the write loop is how a run
1226
+ * leaves a torso behind.
1227
+ */
1228
+ export function collisions(target, files) {
1229
+ const found = [];
1230
+ for (const rel of files.keys()) {
1231
+ // Walked from the target DOWN, one segment at a time, with `lstatSync` — never `statSync`,
1232
+ // and never `existsSync`. Both of those FOLLOW symlinks, which is how the first version of
1233
+ // this check let a `.portulan` symlink carry the whole drafted workspace out of the
1234
+ // repository: `init` wrote nine files somewhere else entirely and reported success.
1235
+ // Demonstrated on the pull request. `doctor` and `plugin-lint` already treat a symlink escape
1236
+ // as significant; this is the same rule arriving at the tool that WRITES, where it matters
1237
+ // more than at the tools that read.
1238
+ //
1239
+ // A symlink anywhere on the chain is a collision rather than something to resolve and permit.
1240
+ // Resolving it would mean deciding whether the destination is "really" inside the repository,
1241
+ // which is a containment judgement with a bad failure mode; refusing is a judgement with none.
1242
+ const segments = rel.split("/");
1243
+ let here = target;
1244
+ for (let i = 0; i < segments.length; i++) {
1245
+ here = path.join(here, segments[i]);
1246
+ let stat;
1247
+ try {
1248
+ stat = fs.lstatSync(here);
1249
+ } catch (error) {
1250
+ if (error.code === "ENOENT") {
1251
+ // Absent — and nothing below an absent directory can exist either, so this path
1252
+ // is clear and the walk stops.
1253
+ break;
1254
+ }
1255
+ // Anything else — EACCES above all — means this path's state is UNKNOWN, and an
1256
+ // unknown is not a clear. Declaring it clear would let the write loop start on the
1257
+ // strength of a question nobody could answer, which is precisely the guarantee this
1258
+ // function exists to give. Reported as a collision so the run refuses.
1259
+ found.push({ rel, why: `\`${path.relative(target, here)}\` could not be examined (${error.code})` });
1260
+ break;
1261
+ }
1262
+ const where = path.relative(target, here);
1263
+ if (stat.isSymbolicLink()) {
1264
+ found.push({ rel, why: `\`${where}\` is a symlink, and writing through it would leave the repository` });
1265
+ break;
1266
+ }
1267
+ if (i === segments.length - 1) {
1268
+ found.push({ rel, why: "already exists" });
1269
+ break;
1270
+ }
1271
+ if (!stat.isDirectory()) {
1272
+ found.push({ rel, why: `\`${where}\` is in the way and is not a directory` });
1273
+ break;
1274
+ }
1275
+ }
1276
+ }
1277
+ return found;
1278
+ }
1279
+
1280
+ // ------------------------------------------------------------------------- the interview
1281
+
1282
+ /**
1283
+ * Every refusal owed to a repository that is already governed, in one place.
1284
+ *
1285
+ * Extracted from `run` when the interview arrived, because it is now asked at two moments — before a
1286
+ * human is made to answer five questions, and at the point in the sequence it has always occupied —
1287
+ * and a second spelling of three refusals is three chances for them to drift apart.
1288
+ */
1289
+ export function refuseIfGoverned(existing, spelling) {
1290
+ if (existing.state === "symlink") {
1291
+ // Handled here rather than left to the collision check, so the containment refusal does not
1292
+ // depend on which check happens to run first — and so nothing outside the repository was read
1293
+ // in order to produce it.
1294
+ throw new InitError(
1295
+ `\`${existing.where}\` is a symlink, and \`init\` will not follow one out of the repository — not to write ` +
1296
+ `through it, and not to read a workspace manifest through it either. A manifest reached that way ` +
1297
+ `describes some other directory, so any verdict about "this repository" drawn from it would be about ` +
1298
+ `somewhere else. Replace the link with a real directory, or draft into a repository that has none.`,
1299
+ );
1300
+ }
1301
+ if (existing.state === "unreadable") {
1302
+ const at = path.join(spelling, ".portulan");
1303
+ throw new InitError(
1304
+ existing.kind === "io"
1305
+ ? `could not examine \`${at}\` — ${existing.why}. Refusing rather than treating it as empty: ` +
1306
+ `a directory this tool cannot look into is a question it could not answer, and "nothing looked" ` +
1307
+ `must never be recorded as "nothing there". Fix the permissions and run this again.`
1308
+ : `could not read the workspace manifest already at \`${path.join(at, "workspace.json")}\` — ` +
1309
+ `${existing.why}. Refusing to write over a manifest it cannot understand: a corrupt policy layer ` +
1310
+ `is the case where overwriting costs the most and this tool knows the least. Repair the JSON and ` +
1311
+ `run \`doctor\` on it, or — if it was never a workspace you meant to keep — move it aside and run ` +
1312
+ `this again.`,
1313
+ );
1314
+ }
1315
+ if (existing.state === "present") {
1316
+ const what = existing.kind === "pointer" ? "a pointer to another workspace" : `a \`${existing.kind}\` workspace`;
1317
+ throw new InitError(
1318
+ `this repository already carries ${what}${existing.name ? ` (\`${existing.name}\`)` : ""} — a repository is ` +
1319
+ `governed by exactly one workspace, and \`init\` will not replace one. Moving between residences is a ` +
1320
+ `switch, not a re-run: the workspace is materialised in the new residence, a pointer or nothing is left ` +
1321
+ `in the old, and validation is green at both ends before the old one is retired. \`vendor\` is the ` +
1322
+ `subcommand that does it — \`portulan vendor <the workspace> --into <where it should live> --residence ` +
1323
+ `<in-repo|feed-side> --switch\` — and it holds that ordering so you do not have to. Doing it by hand the ` +
1324
+ `other way round leaves a window in which this repository is governed by nothing, which looks exactly ` +
1325
+ `like one that never adopted Portulan.`,
1326
+ );
1327
+ }
1328
+ }
1329
+
1330
+ /**
1331
+ * The default reader: a real terminal, and only where there is one at BOTH ends.
1332
+ *
1333
+ * `interactive` is the whole gate on the interview, and it is two questions rather than one. A pipe
1334
+ * on stdin is a script; a redirected stdout is a log — and prompting into either produces a run that
1335
+ * hangs waiting for an answer nobody is there to give, which on CI is a job that burns its timeout
1336
+ * instead of printing the refusal that names the flag to pass.
1337
+ */
1338
+ function terminal() {
1339
+ return {
1340
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
1341
+ async ask(question) {
1342
+ const readline = await import("node:readline/promises");
1343
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1344
+ try {
1345
+ return await rl.question(question);
1346
+ } catch {
1347
+ // A closed stdin rejects rather than returning. Both are "no answer is coming", and
1348
+ // the caller's EOF handling is where that becomes a refusal.
1349
+ return null;
1350
+ } finally {
1351
+ rl.close();
1352
+ }
1353
+ },
1354
+ say(line) {
1355
+ process.stdout.write(`${line}\n`);
1356
+ },
1357
+ };
1358
+ }
1359
+
1360
+ /**
1361
+ * Ask for what the flags did not supply, and fill `answers` in place.
1362
+ *
1363
+ * **What the gloss asked for and what this closes.** `docs/vision.md` glosses `init` as *interview +
1364
+ * codebase scan → drafted workspace, human curates*. The scan has shipped since session 1 and the
1365
+ * substrate — every question modelled as an answer, with one validator each — since then; the prompt
1366
+ * loop was the open half, and this is it. Nothing here decides anything the flags path could not
1367
+ * decide, which is the property that keeps the two paths one tool.
1368
+ *
1369
+ * **A question is re-asked, never fatal.** The flags path refuses a bad value because there is nobody
1370
+ * to ask again; here there is, and aborting an interview on a typo would make the interactive path the
1371
+ * unforgiving one. The validators are the same functions, so the two paths cannot drift into
1372
+ * disagreeing about what a valid answer is.
1373
+ *
1374
+ * Every answer supplied by a flag or an answers file is skipped: being asked to confirm something you
1375
+ * just typed on the command line reads as the tool not having listened.
1376
+ */
1377
+ export async function interview(answers, { io, target, derivedName }) {
1378
+ const ask = async (prompt, { fallback = null, validate = () => null } = {}) => {
1379
+ for (;;) {
1380
+ const raw = await io.ask(fallback === null ? `${prompt}: ` : `${prompt} [${fallback}]: `);
1381
+ // EOF — Ctrl-D, or a stream that closed — is not an empty answer. An empty LINE accepts
1382
+ // the offered default; end-of-input means no answer is coming, and the run stops with
1383
+ // nothing written rather than proceeding on defaults nobody confirmed.
1384
+ if (raw === null) throw new InitError("no answer — the interview ended before it finished, and nothing was written");
1385
+ const value = raw.trim() === "" ? fallback : raw.trim();
1386
+ if (value === null || value === "") {
1387
+ io.say(" that one has no default, so it needs an answer.");
1388
+ continue;
1389
+ }
1390
+ const why = validate(value);
1391
+ if (why === null) return value;
1392
+ io.say(` ${why}`);
1393
+ }
1394
+ };
1395
+
1396
+ io.say("");
1397
+ io.say(`init: drafting a workspace for ${target}`);
1398
+ io.say("init: every answer becomes a DRAFT you curate afterwards. Nothing is written until you say so.");
1399
+ io.say("");
1400
+
1401
+ if (!answers.residence) {
1402
+ io.say("Where does this repository's workspace live?");
1403
+ io.say(" in-repo a full workspace in this repository");
1404
+ io.say(" pointer this repository is governed by a workspace that names it");
1405
+ answers.residence = await ask("residence", {
1406
+ validate: (v) => (RESIDENCES.has(v) ? null : `\`${v}\` is not a residence — the two are \`in-repo\` and \`pointer\`.`),
1407
+ });
1408
+ answers.given.add("residence");
1409
+ }
1410
+
1411
+ if (answers.name === null) {
1412
+ answers.name = await ask("workspace name", {
1413
+ fallback: derivedName,
1414
+ validate: (v) => (SLUG.test(v) ? null : `\`${v}\` is not a slug — lowercase letters, digits and single hyphens (${SLUG.source}).`),
1415
+ });
1416
+ answers.given.add("name");
1417
+ }
1418
+
1419
+ if (answers.summary === null) {
1420
+ answers.summary = await ask("one-line summary", { fallback: `The ${answers.name} workspace — drafted by \`init\`, and not yet curated.` });
1421
+ answers.given.add("summary");
1422
+ }
1423
+
1424
+ if (answers.residence === "pointer") {
1425
+ if (answers.governedBy === null) {
1426
+ answers.governedBy = await ask("the governing workspace's name", {
1427
+ validate: (v) => (SLUG.test(v) ? null : `\`${v}\` is not a slug — it must be spelled exactly as that workspace's own \`name\`.`),
1428
+ });
1429
+ answers.given.add("governed-by");
1430
+ }
1431
+ if (answers.feed === null) {
1432
+ // `none` rather than an empty line, because an empty line means *take the default* at
1433
+ // every other prompt and a second meaning for it here is how an answer gets misread.
1434
+ const feed = await ask("the feed it ships through", { fallback: "none" });
1435
+ if (feed !== "none") {
1436
+ answers.feed = feed;
1437
+ answers.given.add("feed");
1438
+ }
1439
+ }
1440
+ } else if (!answers.given.has("checkpoints") && !answers.given.has("cycle")) {
1441
+ io.say("");
1442
+ io.say("The supervised cycle composes a checkpoints pack, so full-lane work gets a fresh-context");
1443
+ io.say("verdict at session-open, pre-commit and milestone-close. It is opt-out.");
1444
+ const pack = await ask("checkpoints pack (`none` to compose nothing)", { fallback: DEFAULT_CHECKPOINTS });
1445
+ if (pack === "none") {
1446
+ answers.cycle = false;
1447
+ } else {
1448
+ answers.checkpoints = pack;
1449
+ answers.given.add("checkpoints");
1450
+ }
1451
+ }
1452
+
1453
+ // The last question, and the one that makes every answer above reversible: everything is echoed
1454
+ // and nothing has been written. A confirmation printed AFTER the write would be a receipt.
1455
+ io.say("");
1456
+ io.say("init: about to draft");
1457
+ io.say(` residence ${answers.residence}`);
1458
+ io.say(` name ${answers.name}`);
1459
+ io.say(` summary ${answers.summary}`);
1460
+ if (answers.residence === "pointer") {
1461
+ io.say(` governed by ${answers.governedBy}`);
1462
+ io.say(` feed ${answers.feed ?? "(none)"}`);
1463
+ } else {
1464
+ io.say(` cycle ${answers.cycle ? answers.checkpoints : "(none — composing no packs)"}`);
1465
+ }
1466
+ io.say("");
1467
+ const confirm = await io.ask("Write these files? [y/N]: ");
1468
+ if (confirm === null || !/^y(es)?$/i.test(confirm.trim())) {
1469
+ // Exit 2, because this file has exactly two codes and 0 means *it wrote*. A decline is not a
1470
+ // verdict about anybody's workspace either, so 1 stays as absent as it has always been.
1471
+ throw new InitError("nothing written — you declined at the confirmation. Run this again when the answers are right.");
1472
+ }
1473
+ }
1474
+
1475
+ // ------------------------------------------------------------------------- the entry point
1476
+
1477
+ export function usage() {
1478
+ return [
1479
+ "portulan init — draft a workspace for a repository that has none",
1480
+ "",
1481
+ // Both spellings, for #155's reason: the tool is reachable through the entry point and from a
1482
+ // checkout, and a usage line naming only one of them is wrong for whoever arrived the other way.
1483
+ " portulan init [options] <repository-directory>",
1484
+ " node cli/init.mjs [options] <repository-directory> (from a checkout)",
1485
+ "",
1486
+ " --residence <in-repo|pointer> REQUIRED. Where this repository's workspace lives:",
1487
+ " in the repository, or in a workspace that names it.",
1488
+ " There is no default — this is the question init asks.",
1489
+ " --governed-by <workspace> Required with `pointer`: the governing workspace's name.",
1490
+ " --feed <name> Optional, pointer only: the feed it ships through.",
1491
+ " --name <slug> The workspace's name. Defaults to the directory's.",
1492
+ " --summary <text> One line an agent reads before loading anything else.",
1493
+ " --checkpoints <category/name> The checkpoint pack to compose. Default: " + DEFAULT_CHECKPOINTS + ".",
1494
+ " --no-cycle Compose no packs. The binding is opt-out, not opt-in.",
1495
+ " --pack-root <dir> Where packs are looked up, so the composed one can be",
1496
+ " confirmed to exist. Repeatable. `auto` discovers it from",
1497
+ " the host's plugin cache; `./auto` names a directory.",
1498
+ " --answers <file> A JSON object of answers. Flags override its keys.",
1499
+ " --no-interview Never ask, even at a terminal: refuse instead, naming the flag.",
1500
+ "",
1501
+ "Exit codes: 0 it wrote · 2 it wrote nothing. There is no 1: init renders no verdict.",
1502
+ "",
1503
+ "At a terminal, anything you have not answered is asked, and nothing is written until you",
1504
+ "confirm. Where stdin or stdout is not a TTY nothing is asked — the answers arrive as flags",
1505
+ "or as `--answers`, and a missing one is refused with the flag that supplies it.",
1506
+ ].join("\n");
1507
+ }
1508
+
1509
+ export async function run(argv, options = {}) {
1510
+ const say = options.say ?? ((line) => process.stdout.write(`${line}\n`));
1511
+ const warn = options.warn ?? ((line) => process.stderr.write(`${line}\n`));
1512
+
1513
+ try {
1514
+ if (SLUG === null) {
1515
+ throw new InitError(
1516
+ `could not read spec/workspace.schema.json, which defines what a valid name is — ${SCHEMA_ERROR?.message}. ` +
1517
+ `Refusing to write a manifest it cannot check.`,
1518
+ );
1519
+ }
1520
+
1521
+ const parsed = parseArgs(argv);
1522
+ if (parsed.help) {
1523
+ say(usage());
1524
+ return 0;
1525
+ }
1526
+
1527
+ if (!parsed.target) {
1528
+ throw new InitError("no target directory given — `init <repository-directory>` drafts a workspace into a repository");
1529
+ }
1530
+
1531
+ const target = path.resolve(parsed.target);
1532
+ let stat;
1533
+ try {
1534
+ stat = fs.statSync(target);
1535
+ } catch (error) {
1536
+ throw new InitError(
1537
+ `\`${parsed.target}\` does not exist — ${error.message}. \`init\` drafts a workspace INTO a repository; ` +
1538
+ `it does not create the repository.`,
1539
+ );
1540
+ }
1541
+ if (!stat.isDirectory()) throw new InitError(`\`${parsed.target}\` is not a directory`);
1542
+
1543
+ const answers = resolveAnswers(parsed.flags);
1544
+ const derivedName = slugify(path.basename(target));
1545
+
1546
+ // The interview, and the two conditions on it. A prompt loop cannot be run by CI, by a test or
1547
+ // by a headless host, so **everything below happens only where somebody is at both ends** —
1548
+ // stdin and stdout are both TTYs — and a non-interactive run is byte-for-byte what it was
1549
+ // before this existed. `--no-interview` is the escape for a TTY that wants the old refusals.
1550
+ //
1551
+ // The governance question is asked FIRST and by the machine, not the human: being made to
1552
+ // answer five questions and then told this repository already has a workspace is the shape of
1553
+ // a tool that asks before it looks. The same refusal stands where it always did, for callers
1554
+ // that never reach this branch — running it twice is two reads and no writes.
1555
+ const io = options.io ?? terminal();
1556
+ const interviewing = io.interactive && !parsed.flags.noInterview;
1557
+ if (interviewing) {
1558
+ refuseIfGoverned(residenceAt(target), parsed.target);
1559
+ await interview(answers, { io, target, derivedName });
1560
+ }
1561
+
1562
+ if (answers.name === null) answers.name = derivedName;
1563
+ if (answers.name === null) {
1564
+ throw new InitError(
1565
+ `the directory name \`${path.basename(target)}\` yields no usable workspace name — pass \`--name <slug>\`. ` +
1566
+ `A workspace called something nobody chose is worse than being asked.`,
1567
+ );
1568
+ }
1569
+ validateAnswers(answers);
1570
+
1571
+ // Before a byte: is this repository already governed? A repository is governed by exactly one
1572
+ // workspace, so finding one here is a refusal rather than a prompt to replace it.
1573
+ refuseIfGoverned(residenceAt(target), parsed.target);
1574
+
1575
+ // **`answers.packRoots.length` was the gate, and dropping it is what makes the unasked arm
1576
+ // live.** With it, `expandRoots`' new branch was unreachable from the command line — the exact
1577
+ // shape of dead plumbing `skills-set` and `recipe-set` were each caught with, a capability that
1578
+ // reads as wired and is none. Every `in-repo` run that composes a pack now asks the question;
1579
+ // what differs is whether the answer can refuse.
1580
+ let packAdvice = null;
1581
+ if (answers.residence === "in-repo" && answers.cycle) {
1582
+ const expanded = expandRoots(answers.packRoots, target, options.env);
1583
+ // Belt and braces: `validateAnswers` has already refused this pair on every path. Kept
1584
+ // because `expandRoots` is reachable on its own and must not answer with an empty set.
1585
+ if (expanded.refusal) throw new InitError(expanded.refusal);
1586
+ const resolvedAt = packResolvedAt(expanded.roots, answers.checkpoints);
1587
+ const resolved = resolvedAt !== null;
1588
+ if (expanded.asked) {
1589
+ if (expanded.why) {
1590
+ throw new InitError(
1591
+ `\`--pack-root auto\` could not read this host's plugin record, so whether \`${answers.checkpoints}\` ` +
1592
+ `is installed is unknown rather than no — ${expanded.why}. Name a root explicitly, or run with ` +
1593
+ `\`--no-cycle\` and compose the pack later.`,
1594
+ );
1595
+ }
1596
+ if (!resolved) {
1597
+ throw new InitError(
1598
+ `the pack \`${answers.checkpoints}\` does not resolve under ${answers.packRoots.map((r) => `\`${r}\``).join(", ")} ` +
1599
+ `— refusing to compose a pack that is not there. Pass a root that carries it, name a different pack ` +
1600
+ `with \`--checkpoints\`, or run with \`--no-cycle\` and compose it later.`,
1601
+ );
1602
+ }
1603
+ }
1604
+ // **Unasked: an answer, never a refusal.** Carried to the closing advice rather than thrown,
1605
+ // because a run that refuses to draft on one host and drafts on another has made the
1606
+ // existence of files a function of the machine. The three answers are kept apart — resolved,
1607
+ // looked-and-absent, could-not-look — because "the pack is not here" and "I could not tell"
1608
+ // send a reader to different places.
1609
+ // `inTree` is what keeps the advice from naming the wrong residence: the unasked path
1610
+ // searches the host cache AND `<target>/packs`, and only the second is the repository's own.
1611
+ packAdvice = { resolved, why: expanded.why, inTree: resolvedAt !== null && resolvedAt === path.join(target, "packs") };
1612
+ }
1613
+
1614
+ const files = draft(answers, scan(target));
1615
+
1616
+ // The second safety question, and it is not the one above. "Is this repository governed?"
1617
+ // keys on the manifest; "may I write here?" keys on every path the draft touches. A
1618
+ // `.portulan/` with a hand-written file and no manifest answers no to the first and must
1619
+ // still stop the second.
1620
+ const clash = collisions(target, files);
1621
+ if (clash.length) {
1622
+ // Grouped by CAUSE, not listed per path. One symlinked `.portulan` blocks every drafted
1623
+ // file, and naming it ten times buries the single fact the reader needs under nine
1624
+ // copies of it — a refusal nobody finishes reading is a refusal that failed to explain.
1625
+ const byCause = new Map();
1626
+ for (const { rel, why } of clash) (byCause.get(why) ?? byCause.set(why, []).get(why)).push(rel);
1627
+ const summary = [...byCause]
1628
+ .map(([why, rels]) =>
1629
+ rels.length > 2
1630
+ ? `${why} — blocking ${rels.length} drafted files, including \`${rels[0]}\``
1631
+ : `${why} — ${rels.map((r) => `\`${r}\``).join(", ")}`,
1632
+ )
1633
+ .join("; ");
1634
+ throw new InitError(
1635
+ `refusing to write into \`${path.join(parsed.target, ".portulan")}\`: ${summary}. \`init\` drafts a ` +
1636
+ `workspace where there is none; it does not merge into one somebody has started, and it does not ` +
1637
+ `follow a link out of the repository. Move or remove what is in the way, or draft into a clean ` +
1638
+ `directory and copy across what you want to keep.`,
1639
+ );
1640
+ }
1641
+
1642
+ // Every refusal is ahead of this line, so a run that reaches here has nothing left to
1643
+ // discover — but I/O can still fail for reasons no check can see, and the ORDER decides what
1644
+ // a failure leaves behind. `workspace.json` is written LAST: written first, a half-completed
1645
+ // run leaves a torso that the residence check above then reads as a governed repository,
1646
+ // and the retry is refused with a sentence that is false. Last, the same failure leaves
1647
+ // files and no manifest — which the collision check reports plainly and a person can clear.
1648
+ const manifest = ".portulan/workspace.json";
1649
+ for (const [rel, file] of [...files].filter(([rel]) => rel !== manifest).concat([[manifest, files.get(manifest)]])) {
1650
+ const full = path.join(target, rel);
1651
+ fs.mkdirSync(path.dirname(full), { recursive: true });
1652
+ fs.writeFileSync(full, file.contents);
1653
+ if (file.mode !== undefined) fs.chmodSync(full, file.mode);
1654
+ }
1655
+
1656
+ say(`init: drafted ${files.size} file(s) into ${path.join(parsed.target, ".portulan")}`);
1657
+ if (answers.residence === "pointer") {
1658
+ say(`init: this repository is recorded as governed by \`${answers.governedBy}\`. Nothing was fetched.`);
1659
+ } else {
1660
+ say("init: every file is a DRAFT. Read .portulan/README.md before trusting any of it.");
1661
+ say("init: the verify recipe exits 2 until you say what green means here — that is deliberate.");
1662
+ if (answers.cycle) {
1663
+ // Said at the surface rather than only in a file, because the alternative is that the
1664
+ // adopter's very next command is `doctor`, it is RED on a pack nothing can find, and
1665
+ // the tool that put it there said nothing about it.
1666
+ //
1667
+ // **It used to say that unconditionally**, in the words "nothing resolves a pack for
1668
+ // you … name where it lives" — which stopped being true when `--pack-root auto`
1669
+ // landed at milestone 7 session 4, and read as advice to go and find a cache path by
1670
+ // hand immediately after `auto` had found one. Worse than merely stale: this tool has
1671
+ // ALREADY checked resolvability above, so it knew. Found by running `init` on a real
1672
+ // never-seen repository for D1, which is the half of this class no reading catches.
1673
+ // **The invocation printed is the one that WORKS, and since 2026-08-13 that is usually
1674
+ // the bare one.** `doctor` consults discovery unasked, so a host carrying the pack needs
1675
+ // no flag at all — and printing `--pack-root auto` there would be the same defect this
1676
+ // branch was already fixed for once, advice to type something the tool no longer needs.
1677
+ const workspaceArg = path.join(parsed.target, ".portulan");
1678
+ if (answers.packRoots.length) {
1679
+ const rootArgs = answers.packRoots.map((r) => `--pack-root ${r}`).join(" ");
1680
+ say(`init: this workspace composes \`${answers.checkpoints}\`, and it resolved — validate with:`);
1681
+ say(`init: doctor ${rootArgs} ${workspaceArg}`);
1682
+ } else if (packAdvice?.resolved) {
1683
+ const where = packAdvice.inTree ? "from `packs/` in this repository" : "from this host's plugin cache";
1684
+ say(`init: this workspace composes \`${answers.checkpoints}\`, and it resolved ${where} — validate with:`);
1685
+ say(`init: doctor ${workspaceArg}`);
1686
+ // Said out loud because it is the one thing the green does not certify — and only
1687
+ // where it is TRUE. A pack found on this machine is not in the repository, so a CI
1688
+ // runner with nothing installed derives `<repo>/packs` alone and reports it
1689
+ // unresolved; a pack found in the tree travels with the tree and needs no warning.
1690
+ if (!packAdvice.inTree) {
1691
+ say("init: that root is this machine's, not the repository's — pin `--pack-root <dir>` in CI.");
1692
+ }
1693
+ } else if (packAdvice?.why) {
1694
+ say(
1695
+ `init: this workspace composes \`${answers.checkpoints}\` and this host's plugin record ` +
1696
+ `could not be read, so whether it is installed is unknown rather than no:`,
1697
+ );
1698
+ say(`init: ${packAdvice.why}`);
1699
+ say(`init: doctor --pack-root <dir> ${workspaceArg} (naming where the pack lives)`);
1700
+ } else {
1701
+ say(
1702
+ `init: this workspace composes \`${answers.checkpoints}\` and nothing here resolves it — ` +
1703
+ `not the host's plugin cache, and not \`packs/\` in the repository — so validation is RED until you say where to look:`,
1704
+ );
1705
+ say(`init: doctor --pack-root <dir> ${workspaceArg}`);
1706
+ say("init: or re-draft with --no-cycle and compose it once you know.");
1707
+ }
1708
+ }
1709
+ }
1710
+ return 0;
1711
+ } catch (error) {
1712
+ if (error instanceof InitError) {
1713
+ warn(`init: ${error.message}`);
1714
+ return 2;
1715
+ }
1716
+ warn(`init: could not run — ${error.message}`);
1717
+ return 2;
1718
+ }
1719
+ }
1720
+
1721
+ // The `?? ""` is not decoration and every sibling tool here carries it: `process.argv[1]` is absent
1722
+ // when this module is imported by something that is not a script — `node -e "import('./init.mjs')"`,
1723
+ // which is how a suite or a REPL reaches it — and `pathToFileURL(undefined)` throws at module load.
1724
+ // This file argued two hundred lines up that an unreadable schema must not throw while loading,
1725
+ // because the entry point can only turn that into a stack trace; it then omitted the guard that makes
1726
+ // the same promise here. Found at the pre-commit checkpoint.
1727
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
1728
+ process.exitCode = await run(process.argv.slice(2));
1729
+ }