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