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