@sleepy_panda_srl/portulan 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +87 -0
- package/cli/README.md +337 -0
- package/cli/compile.mjs +2209 -0
- package/cli/control-chars.mjs +570 -0
- package/cli/discover.mjs +985 -0
- package/cli/doctor.mjs +2803 -0
- package/cli/feedback.mjs +970 -0
- package/cli/gate.mjs +271 -0
- package/cli/index.mjs +1390 -0
- package/cli/init.mjs +1729 -0
- package/cli/librarian.mjs +1384 -0
- package/cli/manifest.mjs +59 -0
- package/cli/new.mjs +803 -0
- package/cli/pack-version.mjs +543 -0
- package/cli/plugin-lint.mjs +1566 -0
- package/cli/portulan.mjs +266 -0
- package/cli/recipe-set.mjs +425 -0
- package/cli/rule-carriers.mjs +489 -0
- package/cli/skills-set.mjs +715 -0
- package/cli/stop-gate.mjs +434 -0
- package/cli/upgrade.mjs +897 -0
- package/cli/vendor.mjs +1504 -0
- package/core/README.md +46 -0
- package/core/engine.md +47 -0
- package/core/operating/autonomy.md +100 -0
- package/core/operating/evolution.md +84 -0
- package/core/operating/loop.md +83 -0
- package/core/operating/memory.md +161 -0
- package/core/operating/safety.md +42 -0
- package/core/operating/verification.md +76 -0
- package/core/personas/README.md +40 -0
- package/core/personas/implementer.md +51 -0
- package/core/personas/librarian.md +73 -0
- package/core/personas/reviewer.md +52 -0
- package/core/skills/README.md +52 -0
- package/core/skills/clarify/SKILL.md +42 -0
- package/core/skills/codify/SKILL.md +65 -0
- package/core/skills/consolidate/SKILL.md +121 -0
- package/core/templates/gate-policy.md +76 -0
- package/core/templates/handoff.md +29 -0
- package/core/templates/memory-entry.md +44 -0
- package/core/templates/pack.md +94 -0
- package/core/templates/persona.md +69 -0
- package/core/templates/proposal.md +27 -0
- package/core/templates/repo-card.md +28 -0
- package/core/templates/skill.md +57 -0
- package/core/templates/task.md +26 -0
- package/core/templates/workspace.md +81 -0
- package/package.json +41 -0
- package/packs/.claude-plugin/plugin.json +22 -0
- package/packs/README.md +61 -0
- package/packs/rituals/README.md +13 -0
- package/packs/rituals/checkpoints/README.md +105 -0
- package/packs/rituals/checkpoints/pack.json +36 -0
- package/packs/rituals/checkpoints/personas/supervisor.md +64 -0
- package/packs/rituals/checkpoints/skills/milestone-close/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/session-open/SKILL.md +57 -0
- package/packs/stacks/README.md +8 -0
- package/packs/tools/README.md +17 -0
- package/packs/tools/github/README.md +65 -0
- package/packs/tools/github/pack.json +29 -0
- package/packs/tools/github/verify/README.md +51 -0
- package/packs/tools/github/verify/actions-pinned.sh +132 -0
- package/spec/README.md +399 -0
- package/spec/migrations/0001-repository-declares-its-tree.mjs +100 -0
- package/spec/migrations/0002-bundle-fallback-path.mjs +179 -0
- package/spec/migrations/README.md +95 -0
- package/spec/pack.schema.json +201 -0
- package/spec/slots.md +796 -0
- package/spec/workspace.schema.json +438 -0
|
@@ -0,0 +1,1384 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `librarian` — the scheduled pass over the curated layer: reindex, staleness, nags, demotion drafts.
|
|
3
|
+
//
|
|
4
|
+
// node cli/librarian.mjs [--as-of YYYY-MM-DD] [--write] [--log <path>] <workspace-dir> [...]
|
|
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.
|
|
11
|
+
//
|
|
12
|
+
// ## What this is, and the one thing it can do that `doctor` cannot
|
|
13
|
+
//
|
|
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.
|
|
20
|
+
//
|
|
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.
|
|
62
|
+
|
|
63
|
+
import { execFileSync } from "node:child_process";
|
|
64
|
+
import fs from "node:fs";
|
|
65
|
+
import path from "node:path";
|
|
66
|
+
import { pathToFileURL } from "node:url";
|
|
67
|
+
|
|
68
|
+
import { inspect as inspectIndex, IndexError, recordType, dateOf, isInside } from "./index.mjs";
|
|
69
|
+
import { parseProvenance } from "./doctor.mjs";
|
|
70
|
+
|
|
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
|
+
/** Raised when the pass cannot run, or cannot report honestly. Always exit 2. */
|
|
75
|
+
export class LibrarianError extends Error {
|
|
76
|
+
constructor(message) {
|
|
77
|
+
super(message);
|
|
78
|
+
this.name = "LibrarianError";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
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"]);
|
|
87
|
+
|
|
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
|
+
const NOT_A_RECORD = new Set(["README.md"]);
|
|
92
|
+
|
|
93
|
+
const ISO = /^\d{4}-\d{2}-\d{2}$/;
|
|
94
|
+
|
|
95
|
+
// ===========================================================================================
|
|
96
|
+
// Dates
|
|
97
|
+
// ===========================================================================================
|
|
98
|
+
|
|
99
|
+
/** Parse `YYYY-MM-DD` strictly, or throw. `2026-13-45` is not a date this pass will guess about. */
|
|
100
|
+
function parseDate(value, where) {
|
|
101
|
+
if (typeof value !== "string" || !ISO.test(value)) {
|
|
102
|
+
throw new LibrarianError(`${where} is ${JSON.stringify(value)}, which is not a YYYY-MM-DD date`);
|
|
103
|
+
}
|
|
104
|
+
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.
|
|
107
|
+
if (Number.isNaN(ms) || new Date(ms).toISOString().slice(0, 10) !== value) {
|
|
108
|
+
throw new LibrarianError(`${where} is ${JSON.stringify(value)}, which is not a real date`);
|
|
109
|
+
}
|
|
110
|
+
return ms;
|
|
111
|
+
}
|
|
112
|
+
|
|
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
|
+
*/
|
|
117
|
+
export function daysBetween(from, to) {
|
|
118
|
+
const a = parseDate(from, "the earlier date");
|
|
119
|
+
const b = parseDate(to, "the later date");
|
|
120
|
+
return Math.round((b - a) / 86_400_000);
|
|
121
|
+
}
|
|
122
|
+
|
|
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
|
+
*/
|
|
138
|
+
export function sealedStamp(source) {
|
|
139
|
+
if (recordType(source) !== "rule") return null;
|
|
140
|
+
const { fields } = parseProvenance(source);
|
|
141
|
+
if (!fields || fields.form !== "sealed") return null;
|
|
142
|
+
if (!fields.date) {
|
|
143
|
+
throw new LibrarianError(
|
|
144
|
+
"a sealed rule carries no `date=` in its provenance stamp, so its re-validation cannot be dated. " +
|
|
145
|
+
"`doctor` fails this shape at the schema; refusing rather than passing over the record the nag exists for",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
parseDate(fields.date, "a sealed stamp's `date=`");
|
|
149
|
+
return { owner: fields.owner ?? "", date: fields.date };
|
|
150
|
+
}
|
|
151
|
+
|
|
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
|
+
export function retireWhen(source) {
|
|
161
|
+
const lines = source.split("\n");
|
|
162
|
+
const start = lines.findIndex((l) => /^\s*\*\*retire when:\*\*/i.test(l));
|
|
163
|
+
if (start === -1) return null;
|
|
164
|
+
let end = start;
|
|
165
|
+
while (end + 1 < lines.length && lines[end + 1].trim() !== "") end += 1;
|
|
166
|
+
return lines
|
|
167
|
+
.slice(start, end + 1)
|
|
168
|
+
.join(" ")
|
|
169
|
+
.replace(/^\s*\*\*retire when:\*\*\s*/i, "")
|
|
170
|
+
.replace(/\s+/g, " ")
|
|
171
|
+
.trim();
|
|
172
|
+
}
|
|
173
|
+
|
|
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
|
+
*/
|
|
189
|
+
export function proposalPending(source) {
|
|
190
|
+
const lines = source.split("\n");
|
|
191
|
+
const parts = [];
|
|
192
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
193
|
+
if (!/^\s*\*\*(decision|status)\b/i.test(lines[i])) continue;
|
|
194
|
+
let end = i;
|
|
195
|
+
while (end + 1 < lines.length && lines[end + 1].trim() !== "") end += 1;
|
|
196
|
+
parts.push(lines.slice(i, end + 1).join(" "));
|
|
197
|
+
i = end;
|
|
198
|
+
}
|
|
199
|
+
if (!parts.length) return true;
|
|
200
|
+
const text = parts.join(" ").replace(/[*_`]/g, "");
|
|
201
|
+
if (/\bpending\b/i.test(text)) return true;
|
|
202
|
+
if (/[{}]|\|/.test(text)) return true; // an unfilled template line is not a decision
|
|
203
|
+
return !/\b(accepted|rejected|revised|applied|withdrawn|superseded)\b/i.test(text);
|
|
204
|
+
}
|
|
205
|
+
|
|
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.
|
|
214
|
+
|
|
215
|
+
function git(root, args, what) {
|
|
216
|
+
try {
|
|
217
|
+
return execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
218
|
+
} catch (cause) {
|
|
219
|
+
throw new LibrarianError(`git could not ${what} — ${cause.stderr?.toString().trim() || cause.message}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
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
|
+
function historyRoot(dir) {
|
|
233
|
+
const root = git(dir, ["rev-parse", "--show-toplevel"], `find a git repository at ${dir}`);
|
|
234
|
+
if (git(root, ["rev-parse", "--is-shallow-repository"], "test for a shallow repository") === "true") {
|
|
235
|
+
throw new LibrarianError(
|
|
236
|
+
"this is a shallow clone, where a file's history may be truncated away entirely — every record " +
|
|
237
|
+
"would read as undated and every threshold would fire or none would. Check out with full history " +
|
|
238
|
+
"(`fetch-depth: 0`) before asking this pass to date anything",
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return root;
|
|
242
|
+
}
|
|
243
|
+
|
|
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
|
+
*/
|
|
259
|
+
function lastTouched(root, relative) {
|
|
260
|
+
const out = git(root, ["log", "-1", "--format=%as", "--", relative], `date ${relative}`);
|
|
261
|
+
if (ISO.test(out)) return out;
|
|
262
|
+
|
|
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
|
+
try {
|
|
283
|
+
git(root, ["cat-file", "-e", `HEAD:${relative}`], `look ${relative} up in HEAD`);
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
throw new LibrarianError(
|
|
288
|
+
`${relative} is committed and git returned no date for it, so this pass cannot say how old it is. ` +
|
|
289
|
+
"A shallow clone is already refused above, so this is something else about the checkout — and either way it is not a fact about the store",
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
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
|
+
*/
|
|
314
|
+
function threshold(value, where, noun = "threshold", off = "nag") {
|
|
315
|
+
if (value === undefined) return undefined;
|
|
316
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
317
|
+
throw new LibrarianError(
|
|
318
|
+
`${where} is ${JSON.stringify(value)}, which is not a positive integer. A ${noun} that is zero, ` +
|
|
319
|
+
`negative or non-numeric would read as undeclared and switch the ${off} off in the key that exists to switch it on`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
return value;
|
|
323
|
+
}
|
|
324
|
+
|
|
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
|
+
const budget = (workspace, group, key) =>
|
|
333
|
+
threshold(workspace.memory?.[group]?.budget?.[key], `memory.${group}.budget.${key}`, "budget", "rail");
|
|
334
|
+
|
|
335
|
+
const listMarkdown = (dir) => {
|
|
336
|
+
let names;
|
|
337
|
+
try {
|
|
338
|
+
names = fs.readdirSync(dir);
|
|
339
|
+
} catch (cause) {
|
|
340
|
+
throw new LibrarianError(`cannot read ${dir} — ${cause.code ?? cause.message}`);
|
|
341
|
+
}
|
|
342
|
+
return names.filter((n) => n.endsWith(".md") && !NOT_A_RECORD.has(n)).sort();
|
|
343
|
+
};
|
|
344
|
+
|
|
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 } = {}) {
|
|
360
|
+
parseDate(asOf, "--as-of");
|
|
361
|
+
|
|
362
|
+
const manifestPath = path.join(dir, "workspace.json");
|
|
363
|
+
let workspace;
|
|
364
|
+
try {
|
|
365
|
+
workspace = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
366
|
+
} catch (cause) {
|
|
367
|
+
throw new LibrarianError(`cannot read ${manifestPath} — ${cause.message}`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const spec = workspace?.portulan?.spec;
|
|
371
|
+
if (!KNOWN_SPECS.has(String(spec))) {
|
|
372
|
+
throw new LibrarianError(
|
|
373
|
+
`${dir} declares Workspace Definition ${JSON.stringify(spec)}, which this tool does not implement ` +
|
|
374
|
+
`(knows: ${[...KNOWN_SPECS].join(", ")}). Refusing rather than reporting on a manifest it may misread`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const name = workspace.name ?? dir;
|
|
379
|
+
if (!workspace.librarian) return { dir, name, declared: false };
|
|
380
|
+
|
|
381
|
+
const staleness = workspace.librarian.staleness ?? {};
|
|
382
|
+
const thresholds = {
|
|
383
|
+
record_days: threshold(staleness.record_days, "librarian.staleness.record_days"),
|
|
384
|
+
sealed_days: threshold(staleness.sealed_days, "librarian.staleness.sealed_days"),
|
|
385
|
+
proposal_days: threshold(staleness.proposal_days, "librarian.staleness.proposal_days"),
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const memorySlot = workspace.slots?.memory;
|
|
389
|
+
if (!memorySlot) {
|
|
390
|
+
throw new LibrarianError(
|
|
391
|
+
`${dir} declares a \`librarian\` pass and no \`slots.memory\` store — there is nothing to age. ` +
|
|
392
|
+
"The declared JSON Schema subset has no `dependentRequired` (spec/README.md), so this is checked here and by `doctor`",
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
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.
|
|
403
|
+
const rel = (p) => path.relative(root, fs.realpathSync(p)).split(path.sep).join("/");
|
|
404
|
+
|
|
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: [] };
|
|
427
|
+
try {
|
|
428
|
+
const result = inspectIndex(dir, { write: false });
|
|
429
|
+
const of = (which) => ({
|
|
430
|
+
declared: result.series[which].path !== null,
|
|
431
|
+
drifted: result.findings.some((f) => f.check === "index" && f.series === which),
|
|
432
|
+
});
|
|
433
|
+
index = {
|
|
434
|
+
declared: result.declared,
|
|
435
|
+
drifted: result.findings.some((f) => f.check === "index"),
|
|
436
|
+
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
|
+
expected: result.series.memory.expected,
|
|
443
|
+
findings: result.findings.map((f) => f.message),
|
|
444
|
+
};
|
|
445
|
+
} catch (cause) {
|
|
446
|
+
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
|
+
throw new LibrarianError(`the reindex could not run — ${cause.message}`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ---- the store, dated
|
|
453
|
+
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
|
+
const counts = { records: 0, rules: 0, sealed: 0, bytes: 0, uncommitted: 0, largest: { file: null, bytes: 0 } };
|
|
458
|
+
const records = [];
|
|
459
|
+
const seals = [];
|
|
460
|
+
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.
|
|
464
|
+
const curated = [];
|
|
465
|
+
// Records grouped by the incident they cite, for consolidation's step-2 question.
|
|
466
|
+
const byIncident = new Map();
|
|
467
|
+
|
|
468
|
+
for (const file of listMarkdown(storeDir)) {
|
|
469
|
+
const full = path.join(storeDir, file);
|
|
470
|
+
let source;
|
|
471
|
+
try {
|
|
472
|
+
source = fs.readFileSync(full, "utf8");
|
|
473
|
+
} catch (cause) {
|
|
474
|
+
throw new LibrarianError(`cannot read the record ${path.join(memorySlot, file)} — ${cause.code ?? cause.message}`);
|
|
475
|
+
}
|
|
476
|
+
curated.push(source);
|
|
477
|
+
const incident = provenanceHref(source);
|
|
478
|
+
if (incident) byIncident.set(incident, [...(byIncident.get(incident) ?? []), file]);
|
|
479
|
+
|
|
480
|
+
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
|
+
const days = touched === null ? 0 : daysBetween(touched, asOf);
|
|
484
|
+
const type = recordType(source) || "untyped";
|
|
485
|
+
const condition = retireWhen(source);
|
|
486
|
+
|
|
487
|
+
counts.records += 1;
|
|
488
|
+
const recordBytes = Buffer.byteLength(source);
|
|
489
|
+
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
|
+
if (counts.largest.file === null || recordBytes > counts.largest.bytes) {
|
|
495
|
+
counts.largest = { file, bytes: recordBytes };
|
|
496
|
+
}
|
|
497
|
+
if (touched === null) counts.uncommitted += 1;
|
|
498
|
+
if (type === "rule") counts.rules += 1;
|
|
499
|
+
|
|
500
|
+
const record = { file, type, lastTouched: touched, days, condition };
|
|
501
|
+
records.push(record);
|
|
502
|
+
|
|
503
|
+
const stamp = sealedStamp(source);
|
|
504
|
+
if (stamp) {
|
|
505
|
+
counts.sealed += 1;
|
|
506
|
+
const age = daysBetween(stamp.date, asOf);
|
|
507
|
+
seals.push({
|
|
508
|
+
file,
|
|
509
|
+
owner: stamp.owner,
|
|
510
|
+
date: stamp.date,
|
|
511
|
+
days: age,
|
|
512
|
+
due: thresholds.sealed_days !== undefined && age >= thresholds.sealed_days,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (thresholds.record_days !== undefined && days >= thresholds.record_days) {
|
|
517
|
+
drafts.push({
|
|
518
|
+
file,
|
|
519
|
+
condition,
|
|
520
|
+
lastTouched: touched,
|
|
521
|
+
days,
|
|
522
|
+
recommendation: condition
|
|
523
|
+
? "Read the condition against the tree and decide. Nothing here can judge whether it still holds — " +
|
|
524
|
+
"that is a question about the world, and this pass sees only the store."
|
|
525
|
+
: "Give it a `**Retire when:**` line, or state a retirement condition here and demote it — " +
|
|
526
|
+
"a record no condition can demote leaves the store only by someone re-reading it.",
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const stale = thresholds.record_days === undefined ? [] : records.filter((r) => r.days >= thresholds.record_days);
|
|
532
|
+
|
|
533
|
+
// ---- proposals. `null` is *not asked*, which is not the same answer as *none pending*.
|
|
534
|
+
let proposals = null;
|
|
535
|
+
if (workspace.slots?.proposals) {
|
|
536
|
+
const proposalDir = path.resolve(dir, workspace.slots.proposals);
|
|
537
|
+
proposals = [];
|
|
538
|
+
for (const file of listMarkdown(proposalDir)) {
|
|
539
|
+
const full = path.join(proposalDir, file);
|
|
540
|
+
const source = fs.readFileSync(full, "utf8");
|
|
541
|
+
curated.push(source);
|
|
542
|
+
const touched = lastTouched(root, rel(full));
|
|
543
|
+
const days = touched === null ? 0 : daysBetween(touched, asOf);
|
|
544
|
+
const pending = proposalPending(source);
|
|
545
|
+
proposals.push({
|
|
546
|
+
file,
|
|
547
|
+
lastTouched: touched,
|
|
548
|
+
days,
|
|
549
|
+
pending,
|
|
550
|
+
due: pending && thresholds.proposal_days !== undefined && days >= thresholds.proposal_days,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
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
|
+
const handoffsDir = workspace.slots?.handoffs ? path.resolve(dir, workspace.slots.handoffs) : null;
|
|
559
|
+
|
|
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.
|
|
568
|
+
const series = { declared: false, count: null, oldest: null, bytes: 0, files: [] };
|
|
569
|
+
if (handoffsDir) {
|
|
570
|
+
series.declared = true;
|
|
571
|
+
series.count = 0;
|
|
572
|
+
for (const file of listMarkdown(handoffsDir)) {
|
|
573
|
+
const full = path.join(handoffsDir, file);
|
|
574
|
+
let source;
|
|
575
|
+
try {
|
|
576
|
+
source = fs.readFileSync(full, "utf8");
|
|
577
|
+
} catch (cause) {
|
|
578
|
+
throw new LibrarianError(
|
|
579
|
+
`cannot read the handoff ${path.join(workspace.slots.handoffs, file)} — ${cause.code ?? cause.message}`,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
const touched = lastTouched(root, rel(full));
|
|
583
|
+
const days = touched === null ? 0 : daysBetween(touched, asOf);
|
|
584
|
+
series.count += 1;
|
|
585
|
+
series.bytes += Buffer.byteLength(source);
|
|
586
|
+
series.files.push({ file, date: dateOf(file), lastTouched: touched, days });
|
|
587
|
+
}
|
|
588
|
+
series.oldest = series.files.reduce((a, b) => (a === null || b.days > a.days ? b : a), null);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const mining = {
|
|
592
|
+
incidents: mineIncidents(series, curated),
|
|
593
|
+
reviews: mineReviews(reviews, { treeRoot: workspace.tree ? path.resolve(dir, workspace.tree) : null }),
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const consolidation = {
|
|
597
|
+
// Step 2 of core/skills/consolidate/SKILL.md, as a question rather than a verdict — see
|
|
598
|
+
// `sharedIncidents`.
|
|
599
|
+
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
|
+
headroom: {
|
|
608
|
+
store: budgetHeadroom(counts.bytes / 1024, budget(workspace, "store", "kilobytes")),
|
|
609
|
+
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
|
+
record: budgetHeadroom(counts.largest.bytes / 1024, budget(workspace, "store", "record_kilobytes")),
|
|
616
|
+
},
|
|
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
|
+
largest: counts.largest,
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
return {
|
|
623
|
+
dir,
|
|
624
|
+
name,
|
|
625
|
+
declared: true,
|
|
626
|
+
thresholds,
|
|
627
|
+
index,
|
|
628
|
+
records,
|
|
629
|
+
stale,
|
|
630
|
+
seals,
|
|
631
|
+
drafts,
|
|
632
|
+
proposals,
|
|
633
|
+
counts,
|
|
634
|
+
handoffsDir,
|
|
635
|
+
handoffs: series,
|
|
636
|
+
mining,
|
|
637
|
+
consolidation,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
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.
|
|
656
|
+
|
|
657
|
+
/** The pass's own records, named by the constant `run` writes them under — not a guess at a shape. */
|
|
658
|
+
const PASS_RECORD = /-librarian-pass\.md$/;
|
|
659
|
+
|
|
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) {
|
|
680
|
+
if (!series.declared) return { declared: false, total: null, linked: 0, since: null, candidates: [] };
|
|
681
|
+
|
|
682
|
+
const incidents = series.files.filter((f) => !PASS_RECORD.test(f.file));
|
|
683
|
+
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);
|
|
685
|
+
|
|
686
|
+
const isLinked = (file) => curated.some((source) => source.includes(file));
|
|
687
|
+
const linked = incidents.filter((i) => isLinked(i.file)).length;
|
|
688
|
+
|
|
689
|
+
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.
|
|
697
|
+
const inWindow = since
|
|
698
|
+
? (i) => i.date !== null && i.date >= since
|
|
699
|
+
: (i) => i.date !== null && i.date === newest;
|
|
700
|
+
|
|
701
|
+
const candidates = incidents
|
|
702
|
+
.filter((i) => inWindow(i) && !isLinked(i.file))
|
|
703
|
+
.map((i) => ({
|
|
704
|
+
file: i.file,
|
|
705
|
+
date: i.date,
|
|
706
|
+
recommendation:
|
|
707
|
+
"Read it. If it taught a rule, run the `codify` skill; if it already did, add the link — " +
|
|
708
|
+
"a rule whose incident cannot be traced can never be retired on evidence.",
|
|
709
|
+
}));
|
|
710
|
+
|
|
711
|
+
return { declared: true, total: incidents.length, linked, since, window: since ? "since the last pass" : "the newest date in the series", candidates };
|
|
712
|
+
}
|
|
713
|
+
|
|
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
|
+
*/
|
|
738
|
+
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
|
+
if (reviews === undefined || reviews === null || !treeRoot) return null;
|
|
743
|
+
if (!Array.isArray(reviews)) {
|
|
744
|
+
throw new LibrarianError(
|
|
745
|
+
"the review corpus is not a JSON array. GitHub answers an error as an object, so reading this as " +
|
|
746
|
+
"*no reviews* would report *none recurring* over a fetch that failed — refusing instead",
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
|
|
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
|
+
for (const c of reviews) {
|
|
760
|
+
if (!c || typeof c !== "object" || Array.isArray(c) || typeof c.pull_request_url !== "string") {
|
|
761
|
+
throw new LibrarianError(
|
|
762
|
+
`the review corpus holds an element that is not a review comment (${Array.isArray(c) ? "an array" : typeof c}` +
|
|
763
|
+
", with no `pull_request_url`). One flat array of comments is the shape this reads; an array of " +
|
|
764
|
+
"PAGES is what `--slurp` produces and is not it. Refusing rather than reporting *none recurring* " +
|
|
765
|
+
"over a corpus it could not read",
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const findings = reviews.filter((c) => !c.in_reply_to_id);
|
|
771
|
+
const replies = reviews.length - findings.length;
|
|
772
|
+
|
|
773
|
+
const pulls = new Map();
|
|
774
|
+
for (const c of findings) {
|
|
775
|
+
if (!c.path) continue;
|
|
776
|
+
const pull = c.pull_request_url.split("/").pop();
|
|
777
|
+
if (!pulls.has(c.path)) pulls.set(c.path, new Set());
|
|
778
|
+
pulls.get(c.path).add(pull);
|
|
779
|
+
}
|
|
780
|
+
|
|
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.
|
|
796
|
+
const inTree = (p) => {
|
|
797
|
+
const full = path.resolve(treeRoot, p);
|
|
798
|
+
return isInside(treeRoot, full) && fs.existsSync(full);
|
|
799
|
+
};
|
|
800
|
+
const recurring = [...pulls.entries()].map(([p, s]) => ({ path: p, pulls: s.size })).filter((p) => p.pulls >= 2);
|
|
801
|
+
const paths = recurring
|
|
802
|
+
.filter((p) => inTree(p.path))
|
|
803
|
+
.sort((a, b) => b.pulls - a.pulls || a.path.localeCompare(b.path));
|
|
804
|
+
|
|
805
|
+
return { comments: reviews.length, findings: findings.length, replies, gone: recurring.length - paths.length, paths };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// ===========================================================================================
|
|
809
|
+
// Consolidation — the mechanical half, and an honest account of the rest
|
|
810
|
+
// ===========================================================================================
|
|
811
|
+
|
|
812
|
+
/** A rule's provenance link, or null — the `href=` of a `form=link` stamp. */
|
|
813
|
+
export function provenanceHref(source) {
|
|
814
|
+
return source.match(/^\s*\*\*provenance:\*\*.*?href=([^\s`]+)/im)?.[1] ?? null;
|
|
815
|
+
}
|
|
816
|
+
|
|
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
|
+
export function sharedIncidents(byIncident) {
|
|
832
|
+
return [...byIncident.entries()]
|
|
833
|
+
.filter(([, files]) => files.length > 1)
|
|
834
|
+
.map(([incident, files]) => ({
|
|
835
|
+
incident,
|
|
836
|
+
files: [...files].sort(),
|
|
837
|
+
question:
|
|
838
|
+
"Are these one mechanism, or several lessons from one incident? Only the first is a merge — " +
|
|
839
|
+
"and a merge carries BOTH parents' provenance and both retirement conditions.",
|
|
840
|
+
}))
|
|
841
|
+
.sort((a, b) => a.incident.localeCompare(b.incident));
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** How close a measured figure is to its budget — `null` when nothing declared one to be close to. */
|
|
845
|
+
export function budgetHeadroom(actual, budget) {
|
|
846
|
+
if (budget === undefined) return null;
|
|
847
|
+
return { actual: Number(actual.toFixed(1)), budget, percent: Math.round((actual / budget) * 100) };
|
|
848
|
+
}
|
|
849
|
+
|
|
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
|
+
*/
|
|
863
|
+
const renderedLines = (expected) =>
|
|
864
|
+
expected === null || expected === undefined ? 0 : expected.split("\n").length - (expected.endsWith("\n") ? 1 : 0);
|
|
865
|
+
|
|
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.
|
|
879
|
+
|
|
880
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
881
|
+
|
|
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
|
+
*/
|
|
897
|
+
const indexState = (s) => (s.declared ? (s.drifted ? "**was out of date when this pass arrived**" : "current") : "none declared");
|
|
898
|
+
|
|
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 }) {
|
|
901
|
+
const out = [
|
|
902
|
+
"# Handoff — the librarian's scheduled pass",
|
|
903
|
+
"",
|
|
904
|
+
`**Date:** ${asOf} · **Scheduled librarian pass** · Filed by \`cli/librarian.mjs\` on a cron.`,
|
|
905
|
+
"",
|
|
906
|
+
"**State.** Every figure below is **as of " + asOf + "**, read from git history rather than from the",
|
|
907
|
+
"filesystem, and nothing here is a decision. This pass drafts; the maintainer disposes.",
|
|
908
|
+
"",
|
|
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
|
+
];
|
|
923
|
+
|
|
924
|
+
for (const r of results) {
|
|
925
|
+
if (!r.declared) {
|
|
926
|
+
out.push(`## ${r.name}`, "", "Declares no `librarian` object — not passed over.", "");
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
const t = r.thresholds;
|
|
930
|
+
out.push(`## ${r.name}`, "");
|
|
931
|
+
out.push(
|
|
932
|
+
`**Store.** ${plural(r.counts.records, "record")}, ${plural(r.counts.rules, "rule")}, ` +
|
|
933
|
+
`${(r.counts.bytes / 1024).toFixed(1)} KB` +
|
|
934
|
+
(r.counts.uncommitted
|
|
935
|
+
? ` — ${r.counts.uncommitted} not yet committed, so undated here and never stale`
|
|
936
|
+
: "") +
|
|
937
|
+
". Index: " +
|
|
938
|
+
indexState(r.index.series.memory) +
|
|
939
|
+
".",
|
|
940
|
+
"",
|
|
941
|
+
);
|
|
942
|
+
|
|
943
|
+
// ---- the handoff series
|
|
944
|
+
if (!r.handoffs.declared) {
|
|
945
|
+
out.push("**Handoff series.** This workspace declares no `slots.handoffs` — not asked.", "");
|
|
946
|
+
} else {
|
|
947
|
+
out.push(
|
|
948
|
+
`**Handoff series.** ${plural(r.handoffs.count, "handoff")}, ` +
|
|
949
|
+
`${(r.handoffs.bytes / 1024).toFixed(1)} KB, oldest ${r.handoffs.oldest ? `\`${r.handoffs.oldest.file}\` at ${plural(r.handoffs.oldest.days, "day")}` : "none — the series is empty"}. ` +
|
|
950
|
+
"Index: " +
|
|
951
|
+
indexState(r.index.series.handoffs) +
|
|
952
|
+
". No threshold reaches this series and no demotion is drafted against it: it is " +
|
|
953
|
+
"append-only, so the only repair a staleness draft could recommend is deleting the " +
|
|
954
|
+
"record the series exists to keep.",
|
|
955
|
+
"",
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// ---- staleness
|
|
960
|
+
if (t.record_days === undefined) {
|
|
961
|
+
out.push(`**Staleness.** No \`record_days\` declared, so nothing is flagged. The oldest record is ${oldest(r.records)}.`, "");
|
|
962
|
+
} else if (r.stale.length === 0) {
|
|
963
|
+
out.push(
|
|
964
|
+
`**Staleness.** Nothing is stale: no record has gone untouched for ${plural(t.record_days, "day")}. ` +
|
|
965
|
+
`The oldest is ${oldest(r.records)}.`,
|
|
966
|
+
"",
|
|
967
|
+
);
|
|
968
|
+
} else {
|
|
969
|
+
out.push(`**Staleness.** ${plural(r.stale.length, "record")} untouched for ${plural(t.record_days, "day")} or more:`, "");
|
|
970
|
+
for (const s of r.stale) out.push(` - \`${s.file}\` — last authored ${s.lastTouched ?? "never — uncommitted"}, ${plural(s.days, "day")} ago`);
|
|
971
|
+
out.push("");
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// ---- seals
|
|
975
|
+
if (r.counts.sealed === 0) {
|
|
976
|
+
out.push(
|
|
977
|
+
"**Sealed stamps.** None in this store — every rule links its incident, so retirement here can rest " +
|
|
978
|
+
"on evidence rather than on asking. Reported at zero rather than omitted: *nothing to nag* and " +
|
|
979
|
+
"*did not look* must not print the same way.",
|
|
980
|
+
"",
|
|
981
|
+
);
|
|
982
|
+
} else {
|
|
983
|
+
const due = r.seals.filter((s) => s.due);
|
|
984
|
+
out.push(
|
|
985
|
+
`**Sealed stamps.** ${plural(r.counts.sealed, "sealed rule")} of ${r.counts.rules}. ` +
|
|
986
|
+
(t.sealed_days === undefined
|
|
987
|
+
? "No `sealed_days` declared, so none is nagged."
|
|
988
|
+
: due.length
|
|
989
|
+
? `${due.length} due for re-validation:`
|
|
990
|
+
: `None is past ${plural(t.sealed_days, "day")}.`),
|
|
991
|
+
"",
|
|
992
|
+
);
|
|
993
|
+
for (const s of due) {
|
|
994
|
+
out.push(
|
|
995
|
+
` - \`${s.file}\` — sealed ${s.date}, ${plural(s.days, "day")} ago. **${s.owner || "the owner"}**: can the ` +
|
|
996
|
+
"incident behind this rule still occur? Nothing here can see it, which is what sealing means.",
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
if (due.length) out.push("");
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// ---- proposals
|
|
1003
|
+
if (r.proposals === null) {
|
|
1004
|
+
out.push("**Proposals.** This workspace declares no `slots.proposals` — not asked.", "");
|
|
1005
|
+
} else {
|
|
1006
|
+
const pending = r.proposals.filter((p) => p.pending);
|
|
1007
|
+
const due = r.proposals.filter((p) => p.due);
|
|
1008
|
+
out.push(
|
|
1009
|
+
`**Proposals.** ${r.proposals.length} filed, ${pending.length} still waiting on the human gate` +
|
|
1010
|
+
(t.proposal_days === undefined
|
|
1011
|
+
? ", and no `proposal_days` declared, so none is nagged."
|
|
1012
|
+
: due.length
|
|
1013
|
+
? `, of which ${due.length} past ${plural(t.proposal_days, "day")}:`
|
|
1014
|
+
: `, none past ${plural(t.proposal_days, "day")}.`),
|
|
1015
|
+
"",
|
|
1016
|
+
);
|
|
1017
|
+
for (const p of due) out.push(` - \`${p.file}\` — last authored ${p.lastTouched ?? "never — uncommitted"}, ${plural(p.days, "day")} ago`);
|
|
1018
|
+
if (due.length) out.push("");
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// ---- demotion drafts
|
|
1022
|
+
if (r.drafts.length === 0) {
|
|
1023
|
+
out.push(
|
|
1024
|
+
"**Demotion drafts.** None. A draft is written for a record old enough to be worth re-reading, and " +
|
|
1025
|
+
"no record here is.",
|
|
1026
|
+
"",
|
|
1027
|
+
);
|
|
1028
|
+
} else {
|
|
1029
|
+
out.push(`**Demotion drafts.** ${plural(r.drafts.length, "candidate")}, each decided by nobody:`, "");
|
|
1030
|
+
for (const d of r.drafts) {
|
|
1031
|
+
out.push(` - \`${d.file}\` — last authored ${d.lastTouched ?? "never — uncommitted"}, ${plural(d.days, "day")} ago.`);
|
|
1032
|
+
out.push(` Retire when — ${d.condition ? d.condition : "NO CONDITION STATED"}`);
|
|
1033
|
+
out.push(` ${d.recommendation}`);
|
|
1034
|
+
}
|
|
1035
|
+
out.push("");
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// ---- mining: incidents
|
|
1039
|
+
const inc = r.mining.incidents;
|
|
1040
|
+
if (!inc.declared) {
|
|
1041
|
+
out.push("**Mining — incidents.** No `slots.handoffs` series to mine — not asked.", "");
|
|
1042
|
+
} else {
|
|
1043
|
+
out.push(
|
|
1044
|
+
`**Mining — incidents.** ${plural(inc.total, "incident")} in the series; ${inc.linked} have ` +
|
|
1045
|
+
`something in the curated layer pointing back at them, ${inc.total - inc.linked} do not. ` +
|
|
1046
|
+
`Candidates below are ${inc.since ? `those since the last pass (${inc.since})` : "those of the newest date in the series, there being no earlier pass to measure from"}. ` +
|
|
1047
|
+
"The claim is the narrow one: nothing here says an incident taught no rule, only that no " +
|
|
1048
|
+
"rule or proposal points back to it — and a rule whose incident cannot be traced can " +
|
|
1049
|
+
"never be retired on evidence.",
|
|
1050
|
+
"",
|
|
1051
|
+
);
|
|
1052
|
+
if (inc.candidates.length === 0) {
|
|
1053
|
+
out.push(" - None in the window.", "");
|
|
1054
|
+
} else {
|
|
1055
|
+
for (const c of inc.candidates) {
|
|
1056
|
+
out.push(` - \`${c.file}\` — ${c.date}. ${c.recommendation}`);
|
|
1057
|
+
}
|
|
1058
|
+
out.push("");
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// ---- mining: pull-request reviews
|
|
1063
|
+
if (r.mining.reviews === null) {
|
|
1064
|
+
out.push(
|
|
1065
|
+
"**Mining — pull-request reviews.** Not asked: no review corpus was supplied, or this " +
|
|
1066
|
+
"workspace declares no `tree` and so makes claims about no repository. *Not asked* is not " +
|
|
1067
|
+
"*none recurring*.",
|
|
1068
|
+
"",
|
|
1069
|
+
);
|
|
1070
|
+
} else {
|
|
1071
|
+
const rv = r.mining.reviews;
|
|
1072
|
+
out.push(
|
|
1073
|
+
`**Mining — pull-request reviews.** ${plural(rv.comments, "inline comment")}: ${rv.findings} open a ` +
|
|
1074
|
+
`thread and are findings, ${rv.replies} are replies and are not. ` +
|
|
1075
|
+
(rv.paths.length
|
|
1076
|
+
? `${plural(rv.paths.length, "path")} still in the tree have drawn findings on two or more distinct pull requests. `
|
|
1077
|
+
: "No path still in the tree has drawn findings on two or more distinct pull requests. ") +
|
|
1078
|
+
(rv.gone ? `${plural(rv.gone, "other")} did and no longer exists, so they are dropped rather than nagged about forever. ` : "") +
|
|
1079
|
+
"Two is what *recurring* means rather than a number anyone chose. **Inline comments only** " +
|
|
1080
|
+
"— the low-confidence notes collapsed into a review body carry no path and cannot be seen " +
|
|
1081
|
+
"from here, and that is the larger channel.",
|
|
1082
|
+
"",
|
|
1083
|
+
);
|
|
1084
|
+
for (const p of rv.paths) out.push(` - \`${p.path}\` — findings on ${plural(p.pulls, "pull request")}`);
|
|
1085
|
+
if (rv.paths.length) out.push("");
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// ---- consolidation
|
|
1089
|
+
const c = r.consolidation;
|
|
1090
|
+
const head = (label, h, unit) =>
|
|
1091
|
+
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
|
+
const widest =
|
|
1099
|
+
c.headroom.record === null
|
|
1100
|
+
? head("Largest record", null)
|
|
1101
|
+
: `${head("Largest record", c.headroom.record, "KB")}` +
|
|
1102
|
+
(c.largest.file === null ? " — no records yet" : ` — \`${c.largest.file}\``);
|
|
1103
|
+
out.push(
|
|
1104
|
+
`**Consolidation.** ${head("Store", c.headroom.store, "KB")}. ${head("Index", c.headroom.index, "lines")}. ` +
|
|
1105
|
+
`${widest}. ` +
|
|
1106
|
+
"Reported as a distance rather than a verdict: the `index` recipe already answers over or " +
|
|
1107
|
+
"under at pull-request time, and what it cannot say is how close.",
|
|
1108
|
+
"",
|
|
1109
|
+
);
|
|
1110
|
+
if (c.shared.length === 0) {
|
|
1111
|
+
out.push(" - No two records cite one incident.", "");
|
|
1112
|
+
} else {
|
|
1113
|
+
out.push(` - ${plural(c.shared.length, "group")} of records citing one incident — a question, not a verdict:`, "");
|
|
1114
|
+
for (const g of c.shared) {
|
|
1115
|
+
out.push(` - \`${g.incident}\` ← ${g.files.map((f) => `\`${f}\``).join(", ")}`);
|
|
1116
|
+
out.push(` ${g.question}`);
|
|
1117
|
+
}
|
|
1118
|
+
out.push("");
|
|
1119
|
+
}
|
|
1120
|
+
out.push(
|
|
1121
|
+
" Steps 3 and 4 of `core/skills/consolidate/SKILL.md` — surfacing contradictions and " +
|
|
1122
|
+
"compressing what survives — are **not automated here, and are not silently skipped**. " +
|
|
1123
|
+
"Both need a reading of what two records mean, and a pass that guessed would be making " +
|
|
1124
|
+
"the policy decision step 3 exists to forbid.",
|
|
1125
|
+
"",
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
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.",
|
|
1134
|
+
"",
|
|
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.",
|
|
1139
|
+
"",
|
|
1140
|
+
);
|
|
1141
|
+
return out.join("\n");
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
const oldest = (records) => {
|
|
1145
|
+
const o = records.reduce((a, b) => (a === null || b.days > a.days ? b : a), null);
|
|
1146
|
+
return o ? `\`${o.file}\` at ${plural(o.days, "day")}` : "none — the store is empty";
|
|
1147
|
+
};
|
|
1148
|
+
|
|
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
|
+
// ===========================================================================================
|
|
1198
|
+
|
|
1199
|
+
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
|
+
*/
|
|
1220
|
+
export function parseArgs(argv) {
|
|
1221
|
+
const opts = { asOf: undefined, logPath: undefined, reviewsPath: undefined, write: false, dirs: [] };
|
|
1222
|
+
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
|
+
if (next === undefined || next.startsWith("--")) {
|
|
1226
|
+
throw new LibrarianError(`${flag} needs a value.\n${USAGE}`);
|
|
1227
|
+
}
|
|
1228
|
+
return next;
|
|
1229
|
+
};
|
|
1230
|
+
|
|
1231
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
1232
|
+
const arg = argv[i];
|
|
1233
|
+
switch (arg) {
|
|
1234
|
+
case "--as-of":
|
|
1235
|
+
opts.asOf = value(arg, argv[(i += 1)]);
|
|
1236
|
+
break;
|
|
1237
|
+
case "--log":
|
|
1238
|
+
opts.logPath = value(arg, argv[(i += 1)]);
|
|
1239
|
+
break;
|
|
1240
|
+
case "--reviews":
|
|
1241
|
+
opts.reviewsPath = value(arg, argv[(i += 1)]);
|
|
1242
|
+
break;
|
|
1243
|
+
case "--write":
|
|
1244
|
+
opts.write = true;
|
|
1245
|
+
break;
|
|
1246
|
+
default:
|
|
1247
|
+
if (arg.startsWith("--")) throw new LibrarianError(`unknown option ${JSON.stringify(arg)}.\n${USAGE}`);
|
|
1248
|
+
opts.dirs.push(arg);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
return opts;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
export function run(argv, say = console.log) {
|
|
1255
|
+
let asOfArg, logPath, reviewsPath, write, dirs;
|
|
1256
|
+
try {
|
|
1257
|
+
({ asOf: asOfArg, logPath, reviewsPath, write, dirs } = parseArgs(argv));
|
|
1258
|
+
} catch (error) {
|
|
1259
|
+
if (!(error instanceof LibrarianError)) throw error;
|
|
1260
|
+
say(` ✗ ${error.message}`);
|
|
1261
|
+
return 2;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
if (dirs.length === 0) {
|
|
1265
|
+
say(USAGE);
|
|
1266
|
+
return 2;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
const asOf = asOfArg ?? new Date().toISOString().slice(0, 10);
|
|
1270
|
+
try {
|
|
1271
|
+
parseDate(asOf, "--as-of");
|
|
1272
|
+
} catch (error) {
|
|
1273
|
+
say(` ✗ ${error.message}`);
|
|
1274
|
+
return 2;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
let reviews;
|
|
1278
|
+
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
|
+
try {
|
|
1283
|
+
reviews = JSON.parse(fs.readFileSync(reviewsPath, "utf8"));
|
|
1284
|
+
} catch (cause) {
|
|
1285
|
+
say(` ✗ cannot read the review corpus at ${reviewsPath} — ${cause.message}`);
|
|
1286
|
+
return 2;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const results = [];
|
|
1291
|
+
let worst = 0;
|
|
1292
|
+
for (const dir of dirs) {
|
|
1293
|
+
try {
|
|
1294
|
+
const result = passWorkspace(dir, { asOf, reviews });
|
|
1295
|
+
results.push(result);
|
|
1296
|
+
if (!result.declared) {
|
|
1297
|
+
say(` · ${dir}: declares no librarian pass`);
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
say(
|
|
1301
|
+
` ok ${dir}: ${plural(result.counts.records, "record")}, ${result.stale.length} stale, ` +
|
|
1302
|
+
`${result.seals.filter((s) => s.due).length} seal(s) due, ` +
|
|
1303
|
+
`${result.proposals?.filter((p) => p.due).length ?? 0} proposal(s) nagged, ` +
|
|
1304
|
+
`${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
|
+
(result.index.drifted ? ", index drift found" : ""),
|
|
1308
|
+
);
|
|
1309
|
+
} catch (error) {
|
|
1310
|
+
if (!(error instanceof LibrarianError)) throw error;
|
|
1311
|
+
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
|
+
worst = 2;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
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}`);
|
|
1350
|
+
worst = 2;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
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.
|
|
1365
|
+
if (write && worst < 2) {
|
|
1366
|
+
for (const r of results) {
|
|
1367
|
+
if (!r.declared) continue;
|
|
1368
|
+
try {
|
|
1369
|
+
inspectIndex(r.dir, { write: true });
|
|
1370
|
+
if (r.index.drifted) say(` ok regenerated ${r.name}'s index`);
|
|
1371
|
+
} catch (cause) {
|
|
1372
|
+
if (!(cause instanceof IndexError)) throw cause;
|
|
1373
|
+
say(` ✗ ${r.dir}: the index could not be regenerated — ${cause.message}`);
|
|
1374
|
+
worst = 2;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
return worst;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
1383
|
+
process.exit(run(process.argv.slice(2)));
|
|
1384
|
+
}
|