@sleepy_panda_srl/portulan 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +7 -6
  2. package/cli/README.md +145 -358
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1719 -1699
  6. package/cli/context.mjs +883 -0
  7. package/cli/control-chars.mjs +12 -301
  8. package/cli/discover.mjs +17 -574
  9. package/cli/doctor.mjs +269 -1080
  10. package/cli/drills.mjs +92 -504
  11. package/cli/feedback.mjs +16 -237
  12. package/cli/finish.mjs +437 -0
  13. package/cli/form.mjs +644 -0
  14. package/cli/fuzz-shell.mjs +20 -305
  15. package/cli/gate.mjs +7 -199
  16. package/cli/goldens.mjs +26 -184
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +243 -566
  19. package/cli/inside.mjs +3 -28
  20. package/cli/instructions.mjs +829 -0
  21. package/cli/ledger.mjs +882 -0
  22. package/cli/librarian.mjs +135 -599
  23. package/cli/manifest.mjs +2 -39
  24. package/cli/mutants.mjs +9 -170
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +5 -74
  27. package/cli/pack-version.mjs +13 -255
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +6 -125
  30. package/cli/recipe-set.mjs +15 -245
  31. package/cli/release-eval.mjs +28 -348
  32. package/cli/review-meter.mjs +29 -265
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +10 -176
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +83 -514
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +82 -505
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +8 -86
  43. package/core/README.md +1 -1
  44. package/core/engine.md +3 -9
  45. package/core/operating/autonomy.md +1 -1
  46. package/core/operating/context.md +258 -0
  47. package/core/operating/evolution.md +3 -3
  48. package/core/operating/loop.md +13 -20
  49. package/core/operating/memory.md +12 -3
  50. package/core/operating/sessions.md +100 -0
  51. package/core/personas/README.md +33 -0
  52. package/core/personas/librarian.md +2 -2
  53. package/core/skills/clarify/SKILL.md +1 -1
  54. package/core/skills/consolidate/SKILL.md +5 -3
  55. package/core/templates/handoff.md +28 -15
  56. package/core/templates/memory-entry.md +1 -0
  57. package/evals/README.md +271 -845
  58. package/evals/releases/0.2.0.json +146 -0
  59. package/evals/releases/0.2.0.md +67 -0
  60. package/evals/releases/0.3.0.json +150 -0
  61. package/evals/releases/0.3.0.md +68 -0
  62. package/evals/releases/README.md +3 -3
  63. package/package.json +5 -2
  64. package/packs/.claude-plugin/plugin.json +2 -1
  65. package/packs/README.md +7 -4
  66. package/packs/rituals/checkpoints/README.md +1 -1
  67. package/packs/tools/README.md +8 -8
  68. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  69. package/packs/tools/local-llm/README.md +136 -0
  70. package/packs/tools/local-llm/pack.json +32 -0
  71. package/packs/tools/local-llm/personas/local-worker.md +44 -0
  72. package/packs/tools/local-llm/runner/local-llm.mjs +1665 -0
  73. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +64 -0
  74. package/packs/tools/local-llm/verify/README.md +18 -0
  75. package/spec/README.md +113 -6
  76. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  77. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  78. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  79. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  80. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  81. package/spec/migrations/0006-boot-card.mjs +77 -0
  82. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  83. package/spec/migrations/0008-card-reading.mjs +55 -0
  84. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  85. package/spec/migrations/0010-comment-rail.mjs +57 -0
  86. package/spec/migrations/README.md +45 -10
  87. package/spec/pack.schema.json +1 -1
  88. package/spec/slots.md +343 -27
  89. package/spec/workspace.schema.json +145 -4
package/cli/librarian.mjs CHANGED
@@ -1,64 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  // `librarian` — the scheduled pass over the curated layer: reindex, staleness, nags, demotion drafts.
3
3
  //
4
- // node cli/librarian.mjs [--as-of YYYY-MM-DD] [--write] [--log <path>] <workspace-dir> [...]
4
+ // node cli/librarian.mjs [--as-of YYYY-MM-DD] [--since YYYY-MM-DD] [--write] [--report <path>] [--reviews <path>] <workspace-dir> [...]
5
5
  //
6
- // Exit 0 the pass ran and recorded what it found · 2 it could not run. **There is no 1**, and that is
7
- // the design rather than an omission: `doctor`, `index` and `compile` are checkers, where 1 means red
8
- // and a red blocks. This is not a checker. A stale record is not a broken build, and a tool that
9
- // exited 1 on one would turn every nag into a failing job until somebody switched the job off — which
10
- // is what ../.portulan/verify/README.md says happens to checks that cry wolf.
6
+ // `--write` regenerates a drifted index, the pass's only write to the tree; `--report` writes the report as
7
+ // Markdown, never inside a workspace named or a tree one declares.
11
8
  //
12
- // ## What this is, and the one thing it can do that `doctor` cannot
9
+ // Exit 0 the pass ran and recorded what it found · 2 it could not run. No 1: a stale record is a nag, not a red.
13
10
  //
14
- // ../core/operating/memory.md gives the store four states and the fourth is **Retire**. `doctor`
15
- // reports the store's count and size and says, in its own output, why it stops there: "ages live in
16
- // git, which doctor does not read, so staleness is the librarian's (milestone 5)". That is this file.
17
- // Reading history is the affordance a scheduled job has and a verify recipe must not take — a check
18
- // that reads history is a false-red generator in a shallow CI checkout, so the rule is that the
19
- // *recipe* never asks and this *pass* always may.
11
+ // Everything it emits is a literal in this file or derived from the tree, never prose composed at run time:
12
+ // that is what makes its report safe to file unattended.
20
13
  //
21
- // The four passes are the ones ../docs/plan.md's milestone-5 row names:
22
- //
23
- // reindex regenerate the memory index, so a drift on `main` becomes a diff someone reviews
24
- // staleness every record's last-touched date from git, plus the sealed-stamp re-validation nag
25
- // proposals which rule changes are still waiting on the human gate, and for how long
26
- // demotions a draft per record the human might retire — evidence assembled, nothing decided
27
- //
28
- // ## It drafts. It decides nothing.
29
- //
30
- // ../docs/vision.md thesis 4: the librarian "retires rules whose incidents can no longer occur. It
31
- // cannot judge a sealed rule, so it nags the owner to re-validate instead." Read exactly: **it cannot
32
- // evaluate a retirement condition at all.** `Retire when: the generated client is deleted` is a
33
- // sentence about a world this process cannot see. So a demotion draft carries the condition verbatim,
34
- // the evidence a machine can gather (an age, and whether anything the condition names still exists),
35
- // and a recommendation addressed to a human — never a verdict. A draft that said *this has fired*
36
- // would be the tool exceeding its charter inside the artifact the maintainer trusts it with.
37
- //
38
- // ## Why it composes no new prose at run time, and what that buys
39
- //
40
- // Precisely: every string this pass emits is either a **literal in this file** — reviewed and
41
- // seam-scanned in the change that added it, like any other source — or **derived** from the tree: a
42
- // filename, a date git recorded, a count, a condition quoted from a record that passed the scan when
43
- // it landed. Nothing is composed about the work at run time. A rearrangement of already-scanned atoms
44
- // cannot carry a term the scan has not already seen, which is what makes the output safe to file
45
- // automatically.
46
- //
47
- // The claim is worded that way rather than as *authors no prose*, which a pre-commit checkpoint
48
- // pointed out is false on its face — the handoff below is full of sentences. The distinction between
49
- // *written by a reviewed change* and *written by an unattended run* is the one that matters, and it is
50
- // the difference between this and having the pass write a summary in its own words, which would put an
51
- // unreviewed sentence into a permanent history on a cron.
52
- //
53
- // That premise used to read "into a public repository". Corrected 2026-08-10 when the repository went
54
- // private, and true again since 2026-08-17: it was public 2026-07-27 to 2026-08-03, private after that,
55
- // and is public again. The hazard is unchanged and does not depend on the adjective — an unreviewed
56
- // sentence committed unattended is unrecallable either way, the visibility setting has now moved three
57
- // times, and clones taken in a public window cannot be recalled.
58
- //
59
- // Zero dependencies, no network, no install step — same constraints as ./doctor.mjs, ./compile.mjs and
60
- // ./index.mjs. It is **not** one of the six subcommands ../docs/vision.md names for the milestone-7
61
- // CLI; like ./plugin-lint.mjs it is a tool on no such list, and ./README.md says so.
14
+ // Observation procedure: two runs on an unchanged store with one `--as-of` give byte-identical reports; at
15
+ // 1-day thresholds all three nags fire; a shallow clone, a `0` threshold and a directory git never saw exit 2;
16
+ // an uncommitted record is reported undated and never stale.
62
17
 
63
18
  import { execFileSync } from "node:child_process";
64
19
  import fs from "node:fs";
@@ -68,9 +23,6 @@ import { pathToFileURL } from "node:url";
68
23
  import { inspect as inspectIndex, IndexError, recordType, dateOf, isInside } from "./index.mjs";
69
24
  import { parseProvenance } from "./doctor.mjs";
70
25
 
71
- /** Windows separators never reach a Markdown link: a backslash there is a filename character. */
72
- const posix = (p) => p.split(path.sep).join(path.posix.sep);
73
-
74
26
  /** Raised when the pass cannot run, or cannot report honestly. Always exit 2. */
75
27
  export class LibrarianError extends Error {
76
28
  constructor(message) {
@@ -79,62 +31,36 @@ export class LibrarianError extends Error {
79
31
  }
80
32
  }
81
33
 
82
- // The `librarian` object arrives at 2.4. Earlier manifests are read correctly — they simply declare
83
- // no pass, which is the shape every workspace had yesterday. Same reasoning and same refusal as
84
- // ./index.mjs's KNOWN_SPECS: a tool that reads a manifest it does not understand reports about a
85
- // workspace it may have misread.
86
- const KNOWN_SPECS = new Set(["2.0", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7", "2.8"]);
34
+ 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"]);
87
35
 
88
- // The store's own signpost, not a record — the one name ./index.mjs and ./doctor.mjs both exclude.
89
- // Three tools now share this judgement and none of them shares the code, which is issue #74; this
90
- // file is the third and says so rather than pretending it is the first.
91
36
  const NOT_A_RECORD = new Set(["README.md"]);
92
37
 
93
38
  const ISO = /^\d{4}-\d{2}-\d{2}$/;
94
39
 
95
- // ===========================================================================================
96
- // Dates
97
- // ===========================================================================================
40
+ // ---------------------------------------------------------------- Dates
98
41
 
99
- /** Parse `YYYY-MM-DD` strictly, or throw. `2026-13-45` is not a date this pass will guess about. */
100
42
  function parseDate(value, where) {
101
43
  if (typeof value !== "string" || !ISO.test(value)) {
102
44
  throw new LibrarianError(`${where} is ${JSON.stringify(value)}, which is not a YYYY-MM-DD date`);
103
45
  }
104
46
  const ms = Date.parse(`${value}T00:00:00Z`);
105
- // Round-tripping is what rejects a well-shaped impossibility: `Date.parse` accepts some of them
106
- // and rolls them forward, so `2026-02-31` would silently become March.
47
+ // Round-tripped: `Date.parse` rolls some impossible dates forward, `2026-02-31` into March.
107
48
  if (Number.isNaN(ms) || new Date(ms).toISOString().slice(0, 10) !== value) {
108
49
  throw new LibrarianError(`${where} is ${JSON.stringify(value)}, which is not a real date`);
109
50
  }
110
51
  return ms;
111
52
  }
112
53
 
113
- /**
114
- * Whole days from `from` to `to`. Negative when `from` is later — a record dated after the pass is a
115
- * clock problem or a fabricated date, and reading it as "0 days old" would hide exactly that.
116
- */
54
+ /** Whole days from `from` to `to`, negative when `from` is later: never clamped, so a future date shows. */
117
55
  export function daysBetween(from, to) {
118
56
  const a = parseDate(from, "the earlier date");
119
57
  const b = parseDate(to, "the later date");
120
58
  return Math.round((b - a) / 86_400_000);
121
59
  }
122
60
 
123
- // ===========================================================================================
124
- // Reading a record
125
- // ===========================================================================================
126
-
127
- /**
128
- * The sealed stamp on a **rule**, as `{ owner, date }` — or null when there is nobody to nag.
129
- *
130
- * Null covers three different situations on purpose: a linked rule (its incident is readable, so it
131
- * can be retired on evidence rather than by asking), a record that is not a rule (thesis 4 and every
132
- * mandate behind it are rule-scoped, and `doctor` binds provenance the same way), and prose
133
- * provenance (already a `doctor` failure — this pass does not fail a second time for one defect).
134
- *
135
- * A sealed stamp with no `date` is **refused**, not skipped. Skipping would drop the one record that
136
- * most needs the nag, silently, and silence is the failure this whole pass exists to end.
137
- */
61
+ // ---------------------------------------------------------------- Reading a record
62
+
63
+ /** A rule's sealed stamp as `{ owner, date }`, or null where there is nobody to nag. */
138
64
  export function sealedStamp(source) {
139
65
  if (recordType(source) !== "rule") return null;
140
66
  const { fields } = parseProvenance(source);
@@ -149,14 +75,6 @@ export function sealedStamp(source) {
149
75
  return { owner: fields.owner ?? "", date: fields.date };
150
76
  }
151
77
 
152
- /**
153
- * The record's retirement condition, verbatim and whitespace-joined — or null when it states none.
154
- *
155
- * Anchored to the bolded field at line start, exactly as `doctor`'s `RETIRE_WHEN` is, so prose that
156
- * merely discusses retiring never matches. The unit is the paragraph rather than the line, the way
157
- * `parseProvenance` reads a stamp: conditions wrap, and half a condition is worse than none because a
158
- * reader cannot tell it is half.
159
- */
160
78
  export function retireWhen(source) {
161
79
  const lines = source.split("\n");
162
80
  const start = lines.findIndex((l) => /^\s*\*\*retire when:\*\*/i.test(l));
@@ -171,21 +89,7 @@ export function retireWhen(source) {
171
89
  .trim();
172
90
  }
173
91
 
174
- /**
175
- * Is this proposal still waiting on the human gate?
176
- *
177
- * Deliberately generous toward *pending*, and the asymmetry is the point: this classification feeds a
178
- * nag, never a gate. Calling a settled proposal pending costs one line in a report a human skims;
179
- * calling a pending one settled drops it out of the only mechanism that would have chased it. So an
180
- * absent decision is pending — absence is not consent — a placeholder is pending, and the word
181
- * *pending* anywhere in the field wins over any verdict beside it.
182
- *
183
- * Both field spellings are read. The template says `**Decision.**`; two of this repository's fourteen
184
- * proposals record the outcome under `**Status.**` instead, which is a real shape in a real store and
185
- * not one to fail over. What a rail may demand — that the field exists at all — is `docs.sh`'s
186
- * `proposal` check; what it deliberately does not demand is which word appears in it, because a
187
- * grep that classified prose would red on a proposal whose only fault is a maintainer's phrasing.
188
- */
92
+ /** Whether a proposal still waits on the human gate: anything short of a stated verdict does, as this feeds a nag, never a gate. */
189
93
  export function proposalPending(source) {
190
94
  const lines = source.split("\n");
191
95
  const parts = [];
@@ -203,14 +107,7 @@ export function proposalPending(source) {
203
107
  return !/\b(accepted|rejected|revised|applied|withdrawn|superseded)\b/i.test(text);
204
108
  }
205
109
 
206
- // ===========================================================================================
207
- // Reading history
208
- // ===========================================================================================
209
- //
210
- // One `git log` per file. For twenty-odd records across two workspaces that is a few hundred
211
- // milliseconds on a job that runs weekly, and the obvious optimisation — one `git log --name-only`
212
- // walk over all of history — is slower here and harder to read. Noted so the next person does not
213
- // rediscover the trade as a defect.
110
+ // ---------------------------------------------------------------- Reading history
214
111
 
215
112
  function git(root, args, what) {
216
113
  try {
@@ -220,17 +117,9 @@ function git(root, args, what) {
220
117
  }
221
118
  }
222
119
 
223
- /**
224
- * The repository this workspace lives in, refusing anything that would make a date a guess.
225
- *
226
- * **Shallow is refused.** In a shallow clone `git log -1 -- <path>` returns nothing for a file whose
227
- * only commit was truncated away, so every record reads as undated — and a staleness pass over
228
- * undated records either flags everything or nothing, describing the checkout rather than the store.
229
- * `actions/checkout` is shallow by default, so this is not a theoretical clone; it is the *normal*
230
- * one, and the workflow that runs this pass sets `fetch-depth: 0` because of this paragraph.
231
- */
232
120
  function historyRoot(dir) {
233
121
  const root = git(dir, ["rev-parse", "--show-toplevel"], `find a git repository at ${dir}`);
122
+ // `actions/checkout` clones shallow by default: this is the normal clone, not a theoretical one.
234
123
  if (git(root, ["rev-parse", "--is-shallow-repository"], "test for a shallow repository") === "true") {
235
124
  throw new LibrarianError(
236
125
  "this is a shallow clone, where a file's history may be truncated away entirely — every record " +
@@ -241,44 +130,12 @@ function historyRoot(dir) {
241
130
  return root;
242
131
  }
243
132
 
244
- /**
245
- * When a tracked file was last **authored**, `YYYY-MM-DD`.
246
- *
247
- * Author date, not committer date, and the difference is load-bearing in this repository: every branch
248
- * is rebase-merged, which rewrites committer dates wholesale, so a committer-dated pass would report
249
- * that the entire store was touched on the day of the last rebase. Author date survives a rebase,
250
- * which is the property that makes it the honest answer to *when was this written*.
251
- *
252
- * Returns `null` for a path that is **not in `HEAD`** — untracked, or staged and not yet committed.
253
- * That is not a refusal and not a fail-open: such a file is *new*, and nothing in the history this
254
- * pass reads is older than a file's absence from that history, so age 0 is the precise answer. A path
255
- * that **is** in `HEAD` and still has no date is refused, because that is a fact about the checkout
256
- * rather than about the store. The reasoning for the split, and the two defects that produced it, are
257
- * at the call site below rather than repeated here.
258
- */
133
+ /** `relative`'s last author date, or null where HEAD lacks it, staged or not: a new file, age 0. */
259
134
  function lastTouched(root, relative) {
135
+ // The author date: a rebase rewrites committer dates and keeps author dates.
260
136
  const out = git(root, ["log", "-1", "--format=%as", "--", relative], `date ${relative}`);
261
137
  if (ISO.test(out)) return out;
262
138
 
263
- // No dated commit. Two very different situations arrive here and collapsing them is a defect
264
- // either way round, which this pass learned twice in one session by shipping the collapse and
265
- // then shipping the wrong seam between the halves.
266
- //
267
- // not in HEAD the committed tree does not contain this path. It is not undatable, it is NEW:
268
- // nothing in the history this pass reads is older than a file's absence from that
269
- // history, so age 0 is the precise answer rather than a guess. Reported as
270
- // `uncommitted`, so a reader is never told git dated something it did not.
271
- // in HEAD git has the path committed and still returned no date, which should be
272
- // unreachable now that a shallow clone is refused outright. Refused, loudly:
273
- // whatever produces it is a fact about the checkout, and this pass would be
274
- // reporting it as a fact about the store.
275
- //
276
- // **`HEAD`, not `git ls-files`** — and that is the second lesson, measured rather than reasoned.
277
- // The obvious test is whether git *tracks* the file, and it is wrong for one state in between:
278
- // a file that has been `git add`ed and not yet committed is tracked, has no commit, and is new.
279
- // Testing tracking refused exactly those, so a session that staged its work before running the
280
- // pass got the same red the first draft gave for uncommitted work — the same defect, one state
281
- // further in, found the same way: by running it rather than by reading it.
282
139
  try {
283
140
  git(root, ["cat-file", "-e", `HEAD:${relative}`], `look ${relative} up in HEAD`);
284
141
  } catch {
@@ -290,27 +147,8 @@ function lastTouched(root, relative) {
290
147
  );
291
148
  }
292
149
 
293
- // ===========================================================================================
294
- // The pass
295
- // ===========================================================================================
296
-
297
- /**
298
- * A declared threshold must be a positive integer — ./index.mjs's rule, for ./index.mjs's reason.
299
- *
300
- * **Also the memory budgets, as of 2.8.** They were fed to `budgetHeadroom` raw, so a **schema-legal**
301
- * `0` or `1.5` printed `Infinity%` or a plausible-looking percentage in the weekly report rather than
302
- * refusing — the subset has no `minimum` and cannot say `integer`, so both pass validation. A `"8"`
303
- * reaches the same place by a different route: the schema WOULD refuse it on `type: number`, and this
304
- * tool never validates against the schema, so an unvalidated manifest carries it straight through.
305
- * Meanwhile `./index.mjs` and `./doctor.mjs` both refused every one of them outright. A third
306
- * consumer reading the same key with a different answer is how two checkers start disagreeing about
307
- * one manifest, and the report is the one place nobody is watching when it runs. Raised by Copilot on
308
- * #215, suppressed half; the hole predates the per-record rail and is repaired for all three budgets
309
- * rather than only the key that surfaced it — `0020`'s rule, at the site the finding pointed to.
310
- *
311
- * `noun`/`off` keep each caller's sentence true: a staleness value switches a **nag** off, a budget
312
- * switches a **rail** off. Defaulted so the three existing messages stay byte-identical.
313
- */
150
+ // ---------------------------------------------------------------- The pass
151
+
314
152
  function threshold(value, where, noun = "threshold", off = "nag") {
315
153
  if (value === undefined) return undefined;
316
154
  if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
@@ -322,13 +160,6 @@ function threshold(value, where, noun = "threshold", off = "nag") {
322
160
  return value;
323
161
  }
324
162
 
325
- /**
326
- * One declared memory budget, refused unless it is a positive integer.
327
- *
328
- * The `where` it reports is the manifest path a reader would grep for — `memory.store.budget.kilobytes`,
329
- * not the two arguments this function took to build it. A refusal naming a key that does not appear in
330
- * the file is a refusal the author cannot act on.
331
- */
332
163
  const budget = (workspace, group, key) =>
333
164
  threshold(workspace.memory?.[group]?.budget?.[key], `memory.${group}.budget.${key}`, "budget", "rail");
334
165
 
@@ -342,22 +173,10 @@ const listMarkdown = (dir) => {
342
173
  return names.filter((n) => n.endsWith(".md") && !NOT_A_RECORD.has(n)).sort();
343
174
  };
344
175
 
345
- /**
346
- * Run the pass over one workspace.
347
- *
348
- * `asOf` is the date every age is measured against, and it is a parameter rather than a call to the
349
- * clock so that a test asserting "90 days" does not start failing on a date nobody chose. The command
350
- * defaults it to today.
351
- *
352
- * **This function never writes.** It reads the tree and returns what it found; `run` does the writing,
353
- * and does it in an order this function cannot get wrong — see the reindex block below.
354
- *
355
- * `reviews` is the pull-request review corpus, or `undefined` for *not asked*. It is passed in rather
356
- * than fetched because this file makes no network call: the workflow fetches, and every line of the
357
- * reading is then exercised by the suite against a fixture instead of at 06:00 on a Monday.
358
- */
359
- export function passWorkspace(dir, { asOf, reviews } = {}) {
176
+ /** One workspace's pass, which never writes: `run` does, after it. */
177
+ export function passWorkspace(dir, { asOf, reviews, since } = {}) {
360
178
  parseDate(asOf, "--as-of");
179
+ if (since !== undefined) parseDate(since, "--since");
361
180
 
362
181
  const manifestPath = path.join(dir, "workspace.json");
363
182
  let workspace;
@@ -394,36 +213,12 @@ export function passWorkspace(dir, { asOf, reviews } = {}) {
394
213
  }
395
214
 
396
215
  const root = historyRoot(dir);
397
- // `realpathSync` on both sides, and it is not defensive programming. `git rev-parse
398
- // --show-toplevel` answers with the resolved path, while the directory this pass was handed may
399
- // reach the same place through a symlink — which is the ordinary case on macOS, where `/var` is a
400
- // link to `/private/var` and every temporary directory is under it. Without this the relative
401
- // path comes out as a stack of `..` segments and git refuses every file as *outside repository*:
402
- // a refusal that names the right rule for the wrong reason, on a store that is perfectly fine.
216
+ // `--show-toplevel` answers a resolved path, so this one is resolved too: on macOS every temporary directory is under the link `/var`.
403
217
  const rel = (p) => path.relative(root, fs.realpathSync(p)).split(path.sep).join("/");
404
218
 
405
- // ---- reindex: READ ONLY, always
406
- //
407
- // **This pass reads; the command writes, and only after the record is on disk.** The ordering is
408
- // load-bearing now that a second series is indexed: a pass is a session, so it ends by writing a
409
- // dated handoff INTO `slots.handoffs` — the very series the handoff index covers. An index
410
- // regenerated here would be stale the moment the record landed, and the pull request the pass
411
- // files would carry a handoff index that `index.sh` reds. The pass would have broken the one
412
- // demonstration it exists to produce, on its first real run, with nobody watching.
413
- //
414
- // Reading drift here and writing later also makes an earlier fix structural instead of careful.
415
- // `inspect` in write mode regenerates and *then* compares, so it never reports drift — it has
416
- // just removed it — and reading `drifted` off that result said "current" about an index the pass
417
- // had regenerated a line earlier, putting "no index drift" into a machine-written record of the
418
- // one thing the run had changed (Copilot, #81, suppressed half, three sites at once). With no
419
- // write on this path there is no ordering left to get wrong.
420
- // **Per series, and that is not tidiness.** `declared` here means *this series has an index file*
421
- // — `path !== null`, so a workspace declaring budgets and no index is correctly *none declared*.
422
- // The pair-wide flag was what both report sites branched on, and a workspace declaring only one of
423
- // the two indexes then had "current" printed about an index that does not exist: the exact
424
- // sentence `./index.mjs`'s `run` was fixed for on #72, reproduced in the record the pass files.
425
- // Raised on #85 round one in both channels at once, which is what a two-site defect looks like.
426
- let index = { declared: false, drifted: false, series: {}, findings: [] };
219
+ // ---- reindex, read only
220
+ // `inspect` in write mode regenerates before it compares, so it would never report drift.
221
+ let index = { declared: false, drifted: false, series: {}, findings: [], notes: [] };
427
222
  try {
428
223
  const result = inspectIndex(dir, { write: false });
429
224
  const of = (which) => ({
@@ -434,35 +229,23 @@ export function passWorkspace(dir, { asOf, reviews } = {}) {
434
229
  declared: result.declared,
435
230
  drifted: result.findings.some((f) => f.check === "index"),
436
231
  series: { memory: of("memory"), handoffs: of("handoffs") },
437
- // The store index as the store renders it *now* — not the bytes on disk. Consolidation's
438
- // headroom is a pressure signal, and reading the committed file gets the direction wrong
439
- // exactly when it matters: a store that just grew has a committed index one line short, so
440
- // pressure would be under-reported at the moment it rose. `null` when a title disagreement
441
- // stopped the render, which `index.sh` is red for anyway.
442
232
  expected: result.series.memory.expected,
443
233
  findings: result.findings.map((f) => f.message),
234
+ notes: result.notes.map((n) => n.message),
444
235
  };
445
236
  } catch (cause) {
446
237
  if (!(cause instanceof IndexError)) throw cause;
447
- // The index tool's refusals are this pass's refusals: it is the same store, and a pass that
448
- // reported staleness over a store `index` would not read has judged half a thing.
449
238
  throw new LibrarianError(`the reindex could not run — ${cause.message}`);
450
239
  }
451
240
 
452
241
  // ---- the store, dated
453
242
  const storeDir = path.resolve(dir, memorySlot);
454
- // `largest` is the widest record seen, for the per-record rail's distance report. Seeded at zero
455
- // bytes and no name so an EMPTY store reports honestly instead of throwing: a workspace with no
456
- // records yet is valid, and `budgetHeadroom` renders 0 of N as 0%.
457
243
  const counts = { records: 0, rules: 0, sealed: 0, bytes: 0, uncommitted: 0, largest: { file: null, bytes: 0 } };
458
244
  const records = [];
459
245
  const seals = [];
460
246
  const drafts = [];
461
- // Every curated-layer document's text, kept for the link scan mining runs below. The curated
462
- // layer is the memory store and the proposal series and nothing else: a handoff referenced from
463
- // another handoff is one session mentioning another, not a rule tracing its incident.
247
+ // The store and the proposals, never the handoffs: one handoff naming another traces no rule to its incident.
464
248
  const curated = [];
465
- // Records grouped by the incident they cite, for consolidation's step-2 question.
466
249
  const byIncident = new Map();
467
250
 
468
251
  for (const file of listMarkdown(storeDir)) {
@@ -478,8 +261,6 @@ export function passWorkspace(dir, { asOf, reviews } = {}) {
478
261
  if (incident) byIncident.set(incident, [...(byIncident.get(incident) ?? []), file]);
479
262
 
480
263
  const touched = lastTouched(root, rel(full));
481
- // An uncommitted record has no age and is never stale: it cannot have gone untouched for
482
- // longer than it has existed in the history this pass reads.
483
264
  const days = touched === null ? 0 : daysBetween(touched, asOf);
484
265
  const type = recordType(source) || "untyped";
485
266
  const condition = retireWhen(source);
@@ -487,10 +268,6 @@ export function passWorkspace(dir, { asOf, reviews } = {}) {
487
268
  counts.records += 1;
488
269
  const recordBytes = Buffer.byteLength(source);
489
270
  counts.bytes += recordBytes;
490
- // `file === null` is the seed, never a size comparison: a store whose records are all ZERO
491
- // bytes never satisfies `recordBytes > 0`, so a strict comparison alone left the seed standing
492
- // and the report said "no records yet" over a store that held one. An empty `.md` is reachable —
493
- // `doctor` reports it as a record with no provenance, and this pass counts it either way.
494
271
  if (counts.largest.file === null || recordBytes > counts.largest.bytes) {
495
272
  counts.largest = { file, bytes: recordBytes };
496
273
  }
@@ -530,7 +307,7 @@ export function passWorkspace(dir, { asOf, reviews } = {}) {
530
307
 
531
308
  const stale = thresholds.record_days === undefined ? [] : records.filter((r) => r.days >= thresholds.record_days);
532
309
 
533
- // ---- proposals. `null` is *not asked*, which is not the same answer as *none pending*.
310
+ // ---- proposals
534
311
  let proposals = null;
535
312
  if (workspace.slots?.proposals) {
536
313
  const proposalDir = path.resolve(dir, workspace.slots.proposals);
@@ -552,19 +329,9 @@ export function passWorkspace(dir, { asOf, reviews } = {}) {
552
329
  }
553
330
  }
554
331
 
555
- // The handoffs slot rides along so the command knows where a pass record belongs without
556
- // re-reading the manifest — and reads it from the slot rather than assuming the directory name,
557
- // which is the whole reason slots exist.
558
332
  const handoffsDir = workspace.slots?.handoffs ? path.resolve(dir, workspace.slots.handoffs) : null;
559
333
 
560
- // ---- the handoff series: aged and reported, never railed
561
- //
562
- // The milestone-5 row scopes staleness to this series as well as the store, and the same row bars
563
- // a budget on it — an append-only series has no consolidation to offer, so every remedy a budget
564
- // could ask for is barred. A staleness THRESHOLD carries that problem one layer down:
565
- // `record_days` exists to draft a demotion, and a demotion draft here recommends deleting the
566
- // record the series exists to keep, weekly, forever. So the ages are read and reported — count,
567
- // oldest, size — and no threshold reaches them.
334
+ // ---- the handoff series, aged and never railed
568
335
  const series = { declared: false, count: null, oldest: null, bytes: 0, files: [] };
569
336
  if (handoffsDir) {
570
337
  series.declared = true;
@@ -589,33 +356,17 @@ export function passWorkspace(dir, { asOf, reviews } = {}) {
589
356
  }
590
357
 
591
358
  const mining = {
592
- incidents: mineIncidents(series, curated),
359
+ incidents: mineIncidents(series, curated, { since }),
593
360
  reviews: mineReviews(reviews, { treeRoot: workspace.tree ? path.resolve(dir, workspace.tree) : null }),
594
361
  };
595
362
 
596
363
  const consolidation = {
597
- // Step 2 of core/skills/consolidate/SKILL.md, as a question rather than a verdict — see
598
- // `sharedIncidents`.
599
364
  shared: sharedIncidents(byIncident),
600
- // Step 5's rail, read as a distance rather than a verdict. `index.sh` answers over/under at
601
- // pull-request time; what it cannot say is *how close*, and a scheduled pass that reports
602
- // pressure is the difference between consolidating on a calendar and consolidating on a red.
603
- // Every budget goes through `threshold` first. Reading them raw let a schema-legal `0` print
604
- // `Infinity%` and a `1.5` print a plausible percentage — and a `"8"`, which the schema refuses
605
- // but this tool never asks the schema about, do the same — in a weekly artifact nobody is
606
- // watching when it runs, while the two tools that judge the same keys refused them outright.
607
365
  headroom: {
608
366
  store: budgetHeadroom(counts.bytes / 1024, budget(workspace, "store", "kilobytes")),
609
367
  index: budgetHeadroom(renderedLines(index.expected), budget(workspace, "index", "lines")),
610
- // The per-record rail of Workspace Definition 2.8, measured at the record CLOSEST to it —
611
- // the only record whose distance means anything, and the one a split would target. Without
612
- // this the pass reported `Store: no budget declared` over a store that is fully railed, just
613
- // railed per record: a weekly report going quietly silent about a live rail, which is the
614
- // failure ../.portulan/memory/a-mandate-nothing-checks-is-already-broken.md names.
615
368
  record: budgetHeadroom(counts.largest.bytes / 1024, budget(workspace, "store", "record_kilobytes")),
616
369
  },
617
- // Carried beside the figure so the report can say WHICH record is closest. A percentage with
618
- // no name sends a reader to sort the store by hand to find out what to repair.
619
370
  largest: counts.largest,
620
371
  };
621
372
 
@@ -631,69 +382,30 @@ export function passWorkspace(dir, { asOf, reviews } = {}) {
631
382
  drafts,
632
383
  proposals,
633
384
  counts,
634
- handoffsDir,
635
385
  handoffs: series,
636
386
  mining,
637
387
  consolidation,
638
388
  };
639
389
  }
640
390
 
641
- // ===========================================================================================
642
- // Mining — the half of the librarian that reads incidents rather than the store
643
- // ===========================================================================================
644
- //
645
- // `core/skills/codify/SKILL.md` is the on-demand form and the pass is the batch one: same ritual,
646
- // same output shape. What the pass does NOT do is author the proposal, and the reason is mechanical
647
- // as well as principled. `.portulan/verify/docs.sh`'s `proposal` check requires every proposal to
648
- // name the pull request that filed it; this pass writes its files BEFORE the pull request exists and
649
- // has no update path by design, so a generated proposal could never carry that pointer and would red
650
- // the librarian's own pull request on the recipe that shipped one session earlier. And a template
651
- // filled from derived fields is a stub with the argument missing — the part a human has to write is
652
- // exactly the part that makes it a proposal rather than a row in a report.
653
- //
654
- // So mining names CANDIDATES, in the shape the demotion drafts already established: a file, a fact
655
- // about it, and a fixed recommendation. The maintainer's ruling of 2026-07-29.
391
+ // ---------------------------------------------------------------- Mining
656
392
 
657
- /** The pass's own records, named by the constant `run` writes them under — not a guess at a shape. */
393
+ /** A pass record in the handoff series, named as older passes wrote it. */
658
394
  const PASS_RECORD = /-librarian-pass\.md$/;
659
395
 
660
- /**
661
- * Incidents the curated layer does not point back to.
662
- *
663
- * **The claim is the narrow one, and getting it right is the whole design.** This does not establish
664
- * that an incident taught no rule — measured on the real tree, one unlinked handoff had in fact
665
- * minted a rule whose provenance cited the *proposal* that session filed rather than the session. It
666
- * establishes that **nothing in the curated layer points back to this incident**, which is true in
667
- * that case too, and is itself the thing thesis 4 asks for: a rule links its incident so a later
668
- * reader can judge whether it still applies. Read the wider way it would be a query with a known
669
- * false positive; read this way it has none.
670
- *
671
- * **The ratio is the trend; the LIST is windowed.** 25 of this repository's 35 handoffs are unlinked,
672
- * and a pass that listed all 25 would print the same 25 lines every week over a series that only
673
- * grows — a nag nobody can finish, which is how a whole report gets skimmed. So the totals are always
674
- * stated and the candidates are those since the last pass. The window's anchor is derived, not
675
- * remembered: this pass keeps no state, and the newest pass record in the series *is* the record of
676
- * when it last ran. Before there is one, the window is the newest date in the series — the last
677
- * session's incidents, which is small, real, and does not pretend the backlog is not there.
678
- */
679
- export function mineIncidents(series, curated) {
396
+ /** Handoffs nothing in the curated layer points back to: every one counted, those since the last pass listed. */
397
+ export function mineIncidents(series, curated, { since: named } = {}) {
680
398
  if (!series.declared) return { declared: false, total: null, linked: 0, since: null, candidates: [] };
681
399
 
682
400
  const incidents = series.files.filter((f) => !PASS_RECORD.test(f.file));
683
401
  const passes = series.files.filter((f) => PASS_RECORD.test(f.file) && f.date);
684
- const since = passes.reduce((a, b) => (a === null || b.date > a ? b.date : a), null);
402
+ const since = named ?? passes.reduce((a, b) => (a === null || b.date > a ? b.date : a), null);
685
403
 
686
404
  const isLinked = (file) => curated.some((source) => source.includes(file));
687
405
  const linked = incidents.filter((i) => isLinked(i.file)).length;
688
406
 
689
407
  const newest = incidents.reduce((a, b) => (a === null || (b.date && b.date > a) ? b.date : a), null);
690
- // `>=`, and the boundary is worth a sentence because the strict version loses work permanently.
691
- // A pass runs in the morning and a session writes its handoff that afternoon; with `>` that
692
- // handoff is outside this window — the pass had not seen it — and outside every later one too,
693
- // because `since` only ever moves forward. It would survive nowhere but the unlinked ratio. The
694
- // cost of `>=` is the opposite and is bounded: an incident dated the same day as a pass can be
695
- // listed by that pass and by the next one, once. Dates are the granularity the series carries, so
696
- // one repeat is the price of never dropping a day, and that is the direction to err in.
408
+ // `>=`: a handoff written on a pass's own day, after it ran, is listed by the next pass rather than by none.
697
409
  const inWindow = since
698
410
  ? (i) => i.date !== null && i.date >= since
699
411
  : (i) => i.date !== null && i.date === newest;
@@ -711,34 +423,8 @@ export function mineIncidents(series, curated) {
711
423
  return { declared: true, total: incidents.length, linked, since, window: since ? "since the last pass" : "the newest date in the series", candidates };
712
424
  }
713
425
 
714
- /**
715
- * Paths pull-request reviewers keep leaving findings on.
716
- *
717
- * `codify/SKILL.md` triggers on "a review comment keeps reappearing across PRs — a pattern, not a
718
- * one-off", and **two distinct pull requests is what *recurring* means** rather than a number someone
719
- * chose. That is why there is no threshold in the manifest to declare: a tuning knob would be policy
720
- * and policy belongs there, but a definition is not a knob.
721
- *
722
- * **A finding is a comment that OPENS a thread.** Measured on this repository rather than assumed,
723
- * and the partition is exact: of 376 inline review comments, all 189 from the reviewer open threads
724
- * and all 187 replies — 162 from the agent identity, 25 from the maintainer — carry
725
- * `in_reply_to_id`. Filtering by login would have been the obvious spelling and is wrong here in the
726
- * worst direction, because the reviewer is itself a bot: excluding bots excludes the findings.
727
- * Counting replies would invert the signal outright — every reply is one more comment on a path we
728
- * were answering about, so the harder a finding was argued the more it would look like a place
729
- * reviewers keep finding things.
730
- *
731
- * **What this cannot see, stated because it is the larger half.** These are *inline* comments. The
732
- * low-confidence notes GitHub collapses into a review body carry no path and never appear here — and
733
- * on [#81](https://github.com/sleepy-panda-srl/portulan/pull/81) that channel produced nine of
734
- * eleven findings, eight of them real. So this mines the smaller channel, and the standing argument
735
- * for [#66](https://github.com/sleepy-panda-srl/portulan/issues/66) — promoting those notes into
736
- * real threads — is now also an argument about what a scheduled pass can measure at all.
737
- */
426
+ /** Paths still in the tree with findings on two or more pull requests, or null when not asked. */
738
427
  export function mineReviews(reviews, { treeRoot }) {
739
- // *Not asked* and *none found* must not print the same way — the rule the proposals pass already
740
- // follows. A workspace with no `tree` makes claims about no repository, so it has nowhere to
741
- // resolve a reviewed path against and its reviews are not a thing this pass can read.
742
428
  if (reviews === undefined || reviews === null || !treeRoot) return null;
743
429
  if (!Array.isArray(reviews)) {
744
430
  throw new LibrarianError(
@@ -747,15 +433,6 @@ export function mineReviews(reviews, { treeRoot }) {
747
433
  );
748
434
  }
749
435
 
750
- // **Every element must be comment-shaped, and an element that is not is a refusal.** The version
751
- // this was built against emits one flat array from `--paginate` — measured on gh 2.96.0 over four
752
- // pages: 385 objects, one array — but a shape surprise here fails in the worst possible direction.
753
- // Fed the array-of-pages `--slurp` produces, the earlier reading counted each inner array as a
754
- // finding (an array carries no `in_reply_to_id`) and then skipped it for having no `path`, and
755
- // reported *no path has drawn findings on two or more distinct pull requests* over a corpus it had
756
- // entirely misread. Refusing on shape closes that whatever any `gh` does, which is the version this
757
- // check should be written against. Raised by Copilot on #85 round two, whose stated mechanism does
758
- // not hold here and whose hazard does.
759
436
  for (const c of reviews) {
760
437
  if (!c || typeof c !== "object" || Array.isArray(c) || typeof c.pull_request_url !== "string") {
761
438
  throw new LibrarianError(
@@ -778,21 +455,7 @@ export function mineReviews(reviews, { treeRoot }) {
778
455
  pulls.get(c.path).add(pull);
779
456
  }
780
457
 
781
- // **A path the tree no longer holds is dropped.** Found by running this against the real corpus
782
- // rather than by reading it: `cli/mode.mjs` came back recurring on two pull requests, and that
783
- // file does not exist — the mode axis was ruled dead and both its pull requests closed unmerged.
784
- // Review history is append-only, so without this the pass nags weekly and forever about a file
785
- // nobody can open, and no action anyone takes will ever clear it. The count of what was dropped
786
- // rides along, because *some were ignored* and *there were none* must not print the same way.
787
- //
788
- // **Contained before it is probed**, and the containment is not belt-and-braces: a review comment's
789
- // `path` is external data, and `path.resolve` walks straight out of the tree on an absolute path or
790
- // a `../` chain. Measured on #85 round seven before it was closed — `/etc/hosts` came back as *still
791
- // in the tree*, because it resolves to itself and exists; the `../` spellings were dropped only by
792
- // the accident of their targets not existing on that machine. So the pass would have stat'd the
793
- // runner's filesystem on a corpus it does not author, unattended, weekly. `isInside` is this
794
- // repository's one implementation of the question, extracted after two copies of it drifted into the
795
- // identical fail-open — a third copy here would have been that mistake a third time.
458
+ // A reviewed path is external data: contained before it is probed, or an absolute or `../` path stats the runner's disk.
796
459
  const inTree = (p) => {
797
460
  const full = path.resolve(treeRoot, p);
798
461
  return isInside(treeRoot, full) && fs.existsSync(full);
@@ -805,29 +468,12 @@ export function mineReviews(reviews, { treeRoot }) {
805
468
  return { comments: reviews.length, findings: findings.length, replies, gone: recurring.length - paths.length, paths };
806
469
  }
807
470
 
808
- // ===========================================================================================
809
- // Consolidation — the mechanical half, and an honest account of the rest
810
- // ===========================================================================================
471
+ // ---------------------------------------------------------------- Consolidation
811
472
 
812
- /** A rule's provenance link, or null — the `href=` of a `form=link` stamp. */
813
473
  export function provenanceHref(source) {
814
474
  return source.match(/^\s*\*\*provenance:\*\*.*?href=([^\s`]+)/im)?.[1] ?? null;
815
475
  }
816
476
 
817
- /**
818
- * Records citing one incident, raised as a **question** and never as a merge.
819
- *
820
- * `core/skills/consolidate/SKILL.md` step 2 merges records that are one **mechanism**. Sharing an
821
- * incident is not that, and this repository is the counter-example: all three of its shared-incident
822
- * groups are deliberately distinct facts, because one incident teaches several mechanisms — the
823
- * enforcement compiler alone minted three. So the pass surfaces the group and states the question a
824
- * human answers; a pass that concluded *merge these* would be making the policy decision step 3
825
- * forbids it from making about contradictions, one step earlier.
826
- *
827
- * Expected standing yield on this repository: **three groups**, all already judged separate. That is
828
- * said out loud so a reader of the first real pass does not read three known answers as three open
829
- * ones.
830
- */
831
477
  export function sharedIncidents(byIncident) {
832
478
  return [...byIncident.entries()]
833
479
  .filter(([, files]) => files.length > 1)
@@ -841,84 +487,31 @@ export function sharedIncidents(byIncident) {
841
487
  .sort((a, b) => a.incident.localeCompare(b.incident));
842
488
  }
843
489
 
844
- /** How close a measured figure is to its budget — `null` when nothing declared one to be close to. */
845
490
  export function budgetHeadroom(actual, budget) {
846
491
  if (budget === undefined) return null;
847
492
  return { actual: Number(actual.toFixed(1)), budget, percent: Math.round((actual / budget) * 100) };
848
493
  }
849
494
 
850
- /**
851
- * Lines in the index a store renders **now** — the number the `lines` budget is denominated in.
852
- *
853
- * The rendered text, never the committed file. Reading the committed bytes was the first cut and got
854
- * the direction wrong exactly where a pressure signal must not: a store that has just grown carries a
855
- * committed index one line short, so the headroom would look *larger* the moment it got smaller. Two
856
- * of Copilot's rounds on #85 walked into this from opposite ends — one caught a comment describing the
857
- * trade backwards, the next caught that the trade should not have been taken at all.
858
- *
859
- * `null` in, `0` out, and it means *nothing to measure*: either the workspace declares no index, or a
860
- * title disagreement stopped the render — and that is `index`'s red on every pull request, not a
861
- * number for this report to invent.
862
- */
495
+ /** Lines in the index the store renders now: the committed one runs a line short just after the store grows. */
863
496
  const renderedLines = (expected) =>
864
497
  expected === null || expected === undefined ? 0 : expected.split("\n").length - (expected.endsWith("\n") ? 1 : 0);
865
498
 
866
- // ===========================================================================================
867
- // The record the pass writes
868
- // ===========================================================================================
869
- //
870
- // A pass is a session — the maintainer's ruling of 2026-07-28, taken on a category his 2026-07-25
871
- // cadence ruling predates. So it ends the way every session here ends: a dated handoff, and one
872
- // Session log entry pointing at it. That is why this file renders Markdown at all; the alternative
873
- // was a bespoke report format with a bespoke rail, and the repository already has rails for these two.
874
- //
875
- // Both renderings are held to the shape ../.portulan/verify/docs.sh's `record` check enumerates —
876
- // including the two traps a generated entry can walk into that a hand-written one rarely does: the
877
- // ten-line budget binding every entry dated after 2026-07-28, and issue #79, where an unindented
878
- // `- YYYY-` bullet inside an entry is read as the start of the next one.
499
+ // ---------------------------------------------------------------- The report
879
500
 
880
501
  const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
881
502
 
882
- /**
883
- * One series' index, in a sentence — and *none declared* is a state, never a synonym for *current*.
884
- *
885
- * One function rather than the expression written twice, because it was written twice and both copies
886
- * branched on the pair-wide flag. A workspace declaring only one of the two indexes then read
887
- * "current" about the other, which does not exist.
888
- *
889
- * **It reports drift, never a repair, and the tense is the whole of it.** This record is composed
890
- * before `run` regenerates anything — it has to be, since the record is itself a member of one of the
891
- * indexed series — and the regeneration can still fail. *Has been regenerated* is therefore a sentence
892
- * the artifact cannot know to be true at the moment it writes it, and in a local run or a partial
893
- * failure it is a committed record asserting work that did not happen. What is true when this is
894
- * written is that the index was out of date **when the pass arrived**, so that is what it says. Raised
895
- * by Copilot on #85 round two, in the suppressed channel, against three sites at once.
896
- */
503
+ /** Drift, never a repair: the report is composed before `run` regenerates an index, which can still fail. */
897
504
  const indexState = (s) => (s.declared ? (s.drifted ? "**was out of date when this pass arrived**" : "current") : "none declared");
898
505
 
899
- /** The pass's handoff: what it looked at, what it found, and the date all of that is true as of. */
900
- export function renderRecord(results, { asOf }) {
506
+ export function renderReport(results, { asOf }) {
901
507
  const out = [
902
- "# Handoff — the librarian's scheduled pass",
508
+ "# The librarian's scheduled pass",
903
509
  "",
904
- `**Date:** ${asOf} · **Scheduled librarian pass** · Filed by \`cli/librarian.mjs\` on a cron.`,
510
+ `**Date:** ${asOf} · Filed by \`cli/librarian.mjs\` on a cron.`,
905
511
  "",
906
512
  "**State.** Every figure below is **as of " + asOf + "**, read from git history rather than from the",
907
513
  "filesystem, and nothing here is a decision. This pass drafts; the maintainer disposes.",
908
514
  "",
909
- // Counts are read before this record is written, and the ordering is forced rather than
910
- // incidental: a pass IS a session, so it ends by writing a dated handoff INTO the very series
911
- // it just counted. Regenerating an index any earlier would leave it stale in the commit the
912
- // job pushes, `index.sh` would red the pull request, and a scheduled pass could never file
913
- // anything mergeable. So the series count here is always one lower than the index regenerated
914
- // in the same pull request, and on #86 a reviewer read that gap as an off-by-one — correctly
915
- // noticing it, wrongly diagnosing it, because nothing in the artifact said why. A number that
916
- // needs an explanation it does not carry is what gets read as a defect (issue #90).
917
- "**What the counts include.** They are as of **read time**, taken before this record existed, so",
918
- "every series figure below **excludes the handoff this pass is about to write**. Expect the",
919
- "regenerated index in the same pull request to show exactly one more: the two numbers describe",
920
- "the series this pass examined and the series as it now stands, and both are true.",
921
- "",
922
515
  ];
923
516
 
924
517
  for (const r of results) {
@@ -936,7 +529,8 @@ export function renderRecord(results, { asOf }) {
936
529
  : "") +
937
530
  ". Index: " +
938
531
  indexState(r.index.series.memory) +
939
- ".",
532
+ "." +
533
+ r.index.notes.map((n) => ` ${n}.`).join(""),
940
534
  "",
941
535
  );
942
536
 
@@ -1089,12 +683,6 @@ export function renderRecord(results, { asOf }) {
1089
683
  const c = r.consolidation;
1090
684
  const head = (label, h, unit) =>
1091
685
  h === null ? `${label}: no budget declared` : `${label}: ${h.actual} of ${h.budget} ${unit} (${h.percent}%)`;
1092
- // The per-record line names the record it measured. `head` alone would print a bare percentage,
1093
- // and the whole point of this rail is that a breach is LOCAL — a distance with no name would
1094
- // make the reader sort the store by hand to find out which file the number is about.
1095
- // An empty store declares the rail and has no record to name. Printing the seeded `null` as
1096
- // though it were a filename would be the report inventing a record — worse than the silence
1097
- // this line exists to end, and it is the fresh-adopter path: a scope is "empty until earned".
1098
686
  const widest =
1099
687
  c.headroom.record === null
1100
688
  ? head("Largest record", null)
@@ -1127,15 +715,13 @@ export function renderRecord(results, { asOf }) {
1127
715
  }
1128
716
 
1129
717
  out.push(
1130
- "**Open questions.** None raised by machinery. Every nag above is addressed to the maintainer,",
1131
- "and none of them is answered by re-running this pass.",
1132
- "",
1133
- "**Next action.** Read the nags; merge, close, or act. An unmerged pass is itself a nag.",
718
+ "**Next action.** Every nag above is addressed to the maintainer, and none is answered by",
719
+ "re-running this pass. Where the pass opened a pull request, merge or close it: an unmerged pass",
720
+ "is itself a nag.",
1134
721
  "",
1135
- "**Recoverability.** This pass writes this handoff, appends one Session log entry when it is",
1136
- "given a log to append to, and regenerates a memory index only when one had drifted. Nothing",
1137
- "outside the tree is touched. Closing the pull request unopened loses nothing — the next pass",
1138
- "reaches the same conclusions from the same store and says them again.",
722
+ "**Recoverability.** This pass writes nothing into the tree but an index that had drifted, and",
723
+ "this report where its scheduler asks. Closing its pull request unopened loses nothing: the next",
724
+ "pass reaches the same conclusions from the same store and says them again.",
1139
725
  "",
1140
726
  );
1141
727
  return out.join("\n");
@@ -1146,82 +732,14 @@ const oldest = (records) => {
1146
732
  return o ? `\`${o.file}\` at ${plural(o.days, "day")}` : "none — the store is empty";
1147
733
  };
1148
734
 
1149
- /**
1150
- * The Session log entry: a pointer, at most ten lines, carrying a seam attestation.
1151
- *
1152
- * **The attestation is earned, not asserted.** This pass cannot run the seam scan — the term list
1153
- * lives outside the repository, deliberately, and a machine on a cron has no access to it. It never
1154
- * claims one ran. What it states, and what is true, is that it composes no new prose at run time:
1155
- * every line it writes is a literal from this reviewed file, or a filename, a
1156
- * date git recorded, a count, or a condition quoted verbatim out of a record that passed the scan when
1157
- * it landed. A diff with no new prose cannot carry a new seam hit. The residual limit, stated rather
1158
- * than left to be found: if a term had already leaked into a filename or a record, this pass would
1159
- * quote it forward — but that leak would already be in the history, which is the thing the scan exists
1160
- * to prevent and not something this pass could cause.
1161
- */
1162
- export function renderLogEntry(results, { asOf, handoff }) {
1163
- const declared = results.filter((r) => r.declared);
1164
- const stale = declared.reduce((n, r) => n + r.stale.length, 0);
1165
- const seals = declared.reduce((n, r) => n + r.seals.filter((s) => s.due).length, 0);
1166
- const proposals = declared.reduce((n, r) => n + (r.proposals?.filter((p) => p.due).length ?? 0), 0);
1167
- const reindexed = declared.filter((r) => r.index.drifted).map((r) => r.name);
1168
- const incidents = declared.reduce((n, r) => n + r.mining.incidents.candidates.length, 0);
1169
- const paths = declared.reduce((n, r) => n + (r.mining.reviews?.paths.length ?? 0), 0);
1170
- const shared = declared.reduce((n, r) => n + r.consolidation.shared.length, 0);
1171
-
1172
- // Nine lines, against the ten `.portulan/verify/docs.sh`'s `record` check allows every entry dated
1173
- // after 2026-07-28. The margin is one line and it is deliberate: this entry grew by one line when
1174
- // mining and consolidation joined it, so the next thing added here has to displace something
1175
- // rather than append — which is the right pressure on a generated record that a rail is watching.
1176
- return (
1177
- [
1178
- `- ${asOf} · M5 (Memory lifecycle & librarian) · **Scheduled librarian pass**, filed by`,
1179
- " `cli/librarian.mjs` rather than by a person: " + `${plural(declared.length, "workspace")} passed,`,
1180
- ` ${stale} stale record(s), ${seals} sealed stamp(s) due for re-validation, ` +
1181
- `${proposals} proposal(s) nagged` +
1182
- // Drift found, not drift repaired: this entry is appended before `run` regenerates,
1183
- // and a log line is permanent. Same correction as `indexState`, same round.
1184
- (reindexed.length ? `, index drift found in ${reindexed.join(", ")}.` : ", no index drift."),
1185
- ` · Mined: ${incidents} incident(s) with nothing pointing back at them, ${paths} path(s) drawing`,
1186
- ` repeat review findings, ${shared} record group(s) citing one incident.`,
1187
- " · No supervisor checkpoint: a scheduled pass makes no decision for one to grade.",
1188
- " · Seam scan clean by construction — this pass composes no new prose at run time, so its",
1189
- " diff carries nothing the scan had not already passed.",
1190
- ` Handoff: [\`${asOf}\`](${handoff}).`,
1191
- ].join("\n") + "\n"
1192
- );
1193
- }
1194
-
1195
- // ===========================================================================================
1196
- // The command
1197
- // ===========================================================================================
735
+ // ---------------------------------------------------------------- The command
1198
736
 
1199
737
  const USAGE =
1200
- "usage: node cli/librarian.mjs [--as-of YYYY-MM-DD] [--write] [--log <path>] [--reviews <path>] <workspace-dir> [...]";
1201
-
1202
- /**
1203
- * Parse `argv` strictly, or throw `LibrarianError`.
1204
- *
1205
- * **Strictly**, because the permissive version fails silently in both directions and this tool runs
1206
- * unattended. An unknown flag was dropped, so `--wrtie` produced a run that reported everything it
1207
- * found and wrote nothing — a success message over work that did not happen. And a value-bearing flag
1208
- * with no value ate the next argument, so `--log .portulan` set the log path to a *workspace* and then
1209
- * passed over no workspaces at all: a green having examined nothing, which is the enumeration
1210
- * fail-open this repository has now found five of in its own scaffolding. Raised by Copilot on #81.
1211
- *
1212
- * The residual limit, stated rather than left to be found: `--log .portulan` is *not* detectable here.
1213
- * `.portulan` is a perfectly good value and any `--flag value` grammar consumes it. What catches that
1214
- * caller is the layer above — no workspaces left, so `run` prints the usage and exits 2 rather than
1215
- * reporting a green over an empty list. That check is now the only thing standing between the typo and
1216
- * a pass that examined nothing, which is why it is asserted in the suite rather than assumed.
1217
- *
1218
- * `cli/compile.mjs` parses explicitly for the same reason; this now matches it.
1219
- */
738
+ "usage: node cli/librarian.mjs [--as-of YYYY-MM-DD] [--since YYYY-MM-DD] [--write] [--report <path>] [--reviews <path>] <workspace-dir> [...]";
739
+
1220
740
  export function parseArgs(argv) {
1221
- const opts = { asOf: undefined, logPath: undefined, reviewsPath: undefined, write: false, dirs: [] };
741
+ const opts = { asOf: undefined, since: undefined, reportPath: undefined, reviewsPath: undefined, write: false, dirs: [] };
1222
742
  const value = (flag, next) => {
1223
- // A flag's value may not be another flag. Without this, `--log --write .portulan` sets the log
1224
- // path to `--write` and silently drops the mode the caller asked for.
1225
743
  if (next === undefined || next.startsWith("--")) {
1226
744
  throw new LibrarianError(`${flag} needs a value.\n${USAGE}`);
1227
745
  }
@@ -1234,8 +752,11 @@ export function parseArgs(argv) {
1234
752
  case "--as-of":
1235
753
  opts.asOf = value(arg, argv[(i += 1)]);
1236
754
  break;
1237
- case "--log":
1238
- opts.logPath = value(arg, argv[(i += 1)]);
755
+ case "--since":
756
+ opts.since = value(arg, argv[(i += 1)]);
757
+ break;
758
+ case "--report":
759
+ opts.reportPath = value(arg, argv[(i += 1)]);
1239
760
  break;
1240
761
  case "--reviews":
1241
762
  opts.reviewsPath = value(arg, argv[(i += 1)]);
@@ -1251,10 +772,49 @@ export function parseArgs(argv) {
1251
772
  return opts;
1252
773
  }
1253
774
 
775
+ /** Where a write to `p` lands, every link resolved, a dangling one too, which `realpathSync` alone refuses. */
776
+ function realish(p) {
777
+ const rest = [];
778
+ let at = p;
779
+ for (let hops = 0; ; ) {
780
+ try {
781
+ return path.join(fs.realpathSync(at), ...rest);
782
+ } catch {
783
+ // A link's target resolves against its real parent, as the kernel's does; past Linux's forty hops the write fails.
784
+ let target = null;
785
+ try {
786
+ if (fs.lstatSync(at).isSymbolicLink()) {
787
+ target = path.resolve(fs.realpathSync(path.dirname(at)), fs.readlinkSync(at));
788
+ }
789
+ } catch {
790
+ // Missing or unreadable: an ancestor resolves instead.
791
+ }
792
+ if (target !== null && hops++ < 40) {
793
+ at = target;
794
+ continue;
795
+ }
796
+ const up = path.dirname(at);
797
+ if (up === at) return path.join(at, ...rest);
798
+ rest.unshift(path.basename(at));
799
+ at = up;
800
+ }
801
+ }
802
+ }
803
+
804
+ /** The tree a workspace declares, read from its manifest, so the report stays out of it even where the pass failed. */
805
+ function declaredTree(dir) {
806
+ try {
807
+ const { tree } = JSON.parse(fs.readFileSync(path.join(dir, "workspace.json"), "utf8"));
808
+ return typeof tree === "string" && tree !== "" ? path.resolve(dir, tree) : null;
809
+ } catch {
810
+ return null;
811
+ }
812
+ }
813
+
1254
814
  export function run(argv, say = console.log) {
1255
- let asOfArg, logPath, reviewsPath, write, dirs;
815
+ let asOfArg, since, reportPath, reviewsPath, write, dirs;
1256
816
  try {
1257
- ({ asOf: asOfArg, logPath, reviewsPath, write, dirs } = parseArgs(argv));
817
+ ({ asOf: asOfArg, since, reportPath, reviewsPath, write, dirs } = parseArgs(argv));
1258
818
  } catch (error) {
1259
819
  if (!(error instanceof LibrarianError)) throw error;
1260
820
  say(` ✗ ${error.message}`);
@@ -1269,6 +829,7 @@ export function run(argv, say = console.log) {
1269
829
  const asOf = asOfArg ?? new Date().toISOString().slice(0, 10);
1270
830
  try {
1271
831
  parseDate(asOf, "--as-of");
832
+ if (since !== undefined) parseDate(since, "--since");
1272
833
  } catch (error) {
1273
834
  say(` ✗ ${error.message}`);
1274
835
  return 2;
@@ -1276,9 +837,6 @@ export function run(argv, say = console.log) {
1276
837
 
1277
838
  let reviews;
1278
839
  if (reviewsPath !== undefined) {
1279
- // Read here rather than inside the pass so a corpus that cannot be read is a refusal about a
1280
- // file, not a verdict about a workspace — and so `passWorkspace` keeps its property of
1281
- // touching nothing but the tree it was pointed at.
1282
840
  try {
1283
841
  reviews = JSON.parse(fs.readFileSync(reviewsPath, "utf8"));
1284
842
  } catch (cause) {
@@ -1291,7 +849,7 @@ export function run(argv, say = console.log) {
1291
849
  let worst = 0;
1292
850
  for (const dir of dirs) {
1293
851
  try {
1294
- const result = passWorkspace(dir, { asOf, reviews });
852
+ const result = passWorkspace(dir, { asOf, reviews, since });
1295
853
  results.push(result);
1296
854
  if (!result.declared) {
1297
855
  say(` · ${dir}: declares no librarian pass`);
@@ -1302,66 +860,44 @@ export function run(argv, say = console.log) {
1302
860
  `${result.seals.filter((s) => s.due).length} seal(s) due, ` +
1303
861
  `${result.proposals?.filter((p) => p.due).length ?? 0} proposal(s) nagged, ` +
1304
862
  `${result.mining.incidents.candidates.length} incident(s) to codify` +
1305
- // The regeneration is the last thing this command does, so at this point drift is
1306
- // all that is known. The line that says it was regenerated is printed below, after.
1307
863
  (result.index.drifted ? ", index drift found" : ""),
1308
864
  );
1309
865
  } catch (error) {
1310
866
  if (!(error instanceof LibrarianError)) throw error;
1311
867
  say(` ✗ ${dir}: ${error.message}`);
1312
- // Exit 2 wins, and every workspace is still passed first: a run that could not read one
1313
- // target has not done what it was asked, and must not hide what it did manage to find.
1314
868
  worst = 2;
1315
869
  }
1316
870
  }
1317
871
 
1318
- const filing = results.find((r) => r.declared && r.handoffsDir);
1319
- if (write && results.some((r) => r.declared) && !filing) {
1320
- // A pass is a session (the maintainer's ruling, 2026-07-28), and a session ends with a dated
1321
- // handoff. A workspace with nowhere to put one cannot end a session, so this is said out loud
1322
- // rather than being a silent no-op that looks like a clean run.
1323
- say(" ✗ no workspace declares a `slots.handoffs`, so this pass has nowhere to record itself");
1324
- worst = 2;
1325
- } else if (write && filing) {
1326
- const record = renderRecord(results, { asOf });
1327
- const name = `${asOf}-librarian-pass.md`;
1328
- const target = path.join(filing.handoffsDir, name);
1329
- try {
1330
- fs.mkdirSync(path.dirname(target), { recursive: true });
1331
- fs.writeFileSync(target, record);
1332
- say(` ok wrote ${target}`);
1333
- if (logPath) {
1334
- // Relative to the LOG's directory, derived from the two paths rather than spelled —
1335
- // the slot exists so a directory name is never assumed, and a hardcoded
1336
- // `../.portulan/handoffs/` sat three lines under the comment saying so. Getting it
1337
- // wrong dangles a link in the file every session reads to boot, and `docs.sh` would
1338
- // catch it only after the pass had already filed.
1339
- const href = path.posix
1340
- .relative(path.posix.dirname(posix(path.resolve(logPath))), posix(target))
1341
- .split(path.sep)
1342
- .join(path.posix.sep);
1343
- const entry = renderLogEntry(results, { asOf, handoff: href });
1344
- const current = fs.readFileSync(logPath, "utf8");
1345
- fs.writeFileSync(logPath, current.replace(/\n*$/, "\n\n") + entry);
1346
- say(` ok appended a Session log entry to ${logPath}`);
1347
- }
1348
- } catch (cause) {
1349
- say(` ✗ cannot write the pass record — ${cause.code ?? cause.message}`);
872
+ if (reportPath !== undefined) {
873
+ // Written to the resolved path, so the file written is the file judged.
874
+ const target = realish(path.resolve(reportPath));
875
+ const roots = dirs.flatMap((dir) => [path.resolve(dir), declaredTree(dir)]).filter(Boolean);
876
+ const inside = roots.find((root) => isInside(realish(root), target));
877
+ if (inside !== undefined) {
878
+ say(` ✗ refusing to write the report inside ${inside}: a report goes outside the tree, where no commit carries it`);
1350
879
  worst = 2;
880
+ } else {
881
+ try {
882
+ fs.mkdirSync(path.dirname(target), { recursive: true });
883
+ // Renamed into place, never written through: a name may be a hard link into the tree, and `wx` opens nothing already there.
884
+ const fresh = `${target}.${process.pid}.tmp`;
885
+ fs.writeFileSync(fresh, renderReport(results, { asOf }), { flag: "wx" });
886
+ try {
887
+ fs.renameSync(fresh, target);
888
+ } catch (cause) {
889
+ fs.rmSync(fresh, { force: true });
890
+ throw cause;
891
+ }
892
+ say(` ok wrote the report to ${reportPath}`);
893
+ } catch (cause) {
894
+ say(` ✗ cannot write the report — ${cause.code ?? cause.message}`);
895
+ worst = 2;
896
+ }
1351
897
  }
1352
898
  }
1353
899
 
1354
- // ---- the indexes, LAST, and that is the ordering this whole split exists for.
1355
- //
1356
- // A pass is a session, so it just wrote a dated handoff into `slots.handoffs` — a member of the
1357
- // series the handoff index covers. Regenerated any earlier, that index would be stale in the very
1358
- // commit the pass pushes, and `index.sh` would red the pull request the pass exists to file. The
1359
- // failure would have arrived on the first real run, unattended, on the one artifact this
1360
- // milestone's criterion names. So the rule is one sentence with no exceptions in it: **nothing
1361
- // regenerates an index until every record this pass writes is on disk.**
1362
- //
1363
- // Only in write mode, and never when the record could not be written: an index regenerated over a
1364
- // series missing the handoff that belongs in it would be current about the wrong tree.
900
+ // ---- the indexes, last
1365
901
  if (write && worst < 2) {
1366
902
  for (const r of results) {
1367
903
  if (!r.declared) continue;