@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
package/cli/doctor.mjs ADDED
@@ -0,0 +1,2803 @@
1
+ #!/usr/bin/env node
2
+ // `doctor` — the Workspace Definition validator.
3
+ //
4
+ // node cli/doctor.mjs <workspace-dir> [<workspace-dir> ...]
5
+ //
6
+ // Exit 0 every workspace validates · 1 at least one does not · 2 could not run.
7
+ //
8
+ // The third code is the one that carries weight here. A verdict ABOUT a workspace — a missing
9
+ // manifest, a slot pointing nowhere, a rule with no provenance — is exit 1: the tool ran and
10
+ // judged. Exit 2 means the tool could not judge at all: no arguments, an unreadable schema, an
11
+ // unanticipated throw. Borrowing 1 for the last of those would claim a judgement nobody made,
12
+ // which is the laundering already fixed once in ../.portulan/tools/gh-bot-token.mjs.
13
+ //
14
+ // Zero dependencies and no install step, deliberately — ../.portulan/identity.md places doctor at
15
+ // milestone 2 as "zero-dependency JavaScript on Node, run from the repository", absorbed by the
16
+ // TypeScript CLI at milestone 7. Nothing here touches the network: a check that fails for reasons
17
+ // unrelated to the change under test is worse than no check (../.portulan/verify/README.md).
18
+ //
19
+ // What it does NOT do is written next to what it does, in ../spec/slots.md and in ../cli/README.md.
20
+ // The short list: it never runs a verify recipe, never dereferences a link, and never judges whether a
21
+ // sealed stamp is true.
22
+ //
23
+ // **It DOES score agent legibility, since milestone 7 session 7** — this line read "and never scores
24
+ // agent-legibility" until then, which was the honest state from milestone 2 until row 7's 2026-07-28
25
+ // amendment was built. The score reads the `affordances` slot and six manifest keys beside it, prints
26
+ // one line, and **moves no exit code**: a measurement is not a verdict, and one that could fail a
27
+ // workspace would make this tool's judgement a function of how much prose somebody wrote. See section
28
+ // 3b for the dimensions and for why each of them can vary.
29
+ //
30
+ // **One name IS dereferenced, as of milestone 7: a pointer's `governed_by`.** It is resolved against
31
+ // the host's installed-plugin record by ./discover.mjs — on disk, never over the network, and the
32
+ // line above stays literally true because that is not a link. The result is REPORTED and never
33
+ // graded: a pointer whose governor is not installed is a correct pointer, and this tool says where
34
+ // the workspace is rather than passing judgement on it.
35
+
36
+ import fs from "node:fs";
37
+ import path from "node:path";
38
+ import { fileURLToPath, pathToFileURL } from "node:url";
39
+
40
+ // The enforcement compiler, imported rather than reimplemented. `doctor` reports what each backend
41
+ // covers, and the only way that report can be true is for it to ask the backends themselves — a
42
+ // second copy of the accounting would drift, and a *coverage claim* that drifts does not look wrong,
43
+ // it looks like enforcement that quietly stopped covering something. Same reasoning that has
44
+ // ./gate.mjs import the matcher instead of writing a second one. Zero
45
+ // dependencies on both sides, so nothing is added to what this tool needs to run.
46
+ import { parse, backends, resolvePack, rootPlan, packContributions, composeFragments } from "./compile.mjs";
47
+ // The containment test the memory-index siting rule turns on, imported for the reason directly
48
+ // above: the copy that used to live here drifted into the identical fail-open as the original.
49
+ import { isInside, recordType } from "./index.mjs";
50
+ // One frontmatter parser for this repository, not two. `plugin-lint` minted it and has the tests that
51
+ // pin its edges — an unterminated block, a block that is not first, a value with a colon in it — and a
52
+ // second implementation here would be a second carrier of one contract — the class that proposal
53
+ // `0020` names (../.portulan/proposals/0020-a-fix-is-not-done-at-the-site-it-was-found.md), and what
54
+ // ../core/operating/evolution.md prescribes leaving one site for.
55
+ // `AGENT_DIR` for the same reason, and it is the stronger case of the two: it is not a convention this
56
+ // tool is free to choose but a MEASUREMENT of where one host looks — with a positive control, recorded
57
+ // at its declaration. A copy here would be this tool's opinion about somebody else's loader.
58
+ import { parseFrontmatter, AGENT_DIR } from "./plugin-lint.mjs";
59
+ // Host plugin-cache discovery, which is where a pointer's `governed_by` stops being a name and
60
+ // becomes a directory. Imported rather than reimplemented for the reason above, and kept in its own
61
+ // file for a second one: the boot skill's whole instruction is to report what THIS resolver said, so
62
+ // there must be exactly one thing that says it.
63
+ import { resolveGovernor, AUTO, discoverPackRoots, namedWithAuto } from "./discover.mjs";
64
+ import { composedId } from "./recipe-set.mjs";
65
+
66
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
67
+ const DEFAULT_SCHEMA = path.resolve(HERE, "..", "spec", "workspace.schema.json");
68
+ // The Pack Definition is a SEPARATE contract on its own version train — see spec/README.md. A pack
69
+ // and a workspace are different artifacts, and one number governing both would make a bump in
70
+ // either mean a change in the other.
71
+ const DEFAULT_PACK_SCHEMA = path.resolve(HERE, "..", "spec", "pack.schema.json");
72
+
73
+ /**
74
+ * The tail of `doctor`'s binding-success line, exported so the suite keys on ONE carrier.
75
+ *
76
+ * Two tests guard this sentence: one asserts it IS printed for an ordinary binding, the other that it is
77
+ * NOT printed for a name that traverses out of the tree — a path-traversal case where a green would mean
78
+ * `doctor` had opened a file the pack chose. Held as two literals, a reword satisfies the negative
79
+ * assertion **for the wrong reason**: the string is gone, nothing prints it, the test passes, and it
80
+ * would go on passing with the traversal guard deleted. Measured at milestone 7's close, where exactly
81
+ * that happened. Kept here beside the other module constants rather than beside its use, because the
82
+ * first attempt put it between `validatePersona`'s docblock and `validatePersona` — a doc comment
83
+ * attaches to the next symbol, so that silently reassigned it. (Copilot, round 1 on #240.)
84
+ */
85
+ export const BINDING_OK = "names match and a tool grant is declared";
86
+
87
+ /** Raised when `doctor` cannot run at all. Always exit 2, never 1. */
88
+ export class DoctorError extends Error {
89
+ constructor(message) {
90
+ super(message);
91
+ this.name = "DoctorError";
92
+ }
93
+ }
94
+
95
+ // ===========================================================================================
96
+ // 1. The schema subset
97
+ // ===========================================================================================
98
+ //
99
+ // spec/README.md names the exact JSON Schema subset this validator implements, on the reasoning
100
+ // that a validator carried rather than depended on must be small enough to implement completely
101
+ // and honestly. The list below IS that sentence, in a form that fails.
102
+ //
103
+ // Everything outside it is refused rather than ignored — which is the point. A validator that
104
+ // skips the keywords it does not know reports conformance it never checked: the author wrote a
105
+ // constraint, the machine agreed, and nothing enforced it. That is the same fail-open shape this
106
+ // repository has now minted three rules about (../.portulan/memory/verify-preconditions-fail-closed.md),
107
+ // and a validator is exactly where a fourth would hide.
108
+
109
+ // Each supported keyword, with what a well-formed VALUE for it looks like. Knowing the name is not
110
+ // enough: `pattern: "["` and `enum: "repository"` are both inside the subset by name and neither can
111
+ // be honoured, and without this table they surface at instance-validation time as a raw SyntaxError
112
+ // or TypeError — an "unanticipated failure" (exit 2) naming no keyword and no location, from a defect
113
+ // that is squarely in the schema. Refusing at compile time is the same rule one level finer: the unit
114
+ // this validator must refuse is a constraint it cannot apply, not merely a word it does not know.
115
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
116
+ const isCount = (v) => typeof v === "number" && Number.isInteger(v) && v >= 0;
117
+ const regexCompiles = (v) => {
118
+ if (typeof v !== "string") return false;
119
+ try { new RegExp(v); return true; } catch { return false; }
120
+ };
121
+
122
+ const SUPPORTED = {
123
+ $schema: (v) => typeof v === "string",
124
+ $id: (v) => typeof v === "string",
125
+ $defs: isObject,
126
+ $ref: (v) => typeof v === "string",
127
+ title: (v) => typeof v === "string",
128
+ description: (v) => typeof v === "string",
129
+ // Constrained to the names `check` can actually test. `type: "integer"` would otherwise be
130
+ // accepted here and then match nothing, failing every instance — loud, but for the wrong reason.
131
+ type: (v) => ["object", "array", "string", "number", "boolean", "null"].includes(v),
132
+ properties: isObject,
133
+ required: (v) => Array.isArray(v) && v.every((n) => typeof n === "string"),
134
+ additionalProperties: (v) => v === false,
135
+ items: isObject,
136
+ enum: (v) => Array.isArray(v) && v.length > 0,
137
+ pattern: regexCompiles,
138
+ minLength: isCount,
139
+ minItems: isCount,
140
+ uniqueItems: (v) => typeof v === "boolean",
141
+ oneOf: (v) => Array.isArray(v) && v.length > 0 && v.every(isObject),
142
+ };
143
+
144
+ const SHAPE = {
145
+ $defs: "an object", properties: "an object", items: "a schema object",
146
+ required: "an array of strings", enum: "a non-empty array",
147
+ oneOf: "a non-empty array of schema objects",
148
+ pattern: "a string that compiles as a regular expression",
149
+ minLength: "a non-negative integer", minItems: "a non-negative integer",
150
+ uniqueItems: "a boolean",
151
+ type: "one of object, array, string, number, boolean, null",
152
+ additionalProperties:
153
+ "literal false — only that form is in the subset, and a schema-valued form would let unknown " +
154
+ "keys through a check this validator does not implement",
155
+ };
156
+
157
+ // Siblings a `$ref` may carry. Annotations only: they describe, they never constrain, so they
158
+ // cannot silently narrow what the ref resolves to. Used where a field reuses a shared definition
159
+ // and still deserves its own prose — `name` and `verify.default` both do with `$defs/slug`.
160
+ const REF_SIBLINGS = new Set(["$ref", "title", "description"]);
161
+
162
+ /**
163
+ * Walk a schema and refuse anything outside the subset. Returns the schema unchanged; the value
164
+ * is the walk, not the result.
165
+ */
166
+ export function compileSchema(schema, where = "#") {
167
+ if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
168
+ throw new DoctorError(`schema at ${where} is not an object`);
169
+ }
170
+
171
+ const keys = Object.keys(schema);
172
+
173
+ for (const key of keys) {
174
+ if (!(key in SUPPORTED)) {
175
+ throw new DoctorError(
176
+ `schema at ${where} uses \`${key}\`, which is outside the subset this validator ` +
177
+ `implements (see spec/README.md). A schema change reaching outside that list ` +
178
+ `is a change to doctor too, and the two land together.`,
179
+ );
180
+ }
181
+ if (!SUPPORTED[key](schema[key])) {
182
+ throw new DoctorError(
183
+ `schema at ${where} has \`${key}: ${JSON.stringify(schema[key])}\`, which is not ` +
184
+ `${SHAPE[key] ?? "a usable value for that keyword"}. The keyword is in the subset; ` +
185
+ `this value cannot be applied, and a constraint that cannot be applied is refused ` +
186
+ `rather than carried to instance validation, where it would surface as a stack ` +
187
+ `trace naming neither the keyword nor where it lives.`,
188
+ );
189
+ }
190
+ }
191
+
192
+ if ("$ref" in schema) {
193
+ const stray = keys.filter((k) => !REF_SIBLINGS.has(k));
194
+ if (stray.length) {
195
+ throw new DoctorError(
196
+ `schema at ${where} carries \`$ref\` alongside ${stray.map((k) => `\`${k}\``).join(", ")}. ` +
197
+ `Only \`title\` and \`description\` may accompany a $ref — they are annotations and ` +
198
+ `never affect validation; anything else would be a constraint this validator ignores.`,
199
+ );
200
+ }
201
+ if (typeof schema.$ref !== "string" || !schema.$ref.startsWith("#/$defs/")) {
202
+ throw new DoctorError(
203
+ `schema at ${where} has \`$ref: ${JSON.stringify(schema.$ref)}\`; only local ` +
204
+ `\`#/$defs/…\` references are supported.`,
205
+ );
206
+ }
207
+ }
208
+
209
+ for (const [name, sub] of Object.entries(schema.$defs ?? {})) {
210
+ compileSchema(sub, `${where}/$defs/${name}`);
211
+ }
212
+ for (const [name, sub] of Object.entries(schema.properties ?? {})) {
213
+ compileSchema(sub, `${where}/properties/${name}`);
214
+ }
215
+ if (schema.items) compileSchema(schema.items, `${where}/items`);
216
+ if (schema.oneOf) schema.oneOf.forEach((sub, i) => compileSchema(sub, `${where}/oneOf/${i}`));
217
+
218
+ return schema;
219
+ }
220
+
221
+ const typeOf = (v) =>
222
+ v === null ? "null" : Array.isArray(v) ? "array" : typeof v === "number" ? "number" : typeof v;
223
+
224
+ /**
225
+ * Validate an instance against a schema written in the subset.
226
+ * Returns `[{ pointer, message }]` — empty when the instance conforms.
227
+ */
228
+ export function validate(schema, instance) {
229
+ compileSchema(schema);
230
+ const errors = [];
231
+ check(schema, instance, "", schema, errors);
232
+ return errors;
233
+ }
234
+
235
+ function resolveRef(root, ref) {
236
+ const name = ref.slice("#/$defs/".length);
237
+ const target = root.$defs?.[name];
238
+ if (!target) throw new DoctorError(`schema references \`${ref}\`, which is not defined`);
239
+ return target;
240
+ }
241
+
242
+ function check(schema, value, pointer, root, errors) {
243
+ const add = (message) => errors.push({ pointer, message });
244
+
245
+ if (schema.$ref) {
246
+ check(resolveRef(root, schema.$ref), value, pointer, root, errors);
247
+ return;
248
+ }
249
+
250
+ if (schema.type) {
251
+ const actual = typeOf(value);
252
+ const ok = schema.type === "number" ? actual === "number" : actual === schema.type;
253
+ if (!ok) {
254
+ add(`expected type \`${schema.type}\`, found \`${actual}\``);
255
+ return; // Every other keyword here would be reporting the same fault again.
256
+ }
257
+ }
258
+
259
+ if (schema.enum && !schema.enum.some((allowed) => allowed === value)) {
260
+ add(`value ${JSON.stringify(value)} is not one of the permitted values (enum: ${schema.enum.map((v) => JSON.stringify(v)).join(", ")})`);
261
+ }
262
+
263
+ if (typeof value === "string") {
264
+ if (schema.pattern !== undefined && !new RegExp(schema.pattern).test(value)) {
265
+ add(`value ${JSON.stringify(value)} does not match the required pattern \`${schema.pattern}\``);
266
+ }
267
+ if (schema.minLength !== undefined && value.length < schema.minLength) {
268
+ add(`value is shorter than the required minLength of ${schema.minLength}`);
269
+ }
270
+ }
271
+
272
+ if (Array.isArray(value)) {
273
+ if (schema.minItems !== undefined && value.length < schema.minItems) {
274
+ add(`array has ${value.length} item(s), fewer than the required minItems of ${schema.minItems}`);
275
+ }
276
+ if (schema.uniqueItems) {
277
+ const seen = new Set();
278
+ value.forEach((item, i) => {
279
+ const key = JSON.stringify(item);
280
+ if (seen.has(key)) errors.push({ pointer: `${pointer}/${i}`, message: `duplicate entry ${key} violates uniqueItems` });
281
+ seen.add(key);
282
+ });
283
+ }
284
+ if (schema.items) {
285
+ value.forEach((item, i) => check(schema.items, item, `${pointer}/${i}`, root, errors));
286
+ }
287
+ }
288
+
289
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
290
+ for (const name of schema.required ?? []) {
291
+ if (!(name in value)) add(`required property \`${name}\` is missing`);
292
+ }
293
+ if (schema.additionalProperties === false) {
294
+ // `properties` may legitimately be absent, and then this forbids EVERY property — the
295
+ // 2020-12 semantics. Treating a missing `properties` as "nothing to check" would make a
296
+ // supported spelling silently mean the opposite of what it says, which is the failure
297
+ // ../.portulan/memory/a-checker-must-refuse-what-it-cannot-check.md is about.
298
+ const declared = schema.properties ?? {};
299
+ for (const name of Object.keys(value)) {
300
+ if (!(name in declared)) {
301
+ errors.push({
302
+ pointer: `${pointer}/${name}`,
303
+ message: `unexpected property \`${name}\` — the schema sets additionalProperties: false, so an unknown key is rejected rather than ignored (the common case is a typo in a slot name)`,
304
+ });
305
+ }
306
+ }
307
+ }
308
+ for (const [name, sub] of Object.entries(schema.properties ?? {})) {
309
+ if (name in value) check(sub, value[name], `${pointer}/${name}`, root, errors);
310
+ }
311
+ }
312
+
313
+ if (schema.oneOf) {
314
+ const matched = schema.oneOf.filter((sub) => {
315
+ const trial = [];
316
+ check(sub, value, pointer, root, trial);
317
+ return trial.length === 0;
318
+ });
319
+ if (matched.length !== 1) {
320
+ const why = schema.oneOf
321
+ .map((sub, i) => {
322
+ const trial = [];
323
+ check(sub, value, pointer, root, trial);
324
+ return ` form ${i + 1}: ${trial.map((e) => e.message).join("; ") || "matched"}`;
325
+ })
326
+ .join("\n");
327
+ add(`value matches ${matched.length} of the ${schema.oneOf.length} permitted forms, not exactly one (oneOf):\n${why}`);
328
+ }
329
+ }
330
+ }
331
+
332
+ // ===========================================================================================
333
+ // 2. Provenance, parsed out of a Markdown record
334
+ // ===========================================================================================
335
+ //
336
+ // The Workspace Definition puts provenance on a rule rather than in the manifest: a rule lives in
337
+ // Markdown, so a manifest key would describe a workspace's POLICY about provenance while leaving
338
+ // every actual rule unchecked (spec/slots.md). So the schema defines the shape and the records
339
+ // carry instances — one definition, two carriers — and this is the parser between them.
340
+ //
341
+ // The accepted syntax is deliberately narrow: backticked `key=value` tokens on the `**provenance:**`
342
+ // paragraph, keys drawn from the five the schema names. Narrow because the alternative is false
343
+ // reds — the live records carry annotation prose after the stamp ("— the milestone-2 pull request,
344
+ // where…"), and a parser that treated every backtick in that prose as a field would fail records
345
+ // that are perfectly correct.
346
+
347
+ const PROVENANCE_KEYS = ["form", "href", "owner", "date", "shape"];
348
+ const PROVENANCE_TOKEN = new RegExp("`(" + PROVENANCE_KEYS.join("|") + ")=([^`]*)`", "g");
349
+
350
+ /**
351
+ * Pull the provenance stamp out of a record.
352
+ * Returns `{ present, fields, raw }`; `fields` is null when the record carries prose provenance
353
+ * — which is a finding for a rule and a note for anything else, not a parse failure.
354
+ */
355
+ export function parseProvenance(source) {
356
+ const lines = source.split("\n");
357
+ const start = lines.findIndex((l) => /^\s*\*\*provenance:\*\*/i.test(l));
358
+ if (start === -1) return { present: false, fields: null, raw: "" };
359
+
360
+ // The stamp may wrap, so the unit is the paragraph, not the line.
361
+ let end = start;
362
+ while (end + 1 < lines.length && lines[end + 1].trim() !== "") end += 1;
363
+ const raw = lines.slice(start, end + 1).join(" ");
364
+
365
+ // First occurrence wins. The stamp leads the paragraph and annotation prose follows it, so a
366
+ // later `form=…` inside that prose is somebody writing *about* provenance, not declaring it —
367
+ // and last-wins would let a sentence mentioning the other form turn a correct record red. A
368
+ // false red is the failure that gets a whole check switched off, so the tie breaks toward the
369
+ // stamp. The residual limit is stated in ../core/templates/memory-entry.md rather than left to
370
+ // be discovered: a backticked `key=value` using one of these five keys is reserved syntax
371
+ // wherever it appears in the paragraph.
372
+ const fields = {};
373
+ for (const [, key, value] of raw.matchAll(PROVENANCE_TOKEN)) {
374
+ if (!(key in fields)) fields[key] = value.trim();
375
+ }
376
+ return { present: true, fields: Object.keys(fields).length ? fields : null, raw };
377
+ }
378
+
379
+ // A record's declared `**type:**` — imported from ./index.mjs rather than spelled again here. Three
380
+ // tools ask this of the same records and all three carried their own regex, which is half of what #74
381
+ // is about; the direction of the import is set by ./index.mjs already being this module's dependency
382
+ // for `isInside`, so putting the one carrier there keeps the graph acyclic.
383
+
384
+ // The retirement condition is a template field, not a schema one (core/templates/memory-entry.md):
385
+ // the line a demotion pass retires by. Anchored to the bolded field at line start, so prose that
386
+ // merely discusses retiring never matches — the same false-red caution parseProvenance takes with
387
+ // backticked tokens, for the same reason: a false red is what gets a whole check switched off.
388
+ const RETIRE_WHEN = /^\s*\*\*retire when:\*\*/im;
389
+
390
+ // ===========================================================================================
391
+ // 3. Claims against the tree
392
+ // ===========================================================================================
393
+ //
394
+ // The milestone-2 criterion asks for this "the way the `map` check already holds the root README
395
+ // to the repo's shape" — so a claim that is verifiable and wrong FAILS, exactly as `map` does.
396
+ //
397
+ // Three things make that safe to do. First, the lint reads only what parses confidently as a path:
398
+ // a code span or link target containing `/`, never absolute. `build: none — no build step yet`
399
+ // claims nothing and is treated as claiming nothing. Prose outside the two parsed sections is
400
+ // **left alone** — not failed and not reported — because reporting every ordinary sentence in a
401
+ // repo card would bury the findings that matter.
402
+ //
403
+ // Second, FAIL is reserved for a claim that is unambiguous. A build/test/run candidate that is a
404
+ // single path-shaped token is a claim a file exists; a candidate that is a command merely contains
405
+ // tokens that might be paths, output paths, flag values or globs, and those are reported rather
406
+ // than failed. A false red is the outcome that gets a whole recipe switched off
407
+ // (../.portulan/verify/README.md), and an ambitious parser is the shortest route to one — which
408
+ // this check learned by producing them.
409
+ //
410
+ // Second, the tree is DECLARED rather than guessed. A workspace that makes claims about the
411
+ // repository containing it says so with the `tree` slot; one that describes repositories not
412
+ // present beside it — a demo, or a portfolio spanning many — omits it, and its claims are reported
413
+ // **unverifiable**. Never silently skipped: a check class that disappears without saying so is the
414
+ // fail-open this repository keeps re-finding.
415
+
416
+ const CODE_SPAN = /`([^`]+)`/g;
417
+ const LINK_TARGET = /\]\(([^)]+)\)/g;
418
+
419
+ /** Path-shaped claims in a repo card: the build/test/run lines, and the layout. */
420
+ function repoCardClaims(source) {
421
+ const lines = source.split("\n");
422
+ const claims = [];
423
+
424
+ const section = (headingPattern) => {
425
+ const start = lines.findIndex((l) => headingPattern.test(l));
426
+ if (start === -1) return null;
427
+ let end = start;
428
+ while (end + 1 < lines.length && lines[end + 1].trim() !== "") end += 1;
429
+ return lines.slice(start, end + 1);
430
+ };
431
+
432
+ // Absolute paths are excluded because they resolve against the HOST rather than the tree:
433
+ // `/usr/bin/env` inside a command would otherwise be checked, found, and counted as a passing
434
+ // claim about a repository it has nothing to do with.
435
+ const isPathish = (token) =>
436
+ token.includes("/") &&
437
+ !token.startsWith("/") &&
438
+ !/^(https?|mailto):/.test(token) &&
439
+ !token.includes(" ");
440
+
441
+ const build = section(/^\s*\*\*Build\s*\/\s*test\s*\/\s*run\.?\*\*/i);
442
+ if (build) {
443
+ for (const line of build) {
444
+ const entry = line.match(/^\s*[-*]\s*(\w[\w -]*):\s*(.+)$/);
445
+ if (!entry) continue;
446
+ const [, key, rest] = entry;
447
+ const spans = [...rest.matchAll(CODE_SPAN)].map((m) => m[1].trim());
448
+ const candidate = spans[0] ?? rest.trim().split(/\s+/)[0];
449
+ if (!candidate) continue;
450
+ if (/^none\b/i.test(candidate)) continue; // an explicit no-claim
451
+
452
+ // Severity turns on whether the candidate IS a path or merely CONTAINS one, and that
453
+ // split is the whole design of this branch.
454
+ //
455
+ // A candidate that is a single path-shaped token — `./verify.sh` — is unambiguously a
456
+ // claim that a file exists, and absent it is a **failure**. That is the shape customer
457
+ // zero's card uses and the shape the milestone-2 close demonstrated red.
458
+ //
459
+ // A candidate that is a command — `dotnet run --project src/App` — contains tokens that
460
+ // may be an input path, an output path not built yet, a flag value, a `sed` expression, a
461
+ // glob, an npm script name, or an SSH remote. Nothing here can tell those apart, so every
462
+ // extracted token is **reported, never failed**. An earlier version failed them and bought
463
+ // false reds on `go test ./...`, `cc -o bin/app src/main.c`, and `--project=src/App` with
464
+ // the directory present — on exactly the input `../core/templates/repo-card.md` tells
465
+ // adopters to write. Tightening the pattern cannot fix it: `bin/app` and `test/unit` are
466
+ // clean-looking paths. A false red gets a whole recipe switched off; a missed check does
467
+ // not, and this repository has ranked those two twice.
468
+ //
469
+ // What both branches guarantee is that nothing is **silent**. Before this, a
470
+ // command-shaped line was tested whole, rejected for containing a space, and dropped:
471
+ // not checked, not counted, not reported.
472
+ const tokens = candidate.split(/\s+/);
473
+ const targets = tokens.filter(isPathish);
474
+ if (tokens.length === 1 && targets.length === 1) {
475
+ claims.push({ what: `${key}: \`${candidate}\``, target: candidate, severity: "fail" });
476
+ } else if (targets.length) {
477
+ for (const target of new Set(targets)) {
478
+ claims.push({
479
+ what: `${key}: \`${target}\`, taken from \`${candidate}\``,
480
+ target,
481
+ severity: "report",
482
+ });
483
+ }
484
+ } else {
485
+ claims.push({ what: `${key}: \`${candidate}\``, target: null });
486
+ }
487
+ }
488
+ }
489
+
490
+ const layout = section(/^\s*\*\*Layout\.?\*\*/i);
491
+ if (layout) {
492
+ const body = layout.join(" ");
493
+ const tokens = [
494
+ ...[...body.matchAll(CODE_SPAN)].map((m) => m[1].trim()),
495
+ ...[...body.matchAll(LINK_TARGET)].map((m) => m[1].trim()),
496
+ ];
497
+ for (const token of new Set(tokens)) {
498
+ if (isPathish(token)) claims.push({ what: `layout: \`${token}\``, target: token });
499
+ }
500
+ }
501
+
502
+ return claims;
503
+ }
504
+
505
+ /**
506
+ * The status-check contexts a gate map claims `main` requires — every one named in the row, not just
507
+ * the first. A repository may require several, and reading only the first silently exempts the rest:
508
+ * the third real workspace requires two, and the one that was ignored was the one that was wrong.
509
+ */
510
+ function requiredCheckClaims(source) {
511
+ for (const line of source.split("\n")) {
512
+ if (!/^\s*\|/.test(line)) continue;
513
+ const cells = line.split("|").map((c) => c.trim());
514
+ if (!/required status check/i.test(cells[1] ?? "")) continue;
515
+ const tokens = [...cells.slice(2).join(" ").matchAll(/`([^`]+)`/g)].map((m) => m[1]);
516
+ if (tokens.length) return [...new Set(tokens)];
517
+ }
518
+ return [];
519
+ }
520
+
521
+ /**
522
+ * The status-check contexts a workflow file will report, as `{ id, context }`.
523
+ *
524
+ * The context is the job's `name:` when it sets one, and the job id otherwise — that is what branch
525
+ * protection pins, and the distinction is invisible until a job has both. It surfaced on the third
526
+ * real workspace: a job id `example-job` carrying `name: Example Job`, where the ruleset requires
527
+ * `Example Job` and this function previously returned only `example-job`, so a gate map claiming the
528
+ * id passed a check that should have failed. Customer zero could never have shown it, because its
529
+ * workflow deliberately sets no `name:` precisely so the two coincide.
530
+ *
531
+ * A regex rather than a YAML parse, because a parser is a dependency and this reads one shape from one
532
+ * well-known file. Stated as a limit rather than left to be discovered: unusual-but-valid YAML — flow
533
+ * mappings, quoted or multi-line keys, `name` given as an expression — is not recognised.
534
+ */
535
+ function workflowJobs(source) {
536
+ const lines = source.split("\n");
537
+ const start = lines.findIndex((l) => /^jobs:\s*$/.test(l));
538
+ if (start === -1) return [];
539
+ const jobs = [];
540
+ for (const line of lines.slice(start + 1)) {
541
+ if (/^\S/.test(line) && line.trim() !== "") break; // back to top level
542
+ const id = line.match(/^ {2}([A-Za-z0-9_-]+):\s*$/);
543
+ if (id) {
544
+ jobs.push({ id: id[1], context: id[1] });
545
+ continue;
546
+ }
547
+ // `name:` at the job's own level, not inside a step (steps are deeper and start with `- `).
548
+ const name = line.match(/^ {4}name:\s*(.+?)\s*$/);
549
+ if (name && jobs.length) {
550
+ const value = name[1].replace(/^["']|["']$/g, "");
551
+ if (value && !value.includes("${{")) jobs[jobs.length - 1].context = value;
552
+ }
553
+ }
554
+ return jobs;
555
+ }
556
+
557
+ // ===========================================================================================
558
+ // 4. Inspecting one workspace
559
+ // ===========================================================================================
560
+
561
+ const PATH_SLOTS = {
562
+ identity: "file", principles: "file", gates: "file", dod: "file",
563
+ constitution: "either",
564
+ memory: "dir", repos: "dir", tasks: "dir", handoffs: "dir", proposals: "dir",
565
+ };
566
+
567
+ /**
568
+ * The five parts `core/personas/README.md` fixes, each with the pattern that finds it and the word a
569
+ * failure must use so a reader learns **which** part is missing rather than that "the contract" is unmet.
570
+ *
571
+ * The `tools:` allow-list is frontmatter and the other four are sections, which is why the first entry
572
+ * is matched differently — it is a field, not a heading, and a persona that writes it as a heading has
573
+ * not declared a tool grant a host can read.
574
+ */
575
+ const PERSONA_PARTS = [
576
+ { part: "a `tools:` allow-list", find: (fields) => typeof fields?.tools === "string" && fields.tools.trim().length > 0 },
577
+ { part: "a Charter section", find: (_f, body) => /^##\s+Charter\b/im.test(body) },
578
+ { part: "an Autonomy reach section", find: (_f, body) => /^##\s+Autonomy reach\b/im.test(body) },
579
+ { part: "a Memory scope section", find: (_f, body) => /^##\s+Memory scope\b/im.test(body) },
580
+ { part: "a Read / write posture section", find: (_f, body) => /^##\s+Read\s*\/\s*write posture\b/im.test(body) },
581
+ ];
582
+
583
+ /**
584
+ * How deep a declared skills root is walked, and it is a bound rather than a recursion.
585
+ *
586
+ * **Three, matching `cli/plugin-lint.mjs`'s `MAX_DECLARED_SKILL_DEPTH`.** The first cut here was 1 and
587
+ * cited `.portulan/tasks/0008-a-declared-skills-path-sees-one-level-down.md` as fixing it — which that
588
+ * task does not do: it fixes plugin-lint's bound, in a different tool, at 3. Two walkers over the same
589
+ * declared key disagreeing about how deep a skill may sit is the sibling-divergence class this
590
+ * repository already has a rule for, so the number is shared rather than independently chosen, and the
591
+ * task is cited for the *shape* it fixes rather than for a number it never gave this tool.
592
+ *
593
+ * **The bound reports when it stops.** That sentence was in this comment before it was true — the walk
594
+ * returned silently at the limit, so a pack whose only skill sat below it was reported as a root with
595
+ * nothing in it, and a skill with a bad name and an empty description passed. Found by the pre-commit
596
+ * checkpoint. Task 0008's own acceptance criterion is that a validator reaching the limit says so
597
+ * rather than reporting an absence it did not establish.
598
+ */
599
+ const SKILL_DEPTH = 3;
600
+
601
+ /**
602
+ * Open and validate what a pack contributes: its skills' frontmatter and its personas' five-part
603
+ * contract. Returns what was actually opened, so the caller's report line can say *opened* rather than
604
+ * *declared* — the distinction the whole check exists to make. `bindable` carries the key each opened
605
+ * persona would be bound by on a host; the caller owns that check, because it needs the tree.
606
+ *
607
+ * **Containment is checked after resolution, never by pattern.** `spec/pack.schema.json` bars only the
608
+ * leading `../` form and says so in its own text; `a/../../x` matches it and still escapes, and a symlink
609
+ * matches nothing until it is followed. `cli/index.mjs` already models the answer for personas —
610
+ * `realpathSync` on both the pack directory and the file, compared after resolution — and this is that
611
+ * rule applied to the half of the manifest nothing had opened.
612
+ */
613
+ export function validateContributions(packDir, contributes, { fail, report, pack }) {
614
+ let realPack;
615
+ try {
616
+ realPack = fs.realpathSync(packDir);
617
+ } catch (cause) {
618
+ fail("packs", `\`${pack}\` resolved to a directory that cannot be realpathed — ${cause.code ?? cause.message}`);
619
+ // The SAME shape as the success path, `unreadableRoots` included. A caller reading a property
620
+ // this branch omits gets `undefined`, which is falsy — so the report line silently prints the
621
+ // clean wording over a pack nothing could open. Raised by Copilot, round 1 on #227.
622
+ return { skills: 0, personas: 0, unreadableRoots: 0, bindable: [] };
623
+ }
624
+
625
+ // Three answers, not two. `null` meant "could not resolve" and was reported to the reader as "is not
626
+ // there" — which is only true for ENOENT. An EACCES is a question that could not be ANSWERED, and
627
+ // answering it "absent" is the fail-open — "nothing looked" reported as "nothing wrong" — arriving
628
+ // inside the function added to prevent one. Copilot, round 1 on #156; the same class as session 1's
629
+ // round 7, one tool over.
630
+ const inside = (target) => {
631
+ let real;
632
+ try {
633
+ real = fs.realpathSync(target);
634
+ } catch (cause) {
635
+ return cause.code === "ENOENT" ? { state: "absent" } : { state: "unreadable", detail: cause.code ?? cause.message };
636
+ }
637
+ return real === realPack || real.startsWith(`${realPack}${path.sep}`)
638
+ ? { state: "inside", real }
639
+ : { state: "outside" };
640
+ };
641
+
642
+ let skills = 0;
643
+ let unreadableRoots = 0;
644
+ for (const rel of contributes.skills ?? []) {
645
+ const root = path.join(packDir, rel);
646
+ const contained = inside(root);
647
+ if (contained.state === "outside") {
648
+ fail(
649
+ "packs",
650
+ `\`${pack}\` declares the skills root \`${rel}\`, which resolves outside the pack. A pack reaching into the adopter's tree ` +
651
+ `is the one direction the cascade does not run, and the path pattern bars only the leading \`../\` form — this is the check after resolution`,
652
+ );
653
+ continue;
654
+ }
655
+ if (contained.state === "absent") {
656
+ fail("packs", `\`${pack}\` declares the skills root \`${rel}\`, which is not there`);
657
+ continue;
658
+ }
659
+ if (contained.state === "unreadable") {
660
+ fail(
661
+ "packs",
662
+ `\`${pack}\`'s skills root \`${rel}\` could not be resolved — ${contained.detail}. Reported as unreadable rather than absent: ` +
663
+ `only a missing path means "nothing there", and this question went unanswered`,
664
+ );
665
+ unreadableRoots += 1;
666
+ continue;
667
+ }
668
+ const found = walkSkills(contained.real, { fail, report, pack, rel });
669
+ // `null` means the walk could not look, which is NOT zero. Summing it as zero would put "0
670
+ // skill(s)" in the report line over a root nothing opened — the same "nothing looked recorded as
671
+ // nothing wrong" the walker itself refuses, arriving one layer out in the sentence a reader
672
+ // actually reads. Found by the test written for the walker, which is the argument for testing the
673
+ // report line and not only the finding.
674
+ if (found === null) unreadableRoots += 1;
675
+ else skills += found;
676
+ }
677
+
678
+ let personas = 0;
679
+ // What each opened persona would be BOUND by on a host, collected here because this is the one
680
+ // place the file is read. The caller matches them against `agents/` once the tree is known — the
681
+ // check needs a repository, and this function is given a pack.
682
+ const bindable = [];
683
+ for (const rel of contributes.personas ?? []) {
684
+ const file = path.join(packDir, rel);
685
+ const contained = inside(file);
686
+ if (contained.state === "outside") {
687
+ fail("packs", `\`${pack}\` declares the persona \`${rel}\`, which resolves outside the pack`);
688
+ continue;
689
+ }
690
+ if (contained.state === "absent") {
691
+ fail("packs", `\`${pack}\` declares the persona \`${rel}\`, which is not there`);
692
+ continue;
693
+ }
694
+ if (contained.state === "unreadable") {
695
+ fail("packs", `\`${pack}\`'s persona \`${rel}\` could not be resolved — ${contained.detail}. Only a missing path means absent`);
696
+ continue;
697
+ }
698
+ let text;
699
+ try {
700
+ text = fs.readFileSync(contained.real, "utf8");
701
+ } catch (cause) {
702
+ fail("packs", `\`${pack}\`'s persona \`${rel}\` could not be read — ${cause.code ?? cause.message}. Only a missing file means absent`);
703
+ continue;
704
+ }
705
+ const declared = validatePersona(text, { fail, pack, rel });
706
+ personas += 1;
707
+ // The key is the declared `name`, and the file's basename where it declares none. Two sources
708
+ // rather than one because the contract's five parts do not include a name — a persona missing
709
+ // it is not thereby unbindable, and `new persona` writes `personas/<name>.md`, so the basename
710
+ // is the author's own answer to the same question. Which one answered is printed, since a
711
+ // binding resolved by filename is a weaker fact than one resolved by declaration.
712
+ const basename = path.basename(rel, ".md");
713
+ bindable.push({
714
+ rel,
715
+ key: declared?.name ?? basename,
716
+ keyedBy: declared?.name ? "declaration" : "filename",
717
+ });
718
+ }
719
+
720
+ return { skills, personas, unreadableRoots, bindable };
721
+ }
722
+
723
+ /**
724
+ * One persona against the five-part contract, plus the tier no role may claim.
725
+ *
726
+ * **What this checks is PRESENCE, and that is narrower than "validated".** Four of the five parts are
727
+ * matched as headings, so a persona whose Charter, Autonomy reach, Memory scope and Read/write posture
728
+ * sections are all empty passes; and `tools:` is satisfied by any non-empty value, so `tools: []` passes.
729
+ * Both measured at the pre-commit checkpoint. The distinction matters because `core/operating/memory.md`
730
+ * frames this milestone as where a declared memory scope stops being prose a checker can read but not
731
+ * honour — a heading check reads it and does not honour it, and the sentence saying so belongs beside the
732
+ * check rather than only in the milestone's record.
733
+ *
734
+ * **Returns the name the persona declares, or `null` where it declares none** — the key the binding
735
+ * check below resolves a host agent file by. It is returned rather than re-parsed at the call site
736
+ * because the frontmatter has already been read here, and two parses of one block are two chances to
737
+ * disagree about what it said.
738
+ */
739
+ export function validatePersona(text, { fail, pack, rel }) {
740
+ const { fields, error } = parseFrontmatter(text);
741
+ if (!fields) {
742
+ fail("packs", `\`${pack}\`'s persona \`${rel}\` has no usable frontmatter${error ? ` — ${error}` : ""}, so it declares no \`tools:\` allow-list`);
743
+ return null;
744
+ }
745
+ for (const { part, find } of PERSONA_PARTS) {
746
+ if (!find(fields, text)) {
747
+ fail("packs", `\`${pack}\`'s persona \`${rel}\` is missing ${part} — the five-part contract core/personas/README.md fixes`);
748
+ }
749
+ }
750
+ // Prohibited is the one tier no role may act in, so a persona declaring it as a reach claims a
751
+ // permission that does not exist for anyone.
752
+ //
753
+ // **The first cut of this check flagged the word anywhere in the reach section, and its first run
754
+ // false-redded this repository's own `checkpoints` supervisor** — whose reach section says
755
+ // *"**Prohibited is not a reach** and does not appear here"*. That file is obeying the rule by
756
+ // explaining it, and the checker could not tell disclaiming from claiming. A false red is the failure
757
+ // that gets a whole recipe switched off (`.portulan/memory/` records `json.sh` costing exactly that),
758
+ // so the matcher is narrowed to an affirmative mention.
759
+ //
760
+ // **The limit, restated after the pre-commit checkpoint measured it WIDER than the first wording
761
+ // admitted.** This is a prose heuristic and prose defeats it. The splitter breaks on sentence
762
+ // punctuation and blank lines, so a *block* — a bullet list, which is the ordinary way a reach
763
+ // section is written — counts as one unit: a negation **anywhere in the same block** suppresses the
764
+ // finding, not merely one in the same sentence. Both of these pass:
765
+ //
766
+ // - Acts in Prohibited
767
+ // - This role does not merge
768
+ //
769
+ // Reach is Prohibited, and there is no ceiling above it
770
+ //
771
+ // The structural fix is a declared reach *field* the way `tools:` is a field — a persona-contract
772
+ // change, and not an implementer's to make. Named at this width so the gap is visible instead of
773
+ // assumed covered, which is the rule `core/operating/verification.md` legislates.
774
+ const reach = text.split(/^##\s+Autonomy reach\b.*$/im)[1]?.split(/^##\s/m)[0];
775
+ if (reach && claimsProhibited(reach)) {
776
+ fail(
777
+ "packs",
778
+ `\`${pack}\`'s persona \`${rel}\` names Prohibited in its Autonomy reach without disclaiming it. Prohibited is the one tier no ` +
779
+ `role may act in — a persona claiming it claims a permission nobody has (core/personas/README.md)`,
780
+ );
781
+ }
782
+ return { name: typeof fields.name === "string" && fields.name.trim() ? fields.name.trim() : null };
783
+ }
784
+
785
+ /** Walk one declared skills root to the fixed depth, validating each `SKILL.md` frontmatter. */
786
+ function walkSkills(root, { fail, report, pack, rel }, depth = 0) {
787
+ let found = 0;
788
+ const own = path.join(root, "SKILL.md");
789
+ // `lstat`, not `existsSync` — the latter FOLLOWS links, and a `SKILL.md` symlinked out of the pack
790
+ // under a contained root was opened, validated and counted as the pack's own. Containment covered the
791
+ // declared root and not the files found beneath it, which is the half-guarantee shape session 1 paid
792
+ // for twice. Found by the pre-commit checkpoint.
793
+ let ownStat = null;
794
+ try {
795
+ ownStat = fs.lstatSync(own);
796
+ } catch (cause) {
797
+ if (cause.code !== "ENOENT") {
798
+ fail("packs", `\`${pack}\`'s \`${path.join(rel, "SKILL.md")}\` could not be examined — ${cause.code ?? cause.message}`);
799
+ }
800
+ }
801
+ if (ownStat?.isSymbolicLink()) {
802
+ fail(
803
+ "packs",
804
+ `\`${pack}\`'s \`${path.join(rel, "SKILL.md")}\` is a symlink. It is refused rather than followed: a link under a contained root ` +
805
+ `is how a pack ships a file it does not contain, and containment that checks only the declared root is not containment`,
806
+ );
807
+ } else if (ownStat?.isFile()) {
808
+ validateSkill(own, { fail, pack, rel: path.join(rel, "SKILL.md") });
809
+ found += 1;
810
+ }
811
+ if (depth >= SKILL_DEPTH) {
812
+ // Reported, never silent. Only when there is something below to miss: announcing a stop over an
813
+ // empty directory would be noise, and a note nobody can act on trains a reader to skip the ones
814
+ // they can.
815
+ let below = [];
816
+ try {
817
+ below = fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory());
818
+ } catch {
819
+ below = [];
820
+ }
821
+ if (below.length) {
822
+ report(
823
+ "packs",
824
+ `\`${pack}\`'s skills root \`${rel}\` has ${below.length} ${below.length === 1 ? "directory" : "directories"} below the ${SKILL_DEPTH}-level walk bound, ${below.length === 1 ? "which was" : "which were"} NOT looked at. ` +
825
+ `A skill down there is neither validated nor counted — said out loud because a walk that stops quietly is a green over what it never opened`,
826
+ );
827
+ }
828
+ return found;
829
+ }
830
+
831
+ let entries;
832
+ try {
833
+ entries = fs.readdirSync(root, { withFileTypes: true });
834
+ } catch (cause) {
835
+ // Only ENOENT means absent. Anything else and the walk could not look, which must never be
836
+ // reported as a root with no skills in it — #108's shape, arriving in a new walker.
837
+ if (cause.code === "ENOENT") {
838
+ fail("packs", `\`${pack}\` declares the skills root \`${rel}\`, which is not there`);
839
+ return found;
840
+ }
841
+ fail(
842
+ "packs",
843
+ `\`${pack}\`'s skills root \`${rel}\` could not be read — ${cause.code ?? cause.message}. ` +
844
+ `Reported as unreadable rather than as empty: a walk that says "no skills here" over a directory it never opened is a green over nothing`,
845
+ );
846
+ return null;
847
+ }
848
+ for (const entry of entries) {
849
+ // A symlinked directory has `isDirectory() === false` and `isSymbolicLink() === true`, so the
850
+ // obvious `if (!isDirectory()) continue` SKIPS it — silently, with no finding, hiding whatever
851
+ // skills sit behind it. That is a false green of the walk's own kind, and it is reported rather
852
+ // than followed for the same reason the `SKILL.md` link is: following it is how a pack ships
853
+ // content it does not contain. Copilot, round 3 on #156.
854
+ if (entry.isSymbolicLink()) {
855
+ fail(
856
+ "packs",
857
+ `\`${pack}\`'s skills root \`${rel}\` contains \`${entry.name}\`, a symlinked directory. It is refused rather than followed or ` +
858
+ `skipped — following it lets a pack ship skills it does not contain, and skipping it silently would hide whatever is behind it`,
859
+ );
860
+ continue;
861
+ }
862
+ if (!entry.isDirectory()) continue;
863
+ const child = walkSkills(path.join(root, entry.name), { fail, report, pack, rel: path.join(rel, entry.name) }, depth + 1);
864
+ // A child that could not be read returns `null`, and `found += null` would add ZERO — not NaN,
865
+ // which is what the review that found this proposed; `null` coerces to 0 in numeric addition,
866
+ // measured. The conclusion was right and the mechanism was not, and the real defect is the worse
867
+ // of the two: an unreadable subdirectory would be silently counted as *no skills here*, which is
868
+ // exactly the "nothing looked recorded as nothing wrong" this walk was changed to stop — surviving
869
+ // one level down from the fix. Propagated instead, so the root reports UNREAD however deep the
870
+ // unreadable directory sits.
871
+ if (child === null) return null;
872
+ found += child;
873
+ }
874
+ return found;
875
+ }
876
+
877
+ /** One skill's frontmatter: the `name` and `description` that ARE the contract a host reads. */
878
+ export function validateSkill(file, { fail, pack, rel }) {
879
+ let text;
880
+ try {
881
+ text = fs.readFileSync(file, "utf8");
882
+ } catch (cause) {
883
+ fail("packs", `\`${pack}\`'s skill \`${rel}\` could not be read — ${cause.code ?? cause.message}`);
884
+ return;
885
+ }
886
+ const { fields, error } = parseFrontmatter(text);
887
+ if (!fields) {
888
+ fail("packs", `\`${pack}\`'s skill \`${rel}\` has no usable frontmatter${error ? ` — ${error}` : ""}. For a skill the block IS the contract, not decoration`);
889
+ return;
890
+ }
891
+ if (typeof fields.name !== "string" || !SLUG_PATTERN.test(fields.name)) {
892
+ fail(
893
+ "packs",
894
+ `\`${pack}\`'s skill \`${rel}\` has \`name: ${fields.name ?? "(absent)"}\`, which is not kebab-case. ` +
895
+ `The name is required here although the platform treats it as optional, so a skill's invocation name comes from the skill rather than from where it sits`,
896
+ );
897
+ }
898
+ if (typeof fields.description !== "string" || !fields.description.trim()) {
899
+ fail(
900
+ "packs",
901
+ `\`${pack}\`'s skill \`${rel}\` has an empty \`description\`. It is the only line a host reads before deciding whether to load the body, ` +
902
+ `so it carries the trigger — an empty one produces a skill that is never selected`,
903
+ );
904
+ }
905
+ }
906
+
907
+ const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
908
+
909
+ /**
910
+ * Does this reach section CLAIM Prohibited, as opposed to disclaiming it?
911
+ *
912
+ * Sentence-scoped, because the two readings sit in the same section and differ only by a negation. A
913
+ * sentence mentioning the tier alongside `not` / `never` / `no role` / `nobody` / `cannot` is the
914
+ * disclaimer every well-written persona contains — this repository's own supervisor is one — and
915
+ * flagging it made a correct file red on the check's first run.
916
+ */
917
+ function claimsProhibited(reach) {
918
+ for (const sentence of reach.split(/(?<=[.!?])\s+|\n{2,}/)) {
919
+ if (!/\bProhibited\b/.test(sentence)) continue;
920
+ if (/\b(?:not|never|no|nobody|none|cannot|can't|doesn't|does not|isn't|is not)\b/i.test(sentence)) continue;
921
+ return true;
922
+ }
923
+ return false;
924
+ }
925
+
926
+ /**
927
+ * The Pack Definition version a schema implements, read from its `$id` — the same argument as
928
+ * `schemaVersion` below, on the other version train. Separate rather than shared because the two
929
+ * `$id` shapes differ deliberately (`/spec/2.5/` and `/spec/pack/1.0/`), and a single regex loose
930
+ * enough to read both would read `/spec/pack/1.0/` as Workspace Definition version 1.0.
931
+ */
932
+ export function packSchemaVersion(schema) {
933
+ const match = /\/spec\/pack\/([0-9]+)\.([0-9]+)\//.exec(schema.$id ?? "");
934
+ if (!match) {
935
+ throw new DoctorError(
936
+ "the pack schema's `$id` does not carry a `/spec/pack/MAJOR.MINOR/` segment, so `doctor` " +
937
+ "cannot tell which Pack Definition version it implements",
938
+ );
939
+ }
940
+ return { major: Number(match[1]), minor: Number(match[2]) };
941
+ }
942
+
943
+ /**
944
+ * The Workspace Definition version a schema implements, read from its `$id`.
945
+ *
946
+ * Carried in the identifier rather than parsed out of `title`, because a machine fact read from a
947
+ * human sentence drifts the first time somebody rewords the sentence. Absent is a hard stop: a
948
+ * validator that cannot tell which version of the contract it implements cannot honestly say a
949
+ * manifest conforms to it.
950
+ */
951
+ export function schemaVersion(schema) {
952
+ const match = /\/spec\/([0-9]+)\.([0-9]+)\//.exec(schema.$id ?? "");
953
+ if (!match) {
954
+ throw new DoctorError(
955
+ "the schema's `$id` does not carry a `/spec/MAJOR.MINOR/` segment, so `doctor` cannot " +
956
+ "tell which Workspace Definition version it implements",
957
+ );
958
+ }
959
+ return { major: Number(match[1]), minor: Number(match[2]) };
960
+ }
961
+
962
+ function loadPackSchema({ packSchema, packSchemaPath }) {
963
+ if (packSchema) return packSchema;
964
+ return loadSchema({ schemaPath: packSchemaPath ?? DEFAULT_PACK_SCHEMA });
965
+ }
966
+
967
+ function loadSchema({ schema, schemaPath }) {
968
+ if (schema) return schema;
969
+ const file = schemaPath ?? DEFAULT_SCHEMA;
970
+ let source;
971
+ try {
972
+ source = fs.readFileSync(file, "utf8");
973
+ } catch (cause) {
974
+ throw new DoctorError(`could not read the schema at ${file}: ${cause.message}`);
975
+ }
976
+ try {
977
+ return JSON.parse(source);
978
+ } catch (cause) {
979
+ throw new DoctorError(`the schema at ${file} is not valid JSON: ${cause.message}`);
980
+ }
981
+ }
982
+
983
+ // ===========================================================================================
984
+ // 3b. Agent legibility
985
+ // ===========================================================================================
986
+ //
987
+ // Row 7's 2026-07-28 amendment: `doctor` scores agent legibility — the audit `docs/vision.md`'s
988
+ // influence map calls the **unclaimed niche**, "repo affordances scored by doctor".
989
+ //
990
+ // **It moves no exit code, and that is the design rather than a shortfall.** A score is a
991
+ // measurement; `doctor`'s exit codes are verdicts about conformance. A score that could fail a
992
+ // workspace would make the verdict a function of how much affordance prose somebody wrote, and
993
+ // every incentive it created would point at writing more of it.
994
+ //
995
+ // **Every carrier of the amendment says the `affordances` slot "is its input", and this reads six
996
+ // keys besides.** That is a reading, stated rather than taken in silence: the constitution's own
997
+ // gloss is *"repo affordances scored by doctor"* — the repository's affordances, of which the slot
998
+ // is the written half — and a score confined to the slot could not tell two workspaces apart at
999
+ // all. **The slot is the named input, not the only one.**
1000
+ //
1001
+ // What each dimension has in common is the property that makes a score honest: every one is
1002
+ // **optional in the Workspace Definition**, so it can genuinely be absent, and none of them
1003
+ // duplicates a check that already fails. A dimension that cannot vary measures nothing, and a
1004
+ // dimension that restates a hard failure is a second carrier of one fact.
1005
+ //
1006
+ // **Three candidates were dropped by that rule, and they are named because two of them were in this
1007
+ // session's own plan.** *A declared `tree`* and *a repo card for every repository the products name*
1008
+ // were both listed in task 0013's acceptance list; each is already a hard failure — `tree` for any
1009
+ // `kind: repository`, the cards through the `cross` check — so scoring them would have been a
1010
+ // guaranteed point for every workspace that got far enough to be scored, and a second carrier of a
1011
+ // verdict the tool already renders. *Executable verification* was the third, and it survived one
1012
+ // checkpoint before being measured: see `legibility` itself. **Seven dimensions is what the rule
1013
+ // leaves**, and the number is derived by the suite rather than trusted here.
1014
+
1015
+ /**
1016
+ * The headings that state limits, and it is a FORM check.
1017
+ *
1018
+ * A named table rather than a matcher clever enough to be wrong quietly — the same shape and the
1019
+ * same reason as `PERSONA_PARTS` above and as the writer table in `cli/compile.mjs`. What it
1020
+ * establishes is that a limits section is **present**, never that what it says is true or complete:
1021
+ * a heading with an empty body passes. `.portulan/products/portulan/affordances.md` states the rule
1022
+ * this scores in its own words — *"a legibility report that lists only strengths is marketing"* — and
1023
+ * a form check is the most a reader of the tree can hold anybody to.
1024
+ */
1025
+ const LIMIT_HEADINGS = [
1026
+ /^#{2,3}\s+What an agent must not assume\b/im,
1027
+ /^#{2,3}\s+.*\bmust not\b/im,
1028
+ /^#{2,3}\s+.*\bdo(es)? not\b/im,
1029
+ /^#{2,3}\s+(Honest )?[Ll]imits\b/im,
1030
+ /^#{2,3}\s+.*\bnot (guaranteed|checked|enforced)\b/im,
1031
+ ];
1032
+
1033
+ /**
1034
+ * Score one workspace's agent legibility.
1035
+ *
1036
+ * Returns `{ met, applicable, dimensions }`, where a dimension is
1037
+ * `{ id, title, met, applicable, why }`. **Inapplicable dimensions leave the denominator** rather
1038
+ * than counting as failures: a workspace with no products has nothing to declare affordances for,
1039
+ * and scoring it down for that would be grading it against a standard it is not under.
1040
+ *
1041
+ * Reads the tree only through paths the manifest declares, so it fetches nothing and infers nothing.
1042
+ */
1043
+ export function legibility(workspace, dir) {
1044
+ const dimensions = [];
1045
+ const add = (id, title, met, why, applicable = true) => dimensions.push({ id, title, met, applicable, why });
1046
+
1047
+ // **`verify` itself is deliberately NOT a dimension**, and the reason is the rule this list is
1048
+ // built on. The schema's first `oneOf` form requires `slots` and `verify` of every workspace that
1049
+ // is not a pointer, a pointer returns long before this line, and a manifest that fails the schema
1050
+ // returns earlier still — so *does this workspace declare executable verification* is a question
1051
+ // whose answer is always yes by the time anything here could ask it. It was scored for exactly one
1052
+ // pre-commit checkpoint, which measured it and found a dimension that could not vary: a constant
1053
+ // +1 dressed as a measurement, in a list whose whole claim is that every entry can genuinely be
1054
+ // absent. What a recipe DECLARES about itself still varies, which is the dimension below.
1055
+ const recipes = workspace.verify?.recipes ?? [];
1056
+ add(
1057
+ "requires",
1058
+ "recipes that declare what they need",
1059
+ recipes.every((r) => Array.isArray(r.requires) && r.requires.length > 0),
1060
+ "`could not run` stays distinguishable from `ran and failed`, which is what stops a missing tool reading as a pass",
1061
+ recipes.length > 0,
1062
+ );
1063
+ add(
1064
+ "gates",
1065
+ "a gate policy a machine reads",
1066
+ typeof workspace.gates === "string" && workspace.gates.length > 0,
1067
+ "the prose gate map argues the tiers; the policy beside it is the half something can compile into enforcement",
1068
+ );
1069
+ add("dod", "a stated bar for done", Boolean(workspace.slots?.dod), "an agent can tell finished from working, without inferring it from the tests that happen to exist");
1070
+ add(
1071
+ "memory",
1072
+ "a memory store with a generated index",
1073
+ Boolean(workspace.slots?.memory) && Boolean(workspace.memory?.index),
1074
+ "recall is a file an agent reads, not a directory it walks and summarises differently each time",
1075
+ );
1076
+ add(
1077
+ "handoffs",
1078
+ "a handoff series with a generated index",
1079
+ Boolean(workspace.slots?.handoffs) && Boolean(workspace.handoffs?.index),
1080
+ "why a decision was taken survives the session that took it, and is reachable without reading the series",
1081
+ );
1082
+
1083
+ // The slot the amendment names, read for both of its dimensions in one pass.
1084
+ //
1085
+ // **Per DOCUMENT, not per product**, and the distinction is the whole of #228 item 1. `products`
1086
+ // resolve `product.affordances ?? workspace.affordances`, so N products inheriting one
1087
+ // workspace-level default all name the same `rel` — which was read N times, and, when it could not
1088
+ // be read, counted N times. `unreadable` was therefore a count of **products**, under a name that
1089
+ // says documents, and both dimensions below are about documents.
1090
+ //
1091
+ // Nothing read the magnitude — `limits` asks only `unreadable === 0`, so no verdict moved — which
1092
+ // is exactly why it is worth fixing now rather than after something starts reading it. A count
1093
+ // wrong about its own unit, in the tool whose subject is claims being checkable.
1094
+ //
1095
+ // Triaged out of [#227](https://github.com/sleepy-panda-srl/portulan/pull/227) at the two-fix-round
1096
+ // bound and **dropped on 2026-08-12 for scope, not on merits** — "nothing here opens
1097
+ // cli/doctor.mjs" — which is the disposition this change reverses now that something does.
1098
+ const products = workspace.products ?? [];
1099
+ const docs = [];
1100
+ const seen = new Set();
1101
+ let unreadable = 0;
1102
+ for (const product of products) {
1103
+ const rel = product.affordances ?? workspace.affordances;
1104
+ if (!rel || seen.has(rel)) continue;
1105
+ seen.add(rel);
1106
+ try {
1107
+ docs.push({ rel, text: fs.readFileSync(path.resolve(dir, rel), "utf8") });
1108
+ } catch {
1109
+ // Counted, never skipped: a document that could not be read has not been found to state
1110
+ // its limits, and treating it as absent and as present are both claims nothing established.
1111
+ unreadable += 1;
1112
+ }
1113
+ }
1114
+ add(
1115
+ "affordances",
1116
+ "affordances declared for every product",
1117
+ products.length > 0 && products.every((p) => p.affordances || workspace.affordances),
1118
+ "what an agent may rely on is written down per product — its own slot, or the workspace-level default it inherits",
1119
+ products.length > 0,
1120
+ );
1121
+ add(
1122
+ "limits",
1123
+ "affordances that state limits, not only strengths",
1124
+ docs.length > 0 && unreadable === 0 && docs.every((d) => LIMIT_HEADINGS.some((h) => h.test(d.text))),
1125
+ "a legibility document listing only what works is marketing; the half an agent needs is what it must not assume",
1126
+ docs.length > 0 || unreadable > 0,
1127
+ );
1128
+
1129
+ const applicable = dimensions.filter((d) => d.applicable);
1130
+ return { met: applicable.filter((d) => d.met).length, applicable: applicable.length, dimensions };
1131
+ }
1132
+
1133
+ /**
1134
+ * Inspect one workspace directory.
1135
+ * Returns `{ dir, workspace, findings, stats }`. A finding is `{ severity, check, message }`,
1136
+ * where severity is `fail` (exit 1) or `report` (worth reading, not a verdict).
1137
+ */
1138
+ export async function inspect(workspaceDir, options = {}) {
1139
+ const schema = loadSchema(options);
1140
+ const packSchema = loadPackSchema(options);
1141
+ const dir = path.resolve(workspaceDir);
1142
+ const findings = [];
1143
+ // `unretirable` counts records stating no `Retire when:` condition — retirable by a human
1144
+ // re-reading them, never by the condition-driven pass, which is the sense that matters here.
1145
+ const stats = { records: 0, rules: 0, sealed: 0, linked: 0, claims: 0, unverifiable: 0, bytes: 0, unretirable: 0, unassessed: 0, packs: 0 };
1146
+ const fail = (check, message) => findings.push({ severity: "fail", check, message });
1147
+ const report = (check, message) => findings.push({ severity: "report", check, message });
1148
+
1149
+ // ---- the manifest itself
1150
+ const manifestPath = path.join(dir, "workspace.json");
1151
+ let workspace;
1152
+ try {
1153
+ workspace = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
1154
+ } catch (cause) {
1155
+ // A verdict about the workspace, not an environment failure: exit 1, not 2.
1156
+ fail("schema", `no readable manifest at ${path.relative(process.cwd(), manifestPath)} — ${cause.message}`);
1157
+ return { dir, workspace: null, findings, stats };
1158
+ }
1159
+
1160
+ // Which contract is this manifest written against? Checked BEFORE conformance, because grading
1161
+ // a manifest against a definition it was not written for produces confident nonsense: an
1162
+ // optional slot added in a later MINOR would come back as an unexpected property, and the
1163
+ // report would blame the author for using the spec correctly.
1164
+ //
1165
+ // Both mismatches are exit 2 rather than 1. "This workspace is wrong" and "I do not implement
1166
+ // the contract it names" are different statements, and only the first is a verdict `doctor` is
1167
+ // entitled to make — see ../.portulan/memory/a-checker-must-refuse-what-it-cannot-check.md.
1168
+ const here = schemaVersion(schema);
1169
+ const declared = /^([0-9]+)\.([0-9]+)$/.exec(workspace?.portulan?.spec ?? "");
1170
+ if (declared) {
1171
+ const [major, minor] = [Number(declared[1]), Number(declared[2])];
1172
+ if (major !== here.major) {
1173
+ throw new DoctorError(
1174
+ `${path.relative(process.cwd(), dir)} declares Workspace Definition ${major}.${minor}; ` +
1175
+ `this schema is ${here.major}.${here.minor}. A MAJOR difference means a migration ` +
1176
+ `exists and this validator is not the one to run — ` +
1177
+ // Named since milestone 7 session 9. This sentence said a migration exists and
1178
+ // stopped there, which is a refusal that describes a remedy nobody could reach:
1179
+ // the tool did not exist when it was written. It reads the direction too, since
1180
+ // a workspace AHEAD of this bundle is not one `upgrade` can help with.
1181
+ (major > here.major
1182
+ ? "and this validator is OLDER than that workspace, so upgrade the CLI rather than the workspace."
1183
+ : "run `portulan upgrade <workspace-dir>` to see what it owes, and `--write` to apply it."),
1184
+ );
1185
+ }
1186
+ if (minor > here.minor) {
1187
+ throw new DoctorError(
1188
+ `${path.relative(process.cwd(), dir)} declares Workspace Definition ${major}.${minor}, ` +
1189
+ `which is ahead of the ${here.major}.${here.minor} this schema implements. Refusing ` +
1190
+ `rather than grading it against an older contract and reporting the difference as errors — ` +
1191
+ // The MAJOR arm above names its remedy and this one stopped at "Refusing", which is
1192
+ // one rule enforced at two sites and repaired at one — `0020`, ten lines apart, in
1193
+ // the change that repaired the other site. Caught at the pre-commit checkpoint.
1194
+ "and this validator is OLDER than that workspace, so upgrade the CLI rather than the workspace.",
1195
+ );
1196
+ }
1197
+ if (minor < here.minor) {
1198
+ report(
1199
+ "schema",
1200
+ `written against Workspace Definition ${major}.${minor}; this schema is ` +
1201
+ `${here.major}.${here.minor}. Still valid — MINOR is additive — but slots added since ` +
1202
+ `${major}.${minor} will simply be absent`,
1203
+ );
1204
+ }
1205
+ }
1206
+
1207
+ const errors = validate(schema, workspace);
1208
+ for (const e of errors) {
1209
+ fail("schema", `${e.pointer || "/"} — ${e.message}`);
1210
+ }
1211
+ if (errors.length) {
1212
+ // The later checks read the manifest's shape. Running them on a manifest that failed the
1213
+ // schema would produce noise at best and a crash at worst — so they are skipped, and the
1214
+ // skip is said out loud rather than left to look like a pass.
1215
+ report("schema", "path, cross-field, claims and provenance checks were skipped: the manifest must conform first");
1216
+ return { dir, workspace, findings, stats };
1217
+ }
1218
+
1219
+ // ---- residence: exactly one workspace governs a repository
1220
+ //
1221
+ // The maintainer's ruling of 2026-07-30, recorded in ../.portulan/proposals/0017. Two of its three
1222
+ // refusals are here, where a single manifest is enough to see the fault; the third needs to look
1223
+ // outside this directory and is below, with the repo cards.
1224
+ //
1225
+ // These are `doctor`'s and not the schema's ON PURPOSE, and the reason is mechanical rather than
1226
+ // stylistic: the schema's own failures return above at "the manifest must conform first", so a
1227
+ // shape refused by the schema is a shape whose refusal never prints the sentence it exists to
1228
+ // print. The rule is a sentence a human has to read — the whole point is that the reader learns
1229
+ // *why* two workspaces are one too many — so the schema permits the shape and this refuses it.
1230
+ const GOVERNS =
1231
+ "a repository is governed by exactly one workspace — it carries its own full workspace, or a " +
1232
+ "pointer to the workspace that names it, never both";
1233
+ // Everything a pointer may carry. `summary` is here because one line an agent reads before loading
1234
+ // anything else costs nothing and is the difference between "governed elsewhere" and a bare name.
1235
+ const POINTER_KEYS = new Set(["portulan", "name", "summary", "kind", "governed_by"]);
1236
+ // Read off the schema rather than written out again. The cross-repository check below needs to tell a
1237
+ // kind it UNDERSTANDS from one it does not, and a second hand-maintained copy of that list is the
1238
+ // defect class this whole change is partly about — one fact, two carriers, only one of them checked.
1239
+ // Derived, so a fifth kind joins both at once or neither.
1240
+ const KNOWN_KINDS = new Set(schema.properties?.kind?.enum ?? []);
1241
+
1242
+ if (workspace.kind === "pointer") {
1243
+ const carried = Object.keys(workspace).filter((key) => !POINTER_KEYS.has(key)).sort();
1244
+ if (carried.length) {
1245
+ fail(
1246
+ "residence",
1247
+ `this manifest declares \`kind: "pointer"\` and also carries ` +
1248
+ `${carried.map((k) => `\`${k}\``).join(", ")} — ${GOVERNS}. A pointer names its ` +
1249
+ "governor and holds no policy of its own; the moment it holds any of it there are " +
1250
+ "two policy layers for one repository and nothing holding them in agreement",
1251
+ );
1252
+ }
1253
+ // A pointer is not a small workspace, it is a different object: no slots to resolve, no
1254
+ // recipes, no products, no packs, no store, no cards. Every check below reads one of those, so
1255
+ // running them here would grade the absence of things a pointer correctly does not have —
1256
+ // `verify.default` alone would fail every compliant pointer, naming a recipe list that is empty
1257
+ // because the manifest is right. Skipped, and SAID, on the standing rule that a check which
1258
+ // disappears without a word is worse than one that admits what it could not reach.
1259
+ // Blank reads as absent HERE too, because the resolver one file away now treats it that way and
1260
+ // two carriers of one fact are this repository's signature defect. Reporting *"delivered through
1261
+ // ` `"* while `resolveGovernor` ignores that value is a report describing a constraint the tool
1262
+ // does not apply — worse than either behaviour alone, since a reader would go looking for the
1263
+ // marketplace it names. Found by Copilot on the round that reviewed the fix, and by the
1264
+ // pre-commit checkpoint independently, both of them at the site the fix did not reach.
1265
+ const rawFeed = workspace.governed_by?.feed;
1266
+ const feed = typeof rawFeed === "string" && rawFeed.trim() !== "" ? rawFeed : undefined;
1267
+ report(
1268
+ "residence",
1269
+ `governed by \`${workspace.governed_by?.workspace}\`` +
1270
+ (feed ? `, delivered through \`${feed}\`` : "") +
1271
+ " — no workspace resides here, and this manifest says so rather than improvising one",
1272
+ );
1273
+ // ---- and now the name is dereferenced, which it was not until milestone 7's discovery
1274
+ //
1275
+ // This report said *"Nothing was fetched: resolving a pointer to the workspace it names is
1276
+ // discovery, and the roots are named rather than found (milestone 7)"*. Discovery has landed
1277
+ // (./discover.mjs), so the sentence became a document denying a capability that exists —
1278
+ // condition 4 of ../.portulan/dod.md pointing the other way, which this repository has now
1279
+ // corrected often enough to expect.
1280
+ //
1281
+ // **Nothing is fetched is still true and is still said**, by the resolver's own sentence: the
1282
+ // host's installed-plugin record is read from disk and no network call is made anywhere in
1283
+ // this path. What changed is that a name on disk is now looked up, not that anything is
1284
+ // downloaded.
1285
+ //
1286
+ // The verdict is REPORTED and never graded, and that boundary is load-bearing rather than
1287
+ // cautious. `not-installed` is not a defect in this manifest — a pointer is correct while its
1288
+ // governor is uninstalled, which is the ordinary state of a fresh clone — so failing here
1289
+ // would red every honest pointer on every machine that had not run an install yet, including
1290
+ // CI, where nothing is ever installed. What the reader gets instead is the root, and what to
1291
+ // run against it.
1292
+ // `await`ed, though today's resolver is synchronous and `await` on a plain value is a no-op.
1293
+ // `options.discover` is an injection point, `inspect` is already `async`, and a hook that ever
1294
+ // returns a promise would otherwise yield `governor.state === undefined` — a report about a
1295
+ // Promise rather than about a host. One keyword now against a class of silent wrong answer
1296
+ // later (Copilot, round 2).
1297
+ const resolve = options.discover ?? ((governedBy) => resolveGovernor(governedBy, options));
1298
+ const governor = await resolve(workspace.governed_by);
1299
+ // The resolver owns the sentence — one carrier, so every surface reporting this prints the same
1300
+ // words rather than four paraphrases. The fallback names what the resolver answered rather than
1301
+ // inventing prose: a resolver returning no sentence is a defect in the resolver, and a report
1302
+ // that hid it behind something still looking like a report would be the worse failure.
1303
+ //
1304
+ // **The fallback had that defect itself, in the line that claimed to prevent it** (#182 item 2).
1305
+ // It interpolated `governor.state` directly, so a verdict object carrying no `state` printed the
1306
+ // literal word `undefined` inside the one sentence whose whole job is to say a resolver
1307
+ // misbehaved. It now distinguishes three shapes — a state, a state that is not a string, and no
1308
+ // state at all — because "no `state` at all" said of `{state: 123}` is the same class of wrong
1309
+ // one revision later, which is what the pre-commit checkpoint found by feeding it that value.
1310
+ //
1311
+ // Deliberately NOT changed: a resolver that throws, or returns `null` or `undefined`, still
1312
+ // propagates to this tool's top-level catch and exits **2** — *could not run*, which is what that
1313
+ // catch's own header says of anything reaching it, a defect in `doctor` included. Measured four
1314
+ // ways through `run()` before any of this was written, and three of the four were already right.
1315
+ // And the reachability is worth recording so nobody prices this above what it is: `options.discover`
1316
+ // is a test seam, every `resolveGovernor` return path sets `sentence`, so no shipped path reaches
1317
+ // the fallback at all. It is the most marginal of #182's three items.
1318
+ const answered = () => {
1319
+ if (typeof governor.state === "string") return `\`${governor.state}\``;
1320
+ if ("state" in governor) return `a \`state\` that is not a string (\`${typeof governor.state}\`)`;
1321
+ const keys = Object.keys(governor);
1322
+ return `no \`state\` at all (keys: ${keys.length ? keys.map((k) => `\`${k}\``).join(", ") : "none"})`;
1323
+ };
1324
+ report("residence", governor.sentence ?? `the pointer resolver answered ${answered()} and supplied no sentence`);
1325
+ if (governor.state === "resolved") {
1326
+ report(
1327
+ "residence",
1328
+ `run \`doctor ${display(governor.root)}\` to grade it — this run judged the pointer and not ` +
1329
+ "the workspace it names, and a green pointer is not a statement that its governor is green",
1330
+ );
1331
+ }
1332
+ report(
1333
+ "residence",
1334
+ "the governing-workspace checks did not run here — path slots, cross-field, packs, claims, " +
1335
+ "enforcement, provenance and the store reports all read a policy layer this manifest " +
1336
+ "correctly does not carry. They run where the workspace resides, and a green pointer is " +
1337
+ "not a statement that the workspace it names is green",
1338
+ );
1339
+ return { dir, workspace, findings, stats, governor };
1340
+ }
1341
+
1342
+ if (workspace.governed_by) {
1343
+ fail(
1344
+ "residence",
1345
+ `this manifest declares \`kind: "${workspace.kind}"\` — a governing workspace — and also a ` +
1346
+ `\`governed_by\` pointer at \`${workspace.governed_by.workspace}\`, and ${GOVERNS}. This ` +
1347
+ "is the refusal above from the other side: a workspace that carries the policy layer AND " +
1348
+ "names another as its governor is the dual management the ruling refuses",
1349
+ );
1350
+ }
1351
+
1352
+ // ---- path slots
1353
+ // Containment is judged between REAL paths on both sides. Resolving only the target would
1354
+ // report a false escape wherever the workspace's own path runs through a symlink — on macOS
1355
+ // `/tmp` is one, which is where the tests build their scratch workspaces.
1356
+ let realDir = dir;
1357
+ try {
1358
+ realDir = fs.realpathSync(dir);
1359
+ } catch { /* the manifest read below reports a workspace that is not there */ }
1360
+ const inside = (target) => {
1361
+ const rel = path.relative(realDir, target);
1362
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
1363
+ };
1364
+
1365
+ const resolvePath = (value, kind, label) => {
1366
+ const target = path.resolve(dir, value);
1367
+ let stat = null;
1368
+ try {
1369
+ stat = fs.statSync(target);
1370
+ } catch {
1371
+ fail("paths", `${label} points at \`${value}\`, which does not exist`);
1372
+ return;
1373
+ }
1374
+ if (kind === "file" && !stat.isFile()) fail("paths", `${label} points at \`${value}\`, which is not a file`);
1375
+ if (kind === "dir" && !stat.isDirectory()) fail("paths", `${label} points at \`${value}\`, which is not a directory`);
1376
+ // Containment is tested on the REAL path, so a symlink out of the workspace is seen for
1377
+ // what it is. `realpathSync` is only safe after the stat above proved the target exists.
1378
+ let real = target;
1379
+ try {
1380
+ real = fs.realpathSync(target);
1381
+ } catch { /* keep the lexical path; the stat already succeeded, so this is unusual */ }
1382
+ if (!inside(real)) {
1383
+ // Reported, never failed. A workspace embedded in a larger repository may legitimately
1384
+ // reach for a shared document, and failing would make the tool wrong about a case it
1385
+ // cannot see. Two slots are *expected* to escape and are noted as unremarkable rather
1386
+ // than flagged: `constitution`, because a product's constitution normally lives with
1387
+ // the product, and `tree`, which names the repository the workspace sits inside and
1388
+ // would be pointing at the wrong thing if it did not escape.
1389
+ const ordinary = {
1390
+ "slots.constitution": "a constitution usually lives with the product",
1391
+ tree: "a workspace usually sits inside the tree it describes",
1392
+ }[label];
1393
+ report(
1394
+ "paths",
1395
+ `${label} resolves outside the workspace directory (\`${value}\`)` +
1396
+ (ordinary
1397
+ ? ` — ordinary here: ${ordinary}`
1398
+ : " — only `constitution` and `tree` ordinarily do, and neither is required to"),
1399
+ );
1400
+ }
1401
+ };
1402
+
1403
+ for (const [name, kind] of Object.entries(PATH_SLOTS)) {
1404
+ if (workspace.slots?.[name]) resolvePath(workspace.slots[name], kind, `slots.${name}`);
1405
+ }
1406
+ if (workspace.affordances) resolvePath(workspace.affordances, "file", "affordances");
1407
+ if (workspace.tree) resolvePath(workspace.tree, "dir", "tree");
1408
+ // Added with the `gates` key in Workspace Definition 2.1. It shipped for one checkpoint without
1409
+ // this line while ../spec/slots.md already promised "What `doctor` checks: that the path
1410
+ // resolves" — a manifest naming a policy file that did not exist validated GREEN. Caught at the
1411
+ // pre-commit checkpoint, and it is exactly the shape of
1412
+ // ../.portulan/memory/a-mandate-nothing-checks-is-already-broken.md: the mandate was written in
1413
+ // the same change as the key, and nothing looked.
1414
+ if (workspace.gates) resolvePath(workspace.gates, "file", "gates");
1415
+ // Added with the `memory` object in Workspace Definition 2.3, in the same change as the key —
1416
+ // because the `gates` line above records what happens when it is not: a manifest naming a file
1417
+ // that does not exist, validating GREEN, for one whole checkpoint.
1418
+ if (workspace.memory?.index?.path) resolvePath(workspace.memory.index.path, "file", "memory.index.path");
1419
+ (workspace.products ?? []).forEach((product, i) => {
1420
+ resolvePath(product.product, "file", `products[${i}].product`);
1421
+ if (product.affordances) resolvePath(product.affordances, "file", `products[${i}].affordances`);
1422
+ });
1423
+ // Reads `verify.recipes` directly, and that is deliberate now that `cli/recipe-set.mjs` is the one
1424
+ // carrier of the RUNNABLE set. This is not a reader of that set: it validates the workspace's own
1425
+ // DECLARATION — that each declared recipe's `doc` resolves — which is a claim about this manifest,
1426
+ // not about what CI will run. A composed recipe's `doc` resolves from its own pack root and is that
1427
+ // pack's to validate. The same distinction governs the `verify.default` cross-check below. Both are
1428
+ // exempt from the undeclared-reader sweep in `cli/recipe-set.live.test.mjs` for this reason, and
1429
+ // the exemption is named there too so the two cannot drift apart.
1430
+ (workspace.verify?.recipes ?? []).forEach((recipe, i) => {
1431
+ if (recipe.doc) resolvePath(recipe.doc, "file", `verify.recipes[${i}].doc`);
1432
+ });
1433
+
1434
+ // ---- cross-field
1435
+ //
1436
+ // Scoped to the workspace's OWN recipes on purpose, and it stays that way now that a pack's
1437
+ // recipes compose into the runnable set. `verify.default` is a bare slug (`$defs/slug`) and a
1438
+ // composed id is `<category>/<name>:<id>`, so a composed recipe can never be named here — widening
1439
+ // this check to the composed union would accept nothing new and would quietly suggest that naming
1440
+ // a pack's recipe as the default is a thing a manifest may do. Row 7 forbids exactly that: a
1441
+ // composed recipe may never become the workspace's `verify.default`.
1442
+ const ids = (workspace.verify?.recipes ?? []).map((r) => r.id);
1443
+ if (!ids.includes(workspace.verify?.default)) {
1444
+ fail("cross", `verify.default names \`${workspace.verify?.default}\`, which is not among the declared recipes (${ids.join(", ") || "none"})`);
1445
+ }
1446
+
1447
+ // Uniqueness is `verify.default`'s missing sibling. The schema's `uniqueItems` compares whole
1448
+ // objects, so two recipes sharing an id and differing anywhere else pass it — and then naming
1449
+ // that id resolves to whichever the reader happens to look at first.
1450
+ const duplicates = (list, what) => {
1451
+ const seen = new Set();
1452
+ for (const id of list) {
1453
+ if (seen.has(id)) fail("cross", `two ${what} share the id \`${id}\`, so naming it resolves to either`);
1454
+ seen.add(id);
1455
+ }
1456
+ };
1457
+ duplicates(ids, "verify recipes");
1458
+ duplicates((workspace.products ?? []).map((p) => p.id), "products");
1459
+
1460
+ const cardNames = new Set();
1461
+ if (workspace.slots?.repos) {
1462
+ const reposDir = path.resolve(dir, workspace.slots.repos);
1463
+ try {
1464
+ for (const entry of fs.readdirSync(reposDir)) {
1465
+ if (entry.endsWith(".md") && entry !== "README.md") cardNames.add(entry.slice(0, -3));
1466
+ }
1467
+ } catch { /* the missing directory is already a `paths` failure */ }
1468
+ }
1469
+
1470
+ (workspace.products ?? []).forEach((product, i) => {
1471
+ for (const name of product.repos ?? []) {
1472
+ // Exact basename, never a substring: `portulan` must not be satisfied by
1473
+ // `portulan-internal.md`.
1474
+ if (!cardNames.has(name)) {
1475
+ fail("cross", `products[${i}].repos names \`${name}\`, for which there is no card in the repos slot`);
1476
+ }
1477
+ }
1478
+ if (!product.affordances && !workspace.affordances) {
1479
+ report("cross", `products[${i}] (\`${product.id}\`) has neither its own affordances nor an inherited workspace-level default`);
1480
+ }
1481
+ });
1482
+
1483
+ // Proposal 0005, accepted 2026-07-25 and applied here. A `repository` workspace is the policy layer
1484
+ // of a repository that is present, so it always has an answer; `demo` and `portfolio` describe
1485
+ // repositories that are not beside them and genuinely do not.
1486
+ //
1487
+ // This constraint cannot live in the schema: a conditional dependency between two keys needs `if`/
1488
+ // `then` or `dependentRequired`, and neither is in the subset spec/README.md declares. So it is here,
1489
+ // and spec/slots.md states it as a rule the schema does not carry. That asymmetry is a real cost —
1490
+ // a constraint invisible to anyone reading the schema alone — and it is stated rather than hidden.
1491
+ if (workspace.kind === "repository" && !workspace.tree) {
1492
+ fail(
1493
+ "cross",
1494
+ "a `repository` workspace must declare `tree` — it is the policy layer of a repository that " +
1495
+ "is present, so it has an answer, and without one every repo-card and gate-map claim " +
1496
+ "silently degrades from checked to unverifiable",
1497
+ );
1498
+ }
1499
+
1500
+ // The residence ruling's third refusal, and the only one that has to look outside this directory:
1501
+ // a repository this workspace NAMES must not carry a governing workspace of its own. Visibility is
1502
+ // the whole difficulty — a portfolio workspace declares no `tree`, so it has no path to the
1503
+ // repositories it covers — and it is bought the way the packs resolver buys its own, with named
1504
+ // roots rather than discovery. `--repo-root` is to repo cards what `--pack-root` is to packs.
1505
+ //
1506
+ // What this does NOT do, stated because the asymmetry is real: the refusal runs from the naming
1507
+ // workspace outward. A repository carrying a full workspace cannot see a portfolio that claims it,
1508
+ // and nothing here changes that.
1509
+ const repoRoots = options.repoRoots ?? [];
1510
+ if (cardNames.size) {
1511
+ if (repoRoots.length === 0) {
1512
+ report(
1513
+ "residence",
1514
+ `governance of the ${cardNames.size} repositor${cardNames.size === 1 ? "y" : "ies"} this ` +
1515
+ "workspace names was not checked — no `--repo-root` was given, so nothing looked for " +
1516
+ "a workspace on that side. " +
1517
+ "Reported rather than passed over: the roots are named, never discovered, so silence " +
1518
+ "here would be indistinguishable from a clean result",
1519
+ );
1520
+ } else {
1521
+ for (const name of [...cardNames].sort()) {
1522
+ let found = null;
1523
+ for (const root of repoRoots) {
1524
+ const candidate = path.join(root, name, ".portulan", "workspace.json");
1525
+ if (fs.existsSync(candidate)) {
1526
+ found = candidate;
1527
+ break;
1528
+ }
1529
+ }
1530
+ if (!found) {
1531
+ report(
1532
+ "residence",
1533
+ `\`${name}\` carries no manifest under any named root — either it is not governed ` +
1534
+ "by Portulan at all, or it is not checked out where this run could see it. " +
1535
+ "Those two are not distinguished here, and neither is a failure",
1536
+ );
1537
+ continue;
1538
+ }
1539
+ // A workspace that names its own repository finds ITSELF at the root — one manager seen
1540
+ // twice, not two managers. Customer zero is exactly this shape: `.portulan/` names the
1541
+ // card `portulan`, so the first run of this check against a root holding the Portulan
1542
+ // checkout would have refused the very arrangement the ruling permits. Compared on the
1543
+ // REAL path, because the root is commonly reached through a symlink and a lexical
1544
+ // comparison would miss the identity and print a false red.
1545
+ let identical = false;
1546
+ try {
1547
+ identical = fs.realpathSync(found) === fs.realpathSync(manifestPath);
1548
+ } catch { /* one of them moved mid-run; fall through and grade what is there */ }
1549
+ if (identical) {
1550
+ report(
1551
+ "residence",
1552
+ `\`${name}\` resolves to this manifest itself — one workspace seen from outside, ` +
1553
+ "which is a workspace governing its own repository and is the arrangement the " +
1554
+ "ruling permits rather than the one it refuses",
1555
+ );
1556
+ continue;
1557
+ }
1558
+ let other;
1559
+ try {
1560
+ other = JSON.parse(fs.readFileSync(found, "utf8"));
1561
+ } catch (cause) {
1562
+ report(
1563
+ "residence",
1564
+ `\`${name}\` has a manifest at ${display(found)} that could not be read — ` +
1565
+ `${cause.message}. Reported, not failed: an unreadable file is not evidence of ` +
1566
+ "double governance, and refusing on it would make this check wrong about a " +
1567
+ "repository nobody looked into",
1568
+ );
1569
+ continue;
1570
+ }
1571
+ // A manifest at a named root is READ, never validated — `doctor` grades one workspace per
1572
+ // run and this is somebody else's. Everything below follows from that one fact, and it
1573
+ // took two rounds to apply it consistently: this check refuses only what a manifest
1574
+ // clearly DECLARES, and reports every shape it merely fails to understand. A refusal
1575
+ // built on a field this run never validated would be a verdict about a workspace nobody
1576
+ // graded.
1577
+ //
1578
+ // The first miss was a pointer with no `governed_by.workspace`, refused for "naming
1579
+ // `undefined`" — a conflicting-governor verdict about a manifest naming no governor at
1580
+ // all. The second was its sibling one field over, and it survived the fix to the first:
1581
+ // a manifest with a MISSING or unrecognised `kind` fell into the governing branch and was
1582
+ // refused as `kind: "undefined"`. Both found by Copilot on #135, rounds 1 and 4, which is
1583
+ // the sibling class the 2026-07-27 ruling names — found here in the very change that
1584
+ // quotes that ruling twice.
1585
+ if (!KNOWN_KINDS.has(other.kind)) {
1586
+ report(
1587
+ "residence",
1588
+ `\`${name}\` has a manifest at ${display(found)} declaring no recognisable ` +
1589
+ `\`kind\` (${JSON.stringify(other.kind)}). That is a defect in that manifest ` +
1590
+ "and `doctor` says so where it runs; it is not evidence about governance, so " +
1591
+ "nothing is refused here",
1592
+ );
1593
+ } else if (other.kind !== "pointer") {
1594
+ fail(
1595
+ "residence",
1596
+ `\`${name}\` is named by this workspace and carries its own \`kind: ` +
1597
+ `"${other.kind}"\` workspace at ${display(found)}, and ${GOVERNS}. One of the ` +
1598
+ "two has to become a pointer, and which one is the customer's choice — both " +
1599
+ "residences carry full functionality, so nothing is lost either way",
1600
+ );
1601
+ } else if (typeof other.governed_by?.workspace !== "string" || other.governed_by.workspace.trim() === "") {
1602
+ // The question is **is this a usable governor name**, not *is the key present* — the
1603
+ // third gap of this same class in this one block, and the one the first two fixes
1604
+ // left. `=== undefined` catches ABSENT and not INVALID, so `""`, `null` and a
1605
+ // non-string fell through to the conflicting-governor branch below and were refused
1606
+ // for naming a governor they do not name: a **false red** about somebody else's
1607
+ // manifest, in the block whose own rule is *read, never validated*.
1608
+ //
1609
+ // Blank counts as no name, which is the rule `cli/discover.mjs` now enforces at the
1610
+ // other site of this same operation. **Non-blank string**, deliberately, not *usable
1611
+ // slug*: a padded or otherwise illegal name is still a name the manifest DECLARES, and
1612
+ // judging its legality would be validating somebody else's workspace — the one thing
1613
+ // this block forbids. So `" sleepy-panda "` stays a conflict rather than becoming
1614
+ // "no name", and a test pins that boundary. The schema would refuse `""` through `$defs/slug`, but the schema
1615
+ // never runs here, and that is the whole point of read-not-validated.
1616
+ // ([#141](https://github.com/sleepy-panda-srl/portulan/issues/141), found by Copilot
1617
+ // on #135 and filed rather than fixed there.)
1618
+ const named = other.governed_by?.workspace;
1619
+ report(
1620
+ "residence",
1621
+ `\`${name}\` carries a pointer at ${display(found)} that names no usable governing ` +
1622
+ `workspace${named === undefined ? "" : ` (\`governed_by.workspace\` is ${JSON.stringify(named)})`}. ` +
1623
+ "That is a defect in that manifest and `doctor` says so where it " +
1624
+ "runs; it is not evidence about governance, so nothing is refused here",
1625
+ );
1626
+ } else if (other.governed_by.workspace !== workspace.name) {
1627
+ fail(
1628
+ "residence",
1629
+ `\`${name}\` is named by this workspace (\`${workspace.name}\`) but its pointer ` +
1630
+ `names ${JSON.stringify(other.governed_by.workspace)} as its governor, and ${GOVERNS} — ` +
1631
+ "two workspaces both believing they govern one repository is that failure with " +
1632
+ "the second copy moved one directory away",
1633
+ );
1634
+ } else {
1635
+ report(
1636
+ "residence",
1637
+ `\`${name}\` carries a pointer naming this workspace — governed here, once`,
1638
+ );
1639
+ }
1640
+ }
1641
+ }
1642
+ }
1643
+
1644
+ // Every persona a composed pack contributes, with the key a host would bind it by. Declared out
1645
+ // here rather than inside the branch below so the binding check has one shape to read: a workspace
1646
+ // composing no packs contributes no personas, which is an empty list and not a missing one.
1647
+ const composedPersonas = [];
1648
+
1649
+ // The roots the packs section resolved, carried to the enforcement section so the two read ONE
1650
+ // resolution rather than each running `rootPlan`. Two calls would be two answers whenever
1651
+ // discovery is in play — the host's plugin record can change between them, and more to the point a
1652
+ // second call is a second carrier of the precedence rule this file already imports rather than
1653
+ // respells. `null` means the packs section never ran, which is not the same as "it ran and found
1654
+ // nothing": the enforcement section keys on that difference below.
1655
+ let resolvedPackRoots = null;
1656
+ let packsUnresolved = 0;
1657
+
1658
+ if (workspace.packs?.length) {
1659
+ // Resolution, not a count. Until milestone 6 this reported how many packs were declared and
1660
+ // said so — "a declaration only" — because there was no format to validate one against and
1661
+ // nowhere to resolve a name. Both now exist. What is still NOT demonstrated here is resolution
1662
+ // from a FEED: the roots below are the tree this workspace already declares, and an adopter
1663
+ // installing a pack from a private marketplace resolves inside the installed plugin instead.
1664
+ // The resolver takes its roots as an argument for exactly that reason — the second case is the
1665
+ // same code path, not a parallel one — but this repository has not yet run it.
1666
+ // Through the SHARED precedence rule rather than a third spelling of it — see `rootPlan`.
1667
+ // `options.packRoots` keeps the meaning `compile`'s `packContributions` gives it — the FINAL
1668
+ // root set, replacing the derived one, so an explicitly empty array means search nowhere. The
1669
+ // three tools disagreed about this once and `../cli/compile.mjs`'s `namedRootsOption` records
1670
+ // what that cost; aligned here before a caller finds it the same way.
1671
+ // Built through `rootPlan` on EVERY path, including the named one. It used to short-circuit
1672
+ // here with a plan literal, which is how `options.packRoots` came to silently ignore
1673
+ // `discoverPacks`: a caller asking for both got one of them and no word about the other.
1674
+ // `namedGiven` is what lets the shared rule keep this option's meaning — the FINAL root set,
1675
+ // so an explicitly empty array still means search nowhere.
1676
+ const plan = rootPlan(dir, workspace, {
1677
+ named: options.packRoots ?? [],
1678
+ namedGiven: options.packRoots !== undefined && options.packRoots !== null,
1679
+ // `options` carries the injected env — the same one the pointer resolver above is given.
1680
+ // Without it this reader consulted the MACHINE the suite runs on, so the end-to-end pack
1681
+ // cases could not build a host and the file header's "every environment input injectable"
1682
+ // was true of one of its two readers.
1683
+ discovery: () => discoverPackRoots(options),
1684
+ forced: options.discoverPacks === true,
1685
+ });
1686
+ if (plan.refusal) throw new DoctorError(plan.refusal);
1687
+ // Asked-for discovery that could not look is could-not-run, never a green over an unread host.
1688
+ if (plan.couldNotRun) throw new DoctorError(plan.couldNotRun);
1689
+ const roots = plan.roots;
1690
+ resolvedPackRoots = roots;
1691
+ // Which root answered, for each root that could answer. A union's whole contract is that a
1692
+ // tree-derived resolution is visible rather than silent, and a lookup is how a per-pack line
1693
+ // says so without re-deriving the first-match rule.
1694
+ const originOf = new Map((plan.origins ?? []).map((o) => [o.root, o.origin]));
1695
+ // **A MISS is a failure only where somebody CLAIMED a root**, and since 2026-08-13 that is a
1696
+ // question about origin rather than about count. A named root is the caller's claim; a root
1697
+ // derived from `tree` is the workspace's own claim; a **discovered** root is neither — it is a
1698
+ // fact about the machine, and this tool must not turn a fact about the machine into a verdict
1699
+ // about the repository. Keyed on `roots.length` alone, it did exactly that: measured
1700
+ // 2026-08-13, `doctor examples` exited **0** and `doctor --pack-root auto examples` exited
1701
+ // **1**, because `examples` derives no root and `tools/github` is not in this host's cache. The
1702
+ // count key let host state flip a workspace's verdict, and making discovery the unasked default
1703
+ // would have moved that flip onto the bare invocation — red on a laptop, green in CI.
1704
+ //
1705
+ // So: **discovery may flip a note to resolved, and may never turn a MISS into a FAIL.** A hit is
1706
+ // a different matter and is graded whatever its origin — a resolved-but-invalid copy fails with
1707
+ // its origin named, below, because that is a claim the pack's own files make about themselves.
1708
+ const claimed = roots.filter((root) => originOf.get(root) !== "discovered");
1709
+ report("packs", `resolution root ${plan.source} — ${plan.why}`);
1710
+ for (const name of workspace.packs) {
1711
+ const found = resolvePack(name, roots);
1712
+ if (!found.dir) {
1713
+ // A workspace with no `tree` has no root of its own to search, so its declared packs are
1714
+ // *unverifiable* rather than *wrong* — the same answer `tree`'s absence already gives
1715
+ // every other claim needing a tree. Where a claimed root exists, resolution was claimed
1716
+ // and a miss is a failure.
1717
+ if (claimed.length === 0) {
1718
+ // Counted, never skipped: the pack was not graded, and `stats.unverifiable`'s
1719
+ // contract at the head of this file is that a check class which disappears without
1720
+ // saying so is the defect. A discovered-only miss is exactly that class.
1721
+ stats.unverifiable += 1;
1722
+ // Two causes, two sentences. The claimed set is empty either because there was no
1723
+ // root at all — no `tree`, or discovery finding nothing — or because every root
1724
+ // there IS was discovered, which is a real search that nobody asked for and whose
1725
+ // failure is therefore nobody's claim. Saying "there is no packs root to search"
1726
+ // about the second is false: there was one, and it was looked in.
1727
+ const why = roots.length
1728
+ ? `\`${name}\` was looked for under ${roots.length} discovered root(s) and is not there — reported rather than failed, because a root discovered on this host is not a claim this workspace made: ${plan.why}`
1729
+ : `\`${name}\` cannot be resolved — there is no packs root to search: ${plan.why}`;
1730
+ report("packs", why);
1731
+ } else {
1732
+ fail("packs", `\`${name}\` does not resolve — ${found.why}`);
1733
+ }
1734
+ // Counted, not inspected: an unresolved pack's manifest is unreadable, so this tool
1735
+ // cannot know whether it would have contributed gates. The enforcement section says
1736
+ // only that — never that gates were missed, which would claim knowledge of a file it
1737
+ // could not open.
1738
+ packsUnresolved += 1;
1739
+ continue;
1740
+ }
1741
+ let manifest;
1742
+ try {
1743
+ manifest = JSON.parse(fs.readFileSync(found.manifest, "utf8"));
1744
+ } catch (cause) {
1745
+ fail("packs", `\`${name}\` resolves to ${path.relative(dir, found.manifest)} but its manifest is unreadable — ${cause.message}`);
1746
+ continue;
1747
+ }
1748
+ // Refuse before grading, exactly as the workspace train does: a manifest written against a
1749
+ // contract this validator does not implement produces confident nonsense — a key added in a
1750
+ // later MINOR comes back as an unexpected property and the report blames the author for
1751
+ // using the spec correctly. This sentence is normative in spec/pack.schema.json, and for one
1752
+ // pre-commit checkpoint it was normative there and implemented nowhere, while `doctor`
1753
+ // printed "validates against Pack Definition 99.0" — a conformance claim about a contract it
1754
+ // had never seen.
1755
+ const declaredPack = /^([0-9]+)\.([0-9]+)$/.exec(manifest?.portulan?.pack ?? "");
1756
+ if (declaredPack) {
1757
+ const [major, minor] = [Number(declaredPack[1]), Number(declaredPack[2])];
1758
+ const here = packSchemaVersion(packSchema);
1759
+ if (major !== here.major || minor > here.minor) {
1760
+ fail(
1761
+ "packs",
1762
+ `\`${name}\` declares Pack Definition ${major}.${minor}; this \`doctor\` implements ` +
1763
+ `${here.major}.${here.minor}. Refusing to grade it rather than reporting against a ` +
1764
+ `contract it was not written for.`,
1765
+ );
1766
+ continue;
1767
+ }
1768
+ }
1769
+
1770
+ const errors = validate(packSchema, manifest);
1771
+ if (errors.length) {
1772
+ for (const e of errors) {
1773
+ fail("packs", `\`${name}\` manifest${e.pointer ? ` at \`${e.pointer}\`` : ""}: ${e.message}`);
1774
+ }
1775
+ continue;
1776
+ }
1777
+ stats.packs += 1;
1778
+ const c = manifest.contributes ?? {};
1779
+
1780
+ // Milestone 7, the maintainer's ruling of 2026-08-03 on #150: the artifacts a pack ships are
1781
+ // OPENED and validated, not counted. Until this landed, `doctor` counted these paths into the
1782
+ // line below and read none of them — which is why spec/pack.schema.json could say an escaping
1783
+ // `contributes.skills` value was "still inert". It is not inert once something opens it, so
1784
+ // the containment check arrives in the same change as the opening.
1785
+ const opened = validateContributions(found.dir, c, { fail, report, pack: name });
1786
+ for (const one of opened.bindable) composedPersonas.push({ pack: name, ...one });
1787
+
1788
+ const parts = [
1789
+ c.skills?.length
1790
+ ? `${opened.skills} skill(s) in ${c.skills.length} root(s)` +
1791
+ (opened.unreadableRoots ? `, ${opened.unreadableRoots} root(s) UNREAD — that count is over what was opened, not over what was declared` : "")
1792
+ : null,
1793
+ c.personas?.length ? `${opened.personas} of ${c.personas.length} persona(s) opened` : null,
1794
+ // Merged as of milestone 7's composition amendment. This said "declared and not
1795
+ // merged" while that was true, and became false the hour `cli/recipe-set.mjs` landed
1796
+ // — a code carrier of the same fact the schema note carries, which is why both moved
1797
+ // in one change. The ids are printed rather than counted: the namespace is what makes
1798
+ // a collision impossible, so a reader should be able to see it.
1799
+ c.verify?.length
1800
+ ? `${c.verify.length} recipe(s) composed as ${c.verify.map((r) => `\`${composedId(name, r.id)}\``).join(", ")}`
1801
+ : null,
1802
+ c.gates?.length ? `${c.gates.length} gate fragment(s)` : null,
1803
+ ].filter(Boolean);
1804
+ // Stated only where the set is a union, because that is the only arrangement in which a
1805
+ // reader cannot tell the origin from the invocation they typed. Adding it everywhere would
1806
+ // put a sentence on every line that says nothing the command did not already say.
1807
+ const origin = plan.source === "union" ? originOf.get(found.root) ?? null : null;
1808
+ const from =
1809
+ origin === "discovered"
1810
+ ? " from the discovered root"
1811
+ : origin === "derived"
1812
+ ? " from the tree-derived root"
1813
+ : "";
1814
+ report(
1815
+ "packs",
1816
+ `\`${name}\` resolves${from} to ${path.relative(dir, found.dir)} and validates against Pack Definition ${manifest.portulan.pack} — contributes ${parts.length ? parts.join(", ") : "nothing"}`,
1817
+ );
1818
+ }
1819
+ }
1820
+
1821
+ // Workspace Definition 2.3's and 2.4's conditional constraints, here for the same reason the
1822
+ // `repository`/`tree` pair above is: `dependentRequired` is not in the subset spec/README.md
1823
+ // declares, so the schema cannot carry any of them and ../spec/slots.md says so.
1824
+ //
1825
+ // The count went one → three in a single MINOR at 2.3 and three → five at 2.4. That is the
1826
+ // thing to watch rather than absorb quietly: every one of them is a rule the schema states
1827
+ // nowhere, so a workspace validated by some other tool against the published schema alone would
1828
+ // pass shapes this validator refuses. `../spec/README.md` carries the running count for exactly
1829
+ // that reason.
1830
+ if (workspace.librarian && !workspace.slots?.memory) {
1831
+ fail(
1832
+ "cross",
1833
+ "`librarian` declares a scheduled pass with no `slots.memory` store to age — the object " +
1834
+ "configures a store rather than replacing one, and a pass over nothing reports that " +
1835
+ "nothing is stale, which is indistinguishable from a healthy store",
1836
+ );
1837
+ }
1838
+
1839
+ // Every number the schema can only type as `number`, checked here for the constraint the declared
1840
+ // subset cannot express. Without this, `record_days: 0` — or `-1`, or `"90"` — passes CI green and
1841
+ // fails at 06:00 on a Monday, when `cli/librarian.mjs` refuses it with exit 2 and nobody is
1842
+ // watching. A policy defect that only surfaces in an unattended run is the worst place for one, and
1843
+ // this is a `doctor` failure rather than a `librarian` one because it is a fact about the manifest.
1844
+ // Raised by Copilot on #81, suppressed half. The memory budgets are checked the same way, and were
1845
+ // missing it too — a sibling of the same class, fixed in the same stroke rather than left for the
1846
+ // next round to find.
1847
+ //
1848
+ // **The message stated a downstream behaviour this tree does not have**, and that is issue #84,
1849
+ // fixed here. It said a zero or negative value *reads as undeclared to the tool that consumes it* —
1850
+ // which was true of nothing: both consumers, `cli/index.mjs`'s `budgetNumber` and
1851
+ // `cli/librarian.mjs`'s `threshold`, refuse such a value outright with exit 2. What the sentence
1852
+ // described was the hazard those refusals exist to prevent, written in the present tense as though
1853
+ // it survived them. ../.portulan/dod.md condition 4 refuses a claim of a capability that does not
1854
+ // exist; this was the same error pointed the other way, a claim of a *defect* that does not exist,
1855
+ // inside a failure message — where a reader has the least room to check it.
1856
+ const positive = (v) => typeof v === "number" && Number.isInteger(v) && v > 0;
1857
+ for (const [where, value] of [
1858
+ ["memory.index.budget.lines", workspace.memory?.index?.budget?.lines],
1859
+ ["memory.index.budget.columns", workspace.memory?.index?.budget?.columns],
1860
+ ["memory.store.budget.kilobytes", workspace.memory?.store?.budget?.kilobytes],
1861
+ ["memory.store.budget.record_kilobytes", workspace.memory?.store?.budget?.record_kilobytes],
1862
+ ["librarian.staleness.record_days", workspace.librarian?.staleness?.record_days],
1863
+ ["librarian.staleness.sealed_days", workspace.librarian?.staleness?.sealed_days],
1864
+ ["librarian.staleness.proposal_days", workspace.librarian?.staleness?.proposal_days],
1865
+ ]) {
1866
+ if (value !== undefined && !positive(value)) {
1867
+ fail(
1868
+ "schema",
1869
+ `${where} is ${JSON.stringify(value)}, which is not a positive integer. The declared keyword ` +
1870
+ "subset has no `minimum` and cannot say `integer`, so this is checked here — and it is " +
1871
+ "checked at pull-request time so a policy defect does not first surface in an unattended " +
1872
+ "run, where the consuming tool refuses it with exit 2 and nobody is watching",
1873
+ );
1874
+ }
1875
+ }
1876
+
1877
+ if (workspace.librarian?.staleness?.proposal_days !== undefined && !workspace.slots?.proposals) {
1878
+ fail(
1879
+ "cross",
1880
+ "`librarian.staleness.proposal_days` nags about proposals in a workspace that declares no " +
1881
+ "`slots.proposals` — a threshold nothing can ever cross. The pass reports *not asked* " +
1882
+ "here, which is not the same answer as *none pending*, and a policy that can never fire " +
1883
+ "reads as configured to anyone who greps for it",
1884
+ );
1885
+ }
1886
+
1887
+ if (workspace.memory && !workspace.slots?.memory) {
1888
+ fail(
1889
+ "cross",
1890
+ "`memory` declares an index and budgets with no `slots.memory` store to index — the object " +
1891
+ "configures a store rather than replacing one, and an index of nothing renders empty, " +
1892
+ "compares equal to an empty committed file, and passes",
1893
+ );
1894
+ }
1895
+
1896
+ // An index inside the store it indexes is counted as a record by the retirement and provenance
1897
+ // walks below — sized into the KB figure and reported for stating no retirement condition. The
1898
+ // repair is a siting rule rather than an exemption by filename: teaching one name to hide from a
1899
+ // walk is a door any record could use, which is the fail-open class this repository has now found
1900
+ // eight of in its own scaffolding — and then this check turned out to be the ninth, in the very
1901
+ // containment test that was supposed to be the door-less alternative. See `isInside`.
1902
+ if (workspace.memory?.index?.path && workspace.slots?.memory) {
1903
+ const storeDir = path.resolve(dir, workspace.slots.memory);
1904
+ const indexPath = path.resolve(dir, workspace.memory.index.path);
1905
+ // Imported rather than restated. This check and `index`'s had two copies of one containment
1906
+ // rule and both carried the same fail-open: `!path.relative(…).startsWith("..")` calls a file
1907
+ // named `..index.md` *outside* the directory it sits in, because a leading `..` in a filename
1908
+ // is not a traversal. Measured — such an index was written into the store, and this walk then
1909
+ // counted it as a record. Same reasoning as `parse`/`backends` from ./compile.mjs above: a
1910
+ // rule with two copies drifts, and these two drifted identically before either shipped.
1911
+ if (isInside(storeDir, indexPath)) {
1912
+ fail(
1913
+ "cross",
1914
+ `memory.index.path (\`${workspace.memory.index.path}\`) sits inside slots.memory ` +
1915
+ `(\`${workspace.slots.memory}\`), where this validator counts it as a record. ` +
1916
+ "Site the generated index beside the store, not in it",
1917
+ );
1918
+ }
1919
+ }
1920
+
1921
+ // The handoff series gets both of the memory index's conditional requirements, added at 2.5, and
1922
+ // gets them here rather than in a second copy of the reasoning: they are the same two rules about
1923
+ // the same kind of object. What differs is only which walk would swallow a badly-sited index —
1924
+ // `doctor`'s store report there, `docs.sh`'s date correspondence here.
1925
+ if (workspace.handoffs && !workspace.slots?.handoffs) {
1926
+ fail(
1927
+ "cross",
1928
+ "`handoffs` declares an index with no `slots.handoffs` series to index — the object " +
1929
+ "configures a series rather than replacing one, and an index of nothing renders empty, " +
1930
+ "compares equal to an empty committed file, and passes",
1931
+ );
1932
+ }
1933
+
1934
+ if (workspace.handoffs?.index?.path && workspace.slots?.handoffs) {
1935
+ const seriesDir = path.resolve(dir, workspace.slots.handoffs);
1936
+ const indexPath = path.resolve(dir, workspace.handoffs.index.path);
1937
+ if (isInside(seriesDir, indexPath)) {
1938
+ fail(
1939
+ "cross",
1940
+ `handoffs.index.path (\`${workspace.handoffs.index.path}\`) sits inside slots.handoffs ` +
1941
+ `(\`${workspace.slots.handoffs}\`). A Markdown file there is either counted as a handoff by ` +
1942
+ "the `record` check's date correspondence, or failed by it for carrying no date. " +
1943
+ "Site the generated index beside the series, not in it",
1944
+ );
1945
+ }
1946
+ }
1947
+
1948
+ // The scopes layer, at 2.6, gets the same pair for the third time. What differs is only which walk
1949
+ // would swallow a badly-sited index: the store report there, `docs.sh`'s date correspondence for the
1950
+ // handoffs, and here `index`'s orphan sweep — a generated file inside the layer would be read by the
1951
+ // one check whose whole job is telling an arrived location from a directory somebody made.
1952
+ if (workspace.personas && !workspace.slots?.personas) {
1953
+ fail(
1954
+ "cross",
1955
+ "`personas` declares an index with no `slots.personas` layer to index — the object " +
1956
+ "configures a layer rather than replacing one, and a scope with nowhere to land is a " +
1957
+ "declaration nothing can honour",
1958
+ );
1959
+ }
1960
+
1961
+ if (workspace.personas?.index?.path && workspace.slots?.personas) {
1962
+ const layerDir = path.resolve(dir, workspace.slots.personas);
1963
+ const indexPath = path.resolve(dir, workspace.personas.index.path);
1964
+ if (isInside(layerDir, indexPath)) {
1965
+ fail(
1966
+ "cross",
1967
+ `personas.index.path (\`${workspace.personas.index.path}\`) sits inside slots.personas ` +
1968
+ `(\`${workspace.slots.personas}\`), where \`index\`'s orphan sweep would examine it as an ` +
1969
+ "undeclared persona location. Site the generated index beside the layer, not in it",
1970
+ );
1971
+ }
1972
+ }
1973
+
1974
+ // ---- claims against the tree
1975
+ const treeRoot = workspace.tree ? path.resolve(dir, workspace.tree) : null;
1976
+ const claimTargets = [];
1977
+
1978
+ // ---- persona ↔ agent bindings
1979
+ //
1980
+ // Row 7's fourth validation, and the one that was still owed after milestone 7 session 2 built the
1981
+ // other three. A persona is host-agnostic — its `tools:` are capability classes — and a **binding**
1982
+ // is the file that translates one into a host's own vocabulary. `core/personas/README.md` records
1983
+ // that the translation is **lossy**: of three charters exactly one survives it. So this check grades
1984
+ // agreement and never equivalence, and there are exactly two things it can honestly hold.
1985
+ //
1986
+ // **Absence is reported, never failed**, for the same reason a pointer's `governed_by` is reported
1987
+ // and never graded: whether a binding file exists is deployment state, and a persona with no binding
1988
+ // is a legitimate choice — an adopter may be on a host with no agent layer at all. This repository is
1989
+ // the exhibit rather than the embarrassment: the `checkpoints` supervisor is deliberately unbound,
1990
+ // because that pack's own `self-certify-a-checkpoint` gate makes a **fresh context** the mechanism,
1991
+ // and a subagent binding inside the implementer's session is precisely what it refuses.
1992
+ //
1993
+ // **A binding that contradicts its persona is failed**, because that one is wrong on every host: the
1994
+ // loader keys on frontmatter `name`, so a mismatch binds a persona nobody named, and a binding with
1995
+ // no `tools:` hands the role the host's whole toolbox — the context firewall's first part, gone.
1996
+ if (composedPersonas.length) {
1997
+ if (!treeRoot) {
1998
+ // The same answer every other claim gets without a tree, and it is counted with them: a
1999
+ // workspace that declares no repository has nowhere for `agents/` to be, which is not the
2000
+ // same as having looked and found nothing.
2001
+ stats.unverifiable += composedPersonas.length;
2002
+ report(
2003
+ "bindings",
2004
+ `${composedPersonas.length} composed persona(s) could not be matched to a host binding — this workspace declares no \`tree\`, ` +
2005
+ `so there is no repository to look in. Unverifiable, not unbound`,
2006
+ );
2007
+ }
2008
+ // BOTH sides are resolved before they are compared, which is the half a first cut got wrong.
2009
+ // Comparing a realpathed file against an unresolved root fails wherever the root itself sits
2010
+ // under a link — every macOS temporary directory does, `/var` being a link to `/private/var` —
2011
+ // so the guard below refused perfectly ordinary bindings until this line existed. That is the
2012
+ // rule `validateContributions` already applies to a pack directory, applied to a tree.
2013
+ let realTree = treeRoot;
2014
+ try {
2015
+ if (treeRoot) realTree = fs.realpathSync(treeRoot);
2016
+ } catch {
2017
+ // An unresolvable tree is already reported by the claims checks; keeping the declared path
2018
+ // here means the containment test still runs, on the stricter of the two spellings.
2019
+ }
2020
+ for (const persona of treeRoot ? composedPersonas : []) {
2021
+ const rel = path.join(AGENT_DIR, `${persona.key}.md`);
2022
+ const where = `\`${persona.pack}\`'s \`${persona.rel}\``;
2023
+ const keyed = persona.keyedBy === "declaration" ? "" : " (keyed by filename — the persona declares no `name`)";
2024
+ const file = path.join(treeRoot, rel);
2025
+
2026
+ // **The key is a pack's free text, so it is contained before it is opened.** A persona's
2027
+ // `name` is unconstrained — the five-part contract does not include it and the Pack
2028
+ // Definition does not reach persona markdown — so a pack declaring `name: ../../poison`
2029
+ // aims this read anywhere on the machine. Before this guard it read the file, validated
2030
+ // it, and printed *names and tool grant agree*: a green over something no host would ever
2031
+ // load as an agent, with the outside file's own `name:` echoed into the report.
2032
+ //
2033
+ // Resolved and then compared, never matched as a pattern — the rule `validateContributions`
2034
+ // already applies to a pack's own paths, and the same one `plugin-lint` applies to a
2035
+ // symlinked `agents/` entry. A symlink is why the test is on the REAL path: a link inside
2036
+ // the tree pointing out of it passes any check on the spelling.
2037
+ // The LEXICAL test comes first, and it is not redundant with the resolved one below.
2038
+ // `path.join("agents", "../../poison.md")` is `../poison.md`, so a traversing name aims
2039
+ // outside the tree whether or not anything is there — and where nothing is, the resolved
2040
+ // test never runs and this check fell through to its *unbound* sentence, which named the
2041
+ // escaping path as "the one location a host loads agents from". A refusal that depends on
2042
+ // whether the attacker's file happens to exist is not a refusal.
2043
+ if (!isInside(treeRoot, path.resolve(treeRoot, rel))) {
2044
+ fail(
2045
+ "bindings",
2046
+ `${where} keys its host binding to \`${persona.key}\`, which leaves this workspace's tree — \`${rel}\`. A persona's name is the ` +
2047
+ "pack's own free text and this path is built from it, so a name that traverses is a pack choosing which file this validator opens",
2048
+ );
2049
+ continue;
2050
+ }
2051
+
2052
+ let real = null;
2053
+ try {
2054
+ real = fs.realpathSync(file);
2055
+ } catch (cause) {
2056
+ if (cause.code !== "ENOENT") {
2057
+ report("bindings", `${where}'s binding at \`${rel}\` could not be resolved — ${cause.code ?? cause.message}. Unread, not absent`);
2058
+ continue;
2059
+ }
2060
+ // ENOENT is the ordinary unbound case and falls through to the read below, which
2061
+ // renders the one sentence this check has for it.
2062
+ }
2063
+ if (real !== null && !isInside(realTree, real)) {
2064
+ fail(
2065
+ "bindings",
2066
+ `${where} resolves to a host binding OUTSIDE this workspace's tree — \`${rel}\` reaches \`${real}\`. A persona's name is the pack's ` +
2067
+ "own text and this key is built from it, so a name that traverses upward would have this validator open and grade a file the " +
2068
+ "host could never load",
2069
+ );
2070
+ continue;
2071
+ }
2072
+ let text;
2073
+ try {
2074
+ text = fs.readFileSync(file, "utf8");
2075
+ } catch (cause) {
2076
+ if (cause.code === "ENOENT") {
2077
+ report(
2078
+ "bindings",
2079
+ `${where} has no host binding at \`${rel}\`${keyed} — reported, not failed: a persona without one is unbound rather than wrong, ` +
2080
+ "and this is the one location a Claude Code host loads agents from",
2081
+ );
2082
+ } else {
2083
+ // `existsSync` would answer *false* here and this would print as an absence. The
2084
+ // distinction is the same one `validateContributions` carries: only a missing file
2085
+ // means absent, and a question that could not be answered is not an answer.
2086
+ report("bindings", `${where}'s binding at \`${rel}\` could not be read — ${cause.code ?? cause.message}. Unread, not absent`);
2087
+ }
2088
+ continue;
2089
+ }
2090
+ const { fields, error } = parseFrontmatter(text);
2091
+ if (!fields) {
2092
+ fail(
2093
+ "bindings",
2094
+ `${where} is bound by \`${rel}\`, which has no usable frontmatter${error ? ` — ${error}` : ""}. A host reads the binding's ` +
2095
+ "`name`, `description` and `tools` from that block; without it the file registers as nothing",
2096
+ );
2097
+ continue;
2098
+ }
2099
+ const bound = typeof fields.name === "string" ? fields.name.trim() : "";
2100
+ if (bound !== persona.key) {
2101
+ fail(
2102
+ "bindings",
2103
+ `${where} is bound by \`${rel}\`, whose frontmatter declares \`name: ${bound || "(none)"}\` — the host keys on that field and not on ` +
2104
+ `the filename, so this file binds a persona nobody named. Expected \`${persona.key}\``,
2105
+ );
2106
+ continue;
2107
+ }
2108
+ if (!(typeof fields.tools === "string" && fields.tools.trim())) {
2109
+ fail(
2110
+ "bindings",
2111
+ `${where} is bound by \`${rel}\`, which declares no \`tools:\` allow-list. The first of the five parts is a default-deny surface, ` +
2112
+ "and a binding that omits it grants the role every tool the host has",
2113
+ );
2114
+ continue;
2115
+ }
2116
+ // *Declared*, not *agrees*: the check above establishes only that `tools:` is a non-empty
2117
+ // string. Nothing here compares it against the persona's own contract — the persona's
2118
+ // `tools:` never reaches `bindable` — and by design, since `../docs/milestones/m07.md`
2119
+ // holds that a binding may licitly grant less, or other, than the persona describes. The
2120
+ // previous wording said the two *agree*, which reported a grant of `Read, Write, Bash` to a
2121
+ // persona whose contract is *does not write* as though it had been checked. Found by
2122
+ // milestone 7's close pass, forcing exactly that case green.
2123
+ report("bindings", `${where} is bound by \`${rel}\`${keyed} — ${BINDING_OK}`);
2124
+ }
2125
+ }
2126
+
2127
+ if (workspace.slots?.repos) {
2128
+ const reposDir = path.resolve(dir, workspace.slots.repos);
2129
+ for (const name of [...cardNames].sort()) {
2130
+ const card = path.join(reposDir, `${name}.md`);
2131
+ let source;
2132
+ try {
2133
+ source = fs.readFileSync(card, "utf8");
2134
+ } catch (cause) {
2135
+ // Was a bare `continue`: an unreadable card dropped every claim it makes, silently,
2136
+ // and the run stayed green. A card that cannot be read is not a card with no claims.
2137
+ fail("claims", `repos/${name}.md could not be read, so its claims went unchecked — ${cause.message}`);
2138
+ continue;
2139
+ }
2140
+ for (const claim of repoCardClaims(source)) {
2141
+ claimTargets.push({ where: `repos/${name}.md`, ...claim, base: reposDir });
2142
+ }
2143
+ }
2144
+ }
2145
+
2146
+ // The gate map's claim is extracted OUTSIDE the tree branch on purpose. Reading it only when a
2147
+ // tree exists is how a check class disappears without saying so — a no-`tree` workspace whose
2148
+ // gate map named a status check nothing reports used to produce no mention of it at all, while
2149
+ // spec/slots.md promised those claims were "counted and reported unverifiable, never skipped
2150
+ // silently". Found at the pre-commit checkpoint, in the paragraph that made the promise.
2151
+ let claimedChecks = [];
2152
+ // The same list, captured before anything mutates it. `claimedChecks` is emptied further down
2153
+ // when there are no workflows to compare against, and the floor cross-check read it afterwards —
2154
+ // reporting that the gate map named nothing, about a gate map that named it plainly. That is the
2155
+ // SECOND consumer caught reading this array after the mutation; the first fix added a separate
2156
+ // flag for one consumer instead of making the array safe to read, so the next consumer inherited
2157
+ // the trap. Found by review on the pull request. Snapshot rather than flag this time, so a third
2158
+ // consumer inherits something true instead of something to remember.
2159
+ let declaredChecksInProse = [];
2160
+ let gatesRead = false;
2161
+ // What the tree's workflows actually report, filled in once a tree is available. `null` means
2162
+ // no tree was declared, which is a different thing from a tree with no workflows in it.
2163
+ let workflowContexts = null;
2164
+ // Captured immediately after parsing and never reassigned. `claimedChecks` is emptied later when
2165
+ // there are no workflows to compare against, and keying the "names no check" report off the
2166
+ // mutated array made it fire for a gate map that had named one — two contradictory findings from
2167
+ // the same run.
2168
+ let namedAnyCheck = false;
2169
+ if (workspace.slots?.gates) {
2170
+ try {
2171
+ claimedChecks = requiredCheckClaims(fs.readFileSync(path.resolve(dir, workspace.slots.gates), "utf8"));
2172
+ declaredChecksInProse = [...claimedChecks];
2173
+ gatesRead = true;
2174
+ namedAnyCheck = claimedChecks.length > 0;
2175
+ } catch {
2176
+ // Unreadable is already a `paths` failure; it must not also become an exit-2 crash,
2177
+ // which would trade a verdict this run had already reached for "could not run".
2178
+ }
2179
+ }
2180
+
2181
+ if (treeRoot) {
2182
+ for (const claim of claimTargets) {
2183
+ if (claim.target === null) {
2184
+ stats.unverifiable += 1;
2185
+ report("claims", `${claim.where} states ${claim.what}, which contains nothing path-shaped to check — counted as unverifiable rather than passed over`);
2186
+ continue;
2187
+ }
2188
+ // A token pulled out of a command is never a *checked* claim, whether or not it happens to
2189
+ // resolve. Counting it in `stats.claims` when it resolved and in `unverifiable` when it did
2190
+ // not would make the accounting depend on incidental filesystem state and would overstate
2191
+ // what was verified — `docker run -v $(pwd)/state:/state` is not a claim about the tree
2192
+ // because `state` exists. It is unverifiable either way; only the finding differs.
2193
+ const checkable = claim.severity !== "report";
2194
+ if (checkable) stats.claims += 1;
2195
+ else stats.unverifiable += 1;
2196
+
2197
+ // Two bases, because a card legitimately mixes them: `.portulan/` is written from the
2198
+ // repository root while `../../core/` is written from the card. A claim that resolves
2199
+ // under either is a claim that points at something real, which is what the lint is for.
2200
+ const resolved = [path.resolve(treeRoot, claim.target), path.resolve(claim.base, claim.target)];
2201
+ if (!resolved.some((p) => fs.existsSync(p))) {
2202
+ const message = `${claim.where} claims ${claim.what}, which exists nowhere in the tree`;
2203
+ if (checkable) {
2204
+ fail("claims", message);
2205
+ } else {
2206
+ report("claims", `${message} — reported rather than failed: a token pulled out of a command may be an output path, a flag value or a glob, and this cannot tell those from a broken one`);
2207
+ }
2208
+ }
2209
+ }
2210
+
2211
+ // Read once and shared by two consumers: the gate map's prose claim about required checks,
2212
+ // and — since the floor backend arrived — the gate policy's `floor.checks`. It was nested
2213
+ // inside the first consumer's `if`, which would have left the second silently unable to
2214
+ // check anything whenever the prose named nothing.
2215
+ const workflows = path.join(treeRoot, ".github", "workflows");
2216
+ let jobs = [];
2217
+ let found = false;
2218
+ try {
2219
+ for (const entry of fs.readdirSync(workflows)) {
2220
+ if (!/\.ya?ml$/.test(entry)) continue;
2221
+ found = true;
2222
+ jobs.push(...workflowJobs(fs.readFileSync(path.join(workflows, entry), "utf8")));
2223
+ }
2224
+ } catch { /* handled by each consumer */ }
2225
+ const contexts = jobs.map((j) => j.context);
2226
+ workflowContexts = { contexts, found };
2227
+
2228
+ if (claimedChecks.length) {
2229
+ // A job whose id is claimed but whose reported context differs is the interesting failure,
2230
+ // and it deserves its own message: "no job declares that" would send the reader hunting for
2231
+ // a missing job when the job is right there under a display name.
2232
+ if (!found) {
2233
+ stats.unverifiable += claimedChecks.length;
2234
+ report("claims", `the gate map requires ${claimedChecks.length} status check(s) — ${claimedChecks.map((c) => `\`${c}\``).join(", ")} — and there are no workflows in the tree to report them — unverifiable`);
2235
+ claimedChecks = [];
2236
+ }
2237
+ for (const claimedCheck of claimedChecks) {
2238
+ stats.claims += 1;
2239
+ const shadowed = jobs.find((j) => j.id === claimedCheck && j.context !== claimedCheck);
2240
+ if (shadowed) {
2241
+ fail(
2242
+ "claims",
2243
+ `the gate map requires the status check \`${claimedCheck}\`, which is a job **id**; that job sets \`name: ${shadowed.context}\`, and the name is what branch protection pins. The claim names something no check will ever report`,
2244
+ );
2245
+ } else if (!contexts.includes(claimedCheck)) {
2246
+ fail("claims", `the gate map requires the status check \`${claimedCheck}\`, which no workflow job in the tree reports (found: ${contexts.join(", ") || "none"})`);
2247
+ } else {
2248
+ report("claims", `the gate map's required status check \`${claimedCheck}\` is reported by a workflow job in the tree — in-tree only: whether branch protection actually requires it, and the app it is pinned to, are live settings doctor does not fetch`);
2249
+ }
2250
+ }
2251
+ }
2252
+ } else {
2253
+ stats.unverifiable += claimTargets.length;
2254
+ report(
2255
+ "claims",
2256
+ `${claimTargets.length} repo-card claim(s) unverifiable: this workspace declares no \`tree\`, so it describes repositories that are not present beside it. Reported rather than skipped — a check class that disappears quietly is worse than one that says it could not run`,
2257
+ );
2258
+ if (claimedChecks.length) {
2259
+ stats.unverifiable += claimedChecks.length;
2260
+ report(
2261
+ "claims",
2262
+ `the gate map requires ${claimedChecks.map((c) => `\`${c}\``).join(", ")}, and with no \`tree\` there is nothing to check against — unverifiable`,
2263
+ );
2264
+ }
2265
+ }
2266
+
2267
+ // Finding no claim is a result, not an absence, and it has to say so. `doctor` recognises the
2268
+ // required-check claim by one convention — a table row whose first cell reads "required status
2269
+ // check" — and no template defines that label (see spec/slots.md). A gate map wording the row
2270
+ // differently produced **nothing at all**: no finding, no count, and a reader with a GREEN in
2271
+ // front of them had no way to tell "this workspace requires no check" from "I did not recognise
2272
+ // your table". Reported since the review that measured it.
2273
+ if (gatesRead && !namedAnyCheck) {
2274
+ report(
2275
+ "claims",
2276
+ "the gate map names no required status check — either this workspace requires none, or its floor table does not use the row label `Required status check` that this check recognises (spec/slots.md). Nothing was compared against the tree",
2277
+ );
2278
+ }
2279
+
2280
+ // ---- the per-host degradation report
2281
+ //
2282
+ // `../docs/vision.md`: "the enforcement backends are per-host with an honest degradation
2283
+ // report." The compiler's accounting IS that report's data — every rule ends as compiled or
2284
+ // refused-with-a-reason, per backend — so this reads the backends rather than re-deriving what
2285
+ // they cover. Two implementations of one accounting is the drift this repository keeps finding,
2286
+ // and it is the reason `./gate.mjs` imports the matcher instead of copying it.
2287
+ //
2288
+ // Reported, never failed, with one exception below: nothing legislates a coverage floor, and
2289
+ // `doctor` does not enforce what nobody legislated — the same reasoning that made `retirement`
2290
+ // report-only. What it must not do is print a green that reads as "everything is enforced".
2291
+ if (workspace.gates) {
2292
+ const policyFile = path.resolve(dir, workspace.gates);
2293
+ let parsed = null;
2294
+ let columns = null;
2295
+ let composed = null;
2296
+ try {
2297
+ // **Composed before the policy is parsed**, which is the order `compile` uses and for its
2298
+ // reason: a pack's fragment is then validated by exactly the code that validates a
2299
+ // hand-written rule, rather than by a second, laxer path. Through `compile.mjs`'s own
2300
+ // `packContributions` + `composeFragments` — this file already imports four functions from
2301
+ // there and mints none of them, because the last thing a policy needs is a second opinion
2302
+ // about what it contains.
2303
+ //
2304
+ // Why this reads the composed set at all: until 2026-08-13 it read `gates.json` alone, so
2305
+ // its report described a policy the workspace does not have. Measured on `d5a5eb7`, this
2306
+ // section printed `Claude Code: 10 of 23 rule(s) compiled → .claude/settings.json` while
2307
+ // that file carried **eleven** rules' compilation, the eleventh being a composed one — an
2308
+ // arrow naming an artifact beside a number that is not that artifact's. `../.portulan/dod.md`
2309
+ // condition 1 had already been rewritten from *declares* to **yields** for the recipe set,
2310
+ // for the identical reason; this is that word applied to the gate policy.
2311
+ const composition = packContributions(dir, ".", { packRoots: resolvedPackRoots ?? [] });
2312
+ composed = composeFragments(JSON.parse(fs.readFileSync(policyFile, "utf8")), composition.contributions);
2313
+ parsed = parse(composed.policy);
2314
+ // Inside the SAME guard as the parse, which it was not for one checkpoint. A policy can
2315
+ // parse cleanly and still be refused by a backend — a declared floor no rule reaches, or
2316
+ // gate rules that all compile to nothing — and with `backends()` outside the try, that
2317
+ // threw out of `inspect`, exited 2, and discarded every verdict this run had already
2318
+ // reached. Trading a verdict for "could not run" is the milestone-2 gates-file defect,
2319
+ // and it was sitting three lines under a comment naming it. Found at the pre-commit
2320
+ // checkpoint.
2321
+ columns = backends(parsed, { source: workspace.gates });
2322
+ } catch (cause) {
2323
+ // A defect in the WORKSPACE's own policy, reported where every other finding is.
2324
+ fail("enforcement", `${workspace.gates} — ${cause.message}`);
2325
+ parsed = null;
2326
+ }
2327
+
2328
+ if (parsed) {
2329
+ for (const column of columns) {
2330
+ report(
2331
+ "enforcement",
2332
+ `${column.label}: ${column.compiled.length} of ${parsed.rules.length} rule(s) compiled, ${column.refused.length} refused` +
2333
+ (column.artifact ? ` → ${column.artifact.path}` : " → no artifact (this backend compiled nothing here)"),
2334
+ );
2335
+ }
2336
+
2337
+ // The honest signal, split by tier because the halves mean opposite things. An `auto`
2338
+ // rule compiled by nothing is the system working; a `gated` or `prohibited` one is a
2339
+ // gate that exists only as a sentence, which is what this product is against.
2340
+ const uncovered = parsed.rules.filter(
2341
+ (rule) =>
2342
+ (rule.tier === "gated" || rule.tier === "prohibited") &&
2343
+ columns.every((c) => !c.compiled.some((x) => x.id === rule.id)),
2344
+ );
2345
+ // "policy this workspace yields" rather than "declared policy": the count now includes
2346
+ // composed rows, so *declared* would be the word that made this sentence wrong.
2347
+ report(
2348
+ "enforcement",
2349
+ uncovered.length
2350
+ ? `${uncovered.length} gate(s) no backend compiles — policy this workspace yields that nothing enforces: ${uncovered.map((r) => `\`${r.id}\``).join(", ")}. Each is a prompt-level habit until a backend reaches it`
2351
+ : "every gate this workspace yields is compiled by at least one backend — which says the policy is reachable, never that a host honours what was emitted",
2352
+ );
2353
+
2354
+ // Declared from composed, kept legible. A reader who cannot tell which rows came from a
2355
+ // pack cannot tell which half to go and change, and the composed half lives in a file this
2356
+ // workspace does not own.
2357
+ if (composed.added?.length) {
2358
+ // Its own subject and its own members. "N of those" read as the uncovered gates named on
2359
+ // the line above — of which only one is composed — while the real antecedent was the
2360
+ // whole yield two lines up. And a count alone does not tell a reader which file to
2361
+ // change: with two gate-contributing packs, "composed from this workspace's packs" names
2362
+ // neither. `composed.added` already carries `{ pack, id }`, so the members are printed.
2363
+ const members = composed.added.map((a) => `\`${a.id}\` (${a.pack})`).join(", ");
2364
+ report(
2365
+ "enforcement",
2366
+ `${composed.added.length} of the ${parsed.rules.length} rule(s) this workspace yields are composed from its packs rather than declared in \`${workspace.gates}\` — ${members}; change them in the pack that contributes them`,
2367
+ );
2368
+ }
2369
+
2370
+ // What this tool can KNOW, and no more. An unresolved pack's manifest is unreadable, so
2371
+ // whether it would have contributed gates is not a fact in reach — the sentence says the
2372
+ // totals may be incomplete, never that gates were missed. Severity is the packs section's
2373
+ // to set and it has already set it above, origin-keyed; this line does not re-litigate it.
2374
+ if (packsUnresolved) {
2375
+ report(
2376
+ "enforcement",
2377
+ `${packsUnresolved} declared pack(s) did not resolve, so whether they contribute gates could not be seen — the totals above may be incomplete`,
2378
+ );
2379
+ }
2380
+
2381
+ // ---- the floor's declared contexts, against the tree and against the prose
2382
+ for (const check of parsed.floor?.checks ?? []) {
2383
+ if (check.integration_id === undefined) {
2384
+ report(
2385
+ "enforcement",
2386
+ `the floor requires the status check \`${check.context}\` with no \`integration_id\` — an unpinned context is satisfiable by any GitHub App reporting that name, which the branch-protection UI does not surface`,
2387
+ );
2388
+ }
2389
+ if (workflowContexts === null) {
2390
+ stats.unverifiable += 1;
2391
+ report("enforcement", `the floor requires the status check \`${check.context}\` and this workspace declares no tree to check it against — unverifiable`);
2392
+ continue;
2393
+ }
2394
+ if (!workflowContexts.found) {
2395
+ stats.unverifiable += 1;
2396
+ report("enforcement", `the floor requires the status check \`${check.context}\` and there are no workflows in the tree to report it — unverifiable`);
2397
+ continue;
2398
+ }
2399
+ stats.claims += 1;
2400
+ // The one enforcement FAILURE, and it earns the severity: a required context that
2401
+ // never reports blocks every pull request, and `enforce_admins` leaves nobody able
2402
+ // to force past it. That is proposal 0004's lesson, which cost a three-step rename
2403
+ // to work around after the fact — the highest-priced typo this tree can catch.
2404
+ if (!workflowContexts.contexts.includes(check.context)) {
2405
+ fail(
2406
+ "enforcement",
2407
+ `the floor requires the status check \`${check.context}\`, which no workflow job in the tree reports (found: ${workflowContexts.contexts.join(", ") || "none"}). Importing this ruleset would block every pull request on a check that never arrives`,
2408
+ );
2409
+ }
2410
+ }
2411
+
2412
+ // Two in-tree carriers of one fact: the gate map's platform-floor table and the policy's
2413
+ // `floor`. Reported rather than failed — the prose row is a human summary and may
2414
+ // legitimately be shaped differently — but never left silent, because two files stating
2415
+ // one policy is this repository's signature defect, and the prose half is the half no
2416
+ // other check here reads for content.
2417
+ // NOT gated on the prose having named a check. It was for one round, which exempted the
2418
+ // worst divergence there is: a policy declaring required checks beside a gate-map row that
2419
+ // is missing or written in a shape `requiredCheckClaims` does not recognise. The generic
2420
+ // "names no required status check" note fires there, but it reports that nothing was
2421
+ // compared against the *tree* and says nothing about the policy carrying checks the prose
2422
+ // does not. A check that quietly skips its own extreme case is
2423
+ // `a-checker-must-refuse-what-it-cannot-check.md` again. Found by review, round 2.
2424
+ if (parsed.floor && gatesRead) {
2425
+ const declared = parsed.floor.checks.map((c) => c.context);
2426
+ const missingFromProse = declared.filter((c) => !declaredChecksInProse.includes(c));
2427
+ const missingFromPolicy = declaredChecksInProse.filter((c) => !declared.includes(c));
2428
+ if (missingFromProse.length || missingFromPolicy.length) {
2429
+ report(
2430
+ "enforcement",
2431
+ "the gate policy's `floor` and the gate map's required-check row disagree" +
2432
+ (missingFromProse.length ? `; the policy declares ${missingFromProse.map((c) => `\`${c}\``).join(", ")} and the prose does not name it` : "") +
2433
+ (missingFromPolicy.length ? `; the prose names ${missingFromPolicy.map((c) => `\`${c}\``).join(", ")} and the policy does not declare it` : "") +
2434
+ ". Where they disagree the policy wins, because it is the one that compiles",
2435
+ );
2436
+ }
2437
+ }
2438
+ }
2439
+ } else if (workspace.packs?.length) {
2440
+ // **The edge one door down, named rather than left to be found.** This section is guarded on
2441
+ // the workspace declaring a `gates` slot, and composition does not care whether it does: a
2442
+ // workspace can compose a pack that contributes gates while declaring no policy of its own,
2443
+ // and then the guard above skips — silence, over a policy that has gate rules in it. That is
2444
+ // the same shape as the divergence this whole change repairs, displaced by one condition.
2445
+ //
2446
+ // Measured on `d5a5eb7`: `examples/` is exactly this workspace — it composes
2447
+ // `rituals/checkpoints`, which contributes two gate fragments, and declares no `gates` and
2448
+ // carries no `gates.json`. `compile --workspace examples --check` exits **2** there
2449
+ // (could-not-run: nothing to compose *into*), while `doctor` said nothing at all.
2450
+ //
2451
+ // **Report, never fail**, and the severity is not a preference: `../.portulan/verify/doctor.sh`
2452
+ // grades `examples` on every run as part of a required check, so a failure here would turn the
2453
+ // repository's own verify red over a workspace that is behaving as designed. It is also keyed
2454
+ // on contributions being non-empty rather than on `packs` being non-empty, so a workspace
2455
+ // composing packs that contribute no gates keeps producing zero enforcement findings — which
2456
+ // is what `./doctor.test.mjs`'s no-gates case pins.
2457
+ try {
2458
+ const { contributions } = packContributions(dir, ".", { packRoots: resolvedPackRoots ?? [] });
2459
+ const fragments = contributions.reduce((n, c) => n + (c.fragments?.length ?? 0), 0);
2460
+ if (fragments) {
2461
+ // `fragments` counts RESOLVED packs only — `packContributions` cannot read a manifest it
2462
+ // could not find — so where a declared pack did not resolve this number is a floor and
2463
+ // not a total. Saying "composes N from its packs" without that caveat implies the count
2464
+ // covers every declared pack, which is the same overstatement this whole change repairs
2465
+ // one branch up: a number claiming more than it measured. The gates-slot branch already
2466
+ // carried the caveat and this one did not. (Copilot, round 1 on #241.)
2467
+ const incomplete = packsUnresolved
2468
+ ? `, and ${packsUnresolved} declared pack(s) did not resolve, so this count is a floor rather than a total`
2469
+ : "";
2470
+ report(
2471
+ "enforcement",
2472
+ `this workspace composes ${fragments} gate rule(s) from the packs that resolved and declares no \`gates\` policy for them to join, so nothing here compiles them — \`compile\` exits 2 on this shape rather than emitting a partial policy${incomplete}`,
2473
+ );
2474
+ }
2475
+ } catch (cause) {
2476
+ // A composition this tool cannot even read is the packs section's finding, not this one's;
2477
+ // saying it twice would be one defect wearing two severities.
2478
+ report("enforcement", `the composed gate contributions could not be read — ${cause.message}`);
2479
+ }
2480
+ }
2481
+
2482
+ // ---- provenance, and the store's own growth
2483
+ if (workspace.slots?.memory) {
2484
+ const memoryDir = path.resolve(dir, workspace.slots.memory);
2485
+ let entries = [];
2486
+ try {
2487
+ entries = fs.readdirSync(memoryDir).filter((f) => f.endsWith(".md") && f !== "README.md").sort();
2488
+ } catch { /* the missing directory is already a `paths` failure */ }
2489
+
2490
+ for (const entry of entries) {
2491
+ // A record that is present and unreadable is a defect in the WORKSPACE — exit 1, reported
2492
+ // alongside everything else already found. Letting the read throw made it exit 2 and
2493
+ // discarded every finding the run had reached, which is the gates-file defect over again
2494
+ // one function down: the same shape, missed in the same change that fixed it.
2495
+ let source;
2496
+ try {
2497
+ source = fs.readFileSync(path.join(memoryDir, entry), "utf8");
2498
+ } catch (cause) {
2499
+ stats.records += 1;
2500
+ // The unreadable record still occupies the store, and it is already counted in
2501
+ // `records` — so it is sized from stat, or `record(s)` and `KB` would disagree
2502
+ // about what a record is. Its content is still never assessed: this run is
2503
+ // already red on the read failure, and only the accounting stays consistent.
2504
+ try { stats.bytes += fs.statSync(path.join(memoryDir, entry)).size; } catch { /* unstattable: its size stays unknown */ }
2505
+ stats.unassessed += 1;
2506
+ fail("provenance", `${entry} could not be read — ${cause.message}`);
2507
+ continue;
2508
+ }
2509
+ const type = recordType(source);
2510
+ const { present, fields } = parseProvenance(source);
2511
+ stats.records += 1;
2512
+ stats.bytes += Buffer.byteLength(source);
2513
+ const isRule = type === "rule";
2514
+ if (isRule) stats.rules += 1;
2515
+
2516
+ // Growth control rather than provenance: the retirement pass demotes a record by the
2517
+ // condition its own `Retire when:` line states, so a record stating none is one no
2518
+ // condition will ever demote. Reported, never failed — no rule mandates the field, and
2519
+ // doctor does not enforce what nobody legislated. Checked ahead of the provenance
2520
+ // branches below, so a record with a provenance defect is still assessed for this.
2521
+ if (!RETIRE_WHEN.test(source)) {
2522
+ stats.unretirable += 1;
2523
+ report(
2524
+ "retirement",
2525
+ `${entry} states no retirement condition — no \`**Retire when:**\` line (core/templates/memory-entry.md). A record no condition can demote leaves the store only by someone re-reading it`,
2526
+ );
2527
+ }
2528
+
2529
+ if (!present) {
2530
+ const message = `${entry} carries no provenance line at all`;
2531
+ isRule ? fail("provenance", message) : report("provenance", message);
2532
+ continue;
2533
+ }
2534
+ if (!fields) {
2535
+ // Prose provenance. Mandatory only on a rule: thesis 4, proposal 0002 as adopted,
2536
+ // dod.md condition 3 and the task's own criterion are all rule-scoped, and having
2537
+ // doctor bind types nobody legislated for would be tooling enforcing a rule the
2538
+ // constitution does not state — backwards for this product.
2539
+ const message = `${entry} (${type || "untyped"}) carries prose provenance rather than a link or a sealed stamp`;
2540
+ isRule ? fail("provenance", message) : report("provenance", message);
2541
+ continue;
2542
+ }
2543
+ const shapeErrors = validate({ $defs: schema.$defs, ...schema.$defs.provenance }, fields);
2544
+ if (shapeErrors.length) {
2545
+ const message = `${entry}: ${shapeErrors.map((e) => e.message).join("; ")}`;
2546
+ isRule ? fail("provenance", message) : report("provenance", message);
2547
+ continue;
2548
+ }
2549
+ if (isRule) fields.form === "sealed" ? (stats.sealed += 1) : (stats.linked += 1);
2550
+ }
2551
+ }
2552
+
2553
+ // Always emitted, including for zero records. A workspace with no memory yet is valid — the
2554
+ // minimality rule is explicit that a day-one workspace must pass — but "checked nothing" and
2555
+ // "checked everything and found nothing wrong" print identically unless one of them says so.
2556
+ const proportion = stats.rules ? `${stats.sealed} of ${stats.rules}` : "0 of 0";
2557
+ report(
2558
+ "provenance",
2559
+ `${stats.records} memory record(s), ${stats.rules} of them rules; sealed proportion ${proportion}` +
2560
+ (stats.rules && stats.sealed === stats.rules
2561
+ ? " — every rule is sealed, which means this workspace has opted out of retirement altogether"
2562
+ : "") +
2563
+ ". The form is checked, never the truth: a fabricated stamp passes exactly as a real one does",
2564
+ );
2565
+
2566
+ // The store's growth, always emitted for the same reason the line above is: a store quietly
2567
+ // growing and a store quietly fine print identically unless something says which. Size and
2568
+ // count are what doctor can honestly see — it reads the tree and never git, and in a fresh
2569
+ // clone every file's mtime is checkout time, so an age report from here would be fabrication.
2570
+ // Staleness is ./librarian.mjs's, the scheduled pass, which may legitimately ask git — and which
2571
+ // is not a verify recipe precisely so that asking is safe. Built at milestone 5.
2572
+ report(
2573
+ "retirement",
2574
+ stats.records
2575
+ ? `the store holds ${stats.records} record(s), ${(stats.bytes / 1024).toFixed(1)} KB — ` +
2576
+ (stats.unretirable
2577
+ ? `${stats.unretirable} with no retirement condition`
2578
+ : stats.unassessed
2579
+ ? "every readable record states a retirement condition"
2580
+ : "every record states a retirement condition") +
2581
+ (stats.unassessed
2582
+ ? `; ${stats.unassessed} unreadable and never assessed`
2583
+ : "") +
2584
+ ". Size and count only: ages live in git, which doctor does not read, so staleness belongs to `cli/librarian.mjs` — the scheduled pass, which may ask git and does"
2585
+ : "no memory records — nothing measured, nothing awaiting retirement",
2586
+ );
2587
+
2588
+ // Always emitted, for the third time on this page and for the same reason: a workspace nobody
2589
+ // scored and a workspace that scored well print identically unless one of them says which.
2590
+ const score = legibility(workspace, dir);
2591
+ const missed = score.dimensions.filter((d) => d.applicable && !d.met);
2592
+ const skipped = score.dimensions.filter((d) => !d.applicable);
2593
+ report(
2594
+ "legibility",
2595
+ `agent legibility ${score.met} of ${score.applicable}` +
2596
+ (missed.length ? ` — missing: ${missed.map((d) => `${d.title} (${d.why})`).join("; ")}` : " — every dimension met") +
2597
+ (skipped.length ? `. Not applicable here: ${skipped.map((d) => d.title).join(", ")}` : "") +
2598
+ ". Scored from what the manifest declares and the affordances documents it reaches; it moves no exit code, " +
2599
+ "because a score that could fail a workspace would make the verdict a function of how much prose somebody wrote",
2600
+ );
2601
+
2602
+ return { dir, workspace, findings, stats };
2603
+ }
2604
+
2605
+ // ===========================================================================================
2606
+ // 5. The command
2607
+ // ===========================================================================================
2608
+
2609
+ const ICON = { fail: "FAIL ", report: "note " };
2610
+
2611
+ /** Relative when that is shorter to read, absolute when the relative form is a ladder of `../`. */
2612
+ function display(target) {
2613
+ const rel = path.relative(process.cwd(), path.resolve(target));
2614
+ return rel === "" ? "." : rel.startsWith("..") ? path.resolve(target) : rel;
2615
+ }
2616
+
2617
+ /** Run against one or more workspace directories. Returns the exit code; never throws. */
2618
+ /**
2619
+ * The help screen. An explicit `--help` exits **0** and prints to stdout — `./portulan.mjs` states that
2620
+ * contract ("asking for help is a request, and it succeeded") and five sibling tools already keep it;
2621
+ * this tool did not, and what a user saw instead was the no-arguments usage line on **stderr** at exit
2622
+ * 2, which is the could-not-run fallback rather than an answered question. Added at milestone 7's
2623
+ * follow-up, with `compile` and `index`, after the close pass found three nonconformers where its own
2624
+ * handoff had said two.
2625
+ *
2626
+ * `../.portulan/dod.md` condition 4 binds this text: every flag below exists in the parser above it.
2627
+ */
2628
+ function usage() {
2629
+ return [
2630
+ "portulan doctor — validate a workspace against the Workspace Definition",
2631
+ "",
2632
+ " portulan doctor [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir> [<workspace-dir> ...]",
2633
+ "",
2634
+ " --pack-root where declared packs are resolved from; `auto` discovers the host's plugin cache.",
2635
+ " A named root REPLACES every other source. A directory actually named `auto` is `./auto`",
2636
+ " --repo-root where the repositories this workspace's cards NAME are checked out, so their",
2637
+ " claims can be checked against a tree rather than taken on trust",
2638
+ "",
2639
+ "Reports are notes unless something is wrong: a note moves no exit code. Discovery may turn an",
2640
+ "unresolved pack into a resolved one and never a miss into a failure, so a verdict about the",
2641
+ "repository does not become a function of what this machine happens to have installed.",
2642
+ "",
2643
+ "Exit codes: 0 succeeded · 1 a red verdict · 2 could not run.",
2644
+ ].join("\n");
2645
+ }
2646
+
2647
+ export async function run(argv, options = {}) {
2648
+ const say = options.quiet ? () => {} : (line = "") => process.stdout.write(`${line}\n`);
2649
+ // **Before every other argument decision**, so asking for help cannot be outranked by a
2650
+ // complaint about the rest of the command line. `./portulan.mjs` states the contract: an
2651
+ // explicit `--help` exits 0, because asking for help is a request and it succeeded.
2652
+ if (argv.includes("--help") || argv.includes("-h")) {
2653
+ say(usage());
2654
+ return 0;
2655
+ }
2656
+ try {
2657
+ // `--pack-root <dir>`, repeatable: resolution roots that REPLACE the one derived from `tree`
2658
+ // rather than being searched ahead of it. `inspect` has read `options.packRoots` since session 0
2659
+ // and no caller set it, so the path shaped for an adopter resolving from an installed feed had no
2660
+ // way in from a command line. Replacement rather than precedence so that "it resolved from the
2661
+ // feed" cannot be satisfied by a copy in the local tree at all — the three tools disagreed about
2662
+ // this once, and `../cli/compile.mjs`'s `namedRootsOption` carries what that cost.
2663
+ //
2664
+ // **A PACK root IS discovered by default as of 2026-08-13, and the boundary moved rather than
2665
+ // being dropped.** `./discover.mjs` reads the host's installed-plugin record, and this tool uses
2666
+ // it for two things: dereferencing a POINTER's `governed_by`, and resolving pack roots (#123).
2667
+ //
2668
+ // This passage read *"discovered only when asked … what is deliberately not taken is a DEFAULT"*,
2669
+ // and defended it as a boundary. Row 7's clause is that discovery makes `--pack-root` **optional
2670
+ // where discovery finds a root**, and it was not: measured on the workspace `init` drafts by
2671
+ // default plus one pack of the adopter's own, `doctor` exited **1** with no flag and **0** under
2672
+ // `auto`. The maintainer ruled a behaviour change rather than a row amendment; a fresh supervisor
2673
+ // graded that sound from `../docs/vision.md`, which defines this tool as a **per-host capability
2674
+ // report** — answering about the host is what it is for.
2675
+ //
2676
+ // **The boundary it replaces, sharpened, is: a verdict about the *repository* must not depend on
2677
+ // the machine.** Three things hold it, and none of them is the absence of a default:
2678
+ //
2679
+ // 1. **The pins.** Six required invocations name their root, a named root REPLACES every other
2680
+ // source, and `./pinned-roots.live.test.mjs` reds if one drops it or a seventh joins unpinned.
2681
+ // 2. **The note-vs-fail keying below is on ORIGIN**, so a root nobody claimed can turn an
2682
+ // unresolved pack from a note into a resolution, and never into a failure.
2683
+ // 3. **Never silently.** The `resolution root` note names the source, and each pack's line names
2684
+ // which root answered and whether it was discovered or derived.
2685
+ //
2686
+ // The row's own direction is unchanged and is what the arms above implement: **add a root where
2687
+ // none was named; never replace one that was.**
2688
+ //
2689
+ // `--repo-root <dir>`, repeatable, is its sibling and is deliberately shaped the same way: the
2690
+ // directories under which the repositories a workspace's cards NAME are checked out, so the
2691
+ // residence ruling's third refusal has somewhere to look. Named rather than discovered for the
2692
+ // same reason the pack roots are — a host's plugin cache holds installed plugins, and a
2693
+ // developer's checkouts are not in it, so there is nothing there to find. The two lists are
2694
+ // separate because they answer different questions and a single `--root` would make a packs
2695
+ // root and a checkout root interchangeable, which they are not: one holds `category/name` pack
2696
+ // directories, the other holds repositories.
2697
+ const namedRoots = [];
2698
+ let discoverPacks = false;
2699
+ const repoRoots = [];
2700
+ const dirs = [];
2701
+ // ONE validator for both flags, and it is one because a reviewer caught the comment claiming so
2702
+ // while `--pack-root` still carried its own copy (Copilot, round 1 on #135). Two inputs have to
2703
+ // fail closed here and each has a real incident behind it: a root that is **not there** is a
2704
+ // filesystem fact rather than a pack that failed to resolve, and reporting it as the latter sends
2705
+ // a reader to the one file that is not at fault; and existence is not enough, because
2706
+ // `existsSync` is true for a FILE, so one was accepted and every later resolution failure was
2707
+ // misattributed to the workspace. Three tools take `--pack-root` and only `index` had the
2708
+ // directory check — the sibling class the 2026-07-27 ruling names, found by Copilot on #117 round
2709
+ // 7, one round after the same defect was fixed in `index` alone. `what` keeps each flag's message
2710
+ // specific: sharing the check must not cost the reader the sentence that says what a root is FOR.
2711
+ const directoryRoot = (flag, value, what) => {
2712
+ if (value === undefined || value.startsWith("-")) throw new DoctorError(`${flag} needs a directory${what.keyword ?? ""}`);
2713
+ let stat = null;
2714
+ try {
2715
+ stat = fs.statSync(value);
2716
+ } catch (cause) {
2717
+ throw new DoctorError(
2718
+ `${flag} ${value} cannot be read — ${cause.code ?? cause.message}. Refusing to report ${what.unresolvable} against a root nothing looked in`,
2719
+ );
2720
+ }
2721
+ if (!stat.isDirectory()) {
2722
+ throw new DoctorError(`${flag} ${value} is not a directory — a resolution root is a directory ${what.holds} are looked up under`);
2723
+ }
2724
+ return path.resolve(value);
2725
+ };
2726
+ // `keyword` is PACK_ROOT's alone: `auto` discovers a plugin cache, and there is no such thing
2727
+ // for a repository checkout, so offering it on `--repo-root` would advertise a refused value.
2728
+ const PACK_ROOT = {
2729
+ unresolvable: "a pack unresolvable",
2730
+ holds: "packs",
2731
+ keyword: ", or `auto` to discover one from the host plugin cache. A directory actually named `auto` is `./auto`",
2732
+ };
2733
+ const REPO_ROOT = { unresolvable: "a repository ungoverned", holds: "repositories" };
2734
+ for (let i = 0; i < argv.length; i += 1) {
2735
+ if (argv[i] === "--repo-root") {
2736
+ repoRoots.push(directoryRoot("--repo-root", argv[i + 1], REPO_ROOT));
2737
+ i += 1;
2738
+ } else if (argv[i] === "--pack-root") {
2739
+ // The keyword, on the raw argument and before `directoryRoot` resolves anything.
2740
+ if (argv[i + 1] === AUTO) discoverPacks = true;
2741
+ else namedRoots.push(directoryRoot("--pack-root", argv[i + 1], PACK_ROOT));
2742
+ i += 1;
2743
+ } else if (!argv[i].startsWith("-")) dirs.push(argv[i]);
2744
+ else {
2745
+ // **Refused loudly, where it used to be swallowed.** This arm read `else if
2746
+ // (!startsWith("-")) dirs.push(...)` with no `else` at all, so any `-`-prefixed
2747
+ // argument this parser did not recognise was silently discarded. Measured on
2748
+ // `d5a5eb7`: `doctor --repo-rot /nonexistent` dropped the misspelled flag and graded
2749
+ // `/nonexistent` **as a workspace**, red for a reason that had nothing to do with what
2750
+ // was asked. A typo in a flag is could-not-run, never a verdict — and #155's lesson is
2751
+ // that the refusal names both real invocations, because a user who typed one of them
2752
+ // cannot act on advice about the other.
2753
+ throw new DoctorError(
2754
+ `unknown argument \`${argv[i]}\` — run \`portulan doctor --help\` or \`node cli/doctor.mjs --help\` for the flags this tool takes`,
2755
+ );
2756
+ }
2757
+ }
2758
+ // Refused at PARSE time, which is where an exit 2 belongs: before a workspace is read, so the
2759
+ // refusal cannot be mistaken for a verdict about one. `resolutionRoots` refuses the same
2760
+ // combination for callers that never parsed anything; both ask the same exported predicate.
2761
+ const bothAsked = namedWithAuto(namedRoots, discoverPacks);
2762
+ if (bothAsked) throw new DoctorError(bothAsked);
2763
+ if (dirs.length === 0) {
2764
+ if (!options.quiet) {
2765
+ process.stderr.write("usage: node cli/doctor.mjs [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir> [<workspace-dir> ...]\n");
2766
+ }
2767
+ return 2;
2768
+ }
2769
+
2770
+ let failed = 0;
2771
+ for (const dir of dirs) {
2772
+ const roots = {
2773
+ ...(namedRoots.length ? { packRoots: namedRoots } : {}),
2774
+ ...(discoverPacks ? { discoverPacks } : {}),
2775
+ ...(repoRoots.length ? { repoRoots } : {}),
2776
+ };
2777
+ const { findings, stats } = await inspect(dir, { ...options, ...roots });
2778
+ const bad = findings.filter((f) => f.severity === "fail");
2779
+ say(display(dir));
2780
+ for (const f of findings) say(` ${ICON[f.severity]} ${f.check.padEnd(10)} ${f.message}`);
2781
+ say(
2782
+ ` ${bad.length ? "RED" : "GREEN"} — ${bad.length} failure(s), ` +
2783
+ `${findings.length - bad.length} note(s), ${stats.claims} claim(s) checked` +
2784
+ (stats.unverifiable ? `, ${stats.unverifiable} unverifiable` : ""),
2785
+ );
2786
+ say();
2787
+ if (bad.length) failed += 1;
2788
+ }
2789
+ return failed ? 1 : 0;
2790
+ } catch (error) {
2791
+ // Anything reaching here means doctor could not judge — including a defect in doctor
2792
+ // itself. Exit 2. Reporting 1 would assert a verdict about the workspace that was
2793
+ // never reached, and reporting 0 would be the fail-open this whole tool exists against.
2794
+ if (!options.quiet) {
2795
+ process.stderr.write(`doctor: ${error instanceof DoctorError ? error.message : `unanticipated failure — ${error.stack ?? error}`}\n`);
2796
+ }
2797
+ return 2;
2798
+ }
2799
+ }
2800
+
2801
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
2802
+ process.exitCode = await run(process.argv.slice(2));
2803
+ }