@sleepy_panda_srl/portulan 0.1.0 → 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.
- package/README.md +137 -50
- package/cli/README.md +91 -120
- package/cli/compile.mjs +242 -3
- package/cli/doctor.mjs +54 -1
- package/cli/index.mjs +92 -42
- package/cli/init.mjs +3 -2
- package/cli/inside.mjs +38 -0
- package/cli/pack-identity.mjs +180 -0
- package/cli/recipe-set.mjs +42 -1
- package/cli/skills-set.mjs +166 -4
- package/cli/stop-gate.mjs +401 -16
- package/cli/upgrade.mjs +2 -3
- package/cli/version-carriers.mjs +211 -0
- package/core/operating/memory.md +7 -8
- package/core/operating/verification.md +4 -12
- package/package.json +1 -1
- package/packs/README.md +11 -21
- package/packs/tools/README.md +1 -4
- package/spec/README.md +19 -22
- package/spec/migrations/README.md +2 -3
- package/spec/slots.md +4 -9
|
@@ -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));
|
package/core/operating/memory.md
CHANGED
|
@@ -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. **
|
|
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. **
|
|
132
|
-
|
|
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
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
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.
|
|
52
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
54
|
-
>
|
|
55
|
-
|
|
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
|
+
|
package/packs/tools/README.md
CHANGED
|
@@ -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)
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
332
|
-
| A pack manifest conforms to the Pack Definition | `doctor` | **Built
|
|
333
|
-
| A pack's skills and personas are opened and validated | `doctor` | **Built
|
|
334
|
-
| Pack gate fragments merge **tighten-only** | [`../cli/compile.mjs`](../cli/compile.mjs) | **Built
|
|
335
|
-
| A pack declaring a version ahead of `doctor` | `doctor` | **Built
|
|
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
|
|
341
|
-
| The gate policy compiles to host enforcement | `compile` | **Built
|
|
342
|
-
| What each backend cannot enforce | `doctor` · `compile --matrix` | **Built
|
|
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
|
|
345
|
-
| A pointer resolves to the workspace it names | `discover` · `doctor` | **Built
|
|
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
|
|
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
|
|
5
|
-
>
|
|
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.
|
|
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
|
-
_(
|
|
469
|
-
|
|
470
|
-
|
|
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**
|