@sleepy_panda_srl/portulan 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/cli/README.md +142 -358
- package/cli/advisory.mjs +354 -0
- package/cli/comments.mjs +932 -0
- package/cli/compile.mjs +1322 -1607
- package/cli/context.mjs +883 -0
- package/cli/control-chars.mjs +12 -301
- package/cli/discover.mjs +17 -574
- package/cli/doctor.mjs +224 -1074
- package/cli/drills.mjs +68 -502
- package/cli/feedback.mjs +16 -237
- package/cli/finish.mjs +437 -0
- package/cli/form.mjs +644 -0
- package/cli/fuzz-shell.mjs +20 -305
- package/cli/gate.mjs +7 -199
- package/cli/goldens.mjs +26 -184
- package/cli/index.mjs +305 -655
- package/cli/init.mjs +243 -566
- package/cli/inside.mjs +3 -28
- package/cli/instructions.mjs +829 -0
- package/cli/ledger.mjs +778 -0
- package/cli/librarian.mjs +135 -599
- package/cli/manifest.mjs +2 -39
- package/cli/mutants.mjs +9 -170
- package/cli/new.mjs +12 -243
- package/cli/pack-identity.mjs +5 -74
- package/cli/pack-version.mjs +13 -255
- package/cli/plugin-lint.mjs +42 -658
- package/cli/portulan.mjs +5 -124
- package/cli/recipe-set.mjs +15 -245
- package/cli/release-eval.mjs +28 -348
- package/cli/review-meter.mjs +17 -258
- package/cli/rule-carriers.mjs +12 -175
- package/cli/sessions.mjs +82 -0
- package/cli/skill-goldens.mjs +10 -176
- package/cli/skills-set.mjs +12 -414
- package/cli/stop-gate.mjs +83 -514
- package/cli/symbols.mjs +1132 -0
- package/cli/telemetry.mjs +27 -492
- package/cli/upgrade.mjs +171 -314
- package/cli/vendor.mjs +146 -476
- package/cli/version-carriers.mjs +8 -86
- package/core/README.md +1 -1
- package/core/engine.md +3 -9
- package/core/operating/autonomy.md +1 -1
- package/core/operating/context.md +183 -0
- package/core/operating/evolution.md +3 -3
- package/core/operating/loop.md +13 -20
- package/core/operating/memory.md +12 -3
- package/core/operating/sessions.md +100 -0
- package/core/personas/librarian.md +2 -2
- package/core/skills/consolidate/SKILL.md +4 -2
- package/core/templates/handoff.md +28 -15
- package/core/templates/memory-entry.md +1 -0
- package/evals/README.md +255 -845
- package/evals/releases/0.2.0.json +146 -0
- package/evals/releases/0.2.0.md +67 -0
- package/evals/releases/README.md +3 -3
- package/package.json +4 -2
- package/packs/rituals/checkpoints/README.md +1 -1
- package/packs/tools/github/verify/actions-pinned.sh +10 -55
- package/spec/README.md +112 -6
- package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
- package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
- package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
- package/spec/migrations/0004-changelog-fragments.mjs +53 -0
- package/spec/migrations/0005-session-log-retired.mjs +69 -0
- package/spec/migrations/0006-boot-card.mjs +77 -0
- package/spec/migrations/0007-guidance-compiled.mjs +31 -0
- package/spec/migrations/0008-card-reading.mjs +55 -0
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
- package/spec/migrations/0010-comment-rail.mjs +57 -0
- package/spec/migrations/README.md +45 -10
- package/spec/pack.schema.json +1 -1
- package/spec/slots.md +333 -21
- 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] [--
|
|
4
|
+
// node cli/librarian.mjs [--as-of YYYY-MM-DD] [--since YYYY-MM-DD] [--write] [--report <path>] [--reviews <path>] <workspace-dir> [...]
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
15
|
-
//
|
|
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
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// `
|
|
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
|
|
406
|
-
//
|
|
407
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
900
|
-
export function renderRecord(results, { asOf }) {
|
|
506
|
+
export function renderReport(results, { asOf }) {
|
|
901
507
|
const out = [
|
|
902
|
-
"#
|
|
508
|
+
"# The librarian's scheduled pass",
|
|
903
509
|
"",
|
|
904
|
-
`**Date:** ${asOf} ·
|
|
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
|
-
"**
|
|
1131
|
-
"
|
|
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
|
|
1136
|
-
"
|
|
1137
|
-
"
|
|
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] [--
|
|
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,
|
|
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 "--
|
|
1238
|
-
opts.
|
|
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,
|
|
815
|
+
let asOfArg, since, reportPath, reviewsPath, write, dirs;
|
|
1256
816
|
try {
|
|
1257
|
-
({ asOf: asOfArg,
|
|
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
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
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,
|
|
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;
|