@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
@@ -0,0 +1,100 @@
1
+ // Workspace Definition 1.0 → 2.0 — a `repository` workspace declares its `tree`.
2
+ //
3
+ // The only MAJOR migration this train has, and `../README.md` records why it was taken while it cost
4
+ // nothing: `tree` began optional, and optional was a hole — deleting one manifest line degraded the
5
+ // whole claims-lint class from *checked* to *reported*, GREEN, exit 0.
6
+ //
7
+ // **This step has no subject in this repository and none in any tree we have seen.** Nothing here
8
+ // declares 1.0. It is exercised against a fixture, which `../README.md` says out loud rather than
9
+ // leaving a passing suite to imply otherwise.
10
+
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+
14
+ /** The version this step migrates FROM, as a MAJOR. One place, so the two readers below agree. */
15
+ const FROM_MAJOR = 1;
16
+
17
+ /**
18
+ * Is `dir` a repository root? `.git` is the evidence, and it is **a file as often as a directory**.
19
+ *
20
+ * A worktree's `.git` is a FILE containing `gitdir: …`, and so is a submodule's. A directory-only
21
+ * test passes every suite written on an ordinary clone and is wrong for every worktree user — the
22
+ * shape this project keeps paying for, so it is guarded here rather than discovered. The repository
23
+ * this step ships from is itself checked out as a worktree today.
24
+ *
25
+ * Three answers, not two: `yes`, `no`, and **`unknown`** — because only `ENOENT` means absent, and an
26
+ * `EACCES` is a question nobody answered.
27
+ */
28
+ function repositoryRoot(dir) {
29
+ try {
30
+ fs.lstatSync(path.join(dir, ".git"));
31
+ return "yes";
32
+ } catch (error) {
33
+ if (error.code === "ENOENT") return "no";
34
+ return "unknown";
35
+ }
36
+ }
37
+
38
+ export const step = {
39
+ id: "0001-repository-declares-its-tree",
40
+ kind: "version",
41
+ from: "1.0",
42
+ to: "2.0",
43
+ title: "a `repository` workspace declares its `tree`",
44
+ why:
45
+ "`tree` is where the repository a workspace makes claims ABOUT begins. Absent, `doctor` reports " +
46
+ "every repo-card build/test/run and layout claim as unverifiable instead of checking it — a whole " +
47
+ "check class disabled by a missing line, green, exit 0.",
48
+
49
+ owed(ws) {
50
+ const declared = ws.manifest?.portulan?.spec;
51
+ if (typeof declared !== "string") {
52
+ return { owed: null, because: "this manifest declares no `portulan.spec`, so which contract it was written against cannot be read" };
53
+ }
54
+ const parsed = /^([0-9]+)\.([0-9]+)$/.exec(declared);
55
+ if (!parsed) {
56
+ return { owed: null, because: `\`portulan.spec\` is \`${declared}\`, which is not MAJOR.MINOR — refusing to guess which side of 2.0 that is` };
57
+ }
58
+ const major = Number(parsed[1]);
59
+ if (major === FROM_MAJOR) return { owed: true, because: `this workspace declares ${declared}` };
60
+ return { owed: false, because: `this workspace declares ${declared}, which is past ${FROM_MAJOR}.x` };
61
+ },
62
+
63
+ plan(ws, ctx) {
64
+ const next = { ...ws.manifest, portulan: { ...ws.manifest.portulan, spec: this.to } };
65
+
66
+ // Only a `repository` needs `tree`. A `demo` or `portfolio` describes repositories that are
67
+ // not beside it, and giving one a tree would be inventing a claim the workspace never made.
68
+ if (next.kind === "repository" && typeof next.tree !== "string") {
69
+ if (typeof ctx.tree === "string" && ctx.tree !== "") {
70
+ next.tree = ctx.tree;
71
+ } else {
72
+ // Derived only where it is derivable, and refused otherwise. A hopeful `../` is a
73
+ // claim about somebody's layout made by a tool that did not look — and `tree` is
74
+ // precisely the slot whose wrongness turns a check class into a lie rather than an
75
+ // error.
76
+ const parent = path.resolve(ws.dir, "..");
77
+ const verdict = repositoryRoot(parent);
78
+ if (verdict === "yes") {
79
+ next.tree = "../";
80
+ } else if (verdict === "unknown") {
81
+ return {
82
+ ok: false,
83
+ reason:
84
+ `${parent} could not be examined, so whether it is the repository root is unknown. ` +
85
+ "Pass `--tree <path>` to say where this workspace's repository begins",
86
+ };
87
+ } else {
88
+ return {
89
+ ok: false,
90
+ reason:
91
+ `a \`repository\` workspace must declare \`tree\`, and ${parent} is not a repository root, ` +
92
+ "so `../` cannot be derived. Pass `--tree <path>` — refusing to guess at somebody's layout",
93
+ };
94
+ }
95
+ }
96
+ }
97
+
98
+ return { ok: true, edits: [{ file: "workspace.json", next: `${JSON.stringify(next, null, 2)}\n` }] };
99
+ },
100
+ };
@@ -0,0 +1,179 @@
1
+ // A repair — the bundle path a rewriter owes a workspace it touches.
2
+ //
3
+ // Since milestone 7 session 7, `cli/init.mjs` drafts `verify/index.sh` with a third CLI location baked
4
+ // in: the bundle `init` ran from, as an ABSOLUTE path that git cannot carry anywhere. It is a
5
+ // deliberate convenience for the person who ran `init`, and it is true on exactly one machine.
6
+ //
7
+ // **The failure is quiet, which is why it needs a tool rather than a reminder.** `vendor --switch`
8
+ // copies these files byte for byte into another residence; a clone lands on a machine that never had
9
+ // that bundle. The rail then exits **2 — could not run**, which is the fail-closed direction and is
10
+ // therefore easy to never notice: nothing goes red, the index simply stops being checked.
11
+ //
12
+ // **The marker is the contract.** `cli/init.mjs` writes `# portulan:bundle-fallback` on both lines
13
+ // carrying the path precisely so a rewriter can FIND them rather than having to notice a comment, and
14
+ // `cli/init.test.mjs` asserts the marker survives edits to the recipe. This step is the rewriter that
15
+ // note was written for.
16
+
17
+ import path from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ /** The token `cli/init.mjs` writes. One spelling, exported so a reader can grep for the pair. */
21
+ export const MARKER = "portulan:bundle-fallback";
22
+
23
+ /** The entry point a marked line points at, relative to a bundle root. */
24
+ const ENTRY = ["cli", "index.mjs"];
25
+
26
+ /**
27
+ * Is this file one the marker could be *marking*, as opposed to one that talks about the marker?
28
+ *
29
+ * **Found by running this step against this repository rather than against its own fixtures.** The
30
+ * marker is a token, and a token appears in prose: `.portulan/handoffs/2026-08-11-…md` documents the
31
+ * marker in a sentence, and the first cut of this step read that paragraph as a marked line of a
32
+ * shape it did not recognise and refused to migrate the workspace at all — exit 2, on the one
33
+ * workspace this repository actually owns. Every unit test passed, because no fixture here had ever
34
+ * written about itself.
35
+ *
36
+ * What a repair fixes is a **runnable fallback**, so the scope is scripts: a shell shebang, or a
37
+ * `.sh` name. That is narrower than "any file carrying the token" and it is the honest reading of
38
+ * what `cli/init.mjs` writes — it is deliberately NOT narrowed to `verify/index.sh`, because keying
39
+ * on the filename would make a second and narrower carrier of the rule the marker exists to be.
40
+ */
41
+ function couldCarryTheRail(rel, text) {
42
+ if (rel.endsWith(".sh")) return true;
43
+ const first = text.slice(0, text.indexOf("\n") + 1 || undefined);
44
+ return first.startsWith("#!") && /\b(?:ba|da|k|z)?sh\b/.test(first);
45
+ }
46
+
47
+ /** This bundle, derived from where this module sits rather than passed in and trusted. */
48
+ const OWN_BUNDLE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
49
+
50
+ /** What a marked line should name, for a given bundle root. */
51
+ function entryFor(bundle) {
52
+ return `${bundle}/${ENTRY.join("/")}`;
53
+ }
54
+
55
+ /**
56
+ * The one JSON string literal on a marked line that names a CLI entry point.
57
+ *
58
+ * Returns `null` when the line is not the shape `init` writes — which is a **refusal**, never a
59
+ * best-effort rewrite. A marked line this step does not recognise is somebody's edit, and rewriting
60
+ * around it by pattern would be exactly the guesswork `../README.md` says a repair may not do.
61
+ */
62
+ function quotedEntry(line) {
63
+ const matches = [...line.matchAll(/"((?:[^"\\]|\\.)*)"/g)].filter((m) => m[1].endsWith(`/${ENTRY.join("/")}`));
64
+ return matches.length === 1 ? matches[0] : null;
65
+ }
66
+
67
+ /**
68
+ * Every marked line in `text`, paired with the entry path it names — or a reason it is unreadable.
69
+ *
70
+ * **`quoted` is the DECODED path, not the source text between the quotes.** The regex captures what
71
+ * is written in the file, which is still JSON-escaped: `init` emits `JSON.stringify(...)`, so a
72
+ * bundle at `C:\Users\x` appears as `"C:\\Users\\x/cli/index.mjs"`. Comparing that raw capture to an
73
+ * unescaped `want` never matches, and the step reports itself **perpetually owed** — rewriting the
74
+ * same file on every run, forever.
75
+ *
76
+ * Invisible on POSIX, where an ordinary path contains nothing JSON escapes, which is why every test
77
+ * and every live run here passed over it. Copilot's promoted note, round 4 on #231.
78
+ *
79
+ * A literal that will not decode is treated as unreadable rather than guessed at, which is the same
80
+ * refusal an unrecognised marked line already gets.
81
+ */
82
+ function marked(text) {
83
+ const lines = text.split("\n");
84
+ const hits = [];
85
+ for (const [n, line] of lines.entries()) {
86
+ if (!line.includes(MARKER)) continue;
87
+ const found = quotedEntry(line);
88
+ if (found === null) return { ok: false, line: n + 1, text: line };
89
+ let decoded;
90
+ try {
91
+ decoded = JSON.parse(found[0]);
92
+ } catch {
93
+ return { ok: false, line: n + 1, text: line };
94
+ }
95
+ hits.push({ n, quoted: decoded, whole: found[0] });
96
+ }
97
+ return { ok: true, lines, hits };
98
+ }
99
+
100
+ export const step = {
101
+ id: "0002-bundle-fallback-path",
102
+ kind: "repair",
103
+ from: null,
104
+ to: null,
105
+ title: "a drafted rail's bundle path is re-derived for the bundle running this",
106
+ why:
107
+ "`init` bakes the bundle it ran from into `verify/index.sh` as an absolute path on two lines " +
108
+ "marked `# portulan:bundle-fallback`. A workspace is not fixed where it was drafted: the path " +
109
+ "travels to machines it was never true on, and the rail then exits 2 rather than failing loudly.",
110
+
111
+ owed(ws, ctx = {}) {
112
+ const want = entryFor(ctx.bundle ?? OWN_BUNDLE);
113
+ let any = false;
114
+ for (const rel of ws.list()) {
115
+ let text;
116
+ try {
117
+ text = ws.read(rel);
118
+ } catch (error) {
119
+ // *An empty set is two questions.* A file that could not be read has not answered
120
+ // anything, and reporting `not owed` here is "nothing looked" recorded as "nothing
121
+ // wrong" — in a step whose entire subject is a failure that is already silent.
122
+ return { owed: null, because: `${rel} could not be read — ${error.code ?? error.message}, so whether it carries a stale bundle path is unknown` };
123
+ }
124
+ if (!text.includes(MARKER) || !couldCarryTheRail(rel, text)) continue;
125
+ const found = marked(text);
126
+ if (!found.ok) {
127
+ return { owed: null, because: `${rel} line ${found.line} carries the marker in a shape this step does not recognise` };
128
+ }
129
+ if (found.hits.some((h) => h.quoted !== want)) any = true;
130
+ }
131
+ return any
132
+ ? { owed: true, because: `a marked line names a bundle other than ${want}` }
133
+ : { owed: false, because: `no marked line names a bundle other than ${want}` };
134
+ },
135
+
136
+ plan(ws, ctx = {}) {
137
+ const bundle = ctx.bundle ?? OWN_BUNDLE;
138
+ const want = entryFor(bundle);
139
+ const edits = [];
140
+ for (const rel of ws.list()) {
141
+ let text;
142
+ try {
143
+ text = ws.read(rel);
144
+ } catch (error) {
145
+ return { ok: false, reason: `${rel} could not be read — ${error.code ?? error.message}` };
146
+ }
147
+ // The same two conditions as `owed`, and they are spelled the same way on purpose: a
148
+ // scope that differed between asking and doing is `0020`'s class, and it would show up
149
+ // as a step reporting itself owed and then planning no edit.
150
+ if (!text.includes(MARKER) || !couldCarryTheRail(rel, text)) continue;
151
+ const found = marked(text);
152
+ if (!found.ok) {
153
+ return {
154
+ ok: false,
155
+ reason:
156
+ `${rel} line ${found.line} carries \`${MARKER}\` but is not the shape \`init\` writes — ` +
157
+ "expected exactly one quoted path ending `/cli/index.mjs`. Refusing to rewrite a line it " +
158
+ `cannot read rather than guessing: ${found.text.trim()}`,
159
+ };
160
+ }
161
+ if (!found.hits.some((h) => h.quoted !== want)) continue;
162
+
163
+ // Rewritten by SPLICING the one recognised literal, never by a replace over the line: a
164
+ // line may legitimately carry other quoted text, and a blanket substitution would edit it.
165
+ const lines = [...found.lines];
166
+ for (const hit of found.hits) {
167
+ // A **function** replacer, not a string one. `String.prototype.replace` reads `$&`,
168
+ // `` $` ``, `$'` and `$$` in the REPLACEMENT as patterns, so a checkout under a
169
+ // directory whose name contains one of them spliced the old path back in with broken
170
+ // quoting — producing a corrupted rail that `doctor` then grades GREEN, because
171
+ // `doctor` never runs shell. A function replacer is inserted verbatim.
172
+ // Found at the pre-commit checkpoint, by building the path rather than reading the line.
173
+ lines[hit.n] = lines[hit.n].replace(hit.whole, () => JSON.stringify(want));
174
+ }
175
+ edits.push({ file: rel, next: lines.join("\n") });
176
+ }
177
+ return { ok: true, edits };
178
+ },
179
+ };
@@ -0,0 +1,95 @@
1
+ # `spec/migrations/` — what a migration is
2
+
3
+ > Part of the **Workspace Definition**. [`../README.md`](../README.md)'s *Versioning and migrations*
4
+ > section is the contract this directory implements; it said *"There is still no `migrations/`
5
+ > directory … **One arrives when a migration needs code**"* until milestone 7 session 9, and this
6
+ > directory arrives on that stated condition rather than because a tool wanted a home. The runner is
7
+ > [`../../cli/upgrade.mjs`](../../cli/upgrade.mjs).
8
+
9
+ ## A step, and the two kinds
10
+
11
+ A migration is not one act. It is an ordered set of **steps**, each a zero-dependency ESM module
12
+ exporting a single `step` object, and each of exactly one kind:
13
+
14
+ | Kind | What it is | Keyed on |
15
+ |---|---|---|
16
+ | `version` | A Workspace Definition MAJOR migration. `spec/README.md`'s rule stands: **MINORs are additive and owe no migration**, so there is no such thing as a MINOR step and nothing here restamps one. | `from` → `to` |
17
+ | `repair` | Something a rewriter owes a workspace it touches, independent of any version — a value that was true where it was written and is not true where the workspace now is. | the workspace's own state |
18
+
19
+ The second kind exists by the maintainer's ruling of 2026-08-12. Without it this directory would hold
20
+ one step (`1.0 → 2.0`) with **no subject in this repository and none in any tree we have seen** —
21
+ machinery demonstrable only on a fixture. What actually bites an adopter is a workspace that travelled.
22
+
23
+ ```js
24
+ export const step = {
25
+ id: "0002-bundle-fallback-path", // stable, sortable; the id IS the chain's order
26
+ kind: "repair", // "version" | "repair"
27
+ from: null, to: null, // version steps only; null on a repair
28
+ title: "…", // one line, printed in the plan
29
+ why: "…", // why it is owed at all, printed under -v
30
+ owed(ws), // → { owed: true | false | null, because }
31
+ plan(ws, ctx), // → { ok: true, edits } | { ok: false, reason }
32
+ };
33
+ ```
34
+
35
+ `ws` is a read view of the workspace — `{ dir, manifest, manifestText, list(), read(rel) }`. `ctx`
36
+ carries `{ bundle, spec, tree }`: the CLI root this run is executing from, the Workspace Definition
37
+ version **this bundle implements**, and the `--tree` a step may need and must never invent. An `edit`
38
+ is `{ file, next }` — a relative path and its whole next contents.
39
+
40
+ ## Three properties, and what each one replaces
41
+
42
+ **Owedness is derived from the workspace's state, never from a stamp.** There is no
43
+ `migrations-applied` ledger, so there is nothing to keep in sync and nothing that can lie about what
44
+ ran. A step answers *am I owed* by looking at the workspace, every time.
45
+
46
+ **Every step is idempotent.** Applying one twice changes nothing. This is what buys out a transaction:
47
+ a run interrupted partway is recovered by **re-running**, because the steps that landed now answer
48
+ *not owed* and the ones that did not are still owed.
49
+
50
+ **`owed` is three-valued.** `true`, `false`, and **`null` — could not tell**. A step that cannot
51
+ answer must not answer *not owed*: that is *nothing looked* recorded as *nothing wrong*, and it is the
52
+ failure shape this workspace has paid for more than any other. `null` maps to **exit 2**, never to a
53
+ green. The same discipline governs the reads underneath: only `ENOENT` means absent, and an `EACCES`
54
+ is a question that could not be answered.
55
+
56
+ ## Limits, stated where a reader meets them
57
+
58
+ - **This chain governs the workspace train only.** Two version trains exist —
59
+ `portulan.spec` for the Workspace Definition, `portulan.pack` for the Pack Definition (at 1.0). **No
60
+ step here reaches a pack manifest**, and a pack train migration would be its own directory and its
61
+ own runner.
62
+ - **A repair rewrites a value that may still resolve.** `0002` re-points a bundle path at the bundle
63
+ that ran `upgrade`, whether or not the old one still exists. That is deterministic and idempotent,
64
+ and it is chosen out loud: the alternative — repair only what is broken *here* — makes the result
65
+ depend on which machine ran it, which is the property the repair exists to remove.
66
+ - **The pre-state gate is asymmetric, by construction.** `cli/upgrade.mjs` refuses to migrate a
67
+ workspace `doctor` already reds. It can only ever apply that gate to a **same-MAJOR** run: a
68
+ workspace behind by a MAJOR is one `doctor` refuses outright — that refusal is why this directory
69
+ exists — so such a run is graded **post-state only**, and a red afterwards is reported without
70
+ claiming the migration caused it.
71
+ - **Nothing here restamps a MINOR.** A manifest declares the version its content needs, not the newest
72
+ one — `spec/README.md`'s own rule, the one that keeps `examples/` at 2.4 as live compatibility
73
+ evidence and keeps `init`, `new` and `vendor` writing `2.7`.
74
+
75
+ ## The steps that exist
76
+
77
+ | Step | Kind | What |
78
+ |---|---|---|
79
+ | [`0001-repository-declares-its-tree.mjs`](0001-repository-declares-its-tree.mjs) | `version` 1.0 → 2.0 | The one migration `spec/README.md` documents: a `repository` workspace must declare `tree`. It **will not guess** the value — `"../"` only where the parent is verifiably the repository root, otherwise it refuses and names `--tree`. |
80
+ | [`0002-bundle-fallback-path.mjs`](0002-bundle-fallback-path.mjs) | `repair` | `init` bakes the bundle it ran from into `verify/index.sh` as an absolute path, marked `# portulan:bundle-fallback`. `vendor --switch` copies it byte for byte. The rail then exits **2 — could not run**, which is fail-closed and therefore easy never to notice. This re-derives it. |
81
+
82
+ **`0001` has no subject in this tree** — nothing declares 1.0 — and is exercised against a fixture,
83
+ which is said here rather than dressed up. **`0002` has no subject in this repository either**: this
84
+ workspace's `verify/index.sh` was written by hand, not drafted by `init`, and carries no marker. It is
85
+ exercised against workspaces the real `init` drafts.
86
+
87
+ ## Adding one
88
+
89
+ Drop a module in, named `NNNN-slug.mjs`. `cli/upgrade.mjs` reads the directory and orders by filename,
90
+ so nothing registers a step anywhere else — there is no list to keep in sync with the tree, which is
91
+ the second carrier this repository keeps repairing out of its own design.
92
+
93
+ A `version` step ships **with** the MAJOR bump that needs it, never after: `spec/README.md`'s
94
+ *"A MAJOR bump ships with a migration"* is the rule, and a MAJOR that merges without one has already
95
+ broken it.
@@ -0,0 +1,201 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://portulan.dev/spec/pack/1.0/pack.schema.json",
4
+ "title": "Portulan Pack Definition v1.0",
5
+ "description": "The manifest at the root of a pack. A pack is the cascade's middle layer — core < pack < workspace < repo card < task — and this manifest declares what it contributes to that cascade: skills, personas, verify recipes, and gate-policy fragments. It is an index, not a container, for the same reason the Workspace Definition is: the content lives in the files the paths point at. Written in the same deliberately small subset of JSON Schema 2020-12 — see spec/README.md for the subset, for what validates this, and for why a pack carries its own version train rather than the Workspace Definition's.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["portulan", "name", "category", "contributes"],
9
+
10
+ "properties": {
11
+ "portulan": {
12
+ "description": "Version block. `pack` is the Pack Definition version this manifest is written against. It is deliberately NOT the Workspace Definition version: a pack and a workspace are different artifacts validated against different contracts, and one number governing both would make a bump in either mean a change in the other. `doctor` refuses a manifest naming a version ahead of the one it implements rather than grading it against a contract it was not written for.",
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "required": ["pack"],
16
+ "properties": {
17
+ "pack": {
18
+ "description": "Pack Definition version, MAJOR.MINOR. A MAJOR bump means a migration exists.",
19
+ "type": "string",
20
+ "pattern": "^[0-9]+\\.[0-9]+$"
21
+ },
22
+ "version": {
23
+ "description": "Optional version of this pack's own content, SemVer. Distinct from `pack` above, which versions the contract. A pack shipped through a feed is pinned by consumers on this, and it moves independently of the engine. THIS FIELD IS HOW INDEPENDENT VERSIONING IS EXPRESSED, and a git tag is not — re-derived by measurement on CLI 2.1.220, 2026-07-29, because an earlier note in .portulan/handoffs/2026-07-26-the-tag-and-the-install.md reached the opposite guess and said its own basis had gone stale. What was measured: an installed marketplace is not a git working clone at all (no `.git`; contents carry a `.gcs-sha` marker), and an install is cached at `<marketplace>/<plugin>/<version>/` with several versions of one plugin coexisting, resolved from the manifest's version field. Tags cannot participate in that path. Re-measure on CLI upgrade — this is a fact about one version. OPTIONAL HERE, AND NOT FREE WHERE IT MATTERS: since #265 the `pack-version` verify recipe reds a change to this pack's `contributes` that does not also move this field, so a pack that changes what it contributes must declare one and move it. Absence stays free only for a pack nobody edits.",
24
+ "type": "string",
25
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
26
+ }
27
+ }
28
+ },
29
+
30
+ "name": {
31
+ "description": "Stable identifier for this pack, unique within its category. The canonical reference a workspace's `packs` array carries is `category/name` — this manifest owns that form, and resolution maps it to an installed pack. The workspace schema deliberately keeps `packs` items as free strings: tightening them to this shape would be a constraint a manifest could newly fail, which is a MAJOR bump on the other train.",
32
+ "$ref": "#/$defs/slug"
33
+ },
34
+
35
+ "category": {
36
+ "description": "Which of the three pack kinds this is, spelled as the directory that holds it so the canonical `category/name` reference and the tree agree by construction. The distinction is not decorative: a stack pack profiles a language or framework, a tool pack integrates a tool or MCP server and ships the gate classification for each, and a ritual pack carries a repeatable procedure that composes onto the loop.",
37
+ "enum": ["stacks", "tools", "rituals"]
38
+ },
39
+
40
+ "summary": {
41
+ "description": "One line an agent can read before loading anything else.",
42
+ "type": "string",
43
+ "minLength": 1
44
+ },
45
+
46
+ "doc": {
47
+ "description": "Where this pack's content, rationale and honest limits are written up. The `why` half, kept in Markdown, in the same split the Workspace Definition uses for `gates` and `slots.gates`: this manifest is the machine-readable index, and the prose it points at is what a human reads.",
48
+ "$ref": "#/$defs/filePath"
49
+ },
50
+
51
+ "contributes": {
52
+ "description": "What this pack adds to the cascade. Every key is optional and an absent key contributes nothing of that kind — but the object itself is required, because a pack contributing nothing is a plugin, not a pack, and the manifest should say which it is rather than leaving it to be inferred from four absences.",
53
+ "type": "object",
54
+ "additionalProperties": false,
55
+ "properties": {
56
+ "skills": {
57
+ "description": "Skill roots this pack ships, each a directory that either IS one skill (holding SKILL.md) or CONTAINS them. The bound a walk of these roots MUST observe — descend to a fixed depth and report where it stopped, rather than reporting green over what it did not look at — is the SHAPE fixed by .portulan/tasks/0008-a-declared-skills-path-sees-one-level-down.md. That task set the number for `plugin-lint`, which walks a PLUGIN's declared skills path; `doctor` walks THIS key as of 2026-08-03 and takes the same number deliberately, so two walkers over one declared key cannot disagree about how deep a skill may sit. The first cut of doctor's walk took a different number AND returned silently at the limit, which made a skill below the bound invisible — the exact green-over-what-it-did-not-look-at this sentence forbids, shipped inside the change that quotes it. OPENED AS OF 2026-08-03, and this note has now been wrong in both directions, which is why it records the correction rather than being rewritten clean. `doctor` walks these roots and validates each `SKILL.md`'s frontmatter, to the depth .portulan/tasks/0008 fixes, reporting where it stopped. It previously said `doctor` counted the paths into its pack report line and opened none of them — true when written. Composition into a host WAS a different question and is no longer an open one: MILESTONE 7 SESSION 8 BUILT THE CONSUMER. cli/skills-set.mjs is the one carrier of the REGISTRABLE SET — it reads this key and derives the `skills` paths a Claude Code plugin manifest must declare so the host registers what a workspace composed, with `--check` railing that in .portulan/verify/plugin.sh and `--write` deriving the key rather than leaving it hand-typed. That is row 7 clause (b)'s adopter half, issue #184. Two things it does NOT settle, said here so this note is not read as more than it is: the row's DEMONSTRATION — a composed pack's skill invoked through a host the same way a core skill is (D5) — is a separate session's, and nothing here writes an adopter's plugin manifest from nothing, only the pack portion of a `skills` key in a manifest that already exists. This clause has now been wrong in BOTH directions and records the corrections rather than being rewritten clean: it said the opposite until 2026-07-31 — authored on one branch while clause (b) was merging on another, stale nine minutes after it landed, a rule with two carriers drifting at the weaker one by concurrency rather than neglect — and it then said the consumer was unbuilt for as long as that was true. It changes here in the same change that opens the key, which is the rule the 2026-08-03 amendment set for this very sentence. Named at all because a contribution kind that is counted but never opened is the one a reader most easily assumes is wired.",
58
+ "type": "array",
59
+ "uniqueItems": true,
60
+ "minItems": 1,
61
+ "items": { "$ref": "#/$defs/dirPath" }
62
+ },
63
+ "personas": {
64
+ "description": "Persona files this pack ships, each carrying the five-part contract core/personas/README.md fixes: a `tools:` allow-list, a charter, autonomy reach in tier vocabulary, a memory scope, and a read/write posture. The memory scope is the persona's own field and is deliberately not duplicated here — a pack distributes per-persona material its adopter does not own. READ BY TWO CONSUMERS: cli/index.mjs resolves and opens this key as of Workspace Definition 2.6, deriving each persona's declared scope into the adopting workspace's own layer; and as of 2026-08-03 `doctor` opens it too, grading each persona against the five-part contract core/personas/README.md fixes — including the memory scope this field's own description names, which is the half core/operating/memory.md promised would be validated at milestone 7. Both carry the containment check that goes with opening a path — see `$defs/filePath`. **Corrected at the milestone-6 close:** this note said where that material's scope is declared was a question core/operating/memory.md HOLDS OPEN. That file now settles it — the first instance is declared at milestone 6 and validated at milestone 7 — so the clause described the tree as it was before the page it cites was written.",
65
+ "type": "array",
66
+ "uniqueItems": true,
67
+ "minItems": 1,
68
+ "items": { "$ref": "#/$defs/filePath" }
69
+ },
70
+ "verify": {
71
+ "description": "Verify recipes this pack offers. DECLARED, not merged, as of Workspace Definition 2.7: nothing today folds these into a workspace's recipe set, and a workspace runs the recipes its own manifest declares. Said here rather than left to be discovered, because a recipe that looks composed and is not would be a green covering something nobody ran. MILESTONE 7 COMMITS A COMPOSING CONSUMER, on the maintainer's ruling of 2026-07-31 and under a contract stated in row 7 of docs/plan.md rather than left to the implementation: composition is ADDITIVE ONLY — a pack may add a recipe and may never redefine, remove or replace one the workspace declares, nor become the workspace's `verify.default` — and a composed recipe is namespaced by its pack, so a collision is impossible rather than resolved. The limits are not a security boundary and should not be read as one: a recipe's `run` is arbitrary shell, and a workspace that lists a pack has already consented to running that pack's code. What they prevent is narrower and is the thing worth preventing — a pack silently changing what this workspace's green MEANS, by shadowing a recipe the workspace owns or by becoming the recipe the Stop-gate runs on its own. IT LANDED at milestone 7 session 5: `cli/recipe-set.mjs` is the composing consumer and the one carrier of the runnable set, and CI calls it rather than enumerating a manifest — so the declare-only state above is now history rather than description, and is kept as history because this note has been wrong in both directions and records its corrections instead of being rewritten clean. What a reader needs now: a composed recipe's id becomes `<category>/<name>:<id>`, applied by the carrier and never taken from the pack's own spelling, which is what makes a collision with a workspace id impossible rather than resolved — a workspace id and `verify.default` are both `$defs/slug`, and `/` and `:` are outside that grammar.",
72
+ "type": "array",
73
+ "uniqueItems": true,
74
+ "minItems": 1,
75
+ "items": { "$ref": "#/$defs/recipe" }
76
+ },
77
+ "gates": {
78
+ "description": "Gate-policy fragments — the cascade's missing middle, and the contribution that needs care, because an installed pack reaching the gate map means a dependency can change what an agent is allowed to do. The floor is the maintainer's ruling of 2026-07-27 recorded in .portulan/proposals/0010-prohibited-as-a-fourth-universal-tier.md: a pack may raise a tier or add a prohibition, and may NEVER demote another layer's classification. Enforcement is split between this schema and the merge step, and the split is stated rather than implied — see `$defs/fragment`. READ TODAY, like `personas` and unlike `skills` and `verify`: cli/compile.mjs is the merge step, and it resolves this key when a pack root is given.",
79
+ "type": "array",
80
+ "uniqueItems": true,
81
+ "minItems": 1,
82
+ "items": { "$ref": "#/$defs/fragment" }
83
+ }
84
+ }
85
+ }
86
+ },
87
+
88
+ "$defs": {
89
+ "slug": {
90
+ "description": "A lowercase hyphenated identifier. Defined once and referenced everywhere an id is used or named, so the constraint cannot drift between the field that declares an id and the field that points at one.",
91
+ "type": "string",
92
+ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"
93
+ },
94
+
95
+ "filePath": {
96
+ "description": "A relative path to a file inside this pack, resolved from the PACK root. The leading character may not be `/` (no absolute paths), `:` is excluded (no URLs), and `.` is excluded so that neither `./` nor `../` can BEGIN a value — which is where this differs from the Workspace Definition's equivalent, and it is deliberate: a workspace may legitimately point at a constitution that lives beside it, but a pack reaching outside its own directory is reaching into the adopter's tree, which is the one direction the cascade does not run. WHAT THIS PATTERN DOES NOT DO, measured rather than assumed: it bars only the LEADING form. `a/../../x` matches it and still escapes. Containment is not a pattern's job — the same conclusion `actionPath` reaches from the other side, and the one cli/compile.mjs already acts on by resolving its policy path and checking containment AFTER resolution. **Corrected at the milestone-6 close, which found this note describing an earlier state of the tree.** It said nothing resolved these paths and that an escaping value was therefore inert. Since Workspace Definition 2.6 `cli/index.mjs` **does** resolve and open `contributes.personas`, and it already carries the containment check the sentence assigned to milestone 7 — `fs.realpathSync` on both the pack directory and the file, compared AFTER resolution, refusing a persona that lands outside the pack. So the pattern is not the only guard, and never was the one that mattered. **Corrected again 2026-08-03, when the remaining half arrived.** The sentence above said `doctor` validates shape and does not open what a manifest points at, and that `contributes.skills` is opened by nothing so an escaping value there is still inert. Both halves are now false: `doctor` opens the skills roots and the persona files, and an escaping value is therefore no longer inert — so the containment check landed in the same change as the opening, resolved-then-compared exactly as cli/index.mjs does, rather than being left to this pattern. That is the general lesson this field keeps teaching: a value is inert only while nothing reads it, and 'nothing reads it' is a fact with an expiry date. Must not end in `/`.",
97
+ "type": "string",
98
+ "pattern": "^[^#?:/.]([^#?:]*[^#?:/])?$",
99
+ "minLength": 1
100
+ },
101
+
102
+ "dirPath": {
103
+ "description": "A relative path to a directory inside this pack — the same rules as `filePath`, INCLUDING its stated limit: the leading `../` form is barred and a `..` segment deeper in the value is not, so containment belongs to the consumer that resolves these paths rather than to this pattern. Must end in `/`, so the intent is legible without touching the filesystem.",
104
+ "type": "string",
105
+ "pattern": "^[^#?:/.][^#?:]*/$",
106
+ "minLength": 2
107
+ },
108
+
109
+ "recipe": {
110
+ "description": "One verify recipe: what to run, and what it needs in order to run at all. The same shape the Workspace Definition uses, repeated rather than shared because the two schemas are on separate version trains and a shared definition would couple their bumps — which is the coupling this split exists to avoid.",
111
+ "type": "object",
112
+ "additionalProperties": false,
113
+ "required": ["id", "run"],
114
+ "properties": {
115
+ "id": { "$ref": "#/$defs/slug" },
116
+ "run": {
117
+ "description": "The command, as it would be typed from the ADOPTING repository's root — which is a root this pack cannot know. A pack names its own files with `${PACK_ROOT}`, expanded by the composing consumer (`cli/recipe-set.mjs`) to the pack's resolved root relative to that repository root, before the command reaches a shell: `bash ${PACK_ROOT}/verify/thing.sh`. After expansion the first sentence is true again, which is the point. Expansion runs BEFORE the newline-and-tab refusal, so a pack root carrying one cannot pass a check that ran too early. A WORKSPACE recipe's `run` is never expanded — the token has no meaning outside a pack, and rewriting it there would be a consumer inventing a feature the Workspace Definition does not declare.",
118
+ "type": "string",
119
+ "minLength": 1
120
+ },
121
+ "requires": {
122
+ "description": "The tools the recipe needs. Declared rather than discovered, because a recipe that cannot run must be distinguishable from one that ran and failed — the difference between exit 2 and exit 1.",
123
+ "type": "array",
124
+ "uniqueItems": true,
125
+ "items": { "type": "string", "minLength": 1 }
126
+ },
127
+ "doc": {
128
+ "description": "Where the recipe's checks and their known limits are written up. Resolved from the PACK root, like every other path in this manifest — unlike the sibling `run`, which is a command line and is therefore written as it would be typed from the repository root.",
129
+ "$ref": "#/$defs/filePath"
130
+ }
131
+ }
132
+ },
133
+
134
+ "fragment": {
135
+ "description": "One gate-policy fragment: an id-addressed rule this pack contributes, in the same shape .portulan/gates.json already uses, because that policy was deliberately modelled as a list of id-addressed rules with no dependence on being the only source so that a merge step would be an addition rather than a redesign (.portulan/compile/README.md). WHERE THE TIGHTEN-ONLY RULE IS ENFORCED, stated plainly because a schema implying a guarantee it does not carry is what .portulan/dod.md condition 4 refuses: tightening is relative to what the same id already holds in the layer beneath, and a manifest cannot see that layer. So the shape forecloses what shape can — `auto` is not in the tier enum below, which makes demotion-to-unattended unexpressible in a pack at all — and the remaining comparisons are the merge step's. TIGHTENING HAS TWO AXES AND THE MERGE CHECKS BOTH. The tier may only rise. The `action` may not change at all: a fragment naming an existing id must carry that rule's action unaltered, because raising the tier while replacing the matcher passes every tier comparison, is reported as a tightening, and removes the gate — measured, on a real policy, as a fragment that took `git push --force` from gated to prohibited and deleted the emitted gate in the same move. A pack that wants to gate a different action contributes a NEW id; changing what an existing rule matches belongs to the workspace, which owns its policy. Neither layer is sufficient alone, and a release that consumes fragments without the merge's refusals would be trusting what it claims to check.",
136
+ "type": "object",
137
+ "additionalProperties": false,
138
+ "required": ["id", "tier", "action", "reason"],
139
+ "properties": {
140
+ "id": {
141
+ "description": "The rule this fragment declares or tightens. A fragment naming an id no lower layer carries is a pure addition; one naming an existing id is a tightening, and is refused at the merge unless its tier is stronger AND its action is identical. Ids are greppable by design and ship in core, so knowing one is not a barrier — which is exactly why the action must be preserved rather than trusted.",
142
+ "$ref": "#/$defs/slug"
143
+ },
144
+ "tier": {
145
+ "description": "The tier this fragment asserts. THREE values where core names four: `auto` is deliberately absent. A pack may only ever add restriction, and `auto` is the absence of restriction — it compiles to nothing (the compiler emits `ask` and `deny`, never `allow`), so a pack declaring it could only be attempting to demote an id a lower layer restricts. Barring it here is the one part of the rule a manifest's shape can enforce on its own, and it costs a pack nothing it should want: a pack with no opinion about an action simply omits the fragment.",
146
+ "enum": ["propose", "gated", "prohibited"]
147
+ },
148
+ "action": {
149
+ "description": "What the rule matches, in the workspace's own action vocabulary rather than any host's — the same four kinds .portulan/gates.json uses. `none` is the honest one: an action with no tool-level surface, named so the gap is visible instead of implied by a sentence covering more than its matcher does. Exactly one kind per fragment: the branches below each close themselves, which is what makes `{\"write\": …, \"shell\": …}` fail rather than silently matching on whichever the compiler happened to read first.",
150
+ "type": "object",
151
+ "oneOf": [
152
+ {
153
+ "description": "A path this rule protects from writes.",
154
+ "type": "object",
155
+ "additionalProperties": false,
156
+ "required": ["write"],
157
+ "properties": { "write": { "$ref": "#/$defs/actionPath" } }
158
+ },
159
+ {
160
+ "description": "A path this rule governs reads of.",
161
+ "type": "object",
162
+ "additionalProperties": false,
163
+ "required": ["read"],
164
+ "properties": { "read": { "$ref": "#/$defs/actionPath" } }
165
+ },
166
+ {
167
+ "description": "A command prefix, matched as a shell command rather than as a tool name.",
168
+ "type": "object",
169
+ "additionalProperties": false,
170
+ "required": ["shell"],
171
+ "properties": {
172
+ "shell": { "type": "string", "minLength": 1 }
173
+ }
174
+ },
175
+ {
176
+ "description": "No tool-level surface. The value is the explanation of why, which is the whole point of the kind: a matcher pretending to cover this would be worse than the honest gap.",
177
+ "type": "object",
178
+ "additionalProperties": false,
179
+ "required": ["none"],
180
+ "properties": {
181
+ "none": { "type": "string", "minLength": 1 }
182
+ }
183
+ }
184
+ ]
185
+ },
186
+ "reason": {
187
+ "description": "Why the rule exists, in the pack author's own words, and what the agent should do instead. Required rather than optional because a rule with no rationale is taste, and because this sentence is what an adopter reads when a pack they installed refuses something — a refusal whose reason is a pack name is indistinguishable from a bug.",
188
+ "type": "string",
189
+ "minLength": 1
190
+ }
191
+ }
192
+ },
193
+
194
+ "actionPath": {
195
+ "description": "A path a gate rule matches, resolved from the ADOPTER's repository root rather than from the pack — a pack gating its own directory would be gating files nobody edits. No absolute paths and no URLs. UNLIKE the pack-internal `filePath` and `dirPath`, a leading `.` is permitted and must be, because the action vocabulary's real values include `./` and `.portulan/`; that also means this pattern does NOT exclude `../`, and saying so is the point. Containment is not a pattern's job — a `../` chain passes any regex and still escapes, which is why cli/compile.mjs resolves its policy path and checks containment AFTER resolution rather than by pattern alone. What bounds the damage here is the direction of the whole mechanism: a fragment can only ever ADD restriction, and the compiler emits `ask` and `deny` and never `allow`, so a fragment naming a path outside the adopter's tree gates something nobody touches. That is noise, not a breach — and it is stated rather than papered over with a pattern that would look like a containment check while being none.",
196
+ "type": "string",
197
+ "pattern": "^[^#?:/][^#?:]*$",
198
+ "minLength": 1
199
+ }
200
+ }
201
+ }