@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/new.mjs ADDED
@@ -0,0 +1,803 @@
1
+ #!/usr/bin/env node
2
+ // `new` — scaffold a skill · persona · pack · workspace · gate-policy · repo-card from a core template,
3
+ // into the user's OWN layer, never into `core/`.
4
+ //
5
+ // Milestone 7, row 7 of docs/plan.md. Zero dependencies, ESM, no build step (the session-0 ruling).
6
+ //
7
+ // ## The one property this tool states in the imperative
8
+ //
9
+ // **Never into `core/`.** Row 7 says it, and it is the reason this tool exists at all rather than being
10
+ // advice: the cascade runs core < pack < workspace < repo card < task, and a team that answers "where do
11
+ // I put my own skill?" by editing `core/` has merged their specifics into the layer that ships to
12
+ // everybody. Thesis 6 of the constitution — storage follows ownership.
13
+ //
14
+ // The refusal is **resolution-based, not pattern-based**, because every interesting escape parses fine:
15
+ // `packs/../core` matches any path rule you could write, and a symlinked destination matches nothing at
16
+ // all until you follow it. So the check resolves first and compares after — the same conclusion
17
+ // `spec/pack.schema.json`'s `$defs/filePath` reaches from the other side, and the one `cli/compile.mjs`
18
+ // already acts on for its policy path.
19
+ //
20
+ // ## Two exit codes, deliberately
21
+ //
22
+ // **0 wrote · 2 could not run.** There is no exit 1, for the same reason `init` has none: this tool
23
+ // renders no verdict about anybody's workspace, so it has no red to report. `doctor` is what says
24
+ // whether what landed is any good, and conflating "I could not write this" with "what I wrote is wrong"
25
+ // would make a caller unable to tell a missing template from a bad scaffold.
26
+ //
27
+ // ## What it refuses, ahead of the first byte
28
+ //
29
+ // A file it would overwrite — **any** file, never only a manifest. Session 1 shipped a residence check
30
+ // keyed on `workspace.json` and treated it as an answer to *is it safe to write here?*; a hand-written
31
+ // `gate-map.md` was silently overwritten. Two questions, one key: that was the whole defect. Here there
32
+ // is only one question and it is asked about every path.
33
+ //
34
+ // A symlink anywhere on the destination chain — resolved links are how a write leaves the tree it was
35
+ // meant to stay inside, measured on `init` at nine files written into an unrelated directory and
36
+ // reported as success.
37
+ //
38
+ // And an `lstat` failure that is **not** `ENOENT`, because only `ENOENT` means absent. `EACCES` means
39
+ // the question could not be answered, and answering *nothing there* to an unanswerable question is
40
+ // "nothing looked" recorded as "nothing wrong".
41
+
42
+ import fs from "node:fs";
43
+ import path from "node:path";
44
+ import { fileURLToPath, pathToFileURL } from "node:url";
45
+
46
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
47
+ const CORE = path.resolve(HERE, "..", "core");
48
+
49
+ /** Raised for anything that means `new` could not run. Carries no verdict about a workspace. */
50
+ export class NewError extends Error {}
51
+
52
+ /**
53
+ * The six kinds row 7 names. A Set rather than an array so membership is the question asked, and
54
+ * exported so the suite compares against the row rather than against a count that passes while a name
55
+ * is wrong.
56
+ */
57
+ export const KINDS = new Set(["skill", "persona", "pack", "workspace", "gate-policy", "repo-card"]);
58
+
59
+ /** A lowercase hyphenated identifier — the same shape `$defs/slug` fixes in both schemas. */
60
+ export const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
61
+
62
+ /** Kinds whose artifact is JSON, extracted from the template's first fenced `json` block. */
63
+ const JSON_KINDS = new Set(["pack", "workspace", "gate-policy"]);
64
+
65
+ /** Where a kind's core template lives. One place, so a missing template is one failure and not six. */
66
+ export function template(kind) {
67
+ return path.join(CORE, "templates", `${kind}.md`);
68
+ }
69
+
70
+ /**
71
+ * The skeleton inside a template — everything after the first standalone `---`, or, for the JSON kinds,
72
+ * the first fenced ```json block after it.
73
+ *
74
+ * The separator is the first `---` at column 0 rather than the last, which matters for the skill
75
+ * template specifically: its skeleton BEGINS with YAML frontmatter, so the file legitimately contains
76
+ * two `---` lines in a row and taking the last one would swallow the frontmatter the skeleton exists to
77
+ * provide.
78
+ */
79
+ export function skeleton(kind, source) {
80
+ const lines = source.split("\n");
81
+ const at = lines.findIndex((line) => line.trimEnd() === "---");
82
+ if (at === -1) throw new NewError(`the core template for \`${kind}\` has no \`---\` separator — it cannot be read as a skeleton`);
83
+ const body = lines.slice(at + 1).join("\n");
84
+ if (!JSON_KINDS.has(kind)) return `${body.trim()}\n`;
85
+
86
+ const open = body.indexOf("```json");
87
+ if (open === -1) throw new NewError(`the core template for \`${kind}\` carries no fenced \`json\` block, and \`${kind}\` scaffolds JSON`);
88
+ const start = body.indexOf("\n", open) + 1;
89
+ const close = body.indexOf("```", start);
90
+ if (close === -1) throw new NewError(`the core template for \`${kind}\` has an unterminated \`json\` block`);
91
+ return `${body.slice(start, close).trim()}\n`;
92
+ }
93
+
94
+ /**
95
+ * Where a scaffold lands, as a pure function of kind, name and target.
96
+ *
97
+ * A repo card resolves through the workspace's **declared** `repos` slot rather than through a
98
+ * convention, because deriving a path a manifest does not declare is the tool inventing a slot — and a
99
+ * workspace that disagrees with its own manifest on the day it was scaffolded is exactly the drift the
100
+ * claims lint exists to catch.
101
+ */
102
+ export function destination(kind, name, target) {
103
+ switch (kind) {
104
+ case "skill":
105
+ return path.join(target, "skills", name, "SKILL.md");
106
+ case "persona":
107
+ return path.join(target, "personas", `${name}.md`);
108
+ case "gate-policy":
109
+ return path.join(target, `${name}.json`);
110
+ case "repo-card": {
111
+ const slot = declaredSlot(target, "repos");
112
+ // The slot value is the WORKSPACE's, not this tool's, and a `..` in it escapes:
113
+ // `path.join("/ws/sub", "../../evil")` is `/evil`, measured. (An *absolute* slot does not
114
+ // escape `join` — that is `resolve`'s behaviour, not `join`'s — so only the `..` half of the
115
+ // review that found this was right, and the fix is written against what was measured rather
116
+ // than against what was reported.) Checked after resolution, like every other containment
117
+ // question here. Copilot, round 3 on #156.
118
+ const landing = path.resolve(target, slot, `${name}.md`);
119
+ const root = path.resolve(target);
120
+ if (landing !== root && !landing.startsWith(`${root}${path.sep}`)) {
121
+ throw new NewError(
122
+ `this workspace's \`repos\` slot is \`${slot}\`, which resolves to ${landing} — outside the workspace at ${display(root)}. ` +
123
+ `Refusing: a scaffold that follows a slot out of its own workspace writes wherever the manifest points, and a manifest is ` +
124
+ `exactly the thing a reader assumes has been checked`,
125
+ );
126
+ }
127
+ return landing;
128
+ }
129
+ default:
130
+ throw new NewError(`\`${kind}\` writes more than one file — use \`plan\` rather than \`destination\``);
131
+ }
132
+ }
133
+
134
+ /** Reads one slot out of a workspace manifest, refusing rather than guessing when it is not declared. */
135
+ function declaredSlot(workspaceDir, slot) {
136
+ const manifestPath = path.join(workspaceDir, "workspace.json");
137
+ let manifest;
138
+ try {
139
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
140
+ } catch (cause) {
141
+ if (cause.code === "ENOENT") {
142
+ throw new NewError(
143
+ `${display(workspaceDir)} carries no \`workspace.json\`, so there is no manifest to read a \`${slot}\` slot from. ` +
144
+ `Point \`--into\` at a workspace directory, or run \`init\` if this repository has no workspace yet`,
145
+ );
146
+ }
147
+ throw new NewError(`${display(workspaceDir)}/workspace.json could not be read — ${cause.code ?? cause.message}`);
148
+ }
149
+ const declared = manifest?.slots?.[slot];
150
+ if (typeof declared !== "string" || !declared.trim()) {
151
+ throw new NewError(
152
+ `this workspace declares no \`${slot}\` slot, so there is nowhere for a ${slot === "repos" ? "repo card" : slot} to land. ` +
153
+ `Add \`"slots": { "${slot}": "${slot}/" }\` to its \`workspace.json\` first — scaffolding into a path the manifest does not declare ` +
154
+ `would leave the workspace disagreeing with itself on the day it was written`,
155
+ );
156
+ }
157
+ return declared;
158
+ }
159
+
160
+ /**
161
+ * Which of the destination paths already exist, and which sit behind a symlink.
162
+ *
163
+ * `lstatSync`, never `existsSync`/`statSync` — both of those follow links, which is how `init` wrote
164
+ * nine files outside a repository and reported success. A symlink is reported as its own state rather
165
+ * than resolved and permitted: deciding whether a link's target is "really" inside the intended tree is
166
+ * a containment judgement with a bad failure mode, where refusing has none.
167
+ *
168
+ * An existing **directory** is not a collision. This tool writes files into directories a user may
169
+ * legitimately have created; refusing an empty directory would make `new` unusable in the ordinary case
170
+ * and teach nobody anything.
171
+ */
172
+ export function collisions(paths, root = null) {
173
+ const found = [];
174
+ for (const target of paths) {
175
+ for (const step of chain(target, root)) {
176
+ let stat;
177
+ try {
178
+ stat = fs.lstatSync(step);
179
+ } catch (cause) {
180
+ // Only ENOENT means absent. Anything else means the question could not be answered, and
181
+ // an unanswerable question must not be answered "no".
182
+ if (cause.code === "ENOENT") continue;
183
+ found.push({ path: step, why: "unreadable", detail: cause.code ?? cause.message });
184
+ break;
185
+ }
186
+ if (stat.isSymbolicLink()) {
187
+ found.push({ path: step, why: "symlink" });
188
+ break;
189
+ }
190
+ // A DIRECTORY at the leaf is a collision too, and the paragraph above says the opposite —
191
+ // rightly, about the *intermediate* segments, and wrongly here: every path this plans is a
192
+ // file, so a directory sitting at one is something already there. Preflight passed and
193
+ // `writeFileSync` then threw `EISDIR` mid-loop, which is the ordering this whole function
194
+ // exists to prevent. Found by Copilot on #164 against the copy of this shape in
195
+ // `cli/vendor.mjs`; fixed in both, because a defect and its source are the same defect.
196
+ if (step === target) {
197
+ if (stat.isDirectory()) found.push({ path: step, why: "directory" });
198
+ else found.push({ path: step, why: "exists" });
199
+ }
200
+ }
201
+ }
202
+ return found;
203
+ }
204
+
205
+ /**
206
+ * The path steps from `root` down to `target`, inclusive of both — or every step to the filesystem root
207
+ * when no root is given.
208
+ *
209
+ * **Where the boundary sits is the whole rule, and getting it wrong was found by running rather than by
210
+ * reading.** The first cut walked to the filesystem root and refused any symlink anywhere on the chain.
211
+ * On macOS `os.tmpdir()` resolves under `/var`, and `/var` is a symlink to `/private/var` — so every
212
+ * scratch directory in this suite was refused, and by extension so was every user whose destination sat
213
+ * under a symlinked mount, a home directory on a linked volume, or `/tmp`.
214
+ *
215
+ * The reason it was wrong is not that the check was too strict; it is that it asked about the wrong
216
+ * paths. A symlink among the **ancestors of the directory the user named** is a fact about their
217
+ * filesystem, and the user named that path — resolving it is not an escape, it is obeying them. A
218
+ * symlink **at or below** the named path is different: those are the segments this tool derives and
219
+ * creates, and a link there is precisely how `init` wrote nine files into an unrelated directory and
220
+ * reported success (session 1, rounds 4 and 6, the write path and then the read path).
221
+ *
222
+ * So the boundary is the named destination. Above it, resolve. At it and below it, refuse.
223
+ */
224
+ function chain(target, root = null) {
225
+ const steps = [];
226
+ let at = target;
227
+ const stop = root === null ? null : path.resolve(root);
228
+ for (let i = 0; i < 64; i += 1) {
229
+ steps.push(at);
230
+ if (stop !== null && at === stop) break;
231
+ const up = path.dirname(at);
232
+ if (up === at) break;
233
+ at = up;
234
+ }
235
+ return steps.reverse();
236
+ }
237
+
238
+ /** A path as a reader would recognise it — relative to cwd when that is shorter, absolute otherwise. */
239
+ function display(target) {
240
+ const rel = path.relative(process.cwd(), target);
241
+ return rel && !rel.startsWith("..") && rel.length < target.length ? rel : target;
242
+ }
243
+
244
+ /**
245
+ * Is this destination inside a `core/` — this checkout's or anybody's?
246
+ *
247
+ * The rule is about the LAYER, not about this repository: a vendored or installed copy of core is still
248
+ * core, and a scaffold landing there is still an edit to a file the project ships. So two questions are
249
+ * asked — is it inside THIS checkout's core, and does the resolved path contain a `core` directory that
250
+ * looks like one (it holds `engine.md` or `templates/`).
251
+ */
252
+ export function intoCore(resolved) {
253
+ // **Compared case-INSENSITIVELY, because the filesystem may not care and `realpathSync` does not
254
+ // canonicalise case.** Measured by the pre-commit checkpoint on macOS: `--into <repo>/CORE/templates/x`
255
+ // exited 0 and created a directory inside the shipped `core/`. A case-sensitive compare is not a
256
+ // compare at all on a case-insensitive volume, which is the default on macOS and common on Windows.
257
+ //
258
+ // The cost, stated rather than discovered: on a case-SENSITIVE filesystem a directory genuinely named
259
+ // `CORE` — distinct from `core` — is also refused. That is a conservative refusal, and the asymmetry
260
+ // is the whole argument: refusing costs the user one corrected path, permitting costs an edit to a
261
+ // layer this project ships and the property row 7 states in the imperative.
262
+ const fold = (p) => p.toLowerCase();
263
+ const ours = path.resolve(CORE);
264
+ if (fold(resolved) === fold(ours) || fold(resolved).startsWith(fold(`${ours}${path.sep}`))) return ours;
265
+ const parts = resolved.split(path.sep);
266
+ for (let i = parts.length; i > 0; i -= 1) {
267
+ if (fold(parts[i - 1]) !== "core") continue;
268
+ const candidate = parts.slice(0, i).join(path.sep) || path.sep;
269
+ if (fs.existsSync(path.join(candidate, "engine.md")) || fs.existsSync(path.join(candidate, "templates"))) return candidate;
270
+ }
271
+ return null;
272
+ }
273
+
274
+ /**
275
+ * Parse the command line.
276
+ *
277
+ * A value beginning with **any** leading `-` is a missing value, not a value — session 1's round 9,
278
+ * where `--residence -h` was consumed as the residence and the user was then blamed for a token they
279
+ * typed as a flag. `-h` is the likeliest thing to land there and it is a help request.
280
+ *
281
+ * An empty string is refused for every flag, because none of them has a meaningful empty value and an
282
+ * empty one reached a manifest once already, failing `minLength: 1` on the run that reported success.
283
+ */
284
+ export function parseArgs(argv) {
285
+ const out = { kind: null, name: null, into: null, category: null, kindOf: null, governedBy: null, help: false, given: new Set() };
286
+ const positional = [];
287
+ for (let i = 0; i < argv.length; i += 1) {
288
+ const arg = argv[i];
289
+ if (arg === "--help" || arg === "-h") {
290
+ out.help = true;
291
+ continue;
292
+ }
293
+ if (!arg.startsWith("-")) {
294
+ positional.push(arg);
295
+ continue;
296
+ }
297
+ const flag = arg;
298
+ const key = { "--into": "into", "--category": "category", "--kind": "kindOf", "--governed-by": "governedBy" }[flag];
299
+ if (!key) {
300
+ throw new NewError(
301
+ `unknown option \`${flag}\` — run \`portulan new --help\` for the ones this understands, ` +
302
+ `or \`node cli/new.mjs --help\` from a checkout`,
303
+ );
304
+ }
305
+ const value = argv[i + 1];
306
+ if (value === undefined || value.startsWith("-")) {
307
+ throw new NewError(`${flag} needs a value${value === undefined ? "" : ` — \`${value}\` reads as another flag`}`);
308
+ }
309
+ if (value === "") throw new NewError(`${flag} was given an empty value, and no option here has a meaningful empty value`);
310
+ out[key] = value;
311
+ out.given.add(flag);
312
+ i += 1;
313
+ }
314
+ out.kind = positional[0] ?? null;
315
+ out.name = positional[1] ?? null;
316
+ return out;
317
+ }
318
+
319
+ function usage() {
320
+ return [
321
+ "portulan new <kind> <name> [options]",
322
+ "",
323
+ " kinds:",
324
+ " skill a procedure loaded on demand, into a pack you own",
325
+ " persona a role with a five-part contract, into a pack you own",
326
+ " pack the cascade's middle layer — skills, personas, recipes, gate fragments",
327
+ " workspace a workspace authored by hand (use `init` to onboard a repository)",
328
+ " gate-policy actions bound to tiers, compiled into host enforcement",
329
+ " repo-card the per-repository layer, into a workspace's declared `repos` slot",
330
+ "",
331
+ " --into <dir> where it lands. Never inside `core/`.",
332
+ " --category <c> stacks | tools | rituals (packs only)",
333
+ " --kind <k> repository | demo | portfolio | pointer (workspaces only)",
334
+ " --governed-by <ws> the governing workspace's name (required with --kind pointer)",
335
+ "",
336
+ " Exit 0 wrote · 2 could not run. There is no exit 1: this tool renders no verdict",
337
+ " about a workspace, so it has no red to report. Run `doctor` for that.",
338
+ ].join("\n");
339
+ }
340
+
341
+ /**
342
+ * What a kind writes: a list of `{ path, contents }`. Multi-file kinds are planned in one place so the
343
+ * collision check sees **every** path before the first byte is written — a partial write that wedges its
344
+ * own retry is what session 1 shipped when the manifest went out first.
345
+ */
346
+ export function plan(parsed) {
347
+ const { kind, name } = parsed;
348
+ const into = path.resolve(parsed.into ?? process.cwd());
349
+ const body = skeleton(kind, fs.readFileSync(template(kind), "utf8"));
350
+
351
+ if (kind === "pack") {
352
+ const category = parsed.category;
353
+ if (!category) throw new NewError("`pack` needs `--category` — stacks, tools or rituals. It is spelled as the directory that holds the pack, so the `category/name` reference and the tree agree by construction");
354
+ if (!["stacks", "tools", "rituals"].includes(category)) {
355
+ throw new NewError(`\`--category ${category}\` is not one of stacks, tools or rituals`);
356
+ }
357
+ const root = path.join(into, category, name);
358
+ return [
359
+ { path: path.join(root, "pack.json"), contents: fill(body, { name, category }) },
360
+ { path: path.join(root, "README.md"), contents: packReadme(name, category) },
361
+ { path: path.join(root, "skills", ".gitkeep"), contents: "" },
362
+ { path: path.join(root, "personas", ".gitkeep"), contents: "" },
363
+ ];
364
+ }
365
+
366
+ if (kind === "workspace") {
367
+ const root = path.join(into, name);
368
+ const wkind = parsed.kindOf ?? "portfolio";
369
+ if (!["repository", "demo", "portfolio", "pointer"].includes(wkind)) {
370
+ throw new NewError(`\`--kind ${wkind}\` is not one of repository, demo, portfolio or pointer`);
371
+ }
372
+ if (wkind === "pointer" && !parsed.governedBy) {
373
+ throw new NewError(
374
+ "`--kind pointer` needs `--governed-by <workspace-name>`. A pointer's whole content is the name of the workspace that " +
375
+ "governs this repository — one repository is governed by exactly one workspace — so a pointer that names nobody is a " +
376
+ "manifest `doctor` refuses. If you are onboarding a repository rather than hand-authoring, use `portulan init`, which " +
377
+ "asks the residence question instead of taking it as a flag",
378
+ );
379
+ }
380
+ if (wkind !== "pointer" && parsed.governedBy) {
381
+ throw new NewError(`\`--governed-by\` is only meaningful with \`--kind pointer\` — a ${wkind} workspace governs, it is not governed`);
382
+ }
383
+ return workspaceFiles(root, name, wkind, body, parsed.governedBy);
384
+ }
385
+
386
+ return [{ path: destination(kind, name, into), contents: fill(body, { name }) }];
387
+ }
388
+
389
+ /** Substitutes the placeholders a scaffold can honestly know. Everything else stays in `{braces}`. */
390
+ function fill(body, values) {
391
+ let out = body;
392
+ if (values.name !== undefined) out = out.replaceAll("{kebab-case-name}", values.name);
393
+ if (values.category !== undefined) out = out.replaceAll("{stacks | tools | rituals}", values.category);
394
+ return out;
395
+ }
396
+
397
+ function packReadme(name, category) {
398
+ return [
399
+ `# Pack — ${name}`,
400
+ "",
401
+ `A \`${category}\` pack. **Written by its author, not by the scaffold** — this file is where this`,
402
+ "pack's content, rationale and honest limits go, and a pack whose README still says this sentence",
403
+ "has not been finished.",
404
+ "",
405
+ "## What it contributes",
406
+ "",
407
+ "{Skills, personas, verify recipes, gate fragments — and for each, why an adopter would want it.}",
408
+ "",
409
+ "## Honest limits",
410
+ "",
411
+ "{What this pack does not cover, and the neighbouring pack it is easy to confuse with. Required:",
412
+ "a pack that lists only what it does reads as covering everything.}",
413
+ "",
414
+ ].join("\n");
415
+ }
416
+
417
+ /**
418
+ * A workspace scaffold that is **green under `doctor` on the run after it is written**.
419
+ *
420
+ * That is a deliberate departure from what `init` does, and the reason is that the two tools are in
421
+ * different situations rather than that one of them is wrong. `init` composes a checkpoints pack by
422
+ * default because row 7 clause (a) says the cycle arrives opt-**out**, and nothing discovers a pack — so
423
+ * its draft is red until a root is named, loudly and on purpose. `new workspace` composes no pack: it is
424
+ * for a workspace somebody is authoring by hand, where a red first run would be a red the author did not
425
+ * cause and cannot act on.
426
+ *
427
+ * `portfolio` is the default kind for the same reason. A `repository` workspace must declare `tree`, and
428
+ * a scaffold that guesses which tree it governs would be a claim about the filesystem that the scaffold
429
+ * cannot check — the claims lint would then grade a repository nobody chose.
430
+ */
431
+ function workspaceFiles(root, name, wkind, body, governedBy = null) {
432
+ // A POINTER carries `governed_by` and NONE of the governing keys — no slots, no verify, no gates.
433
+ // The Workspace Definition requires the first and `doctor` refuses the second, so a pointer built
434
+ // from the governing shape is red on the run after it is written. Copilot's suppressed notes on #156
435
+ // caught it, twice on one file: the scaffolder always wrote the governing shape while the help screen
436
+ // advertised `pointer` as a supported kind. That is this session's own recurring defect for the third
437
+ // time — a scaffold failing the validation that lives beside it — so it is fixed rather than triaged.
438
+ if (wkind === "pointer") {
439
+ // 2.7, not the newest definition: a writer declares the version its OUTPUT needs, and a pointer
440
+ // needs 2.7 — the version that added the kind. Nothing scaffolded here declares a key from a
441
+ // later MINOR, so claiming one would narrow the manifest against adopter tooling pinned at 2.7
442
+ // for a contract it does not use. Same rule as `cli/init.mjs`'s and `cli/vendor.mjs`'s `SPEC`,
443
+ // and as the `GATE_POLICY_SPEC = "2.2"` beside them.
444
+ const manifest = { portulan: { spec: "2.7" }, name, kind: "pointer", governed_by: { workspace: governedBy } };
445
+ return [
446
+ { path: path.join(root, "workspace.json"), contents: `${JSON.stringify(manifest, null, 2)}\n` },
447
+ {
448
+ path: path.join(root, "README.md"),
449
+ contents: [
450
+ `# Pointer — ${name}`,
451
+ "",
452
+ `This repository is governed by the \`${governedBy}\` workspace, which lives elsewhere. **One repository is`,
453
+ "governed by exactly one workspace**, so this file is a pointer and not a second one: it carries no slots,",
454
+ "no verify recipes and no gate policy, because those belong to the governing workspace.",
455
+ "",
456
+ "Nothing here resolves the pointer for you — reading it is not fetching it. `doctor` reports the governor",
457
+ "it names; finding that workspace on this machine is a separate question.",
458
+ "",
459
+ ].join("\n"),
460
+ },
461
+ ];
462
+ }
463
+
464
+ const manifest = {
465
+ // The version this output NEEDS, not the newest one — see the pointer branch above.
466
+ portulan: { spec: "2.7" },
467
+ name,
468
+ summary: `{One line: whose workspace this is and what it governs.}`,
469
+ kind: wkind,
470
+ // `repos` is declared and its directory scaffolded so the six kinds compose end-to-end from
471
+ // `new` alone: without it, `new repo-card --into <a workspace new just wrote>` exits 2 because
472
+ // the manifest declares nowhere for a card to land. A tool whose own output cannot host its own
473
+ // next command is a tool with a seam in the middle of it.
474
+ slots: { identity: "identity.md", principles: "principles.md", gates: "gate-map.md", repos: "repos/" },
475
+ // The schema requires a `verify` block on every workspace that is not a pointer, so this is not
476
+ // optional and a scaffold omitting it is red on the run after it is written. What it must NOT be
477
+ // is a stub exiting 0: the author has not said what green means here yet, and a false green under
478
+ // every gate from the day the workspace was created is the worst artifact this tool could emit.
479
+ // Exit 2 — could not run — is the honest state, and it is the same answer `init` reached.
480
+ verify: {
481
+ default: "unset",
482
+ recipes: [{ id: "unset", run: "./verify/unset.sh", requires: ["bash"], doc: "verify/README.md" }],
483
+ },
484
+ };
485
+ if (wkind === "repository") manifest.tree = "../";
486
+
487
+ return [
488
+ { path: path.join(root, "verify", "unset.sh"), contents: unsetRecipe(name) },
489
+ { path: path.join(root, "verify", "README.md"), contents: verifyReadme(name) },
490
+ { path: path.join(root, "workspace.json"), contents: `${JSON.stringify(manifest, null, 2)}\n` },
491
+ { path: path.join(root, "identity.md"), contents: slotFile("Identity", name, "Who this team is, the stack, and the glossary. The first thing an agent reads after the kernel.") },
492
+ { path: path.join(root, "principles.md"), contents: slotFile("Principles", name, "This team's own binding principles — what makes an agent work THIS team's way rather than generically.") },
493
+ { path: path.join(root, "gate-map.md"), contents: slotFile("Gate map", name, "Concrete actions bound to Auto / Propose / Gated / Prohibited, plus the triage threshold. Run `new gate-policy` for the machine-readable half that compiles.") },
494
+ { path: path.join(root, "repos", "README.md"), contents: `# Repo cards — ${name}\n\n> One card per repository this workspace covers. Run \`portulan new repo-card <name> --into <this directory's parent>\`.\n` },
495
+ { path: path.join(root, "README.md"), contents: workspaceReadme(name, wkind, body) },
496
+ ];
497
+ }
498
+
499
+ /**
500
+ * The recipe a scaffolded workspace starts with: one that **exits 2**, meaning *could not run*.
501
+ *
502
+ * It is deliberately not a stub exiting 0. A verify recipe is what decides what *done* means for a
503
+ * workspace, the Stop-gate runs the default, and a recipe returning green before anybody has said what
504
+ * green means would put a false pass under every gate the workspace has — on day one, in the artifact
505
+ * meant to welcome the author. The three-code discipline exists exactly for this: a recipe that cannot
506
+ * run must never be indistinguishable from one that ran and passed.
507
+ */
508
+ function unsetRecipe(name) {
509
+ return [
510
+ "#!/usr/bin/env bash",
511
+ `# The starting verify recipe for the \`${name}\` workspace. It exits 2 on purpose.`,
512
+ "#",
513
+ "# Exit 0 green · 1 red · 2 could not run. This one is 2 — *could not run* — because nobody has",
514
+ "# said yet what green means for this workspace. Replace the body with the real check (your test",
515
+ "# suite, your linter, whatever your team already trusts) and this becomes an ordinary recipe.",
516
+ "#",
517
+ "# Do not make it `exit 0` to quiet it. A recipe that passes without checking anything is a false",
518
+ "# green under every gate that reads it, including the Stop-gate that blocks \"done\" on a red.",
519
+ "set -uo pipefail",
520
+ "",
521
+ 'echo "verify: this workspace has not declared what green means yet." >&2',
522
+ 'echo " Edit ./verify/unset.sh — see ./verify/README.md." >&2',
523
+ "exit 2",
524
+ "",
525
+ ].join("\n");
526
+ }
527
+
528
+ function verifyReadme(name) {
529
+ return [
530
+ `# Verify — ${name}`,
531
+ "",
532
+ "> What each recipe checks, what it deliberately does not, and the incident behind it. A recipe",
533
+ "> whose limits are unwritten gets trusted for more than it covers.",
534
+ "",
535
+ "## The three exit codes, and why the third one exists",
536
+ "",
537
+ "`0` green · `1` red · `2` **could not run**. The third is the load-bearing one: a recipe that",
538
+ "could not execute — a missing tool, an unreadable tree — must never look like one that ran and",
539
+ "passed. Enumerating what you are about to check is a **precondition**, not part of the check, so",
540
+ "an empty enumeration is exit 2 rather than a confident pass over nothing.",
541
+ "",
542
+ "## Recipes",
543
+ "",
544
+ "| id | what it checks | known limits |",
545
+ "|---|---|---|",
546
+ "| `unset` | **Nothing yet.** It exits 2 by design, until this team says what green means. | Everything. Replace it. |",
547
+ "",
548
+ ].join("\n");
549
+ }
550
+
551
+ function slotFile(title, name, gloss) {
552
+ return [`# ${title} — ${name}`, "", `> ${gloss}`, "", "{Written by this team. A scaffold cannot know it, and a generated answer here would be", "the auto-generated curated context the constitution names as a non-goal.}", ""].join("\n");
553
+ }
554
+
555
+ function workspaceReadme(name, wkind, body) {
556
+ return [
557
+ `# Workspace — ${name}`,
558
+ "",
559
+ `A \`${wkind}\` workspace, scaffolded by \`portulan new workspace\`. **It is a draft: you curate it.**`,
560
+ "",
561
+ "## What it is, and is not, on the day it was written",
562
+ "",
563
+ "Three slots exist and each says what belongs in it rather than claiming content: `identity.md`,",
564
+ "`principles.md`, `gate-map.md`. `doctor` is green on this workspace, and that green means the",
565
+ "manifest conforms and every declared path resolves — **not** that anything in it is true about",
566
+ "your team yet.",
567
+ "",
568
+ "**No gate policy compiles yet.** `gate-map.md` is prose; run `portulan new gate-policy gates`",
569
+ "and add `\"gates\": \"gates.json\"` to the manifest to make it enforcement.",
570
+ "",
571
+ "**One verify recipe is declared and it exits 2** — *could not run*. The schema requires a",
572
+ "`verify` block, and a recipe is what decides what *done* means here, so the choice was between an",
573
+ "honest \"nobody has said yet\" and a stub exiting 0 that would put a false green under every gate",
574
+ "from the day this was created. Replace `verify/unset.sh` with your real check.",
575
+ "",
576
+ "## The template this came from",
577
+ "",
578
+ "[`core/templates/workspace.md`](../../core/templates/workspace.md) carries the per-key rationale,",
579
+ "including the two constraints a scaffold cannot enforce for you: a slot must be a whole file, and",
580
+ "one repository is governed by exactly one workspace.",
581
+ "",
582
+ `<!-- skeleton bytes: ${body.length} -->`,
583
+ "",
584
+ ].join("\n");
585
+ }
586
+
587
+ export function run(argv, options = {}) {
588
+ const say = options.say ?? ((line = "") => process.stdout.write(`${line}\n`));
589
+ try {
590
+ const parsed = parseArgs(argv);
591
+ if (parsed.help || !parsed.kind) {
592
+ say(usage());
593
+ return parsed.help ? 0 : 2;
594
+ }
595
+ if (!KINDS.has(parsed.kind)) {
596
+ say(`new: \`${parsed.kind}\` is not a kind this scaffolds. The six are: ${[...KINDS].sort().join(" · ")}`);
597
+ return 2;
598
+ }
599
+ if (!parsed.name) {
600
+ say(`new: \`${parsed.kind}\` needs a name — \`portulan new ${parsed.kind} <name>\``);
601
+ return 2;
602
+ }
603
+ if (!SLUG.test(parsed.name)) {
604
+ say(
605
+ `new: \`${parsed.name}\` is not a slug. Names here are lowercase, digits and single hyphens ` +
606
+ `(\`my-skill\`), because the same shape is what both schemas' \`$defs/slug\` accepts and a name ` +
607
+ `that fails validation after it is written is a scaffold that reds its own workspace`,
608
+ );
609
+ return 2;
610
+ }
611
+
612
+ const into = path.resolve(parsed.into ?? process.cwd());
613
+
614
+ // Refused before the template is even read: the destination is the question, and a `core/` answer
615
+ // is refused whether it was reached by name, by `..`, or by a link.
616
+ const linkAt = firstLink(into);
617
+ if (linkAt) {
618
+ say(
619
+ `new: ${display(linkAt)} is a symlink, and this refuses to write through one. A resolved link is how a ` +
620
+ `scaffold leaves the tree it was meant to stay inside. Point \`--into\` at a real directory`,
621
+ );
622
+ return 2;
623
+ }
624
+ const core = intoCore(realOrSelf(into));
625
+ if (core) {
626
+ // Phrased against the RESOLVED destination rather than the argument as typed, because the two
627
+ // differ in exactly the cases worth naming — `packs/../core` and a link — and a refusal that
628
+ // echoes the argument back tells the reader nothing they did not already type.
629
+ const resolved = realOrSelf(into);
630
+ say(
631
+ `new: that destination resolves to ${resolved === core ? `\`${core}\`` : `${resolved}, inside \`${core}\``}, ` +
632
+ `which is the \`core/\` layer this project ships. Your own skills, personas and packs belong in a layer you own — ` +
633
+ `a pack of your own, or your workspace — because core and packs never absorb a team's specifics ` +
634
+ `(the constitution's sixth thesis: storage follows ownership). Point \`--into\` somewhere you own`,
635
+ );
636
+ return 2;
637
+ }
638
+
639
+ const files = plan({ ...parsed, into });
640
+ const clash = collisions(files.map((f) => f.path), into);
641
+ if (clash.length) {
642
+ for (const group of groupByCause(clash)) say(`new: ${group}`);
643
+ return 2;
644
+ }
645
+
646
+ // The manifest is written LAST for the multi-file kinds, so an I/O failure mid-loop cannot leave a
647
+ // torso that a later run reads as a finished artifact — session 1's partial write, which wedged
648
+ // its own retry and refused it with a sentence that was false.
649
+ const ordered = [...files].sort((a, b) => Number(manifestish(a.path)) - Number(manifestish(b.path)));
650
+ for (const file of ordered) {
651
+ fs.mkdirSync(path.dirname(file.path), { recursive: true });
652
+ fs.writeFileSync(file.path, file.contents);
653
+ }
654
+
655
+ say(`new: wrote ${files.length} file(s) for the ${parsed.kind} \`${parsed.name}\``);
656
+ for (const file of files) say(` ${display(file.path)}`);
657
+ say("");
658
+ say(`It is a draft — the placeholders in \`{braces}\` are yours to fill. Then: portulan doctor <workspace-dir>`);
659
+ // What a scaffold CANNOT do for you, said at the moment it matters rather than in a README nobody
660
+ // opens next. This tool never edits a file it did not write — a manifest is a curated layer, and
661
+ // a generator that rewrites one is the generator that eventually rewrites the wrong one — so the
662
+ // wiring step is the author's, and leaving it unsaid would produce a file nothing reads.
663
+ for (const line of wiring(parsed.kind, parsed.name, parsed.category, into)) say(line);
664
+ return 0;
665
+ } catch (error) {
666
+ say(`new: ${error instanceof NewError ? error.message : `unanticipated failure — ${error.stack ?? error}`}`);
667
+ return 2;
668
+ }
669
+ }
670
+
671
+ const manifestish = (p) => /(?:workspace|pack)\.json$/.test(p);
672
+
673
+ /**
674
+ * The step a scaffold cannot take for the author: declaring the thing it just wrote.
675
+ *
676
+ * Nothing here edits a manifest the tool did not write in the same run. That is deliberate and it is the
677
+ * same rule `init` settled — a manifest is part of the curated layer, and a generator that edits one is
678
+ * the generator that eventually edits the wrong one. The cost of that rule is a file nothing references,
679
+ * so the cost is paid out loud, here, at the moment the file lands.
680
+ */
681
+ function wiring(kind, name, category, target) {
682
+ switch (kind) {
683
+ case "gate-policy":
684
+ return ["", `Nothing reads it yet: add \`"gates": "${name}.json"\` to the workspace's \`workspace.json\`, then \`portulan compile\`.`];
685
+ case "skill": {
686
+ // **Say only what was actually checked.** The previous version read whether a `pack.json`
687
+ // EXISTED and then made a claim about what it DECLARES — which is the same defect one level
688
+ // down: a pack declaring `contributes.skills: ["parts/"]` was told `skills/` is "already
689
+ // declared", and a skill landing under a declared root was told nothing declares it. Found
690
+ // by review, inside the fix for that exact class.
691
+ let roots = null;
692
+ try {
693
+ roots = JSON.parse(fs.readFileSync(path.join(target ?? ".", "pack.json"), "utf8"))?.contributes?.skills ?? [];
694
+ } catch {
695
+ roots = null;
696
+ }
697
+ if (roots === null) {
698
+ return ["", "No readable `pack.json` in that directory, so nothing declares this skill yet — put it inside a pack, or add a `contributes.skills` root to the pack that should ship it."];
699
+ }
700
+ if (roots.some((r) => String(r).replace(/\/+$/, "") === "skills")) {
701
+ return ["", "The pack declares `skills/` in `contributes.skills`, which is where this landed — no manifest edit needed."];
702
+ }
703
+ return ["", `That pack declares ${roots.length} skills root(s) and none of them is \`skills/\`, so nothing declares this skill yet — add a root that covers it, or move the skill.`];
704
+ }
705
+ case "persona":
706
+ return ["", `Nothing reads it yet: add \`"personas/${name}.md"\` to the pack manifest's \`contributes.personas\`.`];
707
+ case "pack":
708
+ return ["", `Nothing composes it yet: add \`"${category}/${name}"\` to an adopting workspace's \`packs\` array.`];
709
+ case "repo-card":
710
+ return ["", "It sits in the workspace's declared `repos` slot, so `doctor` will lint its claims against the tree on the next run."];
711
+ default:
712
+ return [];
713
+ }
714
+ }
715
+
716
+ /**
717
+ * The destination as the filesystem actually sees it, **including when it does not exist yet**.
718
+ *
719
+ * The first cut was `try { realpathSync(t) } catch { return path.resolve(t) }` — and the catch is not
720
+ * the rare branch, it is the **ordinary** one: a scaffolding tool's destination usually does not exist.
721
+ * So whenever the leaf was new, this returned an unresolved path and the `core/` check graded a string
722
+ * the filesystem had never confirmed. Measured by the pre-commit checkpoint: a symlink whose target is
723
+ * `core/`, plus a leaf one level below it, wrote into the shipped `core/` and exited 0. The file header
724
+ * two hundred lines up promised "the check resolves first and compares after"; it did, only when the
725
+ * destination already existed, which is the case that matters least.
726
+ *
727
+ * So: realpath the nearest **existing** ancestor and re-append the unresolved tail. That resolves every
728
+ * link on the part of the path that exists — which is the only part a link can be on.
729
+ */
730
+ function realOrSelf(target) {
731
+ let head = path.resolve(target);
732
+ const tail = [];
733
+ for (let i = 0; i < 64; i += 1) {
734
+ try {
735
+ return tail.length ? path.join(fs.realpathSync(head), ...tail) : fs.realpathSync(head);
736
+ } catch (cause) {
737
+ // Only ENOENT means "not there yet, keep walking up". Anything else means the question could
738
+ // not be answered, and an unanswerable question must not be answered "fine".
739
+ if (cause.code !== "ENOENT") {
740
+ throw new NewError(
741
+ `${display(head)} could not be resolved — ${cause.code ?? cause.message}. Refusing rather than grading a path nothing confirmed`,
742
+ );
743
+ }
744
+ const up = path.dirname(head);
745
+ if (up === head) return path.resolve(target);
746
+ tail.unshift(path.basename(head));
747
+ head = up;
748
+ }
749
+ }
750
+ return path.resolve(target);
751
+ }
752
+
753
+ /**
754
+ * Is the destination the user named itself a symlink?
755
+ *
756
+ * Only the named path is asked about here, not its ancestors — see `chain` for why that boundary is the
757
+ * rule rather than a convenience. A user who points `--into` at a link is refused rather than followed,
758
+ * because deciding whether a link's target is "really" where they meant is a containment judgement with
759
+ * a bad failure mode, where refusing has none and costs one `realpath` of typing.
760
+ */
761
+ function firstLink(target) {
762
+ try {
763
+ return fs.lstatSync(target).isSymbolicLink() ? target : null;
764
+ } catch (cause) {
765
+ // Only ENOENT means absent — a destination that does not exist yet is the ordinary case here.
766
+ if (cause.code === "ENOENT") return null;
767
+ throw new NewError(`${display(target)} could not be read — ${cause.code ?? cause.message}. Only a missing path means "nothing there", so nothing was written`);
768
+ }
769
+ }
770
+
771
+ /**
772
+ * One line per cause, not one per path. Session 1 learned this the hard way: a single symlinked
773
+ * directory made the refusal name the same cause ten times, and a refusal nobody finishes reading is a
774
+ * refusal that failed to explain.
775
+ */
776
+ function groupByCause(clash) {
777
+ const by = new Map();
778
+ for (const item of clash) {
779
+ const key = item.why === "unreadable" ? `unreadable (${item.detail})` : item.why;
780
+ if (!by.has(key)) by.set(key, []);
781
+ by.get(key).push(display(item.path));
782
+ }
783
+ const said = [];
784
+ for (const [why, paths] of by) {
785
+ if (why === "exists") {
786
+ said.push(
787
+ `${paths.length} file(s) already exist and nothing here overwrites a file you wrote: ${paths.join(", ")}. ` +
788
+ `Move or delete them and run again`,
789
+ );
790
+ } else if (why === "directory") {
791
+ said.push(`${paths.join(", ")} ${paths.length === 1 ? "is a directory" : "are directories"} where a file has to be written. Move or remove ${paths.length === 1 ? "it" : "them"} and run again`);
792
+ } else if (why === "symlink") {
793
+ said.push(`${paths.join(", ")} ${paths.length === 1 ? "is a symlink" : "are symlinks"} — refused rather than followed, because a resolved link is how a write leaves its tree`);
794
+ } else {
795
+ said.push(`${paths.join(", ")} could not be read — ${why}. Only a missing path means "nothing there"; this question went unanswered, so nothing was written`);
796
+ }
797
+ }
798
+ return said;
799
+ }
800
+
801
+ if (import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1] ?? ".")).href) {
802
+ process.exitCode = run(process.argv.slice(2));
803
+ }