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