@sleepy_panda_srl/portulan 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +5 -0
  3. package/README.md +87 -0
  4. package/cli/README.md +337 -0
  5. package/cli/compile.mjs +2209 -0
  6. package/cli/control-chars.mjs +570 -0
  7. package/cli/discover.mjs +985 -0
  8. package/cli/doctor.mjs +2803 -0
  9. package/cli/feedback.mjs +970 -0
  10. package/cli/gate.mjs +271 -0
  11. package/cli/index.mjs +1390 -0
  12. package/cli/init.mjs +1729 -0
  13. package/cli/librarian.mjs +1384 -0
  14. package/cli/manifest.mjs +59 -0
  15. package/cli/new.mjs +803 -0
  16. package/cli/pack-version.mjs +543 -0
  17. package/cli/plugin-lint.mjs +1566 -0
  18. package/cli/portulan.mjs +266 -0
  19. package/cli/recipe-set.mjs +425 -0
  20. package/cli/rule-carriers.mjs +489 -0
  21. package/cli/skills-set.mjs +715 -0
  22. package/cli/stop-gate.mjs +434 -0
  23. package/cli/upgrade.mjs +897 -0
  24. package/cli/vendor.mjs +1504 -0
  25. package/core/README.md +46 -0
  26. package/core/engine.md +47 -0
  27. package/core/operating/autonomy.md +100 -0
  28. package/core/operating/evolution.md +84 -0
  29. package/core/operating/loop.md +83 -0
  30. package/core/operating/memory.md +161 -0
  31. package/core/operating/safety.md +42 -0
  32. package/core/operating/verification.md +76 -0
  33. package/core/personas/README.md +40 -0
  34. package/core/personas/implementer.md +51 -0
  35. package/core/personas/librarian.md +73 -0
  36. package/core/personas/reviewer.md +52 -0
  37. package/core/skills/README.md +52 -0
  38. package/core/skills/clarify/SKILL.md +42 -0
  39. package/core/skills/codify/SKILL.md +65 -0
  40. package/core/skills/consolidate/SKILL.md +121 -0
  41. package/core/templates/gate-policy.md +76 -0
  42. package/core/templates/handoff.md +29 -0
  43. package/core/templates/memory-entry.md +44 -0
  44. package/core/templates/pack.md +94 -0
  45. package/core/templates/persona.md +69 -0
  46. package/core/templates/proposal.md +27 -0
  47. package/core/templates/repo-card.md +28 -0
  48. package/core/templates/skill.md +57 -0
  49. package/core/templates/task.md +26 -0
  50. package/core/templates/workspace.md +81 -0
  51. package/package.json +41 -0
  52. package/packs/.claude-plugin/plugin.json +22 -0
  53. package/packs/README.md +61 -0
  54. package/packs/rituals/README.md +13 -0
  55. package/packs/rituals/checkpoints/README.md +105 -0
  56. package/packs/rituals/checkpoints/pack.json +36 -0
  57. package/packs/rituals/checkpoints/personas/supervisor.md +64 -0
  58. package/packs/rituals/checkpoints/skills/milestone-close/SKILL.md +75 -0
  59. package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +75 -0
  60. package/packs/rituals/checkpoints/skills/session-open/SKILL.md +57 -0
  61. package/packs/stacks/README.md +8 -0
  62. package/packs/tools/README.md +17 -0
  63. package/packs/tools/github/README.md +65 -0
  64. package/packs/tools/github/pack.json +29 -0
  65. package/packs/tools/github/verify/README.md +51 -0
  66. package/packs/tools/github/verify/actions-pinned.sh +132 -0
  67. package/spec/README.md +399 -0
  68. package/spec/migrations/0001-repository-declares-its-tree.mjs +100 -0
  69. package/spec/migrations/0002-bundle-fallback-path.mjs +179 -0
  70. package/spec/migrations/README.md +95 -0
  71. package/spec/pack.schema.json +201 -0
  72. package/spec/slots.md +796 -0
  73. package/spec/workspace.schema.json +438 -0
@@ -0,0 +1,438 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://portulan.dev/spec/2.8/workspace.schema.json",
4
+ "title": "Portulan Workspace Definition v2.8",
5
+ "description": "The manifest at the root of a workspace. It is an index, not a container: it names the slots the engine resolves against and points at the Markdown that holds each slot's content and rationale. Written in a deliberately small subset of JSON Schema 2020-12 — see spec/README.md for the subset and for what validates it.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["portulan", "name", "kind"],
9
+
10
+ "oneOf": [
11
+ {
12
+ "description": "A GOVERNING workspace — the ordinary form, and the only one that existed before 2.7. It carries the team's policy layer, so `slots` and `verify` are required of it. They sit here rather than in the top-level `required` because a pointer is a manifest too and carries neither; stating the requirement per form keeps it IN the schema, where a third-party validator reading the published contract can see it, rather than moving it into `doctor` as another constraint the schema cannot express (spec/README.md keeps the running count of those, and says the growing gap is the thing to watch — an ordinal here would be a second carrier of that number, which is the drift this bump is partly repairing).",
13
+ "properties": {
14
+ "kind": { "enum": ["repository", "demo", "portfolio"] }
15
+ },
16
+ "required": ["slots", "verify"]
17
+ },
18
+ {
19
+ "description": "A POINTER — added at 2.7 with the residence ruling of 2026-07-30 (.portulan/proposals/0017). Its INTENT is a thin manifest naming the workspace that governs this repository and carrying no policy layer of its own. What this branch actually constrains is narrower than that intent, deliberately, and the gap is stated rather than glossed: the branch requires `governed_by` and adds no prohibition of its own. Unknown keys are still refused — by the root's `additionalProperties: false`, not by anything here — and every key the root declares is still validated. What nothing in this schema refuses is a pointer carrying the GOVERNING keys `slots` and `verify`, which are declared and legal at the root. That shape is schema-VALID and is refused by `doctor` instead, in the ruling's own sentence, which is the point: a branch that failed it would make `doctor` return at `the manifest must conform first`, and the sentence the refusal exists to print would never print. `doctor` holds the permit-list: `portulan`, `name`, `kind`, `governed_by`, and an optional `summary`.",
20
+ "properties": {
21
+ "kind": { "enum": ["pointer"] }
22
+ },
23
+ "required": ["governed_by"]
24
+ }
25
+ ],
26
+
27
+ "properties": {
28
+ "portulan": {
29
+ "description": "Version block. `spec` is the Workspace Definition version this manifest is written against; the engine uses it to decide whether a migration is needed.",
30
+ "type": "object",
31
+ "additionalProperties": false,
32
+ "required": ["spec"],
33
+ "properties": {
34
+ "spec": {
35
+ "description": "Workspace Definition version, MAJOR.MINOR. A MAJOR bump means a migration exists.",
36
+ "type": "string",
37
+ "pattern": "^[0-9]+\\.[0-9]+$"
38
+ },
39
+ "workspace": {
40
+ "description": "Optional version of this workspace's own content, SemVer. Lets a workspace shipped as a plugin be pinned by consumers.",
41
+ "type": "string",
42
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
43
+ }
44
+ }
45
+ },
46
+
47
+ "name": {
48
+ "description": "Stable identifier for this workspace — used as a plugin name when the workspace ships through a feed.",
49
+ "$ref": "#/$defs/slug"
50
+ },
51
+
52
+ "summary": {
53
+ "description": "One line an agent can read before loading anything else.",
54
+ "type": "string",
55
+ "minLength": 1
56
+ },
57
+
58
+ "kind": {
59
+ "description": "Which of the four workspace kinds this is. Three of them GOVERN and the distinction between those is load-bearing: a demo workspace is written to be read by strangers and must carry no real internal policy, while a repository or portfolio workspace carries exactly that. The fourth, `pointer`, added at 2.7, governs nothing — it names the workspace that governs this repository and carries no slots of its own. That is the residence ruling of 2026-07-30 in the schema: a repository is governed by exactly one workspace, so a repository whose workspace resides elsewhere says so in one thin manifest rather than keeping a second copy nothing holds in agreement (.portulan/proposals/0017).",
60
+ "enum": ["repository", "demo", "portfolio", "pointer"]
61
+ },
62
+
63
+ "governed_by": {
64
+ "description": "Optional, added in 2.7. REQUIRED of a `pointer` — the sibling `oneOf` enforces that — and MEANINGFUL only there, but not forbidden elsewhere by this schema: a governing kind carrying it validates here and is refused by `doctor`, because a workspace that carries a full policy layer AND names another as its governor is the dual management the ruling refuses, and that refusal has to reach a reader as a sentence rather than as a schema error. It names the governing workspace by its `name`, and, where the workspace is delivered through a private feed rather than checked out beside this repository, the feed it ships through. Nothing here is dereferenced BY THE SCHEMA, which validates shape and resolves nothing. Resolving a pointer to the workspace it names needs a host's plugin cache, which is discovery — owned by milestone 7's row as of the 2026-08-03 amendment, and BUILT there (cli/discover.mjs): the host's installed-plugin record is read from disk, the match is on the governing manifest's own `name` with `feed` constraining the marketplace, and the four answers are resolved / not-installed / ambiguous / could-not-look. Still never fetched — nothing on that path touches the network — and still never graded: `doctor` reports where the workspace is and no discovery outcome moves its verdict, because a pointer whose governor is uninstalled is a correct pointer.",
65
+ "type": "object",
66
+ "additionalProperties": false,
67
+ "required": ["workspace"],
68
+ "properties": {
69
+ "workspace": {
70
+ "description": "The `name` of the workspace that governs this repository. A slug, on the same definition the governing workspace's own `name` uses, so a pointer and its target cannot drift into different spellings of one identifier.",
71
+ "$ref": "#/$defs/slug"
72
+ },
73
+ "feed": {
74
+ "description": "Optional. Where that workspace is delivered from — a private marketplace, by the name a host installs it under. Absent means the pointer names a workspace without saying where to get it, which is a legitimate state for a workspace checked out beside this repository and is why this is not required. A blank or all-whitespace value means the same as absent to discovery: `minLength: 1` accepts `\" \"`, which constrained every candidate to a marketplace of that name and answered `not-installed` about a workspace that was installed, so a resolver reads blank as unconstrained rather than as a constraint nothing can satisfy.",
75
+ "type": "string",
76
+ "minLength": 1
77
+ }
78
+ }
79
+ },
80
+
81
+ "slots": {
82
+ "description": "Path slots. Every value resolves to a file or a directory relative to the workspace root. Fragments are not permitted: a slot addressed by `#anchor` cannot be checked, so a slot's content must be a whole file. The pattern alone cannot say WHICH slot may leave the workspace directory — of the slots here only `constitution` is expected to (the sibling `tree` is the other, outside this object) — and spec/slots.md has `doctor` report (never fail) any other slot that does.",
83
+ "type": "object",
84
+ "additionalProperties": false,
85
+ "required": ["identity", "principles", "gates"],
86
+ "properties": {
87
+ "identity": {
88
+ "description": "Who this team is, the stack, and the glossary. The first thing an agent reads after the kernel.",
89
+ "$ref": "#/$defs/filePath"
90
+ },
91
+ "principles": {
92
+ "description": "The team's own binding principles — the constitution slot. What makes an agent work THIS team's way rather than generically.",
93
+ "$ref": "#/$defs/filePath"
94
+ },
95
+ "constitution": {
96
+ "description": "Optional. A separate document this team's work is graded against, when one exists apart from `principles`. This is the slot permitted to point outside the workspace directory, because a product's constitution usually lives with the product rather than with the workspace.",
97
+ "$ref": "#/$defs/path"
98
+ },
99
+ "gates": {
100
+ "description": "The gate map: this team's concrete actions bound to the engine's Auto / Propose / Gated tiers, plus the triage threshold.",
101
+ "$ref": "#/$defs/filePath"
102
+ },
103
+ "dod": {
104
+ "description": "Optional. The workspace's extension of core's definition-of-done floor. Absent means core's floor stands alone, which is valid.",
105
+ "$ref": "#/$defs/filePath"
106
+ },
107
+ "memory": {
108
+ "description": "Optional. Directory of durable facts, one per file, each carrying provenance.",
109
+ "$ref": "#/$defs/dirPath"
110
+ },
111
+ "repos": {
112
+ "description": "Optional. Directory of repo cards, one per repository this workspace covers.",
113
+ "$ref": "#/$defs/dirPath"
114
+ },
115
+ "tasks": {
116
+ "description": "Optional. Directory of task files.",
117
+ "$ref": "#/$defs/dirPath"
118
+ },
119
+ "handoffs": {
120
+ "description": "Optional. Directory of dated handoffs, filenames leading with an ISO date.",
121
+ "$ref": "#/$defs/dirPath"
122
+ },
123
+ "proposals": {
124
+ "description": "Optional. Directory of agent-drafted rule changes awaiting the human gate.",
125
+ "$ref": "#/$defs/dirPath"
126
+ },
127
+ "personas": {
128
+ "description": "Optional, added in 2.6. The workspace's own per-persona layer: one directory per persona whose memory scope a composed pack declares. It is NOT where personas are defined — those live in `core/personas/` and in the packs — it is where the memory each of them scopes lands, in the layer its owner controls (docs/vision.md thesis 6: storage follows ownership). Declared rather than derived from `slots.memory`, and both halves of that are deliberate. Not derived, because a path this specification computed would be Portulan choosing a location inside every adopter's workspace in a key nobody typed — the same reasoning that leaves every memory budget undefaulted. And not a subdirectory of the store, because `core/operating/memory.md` holds per-agent memory apart precisely so a reviewer's recall does not spend the implementer's budget, and a nested store would be counted by the store's own KB rail and invisible to the walk that counts it (issue #76). Each directory is **empty until earned**: the pack declares the scope and carries none of its contents.",
129
+ "$ref": "#/$defs/dirPath"
130
+ }
131
+ }
132
+ },
133
+
134
+ "verify": {
135
+ "description": "The executable half of done. A workspace declares its recipes and names the default; a repo card may override it and a task may specialize it.",
136
+ "type": "object",
137
+ "additionalProperties": false,
138
+ "required": ["default", "recipes"],
139
+ "properties": {
140
+ "default": {
141
+ "description": "The `id` of the recipe the Stop-gate runs when nothing more specific applies. Constrained to the same shape as an id, so a value no recipe could ever carry is rejected by the schema rather than only by `doctor`.",
142
+ "$ref": "#/$defs/slug"
143
+ },
144
+ "recipes": {
145
+ "type": "array",
146
+ "minItems": 1,
147
+ "items": { "$ref": "#/$defs/recipe" }
148
+ }
149
+ }
150
+ },
151
+
152
+ "products": {
153
+ "description": "Optional. The product layer, repeated: a workspace may cover many products — a portfolio workspace covers all of a studio's — so mission/what/why and agent affordances belong to a product, not to the workspace. A workspace need not declare products at all; one that does gives an entry per product, including when there is only one.",
154
+ "type": "array",
155
+ "items": { "$ref": "#/$defs/product" }
156
+ },
157
+
158
+ "affordances": {
159
+ "description": "Optional workspace-level default for agent affordances, inherited by any product that does not declare its own. The same more-specific-wins rule as the resolution cascade, applied inside the workspace.",
160
+ "$ref": "#/$defs/filePath"
161
+ },
162
+
163
+ "tree": {
164
+ "description": "Optional. Where the repository tree this workspace makes claims ABOUT begins, relative to the workspace root — for a workspace that is the policy layer of the repository containing it, `../`. Present means `doctor` lints repo-card build/test/run and layout claims, and the gate map's required-check claim, against that tree. Absent means the workspace describes repositories not present beside it — a demo, or a portfolio spanning many — and those claims are REPORTED unverifiable rather than checked. Declared rather than inferred from `kind`: `kind` is self-declared, and a whole check class silently disabled by a self-declared field is a fail-open with a doorbell; and a portfolio workspace is not a demo yet has no single tree either, so kind-dispatch would break on its second case.",
165
+ "$ref": "#/$defs/dirPath"
166
+ },
167
+
168
+ "gates": {
169
+ "description": "Optional, added in 2.1. The gate POLICY — a path to a JSON file the enforcement compiler reads, distinct from `slots.gates`, which is the Markdown that argues it. Two keys rather than one because they answer different questions and only one of them compiles: `slots.gates` is prose a human reads and no tool can dispatch on, while this file binds action patterns to tiers in a host-neutral vocabulary. A workspace may declare either, both, or neither — but a workspace with prose and no policy has a gate map nothing enforces, which is the state this repository was in for three milestones and named honestly while it lasted.",
170
+ "$ref": "#/$defs/filePath"
171
+ },
172
+
173
+ "packs": {
174
+ "description": "Optional. The packs this workspace composes, in cascade order — core < pack < workspace. Named here so a workspace can state which stack, tool, and ritual layers it depends on rather than leaving it to be inferred.",
175
+ "type": "array",
176
+ "uniqueItems": true,
177
+ "items": {
178
+ "type": "string",
179
+ "minLength": 1
180
+ }
181
+ },
182
+
183
+ "memory": {
184
+ "description": "Optional, added in 2.3. What the memory store's generated index is called and what memory may cost — distinct from `slots.memory`, which is the store itself, for the same reason `gates` is distinct from `slots.gates`: one is content, this is machine configuration. A workspace may declare a store and no budget, which is every workspace that existed before 2.3 and is a legitimate shape; what it may not do is declare this object without a store, since there would be nothing to index. That conditional requirement is `doctor`'s, not the schema's — the declared subset has no `dependentRequired`, exactly as with `tree` when `kind` is `repository`.",
185
+ "type": "object",
186
+ "additionalProperties": false,
187
+ "properties": {
188
+ "index": {
189
+ "description": "The generated index: the layer loaded to decide what else to load. It is built by `cli/index.mjs` and byte-compared by a verify recipe, so nothing in it is hand-maintained. Its path must resolve OUTSIDE `slots.memory` — an index living in the store is counted as a record by `doctor`'s store report, and `index` refuses that siting rather than teaching one filename to hide from a walk.",
190
+ "type": "object",
191
+ "additionalProperties": false,
192
+ "required": ["path"],
193
+ "properties": {
194
+ "path": {
195
+ "description": "Where the generated index is written, relative to the workspace root.",
196
+ "$ref": "#/$defs/filePath"
197
+ },
198
+ "budget": {
199
+ "description": "Optional. A rail, not an aim — and never repaired by a raise in the same change (core/operating/memory.md). WHICH remedy applies depends on which of the two keys fired, and they do not share one. `lines` is a count, so it is repaired by consolidation that removes a record: MERGE two that are one fact, or RETIRE one whose condition has fired. Compressing a record does not remove its line, and SPLITTING one ADDS a line here — split spends this budget rather than repairing it, to buy room under `memory.store.budget.record_kilobytes`, which is the trade the two axes exist to make visible. `columns` is not a consolidation matter at all: one over-long line is repaired by RENAMING the record, and nothing is merged, split or retired (core/skills/consolidate/SKILL.md says the same, and says it is not that pass's work). Nothing here is defaulted; an undeclared number is not checked, because a default would be this specification setting a policy for every workspace that adopts it, in a key nobody typed.",
200
+ "type": "object",
201
+ "additionalProperties": false,
202
+ "properties": {
203
+ "lines": {
204
+ "description": "The most lines the generated index may hold. With one line per record this is a rail on record COUNT, which is the cost memory imposes on every recall. Must be a POSITIVE INTEGER: the subset has no `minimum`, so `type: number` is the strongest constraint expressible here and `cli/index.mjs` refuses zero, negative and non-integer values with exit 2 rather than reading them as undeclared — a budget of `0` is falsy, and would otherwise switch the rail off in the key that exists to switch it on.",
205
+ "type": "number"
206
+ },
207
+ "columns": {
208
+ "description": "The most columns any one index line may hold. Closes the hole a line budget has — one enormous line absorbing what the budget counts — and is refused rather than truncated, since a silently shortened title is a generated file disagreeing with its source. A positive integer, on the same terms as `lines`.",
209
+ "type": "number"
210
+ }
211
+ }
212
+ }
213
+ }
214
+ },
215
+ "store": {
216
+ "description": "Optional. Budgets on the store itself rather than on its index. The axis an index cannot see: record count can hold still while the records grow. Two forms since 2.8 — `kilobytes` over the whole store, and `record_kilobytes` over any one record — because an aggregate cannot see inside its units any more than a line count can see inside a line.",
217
+ "type": "object",
218
+ "additionalProperties": false,
219
+ "properties": {
220
+ "budget": {
221
+ "description": "Optional, and not defaulted, on the same reasoning as the index's.",
222
+ "type": "object",
223
+ "additionalProperties": false,
224
+ "properties": {
225
+ "kilobytes": {
226
+ "description": "The most kilobytes (1024 bytes) the store's records may total — the same measure `doctor`'s retirement report already prints, so the report and the rail cannot disagree about what was measured. A positive integer, on the same terms as `lines`. An AGGREGATE over individually-authored records, which is a commons: the record that grew large never feels this rail at write time, and the next unrelated writer hits it instead. `record_kilobytes` is the per-unit form and is what this repository's own workspace declares as of 2.8; both stay legal, and a workspace may declare either, both, or neither.",
227
+ "type": "number"
228
+ },
229
+ "record_kilobytes": {
230
+ "description": "Optional, added in 2.8. The most kilobytes (1024 bytes) any ONE record may hold. This is to `kilobytes` what `memory.index.budget.columns` is to `memory.index.budget.lines`: an aggregate cannot see inside its units, so one enormous record absorbs what a store budget counts, exactly as one enormous line absorbs what a line budget counts. Breaches are LOCAL — record X over its cap never blocks writer Y — and the repair menu is live rather than barred: SPLIT the record where it holds more than one fact (which spends the count axis, `memory.index.budget.lines`), COMPRESS it, or DEMOTE its narrative to the provenance layer. Never a raise in the change that breached it (core/operating/memory.md). A positive integer, on the same terms as `lines`.",
231
+ "type": "number"
232
+ }
233
+ }
234
+ }
235
+ }
236
+ }
237
+ }
238
+ },
239
+
240
+ "handoffs": {
241
+ "description": "Optional, added in 2.5. What the handoff series' generated index is called — the same split as `memory` above, and for the same reason: `slots.handoffs` is the series, this is the machine configuration over it. One key and no more. There is deliberately NO budget here, and the absence is the considered half: a budget's only permitted remedy is consolidation — merge, compress, split, retire (core/operating/memory.md) — and a handoff series is append-only by construction, one per session, held to the Session log by a verify recipe's count-based correspondence. Retiring a handoff to buy headroom would either red that check or destroy the record it exists to keep; SPLITTING one — the move 2.8 added for the per-record rail — would put two dated handoffs against a single session and red the same correspondence from the other direction; and raising the budget in the change that breached it is the one repair that doctrine rules out. Every remedy such a budget could ask for is barred, which makes it a rail designed to be broken. Whether the series should be budgeted on some other axis is a maintainer's question, deferred rather than answered here — and a key that exists but must never be set would be this specification pre-empting that answer in the shape of an invitation.",
242
+ "type": "object",
243
+ "additionalProperties": false,
244
+ "required": ["index"],
245
+ "properties": {
246
+ "index": {
247
+ "description": "Required whenever `handoffs` is present, which is the one way this object differs from `memory` above. There, an object with no `index` is coherent — a workspace may rail its store's size and generate nothing. Here the object has exactly one key and no budget, so `handoffs: {}` configures nothing at all: a no-op that reads as configured to anyone who greps for it, which is the same failure `librarian.staleness.proposal_days` without `slots.proposals` is refused for. The generated index over the handoff series. Built by `cli/index.mjs` and byte-compared by a verify recipe on the same terms as the memory index: every field on a line is derived from the series — the ISO date off the filename, the title off the record's own H1 — so nothing in the file can be edited into disagreement with what it indexes. Its path must resolve OUTSIDE `slots.handoffs`: a file in that directory whose name carries no date already fails the `record` check, and one that did carry a date would be counted as a handoff by the correspondence check. Sited beside the series, both stay true without either check learning a filename to ignore.",
248
+ "type": "object",
249
+ "additionalProperties": false,
250
+ "required": ["path"],
251
+ "properties": {
252
+ "path": {
253
+ "description": "Where the generated index is written, relative to the workspace root.",
254
+ "$ref": "#/$defs/filePath"
255
+ }
256
+ }
257
+ }
258
+ }
259
+ },
260
+
261
+ "personas": {
262
+ "description": "Optional, added in 2.6. What the per-persona scope layer's generated index is called — the same split as `memory` and `handoffs` above, and for the same reason: `slots.personas` is the layer, this is the machine configuration over it. One key and no more, and no budget, for a reason that differs from the handoff series': there is nothing here to budget YET. Each location is empty until the adopter earns a record, so a budget would rail a store that does not exist; and when one does, the axis it should be railed on is per-persona rather than per-workspace, which is a decision that belongs to the row where something finally reads these locations rather than to the row that declares them. This object is what makes a declared scope checkable at all: without it a persona's `## Memory scope` section is prose, and `.portulan/memory/a-mandate-nothing-checks-is-already-broken.md` is this project's record of what that is worth.",
263
+ "type": "object",
264
+ "additionalProperties": false,
265
+ "required": ["index"],
266
+ "properties": {
267
+ "index": {
268
+ "description": "Required whenever `personas` is present — `personas: {}` would configure nothing while reading as configured, the failure `handoffs` is shaped to refuse one version earlier. The generated index over the scopes the composed packs declare. Built by `cli/index.mjs` and byte-compared by a verify recipe on the same terms as the other two, with one difference worth stating: this index is derived from the CASCADE rather than from a directory this workspace owns, since a scope is declared by a persona inside a pack the workspace merely composes. Every field on a line is derived — the persona's name, the pack that contributed it, the location, and a digest over the declared scope's own text — so a pack that rewords a scope moves this file, which is what tells a location that ARRIVED apart from a directory somebody made. Its path must resolve OUTSIDE `slots.personas`, on the siting rule the memory and handoff indexes already carry: an index inside the layer it indexes would be swept as an undeclared location by the same check that looks for orphans.",
269
+ "type": "object",
270
+ "additionalProperties": false,
271
+ "required": ["path"],
272
+ "properties": {
273
+ "path": {
274
+ "description": "Where the generated index is written, relative to the workspace root.",
275
+ "$ref": "#/$defs/filePath"
276
+ }
277
+ }
278
+ }
279
+ }
280
+ },
281
+
282
+ "librarian": {
283
+ "description": "Optional, added in 2.4. What the SCHEDULED librarian pass nags about, and after how long. Distinct from `memory` above in the way a rail is distinct from a reminder: those budgets are byte-checked by a verify recipe on every pull request and a breach is red, while everything here is measured from git history and produces a draft for a human, never a verdict. That split is not a preference — a check that reads history is a false-red generator in a shallow checkout, so the recipe never asks and the scheduled pass always may. A workspace may declare a store and no pass, which is every workspace before 2.4; what it may not do is declare this object without `slots.memory`, since there would be nothing to age. That conditional requirement is `doctor`'s, not the schema's — the declared subset has no `dependentRequired`, exactly as with `memory` at 2.3 and `tree` when `kind` is `repository`.",
284
+ "type": "object",
285
+ "additionalProperties": false,
286
+ "properties": {
287
+ "staleness": {
288
+ "description": "Optional. The three intervals the pass nags on. Nothing here is defaulted, on the same reasoning as the memory budgets: a default would be this specification setting cadence policy for every workspace that adopts it, in a key nobody typed — and an undeclared interval means the pass reports the facts and flags nothing, which is a legitimate way to run it. How OFTEN the pass runs is deliberately not declarable here: that is the host's scheduler rather than the workspace's content, and a cron expression in a manifest nothing reads would be configuration pretending to be policy.",
289
+ "type": "object",
290
+ "additionalProperties": false,
291
+ "properties": {
292
+ "record_days": {
293
+ "description": "How long a memory record may go untouched before the pass flags it as stale and drafts a demotion for it. Measured from the record's last AUTHOR date in git — author rather than committer, because a rebase rewrites every committer date and would report the whole store as touched on the day of the last rebase. A positive integer: the declared subset has no `minimum`, so `type: number` is the strongest constraint expressible here, and `cli/librarian.mjs` refuses zero, negative and non-integer values with exit 2 rather than reading them as undeclared.",
294
+ "type": "number"
295
+ },
296
+ "sealed_days": {
297
+ "description": "How long a SEALED provenance stamp stands before its owner is nagged to re-validate it. Dated from the stamp's own `date=`, never from the file's last commit: the stamp records when the incident was sealed, and a typo fix must not buy it another interval. This is the nag `docs/vision.md` thesis 4 names in as many words — the librarian cannot judge a sealed rule, so it asks the owner instead. A positive integer, on the same terms as `record_days`.",
298
+ "type": "number"
299
+ },
300
+ "proposal_days": {
301
+ "description": "How long a proposal may sit undecided before the pass nags. Requires `slots.proposals`; without it the pass reports that it was not asked, which is not the same answer as none pending. A positive integer, on the same terms as `record_days`.",
302
+ "type": "number"
303
+ }
304
+ }
305
+ }
306
+ }
307
+ }
308
+ },
309
+
310
+ "$defs": {
311
+ "slug": {
312
+ "description": "A lowercase hyphenated identifier. Defined once and referenced everywhere an id is used or named, so the constraint cannot drift between the field that declares an id and the field that points at one.",
313
+ "type": "string",
314
+ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"
315
+ },
316
+
317
+ "path": {
318
+ "description": "A relative path to a file or directory, resolved from the workspace root. The leading character may not be `/` (no absolute paths) and `:` is excluded (no URLs), so `../` is the only way out of the workspace — which is deliberate, since escaping should be visible in the value itself.",
319
+ "type": "string",
320
+ "pattern": "^[^#?:/][^#?:]*$",
321
+ "minLength": 1
322
+ },
323
+
324
+ "filePath": {
325
+ "description": "A relative path to a file — same rules as `path`, and must not end in `/`.",
326
+ "type": "string",
327
+ "pattern": "^[^#?:/]([^#?:]*[^#?:/])?$",
328
+ "minLength": 1
329
+ },
330
+
331
+ "dirPath": {
332
+ "description": "A relative path to a directory — same rules as `path`, and must end in `/`, so the intent is legible without touching the filesystem.",
333
+ "type": "string",
334
+ "pattern": "^[^#?:/][^#?:]*/$",
335
+ "minLength": 2
336
+ },
337
+
338
+ "recipe": {
339
+ "description": "One verify recipe: what to run, and what it needs in order to run at all.",
340
+ "type": "object",
341
+ "additionalProperties": false,
342
+ "required": ["id", "run"],
343
+ "properties": {
344
+ "id": { "$ref": "#/$defs/slug" },
345
+ "run": {
346
+ "description": "The command, as it would be typed from the repository root.",
347
+ "type": "string",
348
+ "minLength": 1
349
+ },
350
+ "requires": {
351
+ "description": "The tools the recipe needs. Declared rather than discovered, because a recipe that cannot run must be distinguishable from one that ran and failed — the difference between exit 2 and exit 1.",
352
+ "type": "array",
353
+ "uniqueItems": true,
354
+ "items": { "type": "string", "minLength": 1 }
355
+ },
356
+ "doc": {
357
+ "description": "Where the recipe's checks and their known limits are written up. Resolved from the WORKSPACE root, like every other path in this manifest — unlike the sibling `run`, which is a command line and is therefore written as it would be typed from the repository root. The two bases differ because the two fields are read by different things: `doc` by something resolving paths, `run` by a shell.",
358
+ "$ref": "#/$defs/filePath"
359
+ }
360
+ }
361
+ },
362
+
363
+ "product": {
364
+ "description": "One product this workspace covers.",
365
+ "type": "object",
366
+ "additionalProperties": false,
367
+ "required": ["id", "product"],
368
+ "properties": {
369
+ "id": { "$ref": "#/$defs/slug" },
370
+ "name": {
371
+ "description": "Display name, when it differs from the id.",
372
+ "type": "string",
373
+ "minLength": 1
374
+ },
375
+ "product": {
376
+ "description": "The product layer: mission, what it is, why it exists.",
377
+ "$ref": "#/$defs/filePath"
378
+ },
379
+ "affordances": {
380
+ "description": "What this product offers an agent working on it — entry points, commands, machine-readable contracts, and the honest gaps. Overrides the workspace-level default.",
381
+ "$ref": "#/$defs/filePath"
382
+ },
383
+ "repos": {
384
+ "description": "The `repos/` card names this product ships from.",
385
+ "type": "array",
386
+ "uniqueItems": true,
387
+ "items": { "type": "string", "minLength": 1 }
388
+ }
389
+ }
390
+ },
391
+
392
+ "provenance": {
393
+ "description": "How a rule links to the incident that created it. NOT a manifest key — rules live in Markdown records (memory entries, proposals), and this is the normative shape their `provenance:` field is parsed into. One definition, two carriers. Adopted 2026-07-25 from .portulan/proposals/0002-sealed-provenance.md; see spec/slots.md for the reasoning and the honest limits.",
394
+ "type": "object",
395
+ "oneOf": [
396
+ {
397
+ "description": "Public — a resolvable link to the incident: a ticket, a PR, a postmortem, a commit.",
398
+ "type": "object",
399
+ "additionalProperties": false,
400
+ "required": ["form", "href"],
401
+ "properties": {
402
+ "form": { "enum": ["link"] },
403
+ "href": {
404
+ "description": "Where the incident lives: a ticket, a PR, a postmortem, a commit. Shape only — never dereferenced, because a gate that needs the network fails for reasons unrelated to the change under test. Whitespace is rejected so that `well-formed` is a claim a checker can actually enforce rather than a description of intent.",
405
+ "type": "string",
406
+ "pattern": "^\\S+$",
407
+ "minLength": 1
408
+ }
409
+ }
410
+ },
411
+ {
412
+ "description": "Sealed — an owner and a date plus the de-identified failure shape, for an incident that cannot leave its owner's layer. The full chain stays in the owner's workspace; what travels is the stamp and the mechanism, never the episode.",
413
+ "type": "object",
414
+ "additionalProperties": false,
415
+ "required": ["form", "owner", "date", "shape"],
416
+ "properties": {
417
+ "form": { "enum": ["sealed"] },
418
+ "owner": {
419
+ "description": "Who can still see the incident and therefore re-validate the rule.",
420
+ "type": "string",
421
+ "minLength": 1
422
+ },
423
+ "date": {
424
+ "description": "ISO date of the incident, which is what the librarian nags against.",
425
+ "type": "string",
426
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
427
+ },
428
+ "shape": {
429
+ "description": "The de-identified failure: the inputs, the wrong outcome, and why the obvious guard misses it. The bar is that someone who never saw the incident can still write the rule's test.",
430
+ "type": "string",
431
+ "minLength": 1
432
+ }
433
+ }
434
+ }
435
+ ]
436
+ }
437
+ }
438
+ }