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