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