@sleepy_panda_srl/portulan 0.1.3 → 0.2.0

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