@sleepy_panda_srl/portulan 0.1.1 → 0.1.2

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.
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ // Portulan — every prose statement of the CURRENT version agrees with `package.json`.
3
+ //
4
+ // ## The defect this exists for
5
+ //
6
+ // `README.md` and `.portulan/products/portulan/product.md` both said the newest release entry was
7
+ // `0.2.0` while `package.json`, both plugin manifests and the registry said `0.1.0`. They were fixed
8
+ // together — and then, one release later, the SAME PAIR drifted again: `README.md` still read
9
+ // "Current release: `0.1.0`" inside the change that cut `0.1.1`. Twice, with the pairing already
10
+ // written down in `docs/plan.md` both times.
11
+ //
12
+ // **A sibling list in prose is not a rail.** This is the rail.
13
+ //
14
+ // It matters more than a tidy number because `README.md` is in `package.json`'s `files`, and npm
15
+ // FREEZES a README per published version: a wrong current-version sentence that reaches a publish
16
+ // cannot be corrected by any edit to `main`, only by cutting another release. That is exactly what
17
+ // `0.1.1` was for.
18
+ //
19
+ // ## Why the record layer is excluded, which is the whole design
20
+ //
21
+ // This repository writes about its own defects, so the retired sentences are QUOTED in the files that
22
+ // record fixing them: `CHANGELOG.md` carries *"The newest release entry is `0.2.0`"* inside the entry
23
+ // explaining that it was retired, and two handoffs plus `docs/plan.md` do the same. A matcher over
24
+ // everything reds on the repository's own account of the repair — the failure mode #286 measured,
25
+ // where both arms it proposed died on this repository's own review corpus.
26
+ //
27
+ // So the scan is scoped to LIVE prose and the record layer is excluded BY PATH. The exclusion is not
28
+ // a convenience: a dated record is supposed to preserve a sentence that is no longer true, and a rail
29
+ // that forbids that would forbid the record.
30
+ //
31
+ // ## What it cannot do, stated rather than discovered
32
+ //
33
+ // A current-version claim spelled a NEW way, in any file, is invisible to it. There is no way to tell
34
+ // "this prose asserts the current version" from "this prose mentions a version" without a matcher, and
35
+ // a matcher only knows the spellings it was given. The accepted practice for that residual is #286's,
36
+ // ruled by the maintainer: **a new spelling gets a pattern here and a fixture first.**
37
+ //
38
+ // The half that IS closed beyond drift: every file in MUST_CARRY has to produce at least one match, so
39
+ // deleting or rewording a known carrier reds instead of silently shrinking the rail's reach.
40
+ //
41
+ // Three further residuals, named because an unstated limit is the one a reader assumes away:
42
+ // · A live claim placed UNDER an excluded prefix is invisible. `.portulan/memory/` in particular
43
+ // holds doctrine-shaped notes rather than only dated records, so a current-version sentence
44
+ // written there would not be checked.
45
+ // · An UNTRACKED carrier is invisible: enumeration is `git ls-files`, deliberately, because a rail
46
+ // that reads the working tree grades files no commit will ever contain.
47
+ // · A NON-MARKDOWN carrier is invisible: the pathspec is `*.md`.
48
+ //
49
+ // Exit 0 green · 1 a finding · 2 could not run.
50
+
51
+ import { readFileSync, realpathSync } from "node:fs";
52
+ import { join } from "node:path";
53
+ import { pathToFileURL } from "node:url";
54
+ import { execFileSync } from "node:child_process";
55
+
56
+ /** Raised when a precondition fails. Always exit 2 — never a finding about the prose. */
57
+ export class CouldNotRun extends Error {}
58
+
59
+ // The spellings that assert a current version. Each must capture the version in group 1.
60
+ export const PATTERNS = [
61
+ { id: "current-release", re: /\*\*Current release:\s*`([^`]+)`\*\*/g },
62
+ { id: "newest-entry", re: /newest release entry is\s*`([^`]+)`/g },
63
+ { id: "supported-current", re: /\|\s*`([^`]+)`\s*\|\s*Yes\s*—\s*the current release\s*\|/g },
64
+ ];
65
+
66
+ // Files that MUST carry at least one claim. Losing one is a finding: it means a carrier was deleted
67
+ // or reworded, which is how this rail would otherwise quietly stop covering what it was built for.
68
+ export const MUST_CARRY = ["README.md", "SECURITY.md", ".portulan/products/portulan/product.md"];
69
+
70
+ // The record layer. These files preserve sentences that are no longer true, on purpose.
71
+ export const RECORD_PREFIXES = [
72
+ "CHANGELOG.md",
73
+ "docs/plan.md",
74
+ "docs/milestones/",
75
+ ".portulan/handoffs/",
76
+ ".portulan/proposals/",
77
+ ".portulan/tasks/",
78
+ ".portulan/memory/",
79
+ ];
80
+
81
+ export const isRecord = (p) => RECORD_PREFIXES.some((r) => (r.endsWith("/") ? p.startsWith(r) : p === r));
82
+
83
+ /** Every tracked Markdown file that is not part of the record layer. Derived, never a written list. */
84
+ export function liveProseFiles(root) {
85
+ let out;
86
+ try {
87
+ out = execFileSync("git", ["-C", root, "ls-files", "-z", "--", "*.md"], { encoding: "utf8" });
88
+ } catch (e) {
89
+ throw new CouldNotRun(`could not enumerate tracked files: ${e.message}`);
90
+ }
91
+ const all = out.split("\0").filter(Boolean);
92
+ if (all.length === 0) throw new CouldNotRun("git listed no Markdown files — refusing to report green over an empty scan");
93
+ return all.filter((p) => !isRecord(p));
94
+ }
95
+
96
+ export function declaredVersion(root) {
97
+ let raw;
98
+ try {
99
+ // From the INDEX, like the prose it is compared against. Reading the version from the
100
+ // worktree while reading carriers from the index compares two different trees: a staged
101
+ // version bump with an unchanged worktree would grade staged prose against the OLD version,
102
+ // reporting drift that is not there — or, reversed, greening drift that is. Half a fix is
103
+ // how this rail's own index change first shipped.
104
+ raw = execFileSync("git", ["-C", root, "show", ":package.json"], { encoding: "utf8" });
105
+ } catch (e) {
106
+ throw new CouldNotRun(`could not read package.json from the index: ${e.message}`);
107
+ }
108
+ let v;
109
+ try {
110
+ v = JSON.parse(raw).version;
111
+ } catch (e) {
112
+ throw new CouldNotRun(`package.json is not valid JSON: ${e.message}`);
113
+ }
114
+ if (typeof v !== "string" || !v.trim()) throw new CouldNotRun("package.json declares no version string");
115
+ return v;
116
+ }
117
+
118
+ /** Every current-version claim in one file's text, with the line it sits on. */
119
+ export function claimsIn(text) {
120
+ const found = [];
121
+ for (const { id, re } of PATTERNS) {
122
+ // A fresh RegExp per call: a `g` regex carries lastIndex, and a shared one skips matches.
123
+ const rx = new RegExp(re.source, re.flags);
124
+ let m;
125
+ while ((m = rx.exec(text)) !== null) {
126
+ found.push({ pattern: id, version: m[1], line: text.slice(0, m.index).split("\n").length });
127
+ }
128
+ }
129
+ return found;
130
+ }
131
+
132
+ export function inspect(root) {
133
+ const version = declaredVersion(root);
134
+ const files = liveProseFiles(root);
135
+ const findings = [];
136
+ const carried = new Set();
137
+ let claimCount = 0;
138
+
139
+ for (const rel of files) {
140
+ let text;
141
+ try {
142
+ // The INDEX, not the working tree — `pack-identity` reads the same way and for the same
143
+ // reason. Enumerating from the index and then reading the worktree lets a staged drift
144
+ // with a reverted worktree copy report green while the commit ships the drift.
145
+ text = execFileSync("git", ["-C", root, "show", `:${rel}`], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
146
+ } catch (e) {
147
+ // NOT `continue`. `git ls-files` just named this path, so failing to read its blob means
148
+ // the rail could not examine live prose it is responsible for — and skipping it would
149
+ // report GREEN over a file never looked at. That is this session's defect class exactly,
150
+ // and it was sitting inside the rail built against it. Could-not-run, never a green.
151
+ throw new CouldNotRun(`could not read ${rel} from the index: ${e.message}`);
152
+ }
153
+ for (const c of claimsIn(text)) {
154
+ claimCount++;
155
+ carried.add(rel);
156
+ if (c.version !== version)
157
+ findings.push(`${rel}:${c.line} states the current version as \`${c.version}\`, but package.json declares \`${version}\` (${c.pattern})`);
158
+ }
159
+ }
160
+
161
+ for (const rel of MUST_CARRY) {
162
+ if (!carried.has(rel))
163
+ findings.push(`${rel} carries no current-version claim this rail recognises — it was deleted or reworded, and the rail's reach shrank silently. Add a pattern in cli/version-carriers.mjs with a fixture, or restore the sentence.`);
164
+ }
165
+
166
+ return { version, filesScanned: files.length, claimCount, findings };
167
+ }
168
+
169
+ export function main(argv, stdout = process.stdout, stderr = process.stderr) {
170
+ const root = argv[0] ?? ".";
171
+ let r;
172
+ try {
173
+ r = inspect(root);
174
+ } catch (e) {
175
+ if (e instanceof CouldNotRun) {
176
+ stderr.write(`version-carriers: could not run — ${e.message}\n`);
177
+ return 2;
178
+ }
179
+ stderr.write(`version-carriers: could not run — unexpected failure: ${e.message}\n`);
180
+ return 2;
181
+ }
182
+ if (r.findings.length) {
183
+ for (const f of r.findings) stderr.write(`version-carriers: ${f}\n`);
184
+ return 1;
185
+ }
186
+ stdout.write(`ok version-carriers — ${r.claimCount} current-version claim(s) across ${r.filesScanned} live prose file(s) all read \`${r.version}\`\n`);
187
+ return 0;
188
+ }
189
+
190
+ // The entry guard, in the ONE form `cli/rule-carriers.mjs` designates after this repository got it
191
+ // wrong twice. `file://${argv[1]}` is NOT that form: `import.meta.url` percent-encodes, this working
192
+ // copy lives under a path with spaces, and the comparison failed — so the tool exited 0 having run
193
+ // nothing. **A green that is the tool never starting**, shipped inside the rail built against exactly
194
+ // that. The realpath fallback covers the symlink case an npm `bin` produces, in a `try` because a
195
+ // missing path must answer no rather than throw.
196
+ function isMain() {
197
+ const invoked = process.argv[1];
198
+ if (!invoked) return false;
199
+ if (import.meta.url === pathToFileURL(invoked).href) return true;
200
+ try {
201
+ return import.meta.url === pathToFileURL(realpathSync(invoked)).href;
202
+ } catch {
203
+ return false;
204
+ }
205
+ }
206
+
207
+ // `process.exitCode` rather than `process.exit`, which `cli/control-chars.mjs:540` settled for this
208
+ // repository: exiting outright can truncate a pipe that has not drained. It matters more here than
209
+ // stylistically — a truncated `ok` line IS exit 0 with no output, which is the precise shape of the
210
+ // false green this file's entry guard was fixed for. The same defect, one door along.
211
+ if (isMain()) process.exitCode = main(process.argv.slice(2));
@@ -125,14 +125,13 @@ memory and an implementer's memory answer different questions; merging them spen
125
125
  on the other's concerns. _(Provenance: Letta; see `../personas/`.)_
126
126
 
127
127
  **The first instance is declared at milestone 6 and validated at milestone 7**, and it takes two rows
128
- because the scoping has two halves. **Milestone 6 declares:** the resolving pack's persona declares its
128
+ because the scoping has two halves. **The declaring half:** the resolving pack's persona declares its
129
129
  memory scope, and the resolution demonstration shows that scope landing in the **adopting workspace's
130
130
  own layer** — declared by the pack, owned and populated only by the adopter, empty until earned. The
131
- deliverable there is the scoping *demonstrated*, never a populated store. **Milestone 7 validates:** that
132
- row already has `doctor` checking a persona against its five-part contract, and the memory scope is one
133
- of those parts, so it is where a declaration stops being prose a checker can read but not honour.
131
+ deliverable there is the scoping *demonstrated*, never a populated store. **The validating half:** `doctor` checks a persona against its five-part contract, and the memory
132
+ scope is one of those parts — where a declaration stops being prose a checker can read but not honour.
134
133
 
135
- **The declaration exists as of milestone 6, session 1**, and what it is *not* is the load-bearing half.
134
+ **The declaration exists**, and what it is *not* is the load-bearing half.
136
135
  The `rituals/checkpoints` pack's supervisor persona declares its scope; a composed workspace lands one
137
136
  directory per declared scope in its own layer, generated into an index that is byte-compared, and the
138
137
  location is **empty**. Nothing reads it, nothing recalls from it, nothing consolidates it, and no budget
@@ -142,9 +141,9 @@ condition 4 exists to catch. This is the **doctrine's** carrier of that limit; t
142
141
  for the slot and the generated index prints it on its own face, deliberately, because a limit stated only
143
142
  where a reader already knows to look is stated at the narrowest carrier.
144
143
 
145
- **Settled by the maintainer on 2026-07-29** — verbatim, *"row 6 declares, row 7 validates"* — after two
146
- sessions put the same question to him within the hour without knowing of each other and were given
147
- different answers. The record of both, and of the third option that reconciles them, is
144
+ **Settled by the maintainer** — verbatim, *"row 6 declares, row 7 validates"* — after the same question
145
+ was put to him twice and answered two different ways. The record of both, and of the third option that
146
+ reconciles them, is
148
147
  [#98](https://github.com/sleepy-panda-srl/portulan/issues/98) and
149
148
  [`docs/milestones/m06.md`](../../docs/milestones/m06.md). Neither row needed a criterion edit to carry
150
149
  this: both already demanded their half. Said plainly because the
@@ -44,23 +44,15 @@ is the machine check that fires when an agent tries to end a task: it runs the t
44
44
  blocks "done" if the recipe is not green. The engine defines the contract — a task declares its verify
45
45
  recipe; the gate runs it on Stop. The recipe resolves down the cascade — the **workspace** sets the
46
46
  default, a **repo card** overrides it, a **task** may specialize — and the **compiler** wires the gate
47
- to the host. _(The cascade is the contract; the runner shipped in milestone 4 reads the **workspace
47
+ to the host. _(The cascade is the contract; the runner reads the **workspace
48
48
  default only**. The repo-card and task steps are **not built, and no milestone owns them yet** — said in
49
49
  that form deliberately, because it is the honest one and this file is where the rule lives. Named here
50
50
  because a sentence describing resolution a runner does not perform is a capability claim, and this file
51
- is the one that legislates against those. Found at the milestone-4 close.
52
- **Re-pointed 2026-08-03, on the maintainer's ruling.** This sentence previously said the two steps
53
- "arrive with the CLI in milestone 7", which satisfied the rule in letter — it named a milestone — and
54
- failed it in fact: the milestone named never took the obligation on, and no later one carries it either,
55
- so the promise was carrier-less across the whole map. The two resolutions were to expand that row or to
56
- re-point this sentence; the maintainer took the second, on
57
- the ground that the runner shipped, at the time of that ruling, in no artifact an adopter received, so
58
- building more of it bought an adopter nothing. _(It ships now — milestone 7 moved both runners into the
59
- packaged CLI — which changes the premise and not the ruling: the two resolution steps are still unbuilt
60
- and still unowned.)_ The precedent for the wording is `spec/pack.schema.json`'s `verify` note, which held
51
+ is the one that legislates against those. A promise naming a milestone that never took the obligation on is carrier-less, which is why this
52
+ sentence names no milestone at all rather than an unowned one. The precedent for the wording is `spec/pack.schema.json`'s `verify` note, which held
61
53
  exactly this form until a ruling gave it an owner.)_
62
54
 
63
- The runner arrived in milestone 4, and two of its properties are contract rather than detail. A recipe
55
+ Two of the runner's properties are contract rather than detail. A recipe
64
56
  that **could not run** blocks exactly as a red one does: "nothing looked" must never be read as "nothing
65
57
  wrong". And the gate carries an **iteration cap** — a host's end-of-turn event is not the same event as
66
58
  *the task is finished*, so a gate that blocked indefinitely would make a red working copy undriveable,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleepy_panda_srl/portulan",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Portulan — an agentic-engineering framework whose product is its files. The CLI wraps the file-based mechanics for hosts beyond Claude Code.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/packs/README.md CHANGED
@@ -15,18 +15,14 @@ array, in the canonical `category/name` form.
15
15
 
16
16
  ## Why there is a `.claude-plugin/plugin.json` in *this* directory
17
17
 
18
- Because **this directory is itself a plugin payload**, and it had been shipping as one while declaring
19
- nothing. The private feed's `portulan-checkpoints` entry is a `git-subdir` source rooted here, so what a
20
- host installs is the contents of `packs/` — and until 2026-08-09 that payload carried no plugin manifest
21
- at all. The host had nothing to read, registered nothing, and said so only as a count nobody was
22
- watching: **`claude plugin details portulan-checkpoints@portulan-internal` → `Skills (0)`**, for the
23
- whole of milestone 6 and after.
24
-
25
- That is [#134](https://github.com/sleepy-panda-srl/portulan/issues/134)'s `Skills (0)` bullet, and the
26
- issue's own diagnosis of it was wrong: it attributed the zero to a declared path pointing one level too
27
- high, which is a real trap and a different one. Measured on Claude Code 2.1.226 by installing this
28
- directory both ways — **`Skills (0)` without the manifest, `Skills (3)` with it**, the same three
29
- `SKILL.md` files either way.
18
+ Because **this directory is itself a plugin payload**. The private feed's
19
+ `portulan-checkpoints` entry is a `git-subdir` source rooted here, so what a host installs is the
20
+ contents of `packs/` — and a payload with no plugin manifest registers nothing, silently, reporting
21
+ it only as a count nobody is watching: `claude plugin details` → `Skills (0)`.
22
+
23
+ **Registration is a property of the plugin manifest and of nothing else.** Measured on Claude Code
24
+ 2.1.226 by installing this directory both ways — **`Skills (0)` without the manifest, `Skills (3)`
25
+ with it**, the same three `SKILL.md` files either way.
30
26
 
31
27
  The manifest declares `./rituals/checkpoints/skills/` — the directory that actually holds them — because
32
28
  **a host expands a declared skills path exactly one level and no further**. Declaring `./rituals/`
@@ -50,12 +46,6 @@ exists and the feed's pin moves to it. Until then the green is demonstrated on t
50
46
  for the feed — a distinction this repository would rather write down than round off.
51
47
 
52
48
  > **Two packs exist.** [`rituals/checkpoints`](rituals/checkpoints/README.md), the supervised-build
53
- > ritual, as of milestone 6; and [`tools/github`](tools/github/README.md), one verify recipe, authored
54
- > at milestone 7 session 5 because a demonstration needed a real subject. `stacks/` is still empty and
55
- > says why. _(This said "One pack exists … `stacks/` and `tools/` are still empty" from milestone 6
56
- > until 2026-08-13, and the second half went false the day `tools/github` landed beside it.)_
57
- >
58
- > This file read "Nothing here yet" for four milestones, and the pack it predicted — a cross-artifact
59
- > consistency check — is not the one that arrived. Recorded rather than quietly replaced: the first
60
- > pack was chosen by which ritual this project could show working evidence for, not by which was named
61
- > first.
49
+ > ritual; and [`tools/github`](tools/github/README.md), one verify recipe. `stacks/` is still empty and
50
+ > says why.
51
+
@@ -3,10 +3,7 @@
3
3
  Tool packs — integrations for the tools and MCP servers an agent drives (issue trackers, cloud
4
4
  CLIs, design tools, databases), with the how-to and the gate classification for each.
5
5
 
6
- > **One tool pack exists** — [`github/`](github/README.md), since milestone 7 session 5. _(This read
7
- > "No tool pack exists yet" until 2026-08-13, beside the directory that refutes it.)_ What changed at
8
- > milestone 6 was the machinery rather
9
- > than this directory. Milestone 4 built the thing that would read one — a gate policy in a
6
+ > **One tool pack exists** — [`github/`](github/README.md). The machinery that reads one is a gate policy in a
10
7
  > host-neutral vocabulary, dispatched on by [`../../cli/compile.mjs`](../../cli/compile.mjs) — and the
11
8
  > cascade's missing middle (`core < pack < workspace`) is now bridged: a pack's `contributes.gates`
12
9
  > fragments are resolved and merged into the policy, **tighten-only**, by the same compiler.
package/spec/README.md CHANGED
@@ -6,8 +6,7 @@ what makes both machine-readable instead of a folder convention.
6
6
 
7
7
  - [`workspace.schema.json`](workspace.schema.json) — the **Workspace Definition**: the manifest at a
8
8
  workspace root. Normative.
9
- - [`pack.schema.json`](pack.schema.json) — the **Pack Definition**: the manifest at a pack root, added at
10
- milestone 6, declaring what the pack contributes to the cascade. Normative, and on **its own version
9
+ - [`pack.schema.json`](pack.schema.json) — the **Pack Definition**: the manifest at a pack root, declaring what the pack contributes to the cascade. Normative, and on **its own version
11
10
  train** — see [Versioning](#versioning-and-migrations).
12
11
  - [`slots.md`](slots.md) — every workspace slot: what it holds, why it exists, where it was derived from,
13
12
  and what `doctor` checks about it. The rationale lives there; this file is the orientation.
@@ -64,7 +63,7 @@ read.
64
63
  The fourth, **`pointer`**, added at 2.7, governs nothing. It names the workspace that governs this
65
64
  repository and carries no slots of its own — a repository whose policy layer resides elsewhere says so in
66
65
  one thin manifest instead of keeping a second copy nothing holds in agreement. That is the maintainer's
67
- residence ruling of 2026-07-30 in the schema
66
+ residence ruling in the schema
68
67
  ([proposal 0017](../.portulan/proposals/0017-one-repository-one-governing-workspace.md)):
69
68
 
70
69
  > A repository is governed by exactly one workspace. It carries its own full workspace, or a pointer to
@@ -76,11 +75,11 @@ makes that a property rather than a promise is that every feature keys to a work
76
75
  to a residence. Nothing in `doctor`, `compile` or `index` asks where the manifest lives. The proposal
77
76
  carries the one place where that was not yet true, and it was a discovery gap rather than a feature gap:
78
77
  the boot skill searched the project directory only, so a repository governed from a feed booted to a
79
- pointer's honest report rather than to a resolved workspace. **Closed at milestone 7** by
78
+ pointer's honest report rather than to a resolved workspace. **Closed** by
80
79
  [`../cli/discover.mjs`](../cli/discover.mjs), which reads the host's installed-plugin record and
81
80
  dereferences a pointer's `governed_by` — from disk, never over the network. The other half of the same amendment — the **pack** root
82
81
  ([#123](https://github.com/sleepy-panda-srl/portulan/issues/123)) — landed beside it: `--pack-root
83
- auto` resolves one from that same record. It ran **only when asked** until 2026-08-13, which was narrower
82
+ auto` resolves one from that same record. It ran **only when asked**, which was narrower
84
83
  than the row's *"optional where discovery finds a root"* and was recorded as a narrowing rather than read
85
84
  as compliance. **The narrowing is now lifted and the row's word is met**: discovery runs on the unasked
86
85
  path, and `auto` selects the strict degrade rather than unlocking anything. The narrowing's stated reason
@@ -159,10 +158,8 @@ real cost, stated here rather than discovered. The subset earns its narrowness b
159
158
  completely and honestly; the price is this list, and the list is the thing to watch if it keeps growing.
160
159
  **Four MINORs at +2 each** was the growth rate to hold the next bump against, and **2.3's, 2.5's and
161
160
  2.6's** pairs are the *same* pair three times over — a series needs a store and its index must sit outside
162
- it — which was an argument for generalising it rather than adding it again. _(2.4's pair is `librarian`'s
163
- and is a different one: a pass needs a store to age, and a proposal threshold needs a proposal series.
164
- An earlier draft of this sentence read "2.4's, 2.5's and 2.6's", which would have made the repeating pair
165
- four bumps old instead of three and misattributed `memory`'s contribution to `librarian`.)_
161
+ it — which was an argument for generalising it rather than adding it again. _(2.4's pair is `librarian`'s and is a
162
+ different one: a pass needs a store to age, and a proposal threshold needs a proposal series.)_
166
163
 
167
164
  **2.7 is the first MINOR since 2.2 that adds nothing to this list**, and that is the reason its pointer
168
165
  requirement went into the schema's `oneOf` instead. The generalisation the paragraph above kept asking for
@@ -198,7 +195,7 @@ number governing both would make a bump in either mean a change in the other:
198
195
  | Schema | Manifest key | Current | What it governs |
199
196
  |---|---|---|---|
200
197
  | [`workspace.schema.json`](workspace.schema.json) | `portulan.spec` | **2.8** | the Workspace Definition — the manifest at a workspace root |
201
- | [`pack.schema.json`](pack.schema.json) | `portulan.pack` | **1.0** | the Pack Definition — the manifest at a pack root, added at milestone 6 |
198
+ | [`pack.schema.json`](pack.schema.json) | `portulan.pack` | **1.0** | the Pack Definition — the manifest at a pack root. |
202
199
 
203
200
  The rules below apply to each train independently. `portulan.spec` is `MAJOR.MINOR`, and the current
204
201
  Workspace Definition version is **2.8**. It did **not** move when the Pack Definition arrived, because
@@ -300,7 +297,7 @@ better, and not a fix.)_
300
297
 
301
298
  This migration is a paragraph and a version bump, and a directory holding a document that says "add one
302
299
  line" would be scaffolding pretending to be machinery. This paragraph therefore ended **"There is still
303
- no `migrations/` directory: one arrives when a migration needs code"** — and at milestone 7 session 9
300
+ no `migrations/` directory: one arrives when a migration needs code"** — and
304
301
  one did, on that stated condition rather than because a tool wanted a home.
305
302
 
306
303
  **[`migrations/`](migrations/) is now the contract**, and its README is where a step is defined. What
@@ -328,24 +325,24 @@ workspace with no constitution slot and a green report.
328
325
  |---|---|---|
329
326
  | Every `.json` file parses | [`../.portulan/verify/json.sh`](../.portulan/verify/json.sh) | **Built.** Well-formedness only — it does not read the schema. |
330
327
  | Manifest conforms to the schema | [`../cli/doctor.mjs`](../cli/doctor.mjs) | **Built.** Names the violated constraint and its location. |
331
- | A declared pack **resolves** | `doctor` | **Built** (milestone 6). Roots are derived from `tree`, and named roots may be given with `--pack-root`, which **replace** the derived one rather than preceding it, so a from-a-feed demonstration cannot be satisfied by a copy in the local tree at all. A workspace with no `tree` and no named root has its packs reported *unverifiable* rather than failed. Resolution **from a feed is demonstrated** as of milestone 6, session 1: the checkpoint ritual pack installed from the private `portulan-internal` marketplace and resolved from the install root, which is the same code path the derived one takes. **Discovery landed at milestone 7** ([#123](https://github.com/sleepy-panda-srl/portulan/issues/123)): `--pack-root auto` reads the host's installed-plugin record — in **both** shapes a plugin lands in, packs under `packs/` for a repository-shaped one and categories at the install root for a flat one, which is what this project's own feed ships. **Amended 2026-08-12:** asked-for discovery is **unioned** with the tree-derived root, discovered first, so a workspace composing a cache-installed pack *and* one of its own resolves both — which no invocation could do before without naming a cache path by hand. The **named**-root property above is untouched: a named root still replaces the derived one, so a from-a-feed demonstration given one cannot be satisfied by a local copy. What the union trades is the weaker guarantee that `auto` *alone* implied provenance; it no longer does, and each pack's resolution states which root answered instead. Asking for a named root **and** `auto` together is refused (exit 2) rather than silently resolved to one of them. **Amended 2026-08-13 — the disposal:** discovery runs on the **unasked** path too, because the row's clause is that `--pack-root` is *optional where discovery finds a root* and it was not. `auto` now selects the strict degrade (asked-and-could-not-look is exit 2) rather than unlocking discovery. Two things bound it, and neither makes a bare run host-independent — `doctor` is a per-host capability report and a bare verdict does move with the machine. First, every **required** check **names** its root, which replaces every other source, so it cannot consult the host at all. Second, `doctor`'s note-vs-fail keying is on a root's **origin**, so discovery can turn a note into a **resolution** and never a **miss** into a failure — a miss under a root the workspace or the caller claimed stays a FAIL whatever is installed. It softens nothing: a discovered copy that resolves and is *invalid* still fails, with its origin named. |
332
- | A pack manifest conforms to the Pack Definition | `doctor` | **Built** (milestone 6). Same validator, same subset, a different schema and version train. |
333
- | A pack's skills and personas are opened and validated | `doctor` | **Built** (milestone 7 session 2), on the maintainer's ruling of 2026-08-03 that row 7's validation reads broad. A skill's frontmatter (`name` kebab-case, `description` non-empty — the two a host reads before loading a body); a persona against the five-part contract, **including the memory scope** `core/operating/memory.md` promised would be validated at this milestone; and the tier no role may claim. Containment is checked **after resolution**, because opening these paths is what made an escaping value stop being inert. Three honest limits, each measured rather than assumed. The skills walk stops **three levels down**, matching `cli/plugin-lint.mjs`'s bound so two walkers over the same key cannot disagree, and it **reports the directories it did not descend into** rather than counting a root it never opened as empty. The Prohibited check is a prose heuristic defeated by any negation **in the same block** — a bullet list counts as one block — and the structural fix is a declared reach *field*, a contract change that is not built. And the five-part check is **presence**, not content: a persona whose sections are all empty passes, so a declared memory scope is read and not honoured. |
334
- | Pack gate fragments merge **tighten-only** | [`../cli/compile.mjs`](../cli/compile.mjs) | **Built** (milestone 6). Two layers and two axes: `auto` is absent from the Pack Definition's tier enum, so a demotion to unattended is unexpressible; the comparison against the composed base is the compiler's, and both a weakened **tier** and a changed **action** throw rather than being dropped. The action half is the one a tier-only check misses — raising the tier while swapping the matcher removes the gate and still reads as a tightening. |
335
- | A pack declaring a version ahead of `doctor` | `doctor` | **Built** (milestone 6). Refused rather than graded, symmetric with the workspace train, and on the Pack Definition's own `$id`. |
328
+ | A declared pack **resolves** | `doctor` | **Built**. Roots are derived from `tree`, and named roots may be given with `--pack-root`, which **replace** the derived one rather than preceding it, so a from-a-feed demonstration cannot be satisfied by a copy in the local tree at all. A workspace with no `tree` and no named root has its packs reported *unverifiable* rather than failed. Resolution **from a feed is demonstrated**: the checkpoint ritual pack installed from the private `portulan-internal` marketplace and resolved from the install root, which is the same code path the derived one takes. **Discovery is built** ([#123](https://github.com/sleepy-panda-srl/portulan/issues/123)): `--pack-root auto` reads the host's installed-plugin record — in **both** shapes a plugin lands in, packs under `packs/` for a repository-shaped one and categories at the install root for a flat one, which is what this project's own feed ships. Asked-for discovery is **unioned** with the tree-derived root, discovered first, so a workspace composing a cache-installed pack *and* one of its own resolves both. The **named**-root property above is untouched: a named root still replaces the derived one, so a from-a-feed demonstration given one cannot be satisfied by a local copy. What the union trades is the weaker guarantee that `auto` *alone* implied provenance; it no longer does, and each pack's resolution states which root answered instead. Asking for a named root **and** `auto` together is refused (exit 2) rather than silently resolved to one of them. Discovery runs on the **unasked** path too, because `--pack-root` is *optional where discovery finds a root*. `auto` now selects the strict degrade (asked-and-could-not-look is exit 2) rather than unlocking discovery. Two things bound it, and neither makes a bare run host-independent — `doctor` is a per-host capability report and a bare verdict does move with the machine. First, every **required** check **names** its root, which replaces every other source, so it cannot consult the host at all. Second, `doctor`'s note-vs-fail keying is on a root's **origin**, so discovery can turn a note into a **resolution** and never a **miss** into a failure — a miss under a root the workspace or the caller claimed stays a FAIL whatever is installed. It softens nothing: a discovered copy that resolves and is *invalid* still fails, with its origin named. |
329
+ | A pack manifest conforms to the Pack Definition | `doctor` | **Built**. Same validator, same subset, a different schema and version train. |
330
+ | A pack's skills and personas are opened and validated | `doctor` | **Built**, on the maintainer's ruling that row 7's validation reads broad. A skill's frontmatter (`name` kebab-case, `description` non-empty — the two a host reads before loading a body); a persona against the five-part contract, **including the memory scope** `core/operating/memory.md` promised would be validated at this milestone; and the tier no role may claim. Containment is checked **after resolution**, because opening these paths is what made an escaping value stop being inert. Three honest limits, each measured rather than assumed. The skills walk stops **three levels down**, matching `cli/plugin-lint.mjs`'s bound so two walkers over the same key cannot disagree, and it **reports the directories it did not descend into** rather than counting a root it never opened as empty. The Prohibited check is a prose heuristic defeated by any negation **in the same block** — a bullet list counts as one block — and the structural fix is a declared reach *field*, a contract change that is not built. And the five-part check is **presence**, not content: a persona whose sections are all empty passes, so a declared memory scope is read and not honoured. |
331
+ | Pack gate fragments merge **tighten-only** | [`../cli/compile.mjs`](../cli/compile.mjs) | **Built**. Two layers and two axes: `auto` is absent from the Pack Definition's tier enum, so a demotion to unattended is unexpressible; the comparison against the composed base is the compiler's, and both a weakened **tier** and a changed **action** throw rather than being dropped. The action half is the one a tier-only check misses — raising the tier while swapping the matcher removes the gate and still reads as a tightening. |
332
+ | A pack declaring a version ahead of `doctor` | `doctor` | **Built**. Refused rather than graded, symmetric with the workspace train, and on the Pack Definition's own `$id`. |
336
333
  | Path slots resolve to real files | `doctor` | **Built.** File-versus-directory too; a slot resolving outside the workspace is reported, never failed. |
337
334
  | Workspace claims match the tree | `doctor` | **Built, where a tree is declared** — repo-card build/test/run lines and layout, and the gate map's required-check claim. A workspace with no `tree` has those claims *reported unverifiable*. |
338
335
  | A rule's provenance is well-formed | `doctor` | **Built.** On `type: rule` records in the `memory` slot; the form only, never the truth. |
339
336
  | Sealed proportion reported | `doctor` | **Built.** Over rules, which is the denominator the mandate uses. |
340
- | The recipes a workspace declares actually run | Stop-gate runner | **Built** (milestone 4), for the **default** recipe only, and outside `doctor` — which still reads recipes and executes none. A non-default recipe declaring a tool it does not have still passes. |
341
- | The gate policy compiles to host enforcement | `compile` | **Built** (milestone 4). Two backends — the Claude Code host and the GitHub repository ruleset that is the platform floor. Every rule ends as compiled or refused-with-a-reason, **per backend**, and each emitted artifact is held to the policy by a verify recipe. That the *host* honours it is not checkable here — CI installs nothing. |
342
- | What each backend cannot enforce | `doctor` · `compile --matrix` | **Built** (milestone 4). Per-backend coverage, the gates no backend compiles, and the floor's declared status checks against the tree's workflow jobs — the last of those a failure, the rest reports. Exported-versus-live drift is **not** checked: `doctor` does not fetch settings, and no recipe here makes a network call. |
337
+ | The recipes a workspace declares actually run | Stop-gate runner | **Built**, for the **default** recipe only, and outside `doctor` — which still reads recipes and executes none. A non-default recipe declaring a tool it does not have still passes. |
338
+ | The gate policy compiles to host enforcement | `compile` | **Built**. Two backends — the Claude Code host and the GitHub repository ruleset that is the platform floor. Every rule ends as compiled or refused-with-a-reason, **per backend**, and each emitted artifact is held to the policy by a verify recipe. That the *host* honours it is not checkable here — CI installs nothing. |
339
+ | What each backend cannot enforce | `doctor` · `compile --matrix` | **Built**. Per-backend coverage, the gates no backend compiles, and the floor's declared status checks against the tree's workflow jobs — the last of those a failure, the rest reports. Exported-versus-live drift is **not** checked: `doctor` does not fetch settings, and no recipe here makes a network call. |
343
340
  | One repository, one governing workspace | `doctor` | **Built** (2.7). Two refusals need only the manifest in hand: a `pointer` carrying governing slots, and a governing kind carrying a `governed_by`. Both print the ruling's own sentence, which is why they are `doctor`'s and not the schema's — a schema failure returns at *the manifest must conform first* and the sentence would never reach a reader. |
344
- | A repository named by one workspace and governed by another | `doctor --repo-root` | **Built** (2.7), **where visible**. Roots are named, never discovered — **and since milestone 7 this is the narrower limit of the two**: `--pack-root` gained `auto` and `--repo-root` did not, because what it would have to find is a *repository checkout*, which no host plugin record lists. The amendment that ordered discovery names the plugin cache in its own title, so this is outside its scope rather than owed by it. Without a root the check **reports that it did not run** rather than passing quietly. Visibility is one-way: the refusal runs from the naming workspace outward, so a repository carrying a full workspace cannot see a portfolio that claims it. A workspace that names its **own** repository finds itself and is not two managers; that identity is compared on the real path. |
345
- | A pointer resolves to the workspace it names | `discover` · `doctor` | **Built** (milestone 7). `governed_by` is dereferenced against the host's installed-plugin record — `<config>/plugins/installed_plugins.json`, `CLAUDE_CONFIG_DIR` overriding the config directory — and the match is on the **governing manifest's `name`**, never on a plugin's, with `governed_by.feed` constraining the marketplace where it is declared. Four answers, because three of them are not *no*: `resolved` (exactly one), `not-installed`, `ambiguous` (two or more — **refused and both named**, never ranked), `could-not-look` (a record that would not parse, which must never spend as absence). **Nothing is fetched**: the record is read from disk and no path here touches the network, so *discovery of anything not installed* stays out of scope as row 7 states. `doctor` **reports** the answer on a pointer and never grades it — a pointer whose governor is uninstalled is a correct pointer, and no discovery outcome moves this tool's exit code. The candidate locations inside a plugin payload are a **named pair** — `<installPath>/workspace.json` and `<installPath>/.portulan/workspace.json` — rather than a walk, which is what keeps this bundle's demo and drifted fixtures out of reach by construction — neither sits at one; the bundle's own `.portulan/` does, which is the correct answer to a pointer naming `portulan` rather than a hole. `init` **asks** the residence question and writes a pointer as of milestone 7 session 1; `vendor` **performs the switch in both directions** as of session 3, and both write pointers rather than resolving them. |
341
+ | A repository named by one workspace and governed by another | `doctor --repo-root` | **Built** (2.7), **where visible**. Roots are named, never discovered — **and this is the narrower limit of the two**: `--pack-root` gained `auto` and `--repo-root` did not, because what it would have to find is a *repository checkout*, which no host plugin record lists. The amendment that ordered discovery names the plugin cache in its own title, so this is outside its scope rather than owed by it. Without a root the check **reports that it did not run** rather than passing quietly. Visibility is one-way: the refusal runs from the naming workspace outward, so a repository carrying a full workspace cannot see a portfolio that claims it. A workspace that names its **own** repository finds itself and is not two managers; that identity is compared on the real path. |
342
+ | A pointer resolves to the workspace it names | `discover` · `doctor` | **Built**. `governed_by` is dereferenced against the host's installed-plugin record — `<config>/plugins/installed_plugins.json`, `CLAUDE_CONFIG_DIR` overriding the config directory — and the match is on the **governing manifest's `name`**, never on a plugin's, with `governed_by.feed` constraining the marketplace where it is declared. Four answers, because three of them are not *no*: `resolved` (exactly one), `not-installed`, `ambiguous` (two or more — **refused and both named**, never ranked), `could-not-look` (a record that would not parse, which must never spend as absence). **Nothing is fetched**: the record is read from disk and no path here touches the network, so *discovery of anything not installed* stays out of scope as row 7 states. `doctor` **reports** the answer on a pointer and never grades it — a pointer whose governor is uninstalled is a correct pointer, and no discovery outcome moves this tool's exit code. The candidate locations inside a plugin payload are a **named pair** — `<installPath>/workspace.json` and `<installPath>/.portulan/workspace.json` — rather than a walk, which is what keeps this bundle's demo and drifted fixtures out of reach by construction — neither sits at one; the bundle's own `.portulan/` does, which is the correct answer to a pointer naming `portulan` rather than a hole. `init` **asks** the residence question and writes a pointer; `vendor` **performs the switch in both directions** as of session 3, and both write pointers rather than resolving them. |
346
343
  | A feed-side repo card's claims against a repo-side tree | — | **Not built** — the cross-repo claims-lint gap, priced in [proposal 0017](../.portulan/proposals/0017-one-repository-one-governing-workspace.md). A portfolio workspace declares no `tree`, so its cards' build/test/run and layout claims are *reported unverifiable*; repo-side the tree is present and the cards are not. No single CI run sees both halves. In-session validation does work, and `--repo-root` narrows the governance question specifically — neither closes this. **Tracked as [#138](https://github.com/sleepy-panda-srl/portulan/issues/138)**, filed 2026-07-30 at the maintainer's instruction. This row states no *arrives-at*, unlike its neighbour below which says outright that it is not planned as a gate; which milestone owns it — milestone 9 has been proposed, since its criterion is the first that needs both halves green together — is **the maintainer's to decide**, and this row will name it once [`../docs/plan.md`](../docs/plan.md) does. Until then the honest state is *not built, tracked, undispositioned* rather than a milestone claimed here first. |
347
344
  | A rule's link resolves | — | Not built, and not planned as a gate: dereferencing needs the network, and a gate that fails for reasons unrelated to the change under test is worse than no gate. |
348
- | Agent-legibility scored | `doctor` | **Built** (milestone 7 session 7). **Seven** dimensions, each optional in this definition so it can genuinely be absent and none of them a restatement of a check that already fails — *executable verification* was an eighth for one checkpoint until that rule was applied to it, since form 0 of the `oneOf` above requires `verify` of every workspace the score can reach: recipes declaring `requires` · a machine-readable gate policy · a `dod` slot · a memory store with a generated index · a handoff series with a generated index · affordances reachable for every product (its own or the workspace-level default) · each of those documents naming what an agent must **not** assume. The `affordances` slot is the **named input, not the only one** — a score confined to it could not tell two workspaces apart. **It moves no exit code**: a measurement is not a verdict, and one that could fail a workspace would make conformance a function of prose volume. The last dimension is a **form** check over a named heading table, so a limits section with an empty body passes. |
345
+ | Agent-legibility scored | `doctor` | **Built**. **Seven** dimensions, each optional in this definition so it can genuinely be absent and none of them a restatement of a check that already fails — *executable verification* was an eighth for one checkpoint until that rule was applied to it, since form 0 of the `oneOf` above requires `verify` of every workspace the score can reach: recipes declaring `requires` · a machine-readable gate policy · a `dod` slot · a memory store with a generated index · a handoff series with a generated index · affordances reachable for every product (its own or the workspace-level default) · each of those documents naming what an agent must **not** assume. The `affordances` slot is the **named input, not the only one** — a score confined to it could not tell two workspaces apart. **It moves no exit code**: a measurement is not a verdict, and one that could fail a workspace would make conformance a function of prose volume. The last dimension is a **form** check over a named heading table, so a limits section with an empty body passes. |
349
346
 
350
347
  As of milestone 2 the schema is a rail rather than only a specification — but read the right-hand column
351
348
  rather than the middle one. `doctor` checks that a workspace's claims are **well-formed and resolvable**;
@@ -1,9 +1,8 @@
1
1
  # `spec/migrations/` — what a migration is
2
2
 
3
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
4
+ > section is the contract this directory implements, and this directory exists on that section's own
5
+ > stated condition: a migration needed code. The runner is
7
6
  > [`../../cli/upgrade.mjs`](../../cli/upgrade.mjs).
8
7
 
9
8
  ## A step, and the two kinds
package/spec/slots.md CHANGED
@@ -345,9 +345,7 @@ has a product-layer slot, which is what the criterion requires; *instances* may
345
345
  **What `doctor` checks:** each `product` path resolves; each `affordances` path **that is present**
346
346
  resolves; each `repos` entry names a card in the `repos` slot. Per-product `affordances` is optional
347
347
  because a product may inherit the workspace-level default — so the check is conditional, and `doctor`
348
- *reports* a product that has neither its own nor an inherited one rather than failing it. (An earlier
349
- draft of this line said `doctor` checks `affordances` unconditionally, which would have made the spec
350
- demand something the schema declares optional; caught in review of the change that introduced it.) The
348
+ *reports* a product that has neither its own nor an inherited one rather than failing it. The
351
349
  claim it cannot check is whether the mission written there is still true.
352
350
 
353
351
  ## `affordances` — what the product offers an agent
@@ -465,12 +463,9 @@ workspace that cannot say which packs it composes leaves an agent to infer the m
465
463
  resolution order. The portfolio workspace at milestone 6 ships as a private-feed plugin composing a pack
466
464
  delivered by that feed — a manifest that cannot express that forces a MAJOR bump one milestone after v1.
467
465
 
468
- _(This sentence said **premium** packs until milestone 6, session 1, and no shipped pack is premium: the
469
- one the portfolio workspace composes is the universal checkpoint ritual, delivered by the private feed and
470
- authored in the public engine. The prediction was reasonable and turned out not to be what milestone 6
471
- built, so it is corrected to what the tree shows rather than left as a promise about a pack nobody wrote.
472
- Whether a premium pack should exist at all is a commercial question for the maintainer, not a gap this
473
- file may quietly keep open.)_
466
+ _(No shipped pack is premium: the one the portfolio workspace composes is the universal checkpoint
467
+ ritual, delivered by the private feed and authored in the public engine. Whether a premium pack should
468
+ exist at all is a commercial question for the maintainer, not a gap this file may quietly keep open.)_
474
469
 
475
470
  An array of names in cascade order, empty by default. Each name is the canonical `category/name` form
476
471
  the Pack Definition owns ([`pack.schema.json`](pack.schema.json)) — but the items stay **free strings**