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