@sleepy_panda_srl/portulan 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/cli/README.md +145 -358
- package/cli/advisory.mjs +354 -0
- package/cli/comments.mjs +932 -0
- package/cli/compile.mjs +1719 -1699
- package/cli/context.mjs +883 -0
- package/cli/control-chars.mjs +12 -301
- package/cli/discover.mjs +17 -574
- package/cli/doctor.mjs +269 -1080
- package/cli/drills.mjs +92 -504
- package/cli/feedback.mjs +16 -237
- package/cli/finish.mjs +437 -0
- package/cli/form.mjs +644 -0
- package/cli/fuzz-shell.mjs +20 -305
- package/cli/gate.mjs +7 -199
- package/cli/goldens.mjs +26 -184
- package/cli/index.mjs +305 -655
- package/cli/init.mjs +243 -566
- package/cli/inside.mjs +3 -28
- package/cli/instructions.mjs +829 -0
- package/cli/ledger.mjs +882 -0
- package/cli/librarian.mjs +135 -599
- package/cli/manifest.mjs +2 -39
- package/cli/mutants.mjs +9 -170
- package/cli/new.mjs +12 -243
- package/cli/pack-identity.mjs +5 -74
- package/cli/pack-version.mjs +13 -255
- package/cli/plugin-lint.mjs +42 -658
- package/cli/portulan.mjs +6 -125
- package/cli/recipe-set.mjs +15 -245
- package/cli/release-eval.mjs +28 -348
- package/cli/review-meter.mjs +29 -265
- package/cli/rule-carriers.mjs +12 -175
- package/cli/sessions.mjs +82 -0
- package/cli/skill-goldens.mjs +10 -176
- package/cli/skills-set.mjs +12 -414
- package/cli/stop-gate.mjs +83 -514
- package/cli/symbols.mjs +1132 -0
- package/cli/telemetry.mjs +82 -505
- package/cli/upgrade.mjs +171 -314
- package/cli/vendor.mjs +146 -476
- package/cli/version-carriers.mjs +8 -86
- package/core/README.md +1 -1
- package/core/engine.md +3 -9
- package/core/operating/autonomy.md +1 -1
- package/core/operating/context.md +258 -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/README.md +33 -0
- package/core/personas/librarian.md +2 -2
- package/core/skills/clarify/SKILL.md +1 -1
- package/core/skills/consolidate/SKILL.md +5 -3
- package/core/templates/handoff.md +28 -15
- package/core/templates/memory-entry.md +1 -0
- package/evals/README.md +271 -845
- package/evals/releases/0.2.0.json +146 -0
- package/evals/releases/0.2.0.md +67 -0
- package/evals/releases/0.3.0.json +150 -0
- package/evals/releases/0.3.0.md +68 -0
- package/evals/releases/README.md +3 -3
- package/package.json +5 -2
- package/packs/.claude-plugin/plugin.json +2 -1
- package/packs/README.md +7 -4
- package/packs/rituals/checkpoints/README.md +1 -1
- package/packs/tools/README.md +8 -8
- package/packs/tools/github/verify/actions-pinned.sh +10 -55
- package/packs/tools/local-llm/README.md +136 -0
- package/packs/tools/local-llm/pack.json +32 -0
- package/packs/tools/local-llm/personas/local-worker.md +44 -0
- package/packs/tools/local-llm/runner/local-llm.mjs +1665 -0
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +64 -0
- package/packs/tools/local-llm/verify/README.md +18 -0
- package/spec/README.md +113 -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 +343 -27
- package/spec/workspace.schema.json +145 -4
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `instructions` — the sections a team marks in its own instruction file, moved to on-read units and proved.
|
|
3
|
+
//
|
|
4
|
+
// node cli/instructions.mjs --workspace <dir> what would move, proved; nothing written
|
|
5
|
+
// node cli/instructions.mjs --workspace <dir> --write move it, then compile the index
|
|
6
|
+
// node cli/instructions.mjs --workspace <dir> --join [--write] put every moved section back
|
|
7
|
+
//
|
|
8
|
+
// Claude Code 2.1.281 removes each block-level `<!-- … -->` from an instruction file or rule it loads, so a mark or marker costs no context.
|
|
9
|
+
//
|
|
10
|
+
// Exit 0 printed or written · 2 could not: a refused mark, a split that does not reassemble, a failed read or write. There is no 1.
|
|
11
|
+
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { pathToFileURL } from "node:url";
|
|
16
|
+
|
|
17
|
+
import { BOOT_CARD_UNIT, CompileError, GUIDANCE_RULES_DIR, ON_READ_INDEX, claudeCodeGuidance, guidanceEdits, importPath, importSpans, parseUnit } from "./compile.mjs";
|
|
18
|
+
import { isInside } from "./inside.mjs";
|
|
19
|
+
import { outlineMd } from "./symbols.mjs";
|
|
20
|
+
|
|
21
|
+
/** The project instruction files Claude Code loads whole into every context. */
|
|
22
|
+
export const INSTRUCTION_FILES = ["CLAUDE.md", ".claude/CLAUDE.md"];
|
|
23
|
+
|
|
24
|
+
/** The team's word that the section whose heading sits directly above this line moves to an on-read unit. */
|
|
25
|
+
export const ON_READ_MARK = "<!-- portulan: on-read -->";
|
|
26
|
+
|
|
27
|
+
/** The mark as a line may carry it: indented less than a code block is, and with space after it. */
|
|
28
|
+
const MARK = /^ {0,3}<!-- portulan: on-read -->[ \t]*$/;
|
|
29
|
+
|
|
30
|
+
const MOVED = /^ {0,3}<!-- portulan: on-read (.+?) ([0-9a-f]{8}) -->[ \t]*$/;
|
|
31
|
+
export const movedMark = (source, digest) => `<!-- portulan: on-read ${source} ${digest} -->`;
|
|
32
|
+
|
|
33
|
+
/** Only the section the join puts back, each line without its `\r`: an edited description or a checkout's line ends is no edit. */
|
|
34
|
+
export const unitDigest = (text) => createHash("sha256").update(unitBody(text).map(bare).join("\n"), "utf8").digest("hex").slice(0, 8);
|
|
35
|
+
|
|
36
|
+
const RESERVED = new Set([BOOT_CARD_UNIT, path.basename(ON_READ_INDEX, ".md")]);
|
|
37
|
+
|
|
38
|
+
export class InstructionsError extends Error {}
|
|
39
|
+
|
|
40
|
+
const posix = (p) => p.split(path.sep).join("/");
|
|
41
|
+
const bare = (line) => line.replace(/\r$/, "");
|
|
42
|
+
const blank = (line) => bare(line).trim() === "";
|
|
43
|
+
export const grouped = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
44
|
+
|
|
45
|
+
// ===========================================================================================
|
|
46
|
+
// § A file's lines, the ones Markdown does not read as text, and its sections
|
|
47
|
+
// ===========================================================================================
|
|
48
|
+
|
|
49
|
+
/** The lines a mark cannot sit on, as `outlineMd` reads them: the frontmatter, fenced blocks and multi-line HTML comments. */
|
|
50
|
+
function unread(lines) {
|
|
51
|
+
const out = new Set();
|
|
52
|
+
let i = 0;
|
|
53
|
+
if (lines.length && /^---[ \t]*$/.test(bare(lines[0]))) {
|
|
54
|
+
let close = 1;
|
|
55
|
+
while (close < lines.length && !/^(?:---|\.\.\.)[ \t]*$/.test(bare(lines[close]))) close += 1;
|
|
56
|
+
if (close < lines.length) for (; i <= close; i += 1) out.add(i);
|
|
57
|
+
}
|
|
58
|
+
let fence = null;
|
|
59
|
+
let comment = false;
|
|
60
|
+
for (; i < lines.length; i += 1) {
|
|
61
|
+
const line = bare(lines[i]);
|
|
62
|
+
if (fence !== null || comment) {
|
|
63
|
+
out.add(i);
|
|
64
|
+
if (fence !== null && fence.test(line)) fence = null;
|
|
65
|
+
else if (comment && line.includes("-->")) comment = false;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const open = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
69
|
+
if (open && !(open[1][0] === "`" && open[2].includes("`"))) {
|
|
70
|
+
fence = new RegExp(`^ {0,3}${open[1][0]}{${open[1].length},}[ \\t]*$`);
|
|
71
|
+
out.add(i);
|
|
72
|
+
} else if (/^ {0,3}<!--/.test(line) && !line.slice(line.indexOf("<!--") + 4).includes("-->")) {
|
|
73
|
+
comment = true;
|
|
74
|
+
out.add(i);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function headingsOf(text) {
|
|
81
|
+
const out = [];
|
|
82
|
+
const walk = (entries, parents) => {
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
out.push({ ...entry, parents });
|
|
85
|
+
walk(entry.children ?? [], [...parents, entry.title]);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
walk(outlineMd(text).entries, []);
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function sectionsOf(text) {
|
|
93
|
+
let level = outlineMd(text).entries;
|
|
94
|
+
let title = null;
|
|
95
|
+
while (level.length === 1 && (level[0].children ?? []).length > 0) {
|
|
96
|
+
title = level[0];
|
|
97
|
+
level = level[0].children;
|
|
98
|
+
}
|
|
99
|
+
return { title, sections: level };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ===========================================================================================
|
|
103
|
+
// § Marks and markers
|
|
104
|
+
// ===========================================================================================
|
|
105
|
+
|
|
106
|
+
/** Lines are 0-based; a mark's `heading` is null where no heading sits directly above it. */
|
|
107
|
+
export function marksOf(text) {
|
|
108
|
+
const lines = text.split("\n");
|
|
109
|
+
const skip = unread(lines);
|
|
110
|
+
const headings = headingsOf(text);
|
|
111
|
+
const marks = [];
|
|
112
|
+
const moved = [];
|
|
113
|
+
for (const [at, line] of lines.entries()) {
|
|
114
|
+
if (skip.has(at)) continue;
|
|
115
|
+
const shown = bare(line);
|
|
116
|
+
if (MARK.test(shown)) {
|
|
117
|
+
marks.push({ line: at, heading: headingAbove(lines, headings, at) });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const marker = MOVED.exec(shown);
|
|
121
|
+
// Only a path this split could have written: relative, inside the repository, and a Markdown file.
|
|
122
|
+
if (marker && marker[1].endsWith(".md") && !marker[1].startsWith("/") && !marker[1].includes("\\") && !marker[1].split("/").includes("..")) {
|
|
123
|
+
moved.push({ line: at, source: marker[1], digest: marker[2] });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { marks, moved };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const UNDERLINE = /^ {0,3}(=+|-+)[ \t]*$/;
|
|
130
|
+
|
|
131
|
+
function headingAbove(lines, headings, at) {
|
|
132
|
+
let above = at - 1;
|
|
133
|
+
while (above >= 0 && blank(lines[above])) above -= 1;
|
|
134
|
+
const h = headings.findLast((entry) => entry.start - 1 <= above);
|
|
135
|
+
if (h === undefined) return null;
|
|
136
|
+
const atx = /^ {0,3}#{1,6}(?:[ \t]|$)/.test(bare(lines[h.start - 1]));
|
|
137
|
+
if (h.start - 1 === above) return atx ? h : null;
|
|
138
|
+
// An ATX heading has no underline: a `---` under one is a rule, and a mark under that is under no heading.
|
|
139
|
+
if (atx) return null;
|
|
140
|
+
const underline = lines.findIndex((line, i) => i > h.start - 1 && UNDERLINE.test(bare(line)));
|
|
141
|
+
return underline === above && lines.slice(h.start - 1, above).every((line) => !blank(line)) ? h : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Keyed by 0-based line; an import into a home directory, which this cannot see, counts as loading. */
|
|
145
|
+
function importLines(text, from) {
|
|
146
|
+
const starts = [0];
|
|
147
|
+
for (const line of text.split("\n")) starts.push(starts.at(-1) + line.length + 1);
|
|
148
|
+
const out = new Map();
|
|
149
|
+
for (const { target, index } of importSpans(text)) {
|
|
150
|
+
const bare = importPath(target);
|
|
151
|
+
if (bare === null) continue;
|
|
152
|
+
if (!bare.startsWith("~") && !fs.statSync(path.resolve(path.dirname(from), bare), { throwIfNoEntry: false })?.isFile()) continue;
|
|
153
|
+
const line = starts.findLastIndex((start) => start <= index);
|
|
154
|
+
out.set(line, [...(out.get(line) ?? []), `@${target}`]);
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const importsWithin = (imports, first, last) => [...imports].filter(([line]) => line >= first && line <= last).flatMap(([, found]) => found);
|
|
160
|
+
|
|
161
|
+
/** The imports `compile` refuses in a unit that is not `always`. */
|
|
162
|
+
function strayImports(body, unitFile) {
|
|
163
|
+
const out = [];
|
|
164
|
+
for (const { target } of importSpans(body)) {
|
|
165
|
+
const bare = importPath(target);
|
|
166
|
+
if (bare === null || bare.startsWith("~") || path.isAbsolute(bare)) continue;
|
|
167
|
+
if (fs.statSync(path.resolve(path.dirname(unitFile), bare), { throwIfNoEntry: false })?.isFile()) out.push(`@${target}`);
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ===========================================================================================
|
|
173
|
+
// § The clauses a proof counts
|
|
174
|
+
// ===========================================================================================
|
|
175
|
+
|
|
176
|
+
/** Each sentence, and each part of one between a dash, a colon or a semicolon, of 12 characters or more, block by block. */
|
|
177
|
+
export function clausesOf(text) {
|
|
178
|
+
const blocks = [];
|
|
179
|
+
let current = [];
|
|
180
|
+
const flush = () => {
|
|
181
|
+
if (current.length) blocks.push(current.join(" "));
|
|
182
|
+
current = [];
|
|
183
|
+
};
|
|
184
|
+
const lines = text.split("\n");
|
|
185
|
+
const skip = unread(lines);
|
|
186
|
+
for (const [at, raw] of lines.entries()) {
|
|
187
|
+
const line = bare(raw);
|
|
188
|
+
if (line.trim() === "" || MARK.test(line) || MOVED.test(line) || /^ {0,3}(=+|-+)[ \t]*$/.test(line)) {
|
|
189
|
+
flush();
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (skip.has(at) || /^ {0,3}#{1,6}(?:[ \t]|$)/.test(line) || /^\s*\|/.test(line)) {
|
|
193
|
+
flush();
|
|
194
|
+
blocks.push(line.replace(/^ {0,3}#{1,6}[ \t]*/, ""));
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (/^\s*(?:[-*+]|\d{1,9}[.)])[ \t]/.test(line)) flush();
|
|
198
|
+
current.push(line.replace(/^\s*(?:>\s?)+/, "").replace(/^\s*(?:[-*+]|\d{1,9}[.)])[ \t]+/, "").trim());
|
|
199
|
+
}
|
|
200
|
+
flush();
|
|
201
|
+
const out = [];
|
|
202
|
+
for (const block of blocks) {
|
|
203
|
+
const text = block
|
|
204
|
+
.replace(/\s+/g, " ")
|
|
205
|
+
.trim()
|
|
206
|
+
.replace(/([.?!:;])((?:\*\*|\*|_|\)|")*)\s+(?=[A-Z*_`(["|$])/g, "$1$2\n")
|
|
207
|
+
.replaceAll(" | ", "\n");
|
|
208
|
+
for (const sentence of text.split("\n")) {
|
|
209
|
+
for (const part of sentence.split(/ — |: |; |\)_ /)) {
|
|
210
|
+
const clause = part.replace(/^[ .,;:|]+|[ .,;:|]+$/g, "");
|
|
211
|
+
if (clause.length >= 12) out.push(clause);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function tally(clauses) {
|
|
219
|
+
const counts = new Map();
|
|
220
|
+
for (const c of clauses) counts.set(c, (counts.get(c) ?? 0) + 1);
|
|
221
|
+
return counts;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function landed(before, kept, moved) {
|
|
225
|
+
const o = tally(before);
|
|
226
|
+
const k = tally(kept);
|
|
227
|
+
const u = tally(moved);
|
|
228
|
+
const result = { clauses: before.length, kept: 0, moved: 0, missing: 0, doubled: 0 };
|
|
229
|
+
for (const [clause, n] of o) {
|
|
230
|
+
const inKept = Math.min(n, k.get(clause) ?? 0);
|
|
231
|
+
const inMoved = Math.min(n - inKept, u.get(clause) ?? 0);
|
|
232
|
+
result.kept += inKept;
|
|
233
|
+
result.moved += inMoved;
|
|
234
|
+
result.missing += n - inKept - inMoved;
|
|
235
|
+
result.doubled += Math.max(0, (k.get(clause) ?? 0) + (u.get(clause) ?? 0) - n);
|
|
236
|
+
}
|
|
237
|
+
for (const [clause, n] of [...k, ...u]) if (!o.has(clause)) result.doubled += n;
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ===========================================================================================
|
|
242
|
+
// § The split
|
|
243
|
+
// ===========================================================================================
|
|
244
|
+
|
|
245
|
+
export function unitName(title, taken) {
|
|
246
|
+
let base = title
|
|
247
|
+
.normalize("NFKD")
|
|
248
|
+
.replace(/[̀-ͯ]/g, "")
|
|
249
|
+
.toLowerCase()
|
|
250
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
251
|
+
.replace(/^-+|-+$/g, "");
|
|
252
|
+
if (base.length > 48) base = base.slice(0, 48).replace(/-+$/, "");
|
|
253
|
+
if (base === "") base = "section";
|
|
254
|
+
let name = base;
|
|
255
|
+
for (let n = 2; taken.has(name) || RESERVED.has(name); n += 1) name = `${base}-${n}`;
|
|
256
|
+
taken.add(name);
|
|
257
|
+
return name;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function draftUnit(description, body, eol, ended) {
|
|
261
|
+
const head = ["---", "tier: on-read", `description: ${JSON.stringify(description)}`, "---", ""].map((l) => (eol === "\r\n" ? `${l}\r` : l));
|
|
262
|
+
return `${[...head, ...body].join("\n")}${ended ? "\n" : ""}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function draftedBody(text) {
|
|
266
|
+
const lines = text.split("\n");
|
|
267
|
+
const close = lines.findIndex((line, at) => at > 0 && bare(line) === "---");
|
|
268
|
+
return lines.slice(close + 2, text.endsWith("\n") ? -1 : undefined);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function takenWith(lines, at, limit) {
|
|
272
|
+
return at > 0 && blank(lines[at - 1]) && at + 1 <= limit && blank(lines[at + 1]) ? [at, at + 1] : [at];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Proved, with nothing written: `context` is `contextDir`'s answer, and `read` gives null for an absent file. */
|
|
276
|
+
export function planSplit({ tree, context, taken, read }) {
|
|
277
|
+
const present = [...taken];
|
|
278
|
+
// A name a marker still gives is taken even where its unit is gone, so no new unit answers an old marker.
|
|
279
|
+
const markers = INSTRUCTION_FILES.flatMap((rel) => marksOf(read(rel) ?? "").moved.map((m) => path.posix.basename(m.source, ".md")));
|
|
280
|
+
const names = new Set([...present, ...markers].map((n) => n.toLowerCase()));
|
|
281
|
+
const files = [];
|
|
282
|
+
const units = [];
|
|
283
|
+
const refusals = [];
|
|
284
|
+
let marked = 0;
|
|
285
|
+
for (const rel of INSTRUCTION_FILES) {
|
|
286
|
+
const text = read(rel);
|
|
287
|
+
if (text === null) continue;
|
|
288
|
+
const { marks, moved } = marksOf(text);
|
|
289
|
+
if (marks.length === 0) continue;
|
|
290
|
+
marked += marks.length;
|
|
291
|
+
const refused = refusals.length;
|
|
292
|
+
const from = path.join(tree, ...rel.split("/"));
|
|
293
|
+
const link = linkRefusal(tree, rel);
|
|
294
|
+
if (link !== null) {
|
|
295
|
+
refusals.push(link);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const lines = text.split("\n");
|
|
299
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
300
|
+
const count = text.endsWith("\n") ? lines.length - 1 : lines.length;
|
|
301
|
+
const at = (line) => `line ${line + 1} of ${rel}`;
|
|
302
|
+
|
|
303
|
+
for (const mark of marks.filter((m) => m.heading === null)) {
|
|
304
|
+
refusals.push(`${at(mark.line)} is a mark under no heading: a mark sits directly under the heading of the section it moves, with only blank lines between`);
|
|
305
|
+
}
|
|
306
|
+
const chosen = marks.filter((m) => m.heading !== null);
|
|
307
|
+
for (const inner of chosen) {
|
|
308
|
+
const outer = chosen.find((m) => m !== inner && m.heading.start < inner.heading.start && inner.heading.start <= m.heading.end);
|
|
309
|
+
if (outer) refusals.push(`the section "${inner.heading.title}" (${at(inner.heading.start - 1)}) is marked inside the marked section "${outer.heading.title}": mark one of them`);
|
|
310
|
+
}
|
|
311
|
+
if (context === null) {
|
|
312
|
+
refusals.push(`${rel} has a marked section, and the workspace declares no \`slots.context\` for its unit to go in — declare it, or run \`portulan upgrade\`, which drafts it`);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const imports = importLines(text, from);
|
|
317
|
+
const depth = sectionDepth(text);
|
|
318
|
+
const cuts = [];
|
|
319
|
+
for (const mark of chosen) {
|
|
320
|
+
const h = mark.heading;
|
|
321
|
+
const start = h.start - 1;
|
|
322
|
+
const end = Math.min(h.end, count) - 1;
|
|
323
|
+
let last = end;
|
|
324
|
+
while (last > start && (blank(lines[last]) || last === mark.line)) last -= 1;
|
|
325
|
+
last = Math.max(last, mark.line);
|
|
326
|
+
const dropped = takenWith(lines, mark.line, last);
|
|
327
|
+
const body = lines.slice(start, last + 1).filter((_, i) => !dropped.includes(start + i));
|
|
328
|
+
const title = [...h.parents.slice(depth), h.title].join(" > ");
|
|
329
|
+
const loads = importsWithin(imports, start, last);
|
|
330
|
+
if (loads.length) {
|
|
331
|
+
refusals.push(
|
|
332
|
+
`the section "${h.title}" (${at(start)}) imports ${loads.join(", ")}, which loads into every context from ${rel}; ` +
|
|
333
|
+
"an on-read unit's import loads nothing, so the file would leave every context without a word — move the import out of the section, or unmark it",
|
|
334
|
+
);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const nested = moved.find((m) => m.line >= start && m.line <= last);
|
|
338
|
+
if (nested) {
|
|
339
|
+
refusals.push(`the section "${h.title}" (${at(start)}) holds the marker of a section moved before it, ${nested.source}: join that back first, or mark the sections around it`);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const name = unitName(h.title, names);
|
|
343
|
+
const source = `${context}${name}.md`;
|
|
344
|
+
const unitText = draftUnit(title, body, eol, last < lines.length - 1);
|
|
345
|
+
try {
|
|
346
|
+
const unit = parseUnit(name, unitText, source);
|
|
347
|
+
const stray = strayImports(unit.body, path.join(tree, ...source.split("/")));
|
|
348
|
+
if (stray.length) throw new InstructionsError(`${stray.join(", ")} names a file from the unit's own directory, and \`compile\` refuses an import in an on-read unit`);
|
|
349
|
+
units.push({ name, source, text: unitText, title, from: rel, bytes: unit.bytes, unit });
|
|
350
|
+
cuts.push({ start, last, dropped, source, body, digest: unitDigest(unitText) });
|
|
351
|
+
} catch (error) {
|
|
352
|
+
refusals.push(`the section "${h.title}" (${at(start)}) would make a unit \`compile\` refuses — ${error.message}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (refusals.length > refused) continue;
|
|
356
|
+
|
|
357
|
+
// Cut from the bottom, so each earlier cut's lines are where the outline said.
|
|
358
|
+
const next = [...lines];
|
|
359
|
+
for (const cut of [...cuts].sort((a, b) => b.start - a.start)) {
|
|
360
|
+
next.splice(cut.start, cut.last - cut.start + 1, `${movedMark(cut.source, cut.digest)}${lines[cut.last].endsWith("\r") ? "\r" : ""}`);
|
|
361
|
+
}
|
|
362
|
+
const after = next.join("\n");
|
|
363
|
+
|
|
364
|
+
const dropped = new Set(cuts.flatMap((c) => c.dropped));
|
|
365
|
+
const onlyMarks = [...dropped].every((i) => MARK.test(bare(lines[i])) || blank(lines[i]));
|
|
366
|
+
const target = lines.filter((_, i) => !dropped.has(i)).join("\n");
|
|
367
|
+
const bySource = new Map(units.filter((u) => u.from === rel).map((u) => [u.source, draftedBody(u.text)]));
|
|
368
|
+
const reassembled = next
|
|
369
|
+
.flatMap((line) => {
|
|
370
|
+
const marker = MOVED.exec(bare(line));
|
|
371
|
+
return marker && bySource.has(marker[1]) ? bySource.get(marker[1]) : [line];
|
|
372
|
+
})
|
|
373
|
+
.join("\n");
|
|
374
|
+
const exact = onlyMarks && reassembled === target;
|
|
375
|
+
const clauses = landed(
|
|
376
|
+
clausesOf(target),
|
|
377
|
+
clausesOf(after),
|
|
378
|
+
units.filter((u) => u.from === rel).flatMap((u) => clausesOf(draftedBody(u.text).join("\n"))),
|
|
379
|
+
);
|
|
380
|
+
files.push({ rel, before: text, after, exact, clauses, removed: Buffer.byteLength(text, "utf8") - Buffer.byteLength(after, "utf8") });
|
|
381
|
+
if (!exact) refusals.push(`${rel} does not reassemble from its split byte for byte, so nothing of it was moved — this is a defect in the split, not in the file`);
|
|
382
|
+
else if (clauses.missing || clauses.doubled) {
|
|
383
|
+
refusals.push(`${rel}'s clauses do not land once each (${clauses.missing} missing, ${clauses.doubled} doubled), so nothing of it was moved — this is a defect in the split, not in the file`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return { files, units, refusals, marked, index: units.length === 0 ? 0 : indexGrowth(units, { context, present, read }) };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function sectionDepth(text) {
|
|
390
|
+
let depth = 0;
|
|
391
|
+
let level = outlineMd(text).entries;
|
|
392
|
+
while (level.length === 1 && (level[0].children ?? []).length > 0) {
|
|
393
|
+
depth += 1;
|
|
394
|
+
level = level[0].children;
|
|
395
|
+
}
|
|
396
|
+
return depth;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** A unit in the slot that cannot be read counts no line, since `compile` refuses it. */
|
|
400
|
+
function indexGrowth(units, { context, present, read }) {
|
|
401
|
+
const onRead = present.flatMap((name) => {
|
|
402
|
+
const source = `${context}${name}.md`;
|
|
403
|
+
try {
|
|
404
|
+
const unit = parseUnit(name, read(source) ?? "", source);
|
|
405
|
+
return unit.tier === "on-read" ? [unit] : [];
|
|
406
|
+
} catch (error) {
|
|
407
|
+
if (error instanceof CompileError || error instanceof InstructionsError) return [];
|
|
408
|
+
throw error;
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
const index = claudeCodeGuidance({ units: [...onRead, ...units.map((u) => u.unit)] }).files.find((f) => f.unit === null);
|
|
412
|
+
return Buffer.byteLength(index.text, "utf8") - Buffer.byteLength(read(`${GUIDANCE_RULES_DIR}/${ON_READ_INDEX}`) ?? "", "utf8");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ===========================================================================================
|
|
416
|
+
// § The join
|
|
417
|
+
// ===========================================================================================
|
|
418
|
+
|
|
419
|
+
function unitBody(text) {
|
|
420
|
+
const lines = text.split("\n");
|
|
421
|
+
const close = lines.findIndex((line, at) => at > 0 && bare(line) === "---");
|
|
422
|
+
const rest = lines.slice(close + 1);
|
|
423
|
+
while (rest.length && blank(rest[0])) rest.shift();
|
|
424
|
+
while (rest.length && blank(rest[rest.length - 1])) rest.pop();
|
|
425
|
+
return rest;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function planJoin({ tree, context, read }) {
|
|
429
|
+
const files = [];
|
|
430
|
+
const units = [];
|
|
431
|
+
const refusals = [];
|
|
432
|
+
for (const rel of INSTRUCTION_FILES) {
|
|
433
|
+
const text = read(rel);
|
|
434
|
+
if (text === null) continue;
|
|
435
|
+
const { moved } = marksOf(text);
|
|
436
|
+
if (moved.length === 0) continue;
|
|
437
|
+
const refused = refusals.length;
|
|
438
|
+
const from = path.join(tree, ...rel.split("/"));
|
|
439
|
+
const lines = text.split("\n");
|
|
440
|
+
const back = new Map();
|
|
441
|
+
for (const { line, source, digest } of moved) {
|
|
442
|
+
if (units.some((u) => u.source === source) || back.has(source)) {
|
|
443
|
+
refusals.push(`${source} is named by two markers, line ${line + 1} of ${rel} among them: which one it goes back to is not the join's to guess`);
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
// The join removes the unit it puts back, so a marker written by hand must not reach a file outside the slot's top.
|
|
447
|
+
if (context === null || !source.startsWith(context) || source.slice(context.length).includes("/")) {
|
|
448
|
+
refusals.push(
|
|
449
|
+
`line ${line + 1} of ${rel} names ${source}, ${context === null ? "and the workspace declares no `slots.context`" : `which is not at the top of \`slots.context\` (${context})`}, ` +
|
|
450
|
+
"where the split makes every unit: put the section back by hand, or delete the marker",
|
|
451
|
+
);
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
const unitText = read(source);
|
|
455
|
+
if (unitText === null) {
|
|
456
|
+
refusals.push(`line ${line + 1} of ${rel} names ${source}, which is not there: restore it, or delete the marker to leave the section retired`);
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
let unit;
|
|
460
|
+
try {
|
|
461
|
+
unit = parseUnit(path.posix.basename(source, ".md"), unitText, source);
|
|
462
|
+
} catch (error) {
|
|
463
|
+
refusals.push(`${source} could not be read as a unit — ${error.message}`);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (unit.tier !== "on-read") {
|
|
467
|
+
refusals.push(
|
|
468
|
+
`${source} is \`tier: ${unit.tier}\`${unit.paths ? " with `paths`" : ""} now, and the split made it \`on-read\`: ` +
|
|
469
|
+
`re-tier it to on-read to put it back, or delete the marker at line ${line + 1} of ${rel} to keep it`,
|
|
470
|
+
);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
// Where every line of the unit ends the other way from its marker, a checkout turned them all, and each takes the marker's.
|
|
474
|
+
const ends = lines[line].endsWith("\r") ? "\r" : "";
|
|
475
|
+
const held = unitBody(unitText);
|
|
476
|
+
const turned = line < lines.length - 1 && held.every((l) => l.endsWith("\r") !== (ends === "\r"));
|
|
477
|
+
const body = held.map((l, i, all) => (i === all.length - 1 || turned ? `${bare(l)}${ends}` : l));
|
|
478
|
+
const loads = [...importLines(body.join("\n"), from).values()].flat();
|
|
479
|
+
if (loads.length) {
|
|
480
|
+
refusals.push(`${source} imports ${loads.join(", ")}, which would load into every context from ${rel}: move the import out of the unit first`);
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
const bytes = Buffer.byteLength(body.join("\n"), "utf8") + (line < lines.length - 1 ? 1 : 0);
|
|
484
|
+
back.set(source, { source, body, edited: unitDigest(unitText) !== digest, description: unit.description, bytes });
|
|
485
|
+
}
|
|
486
|
+
if (refusals.length > refused) continue;
|
|
487
|
+
const next = [];
|
|
488
|
+
for (const line of lines) {
|
|
489
|
+
const marker = MOVED.exec(bare(line));
|
|
490
|
+
const unit = marker ? back.get(marker[1]) : undefined;
|
|
491
|
+
if (unit === undefined) {
|
|
492
|
+
next.push(line);
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
unit.at = next.length;
|
|
496
|
+
next.push(...unit.body);
|
|
497
|
+
}
|
|
498
|
+
const after = next.join("\n");
|
|
499
|
+
const headings = headingsOf(after);
|
|
500
|
+
const depth = sectionDepth(after);
|
|
501
|
+
for (const unit of back.values()) {
|
|
502
|
+
const h = headings.find((entry) => entry.start - 1 === unit.at);
|
|
503
|
+
unit.heading = h === undefined ? null : [...h.parents.slice(depth), h.title].join(" > ");
|
|
504
|
+
}
|
|
505
|
+
const clauses = landed(
|
|
506
|
+
[...clausesOf(text), ...[...back.values()].flatMap((u) => clausesOf(u.body.join("\n")))],
|
|
507
|
+
clausesOf(after),
|
|
508
|
+
[],
|
|
509
|
+
);
|
|
510
|
+
if (clauses.missing || clauses.doubled) {
|
|
511
|
+
refusals.push(`${rel}'s join does not carry every clause once (${clauses.missing} missing, ${clauses.doubled} doubled), so nothing was put back`);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
files.push({ rel, before: text, after, clauses, units: [...back.values()] });
|
|
515
|
+
units.push(...back.values());
|
|
516
|
+
}
|
|
517
|
+
return { files, units, refusals };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function joinLine(unit, rel) {
|
|
521
|
+
const where = unit.heading === null ? rel : `${rel} under "${unit.heading}"`;
|
|
522
|
+
const how = unit.edited ? "as it stands now, edited since the move" : "as the move left it";
|
|
523
|
+
const described = unit.description === unit.heading ? "" : `; its description, "${unit.description}", is not kept, since ${rel} names a section by its heading`;
|
|
524
|
+
return `${unit.source}: ${grouped(unit.bytes)} B go back into ${where}, ${how}${described}`;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ===========================================================================================
|
|
528
|
+
// § What `doctor`, the boot and `form` read
|
|
529
|
+
// ===========================================================================================
|
|
530
|
+
|
|
531
|
+
export function movableSections(text, from) {
|
|
532
|
+
const imports = importLines(text, from);
|
|
533
|
+
return sectionsOf(text)
|
|
534
|
+
.sections.filter((s) => importsWithin(imports, s.start - 1, s.end - 1).length === 0)
|
|
535
|
+
.map((s) => ({ title: s.title, bytes: s.bytes }))
|
|
536
|
+
.sort((a, b) => b.bytes - a.bytes);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export function instructionsState(tree, read) {
|
|
540
|
+
const state = { pending: 0, moved: 0, gone: [], files: [], linked: [] };
|
|
541
|
+
const readThrough = instructionReader(tree, read);
|
|
542
|
+
for (const rel of INSTRUCTION_FILES) {
|
|
543
|
+
const text = readThrough(rel);
|
|
544
|
+
if (text === null) continue;
|
|
545
|
+
const { marks, moved } = marksOf(text);
|
|
546
|
+
if (marks.length === 0 && moved.length === 0) continue;
|
|
547
|
+
state.files.push(rel);
|
|
548
|
+
state.pending += marks.length;
|
|
549
|
+
state.moved += moved.length;
|
|
550
|
+
if (marks.length && linkRefusal(tree, rel) !== null) state.linked.push(rel);
|
|
551
|
+
for (const { source } of moved) if (read(source) === null) state.gone.push(source);
|
|
552
|
+
}
|
|
553
|
+
return state;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function linkRefusal(tree, rel) {
|
|
557
|
+
const at = path.join(tree, ...rel.split("/"));
|
|
558
|
+
if (!fs.lstatSync(at, { throwIfNoEntry: false })?.isSymbolicLink()) return null;
|
|
559
|
+
return `${rel} is a link, to ${posix(path.relative(fs.realpathSync(tree), fs.realpathSync(at)))}, and another host may load that file whole: the split moves sections of a file of its own — make ${rel} one to split it`;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** `read`, but through an instruction file's link that stays in the repository, so the split sees the marks it refuses. */
|
|
563
|
+
export function instructionReader(tree, read) {
|
|
564
|
+
return (rel) => {
|
|
565
|
+
const at = path.join(tree, ...rel.split("/"));
|
|
566
|
+
if (!INSTRUCTION_FILES.includes(rel) || !fs.lstatSync(at, { throwIfNoEntry: false })?.isSymbolicLink()) return read(rel);
|
|
567
|
+
let to;
|
|
568
|
+
try {
|
|
569
|
+
to = fs.realpathSync(at);
|
|
570
|
+
} catch (error) {
|
|
571
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR") return null;
|
|
572
|
+
throw new InstructionsError(`${rel} could not be followed — ${error.code ?? error.message}`);
|
|
573
|
+
}
|
|
574
|
+
if (!isInside(fs.realpathSync(tree), to)) return null;
|
|
575
|
+
try {
|
|
576
|
+
return fs.readFileSync(to, "utf8");
|
|
577
|
+
} catch (error) {
|
|
578
|
+
throw new InstructionsError(`${rel} could not be read — ${error.code ?? error.message}`);
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export function splitLines(split) {
|
|
584
|
+
return [
|
|
585
|
+
...split.units.map((u) => `"${u.title}" in ${u.from} → ${u.source}, ${grouped(u.bytes)} B`),
|
|
586
|
+
...split.files.map(
|
|
587
|
+
({ rel, clauses: c }) => `${rel} reassembles from its units byte for byte, and of its ${grouped(c.clauses)} clauses ${grouped(c.kept)} stay and ${grouped(c.moved)} move, none missing and none doubled`,
|
|
588
|
+
),
|
|
589
|
+
];
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** A file that cannot be read is left to the measurement that reads it, which says so. */
|
|
593
|
+
export function splitOffers(tree, { ratio, floor, over = false }) {
|
|
594
|
+
const offers = [];
|
|
595
|
+
for (const rel of INSTRUCTION_FILES) {
|
|
596
|
+
const from = path.join(tree, ...rel.split("/"));
|
|
597
|
+
let text;
|
|
598
|
+
try {
|
|
599
|
+
if (!isInside(fs.realpathSync(tree), fs.realpathSync(from)) || fs.lstatSync(from).isSymbolicLink()) continue;
|
|
600
|
+
text = fs.readFileSync(from, "utf8");
|
|
601
|
+
} catch {
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
const tokens = Math.round(Buffer.byteLength(text, "utf8") / ratio);
|
|
605
|
+
if (tokens <= floor && !over) continue;
|
|
606
|
+
const sections = movableSections(text, from).map((s) => ({ title: s.title, tokens: Math.round(s.bytes / ratio) }));
|
|
607
|
+
if (sections.length) offers.push({ rel, tokens, sections });
|
|
608
|
+
}
|
|
609
|
+
return offers;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Over a declared budget `upgrade` will not run, so there the sentence names the command that moves a section. */
|
|
613
|
+
export function offerText(offers, { over = false, workspace = null } = {}, shown = 3) {
|
|
614
|
+
if (offers.length === 0) return null;
|
|
615
|
+
const two = offers.length > 1;
|
|
616
|
+
const named = offers
|
|
617
|
+
.flatMap((o) => o.sections.map((s) => ({ ...s, rel: o.rel })))
|
|
618
|
+
.sort((a, b) => b.tokens - a.tokens)
|
|
619
|
+
.slice(0, shown)
|
|
620
|
+
.map((s) => `"${s.title}"${two ? ` (${s.rel})` : ""} ~${grouped(s.tokens)}`);
|
|
621
|
+
const listed = named.length === 1 ? named[0] : `${named.slice(0, -1).join(", ")} and ${named.at(-1)}`;
|
|
622
|
+
const files = offers.map((o, i) => (i === 0 ? `${o.rel} is ~${grouped(o.tokens)} tokens in every context` : `${o.rel} ~${grouped(o.tokens)}`)).join(" and ");
|
|
623
|
+
const command = over ? splitCommand(workspace) : "portulan upgrade --write";
|
|
624
|
+
return (
|
|
625
|
+
`${files}, and a line \`${ON_READ_MARK}\` under a heading moves that section to an on-read unit at the next \`${command}\`: ` +
|
|
626
|
+
`${two ? "their" : "its"} largest ${named.length === 1 ? "is" : "are"} ${listed} tokens`
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export function splitCommand(workspace = null) {
|
|
631
|
+
return `node <plugin root>/cli/instructions.mjs --workspace ${workspace === null ? "<dir>" : shellWord(workspace)} --write`;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export const shellWord = (word) => (/^[\w./-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`);
|
|
635
|
+
|
|
636
|
+
export function contextDir(tree, workspaceDir, manifest) {
|
|
637
|
+
const declared = manifest?.slots?.context;
|
|
638
|
+
if (typeof declared !== "string") return null;
|
|
639
|
+
const dir = path.resolve(workspaceDir, declared);
|
|
640
|
+
if (!isInside(workspaceDir, dir) || !isInside(tree, dir) || path.resolve(tree) === dir) {
|
|
641
|
+
throw new InstructionsError(`\`slots.context\` (${declared}) lies outside the workspace or its repository`);
|
|
642
|
+
}
|
|
643
|
+
return `${posix(path.relative(tree, dir))}/`;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export function treeReader(tree) {
|
|
647
|
+
const real = fs.realpathSync(tree);
|
|
648
|
+
return (rel) => {
|
|
649
|
+
const at = path.resolve(tree, ...rel.split("/"));
|
|
650
|
+
if (!isInside(tree, at)) throw new InstructionsError(`${rel} resolves outside the repository`);
|
|
651
|
+
let text;
|
|
652
|
+
try {
|
|
653
|
+
if (!isInside(real, fs.realpathSync(at))) throw new InstructionsError(`${rel} is a link out of the repository, which this does not read`);
|
|
654
|
+
text = fs.readFileSync(at, "utf8");
|
|
655
|
+
} catch (error) {
|
|
656
|
+
if (error instanceof InstructionsError) throw error;
|
|
657
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR") return null;
|
|
658
|
+
throw new InstructionsError(`${rel} could not be read — ${error.code ?? error.message}`);
|
|
659
|
+
}
|
|
660
|
+
return text;
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ===========================================================================================
|
|
665
|
+
// § The command line
|
|
666
|
+
// ===========================================================================================
|
|
667
|
+
|
|
668
|
+
function parseArgs(argv) {
|
|
669
|
+
const options = { workspace: null, write: false, join: false };
|
|
670
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
671
|
+
const flag = argv[i];
|
|
672
|
+
if (flag === "--write" || flag === "--join") {
|
|
673
|
+
const key = flag.slice(2);
|
|
674
|
+
if (options[key]) throw new InstructionsError(`${flag} is given twice`);
|
|
675
|
+
options[key] = true;
|
|
676
|
+
} else if (flag === "--workspace") {
|
|
677
|
+
const value = argv[i + 1];
|
|
678
|
+
i += 1;
|
|
679
|
+
if (value === undefined) throw new InstructionsError("--workspace needs a value");
|
|
680
|
+
options.workspace = value;
|
|
681
|
+
} else {
|
|
682
|
+
throw new InstructionsError(`unknown argument ${JSON.stringify(flag)}`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (options.workspace === null) throw new InstructionsError("--workspace <dir> is required: the directory holding workspace.json");
|
|
686
|
+
return options;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function locate(workspaceDir) {
|
|
690
|
+
let manifest;
|
|
691
|
+
try {
|
|
692
|
+
manifest = JSON.parse(fs.readFileSync(path.join(workspaceDir, "workspace.json"), "utf8"));
|
|
693
|
+
} catch (error) {
|
|
694
|
+
throw new InstructionsError(`${path.join(workspaceDir, "workspace.json")} could not be read as a manifest — ${error.code ?? error.message}`);
|
|
695
|
+
}
|
|
696
|
+
if (typeof manifest?.tree !== "string") throw new InstructionsError("the workspace declares no `tree`, so there is no repository whose instruction file it could split");
|
|
697
|
+
const tree = path.resolve(workspaceDir, manifest.tree);
|
|
698
|
+
if (!fs.statSync(tree, { throwIfNoEntry: false })?.isDirectory()) throw new InstructionsError(`tree (${manifest.tree}) names no directory`);
|
|
699
|
+
const context = contextDir(tree, workspaceDir, manifest);
|
|
700
|
+
let taken = [];
|
|
701
|
+
try {
|
|
702
|
+
if (context !== null) taken = fs.readdirSync(path.join(tree, context)).filter((name) => name.endsWith(".md")).map((name) => name.slice(0, -3));
|
|
703
|
+
} catch (error) {
|
|
704
|
+
// A slot declared and not yet made takes its first unit from this split.
|
|
705
|
+
if (error.code !== "ENOENT") throw new InstructionsError(`\`slots.context\` (${manifest.slots.context}) could not be listed — ${error.code ?? error.message}`);
|
|
706
|
+
}
|
|
707
|
+
return { manifest, tree, context, taken };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// `write` is the suite's, as `applyEdits` takes it: a write failing partway cannot be staged on a real disk.
|
|
711
|
+
export async function run(argv, say = (line) => process.stdout.write(`${line}\n`), { cwd = process.cwd(), write } = {}) {
|
|
712
|
+
let options;
|
|
713
|
+
let where;
|
|
714
|
+
try {
|
|
715
|
+
options = parseArgs(argv);
|
|
716
|
+
where = locate(path.resolve(cwd, options.workspace));
|
|
717
|
+
} catch (error) {
|
|
718
|
+
if (!(error instanceof InstructionsError)) throw error;
|
|
719
|
+
say(`instructions: ${error.message}`);
|
|
720
|
+
return 2;
|
|
721
|
+
}
|
|
722
|
+
const workspaceDir = path.resolve(cwd, options.workspace);
|
|
723
|
+
const read = treeReader(where.tree);
|
|
724
|
+
// Loaded here, not at the top: `./context.mjs` and `./upgrade.mjs` reach this module, so a static edge would close a cycle.
|
|
725
|
+
const context = await import("./context.mjs");
|
|
726
|
+
const { applyEdits, restore } = await import("./upgrade.mjs");
|
|
727
|
+
let declared;
|
|
728
|
+
try {
|
|
729
|
+
declared = context.declaredContext(where.manifest);
|
|
730
|
+
} catch (error) {
|
|
731
|
+
if (!(error instanceof context.ContextError)) throw error;
|
|
732
|
+
say(`instructions: ${error.message}`);
|
|
733
|
+
return 2;
|
|
734
|
+
}
|
|
735
|
+
const ratio = declared.ratio ?? context.ESTIMATED_BYTES_PER_TOKEN;
|
|
736
|
+
const tokens = (bytes) => `~${grouped(context.tokensOf(bytes, ratio))} tokens`;
|
|
737
|
+
const alwaysBytes = () => context.alwaysTier(where.tree).entries.reduce((n, e) => n + e.bytes, 0);
|
|
738
|
+
|
|
739
|
+
let edits;
|
|
740
|
+
try {
|
|
741
|
+
if (options.join) {
|
|
742
|
+
const joined = planJoin({ tree: where.tree, context: where.context, read });
|
|
743
|
+
for (const r of joined.refusals) say(`instructions: refused — ${r}`);
|
|
744
|
+
if (joined.refusals.length) return 2;
|
|
745
|
+
if (joined.files.length === 0) {
|
|
746
|
+
say("instructions: no section of CLAUDE.md or .claude/CLAUDE.md is moved, so there is nothing to join");
|
|
747
|
+
return 0;
|
|
748
|
+
}
|
|
749
|
+
for (const f of joined.files) {
|
|
750
|
+
for (const unit of f.units) say(`instructions: ${joinLine(unit, f.rel)}`);
|
|
751
|
+
const n = f.units.length;
|
|
752
|
+
say(`instructions: ${f.rel} takes back ${n} section${n === 1 ? "" : "s"}, and each of the ${grouped(f.clauses.clauses)} clauses lands once, none missing and none doubled`);
|
|
753
|
+
}
|
|
754
|
+
edits = [
|
|
755
|
+
...joined.files.map((f) => ({ root: "tree", file: f.rel, next: f.after })),
|
|
756
|
+
...joined.units.map((u) => ({ root: "tree", file: u.source, next: null })),
|
|
757
|
+
];
|
|
758
|
+
} else {
|
|
759
|
+
const split = planSplit({ tree: where.tree, context: where.context, taken: where.taken, read });
|
|
760
|
+
for (const r of split.refusals) say(`instructions: refused — ${r}`);
|
|
761
|
+
if (split.refusals.length) return 2;
|
|
762
|
+
if (split.marked === 0) {
|
|
763
|
+
say(`instructions: no section of CLAUDE.md or .claude/CLAUDE.md is marked — a line \`${ON_READ_MARK}\` under a heading marks its section`);
|
|
764
|
+
return 0;
|
|
765
|
+
}
|
|
766
|
+
for (const line of splitLines(split)) say(`instructions: ${line}`);
|
|
767
|
+
const before = alwaysBytes();
|
|
768
|
+
const after = before - split.files.reduce((n, f) => n + f.removed, 0) + split.index;
|
|
769
|
+
say(`instructions: the always tier goes from ${grouped(before)} B (${tokens(before)}) to ${grouped(after)} B (${tokens(after)}) at ${ratio} bytes per token, markers counted, which the host drops`);
|
|
770
|
+
edits = [
|
|
771
|
+
...split.files.map((f) => ({ root: "tree", file: f.rel, next: f.after })),
|
|
772
|
+
...split.units.map((u) => ({ root: "tree", file: u.source, next: u.text })),
|
|
773
|
+
];
|
|
774
|
+
}
|
|
775
|
+
} catch (error) {
|
|
776
|
+
if (!(error instanceof InstructionsError || error instanceof context.ContextError)) throw error;
|
|
777
|
+
say(`instructions: ${error.message}`);
|
|
778
|
+
return 2;
|
|
779
|
+
}
|
|
780
|
+
if (!options.write) {
|
|
781
|
+
say("instructions: nothing was written — run with --write to apply it");
|
|
782
|
+
return 0;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const applied = applyEdits(workspaceDir, edits, { treeDir: where.tree, write });
|
|
786
|
+
const undo = (why) => {
|
|
787
|
+
const back = restore(workspaceDir, applied.snapshots, { treeDir: where.tree });
|
|
788
|
+
say(`instructions: ${why}; ${back.ok ? "rolled back, nothing is changed" : `the rollback was INCOMPLETE — not put back: ${back.failed.join(", ")}`}`);
|
|
789
|
+
return 2;
|
|
790
|
+
};
|
|
791
|
+
if (!applied.ok) return undo(`the write failed — ${applied.reason}`);
|
|
792
|
+
let compiled;
|
|
793
|
+
try {
|
|
794
|
+
compiled = guidanceEdits(workspaceDir).edits.map((edit) => ({ root: "tree", file: edit.file, next: edit.next }));
|
|
795
|
+
} catch (error) {
|
|
796
|
+
return undo(`\`compile\` refused the guidance — ${error.message}`);
|
|
797
|
+
}
|
|
798
|
+
const indexed = applyEdits(workspaceDir, compiled, { treeDir: where.tree, write });
|
|
799
|
+
applied.snapshots.push(...indexed.snapshots);
|
|
800
|
+
if (!indexed.ok) return undo(`the index could not be written — ${indexed.reason}`);
|
|
801
|
+
let now;
|
|
802
|
+
try {
|
|
803
|
+
now = alwaysBytes();
|
|
804
|
+
} catch (error) {
|
|
805
|
+
if (!(error instanceof context.ContextError)) throw error;
|
|
806
|
+
say(`instructions: written, and the index compiled; the always tier could not be measured after it — ${error.message}`);
|
|
807
|
+
return 0;
|
|
808
|
+
}
|
|
809
|
+
const offer =
|
|
810
|
+
options.join || declared.budget !== null
|
|
811
|
+
? ""
|
|
812
|
+
: `. A budget is yours to declare: 0036 offers the larger of ${grouped(context.OFFER_FLOOR_TOKENS)} tokens and that, ` +
|
|
813
|
+
`${grouped(Math.max(context.OFFER_FLOOR_TOKENS, context.tokensOf(now, ratio)))}, as \`context.always.budget.tokens\``;
|
|
814
|
+
say(`instructions: written, and the index compiled: the always tier is ${grouped(now)} B (${tokens(now)})${offer}`);
|
|
815
|
+
return 0;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
819
|
+
// Not awaited at the top level: `run` imports `./context.mjs`, whose import of this module would then wait on itself.
|
|
820
|
+
run(process.argv.slice(2)).then(
|
|
821
|
+
(code) => {
|
|
822
|
+
process.exitCode = code;
|
|
823
|
+
},
|
|
824
|
+
(cause) => {
|
|
825
|
+
process.stderr.write(`instructions: could not run — ${cause?.stack ?? cause}\nThis is a defect in the tool, not a verdict about the repository: nothing was judged.\n`);
|
|
826
|
+
process.exitCode = 2;
|
|
827
|
+
},
|
|
828
|
+
);
|
|
829
|
+
}
|