@sleepy_panda_srl/portulan 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +87 -0
- package/cli/README.md +337 -0
- package/cli/compile.mjs +2209 -0
- package/cli/control-chars.mjs +570 -0
- package/cli/discover.mjs +985 -0
- package/cli/doctor.mjs +2803 -0
- package/cli/feedback.mjs +970 -0
- package/cli/gate.mjs +271 -0
- package/cli/index.mjs +1390 -0
- package/cli/init.mjs +1729 -0
- package/cli/librarian.mjs +1384 -0
- package/cli/manifest.mjs +59 -0
- package/cli/new.mjs +803 -0
- package/cli/pack-version.mjs +543 -0
- package/cli/plugin-lint.mjs +1566 -0
- package/cli/portulan.mjs +266 -0
- package/cli/recipe-set.mjs +425 -0
- package/cli/rule-carriers.mjs +489 -0
- package/cli/skills-set.mjs +715 -0
- package/cli/stop-gate.mjs +434 -0
- package/cli/upgrade.mjs +897 -0
- package/cli/vendor.mjs +1504 -0
- package/core/README.md +46 -0
- package/core/engine.md +47 -0
- package/core/operating/autonomy.md +100 -0
- package/core/operating/evolution.md +84 -0
- package/core/operating/loop.md +83 -0
- package/core/operating/memory.md +161 -0
- package/core/operating/safety.md +42 -0
- package/core/operating/verification.md +76 -0
- package/core/personas/README.md +40 -0
- package/core/personas/implementer.md +51 -0
- package/core/personas/librarian.md +73 -0
- package/core/personas/reviewer.md +52 -0
- package/core/skills/README.md +52 -0
- package/core/skills/clarify/SKILL.md +42 -0
- package/core/skills/codify/SKILL.md +65 -0
- package/core/skills/consolidate/SKILL.md +121 -0
- package/core/templates/gate-policy.md +76 -0
- package/core/templates/handoff.md +29 -0
- package/core/templates/memory-entry.md +44 -0
- package/core/templates/pack.md +94 -0
- package/core/templates/persona.md +69 -0
- package/core/templates/proposal.md +27 -0
- package/core/templates/repo-card.md +28 -0
- package/core/templates/skill.md +57 -0
- package/core/templates/task.md +26 -0
- package/core/templates/workspace.md +81 -0
- package/package.json +41 -0
- package/packs/.claude-plugin/plugin.json +22 -0
- package/packs/README.md +61 -0
- package/packs/rituals/README.md +13 -0
- package/packs/rituals/checkpoints/README.md +105 -0
- package/packs/rituals/checkpoints/pack.json +36 -0
- package/packs/rituals/checkpoints/personas/supervisor.md +64 -0
- package/packs/rituals/checkpoints/skills/milestone-close/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/session-open/SKILL.md +57 -0
- package/packs/stacks/README.md +8 -0
- package/packs/tools/README.md +17 -0
- package/packs/tools/github/README.md +65 -0
- package/packs/tools/github/pack.json +29 -0
- package/packs/tools/github/verify/README.md +51 -0
- package/packs/tools/github/verify/actions-pinned.sh +132 -0
- package/spec/README.md +399 -0
- package/spec/migrations/0001-repository-declares-its-tree.mjs +100 -0
- package/spec/migrations/0002-bundle-fallback-path.mjs +179 -0
- package/spec/migrations/README.md +95 -0
- package/spec/pack.schema.json +201 -0
- package/spec/slots.md +796 -0
- package/spec/workspace.schema.json +438 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# `actions-pinned` — what it checks, and what it cannot
|
|
2
|
+
|
|
3
|
+
One recipe. `bash ${PACK_ROOT}/verify/actions-pinned.sh`, run from the adopting repository's root.
|
|
4
|
+
|
|
5
|
+
**`${PACK_ROOT}` is expanded by the composing reader** ([`cli/recipe-set.mjs`](../../../../cli/recipe-set.mjs))
|
|
6
|
+
to the pack's resolved root, relative to the repository root, before the command reaches a shell. A pack
|
|
7
|
+
does not know the adopter's layout — the token is how a composed recipe names its own files without
|
|
8
|
+
guessing one.
|
|
9
|
+
|
|
10
|
+
## The three exits
|
|
11
|
+
|
|
12
|
+
| Exit | Means |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `0` | every `uses:` reference examined is pinned to a full 40-character commit SHA |
|
|
15
|
+
| `1` | at least one is pinned to a tag or a branch, or carries no `@ref` at all |
|
|
16
|
+
| `2` | **could not run** — there is no `.github/workflows/` directory, or it holds no `.yml`/`.yaml` file |
|
|
17
|
+
|
|
18
|
+
**Exit 2 on an absent or empty workflows directory is a decision, not an accident.** The alternative —
|
|
19
|
+
reporting green over a directory nobody looked in — is the false green: "nothing looked" reported as
|
|
20
|
+
"nothing wrong", and enumeration is a precondition here exactly as it is inside every recipe this
|
|
21
|
+
repository writes. A workspace with no GitHub workflows has composed a pack that cannot do its job, and
|
|
22
|
+
the honest answer is to say so rather than to hand back a green it might be mistaken for coverage. What
|
|
23
|
+
to change in that case is the `packs` array, not this exit code.
|
|
24
|
+
|
|
25
|
+
## Known limits, each with its reason
|
|
26
|
+
|
|
27
|
+
- **One `uses:` per line.** The scanner reads line-oriented `uses:` entries, which is the spelling every
|
|
28
|
+
workflow in the wild uses. A folded scalar or a flow-mapping spelling would not be seen. A YAML parser
|
|
29
|
+
would close this, and it would make the recipe depend on a parser the pack would then have to ship or
|
|
30
|
+
require — priced and declined at this size. A matcher that claimed to read YAML while reading lines
|
|
31
|
+
would be worse than the stated limit.
|
|
32
|
+
- **Local actions are exempt.** `uses: ./.github/actions/thing` lives in the adopter's own repository and
|
|
33
|
+
moves with it; there is no third-party commit to pin, so requiring one would be a check that cannot be
|
|
34
|
+
satisfied.
|
|
35
|
+
- **Container references are exempt.** `uses: docker://…` is pinned by image digest, not by commit. Out
|
|
36
|
+
of scope, and named rather than silently passed.
|
|
37
|
+
- **A pinned SHA is not verified to exist.** Checking that `owner/action@<sha>` is reachable in that
|
|
38
|
+
repository needs a network call, and **a network call inside a verify recipe is a standing prohibition
|
|
39
|
+
in this project** — recorded in row 7's own exclusions. So a 40-hex string that is not a real commit
|
|
40
|
+
passes here. The check is *shape*, not *existence*, and the shape is what stops a moving target.
|
|
41
|
+
- **It does not read reusable-workflow `uses:` differently from action `uses:`.** Both are pinned by the
|
|
42
|
+
same rule, which is correct, but a reusable workflow in the same repository is a local reference and is
|
|
43
|
+
therefore exempt for the reason above.
|
|
44
|
+
|
|
45
|
+
## What this does not replace
|
|
46
|
+
|
|
47
|
+
`sha_pinning_required` is enforced by GitHub itself at the organisation and repository level for this
|
|
48
|
+
project, and where an adopter has it the platform refuses an unpinned action at run time regardless of
|
|
49
|
+
this recipe. See the [pack README](../README.md) for why the recipe still earns its place — the short
|
|
50
|
+
form is that this fires at the pull request rather than at the run, and that a pack travels to adopters
|
|
51
|
+
whose platform enforces nothing.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Every `uses:` in a GitHub Actions workflow is pinned to a full 40-character commit SHA.
|
|
4
|
+
#
|
|
5
|
+
# Exit 0 green · 1 red · 2 could not run. The third is load-bearing here, as everywhere in this
|
|
6
|
+
# project: a recipe that could not look must never report what a recipe that looked and found nothing
|
|
7
|
+
# reports. See `README.md` beside this file for what the check does NOT cover and why it exists at all
|
|
8
|
+
# given that GitHub can enforce pinning itself.
|
|
9
|
+
#
|
|
10
|
+
# Composed into an adopting workspace through `pack.json`'s `contributes.verify`, whose `run` names
|
|
11
|
+
# this file through `${PACK_ROOT}` — the pack does not know the adopter's layout and must not guess it.
|
|
12
|
+
|
|
13
|
+
set -uo pipefail
|
|
14
|
+
|
|
15
|
+
WORKFLOWS=".github/workflows"
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------------------------
|
|
18
|
+
# The tools this recipe invokes, checked before it invokes them.
|
|
19
|
+
#
|
|
20
|
+
# Without this the failure is SILENT and GREEN: the two loops below read from process substitutions,
|
|
21
|
+
# so a missing `find` or `grep` yields no lines at all, every counter stays zero, `status` stays 0 —
|
|
22
|
+
# and the recipe reports a pass having examined nothing. That is the false green — "nothing looked"
|
|
23
|
+
# reported as "nothing wrong" — and it is precisely what `.portulan/memory/verify-preconditions-fail-closed.md`
|
|
24
|
+
# exists to refuse. The header below already cited that rule for the workflows DIRECTORY and did not
|
|
25
|
+
# apply it to the recipe's own dependencies: a rule obeyed at one of its two sites, in a file that
|
|
26
|
+
# quotes the rule. Copilot round 5.
|
|
27
|
+
#
|
|
28
|
+
# `requires` in `pack.json` declares the same list. Two carriers, deliberately — the manifest's is
|
|
29
|
+
# what a host reads to decide whether to run at all, this one is what fires when it ran anyway — and
|
|
30
|
+
# they are checked against each other by nothing, which is stated here rather than implied.
|
|
31
|
+
# ---------------------------------------------------------------------------------------------
|
|
32
|
+
missing=""
|
|
33
|
+
for tool in find grep sed sort cut wc tr; do
|
|
34
|
+
command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool"
|
|
35
|
+
done
|
|
36
|
+
if [ -n "$missing" ]; then
|
|
37
|
+
printf 'actions-pinned: COULD NOT RUN — missing required tool(s):%s\n' "$missing" >&2
|
|
38
|
+
printf ' Reported rather than run around: without these the scan reads nothing and would report\n' >&2
|
|
39
|
+
printf ' a pass over a tree it never examined.\n' >&2
|
|
40
|
+
exit 2
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------------------------
|
|
44
|
+
# Preconditions fail closed. Enumeration is the precondition here exactly as it is in this project's
|
|
45
|
+
# own recipes (`.portulan/memory/verify-preconditions-fail-closed.md`): a green computed over a
|
|
46
|
+
# directory that was not there is a green about nothing.
|
|
47
|
+
#
|
|
48
|
+
# Both the absent directory and the present-but-empty one are could-not-run rather than vacuously
|
|
49
|
+
# green, and the distinction between them is kept in the MESSAGE rather than in the exit code —
|
|
50
|
+
# a workspace with no workflows has composed a pack that cannot do its job, and should hear that
|
|
51
|
+
# sentence rather than a green it might mistake for coverage.
|
|
52
|
+
# ---------------------------------------------------------------------------------------------
|
|
53
|
+
if [ ! -d "$WORKFLOWS" ]; then
|
|
54
|
+
printf 'actions-pinned: COULD NOT RUN — there is no %s directory in this repository.\n' "$WORKFLOWS" >&2
|
|
55
|
+
printf ' A workspace with no GitHub Actions workflows has nothing for this recipe to check;\n' >&2
|
|
56
|
+
printf ' composing `tools/github` there is the thing to change, not this exit code.\n' >&2
|
|
57
|
+
exit 2
|
|
58
|
+
fi
|
|
59
|
+
|
|
60
|
+
# `find` rather than a glob, so a directory holding only subdirectories is not mistaken for one
|
|
61
|
+
# holding workflows. Sorted so the report is stable between runs and between machines.
|
|
62
|
+
files=$(find "$WORKFLOWS" -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) | sort)
|
|
63
|
+
|
|
64
|
+
if [ -z "$files" ]; then
|
|
65
|
+
printf 'actions-pinned: COULD NOT RUN — %s holds no .yml or .yaml file.\n' "$WORKFLOWS" >&2
|
|
66
|
+
exit 2
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
status=0
|
|
70
|
+
examined=0
|
|
71
|
+
pinned=0
|
|
72
|
+
local_refs=0
|
|
73
|
+
|
|
74
|
+
while IFS= read -r file; do
|
|
75
|
+
# One `uses:` per line is the shape every workflow in the wild uses; a folded or flow-mapping
|
|
76
|
+
# spelling would slip past this, which is stated in README.md as a known limit rather than
|
|
77
|
+
# covered by a matcher that would claim more than it reads.
|
|
78
|
+
while IFS= read -r line; do
|
|
79
|
+
value=$(printf '%s\n' "$line" | sed -E 's/^[[:space:]]*-?[[:space:]]*uses:[[:space:]]*//; s/[[:space:]]*(#.*)?$//; s/^["'"'"']//; s/["'"'"']$//')
|
|
80
|
+
[ -n "$value" ] || continue
|
|
81
|
+
|
|
82
|
+
# `examined` counts only references this recipe can actually judge, and the exempt ones are
|
|
83
|
+
# counted separately below. Counting them here made the summary print a ratio like `2 of 3
|
|
84
|
+
# pinned` on a run where every non-exempt reference WAS pinned — a green whose own number
|
|
85
|
+
# looked like a partial pass. Copilot round 2.
|
|
86
|
+
case "$value" in
|
|
87
|
+
./*)
|
|
88
|
+
# A local action lives in this repository and moves with it — there is no third-party
|
|
89
|
+
# commit to pin, and demanding one would be a check that cannot be satisfied.
|
|
90
|
+
local_refs=$((local_refs + 1))
|
|
91
|
+
continue
|
|
92
|
+
;;
|
|
93
|
+
docker://*)
|
|
94
|
+
# A container reference is pinned by digest, not by commit. Out of scope and named.
|
|
95
|
+
local_refs=$((local_refs + 1))
|
|
96
|
+
continue
|
|
97
|
+
;;
|
|
98
|
+
esac
|
|
99
|
+
|
|
100
|
+
examined=$((examined + 1))
|
|
101
|
+
ref=${value##*@}
|
|
102
|
+
if [ "$ref" = "$value" ]; then
|
|
103
|
+
printf ' UNPINNED %s: `uses: %s` carries no `@ref` at all\n' "$file" "$value" >&2
|
|
104
|
+
status=1
|
|
105
|
+
# Case-insensitive: a git object name is hex and hex is case-insensitive, so an uppercase or
|
|
106
|
+
# mixed-case SHA is a pinned SHA. Matching lowercase only would have reported a correctly
|
|
107
|
+
# pinned action as unpinned — a FALSE RED, which is the failure mode this project treats as
|
|
108
|
+
# worse than a missed catch, because it is the one that gets a check switched off.
|
|
109
|
+
elif printf '%s' "$ref" | grep -Eqi '^[0-9a-f]{40}$'; then
|
|
110
|
+
pinned=$((pinned + 1))
|
|
111
|
+
else
|
|
112
|
+
printf ' UNPINNED %s: `uses: %s` is pinned to `%s`, which is a tag or branch, not a commit\n' "$file" "$value" "$ref" >&2
|
|
113
|
+
status=1
|
|
114
|
+
fi
|
|
115
|
+
done < <(grep -nE '^[[:space:]]*-?[[:space:]]*uses:[[:space:]]*[^[:space:]]' "$file" | cut -d: -f2-)
|
|
116
|
+
done < <(printf '%s\n' "$files")
|
|
117
|
+
|
|
118
|
+
count=$(printf '%s\n' "$files" | wc -l | tr -d ' ')
|
|
119
|
+
|
|
120
|
+
if [ "$status" -eq 0 ]; then
|
|
121
|
+
printf 'actions-pinned: %s of %s `uses:` reference(s) pinned to a full commit SHA across %s workflow file(s)' \
|
|
122
|
+
"$pinned" "$examined" "$count"
|
|
123
|
+
if [ "$local_refs" -gt 0 ]; then
|
|
124
|
+
printf ', %s local or container reference(s) exempt' "$local_refs"
|
|
125
|
+
fi
|
|
126
|
+
printf '\n\nGREEN — verify recipe passed.\n'
|
|
127
|
+
else
|
|
128
|
+
printf '\nRED — at least one action is not pinned to a commit. A tag is a moving target: the code that\n' >&2
|
|
129
|
+
printf 'runs in this workflow tomorrow is whatever the tag points at then.\n' >&2
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
exit "$status"
|
package/spec/README.md
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
# spec/
|
|
2
|
+
|
|
3
|
+
The contracts between the engine and the layers above it. `core/` is mechanism and is identical for
|
|
4
|
+
everyone; a workspace is the half that is theirs, and a pack is the composable middle. This directory is
|
|
5
|
+
what makes both machine-readable instead of a folder convention.
|
|
6
|
+
|
|
7
|
+
- [`workspace.schema.json`](workspace.schema.json) — the **Workspace Definition**: the manifest at a
|
|
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
|
|
11
|
+
train** — see [Versioning](#versioning-and-migrations).
|
|
12
|
+
- [`slots.md`](slots.md) — every workspace slot: what it holds, why it exists, where it was derived from,
|
|
13
|
+
and what `doctor` checks about it. The rationale lives there; this file is the orientation.
|
|
14
|
+
|
|
15
|
+
## The manifest is an index, not a container
|
|
16
|
+
|
|
17
|
+
A workspace's content is Markdown, and it stays Markdown. The manifest names the slots and points at the
|
|
18
|
+
documents that hold each one — so the *why* stays next to the rule, where a human reads it, and the
|
|
19
|
+
machine gets a contract it can check. A manifest that absorbed the prose would put the product's actual
|
|
20
|
+
value in a file nobody enjoys reading.
|
|
21
|
+
|
|
22
|
+
That splits the slots in two, and the distinction is worth naming because it decides what `doctor` can
|
|
23
|
+
say about each:
|
|
24
|
+
|
|
25
|
+
- **Path slots** — `slots.*`, the workspace-level `affordances`, `tree`, `products[].product`,
|
|
26
|
+
`products[].affordances`, and `verify.recipes[].doc` — resolve to a file or a directory. `doctor` can
|
|
27
|
+
check they exist and that what they claim matches the tree. The list is exhaustive on purpose:
|
|
28
|
+
`doctor`'s existence checks are derived from it, so a path field missing here is a path field nothing
|
|
29
|
+
will ever check.
|
|
30
|
+
- **Structured slots** (`verify`, `products`, `packs`) are data in the manifest itself, because they are
|
|
31
|
+
consumed rather than read — the Stop-gate needs to know *which* recipe is the default, and no amount
|
|
32
|
+
of prose gives it that.
|
|
33
|
+
|
|
34
|
+
Two rules follow, and both were forced by real defects rather than chosen for tidiness:
|
|
35
|
+
|
|
36
|
+
1. **No `#fragment` targets.** A slot must be a whole file. This repository's own `links` check cannot
|
|
37
|
+
validate fragments ([`../.portulan/verify/README.md`](../.portulan/verify/README.md)), so a slot
|
|
38
|
+
addressed by anchor would be unlintable by construction — the slot would exist and nothing could ever
|
|
39
|
+
confirm it pointed anywhere real.
|
|
40
|
+
2. **A path slot may point outside the workspace directory.** Two slots commonly do, and neither is
|
|
41
|
+
required to. `constitution`, because a product's constitution normally lives with the product rather
|
|
42
|
+
than with the workspace — customer zero is the first proof: its constitution is `docs/vision.md`, one
|
|
43
|
+
level up. And `tree`, which names the repository a workspace makes claims *about*: usually `../`, for
|
|
44
|
+
a workspace directory sitting inside the repository it describes, but legitimately `./` when the
|
|
45
|
+
workspace root and the tree are the same directory. A schema that required containment would have
|
|
46
|
+
failed on its own author's workspace; one that *expected* escaping would be wrong about the other
|
|
47
|
+
arrangement. `doctor` reports an escape and never fails it, for both of them.
|
|
48
|
+
|
|
49
|
+
The schema cannot express *which* slot may escape — a `pattern` sees one string, not which key it
|
|
50
|
+
belongs to — so escaping is legal everywhere and `doctor` **reports** rather than fails when any slot
|
|
51
|
+
other than `constitution` does it. Reporting rather than failing is the right severity: a workspace
|
|
52
|
+
embedded in a larger repository may legitimately reach for a shared document, and a hard failure
|
|
53
|
+
would make the schema wrong about a case it cannot see.
|
|
54
|
+
|
|
55
|
+
## The four kinds — three that govern, and one that points
|
|
56
|
+
|
|
57
|
+
`kind` is required, and it is not bookkeeping. Three kinds **govern**: a **demo** workspace is written to
|
|
58
|
+
be read by strangers and must carry no real internal policy; a **repository** workspace is a team
|
|
59
|
+
dogfooding on their own product; a **portfolio** workspace covers many products at once. Confusing those
|
|
60
|
+
three produces the two failures that actually cost something: real internal policy published in a demo, or
|
|
61
|
+
a demo written as merely illustrative when it is the only complete worked example an evaluator will ever
|
|
62
|
+
read.
|
|
63
|
+
|
|
64
|
+
The fourth, **`pointer`**, added at 2.7, governs nothing. It names the workspace that governs this
|
|
65
|
+
repository and carries no slots of its own — a repository whose policy layer resides elsewhere says so in
|
|
66
|
+
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
|
|
68
|
+
([proposal 0017](../.portulan/proposals/0017-one-repository-one-governing-workspace.md)):
|
|
69
|
+
|
|
70
|
+
> A repository is governed by exactly one workspace. It carries its own full workspace, or a pointer to
|
|
71
|
+
> the workspace that names it — never both.
|
|
72
|
+
|
|
73
|
+
The two residences a customer may choose between — the workspace in the repository, or the workspace in a
|
|
74
|
+
portfolio that names the repository — deliver **full functionality either way**, and the mechanism that
|
|
75
|
+
makes that a property rather than a promise is that every feature keys to a workspace **slot** and never
|
|
76
|
+
to a residence. Nothing in `doctor`, `compile` or `index` asks where the manifest lives. The proposal
|
|
77
|
+
carries the one place where that was not yet true, and it was a discovery gap rather than a feature gap:
|
|
78
|
+
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
|
|
80
|
+
[`../cli/discover.mjs`](../cli/discover.mjs), which reads the host's installed-plugin record and
|
|
81
|
+
dereferences a pointer's `governed_by` — from disk, never over the network. The other half of the same amendment — the **pack** root
|
|
82
|
+
([#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
|
|
84
|
+
than the row's *"optional where discovery finds a root"* and was recorded as a narrowing rather than read
|
|
85
|
+
as compliance. **The narrowing is now lifted and the row's word is met**: discovery runs on the unasked
|
|
86
|
+
path, and `auto` selects the strict degrade rather than unlocking anything. The narrowing's stated reason
|
|
87
|
+
— that an unasked discovered root would make `.portulan/verify/doctor.sh` read the host on every run —
|
|
88
|
+
was answered by **pinning that recipe's root** rather than by keeping the default: a named root replaces
|
|
89
|
+
every other source, so the required checks cannot consult the host whatever the default is. Either way
|
|
90
|
+
the state is a workspace's, whichever residence it sits in, so it is not a residence asymmetry.
|
|
91
|
+
|
|
92
|
+
## Required versus optional
|
|
93
|
+
|
|
94
|
+
A slot is **required** only when a workspace without it breaks a promise the engine makes. That is the
|
|
95
|
+
whole test, and it is deliberately strict — a day-one workspace has no proposals, no handoffs, and no
|
|
96
|
+
memory yet, and it must still validate. Ceremony that cannot scale down is a binding non-goal.
|
|
97
|
+
|
|
98
|
+
Required of every manifest: `portulan.spec`, `name`, `kind`. Required **per form**, and stated in the
|
|
99
|
+
schema itself by the top-level `oneOf` added at 2.7: `slots` and `verify` of a governing workspace —
|
|
100
|
+
with `slots.identity`, `slots.principles` and `slots.gates` inside `slots` — and `governed_by` of a
|
|
101
|
+
`pointer`. Those live in the schema rather than in this list deliberately: the list below is the price
|
|
102
|
+
of the subset's narrowness, and a new constraint belongs in it only when the subset genuinely cannot
|
|
103
|
+
express the constraint.
|
|
104
|
+
|
|
105
|
+
Then the conditional requirements, none of which the schema can express (the subset has no
|
|
106
|
+
`dependentRequired`, so `doctor` enforces every one and [`slots.md`](slots.md) says so):
|
|
107
|
+
`tree` when `kind` is `repository`; `slots.memory` when `memory` is declared, since the object
|
|
108
|
+
configures a store rather than replacing one; `memory.index.path` resolving **outside**
|
|
109
|
+
`slots.memory`, since an index inside the store is counted as a record by `doctor`'s own store report;
|
|
110
|
+
`slots.memory` when `librarian` is declared, since a pass over nothing reports that nothing is stale;
|
|
111
|
+
`slots.proposals` when `librarian.staleness.proposal_days` is, since a threshold nothing can ever
|
|
112
|
+
cross reads as configured to anyone who greps for it; as of 2.5, the same two the memory index
|
|
113
|
+
carries, applied to the handoff series — `slots.handoffs` when `handoffs` is declared, and
|
|
114
|
+
`handoffs.index.path` resolving **outside** `slots.handoffs`, since a file sited in that directory is
|
|
115
|
+
either counted as a handoff by the `record` check's correspondence or failed by it for carrying no
|
|
116
|
+
date; and, as of 2.6, that same pair a third time over the per-persona scope layer — `slots.personas`
|
|
117
|
+
when `personas` is declared, and `personas.index.path` resolving **outside** `slots.personas`, since
|
|
118
|
+
`index`'s orphan sweep would examine a file sited there as an undeclared persona location.
|
|
119
|
+
Everything else is optional.
|
|
120
|
+
|
|
121
|
+
_The 2.6 pair was enforced in `doctor` from the day the keys landed and was missing from this list until
|
|
122
|
+
2.7 — the list said seven while the validator refused nine. Nothing was wrong in the mechanism; the prose
|
|
123
|
+
about it was wrong, which is the class [#133](https://github.com/sleepy-panda-srl/portulan/issues/133)
|
|
124
|
+
is filed about and the reason the count below is now read off `cli/doctor.mjs` rather than incremented by
|
|
125
|
+
hand. [`slots.md`](slots.md) carried the same fact and said **five**, stale by two bumps; both are
|
|
126
|
+
corrected in the change that added the pointer kind, because a fix that repairs one carrier and knowingly
|
|
127
|
+
leaves its sibling is the class [`0020`](../.portulan/proposals/0020-a-fix-is-not-done-at-the-site-it-was-found.md) names._
|
|
128
|
+
|
|
129
|
+
**The residence refusals are deliberately NOT in that list, and the distinction is the reason it can
|
|
130
|
+
still be trusted.** `doctor` refuses a pointer carrying governing slots, a governing workspace carrying a
|
|
131
|
+
`governed_by`, and — where a `--repo-root` makes it visible — a repository named by one workspace and
|
|
132
|
+
governed by another. The **first two** are expressible in the subset — a branch-level permit-list would
|
|
133
|
+
carry either — and they are enforced in `doctor` anyway, because the schema's own failures return at *the
|
|
134
|
+
manifest must conform first*, so a shape the schema refused would never print the sentence the refusal
|
|
135
|
+
exists to print. So for those two this is a choice about **where a rule is stated**, not a constraint the
|
|
136
|
+
schema cannot carry.
|
|
137
|
+
|
|
138
|
+
The **third is not expressible in any schema at all** — this subset, full 2020-12, or any other: it reads
|
|
139
|
+
a manifest in *another repository*, and no contract over a single document can see one. It still does not
|
|
140
|
+
join the list, and the reason is what keeps that list meaningful: every entry there is a dependency
|
|
141
|
+
between two keys **of one manifest**, which is the thing a reader of the schema alone would expect to find
|
|
142
|
+
and does not. A check that needs a second repository is not in that category, and folding it in would make
|
|
143
|
+
the count measure two different things at once. **The count stays at nine**, and it counts what it always
|
|
144
|
+
counted.
|
|
145
|
+
|
|
146
|
+
[`slots.md`](slots.md) argues each one.
|
|
147
|
+
|
|
148
|
+
_The opening of that paragraph used to carry the count. The number is gone rather than corrected, and
|
|
149
|
+
the list is now the only carrier: a sentence that hard-codes the figure it must maintain by hand is the
|
|
150
|
+
defect [#77](https://github.com/sleepy-panda-srl/portulan/issues/77) is filed about, one file over,
|
|
151
|
+
and adding to the list at 2.5 is exactly the edit that would have left it stale again — which is how
|
|
152
|
+
this one was noticed._
|
|
153
|
+
|
|
154
|
+
That the count went from one to three in a single MINOR, then to five, then to seven, and now stands at
|
|
155
|
+
**nine**, is worth noticing rather than absorbing. Each is a genuine dependency between two keys and none
|
|
156
|
+
can be written in the declared subset, so the gap between *what the schema says* and *what a conforming
|
|
157
|
+
manifest must satisfy* is widening — and a constraint invisible to someone reading the schema alone is a
|
|
158
|
+
real cost, stated here rather than discovered. The subset earns its narrowness by being implementable
|
|
159
|
+
completely and honestly; the price is this list, and the list is the thing to watch if it keeps growing.
|
|
160
|
+
**Four MINORs at +2 each** was the growth rate to hold the next bump against, and **2.3's, 2.5's and
|
|
161
|
+
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`.)_
|
|
166
|
+
|
|
167
|
+
**2.7 is the first MINOR since 2.2 that adds nothing to this list**, and that is the reason its pointer
|
|
168
|
+
requirement went into the schema's `oneOf` instead. The generalisation the paragraph above kept asking for
|
|
169
|
+
is still owed for the three index pairs; what 2.7 establishes is only that a new conditional requirement
|
|
170
|
+
is a last resort rather than the default shape.
|
|
171
|
+
|
|
172
|
+
**2.8 adds nothing to it either, and needed no argument to avoid doing so** — its one key sits inside
|
|
173
|
+
`memory`, whose `slots.memory` requirement is already on the list and already covers it. It does add one
|
|
174
|
+
more entry to the *other* running gap, the positive-integer refusals: `record_kilobytes` is the **fourth
|
|
175
|
+
budget** the subset can only type as `number`, checked in `cli/doctor.mjs` and `cli/index.mjs` for the
|
|
176
|
+
same reason as `lines`, `columns` and `kilobytes`. Counting the whole shape rather than just the budgets,
|
|
177
|
+
`doctor`'s hand-check now covers **seven** keys — those four plus `librarian.staleness`'s three — which is
|
|
178
|
+
the running number the paragraph above says to watch. **One keyword would not retire them.** `minimum`
|
|
179
|
+
closes `0` and the negatives and stops there: the subset cannot say `integer`, so `1.5` still passes and
|
|
180
|
+
the gap is **two** keywords wide. _(Not `"8"` — a string is already refused by `type: number`, which the
|
|
181
|
+
subset can say, and `doctor` returns at *the manifest must conform first* before its hand-check ever
|
|
182
|
+
runs. It is a live hole for `cli/index.mjs` alone, which validates against no schema by design and
|
|
183
|
+
therefore refuses the string itself.)_
|
|
184
|
+
|
|
185
|
+
_These figures are history rather than state: what 2.3 and 2.4 added cannot change, so they do not go
|
|
186
|
+
stale the way the removed count did. The one forward-looking sentence is the growth rate, and it is
|
|
187
|
+
dated by the version it names._
|
|
188
|
+
|
|
189
|
+
Note the distinction that does the work here: the *definition* carries a slot, while an *instance* may
|
|
190
|
+
leave it empty. "The Workspace Definition has a product-layer slot" and "every workspace must declare a
|
|
191
|
+
product" are different claims, and only the first is true.
|
|
192
|
+
|
|
193
|
+
## Versioning and migrations
|
|
194
|
+
|
|
195
|
+
**Two schemas, two version trains.** They are separate because they version different contracts, and one
|
|
196
|
+
number governing both would make a bump in either mean a change in the other:
|
|
197
|
+
|
|
198
|
+
| Schema | Manifest key | Current | What it governs |
|
|
199
|
+
|---|---|---|---|
|
|
200
|
+
| [`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 |
|
|
202
|
+
|
|
203
|
+
The rules below apply to each train independently. `portulan.spec` is `MAJOR.MINOR`, and the current
|
|
204
|
+
Workspace Definition version is **2.8**. It did **not** move when the Pack Definition arrived, because
|
|
205
|
+
`workspace.schema.json` was byte-identical across that change: `packs` already existed as an array of
|
|
206
|
+
strings and was deliberately left that way, since tightening its items to the canonical `category/name`
|
|
207
|
+
form would be a constraint an existing manifest could newly fail, which is a MAJOR.
|
|
208
|
+
|
|
209
|
+
**2.8 is a MINOR on the plainest terms in this train's history: one optional key and nothing else.**
|
|
210
|
+
`memory.store.budget.record_kilobytes` — the most bytes any single record may hold — joins the sibling
|
|
211
|
+
`kilobytes` it does not replace. Nothing is removed, renamed, tightened or defaulted, so every 2.7
|
|
212
|
+
manifest is a valid 2.8 manifest unchanged, and a workspace declaring neither key is checked on neither.
|
|
213
|
+
The argument for the key is [`slots.md`](slots.md)'s and proposal `0025`'s: an aggregate over
|
|
214
|
+
individually-authored records cannot see inside its units, which is the hole `columns` already closes
|
|
215
|
+
for `lines`, one level down.
|
|
216
|
+
|
|
217
|
+
**Measured across the bump rather than reasoned about:** `node cli/doctor.mjs .portulan examples` is
|
|
218
|
+
GREEN, with `examples/` still on **2.4 and untouched** — the same compatibility demonstration 2.7 leaned
|
|
219
|
+
on, and the reason `examples/` did *not* acquire the new key in the change that added it. A demo
|
|
220
|
+
workspace four MINORs behind, still validating and still exercising the older sibling rail, is worth
|
|
221
|
+
more as evidence than a second declaration of the newest key would be.
|
|
222
|
+
|
|
223
|
+
**What this bump costs.** `KNOWN_SPECS` in [`../cli/index.mjs`](../cli/index.mjs) and
|
|
224
|
+
[`../cli/librarian.mjs`](../cli/librarian.mjs) gains `"2.8"` **by addition** — both tools refuse a spec
|
|
225
|
+
outside that set with exit 2, so a replacement would have dropped support for every 2.7 manifest those
|
|
226
|
+
tools already write and read. The four constants that WRITE a spec version — `cli/init.mjs`,
|
|
227
|
+
`cli/new.mjs` (twice) and `cli/vendor.mjs` — deliberately stay at `2.7`, on the rule stated beside them
|
|
228
|
+
and demonstrated by the `GATE_POLICY_SPEC = "2.2"` sitting in the same file: a writer declares **the
|
|
229
|
+
version its output needs**, not the newest one, and nothing those tools scaffold declares a memory
|
|
230
|
+
budget at all. Bumping them would have made every scaffolded manifest claim a contract it does not use
|
|
231
|
+
and narrowed it against adopter tooling pinned at 2.7, which is the opposite of what an additive MINOR
|
|
232
|
+
is for.
|
|
233
|
+
|
|
234
|
+
**2.7 is a MINOR, and it is one on the definition rather than by assertion.** It adds a fourth `kind`
|
|
235
|
+
value and one optional key, `governed_by`, and it moves `slots` and `verify` out of the top-level
|
|
236
|
+
`required` into a `oneOf` branch that requires them of every governing kind. That last move is the one
|
|
237
|
+
that has to be argued rather than asserted, because relaxing a `required` list *looks* like a
|
|
238
|
+
weakening: it is not, because the branch re-imposes both on all three kinds that existed at 2.6, so no
|
|
239
|
+
manifest that was valid becomes invalid and no manifest that was invalid becomes valid. Measured rather
|
|
240
|
+
than reasoned — `node cli/doctor.mjs .portulan examples` is GREEN across the bump, with `examples/` still
|
|
241
|
+
on **2.4 and untouched**, which is the property a MINOR is supposed to have. No migration exists and none
|
|
242
|
+
is owed.
|
|
243
|
+
|
|
244
|
+
**What the bump costs, stated rather than discovered.** `kind` discriminates the two forms, so a manifest
|
|
245
|
+
whose `kind` is in neither enum now fails **twice**: once precisely, at `/kind`, and once at the root
|
|
246
|
+
saying it is neither a governing workspace nor a pointer. The second error is true and the first is
|
|
247
|
+
unchanged, but a reader who used to see one error sees two. The blast radius is exactly that case — an
|
|
248
|
+
unknown key, a `#fragment` slot and a bad path each still produce one error, because the forms constrain
|
|
249
|
+
only `kind`, `slots`, `verify` and `governed_by`. It is held there by a test rather than by this sentence.
|
|
250
|
+
|
|
251
|
+
_(Contrast the bump refused earlier in milestone 6, where the intent was 2.5 → 2.6 and nothing in the
|
|
252
|
+
schema had moved at all — the discipline is the same in both directions.)_
|
|
253
|
+
|
|
254
|
+
**2.6 was a MINOR on the same terms**: two optional keys, `slots.personas` and `personas`, tightening
|
|
255
|
+
nothing. The pair is what makes a pack-declared persona memory scope checkable rather than prose, and
|
|
256
|
+
[`slots.md`](slots.md) carries the argument for why the layer is **declared** rather than derived from
|
|
257
|
+
`slots.memory`.
|
|
258
|
+
|
|
259
|
+
_(This line read `2.0` for two MINOR bumps after the schema had moved past it — the version of the
|
|
260
|
+
spec, stated wrongly in the spec's own README, while the `$id` beside it was right. It is recorded
|
|
261
|
+
rather than quietly corrected because it is the exact defect class this project keeps finding: a fact
|
|
262
|
+
with two carriers, only one of which anything checks. Both live manifests declare their version and
|
|
263
|
+
`doctor` reads it, so nothing was broken by the wrong sentence; nothing would have caught it either.)_
|
|
264
|
+
|
|
265
|
+
- **MINOR** — additive only: new optional slots, relaxed constraints. An older manifest stays valid, and
|
|
266
|
+
`doctor` says so rather than staying silent about it.
|
|
267
|
+
- **MAJOR** — anything that can invalidate a manifest that used to pass: a new required slot, a removed
|
|
268
|
+
or renamed one, a tightened constraint. A MAJOR bump ships with a migration.
|
|
269
|
+
|
|
270
|
+
`doctor` reads `portulan.spec` before it validates anything. A manifest naming a MAJOR it does not
|
|
271
|
+
implement, or a MINOR ahead of it, is **refused** — exit `2` — rather than graded against the version it
|
|
272
|
+
happens to carry, because grading a manifest against a contract it was not written for produces confident
|
|
273
|
+
nonsense: a slot added in a later MINOR comes back as an unexpected property, and the report blames the
|
|
274
|
+
author for using the spec correctly.
|
|
275
|
+
|
|
276
|
+
## Migration 1.0 → 2.0
|
|
277
|
+
|
|
278
|
+
The only migration so far, and it is deliberately taken while it costs nothing.
|
|
279
|
+
|
|
280
|
+
**What changed.** The `tree` slot was added, and a workspace whose `kind` is `repository` must now declare
|
|
281
|
+
it. `demo` and `portfolio` may omit it. Nothing else.
|
|
282
|
+
|
|
283
|
+
**How to migrate.** A `repository` workspace adds one line — `"tree": "../"` for the common case, a
|
|
284
|
+
workspace directory sitting one level inside the repository it describes — and sets `portulan.spec` to
|
|
285
|
+
`2.0`. A `demo` or `portfolio` workspace changes the version and nothing else.
|
|
286
|
+
|
|
287
|
+
**Why a MAJOR for one constraint, now rather than later.** `tree` began as an optional slot, and optional
|
|
288
|
+
was a hole: deleting one manifest line degraded the entire claims-lint class from *checked* to *reported*,
|
|
289
|
+
GREEN, exit `0`. That is a fail-open in gate machinery, defended by "review would notice" — on the one
|
|
290
|
+
edit review is worst at noticing, a removed line. The trade was priced at the only moment it is cheap: two
|
|
291
|
+
manifests exist, one already declared `tree` and the other is a `demo` and exempt, so the migration was a
|
|
292
|
+
version bump and a note with **zero manifest edits**. Every milestone toward public makes a MAJOR strictly
|
|
293
|
+
more expensive, and deferring would have carried the hole across the public flip — the window when
|
|
294
|
+
outside readers first probe this spec. It also exercises the migration path while the blast radius is
|
|
295
|
+
zero, which is the same reasoning that made the demo workspace worth building: a migration whose first run
|
|
296
|
+
is on a real adopter is a claimed capability, not a demonstrated one.
|
|
297
|
+
_(Adopted from [proposal 0005](../.portulan/proposals/0005-a-repository-workspace-must-declare-its-tree.md).
|
|
298
|
+
`kind` is still self-declared, so the escape narrows from *omit a line* to *lie about what you are* —
|
|
299
|
+
better, and not a fix.)_
|
|
300
|
+
|
|
301
|
+
This migration is a paragraph and a version bump, and a directory holding a document that says "add one
|
|
302
|
+
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
|
|
304
|
+
one did, on that stated condition rather than because a tool wanted a home.
|
|
305
|
+
|
|
306
|
+
**[`migrations/`](migrations/) is now the contract**, and its README is where a step is defined. What
|
|
307
|
+
needed code was not this migration: it was the **second kind of step** the maintainer admitted on
|
|
308
|
+
2026-08-12 — a **repair**, something a rewriter owes a workspace it touched, independent of any version.
|
|
309
|
+
Re-deriving a machine-local path cannot be a paragraph. Without it the directory would have held this
|
|
310
|
+
one migration and nothing else, with **no subject in this repository and none in any tree we have
|
|
311
|
+
seen** — nothing here declares 1.0 — which is precisely the scaffolding the older sentence refused.
|
|
312
|
+
|
|
313
|
+
The runner is [`../cli/upgrade.mjs`](../cli/upgrade.mjs), and `doctor`'s MAJOR refusal now names it:
|
|
314
|
+
the sentence *"a migration exists and this validator is not the one to run"* pointed at a remedy that
|
|
315
|
+
did not exist for two milestones.
|
|
316
|
+
|
|
317
|
+
**Nothing there restamps a MINOR**, which is this section's own rule kept rather than quietly widened:
|
|
318
|
+
a manifest declares the version its content needs, not the newest one. `examples/` stays at 2.4, and
|
|
319
|
+
`upgrade --check` reporting it as owing nothing is pinned by a test.
|
|
320
|
+
|
|
321
|
+
The schema sets `additionalProperties: false` throughout. Unknown keys fail rather than being ignored,
|
|
322
|
+
because the common case is a typo in a slot name, and a silently-ignored `principals` would leave a
|
|
323
|
+
workspace with no constitution slot and a green report.
|
|
324
|
+
|
|
325
|
+
## What actually validates any of this, today
|
|
326
|
+
|
|
327
|
+
| Artifact | Checked by | Status |
|
|
328
|
+
|---|---|---|
|
|
329
|
+
| Every `.json` file parses | [`../.portulan/verify/json.sh`](../.portulan/verify/json.sh) | **Built.** Well-formedness only — it does not read the schema. |
|
|
330
|
+
| 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`. |
|
|
336
|
+
| Path slots resolve to real files | `doctor` | **Built.** File-versus-directory too; a slot resolving outside the workspace is reported, never failed. |
|
|
337
|
+
| 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
|
+
| A rule's provenance is well-formed | `doctor` | **Built.** On `type: rule` records in the `memory` slot; the form only, never the truth. |
|
|
339
|
+
| 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. |
|
|
343
|
+
| 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. |
|
|
346
|
+
| 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
|
+
| 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. |
|
|
349
|
+
|
|
350
|
+
As of milestone 2 the schema is a rail rather than only a specification — but read the right-hand column
|
|
351
|
+
rather than the middle one. `doctor` checks that a workspace's claims are **well-formed and resolvable**;
|
|
352
|
+
almost nothing it checks is a claim about whether the workspace's content is *true*. It is run by CI on
|
|
353
|
+
every pull request here, because [`../.portulan/workspace.json`](../.portulan/workspace.json) declares it
|
|
354
|
+
as a verify recipe and the workflow runs every recipe the manifest **yields** — which since milestone
|
|
355
|
+
7's composition amendment is the recipes the workspace declares plus those the packs it composes
|
|
356
|
+
contribute, namespaced by pack. *Declares* was the right word until that landed; the set is computed by
|
|
357
|
+
[`../cli/recipe-set.mjs`](../cli/recipe-set.mjs) now, and CI calls it rather than enumerating the
|
|
358
|
+
manifest itself.
|
|
359
|
+
|
|
360
|
+
## The JSON Schema subset
|
|
361
|
+
|
|
362
|
+
`doctor` will carry its own validator rather than a dependency, so the schema is written in a subset
|
|
363
|
+
small enough to implement completely and honestly. **Full JSON Schema 2020-12 is not supported and will
|
|
364
|
+
not be claimed.** The subset is exactly:
|
|
365
|
+
|
|
366
|
+
`$schema` · `$id` · `$defs` · `$ref` (local, `#/$defs/…` only) · `title` · `description` · `type` ·
|
|
367
|
+
`properties` · `required` · `additionalProperties: false` · `items` · `enum` · `pattern` · `minLength` ·
|
|
368
|
+
`minItems` · `uniqueItems` · `oneOf`
|
|
369
|
+
|
|
370
|
+
Two notes a validator author needs. **`$ref` may carry sibling `description` and `title`** — used where a
|
|
371
|
+
field reuses a shared definition but deserves its own prose, as `name` and `verify.default` both do with
|
|
372
|
+
`$defs/slug`; siblings are annotations and never affect validation. And **every id-shaped field routes
|
|
373
|
+
through `$defs/slug`** rather than repeating a pattern, so the field that *declares* an id and the field
|
|
374
|
+
that *points at* one cannot drift apart — `verify.default` originally carried only a length constraint,
|
|
375
|
+
which let a manifest name a default no recipe could ever match.
|
|
376
|
+
|
|
377
|
+
A schema change that reaches outside this list is a change to `doctor` too, and the two land together.
|
|
378
|
+
`$id` is an identifier, not a live endpoint — nothing is served at that URL until the docs site in
|
|
379
|
+
milestone 10.
|
|
380
|
+
|
|
381
|
+
**Formats are JSON across the product**, not only here: machine inputs are JSON, and the *why* that would
|
|
382
|
+
otherwise live in comments belongs in the Markdown the manifest points at. That is a maintainer decision
|
|
383
|
+
of 2026-07-25 covering the enforcement compiler's input too.
|
|
384
|
+
|
|
385
|
+
## Reading an instance
|
|
386
|
+
|
|
387
|
+
The first real instance is customer zero's own manifest,
|
|
388
|
+
[`../.portulan/workspace.json`](../.portulan/workspace.json) — the material this schema was derived from,
|
|
389
|
+
rather than an example written to flatter it.
|
|
390
|
+
|
|
391
|
+
The **second** is [`../examples/`](../examples/), a fictional workspace covering two products, and it is
|
|
392
|
+
the one that actually tested the schema: a definition derived from a single sample has not been tested
|
|
393
|
+
until it meets a differently-shaped one. It exercises what customer zero cannot — repeated products,
|
|
394
|
+
affordances resolving down the cascade, declared packs, a workspace-level default, and a sealed
|
|
395
|
+
provenance stamp — and it is where the `tree` slot came from, because writing a workspace whose
|
|
396
|
+
repositories are *not present* is what exposed that the claims lint had no way to say so.
|
|
397
|
+
|
|
398
|
+
Illustrative fragments in [`slots.md`](slots.md) use `{braces}` for placeholders, the same convention as
|
|
399
|
+
[`../core/templates/`](../core/templates/).
|