@sleepy_panda_srl/portulan 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +5 -0
  3. package/README.md +87 -0
  4. package/cli/README.md +337 -0
  5. package/cli/compile.mjs +2209 -0
  6. package/cli/control-chars.mjs +570 -0
  7. package/cli/discover.mjs +985 -0
  8. package/cli/doctor.mjs +2803 -0
  9. package/cli/feedback.mjs +970 -0
  10. package/cli/gate.mjs +271 -0
  11. package/cli/index.mjs +1390 -0
  12. package/cli/init.mjs +1729 -0
  13. package/cli/librarian.mjs +1384 -0
  14. package/cli/manifest.mjs +59 -0
  15. package/cli/new.mjs +803 -0
  16. package/cli/pack-version.mjs +543 -0
  17. package/cli/plugin-lint.mjs +1566 -0
  18. package/cli/portulan.mjs +266 -0
  19. package/cli/recipe-set.mjs +425 -0
  20. package/cli/rule-carriers.mjs +489 -0
  21. package/cli/skills-set.mjs +715 -0
  22. package/cli/stop-gate.mjs +434 -0
  23. package/cli/upgrade.mjs +897 -0
  24. package/cli/vendor.mjs +1504 -0
  25. package/core/README.md +46 -0
  26. package/core/engine.md +47 -0
  27. package/core/operating/autonomy.md +100 -0
  28. package/core/operating/evolution.md +84 -0
  29. package/core/operating/loop.md +83 -0
  30. package/core/operating/memory.md +161 -0
  31. package/core/operating/safety.md +42 -0
  32. package/core/operating/verification.md +76 -0
  33. package/core/personas/README.md +40 -0
  34. package/core/personas/implementer.md +51 -0
  35. package/core/personas/librarian.md +73 -0
  36. package/core/personas/reviewer.md +52 -0
  37. package/core/skills/README.md +52 -0
  38. package/core/skills/clarify/SKILL.md +42 -0
  39. package/core/skills/codify/SKILL.md +65 -0
  40. package/core/skills/consolidate/SKILL.md +121 -0
  41. package/core/templates/gate-policy.md +76 -0
  42. package/core/templates/handoff.md +29 -0
  43. package/core/templates/memory-entry.md +44 -0
  44. package/core/templates/pack.md +94 -0
  45. package/core/templates/persona.md +69 -0
  46. package/core/templates/proposal.md +27 -0
  47. package/core/templates/repo-card.md +28 -0
  48. package/core/templates/skill.md +57 -0
  49. package/core/templates/task.md +26 -0
  50. package/core/templates/workspace.md +81 -0
  51. package/package.json +41 -0
  52. package/packs/.claude-plugin/plugin.json +22 -0
  53. package/packs/README.md +61 -0
  54. package/packs/rituals/README.md +13 -0
  55. package/packs/rituals/checkpoints/README.md +105 -0
  56. package/packs/rituals/checkpoints/pack.json +36 -0
  57. package/packs/rituals/checkpoints/personas/supervisor.md +64 -0
  58. package/packs/rituals/checkpoints/skills/milestone-close/SKILL.md +75 -0
  59. package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +75 -0
  60. package/packs/rituals/checkpoints/skills/session-open/SKILL.md +57 -0
  61. package/packs/stacks/README.md +8 -0
  62. package/packs/tools/README.md +17 -0
  63. package/packs/tools/github/README.md +65 -0
  64. package/packs/tools/github/pack.json +29 -0
  65. package/packs/tools/github/verify/README.md +51 -0
  66. package/packs/tools/github/verify/actions-pinned.sh +132 -0
  67. package/spec/README.md +399 -0
  68. package/spec/migrations/0001-repository-declares-its-tree.mjs +100 -0
  69. package/spec/migrations/0002-bundle-fallback-path.mjs +179 -0
  70. package/spec/migrations/README.md +95 -0
  71. package/spec/pack.schema.json +201 -0
  72. package/spec/slots.md +796 -0
  73. package/spec/workspace.schema.json +438 -0
package/cli/index.mjs ADDED
@@ -0,0 +1,1390 @@
1
+ #!/usr/bin/env node
2
+ // `index` — the memory index generator, and the rail on what memory may cost.
3
+ //
4
+ // node cli/index.mjs [--check] [--pack-root <dir>]... <workspace-dir> [<workspace-dir> ...]
5
+ //
6
+ // Exit 0 every index is current and within budget · 1 one is not · 2 could not run.
7
+ //
8
+ // ../core/operating/memory.md gives the store four states, and this is the third: "a size-budgeted
9
+ // index is generated so the right memory is recalled without loading all of it. The index is built,
10
+ // never hand-maintained." Before this file existed the *built* half was a promise and the *budgeted*
11
+ // half was a sentence binding review — which ../.portulan/memory/a-mandate-nothing-checks-is-already-broken.md
12
+ // says is another way of spelling broken.
13
+ //
14
+ // ## What is budgeted, and why four numbers rather than one
15
+ //
16
+ // The index is the layer that gets loaded to decide what else to load, so its size is what memory
17
+ // costs on every recall — that is the `lines` budget, and with one line per record it is a rail on
18
+ // record COUNT. It cannot see the other axis: a store of unchanged record count can grow without
19
+ // limit in bytes, and nothing in the index would move. So the store's own size is budgeted too, in
20
+ // KB, against the same measure `doctor` already reports. The maintainer's directive was "memory
21
+ // growing too large"; one number would have covered half of it.
22
+ //
23
+ // A third number, `columns`, closes the hole a line budget has — one record whose line is enormous
24
+ // absorbs what the line budget is counting. It is refused rather than truncated, because a silently
25
+ // shortened title is a generated file disagreeing with the store it was generated from, which is the
26
+ // class of defect this whole file exists to make impossible.
27
+ //
28
+ // A fourth, `record_kilobytes`, closes the SAME hole one level down, and arrived at Workspace
29
+ // Definition 2.8 with proposal `0025` — the store's `columns`. An aggregate over individually-authored
30
+ // records cannot see inside them: one enormous record absorbs what the store budget counts, exactly as
31
+ // one enormous line absorbs what the line budget counts. It also fixes where the rail lands. An
32
+ // aggregate is a COMMONS — the record that grew to 15.7 KB never felt the total at write time, and
33
+ // three unrelated changes in two weeks hit it instead, one of them with 551 bytes of headroom. A
34
+ // per-record cap makes the breach LOCAL: record X over its cap never blocks writer Y, and it fires on
35
+ // the author growing the record, at the moment of growth. This workspace declares it in place of
36
+ // `kilobytes`; both stay legal, and `../examples/workspace.json` is the tree's live `kilobytes` case.
37
+ //
38
+ // **None of the four is defaulted.** An undeclared budget is not checked. Defaulting would be this
39
+ // project setting policy for every workspace that ever adopts the spec, in a key nobody typed —
40
+ // the rule spec 2.2 already applies to the gate policy's `floor` object.
41
+ //
42
+ // ## The remedy on breach is consolidation, never a larger budget
43
+ //
44
+ // Stated in ../core/operating/memory.md and carried out by ../core/skills/consolidate/SKILL.md.
45
+ // What this tool can enforce is the breach; it cannot enforce the remedy. Refusing a budget RAISE
46
+ // would mean reading git history — and **the reason that used to settle the matter no longer holds.**
47
+ // This said a history check is "a false-red generator in a shallow CI checkout"; since #265 the verify
48
+ // job sets `fetch-depth: 0`, because `./pack-version.mjs` reads a diff and cannot answer without one.
49
+ // So a history-reading check now runs in this repository's CI, and the remaining reason is the smaller
50
+ // and more honest one: nobody has written this particular check. #75 is its issue, and it is reachable
51
+ // rather than blocked. The raise stays at the human gate until then, stated rather than implied away.
52
+ //
53
+ // ## Why this generator writes an over-budget index instead of refusing to
54
+ //
55
+ // Because the remedy is consolidation, and consolidating needs the artifact to consolidate FROM.
56
+ // A generator that will not emit what breaches leaves the author holding a red and no file. So it
57
+ // emits, and then judges — the one place in this repository where writing and verdict are separate
58
+ // steps on purpose.
59
+ //
60
+ // Zero dependencies, no network, no install step — same constraints as ./doctor.mjs and
61
+ // ./compile.mjs. Absorbed by the zero-dependency ESM CLI at milestone 7, where `index` is one of the eight
62
+ // subcommands ../docs/vision.md names. (A note for that session: this file is `cli/index.mjs`
63
+ // because the subcommand is called `index`, and npm packaging conventionally wants that name for a
64
+ // package entry point. The collision is inherited deliberately rather than discovered.)
65
+
66
+ import crypto from "node:crypto";
67
+ import fs from "node:fs";
68
+ import path from "node:path";
69
+ import { pathToFileURL } from "node:url";
70
+
71
+ // The cascade, imported rather than re-derived. The scopes series is the one series whose source is
72
+ // not a directory this workspace owns: a persona's memory scope is declared inside a pack the
73
+ // workspace merely composes, so finding it means resolving the `packs` array exactly the way the
74
+ // compiler and `doctor` already resolve it. A second resolver here would be a second answer to
75
+ // "which pack is that" — and issue #74 already records what a memory store cost this project when
76
+ // three copies decided what counted as a record.
77
+ import { resolvePack, rootPlan } from "./compile.mjs";
78
+ // Host plugin-cache discovery (#123) — imported, never re-implemented, for the reason above.
79
+ import { AUTO, discoverPackRoots, namedWithAuto } from "./discover.mjs";
80
+
81
+ /** Raised when `index` cannot run, or cannot judge honestly. Always exit 2, never 1. */
82
+ export class IndexError extends Error {
83
+ constructor(message) {
84
+ super(message);
85
+ this.name = "IndexError";
86
+ }
87
+ }
88
+
89
+ // The Workspace Definition versions whose shape this tool reads. The `memory` object arrives at 2.3
90
+ // and is optional, so an earlier manifest is read correctly — it simply declares no index, which is
91
+ // a legitimate shape and the one every workspace had yesterday. Same reasoning as ./compile.mjs's
92
+ // KNOWN_GATE_POLICY_SPECS — which tracks the GATE-POLICY train, not this one — and the same refusal
93
+ // for anything outside the set: a tool that reads a manifest it
94
+ // does not understand reports about a workspace it may have misread.
95
+ const KNOWN_SPECS = new Set(["2.0", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7", "2.8"]);
96
+
97
+ // The store's own signpost, not a record. `doctor` excludes exactly this name from its walk, so the
98
+ // two tools agree on what the store contains; disagreeing would put a record in the index that the
99
+ // store report does not count, or the reverse.
100
+ const NOT_A_RECORD = new Set(["README.md"]);
101
+
102
+ const KB = 1024;
103
+
104
+ /**
105
+ * Is `child` inside `parent`? Exported because `doctor` asks the same question about the same two
106
+ * paths, and the obvious spelling — `!path.relative(parent, child).startsWith("..")` — is wrong in a
107
+ * way that fails OPEN.
108
+ *
109
+ * A name beginning with `..` is an ordinary filename, not a traversal: `path.relative` of
110
+ * `memory/..index.md` against `memory/` is `..index.md`, which `startsWith("..")` calls *outside*.
111
+ * Measured end to end before this was written — an index declared at `memory/..index.md` was written
112
+ * into the store, reported `ok`, and `doctor` then counted it as a second record, reporting it for
113
+ * stating no retirement condition. That is precisely the outcome the siting rule exists to prevent,
114
+ * in the check chosen over a filename exemption **because** an exemption would be a door any record
115
+ * could walk through. The door was in the containment test. Found by Copilot on #72, in the
116
+ * suppressed half of the round; ninth fail-open found in this repository's scaffolding, and the
117
+ * first one written by the change that cites the class.
118
+ *
119
+ * Only `..` exactly, or `..` followed by a separator, means outside. An absolute result means the
120
+ * two paths share no root and cannot contain one another.
121
+ *
122
+ * It lives here and `doctor` imports it, on the reasoning that already put `compile`'s accounting
123
+ * behind one import: two copies of a rule drift, and these two copies drifted into the identical
124
+ * defect before either had shipped.
125
+ */
126
+ export function isInside(parent, child) {
127
+ const rel = path.relative(parent, child);
128
+ if (rel === "") return true;
129
+ if (path.isAbsolute(rel)) return false;
130
+ return rel !== ".." && !rel.startsWith(`..${path.sep}`);
131
+ }
132
+
133
+ // ===========================================================================================
134
+ // Titles
135
+ // ===========================================================================================
136
+ //
137
+ // A record's title is its FILENAME. That is not a preference — it is the only title every record
138
+ // already has: ../core/templates/memory-entry.md prescribes no heading, and 24 of the 27 live
139
+ // records carry none. It is also the title every cross-reference in this repository already uses,
140
+ // since links to records are written with the path as the link text.
141
+ //
142
+ // The three records that DO carry an H1 are the reason for the check below rather than an argument
143
+ // against the rule. Two carriers of a name are tolerable; two answers are not.
144
+
145
+ /**
146
+ * A record's declared `**type:**`, lowercased — `""` when it declares none.
147
+ *
148
+ * The one carrier. Three tools ask this of the same records — this one puts it on every index line,
149
+ * `./doctor.mjs` decides by it which records provenance is mandatory on, and `./librarian.mjs` needs
150
+ * it because thesis 4's mandates are rule-scoped — and all three carried their own spelling of the
151
+ * regex, which is half of what issue #74 is about. It lives here rather than in `./doctor.mjs`
152
+ * because that module already imports `isInside` from this one, and the reverse direction would make
153
+ * the graph a cycle for no gain.
154
+ */
155
+ export const recordType = (source) => (source.match(/^\s*\*\*type:\*\*\s*(\S+)/im)?.[1] ?? "").toLowerCase();
156
+
157
+ /** `a-review-loop-needs-a-bound.md` → `A review loop needs a bound`. */
158
+ export function titleOf(filename) {
159
+ const stem = filename.replace(/\.md$/i, "");
160
+ const words = stem.split("-").filter(Boolean).join(" ");
161
+ return words.charAt(0).toUpperCase() + words.slice(1);
162
+ }
163
+
164
+ /**
165
+ * The record's H1, or null — read from the document's **first non-blank line only**.
166
+ *
167
+ * The obvious spelling, a multiline `^#` match, is a false-red generator and was measured as one
168
+ * before this comment was written: a record with no heading at all but a shell comment at column 0
169
+ * inside a fenced block — `# regenerate the thing` — came back as carrying that heading, and the
170
+ * title check failed a record whose filename was never in question. `docs.sh` has the same blind
171
+ * spot with code spans and states it in Known limits rather than parsing Markdown; this takes the
172
+ * cheaper repair available here, because a record's title can only be its first line.
173
+ *
174
+ * The residual limit, stated rather than left to be found: a heading further down the document is
175
+ * not read, so a record whose *second* section is an `# H1` disagreeing with the filename passes.
176
+ * That is the right trade — the failure it lets through is a stray heading nobody navigates by, and
177
+ * the failure it prevents is a red on a record that is correct, which is what gets a check switched
178
+ * off (../.portulan/verify/README.md).
179
+ */
180
+ export function headingOf(source) {
181
+ const first = source.split("\n").find((line) => line.trim() !== "");
182
+ return first?.match(/^#[ \t]+(.+?)[ \t]*$/)?.[1] ?? null;
183
+ }
184
+
185
+ /**
186
+ * A handoff's date, read from the leading `YYYY-MM-DD` of its filename — or `null`.
187
+ *
188
+ * ../core/operating/loop.md fixes the form as `YYYY-MM-DD-{slug}.md` and argues why the date is in the
189
+ * filename rather than in the prose: a date buried in a document needs parsing and is written
190
+ * differently by every author, while a filename sorts chronologically for free. This reads the carrier
191
+ * that rule already established rather than adding a second one.
192
+ *
193
+ * **Validated, not merely matched.** `2026-13-45-impossible.md` matches the shape and names no day. An
194
+ * index whose leading column is an unparseable string is a chronological index that is not
195
+ * chronological, and every consumer downstream — the pass that ages the series, a reader scanning for
196
+ * a month — would be reading a slug. The round-trip through `Date.UTC` is the cheapest complete check:
197
+ * it rejects month 13, day 45, and 30 February, none of which a regex can see.
198
+ *
199
+ * `null` is a refusal, never a default. ../.portulan/verify/docs.sh already fails an undated file in
200
+ * the series; this is not a second opinion about that rule but the generator being unable to derive a
201
+ * field it must emit.
202
+ */
203
+ export function dateOf(filename) {
204
+ const m = filename.match(/^(\d{4})-(\d{2})-(\d{2})-/);
205
+ if (!m) return null;
206
+ const [, y, mo, d] = m;
207
+ const at = new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d)));
208
+ const iso = `${y}-${mo}-${d}`;
209
+ return at.toISOString().slice(0, 10) === iso ? iso : null;
210
+ }
211
+
212
+ // Compared with punctuation and case removed, because a filename cannot carry either. "Who may
213
+ // commit is verified, not assumed" is the same title as `who-may-commit-is-verified-not-assumed.md`
214
+ // and must not be reported as a disagreement — a check that reds on a comma is a false-red machine,
215
+ // and a false red is what gets a whole check switched off (../.portulan/verify/README.md).
216
+ const normalize = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "");
217
+
218
+ // ===========================================================================================
219
+ // Reading the store
220
+ // ===========================================================================================
221
+
222
+ /**
223
+ * The `.md` files of a series, sorted, or an `IndexError`.
224
+ *
225
+ * One enumeration for both series, because the failure it guards is the same one and this repository
226
+ * has now fixed it four times: an unreadable directory returning `[]` renders an empty index, and an
227
+ * empty index compares byte-equal to an empty committed one and passes
228
+ * (../.portulan/memory/verify-preconditions-fail-closed.md). A second copy of this three-line function
229
+ * is a second place for that to be got wrong.
230
+ */
231
+ function listSeries(seriesDir, slot, what) {
232
+ let names;
233
+ try {
234
+ names = fs.readdirSync(seriesDir);
235
+ } catch (cause) {
236
+ throw new IndexError(
237
+ `cannot read the ${what} at ${slot} — ${cause.code ?? cause.message}. ` +
238
+ "Refusing to render an index of nothing: an empty index compares equal to an empty committed one and would pass",
239
+ );
240
+ }
241
+ return names.filter((n) => n.endsWith(".md") && !NOT_A_RECORD.has(n)).sort();
242
+ }
243
+
244
+ /**
245
+ * Read a workspace's memory store.
246
+ *
247
+ * Returns `{ records: [{ file, title, type, heading, bytes }], bytes }`. Throws `IndexError` rather than
248
+ * returning an empty store for anything it cannot read: an empty list renders an empty index, and an
249
+ * empty index compares equal to an empty committed one and passes — the enumeration fail-open this
250
+ * repository has now fixed four times (../.portulan/memory/verify-preconditions-fail-closed.md).
251
+ */
252
+ export function readStore(dir, workspace) {
253
+ const slot = workspace?.slots?.memory;
254
+ if (!slot) {
255
+ throw new IndexError(
256
+ "the manifest declares a `memory` budget but no `slots.memory` store — there is nothing to index. " +
257
+ "The declared JSON Schema subset has no `dependentRequired` (spec/README.md), so this is checked here and by `doctor`",
258
+ );
259
+ }
260
+
261
+ const storeDir = path.resolve(dir, slot);
262
+ const files = listSeries(storeDir, slot, "memory store");
263
+ const records = [];
264
+ let bytes = 0;
265
+
266
+ for (const file of files) {
267
+ let source;
268
+ try {
269
+ source = fs.readFileSync(path.join(storeDir, file), "utf8");
270
+ } catch (cause) {
271
+ // Present and unreadable is not "absent". Skipping it would drop a record from the index
272
+ // silently, which is precisely the shape a generated file must never have.
273
+ throw new IndexError(`cannot read the record ${path.join(slot, file)} — ${cause.code ?? cause.message}`);
274
+ }
275
+ const recordBytes = Buffer.byteLength(source);
276
+ bytes += recordBytes;
277
+ records.push({
278
+ file,
279
+ title: titleOf(file),
280
+ // The per-record measure the `record_kilobytes` rail is denominated in, taken here rather
281
+ // than re-read later so the aggregate and the per-unit rail cannot disagree about what
282
+ // they measured — the same reason the store total uses the figure `doctor` already prints.
283
+ bytes: recordBytes,
284
+ // `untyped` rather than a failure: `doctor` already reports a record with no `**type:**`
285
+ // line, and legislating one field in two tools with two severities is how two checkers
286
+ // start disagreeing about one store.
287
+ type: recordType(source) || "untyped",
288
+ heading: headingOf(source),
289
+ });
290
+ }
291
+
292
+ return { records, bytes };
293
+ }
294
+
295
+ /**
296
+ * Read a workspace's handoff series.
297
+ *
298
+ * Returns `{ records: [{ file, date, heading }], bytes }`, **newest first** — reverse filename order,
299
+ * which is reverse chronological because the filename leads with the date. Two handoffs of the same
300
+ * date fall in reverse slug order, which carries no claim about which was written first; nothing in
301
+ * the series records that, and inventing an order would be the generated file asserting something its
302
+ * source does not say.
303
+ *
304
+ * `date` and `heading` may be `null`. They are not repaired here — the judge reports them, with a
305
+ * different repair for each, and emits no index at all while either is missing.
306
+ */
307
+ export function readHandoffs(dir, workspace) {
308
+ const slot = workspace?.slots?.handoffs;
309
+ if (!slot) {
310
+ throw new IndexError(
311
+ "the manifest declares a `handoffs` index but no `slots.handoffs` series — there is nothing to index. " +
312
+ "The declared JSON Schema subset has no `dependentRequired` (spec/README.md), so this is checked here and by `doctor`",
313
+ );
314
+ }
315
+
316
+ const seriesDir = path.resolve(dir, slot);
317
+ const files = listSeries(seriesDir, slot, "handoff series").reverse();
318
+ const records = [];
319
+ let bytes = 0;
320
+
321
+ for (const file of files) {
322
+ let source;
323
+ try {
324
+ source = fs.readFileSync(path.join(seriesDir, file), "utf8");
325
+ } catch (cause) {
326
+ throw new IndexError(`cannot read the handoff ${path.join(slot, file)} — ${cause.code ?? cause.message}`);
327
+ }
328
+ bytes += Buffer.byteLength(source);
329
+ records.push({ file, date: dateOf(file), heading: headingOf(source) });
330
+ }
331
+
332
+ return { records, bytes };
333
+ }
334
+
335
+ /**
336
+ * The `## Memory scope` section of a persona file, normalized, or `null` if it carries none.
337
+ *
338
+ * The fourth part of the five-part persona contract (../core/personas/README.md). Read as a section
339
+ * rather than a frontmatter key because that is where the contract puts it, and inventing a key would
340
+ * make every persona this project already ships non-conforming to a contract nobody changed.
341
+ */
342
+ export function memoryScopeOf(source) {
343
+ const m = source.match(/^##[ \t]+Memory scope[ \t]*$([\s\S]*?)(?=^##[ \t]|$(?![\s\S]))/im);
344
+ if (!m) return null;
345
+ // Collapsed to one line, and parenthetical provenance asides dropped, so the digest is stable
346
+ // against reflowing: a scope rewrapped to a different column width is the same declaration, and a
347
+ // rail that reddened on it would teach authors to distrust the rail. What it is deliberately NOT
348
+ // stable against is a changed word — which is the entire point of digesting this text.
349
+ // `_(…)_` non-greedily to the first `)_`, NOT `[^)]*`: the one aside the shipped supervisor persona
350
+ // carries contains a markdown link, whose own `)` defeated the character class — so the aside was
351
+ // digest input while this comment said asides were dropped. Found by the pre-commit checkpoint.
352
+ const body = m[1].replace(/_\([\s\S]*?\)_/g, " ").replace(/\s+/g, " ").trim();
353
+ return body || null;
354
+ }
355
+
356
+ /**
357
+ * The first sentence of a scope — the legible half of an index line, cut VISIBLY when it runs long.
358
+ *
359
+ * A colon does not end a sentence here, and that is a measured choice rather than a grammatical one:
360
+ * the shipped supervisor persona opens `Its own supervisor memory: classes of defect …`, and breaking
361
+ * on the colon rendered a line that named the persona twice and said nothing. The clause after the
362
+ * colon is the informative half.
363
+ *
364
+ * When the sentence is longer than the budget it is truncated with an ellipsis rather than quietly
365
+ * clipped — a generated file that shortens its source without saying so is the defect `render` above
366
+ * refuses for the same reason. Completeness is the digest's job, not this text's.
367
+ */
368
+ const SENTENCE_BUDGET = 120;
369
+ const firstSentence = (scope) => {
370
+ const sentence = (scope.match(/^([\s\S]*?\.)(\s|$)/) ?? [null, scope])[1].trim();
371
+ if (sentence.length <= SENTENCE_BUDGET) return sentence;
372
+ const clipped = sentence.slice(0, SENTENCE_BUDGET);
373
+ const atWord = clipped.lastIndexOf(" ");
374
+ return `${(atWord > 40 ? clipped.slice(0, atWord) : clipped).trimEnd()}…`;
375
+ };
376
+
377
+ /** Eight hex of sha-256 over the normalized scope — the complete half, since a first sentence is not. */
378
+ const scopeDigest = (scope) => crypto.createHash("sha256").update(scope, "utf8").digest("hex").slice(0, 8);
379
+
380
+ /**
381
+ * Everything a pack ships that would be a memory record — any path with `memory` as a **segment**.
382
+ *
383
+ * The segment, not the prefix `memory/`: that reports a file under a `memory/` directory, the directory
384
+ * itself, and a **symlink named `memory`** — which is the case the prefix form missed, since a link is
385
+ * neither a directory this walk descends nor a file beneath one. Copilot found that bypass on #117 round
386
+ * 8, and this docstring described the prefix form for one round after the code stopped implementing it —
387
+ * a code/comment mismatch in a security-relevant check, which is `a-stated-enforcer-must-be-the-real-one`
388
+ * pointed at a comment instead of a rule.
389
+ *
390
+ * Bounded by construction rather than by a depth constant: it walks the pack it was handed and nothing
391
+ * above it, and it does not follow links — this answers *what does the pack carry*, and a link named
392
+ * `memory` is something it carries whatever it points at. `memory` is the name the Workspace Definition's
393
+ * store slot uses in both live manifests, so it is the name a pack author would reach for.
394
+ */
395
+ function packRecords(packDir, rel = "", out = []) {
396
+ let entries;
397
+ try {
398
+ entries = fs.readdirSync(path.join(packDir, rel), { withFileTypes: true });
399
+ } catch (cause) {
400
+ throw new IndexError(
401
+ `cannot read the pack directory ${path.join(packDir, rel)} — ${cause.code ?? cause.message}. ` +
402
+ "Refusing to report that a pack carries no records of its own when it could not be enumerated",
403
+ );
404
+ }
405
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
406
+ const child = rel ? path.posix.join(rel, e.name) : e.name;
407
+ // `memory` as a SEGMENT, matched before anything is recursed into or classified. A symlink named
408
+ // `memory` is neither a directory this walk descends nor a file under `memory/`, so the old
409
+ // `memory/`-only pattern let a pack smuggle a whole store past a check whose entire subject is
410
+ // what the pack ships. Copilot, #117 round 8. Matching the segment closes it without following
411
+ // the link — which is the right trade: this walk answers "what does the pack CARRY", and a link
412
+ // named `memory` is something it carries whatever it points at.
413
+ if (/(^|\/)memory(\/|$)/.test(child)) {
414
+ out.push(child);
415
+ continue;
416
+ }
417
+ if (e.isDirectory()) packRecords(packDir, child, out);
418
+ }
419
+ return out;
420
+ }
421
+
422
+ /**
423
+ * Read the per-persona memory scopes a workspace's composed packs declare.
424
+ *
425
+ * Returns `{ scopes: [{ persona, pack, location, scope, origin }], unresolved, carrying, broken }`.
426
+ * `origin` is which root answered — `"discovered"`, `"derived"`, or null when the set was not a
427
+ * union and the invocation therefore already said. It is reported and never written.
428
+ *
429
+ * **The one series whose source is the cascade rather than the tree.** A workspace declares `packs`;
430
+ * each pack's manifest declares `contributes.personas`; each persona declares its memory scope. What
431
+ * lands in the adopter's layer is one location per persona — declared by the pack, owned and populated
432
+ * only by the adopter (../docs/milestones/m06.md; thesis 6 of ../docs/vision.md, storage follows
433
+ * ownership). That is also why nothing here reads or writes a record: the scope is a declaration, and
434
+ * the contents are the adopter's to earn.
435
+ *
436
+ * Unresolvable packs, packs carrying records of their own, and personas declaring no scope are all
437
+ * RETURNED rather than thrown — each is a verdict about the composition and belongs in the findings
438
+ * list beside the index's own. What throws is only what makes the render underivable at all.
439
+ */
440
+ export function readScopes(dir, workspace, options = {}) {
441
+ const slot = workspace?.slots?.personas;
442
+ if (!slot) {
443
+ throw new IndexError(
444
+ "the manifest declares a `personas` index but no `slots.personas` layer — there is nowhere for a scope to land. " +
445
+ "The declared JSON Schema subset has no `dependentRequired` (spec/README.md), so this is checked here and by `doctor`",
446
+ );
447
+ }
448
+
449
+ const declared = Array.isArray(workspace?.packs) ? workspace.packs : [];
450
+ // `options.packRoots` is the FINAL root set when given, matching `compile`'s `packContributions` —
451
+ // an explicitly empty array means search nowhere rather than falling through to the derived root.
452
+ // Built through `rootPlan` on every path, `namedGiven` carrying this option's FINAL-set meaning.
453
+ // The short-circuit it replaces made `packRoots` silently ignore `discoverPacks`, so a caller
454
+ // asking for both got one of them and no word about the other.
455
+ const plan = rootPlan(dir, workspace, {
456
+ named: options.packRoots ?? [],
457
+ namedGiven: options.packRoots !== undefined && options.packRoots !== null,
458
+ discovery: () => discoverPackRoots(),
459
+ forced: options.discoverPacks === true,
460
+ });
461
+ if (plan.refusal) throw new IndexError(plan.refusal);
462
+ if (plan.couldNotRun) throw new IndexError(plan.couldNotRun);
463
+ const roots = plan.roots;
464
+ // Origin is stated in what a reader SEES and never in what gets written: an index whose bytes
465
+ // recorded which root answered would regenerate differently on two machines, and `index --check`
466
+ // byte-compares. Provenance belongs in the finding, not in the artifact.
467
+ const originOf = new Map((plan.origins ?? []).map((o) => [o.root, o.origin]));
468
+ const posixSlot = slot.split(path.sep).join(path.posix.sep).replace(/\/$/, "");
469
+
470
+ const scopes = [];
471
+ const unresolved = [];
472
+ const carrying = [];
473
+ const broken = [];
474
+
475
+ for (const name of declared) {
476
+ const found = resolvePack(name, roots);
477
+ if (!found.dir) {
478
+ unresolved.push(found);
479
+ continue;
480
+ }
481
+
482
+ let manifest;
483
+ try {
484
+ manifest = JSON.parse(fs.readFileSync(found.manifest, "utf8"));
485
+ } catch (cause) {
486
+ throw new IndexError(
487
+ `cannot read the pack manifest for \`${found.name}\` at ${found.manifest} — ${cause.code ?? cause.message}. ` +
488
+ "Refusing to render a scope index over a pack whose declaration could not be read",
489
+ );
490
+ }
491
+
492
+ // Observation 3 of the landing clause, checked at the DISTRIBUTING side. A pack shipping
493
+ // records would be core-and-packs absorbing the adopter's specifics, and it is invisible from
494
+ // the adopter's own layer — which is exactly why the check lives here.
495
+ for (const rel of packRecords(found.dir)) carrying.push({ pack: found.name, file: rel });
496
+
497
+ const personas = manifest?.contributes?.personas;
498
+ if (personas !== undefined && !Array.isArray(personas)) {
499
+ throw new IndexError(
500
+ `the pack manifest for \`${found.name}\` declares \`contributes.personas\` as ${typeof personas} rather than an array. ` +
501
+ "Refusing rather than reading past it — run `doctor` to validate the pack against the Pack Definition",
502
+ );
503
+ }
504
+
505
+ for (const rel of personas ?? []) {
506
+ // **A declared persona path must stay inside the pack.** `resolvePack` already refuses a `..`
507
+ // in the pack NAME, on the reasoning that it "would resolve a declared pack outside every root
508
+ // it was supposed to be searched in" — and the paths inside the manifest had no such guard, so
509
+ // `../../../somewhere` or an absolute path read a file the adopter never offered and put a
510
+ // digest of its contents into a committed index. A composed pack is third-party content by
511
+ // construction: that is the whole reason its gate fragments may only ever tighten, and the same
512
+ // reasoning binds what it may read. Found by Copilot on #117, round 5.
513
+ // Checked before `path.resolve` touches it: a manifest is a file a human edits, `doctor` may
514
+ // not have run (the two tools have no ordering), and `path.resolve` on a number throws a bare
515
+ // TypeError that escapes as an unanticipated crash rather than the exit 2 this module reserves
516
+ // for "could not judge". Every other malformed-manifest shape here already gets a named
517
+ // `IndexError`; this one did not. Found by Copilot on #117, round 6.
518
+ if (typeof rel !== "string" || !rel.trim()) {
519
+ throw new IndexError(
520
+ `pack \`${found.name}\` declares a persona entry that is not a string path — ${JSON.stringify(rel)}. ` +
521
+ "Refusing rather than resolving it — run `doctor` to validate the pack against the Pack Definition",
522
+ );
523
+ }
524
+ // Containment is checked on the CANONICAL path, not the lexical one. `path.resolve` is textual
525
+ // and `readFileSync` **follows symlinks**, so the lexical check proved a string was inside the
526
+ // pack and then read whatever the filesystem pointed at — a pack shipping `personas/x.md` as a
527
+ // link out of the tree got arbitrary files hashed into a committed index. Found by Copilot on
528
+ // #117 round 8, one round after the lexical traversal was closed: the same guard, a second
529
+ // door. `cli/doctor.mjs` already resolves containment this way, so this follows an established
530
+ // pattern rather than inventing one.
531
+ const file = path.resolve(found.dir, rel);
532
+ let real = file;
533
+ let realPackDir = found.dir;
534
+ try {
535
+ realPackDir = fs.realpathSync(found.dir);
536
+ real = fs.realpathSync(file);
537
+ } catch { /* absent or unresolvable — the lexical check below still applies, then the read reports it */ }
538
+ if (!isInside(found.dir, file) || !isInside(realPackDir, real)) {
539
+ throw new IndexError(
540
+ `pack \`${found.name}\` declares the persona ${JSON.stringify(rel)}, which resolves outside the pack ` +
541
+ "directory. A pack may only contribute files it ships — refusing to read it",
542
+ );
543
+ }
544
+ let source;
545
+ try {
546
+ source = fs.readFileSync(file, "utf8");
547
+ } catch (cause) {
548
+ throw new IndexError(
549
+ `pack \`${found.name}\` declares the persona ${rel}, which cannot be read — ${cause.code ?? cause.message}. ` +
550
+ "Refusing to render an index that would silently omit a declared persona",
551
+ );
552
+ }
553
+
554
+ const persona = path.posix.basename(rel.split(path.sep).join(path.posix.sep), ".md");
555
+ const scope = memoryScopeOf(source);
556
+ if (scope === null) {
557
+ broken.push({ pack: found.name, persona, file: rel });
558
+ continue;
559
+ }
560
+ // `origin` rides on the finding and never reaches `expected` below — see the note at
561
+ // `originOf`. Only a union can produce a non-null value here.
562
+ const origin = plan.source === "union" ? originOf.get(found.root) ?? null : null;
563
+ scopes.push({ persona, pack: found.name, location: `${posixSlot}/${persona}/`, scope, origin });
564
+ }
565
+ }
566
+
567
+ scopes.sort((a, b) => `${a.pack}/${a.persona}`.localeCompare(`${b.pack}/${b.persona}`));
568
+ return { scopes, unresolved, carrying, broken };
569
+ }
570
+
571
+ // ===========================================================================================
572
+ // Rendering
573
+ // ===========================================================================================
574
+ //
575
+ // One line per record: title, path, type. Everything on the line is derived, so nothing in the file
576
+ // can be edited into disagreement with the store — which is what "built, never hand-maintained"
577
+ // has to mean if it is to be checkable at all.
578
+ //
579
+ // `scope` is deliberately NOT on the line. It was measured before it was excluded: all 27 live
580
+ // records across both stores carry the same scope head, `workspace`. A field that never varies buys
581
+ // no recall and still costs the budget it is counted against, and ../core/operating/evolution.md
582
+ // asks every line of context to earn its place.
583
+
584
+ /** The index as text, for a workspace and the store `readStore` returned. */
585
+ export function render(workspace, store) {
586
+ const indexPath = workspace?.memory?.index?.path;
587
+ const lineBudget = workspace?.memory?.index?.budget?.lines;
588
+ const slot = workspace.slots.memory;
589
+
590
+ // Links resolve from the index's own directory, not the workspace root — so an index sited
591
+ // anywhere still links correctly, and `docs.sh`'s links check holds it to that. Everything here
592
+ // is posix: these strings end up in a Markdown file that GitHub renders, where a backslash is a
593
+ // character in a filename rather than a separator.
594
+ const posix = (p) => p.split(path.sep).join(path.posix.sep);
595
+ const from = path.posix.dirname(posix(indexPath));
596
+
597
+ const header = [
598
+ `# Memory index — ${workspace.name}`,
599
+ "",
600
+ `> Generated from \`${slot}\` by \`node cli/index.mjs\`. Do not edit by hand: it is regenerated`,
601
+ `> and byte-compared, so a hand-edit survives exactly until the next run.`,
602
+ `> ${store.records.length} record(s)` + (lineBudget ? ` · budget ${lineBudget} lines.` : "."),
603
+ "",
604
+ ];
605
+
606
+ const entries = store.records.map((r) => {
607
+ const href = path.posix.relative(from, path.posix.join(posix(slot), r.file));
608
+ return `- [${r.title}](${href}) — ${r.type}`;
609
+ });
610
+
611
+ return [...header, ...entries].join("\n") + "\n";
612
+ }
613
+
614
+ /**
615
+ * The handoff index as text.
616
+ *
617
+ * Two derived fields per line — the date off the filename, the title off the record's own H1 — and the
618
+ * link. The title carrier differs from the store's **because the evidence differs**: a memory record
619
+ * usually has no heading, so its filename is the only title it has; a handoff always has one, and its
620
+ * filename leads with a date, so `titleOf` would render `2026 07 28 the librarian goes on a cron`. A
621
+ * generator that picked one rule for both series would be choosing consistency over correctness in the
622
+ * one file whose whole claim is that it agrees with its source.
623
+ *
624
+ * The header states the absent budget rather than leaving its absence to be read as an oversight — the
625
+ * argument is in spec/slots.md and ../core/operating/memory.md, and this is the sentence a reader of
626
+ * the artifact gets.
627
+ *
628
+ * **The H1 is printed as written, and the redundancy that produces is deliberate.** Every handoff here
629
+ * opens `# Handoff — …`, so every line of the index repeats the word. Stripping that prefix was the
630
+ * obvious tidy-up and is refused: it is a rule about the series' *content*, which this generator does
631
+ * not own, and it would render two handoffs differently for a difference that really exists between
632
+ * them — a generated file quietly normalising its source is the failure this whole file is built to
633
+ * make impossible. If the repetition is worth removing, the repair is to reword the headings, which
634
+ * changes the fact rather than the report of it.
635
+ */
636
+ export function renderHandoffIndex(workspace, series) {
637
+ const indexPath = workspace?.handoffs?.index?.path;
638
+ const slot = workspace.slots.handoffs;
639
+
640
+ const posix = (p) => p.split(path.sep).join(path.posix.sep);
641
+ const from = path.posix.dirname(posix(indexPath));
642
+
643
+ const header = [
644
+ `# Handoff index — ${workspace.name}`,
645
+ "",
646
+ `> Generated from \`${slot}\` by \`node cli/index.mjs\`. Do not edit by hand: it is regenerated`,
647
+ `> and byte-compared, so a hand-edit survives exactly until the next run.`,
648
+ `> ${series.records.length} handoff(s), newest first. No budget: the series is append-only, so`,
649
+ `> the only remedy a budget could ask for is one this project rules out.`,
650
+ "",
651
+ ];
652
+
653
+ const entries = series.records.map((r) => {
654
+ const href = path.posix.relative(from, path.posix.join(posix(slot), r.file));
655
+ return `- ${r.date} · [${r.heading}](${href})`;
656
+ });
657
+
658
+ return [...header, ...entries].join("\n") + "\n";
659
+ }
660
+
661
+ /**
662
+ * The per-persona scope index — the artifact that makes a landing checkable.
663
+ *
664
+ * Every field on a line is derived from the pack that declared the scope, so nothing in the file can
665
+ * be edited into disagreement with the cascade. That is the same property the other two indexes have,
666
+ * and here it carries more weight than in either: this file **is** the positive control the landing
667
+ * clause demands (../docs/milestones/m06.md). A location without a line here is a directory somebody
668
+ * made; a line whose digest no longer matches the persona is a scope that moved without the adopter
669
+ * being told.
670
+ *
671
+ * The digest is over the whole normalized scope, and the sentence beside it is only the legible half.
672
+ * A first sentence alone would let a pack reword everything after it invisibly — which is the exact
673
+ * shape of `a-checkers-coverage-is-measured-not-named`.
674
+ *
675
+ * **What this file is not**, stated here because the alternative is a page describing machinery that
676
+ * does not exist (`.portulan/dod.md` condition 4): nothing reads these locations, nothing recalls from
677
+ * them, and nothing consolidates them. Row 6 declares the scope and shows it landing; `doctor` GAINED
678
+ * the persona-contract check at milestone 7 session 2 (`row 6 declares, row 7 validates`, the
679
+ * maintainer's ruling of 2026-07-29 — ../core/operating/memory.md), so the *validates* half is no
680
+ * longer a future arrival. What it checks is the PRESENCE of the five parts, including a `## Memory
681
+ * scope` section — never what is in these locations, which stay unread by anything. So this remains a
682
+ * declared scope with an empty location, and it says so on its own face rather than in a session
683
+ * record nobody re-reads.
684
+ */
685
+ export function renderScopeIndex(workspace, series) {
686
+ const header = [
687
+ `# Persona memory scopes — ${workspace.name}`,
688
+ "",
689
+ "> Generated from the packs this workspace composes by `node cli/index.mjs`. Do not edit by hand:",
690
+ "> it is regenerated and byte-compared, so a hand-edit survives exactly until the next run.",
691
+ // Every emphasis span stays on ONE line. Markdown strong emphasis cannot cross a newline, so a
692
+ // `**…**` split across two rendered lines prints its asterisks literally — which the committed
693
+ // index did, until Copilot pointed at it. A generated artifact that RENDERS wrong is the same class
694
+ // as one that says something wrong, and `cli/index.test.mjs` now asserts balanced `**` per line.
695
+ `> ${series.scopes.length} declared scope(s). **Owned and populated only by this workspace.**`,
696
+ "> Each location is empty until earned — the pack declares the scope and carries none of its",
697
+ "> contents. Nothing reads these locations: `doctor` checks a persona against its five-part",
698
+ "> contract as of milestone 7, which is not a check of what is in them.",
699
+ "",
700
+ ];
701
+
702
+ // The location is NAMED, never LINKED, and the reason is the same property the whole design rests on:
703
+ // the location is empty, git does not carry an empty directory, and so it does not exist in a fresh
704
+ // clone. A markdown link asserts a resolvable target; this path is a *declaration* that may
705
+ // legitimately not exist yet. The first version linked it, `docs.sh`'s `links` check passed on the
706
+ // author's disk — where the directory had just been created — and failed in CI on a clean checkout.
707
+ // That is a **local false green in a generated file**, which regenerating would faithfully reproduce.
708
+ // Exempting the filename from the link walk was the other repair available and is refused for the
709
+ // reason this repository always refuses it: an exemption is a door every other record can use.
710
+ //
711
+ // Since 2026-07-30 `links` resolves against `git ls-files --cached` (#121), so linking the location
712
+ // would now be a consistent red rather than a local green. That changes nothing here, and the
713
+ // distinction is worth keeping straight: the check improved, the reason did not. Naming the location
714
+ // is right because the path is a declaration, and it would still be right in a workspace `vendor`
715
+ // shipped to a host that runs no recipes at all.
716
+ const entries = series.scopes.map((s) =>
717
+ `- \`${s.persona}\` · ${s.pack} · \`${s.location}\` · scope \`${scopeDigest(s.scope)}\` — ${firstSentence(s.scope)}`,
718
+ );
719
+
720
+ return [...header, ...entries].join("\n") + "\n";
721
+ }
722
+
723
+ /** The number of lines in a rendered index — the unit the `lines` budget is denominated in. */
724
+ const lineCount = (text) => text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
725
+
726
+ // ===========================================================================================
727
+ // The verdict
728
+ // ===========================================================================================
729
+
730
+ /**
731
+ * Judge one workspace's index.
732
+ *
733
+ * Returns `{ dir, declared, path, expected, findings }`. A finding is `{ severity, check, message }`
734
+ * with severity `fail` (exit 1); `check` is one of `title`, `index`, `budget`. Throws `IndexError`
735
+ * (exit 2) for anything that is not a verdict about a store.
736
+ *
737
+ * The three checks are kept apart on purpose. *The index is stale* is repaired by running the
738
+ * generator; *the index is over budget* is repaired by consolidating the store; *a record's H1
739
+ * disagrees with its filename* is repaired by editing the record. A single "index check failed"
740
+ * would send an author to regenerate a file that is already correct and still too big.
741
+ */
742
+ export function inspect(dir, { write = false, packRoots: extraRoots, discoverPacks = false } = {}) {
743
+ const manifestPath = path.join(dir, "workspace.json");
744
+ let workspace;
745
+ try {
746
+ workspace = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
747
+ } catch (cause) {
748
+ throw new IndexError(`cannot read ${manifestPath} — ${cause.message}`);
749
+ }
750
+
751
+ const spec = workspace?.portulan?.spec;
752
+ if (!KNOWN_SPECS.has(String(spec))) {
753
+ throw new IndexError(
754
+ `${dir} declares Workspace Definition ${JSON.stringify(spec)}, which this tool does not implement ` +
755
+ `(knows: ${[...KNOWN_SPECS].join(", ")}). Refusing rather than indexing a manifest it may misread`,
756
+ );
757
+ }
758
+
759
+ const findings = [];
760
+ const fail = (series, check, message) => findings.push({ severity: "fail", series, check, message });
761
+
762
+ const memory = judgeMemory(dir, workspace, { write, fail });
763
+ const handoffs = judgeHandoffs(dir, workspace, { write, fail });
764
+ const scopes = judgeScopes(dir, workspace, { write, fail, packRoots: extraRoots, discoverPacks });
765
+
766
+ return {
767
+ dir,
768
+ declared: memory.declared || handoffs.declared || scopes.declared,
769
+ series: { memory, handoffs, scopes },
770
+ findings,
771
+ };
772
+ }
773
+
774
+ /**
775
+ * Refuse an index sited inside the series it indexes, and return its resolved path.
776
+ *
777
+ * One rule, one implementation, two series — and it is refused rather than special-cased for the same
778
+ * reason in both. In the store, `doctor` counts every `.md` as a record, so an index living there is
779
+ * counted, sized into the KB figure, and reported for stating no retirement condition. In the handoff
780
+ * series, `docs.sh`'s correspondence check counts every dated `.md` as a handoff and fails every
781
+ * undated one, so an index there is either an extra handoff or a red. The alternative repair —
782
+ * excluding the index's filename from each walk — is a door any record could use, and this repository
783
+ * had found eight fail-opens of that shape in its own scaffolding before it stopped taking that door.
784
+ */
785
+ function siteOutside(dir, declaredPath, slot, word) {
786
+ const indexPath = path.resolve(dir, declaredPath);
787
+ if (slot && isInside(path.resolve(dir, slot), indexPath)) {
788
+ throw new IndexError(
789
+ `${declaredPath} sits inside the ${word} it indexes (${slot}) — a walk over that directory ` +
790
+ `would count the index as one of its members. Site the index beside the ${word}, not in it`,
791
+ );
792
+ }
793
+ return indexPath;
794
+ }
795
+
796
+ /**
797
+ * Write (when asked) and byte-compare one generated index.
798
+ *
799
+ * Shared, because the ways this goes wrong are the same ways for every series and each has already
800
+ * been paid for once. **Never written in check mode** — a verify recipe that repairs what it is
801
+ * checking always passes, the property ../.portulan/verify/compile.sh was built around. **A failed
802
+ * write is an `IndexError`, not a red** — an uncaught ENOENT here exits node with 1, which `index.sh`
803
+ * passes through as "the index has drifted", said about a series nothing had judged, for what is a
804
+ * configuration problem (`a-checker-must-refuse-what-it-cannot-check`; found by Copilot on #72).
805
+ *
806
+ * **And a failed READ is the same fact, which this function did not say for five review rounds.**
807
+ * Only `ENOENT` means the index is absent; `EACCES`, `EISDIR`, `ELOOP` and the rest mean the process
808
+ * could not look. Reported as *declared and absent* they came back as actionable drift at exit 1,
809
+ * telling the operator to regenerate a file that may be correct and merely unopenable — *could not
810
+ * look* dressed as *I looked and found nothing*, which is the record above in as many words. Issue
811
+ * #91; Copilot raised it verbatim on #85 in rounds three through seven, each triaged under
812
+ * `a-review-loop-needs-a-bound.md` rule 4, which is that rule working rather than failing: a triaged
813
+ * note is not a withdrawn one. The rule it restores is stated three times in `./vendor.mjs` and again
814
+ * in `./doctor.mjs`, `./new.mjs`, `./init.mjs`, `./plugin-lint.mjs` and `./compile.mjs` — this was the
815
+ * one read in the repository that did not carry it.
816
+ */
817
+ function compareOrWrite({ dir, declaredPath, indexPath, expected, write, series, source, fail }) {
818
+ if (write) {
819
+ try {
820
+ fs.mkdirSync(path.dirname(indexPath), { recursive: true });
821
+ fs.writeFileSync(indexPath, expected);
822
+ } catch (cause) {
823
+ throw new IndexError(
824
+ `cannot write the index at ${declaredPath} — ${cause.code ?? cause.message}. ` +
825
+ `Refusing rather than reporting a verdict about the ${source}: this is a fact about the filesystem, ` +
826
+ `not about the ${source}`,
827
+ );
828
+ }
829
+ }
830
+
831
+ let actual = null;
832
+ try {
833
+ actual = fs.readFileSync(indexPath);
834
+ } catch (cause) {
835
+ // Only ENOENT is the red below. Everything else is a fact about the filesystem, and the two
836
+ // repairs have nothing in common: one is `run the generator`, the other is `fix the permissions
837
+ // on this path`. The errno is named because it is the whole diagnosis — `declared and absent`
838
+ // sent an operator to regenerate a file that was sitting right there.
839
+ if (cause.code !== "ENOENT") {
840
+ throw new IndexError(
841
+ `cannot read the index at ${declaredPath} — ${cause.code ?? cause.message}. ` +
842
+ `Refusing rather than reporting it absent: this is a fact about the filesystem, ` +
843
+ `not about the ${source}`,
844
+ );
845
+ }
846
+ }
847
+
848
+ if (actual === null) {
849
+ fail(series, "index", `${declaredPath} is declared and absent — run \`node cli/index.mjs ${dir}\` to generate it`);
850
+ } else if (!actual.equals(Buffer.from(expected, "utf8"))) {
851
+ fail(series, "index", `${declaredPath} is out of date against the ${source} — run \`node cli/index.mjs ${dir}\` to regenerate it`);
852
+ }
853
+ }
854
+
855
+ /** The memory store's index and its budgets. */
856
+ function judgeMemory(dir, workspace, { write, fail }) {
857
+ const memory = workspace.memory;
858
+ if (!memory) return { declared: false, path: null, expected: null, budgets: 0 };
859
+
860
+ const declaredPath = memory.index?.path;
861
+ if (!declaredPath) {
862
+ // A budget with no index is coherent — a workspace may rail its store's size and generate
863
+ // nothing — so this is not an error. There is simply no file to render.
864
+ const store = readStore(dir, workspace);
865
+ return { declared: true, path: null, expected: null, budgets: budgetFindings(memory, store, null, fail) };
866
+ }
867
+
868
+ const indexPath = siteOutside(dir, declaredPath, workspace.slots?.memory, "store");
869
+ const store = readStore(dir, workspace);
870
+
871
+ // Titles first, and alone. A disagreement means there is no correct line to emit for that
872
+ // record, so rendering anyway would produce a generated file the generator knows is wrong.
873
+ let broken = 0;
874
+ for (const r of store.records) {
875
+ if (r.heading && normalize(r.heading) !== normalize(r.title)) {
876
+ broken += 1;
877
+ fail(
878
+ "memory",
879
+ "title",
880
+ `${path.join(workspace.slots.memory, r.file)} carries the heading "${r.heading}", which is not its filename's title ` +
881
+ `"${r.title}". A record may hold two carriers of its name; it may not hold two answers — ` +
882
+ "rename the file or reword the heading",
883
+ );
884
+ }
885
+ }
886
+ // Scoped to this series, not to the whole run. A broken record title says nothing about whether
887
+ // the handoff index is current, and returning early on the shared list would have made one
888
+ // series' defect silence the other's verdict — a green by omission, which is the shape this file
889
+ // exists to refuse.
890
+ if (broken) return { declared: true, path: indexPath, expected: null, budgets: 0 };
891
+
892
+ const expected = render(workspace, store);
893
+ compareOrWrite({ dir, declaredPath, indexPath, expected, write, series: "memory", source: "store", fail });
894
+ const budgets = budgetFindings(memory, store, expected, fail);
895
+
896
+ return { declared: true, path: indexPath, expected, budgets };
897
+ }
898
+
899
+ /**
900
+ * The handoff series' index. No budget — see spec/slots.md: every remedy a budget could ask for on an
901
+ * append-only series is barred, so a rail here is one built to be broken.
902
+ */
903
+ function judgeHandoffs(dir, workspace, { write, fail }) {
904
+ const declaredPath = workspace.handoffs?.index?.path;
905
+ if (!declaredPath) return { declared: false, path: null, expected: null };
906
+
907
+ const indexPath = siteOutside(dir, declaredPath, workspace.slots?.handoffs, "series");
908
+ const series = readHandoffs(dir, workspace);
909
+
910
+ // Two derived fields, two ways to be underivable, two repairs — so two checks. `date` is repaired
911
+ // by renaming the file; `title` by editing it. One "handoff malformed" would send an author to
912
+ // the wrong one half the time.
913
+ let broken = 0;
914
+ for (const r of series.records) {
915
+ const where = path.join(workspace.slots.handoffs, r.file);
916
+ if (r.date === null) {
917
+ broken += 1;
918
+ fail(
919
+ "handoffs",
920
+ "date",
921
+ `${where} does not lead with a valid YYYY-MM-DD date, so the index has no date to put on its line. ` +
922
+ "core/operating/loop.md fixes the form as `YYYY-MM-DD-{slug}.md` — rename the file",
923
+ );
924
+ }
925
+ if (r.heading === null) {
926
+ broken += 1;
927
+ fail(
928
+ "handoffs",
929
+ "title",
930
+ `${where} carries no \`# \` heading on its first non-blank line, so the index has no title to put on its line. ` +
931
+ "A handoff's title is its H1 — the filename leads with a date and would render as one. Give it a heading",
932
+ );
933
+ }
934
+ }
935
+ if (broken) return { declared: true, path: indexPath, expected: null };
936
+
937
+ const expected = renderHandoffIndex(workspace, series);
938
+ compareOrWrite({ dir, declaredPath, indexPath, expected, write, series: "handoffs", source: "series", fail });
939
+
940
+ return { declared: true, path: indexPath, expected };
941
+ }
942
+
943
+ /**
944
+ * The persona scopes series: its index, the landing itself, and the two controls the clause turns on.
945
+ *
946
+ * **Write mode creates the locations; check mode never does.** A verify recipe that materialised what
947
+ * it is checking always passes — the property `../.portulan/verify/compile.sh` was built around, and
948
+ * the reason `--check` is the flag the recipe passes and `--write` is the one it does not.
949
+ *
950
+ * The locations are created **empty**, and empty is not a state git records — so a fresh clone has the
951
+ * index and no directories, which is correct and is why the index is the carrier of the declaration
952
+ * rather than the directory. An absent location is therefore never a finding; what *is* a finding is a
953
+ * location under this layer that no composed persona declares (the negative control), because without
954
+ * that sweep the layer would accept anything at all and the positive control would only ever examine the
955
+ * ones it already expected.
956
+ */
957
+ function judgeScopes(dir, workspace, { write, fail, packRoots: extraRoots, discoverPacks }) {
958
+ const declaredPath = workspace.personas?.index?.path;
959
+ if (!declaredPath) return { declared: false, path: null, expected: null };
960
+
961
+ const slot = workspace.slots?.personas;
962
+ const indexPath = siteOutside(dir, declaredPath, slot, "layer");
963
+ const series = readScopes(dir, workspace, { packRoots: extraRoots, discoverPacks });
964
+
965
+ // Four ways this goes wrong, four repairs, so four checks rather than one "scopes malformed".
966
+ for (const p of series.unresolved) {
967
+ fail(
968
+ "scopes",
969
+ "pack",
970
+ `\`${p.name}\` is declared in \`packs\` and does not resolve — ${p.why}. ` +
971
+ "A scope cannot land from a pack that is not there; resolve the pack or stop declaring it",
972
+ );
973
+ }
974
+ for (const b of series.broken) {
975
+ fail(
976
+ "scopes",
977
+ "scope",
978
+ `pack \`${b.pack}\` contributes the persona \`${b.persona}\` (${b.file}), which carries no \`## Memory scope\` ` +
979
+ "section — the fourth part of the five-part persona contract (core/personas/README.md). " +
980
+ "There is nothing to land, and this generator will not invent a scope for it",
981
+ );
982
+ }
983
+ for (const c of series.carrying) {
984
+ fail(
985
+ "scopes",
986
+ "contents",
987
+ `pack \`${c.pack}\` carries ${c.file}, which is a memory record. A pack declares a scope and carries ` +
988
+ "**none** of its contents: the store belongs to the adopter's layer (docs/vision.md thesis 6 — " +
989
+ "storage follows ownership). Delete it from the pack",
990
+ );
991
+ }
992
+
993
+ if (series.unresolved.length || series.broken.length || series.carrying.length) {
994
+ return { declared: true, path: indexPath, expected: null };
995
+ }
996
+
997
+ // The negative control. Only over directories that exist — an absent layer means nothing has landed
998
+ // yet, which is the state of every fresh clone.
999
+ if (slot) {
1000
+ const layer = path.resolve(dir, slot);
1001
+ const declared = new Set(series.scopes.map((s) => s.persona));
1002
+ let present = null;
1003
+ try {
1004
+ present = fs.readdirSync(layer, { withFileTypes: true });
1005
+ } catch (cause) {
1006
+ // ENOENT is the ordinary state; anything else is a filesystem fact, not a verdict.
1007
+ if (cause.code !== "ENOENT") {
1008
+ throw new IndexError(
1009
+ `cannot read the persona layer at ${slot} — ${cause.code ?? cause.message}. ` +
1010
+ "Refusing to report that no location is orphaned when the layer could not be enumerated",
1011
+ );
1012
+ }
1013
+ }
1014
+ // **Empty means readable-and-zero, never could-not-look.** A declared location that cannot be
1015
+ // enumerated is refused with exit 2, because the feature's own success state IS an
1016
+ // empty directory — so the enumeration fail-open this repository has fixed four times would here
1017
+ // read as the design working rather than as a broken read. Absent is a third, different fact and
1018
+ // stays green: git carries no empty directory, so absent is the state of every fresh clone.
1019
+ //
1020
+ // **Absent is decided by the read itself, not by a prior `existsSync`** — the sibling of #91,
1021
+ // in the paragraph that states the rule. `fs.existsSync` is a `stat` that answers `false` for
1022
+ // every failure, `EACCES` included, so a layer mode `0400` — readable, not searchable, which
1023
+ // the enumeration above passes — made every declared location under it stat-false, skipped as
1024
+ // absent, and the refusal three lines down unreachable. The guard whose comment promises
1025
+ // *never could-not-look* was the one thing looking, and it could not. Removing it also removes
1026
+ // a TOCTOU: one syscall now decides, instead of two that can disagree.
1027
+ //
1028
+ // **The message no longer says the location EXISTS**, and that word had to go with the guard
1029
+ // rather than after it. It was true while `existsSync` gated the read — a `stat` had succeeded
1030
+ // — and this change is what made it reachable for an `EACCES` raised by an unsearchable
1031
+ // ancestor, where existence is precisely what cannot be known. A refusal asserting the one
1032
+ // thing it could not establish is the defect this pull request is about, wearing the fix's own
1033
+ // sentence. Copilot, round 1 on #166.
1034
+ for (const s of series.scopes) {
1035
+ const location = path.resolve(dir, s.location);
1036
+ try {
1037
+ fs.readdirSync(location);
1038
+ } catch (cause) {
1039
+ if (cause.code === "ENOENT") continue;
1040
+ throw new IndexError(
1041
+ `the persona memory location ${s.location} cannot be read — ${cause.code ?? cause.message}. ` +
1042
+ "Refusing to report it empty: empty is this feature's success state, so an unreadable " +
1043
+ "location reported as empty would read as the design working",
1044
+ );
1045
+ }
1046
+ }
1047
+
1048
+ for (const e of present ?? []) {
1049
+ if (e.isDirectory() && declared.has(e.name)) continue;
1050
+ // Directories AND files. The first draft tested `isDirectory()` and skipped everything else,
1051
+ // so a `.md` dropped straight into the layer passed in silence — found by the pre-commit
1052
+ // checkpoint, and the same shape as `a-checkers-coverage-is-measured-not-named`: a sweep
1053
+ // whose description says "anything undeclared" while its code says "any directory".
1054
+ const where = path.posix.join(slot.replace(/\/$/, ""), e.name) + (e.isDirectory() ? "/" : "");
1055
+ fail(
1056
+ "scopes",
1057
+ "orphan",
1058
+ `${where} is under the persona layer and no composed pack declares it. ` +
1059
+ "This layer holds one directory per declared scope and nothing else — what is not declared is " +
1060
+ "something somebody made, which is exactly what this sweep exists to tell apart from a scope " +
1061
+ "that arrived. Remove it, or compose the pack that declares it",
1062
+ );
1063
+ }
1064
+ }
1065
+
1066
+ const expected = renderScopeIndex(workspace, series);
1067
+ compareOrWrite({ dir, declaredPath, indexPath, expected, write, series: "scopes", source: "packs this workspace composes", fail });
1068
+
1069
+ // The landing itself, and it happens after the index is written rather than before: if the render
1070
+ // could not be derived there is nothing to land, and a directory created for a scope no index
1071
+ // records is the orphan the sweep above exists to catch.
1072
+ if (write) {
1073
+ for (const s of series.scopes) {
1074
+ const location = path.resolve(dir, s.location);
1075
+ try {
1076
+ fs.mkdirSync(location, { recursive: true });
1077
+ } catch (cause) {
1078
+ throw new IndexError(
1079
+ `cannot create the persona memory location at ${s.location} — ${cause.code ?? cause.message}. ` +
1080
+ "Refusing rather than reporting a verdict about a landing that did not happen",
1081
+ );
1082
+ }
1083
+ }
1084
+ }
1085
+
1086
+ return { declared: true, path: indexPath, expected };
1087
+ }
1088
+
1089
+ // A declared budget must be a positive integer. Without this, `lines: 0` — or `lines: -1`, or
1090
+ // `lines: "60"` — reads as falsy and turns the rail OFF, silently, in a key whose whole purpose is
1091
+ // to be on. The schema cannot catch it: the declared keyword subset has no `minimum`, so `type:
1092
+ // "number"` is the strongest constraint expressible there (spec/README.md). Exit 2 rather than a
1093
+ // red, because it is a defect in the policy rather than a verdict about the store.
1094
+ const positive = (v) => typeof v === "number" && Number.isInteger(v) && v > 0;
1095
+
1096
+ function budgetNumber(value, where) {
1097
+ if (value === undefined) return undefined;
1098
+ if (!positive(value)) {
1099
+ throw new IndexError(
1100
+ `${where} is ${JSON.stringify(value)}, which is not a positive integer. A budget that is zero, ` +
1101
+ "negative or non-numeric would read as undeclared and switch the rail off in the key that exists to switch it on",
1102
+ );
1103
+ }
1104
+ return value;
1105
+ }
1106
+
1107
+ /**
1108
+ * The four budget checks, each skipped when the workspace declares no number for it.
1109
+ *
1110
+ * **Returns how many were actually JUDGED**, which is the only thing that licenses the run summary's
1111
+ * `within budget` clause. Budgets are optional in the schema and none is defaulted (see the header),
1112
+ * so a workspace may declare none at all — and the summary said `within budget` over it anyway, which
1113
+ * reads as a verified constraint where the truth is that there was nothing to check. Issue #92, raised
1114
+ * by Copilot on #85 round six and triaged there under `a-review-loop-needs-a-bound.md` rule 4.
1115
+ *
1116
+ * The count is what this function DID, never what the manifest declares, and the two can differ:
1117
+ * `lines` and `columns` are measured against the rendered index, so a manifest declaring them under
1118
+ * no `index.path` has neither judged. The schema makes `path` required inside `index`, so that shape
1119
+ * is one `doctor` rejects — but this tool does not validate against the schema and the two have no
1120
+ * ordering (see `readScopes`, where the same reasoning already refuses to resolve an unvalidated
1121
+ * path). Counting the declaration instead would have put the false green back in the one shape a
1122
+ * caller reaches without `doctor`, which is the class `0020` names: a fix arriving without its sibling.
1123
+ */
1124
+ function budgetFindings(memory, store, expected, fail) {
1125
+ const lines = budgetNumber(memory.index?.budget?.lines, "memory.index.budget.lines");
1126
+ const columns = budgetNumber(memory.index?.budget?.columns, "memory.index.budget.columns");
1127
+ const kilobytes = budgetNumber(memory.store?.budget?.kilobytes, "memory.store.budget.kilobytes");
1128
+ const recordKilobytes = budgetNumber(memory.store?.budget?.record_kilobytes, "memory.store.budget.record_kilobytes");
1129
+
1130
+ let judged = 0;
1131
+
1132
+ if (expected !== null && lines) {
1133
+ judged += 1;
1134
+ const count = lineCount(expected);
1135
+ if (count > lines) {
1136
+ fail(
1137
+ "memory",
1138
+ "budget",
1139
+ `the index is ${count} lines against a budget of ${lines} — over by ${count - lines}. ` +
1140
+ "Consolidate the store: on THIS axis the moves are MERGE two records that are one fact, or RETIRE " +
1141
+ "one whose condition has fired. Compressing a record does not remove its line, and SPLITTING one " +
1142
+ "adds a line — it spends this budget to buy room under `memory.store.budget.record_kilobytes`. Raising the budget in " +
1143
+ "the change that broke it is the one repair core/operating/memory.md rules out",
1144
+ );
1145
+ }
1146
+ }
1147
+
1148
+ if (expected !== null && columns) {
1149
+ judged += 1;
1150
+ for (const line of expected.split("\n")) {
1151
+ if (line.length > columns) {
1152
+ fail(
1153
+ "memory",
1154
+ "budget",
1155
+ `an index line is ${line.length} columns against a cap of ${columns}: ${JSON.stringify(line.slice(0, 60) + "…")}. ` +
1156
+ "Shorten the record's filename — the cap exists so one long line cannot absorb what the line budget counts",
1157
+ );
1158
+ break;
1159
+ }
1160
+ }
1161
+ }
1162
+
1163
+ if (kilobytes) {
1164
+ judged += 1;
1165
+ const kb = store.bytes / KB;
1166
+ if (kb > kilobytes) {
1167
+ // The exact byte count rides along with the rounded figure, because rounding alone can
1168
+ // print a sentence that contradicts its own verdict: 1025 bytes is `1.0 KB` against a
1169
+ // budget of `1 KB`, which reads as within budget in the message announcing that it is
1170
+ // not. A red whose sentence argues against it is worse than no message. Found by Copilot
1171
+ // on #72. (`doctor`'s retirement note rounds the same way and is left alone: it states a
1172
+ // size and compares it with nothing, so there is no verdict for the rounding to
1173
+ // contradict.)
1174
+ fail(
1175
+ "memory",
1176
+ "budget",
1177
+ `the store is ${kb.toFixed(1)} KB (${store.bytes} bytes) against a budget of ${kilobytes} KB (${kilobytes * KB} bytes). ` +
1178
+ "This is the axis the index cannot see: record count can hold still while the store grows",
1179
+ );
1180
+ }
1181
+ }
1182
+
1183
+ // The per-record cap, added at Workspace Definition 2.8. This is to `kilobytes` what `columns` is
1184
+ // to `lines`: an aggregate cannot see inside its units, so one enormous record absorbs what a store
1185
+ // budget counts exactly as one enormous line absorbs what a line budget counts.
1186
+ //
1187
+ // **Every over-budget record is reported, not just the first.** `columns` breaks after one because
1188
+ // the repair there is the same trivial rename every time and the second instance teaches nothing.
1189
+ // Here each breach is a separate editorial decision — split this record, compress that one, demote
1190
+ // the other's narrative — so stopping at the first would send the author round the loop once per
1191
+ // record, each time believing they were done. The count of budgets JUDGED is still one: `judged`
1192
+ // counts checks this function ran, never findings it raised (see the doc comment).
1193
+ if (recordKilobytes) {
1194
+ judged += 1;
1195
+ const cap = recordKilobytes * KB;
1196
+ for (const record of store.records) {
1197
+ if (record.bytes > cap) {
1198
+ fail(
1199
+ "memory",
1200
+ "budget",
1201
+ `the record ${record.file} is ${(record.bytes / KB).toFixed(1)} KB (${record.bytes} bytes) against a per-record cap of ` +
1202
+ `${recordKilobytes} KB (${cap} bytes) — over by ${record.bytes - cap}. ` +
1203
+ "Repair it where it is: SPLIT it if it holds more than one fact (which spends `memory.index.budget.lines`, " +
1204
+ "the axis with the headroom), COMPRESS it, or DEMOTE its narrative to the provenance layer. " +
1205
+ "Raising the cap in the change that broke it is the one repair core/operating/memory.md rules out",
1206
+ );
1207
+ }
1208
+ }
1209
+ }
1210
+
1211
+ return judged;
1212
+ }
1213
+
1214
+ // ===========================================================================================
1215
+ // The command
1216
+ // ===========================================================================================
1217
+
1218
+ /**
1219
+ * The help screen — see `./doctor.mjs`'s for the contract and why these three gained one late.
1220
+ * `../.portulan/dod.md` condition 4 binds it: every flag below exists in the parser above.
1221
+ */
1222
+ function usage() {
1223
+ return [
1224
+ "portulan index — regenerate the memory, handoff and scope indexes",
1225
+ "",
1226
+ " portulan index [--check] [--pack-root <dir>|auto]... <workspace-dir> [<workspace-dir> ...]",
1227
+ "",
1228
+ " --check write nothing; exit 1 if any index is out of date against its store",
1229
+ " --pack-root where declared packs are resolved from; `auto` discovers the host's plugin cache.",
1230
+ " A named root REPLACES every other source. A directory actually named `auto` is `./auto`",
1231
+ "",
1232
+ "What is WRITTEN never records which root answered: an index whose bytes carried that would",
1233
+ "regenerate differently on two machines, and `--check` byte-compares.",
1234
+ "",
1235
+ "Exit codes: 0 succeeded · 1 a red verdict · 2 could not run.",
1236
+ ].join("\n");
1237
+ }
1238
+
1239
+ export function run(argv, say = console.log) {
1240
+ // **Before every other argument decision**, so asking for help cannot be outranked by a
1241
+ // complaint about the rest of the command line. `./portulan.mjs` states the contract: an
1242
+ // explicit `--help` exits 0, because asking for help is a request and it succeeded.
1243
+ if (argv.includes("--help") || argv.includes("-h")) {
1244
+ say(usage());
1245
+ return 0;
1246
+ }
1247
+ let check = false;
1248
+ const dirs = [];
1249
+ // Named pack roots, which REPLACE a workspace's own derived root rather than being searched ahead
1250
+ // of it. `resolvePack` was root-parameterized at session 0 so an installed-from-a-feed pack would
1251
+ // travel the same code path as one shipping beside the workspace — but nothing set those roots, so
1252
+ // the parameter was reachable only from a test. Replacement rather than precedence on purpose: a
1253
+ // demonstration that "the pack resolved from the feed" must not be satisfiable by a copy sitting in
1254
+ // the local tree at all. The three tools that take this flag disagreed about it once, and
1255
+ // ./compile.mjs's `namedRootsOption` carries what that cost.
1256
+ // Discovery of a host's plugin cache is built for both halves as of milestone 7 (#123): a POINTER's
1257
+ // `governed_by` through ./discover.mjs, and a PACK root through `--pack-root auto`.
1258
+ //
1259
+ // **Discovery is the default as of 2026-08-13** — this said *"asking is the whole trigger"*, and row
1260
+ // 7's clause is that `--pack-root` is *optional where discovery finds a root*. `auto` now insists
1261
+ // rather than unlocks. The row's direction is unchanged and is the arm above: **add a root where none
1262
+ // was named, never replace one that was.**
1263
+ //
1264
+ // **What this tool WRITES is unaffected, and that is stated in `readScopes` rather than here**: an
1265
+ // index whose bytes recorded which root answered would regenerate differently on two machines and
1266
+ // `index --check` byte-compares, so origin lives in the finding and never in the artifact. The
1267
+ // required `index` recipe also pins its root (`../.portulan/verify/index.sh`), so its verdict is
1268
+ // about the tree whatever this default is.
1269
+ const roots = [];
1270
+ let discoverPacks = false;
1271
+ for (let i = 0; i < argv.length; i += 1) {
1272
+ if (argv[i] === "--check") check = true;
1273
+ else if (argv[i] === "--pack-root") {
1274
+ const dir = argv[i + 1];
1275
+ i += 1;
1276
+ // Single leading `-` refused as well, matching `doctor` — see `compile.mjs` for why.
1277
+ if (dir === undefined || dir.startsWith("-")) {
1278
+ say(" ✗ --pack-root needs a directory, or `auto` to discover one from the host plugin cache. A directory actually named `auto` is `./auto`");
1279
+ return 2;
1280
+ }
1281
+ // Raw-argument match, before `path.resolve` — `./auto` still names a directory.
1282
+ if (dir === AUTO) discoverPacks = true;
1283
+ else roots.push(path.resolve(dir));
1284
+ } else if (argv[i].startsWith("-")) {
1285
+ // Loud, where it used to be ENOENT noise. Before this, an unrecognised flag fell into
1286
+ // `dirs` and was resolved as a workspace path — `index --help` reported `cannot read
1287
+ // --help/workspace.json`, which reads as a broken workspace rather than a bad argument.
1288
+ // Both real invocations are named for #155's reason.
1289
+ say(` ✗ unknown argument \`${argv[i]}\` — run \`portulan index --help\` or \`node cli/index.mjs --help\` for the flags this tool takes`);
1290
+ return 2;
1291
+ } else dirs.push(argv[i]);
1292
+ }
1293
+
1294
+ if (dirs.length === 0) {
1295
+ say("usage: node cli/index.mjs [--check] [--pack-root <dir>|auto]... <workspace-dir> [<workspace-dir> ...]");
1296
+ return 2;
1297
+ }
1298
+
1299
+ // Refused at PARSE time like the other four tools, and NOT left to the plan built inside
1300
+ // `judgeScopes`: that plan is only reached by a workspace declaring a scope index, so a workspace
1301
+ // without one took both flags, silently kept one, and reported `ok`. Measured on this branch —
1302
+ // four of the five carriers refused and this was the fifth, which is the whole class this change
1303
+ // is about, found in the change that fixes it.
1304
+ const bothAsked = namedWithAuto(roots, discoverPacks);
1305
+ if (bothAsked) {
1306
+ say(` ✗ ${bothAsked}`);
1307
+ return 2;
1308
+ }
1309
+
1310
+ for (const root of roots) {
1311
+ // A root that is not there is a configuration fact, not a pack that failed to resolve. Reporting
1312
+ // it as the latter would send an author to look at the one file that is not at fault — the same
1313
+ // distinction ../.portulan/memory/verify-preconditions-fail-closed.md was written for.
1314
+ // Existence is not enough: `existsSync` is true for a FILE, and the flag's own usage says it needs
1315
+ // a directory — so a file was accepted and every later resolution failure was misattributed to the
1316
+ // packs rather than to the argument. A stat that throws fails closed for the same reason. Found by
1317
+ // Copilot on #117, round 6.
1318
+ let stat = null;
1319
+ try {
1320
+ stat = fs.statSync(root);
1321
+ } catch (cause) {
1322
+ say(` ✗ --pack-root ${root} cannot be read — ${cause.code ?? cause.message}. Refusing to report a pack unresolvable against a root nothing looked in`);
1323
+ return 2;
1324
+ }
1325
+ if (!stat.isDirectory()) {
1326
+ say(` ✗ --pack-root ${root} is not a directory — a resolution root is a directory packs are looked up under`);
1327
+ return 2;
1328
+ }
1329
+ }
1330
+
1331
+ let worst = 0;
1332
+ for (const dir of dirs) {
1333
+ let result;
1334
+ try {
1335
+ result = inspect(dir, { write: !check, packRoots: roots.length ? roots : undefined, discoverPacks });
1336
+ } catch (error) {
1337
+ if (!(error instanceof IndexError)) throw error;
1338
+ say(` ✗ ${dir}: ${error.message}`);
1339
+ // Exit 2 wins over a red from another workspace: a run that could not judge one of its
1340
+ // targets has not produced the verdict the caller asked for, and reporting 1 would claim
1341
+ // it did. Every workspace is still judged first, so one broken manifest does not hide
1342
+ // the others' findings.
1343
+ worst = 2;
1344
+ continue;
1345
+ }
1346
+
1347
+ if (!result.declared) {
1348
+ say(` · ${dir}: declares no index`);
1349
+ continue;
1350
+ }
1351
+ for (const f of result.findings) say(` ✗ ${dir}: ${f.message}`);
1352
+ if (result.findings.length && worst < 1) worst = 1;
1353
+ if (!result.findings.length) {
1354
+ // Each series is named separately, and a series that generates nothing says so. A
1355
+ // workspace may declare budgets and no index — rail the store's size, generate nothing —
1356
+ // and saying "index current" there is a green about a file that does not exist, which is
1357
+ // the one sentence a tool whose subject is generated artifacts must not print. Found by
1358
+ // Copilot on #72, in the suppressed half of the round; the same trap has two doors now
1359
+ // that there are two series, and a workspace declaring one index would otherwise get a
1360
+ // sentence that reads as covering both. **Three doors since 2.6**, and the first draft of
1361
+ // the scopes series walked through the new one: `index` printed a green naming the store
1362
+ // and the handoffs and silently omitted the scope index it had just written.
1363
+ const state = check ? "current" : "written";
1364
+ const parts = [];
1365
+ if (result.series.memory.declared) {
1366
+ // **`within budget` only where a budget was judged.** All three are optional and none
1367
+ // is defaulted, so a workspace may declare an index and no budget at all — and this
1368
+ // sentence claimed a constraint had held when nothing had been measured, which is the
1369
+ // same over-claim as the `index written` the clause beside it was already repaired for,
1370
+ // one clause over. Issue #92; Copilot, #85 round six, in the suppressed channel.
1371
+ // `budgets` counts what ran rather than what was declared — see `budgetFindings`.
1372
+ const memory = result.series.memory;
1373
+ if (memory.path === null) {
1374
+ parts.push(memory.budgets ? "no store index declared; store within budget" : "no store index declared");
1375
+ } else {
1376
+ parts.push(memory.budgets ? `store index ${state}, within budget` : `store index ${state}`);
1377
+ }
1378
+ }
1379
+ if (result.series.handoffs.declared) parts.push(`handoff index ${state}`);
1380
+ if (result.series.scopes.declared) parts.push(`scope index ${state}`);
1381
+ say(` ok ${dir}: ${parts.join("; ")}`);
1382
+ }
1383
+ }
1384
+
1385
+ return worst;
1386
+ }
1387
+
1388
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
1389
+ process.exit(run(process.argv.slice(2)));
1390
+ }